fix: a credential store holds credentials of one kind, without a kind field in its entries, and the dialog edits several stores in tabs.
Release / release (push) Successful in 19s

This commit is contained in:
slawek committed 2026-10-28 20:57:45 +01:00
1 parent b0b7a4bb7f
commit 7959c90012
9 files changed
+319 -224

No files matched your search

+27 -39
View File
@@ -7,27 +7,26 @@ import { readJsonObject } from './process.ts'
import { decrypt, encrypt, encryptionKey } from './secrets.ts'
/**
* A credential store: the list under key of config.json at configPath, of the kinds the application gives, with the
* names reserved for its own credentials. With missingAsEmpty a missing config.json reads as {}, and the first write
* creates it.
* A credential store: the list under key of config.json at configPath, every entry of the kind the application gives,
* with the names reserved for its own credentials. With missingAsEmpty a missing config.json reads as {}, and the
* first write creates it.
*/
export type CredentialStore = {
configPath: string
key: string
kinds: CredentialKind[]
kind: CredentialKind
reserved?: string[]
missingAsEmpty: boolean
}
/** A credential with its secrets decrypted; a secret that is not kept is absent. */
export type CredentialValues = { name: string; kind: string; values: Record<string, string | number> }
export type CredentialValues = { name: string; values: Record<string, string | number> }
/**
* The kind of a PostgreSQL connection: host, port, database, user, SSL mode and an encrypted password. The SSL modes
* are those libpq and node-postgres both take: disable, require (no check of the certificate) and verify-full.
*/
export const POSTGRESQL_KIND: CredentialKind = {
kind: 'postgresql',
label: 'PostgreSQL connection',
fields: [
{ key: 'host', label: 'Host', help: 'The host name or address of the server.', type: 'text', required: true, secret: false },
@@ -55,7 +54,7 @@ const text = (value: unknown) => typeof value === 'string' && value.trim() !== '
* The problems of the value of the store's key in config.json, by <key>, <key>.<index> or <key>.<index>.<field key>,
* each with what it must be; empty when every value is allowed.
*/
export function credentialProblems(entries: unknown, store: Pick<CredentialStore, 'key' | 'kinds' | 'reserved'>): Record<string, string> {
export function credentialProblems(entries: unknown, store: Pick<CredentialStore, 'key' | 'kind' | 'reserved'>): Record<string, string> {
if (entries === undefined) {
return {}
}
@@ -64,10 +63,11 @@ export function credentialProblems(entries: unknown, store: Pick<CredentialStore
}
const problems: Record<string, string> = {}
const reserved = store.reserved ?? []
const keys = ['name', ...store.kind.fields.map((field) => field.key)]
entries.forEach((entry: unknown, index) => {
const at = `${store.key}.${index}`
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
problems[at] = 'must be an object with name and kind'
problems[at] = `must be an object with ${keys.join(', ')}`
return
}
const record = entry as Record<string, unknown>
@@ -79,17 +79,11 @@ export function credentialProblems(entries: unknown, store: Pick<CredentialStore
} else if (entries.findIndex((other) => (other as Record<string, unknown> | null)?.name === name) !== index) {
problems[`${at}.name`] = 'must differ from the names of the other credentials'
}
const kind = store.kinds.find((item) => item.kind === record.kind)
if (kind === undefined) {
problems[`${at}.kind`] = `must be one of ${store.kinds.map((item) => item.kind).join(', ')}`
return
}
const keys = ['name', 'kind', ...kind.fields.map((field) => field.key)]
const unknown = Object.keys(record).filter((key) => !keys.includes(key))
if (unknown.length > 0) {
problems[at] = `must hold only ${keys.join(', ')}; ${unknown.join(', ')} is not a field of ${kind.kind}`
problems[at] = `must hold only ${keys.join(', ')}; ${unknown.join(', ')} is not one of them`
}
for (const field of kind.fields) {
for (const field of store.kind.fields) {
const value = record[field.key]
const path = `${at}.${field.key}`
if (value === undefined) {
@@ -146,19 +140,15 @@ function read(store: CredentialStore) {
/** The credentials of the entries, without their secrets. */
function toProfiles(data: Record<string, unknown>, store: CredentialStore): CredentialProfile[] {
return entriesOf(data, store).map((entry) => {
const kind = store.kinds.find((item) => item.kind === entry.kind)
const fields = kind?.fields ?? []
const shown = (value: unknown) => (typeof value === 'string' || typeof value === 'number' ? String(value) : '')
return {
original: typeof entry.name === 'string' ? entry.name : null,
name: typeof entry.name === 'string' ? entry.name : '',
kind: typeof entry.kind === 'string' ? entry.kind : '',
values: Object.fromEntries(fields.filter((field) => !field.secret).map((field) => [field.key, shown(entry[field.key])])),
secrets: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, null])),
stored: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, typeof entry[field.key] === 'string'])),
}
})
const { fields } = store.kind
const shown = (value: unknown) => (typeof value === 'string' || typeof value === 'number' ? String(value) : '')
return entriesOf(data, store).map((entry) => ({
original: typeof entry.name === 'string' ? entry.name : null,
name: typeof entry.name === 'string' ? entry.name : '',
values: Object.fromEntries(fields.filter((field) => !field.secret).map((field) => [field.key, shown(entry[field.key])])),
secrets: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, null])),
stored: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, typeof entry[field.key] === 'string'])),
}))
}
/**
@@ -169,13 +159,12 @@ function toProfiles(data: Record<string, unknown>, store: CredentialStore): Cred
function toEntries(profiles: CredentialProfile[], data: Record<string, unknown>, store: CredentialStore, seal: (secret: string) => string) {
const stored = entriesOf(data, store)
return profiles.map((profile) => {
const kind = store.kinds.find((item) => item.kind === profile.kind)
const previous = profile.original === null ? undefined : stored.find((entry) => entry.name === profile.original)
const entry: Record<string, unknown> = { name: profile.name.trim(), kind: profile.kind }
for (const field of kind?.fields ?? []) {
const entry: Record<string, unknown> = { name: profile.name.trim() }
for (const field of store.kind.fields) {
if (field.secret) {
const secret = profile.secrets[field.key] ?? null
const value = secret === null ? (previous?.kind === profile.kind ? previous?.[field.key] : undefined) : secret === '' ? undefined : seal(secret)
const value = secret === null ? previous?.[field.key] : secret === '' ? undefined : seal(secret)
if (value !== undefined) {
entry[field.key] = value
}
@@ -202,11 +191,10 @@ export function credentialValues(store: CredentialStore, name: string): Credenti
}
const entry = entriesOf(data, store).find((item) => item.name === name)
if (entry === undefined) {
throw new HttpError(404, `The credential ${name} does not exist.`)
throw new HttpError(404, `The ${store.kind.label} ${name} does not exist.`)
}
const kind = store.kinds.find((item) => item.kind === entry.kind)!
const values: Record<string, string | number> = {}
for (const field of kind.fields) {
for (const field of store.kind.fields) {
const value = entry[field.key]
if (value === undefined) {
continue
@@ -225,7 +213,7 @@ export function credentialValues(store: CredentialStore, name: string): Credenti
throw new HttpError(400, `The ${field.label.toLowerCase()} of ${name} cannot be decrypted with the server's encryption key; enter it again.`)
}
}
return { name, kind: kind.kind, values }
return { name, values }
}
/**
@@ -239,7 +227,7 @@ export function registerCredentialRoutes(app: FastifyInstance, store: Credential
const flags = { type: 'object', additionalProperties: { type: 'boolean' } }
const profiles = {
type: 'array',
items: strict({ original: { type: ['string', 'null'] }, name: { type: 'string' }, kind: { type: 'string' }, values: record, secrets, stored: flags }),
items: strict({ original: { type: ['string', 'null'] }, name: { type: 'string' }, values: record, secrets, stored: flags }),
}
/** The problems of the credentials by <index>.<field key>, a new secret included when the server has no key. */
@@ -267,7 +255,7 @@ export function registerCredentialRoutes(app: FastifyInstance, store: Credential
value: toProfiles(file.data, store),
path: store.configPath,
encryption: key() !== null,
kinds: store.kinds,
kind: store.kind,
reserved: store.reserved ?? [],
}
}