1 Commits
Author SHA1 Message Date
slawek 97c46ff0d8 feature: the key of a credential store may be a path of keys, so stores of several kinds share one object of config.json.
Release / release (push) Failing after 16m30s
2026-10-28 23:44:36 +01:00
6 changed files with 71 additions and 12 deletions

No files matched your search

+19
View File
@@ -76,3 +76,22 @@ Consequences: Without the key no secret can be saved or used, and a changed key
secrets unusable until they are entered again. SHA-256 does not slow down guessing a weak key, so secrets unusable until they are entered again. SHA-256 does not slow down guessing a weak key, so
the key has to be long and random. Anyone who reaches the routes can replace credentials, as the the key has to be long and random. Anyone who reaches the routes can replace credentials, as the
applications have no authentication. applications have no authentication.
## ADR-4: The key of a credential store is a path, so related stores share one object
Status: Accepted
Context: sql-query-webui keeps connection profiles of PostgreSQL and of SQL Server. As stores of
top-level keys, the keys have to carry the kind in their names, and a key such as `connections`
does not say which kind it holds once there are several.
Decision: The key of a store is a key of `config.json` or a path of keys separated by dots, such
as `connections.pg` and `connections.mssql`. The store reads the list at the path, reports its
problems by `<path>.<index>.<field>`, and a write sets the list at the path, creating the objects
of the path that are missing, or are not objects, and keeping their other keys, so stores of one
object can be written one after the other. The kind stays the store's, not a field of an entry
(ADR-3).
Consequences: A key of `config.json` holding a dot cannot be the key of a store. A single key
reads and writes as before, so applications of one-key stores need no change. The application
checks the object of a path, such as the keys it allows in it, itself.
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## 0.3.0
feature: the key of a credential store may be a path of keys, so stores of several kinds share one object of config.json.
- `CredentialStore.key` is a key of `config.json` or a path of keys separated by dots, such as
`connections.pg`; read, validate, write and `credentialValues` use the list at that path, and a
write creates the objects of the path that are missing and keeps their other keys.
## 0.2.1 ## 0.2.1
fix: a credential store holds credentials of one kind, without a kind field in its entries, and the dialog edits several stores in tabs. fix: a credential store holds credentials of one kind, without a kind field in its entries, and the dialog edits several stores in tabs.
+13 -1
View File
@@ -124,7 +124,19 @@ saved or used; a secret encrypted with another key cannot be decrypted and has t
again. again.
`CredentialStore` describes a store: the path of `config.json`, the key, the kind, the reserved `CredentialStore` describes a store: the path of `config.json`, the key, the kind, the reserved
names and whether a missing `config.json` reads as `{}`. names and whether a missing `config.json` reads as `{}`. The key is a key of `config.json` or a
path of keys separated by dots, such as `connections.pg`, for a list in nested objects; a write
creates the objects of the path that are missing and keeps their other keys, so stores of several
kinds can share one object:
```json
{
"connections": {
"pg": [{ "name": "state", "host": "db.example.com", "port": 5432, "...": "..." }],
"mssql": [{ "name": "warehouse", "host": "sql.example.com", "port": 1433, "...": "..." }]
}
}
```
- `credentialProblems(value, store)`: the problems of the key's value by `<key>.<index>.<field>`, - `credentialProblems(value, store)`: the problems of the key's value by `<key>.<index>.<field>`,
for the application's own check of `config.json`. for the application's own check of `config.json`.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@koszewscy/sk-react-toolkit", "name": "@koszewscy/sk-react-toolkit",
"version": "0.2.1", "version": "0.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@koszewscy/sk-react-toolkit", "name": "@koszewscy/sk-react-toolkit",
"version": "0.2.1", "version": "0.3.0",
"devDependencies": { "devDependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@koszewscy/sk-react-toolkit", "name": "@koszewscy/sk-react-toolkit",
"version": "0.2.1", "version": "0.3.0",
"description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font plugin.", "description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font plugin.",
"private": true, "private": true,
"type": "module", "type": "module",
+28 -8
View File
@@ -8,7 +8,8 @@ import { decrypt, encrypt, encryptionKey } from './secrets.ts'
/** /**
* A credential store: the list under key of config.json at configPath, every entry of the kind the application gives, * 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 * with the names reserved for its own credentials. key is a key of config.json or a path of keys separated by dots,
* such as connections.pg, for a list in nested objects. With missingAsEmpty a missing config.json reads as {}, and the
* first write creates it. * first write creates it.
*/ */
export type CredentialStore = { export type CredentialStore = {
@@ -114,9 +115,30 @@ export function credentialProblems(entries: unknown, store: Pick<CredentialStore
return problems return problems
} }
const isObject = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value)
/** The value of data at key, a key or a path of keys separated by dots; undefined when it is not there. */
function valueAt(data: Record<string, unknown>, key: string): unknown {
return key.split('.').reduce<unknown>((value, part) => (isObject(value) ? value[part] : undefined), data)
}
/**
* data with value at key, a key or a path of keys separated by dots, after the other keys of its object; the objects
* of the path that are missing, or are not objects, are created, and their other keys kept.
*/
function withValue(data: Record<string, unknown>, key: string, value: unknown): Record<string, unknown> {
const [first, ...rest] = key.split('.')
if (rest.length > 0) {
return { ...data, [first]: withValue(isObject(data[first]) ? data[first] : {}, rest.join('.'), value) }
}
const others = { ...data }
delete others[first]
return { ...others, [first]: value }
}
/** The entries of the store's key in config.json; a value that is not a list reads as none. */ /** The entries of the store's key in config.json; a value that is not a list reads as none. */
function entriesOf(data: Record<string, unknown>, store: CredentialStore): Record<string, unknown>[] { function entriesOf(data: Record<string, unknown>, store: CredentialStore): Record<string, unknown>[] {
const entries = data[store.key] const entries = valueAt(data, store.key)
return Array.isArray(entries) ? entries.filter((entry) => entry !== null && typeof entry === 'object') : [] return Array.isArray(entries) ? entries.filter((entry) => entry !== null && typeof entry === 'object') : []
} }
@@ -185,7 +207,7 @@ function toEntries(profiles: CredentialProfile[], data: Record<string, unknown>,
*/ */
export function credentialValues(store: CredentialStore, name: string): CredentialValues { export function credentialValues(store: CredentialStore, name: string): CredentialValues {
const { data } = read(store) const { data } = read(store)
const [problem] = Object.entries(credentialProblems(data[store.key], store)) const [problem] = Object.entries(credentialProblems(valueAt(data, store.key), store))
if (problem !== undefined) { if (problem !== undefined) {
throw new HttpError(500, `${problem[0]} in ${store.configPath} ${problem[1]}.`) throw new HttpError(500, `${problem[0]} in ${store.configPath} ${problem[1]}.`)
} }
@@ -234,8 +256,8 @@ export function registerCredentialRoutes(app: FastifyInstance, store: Credential
function problemsOf(value: CredentialProfile[], data: Record<string, unknown>): Record<string, string> { function problemsOf(value: CredentialProfile[], data: Record<string, unknown>): Record<string, string> {
const problems: Record<string, string> = {} const problems: Record<string, string> = {}
for (const [path, message] of Object.entries(credentialProblems(toEntries(value, data, store, () => 'new'), store))) { for (const [path, message] of Object.entries(credentialProblems(toEntries(value, data, store, () => 'new'), store))) {
const [, index, field] = path.split('.') const [index, field] = path.slice(store.key.length + 1).split('.')
problems[field === undefined ? index ?? store.key : `${index}.${field}`] = `${field ?? 'The credential'} ${message}.` problems[field === undefined ? index || store.key : `${index}.${field}`] = `${field ?? 'The credential'} ${message}.`
} }
if (key() === null) { if (key() === null) {
value.forEach((profile, index) => { value.forEach((profile, index) => {
@@ -279,11 +301,9 @@ export function registerCredentialRoutes(app: FastifyInstance, store: Credential
throw new HttpError(422, 'The credentials are not valid.', problems) throw new HttpError(422, 'The credentials are not valid.', problems)
} }
const sealing = key()! const sealing = key()!
const others = { ...file.data }
delete others[store.key]
const entries = toEntries(request.body.value, file.data, store, (secret) => encrypt(secret, sealing)) const entries = toEntries(request.body.value, file.data, store, (secret) => encrypt(secret, sealing))
try { try {
writeFileSync(store.configPath, `${JSON.stringify({ ...others, [store.key]: entries }, null, 2)}\n`) writeFileSync(store.configPath, `${JSON.stringify(withValue(file.data, store.key, entries), null, 2)}\n`)
} catch (error) { } catch (error) {
throw new HttpError(500, `${store.configPath} cannot be written: ${(error as Error).message}`) throw new HttpError(500, `${store.configPath} cannot be written: ${(error as Error).message}`)
} }