2 Commits
13 changed files with 739 additions and 30 deletions

No files matched your search

+36 -10
View File
@@ -1,25 +1,27 @@
# Architecture Decision Records # Architecture Decision Records
## ADR-1: The toolkit is TypeScript source, linked into the applications ## ADR-1: The source is TypeScript, linked into an application while both change
Status: Accepted Status: Accepted
Context: manage-ui and sql-query-webui share their server helpers and web components. The Context: manage-ui and sql-query-webui share their server helpers and web components. The
applications run their servers with Node's type stripping and build their web code with Vite. applications run their servers with Node's type stripping and build their web code with Vite.
The toolkit is to be published to Gitea later; until then it is developed next to them. They depend on the published package (ADR-2), and a change of the toolkit is tried in them before
it is released.
Decision: The toolkit ships its `.ts` and `.tsx` files without a build, with three entry points: Decision: The source is `.ts` and `.tsx` files with three entry points: `./server`, `./web` and
`./server`, `./web` and `./vite`. The applications depend on the checkout by a `file:` path, which `./vite`. While the toolkit and an application change together, `npm link --no-save` replaces the
npm links; Node runs the server helpers from the checkout's real path, where type stripping installed package with the checkout, leaving `package.json` and the lock file on the published
version; Node runs the server helpers from the checkout's real path, where type stripping
applies, and Vite compiles the web code. The server helpers import only Node's modules at run applies, and Vite compiles the web code. The server helpers import only Node's modules at run
time, and types of Fastify; the Vite helpers import only types of Vite, taking `loadEnv`'s answer time, and types of Fastify; the Vite helpers import only types of Vite, taking `loadEnv`'s answer
from the application. The web code's React, MUI and Emotion come from the application, which from the application. The web code's React, MUI and Emotion come from the application, which
dedupes them with `TOOLKIT_RESOLVE`. The peer dependencies are optional, as an application uses dedupes them with `TOOLKIT_RESOLVE`. The peer dependencies are optional, as an application uses
only some of the entry points, and are development dependencies for the toolkit's own checks. only some of the entry points, and are development dependencies for the toolkit's own checks.
Consequences: The applications need the checkout at `../sk-react-toolkit`, and their container Consequences: The dependency of an application never names a path; `npm ci` replaces a link
builds cannot install it until it is published. Node refuses type stripping in `node_modules`, with the published version. Node refuses type stripping in `node_modules`, so the published
so the published package is compiled (ADR-2). package is compiled (ADR-2).
## ADR-2: The published package is built in dist/ and published to Gitea by a tag ## ADR-2: The published package is built in dist/ and published to Gitea by a tag
@@ -35,8 +37,8 @@ import extensions to `.js`, into `dist/` with declarations, and writes `dist/pac
exports of the compiled files and declarations; the package published is `dist/`, while the exports of the compiled files and declarations; the package published is `dist/`, while the
source `package.json` stays private with exports of the source. The name is source `package.json` stays private with exports of the source. The name is
`@koszewscy/sk-react-toolkit`, scoped to the owner, so an application takes only this scope from `@koszewscy/sk-react-toolkit`, scoped to the owner, so an application takes only this scope from
Gitea; it imports it as `sk-react-toolkit` by an npm alias, so the switch from the checkout to the Gitea; it depends on a published version and imports the package by this name, which is also the
published package changes only the dependency. The package is built and published only by the name `npm link` gives the checkout. The package is built and published only by the
`Release` workflow, which follows linux-cloud-router's: it runs for each tag and on demand, with `Release` workflow, which follows linux-cloud-router's: it runs for each tag and on demand, with
the secrets `CI_URL`, `CI_OWNER` and `CI_TOKEN`, and skips a version already published. the secrets `CI_URL`, `CI_OWNER` and `CI_TOKEN`, and skips a version already published.
`actions/setup-node` gives npm the registry of the package's scope and the token, so the package `actions/setup-node` gives npm the registry of the package's scope and the token, so the package
@@ -46,3 +48,27 @@ carries no registry; `release.mjs` refuses a tag `x.y.z` that is not the version
Consequences: A release raises the version in both CHANGELOG.md and `package.json`, and a Consequences: A release raises the version in both CHANGELOG.md and `package.json`, and a
re-run, or a tag `x.y.z-n`, publishes nothing. The declarations of `renderApp` reference the re-run, or a tag `x.y.z-n`, publishes nothing. The declarations of `renderApp` reference the
declaration of the `virtual:font` module, which `build.mjs` copies into `dist/`. declaration of the `virtual:font` module, which `build.mjs` copies into `dist/`.
## ADR-3: Credentials of any kind are kept in config.json, with secrets encrypted by a key of the environment
Status: Accepted
Context: sql-query-webui kept PostgreSQL connection profiles with encrypted passwords, and
manage-ui needs a PostgreSQL connection and an Azure service principal without taking them from
its environment. Other systems will need other kinds of credentials.
Decision: A credential store is a list in a key of the application's `config.json`; each entry
has a name, a kind and the fields of its kind. The application gives the kinds as data: each field
with its key, label, help, type (text, port, GUID or a choice), whether it is required and whether
it is secret. The toolkit checks the entries, serves read, validate and write routes, and renders
the dialog from the kinds, so a new kind needs no new code. Secret fields are encrypted with
AES-256-GCM, a random 12-byte IV each, with the key the SHA-256 of `ENCRYPTION_KEY`, else of the
content of `ENCRYPTION_KEY_FILE`. A secret is never answered: read gives null and whether it is
kept, and a write sends null to keep it, `''` to drop it, or a new secret. The applications decide
how a credential is used: `credentialValues` answers it with its secrets decrypted. The types the
server and the dialog share are in `shared/credentials.ts`, exported by both entry points.
Consequences: Without the key no secret can be saved or used, and a changed key makes the saved
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
applications have no authentication.
+20
View File
@@ -1,5 +1,25 @@
# Changelog # Changelog
## 0.2.0
feature: the credential store keeps credentials of any kind in config.json with encrypted secrets, with its routes and its dialog.
- `encryptionKey`, `encrypt` and `decrypt`: AES-256-GCM with the SHA-256 of `ENCRYPTION_KEY` or of
the content of `ENCRYPTION_KEY_FILE`.
- `CredentialStore`, `credentialProblems`, `credentialValues` and `registerCredentialRoutes`: a
list of credentials in a key of `config.json`, of the kinds the application gives, with routes
to read, validate and write it; secrets are encrypted and never answered.
- `POSTGRESQL_KIND`, the kind of a PostgreSQL connection.
- `CredentialsButton`, the dialog that edits a store, its fields rendered from the kinds.
## 0.1.1
fix: the declarations of the published package import the other declarations by their .js names.
- The `.d.ts` files of the package name the files they import as `.js`, as the compiled
JavaScript does, instead of the `.ts` and `.tsx` files of the source, which the package does not
hold.
## 0.1.0 ## 0.1.0
feature: sk-react-toolkit holds the server helpers, web components and Vite font plugin shared by manage-ui and sql-query-webui. feature: sk-react-toolkit holds the server helpers, web components and Vite font plugin shared by manage-ui and sql-query-webui.
+78 -15
View File
@@ -7,39 +7,43 @@ source: Node 26 runs the server helpers, and the application's Vite build compil
## Using it ## Using it
The package is `@koszewscy/sk-react-toolkit`, published to the npm registry of Gitea, The package is `@koszewscy/sk-react-toolkit`, published to the npm registry of Gitea,
`https://gitea.koszewscy.waw.pl/api/packages/koszewscy/npm/`. An application imports it as `https://gitea.koszewscy.waw.pl/api/packages/koszewscy/npm/`. An application depends on a
`sk-react-toolkit`, by an alias. Its `.npmrc` names the registry of the scope: published version and imports `@koszewscy/sk-react-toolkit/server`, `/web` and `/vite`. Its
`.npmrc` names the registry of the scope:
```ini ```ini
@koszewscy:registry=https://gitea.koszewscy.waw.pl/api/packages/koszewscy/npm/ @koszewscy:registry=https://gitea.koszewscy.waw.pl/api/packages/koszewscy/npm/
``` ```
```json ```json
{ "dependencies": { "sk-react-toolkit": "npm:@koszewscy/sk-react-toolkit@^0.1.0" } } { "dependencies": { "@koszewscy/sk-react-toolkit": "^0.2.0" } }
``` ```
While the toolkit and an application change together, the application depends on the checkout The web application adds the toolkit's resolve options and font plugin to its `vite.config.ts`, so
next to its own repository instead, the TypeScript source: React, MUI and Emotion come from its own `node_modules`:
```json
{ "dependencies": { "sk-react-toolkit": "file:../sk-react-toolkit" } }
```
npm links the checkout into `node_modules`, and `npm ci` keeps the link. The web application adds
the toolkit's resolve options and font plugin to its `vite.config.ts`, so React, MUI and Emotion
come from its own `node_modules`, and allows the dev server to read the checkout:
```ts ```ts
import { font, fontChoice, TOOLKIT_RESOLVE } from 'sk-react-toolkit/vite' import { font, fontChoice, TOOLKIT_RESOLVE } from '@koszewscy/sk-react-toolkit/vite'
import { defineConfig, loadEnv } from 'vite' import { defineConfig, loadEnv } from 'vite'
export default defineConfig(({ mode }) => ({ export default defineConfig(({ mode }) => ({
plugins: [react(), font(fontChoice(loadEnv(mode, import.meta.dirname)))], plugins: [react(), font(fontChoice(loadEnv(mode, import.meta.dirname)))],
resolve: TOOLKIT_RESOLVE, resolve: TOOLKIT_RESOLVE,
server: { fs: { allow: ['..', '../../sk-react-toolkit'] } },
})) }))
``` ```
To change the toolkit and an application together, `npm link --no-save` replaces the installed
package with the checkout, the TypeScript source, in each package of the application:
```sh
npm link --no-save ../sk-react-toolkit
npm --prefix web link --no-save ../../sk-react-toolkit
```
`package.json` and the lock file keep the published version; `npm ci` or `npm install` replaces
the link with it again. A change of the toolkit reaches the application through a release of the
toolkit and an update of the dependency.
The peer dependencies are the application's: `fastify` and `@fastify/static` for the server The peer dependencies are the application's: `fastify` and `@fastify/static` for the server
helpers; `react`, `react-dom`, `@mui/material`, `@mui/icons-material` and Emotion for the web helpers; `react`, `react-dom`, `@mui/material`, `@mui/icons-material` and Emotion for the web
code; `vite` for the Vite helpers. code; `vite` for the Vite helpers.
@@ -57,6 +61,8 @@ code; `vite` for the Vite helpers.
| `gitVersion(dir, variable)` | `git describe` of the checkout, else the environment variable, else `dev` | | `gitVersion(dir, variable)` | `git describe` of the checkout, else the environment variable, else `dev` |
| `exitOnSignals(close)` | closes the server on Ctrl-C and SIGTERM, ending the process after 5 seconds at most | | `exitOnSignals(close)` | closes the server on Ctrl-C and SIGTERM, ending the process after 5 seconds at most |
| `readJsonObject(path, options)` | a JSON object file and its version, the SHA-256 of its bytes; optionally `{}` for a missing file | | `readJsonObject(path, options)` | a JSON object file and its version, the SHA-256 of its bytes; optionally `{}` for a missing file |
| `encryptionKey()`, `encrypt(text, key)`, `decrypt(encrypted, key)` | the key of the secrets and AES-256-GCM encryption; see [Credential store](#credential-store) |
| `CredentialStore`, `credentialProblems`, `credentialValues`, `registerCredentialRoutes`, `POSTGRESQL_KIND` | the credential store; see [Credential store](#credential-store) |
## sk-react-toolkit/web ## sk-react-toolkit/web
@@ -71,6 +77,7 @@ code; `vite` for the Vite helpers.
| `TextInput` | a text field with clear and show/hide buttons | | `TextInput` | a text field with clear and show/hide buttons |
| `RowDialog`, `useValidation` | a dialog that edits a value validated by the server as it changes, and the validation hook | | `RowDialog`, `useValidation` | a dialog that edits a value validated by the server as it changes, and the validation hook |
| `createBrowserSettings(keys, event)` | switches kept in localStorage and followed across tabs | | `createBrowserSettings(keys, event)` | switches kept in localStorage and followed across tabs |
| `CredentialsButton` | the dialog of a credential store; see [Credential store](#credential-store) |
## sk-react-toolkit/vite ## sk-react-toolkit/vite
@@ -79,6 +86,62 @@ code; `vite` for the Vite helpers.
| `font(choice)`, `fontChoice(env)`, `FONTS` | the font plugin of `VITE_FONT`: `cdn` (Google Fonts links, the default), `bundled` (`@fontsource/roboto`, a dependency of the application) or `none` | | `font(choice)`, `fontChoice(env)`, `FONTS` | the font plugin of `VITE_FONT`: `cdn` (Google Fonts links, the default), `bundled` (`@fontsource/roboto`, a dependency of the application) or `none` |
| `TOOLKIT_RESOLVE` | the resolve options that keep one copy of React, MUI and Emotion | | `TOOLKIT_RESOLVE` | the resolve options that keep one copy of React, MUI and Emotion |
## Credential store
A credential store is a list of credentials in a key of the application's `config.json`, of the
kinds the application gives, such as a database connection or a service principal. Each entry has
a `name`, unique and not one of the store's reserved names, a `kind`, and the fields of its kind:
```json
{
"credentials": [
{
"name": "state",
"kind": "postgresql",
"host": "db.example.com",
"port": 5432,
"database": "platform",
"user": "tofu",
"ssl_mode": "verify-full",
"password": "<encrypted>"
}
]
}
```
A kind (`CredentialKind`) names its fields (`CredentialField`), each by its key in `config.json`,
with a label, a help text, a type, `text`, `port`, `guid` or `choice` with its `choices`, whether
it is required, and whether it is secret. `POSTGRESQL_KIND` is the kind of a PostgreSQL
connection: `host`, `port`, `database`, `user`, `ssl_mode` (`disable`, `require` or
`verify-full`) and a secret `password`.
A secret field is encrypted with AES-256-GCM, stored as base64 of a random IV, the ciphertext and
the authentication tag. The key is the SHA-256 of `ENCRYPTION_KEY`, else of the content of the
file `ENCRYPTION_KEY_FILE` names, without its final newline. Without either, no secret can be
saved or used; a secret encrypted with another key cannot be decrypted and has to be entered
again.
`CredentialStore` describes a store: the path of `config.json`, the key, the kinds, the reserved
names and whether a missing `config.json` reads as `{}`.
- `credentialProblems(value, store)`: the problems of the key's value by `<key>.<index>.<field>`,
for the application's own check of `config.json`.
- `credentialValues(store, name)`: a credential with its secrets decrypted; HttpError 404 for a
name that does not exist, 400 without a key or for a secret the key does not decrypt.
- `registerCredentialRoutes(app, store, prefix, log)`: `GET <prefix>/read`, `POST
<prefix>/validate` and `POST <prefix>/write`. Read answers the credentials, `CredentialProfile`,
with `values` (the fields that are not secret, as text), `secrets` (null for each secret, never
answered) and `stored` (which secrets are kept), the kinds, the reserved names, the path of
`config.json`, its version and whether the server has a key. A write takes the version read and
the credentials; a secret is null to keep the secret of the credential named `original`, `''`
for none, or the new secret, which is encrypted. A file that changed since the version read is
409, credentials with problems 422; the other keys of `config.json` are kept.
`CredentialsButton` (`prefix`, `title`, `icon`, `note`, `onSaved`) opens the store's credentials in
a dialog: a list at the left with Add for each kind and Remove, and the fields of the credential
selected, as its kind names them. A secret shows only whether it is kept: Set or Change replaces
it and Remove drops it.
## Development ## Development
`npm install` installs the peer dependencies as development dependencies; `npm run typecheck` `npm install` installs the peer dependencies as development dependencies; `npm run typecheck`
+9 -1
View File
@@ -4,7 +4,7 @@
// the font plugin's virtual module, README.md, CHANGELOG.md and a package.json whose exports name the compiled files. // the font plugin's virtual module, README.md, CHANGELOG.md and a package.json whose exports name the compiled files.
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { copyFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { copyFileSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
const ROOT = import.meta.dirname; const ROOT = import.meta.dirname;
@@ -21,6 +21,14 @@ const tsc = spawnSync(join(ROOT, 'node_modules', '.bin', 'tsc'), ['-p', join(ROO
if (tsc.error) fail(`tsc: ${tsc.error.message}; run npm ci first`); if (tsc.error) fail(`tsc: ${tsc.error.message}; run npm ci first`);
if (tsc.status !== 0) process.exit(tsc.status ?? 1); if (tsc.status !== 0) process.exit(tsc.status ?? 1);
// The declarations import the other declarations by their .js names, as the compiled JavaScript does.
for (const entry of [...ENTRIES, 'shared']) {
for (const file of readdirSync(join(DIST, entry)).filter((name) => name.endsWith('.d.ts'))) {
const path = join(DIST, entry, file);
writeFileSync(path, readFileSync(path, 'utf8').replace(/(from\s+|import\s+)(['"])(\.{1,2}\/[^'"]+)\.tsx?\2/g, '$1$2$3.js$2'));
}
}
// The compiled renderApp keeps its import of virtual:font, so its declarations reference the module's declaration. // The compiled renderApp keeps its import of virtual:font, so its declarations reference the module's declaration.
copyFileSync(join(ROOT, 'web', 'virtual-font.d.ts'), join(DIST, 'web', 'virtual-font.d.ts')); copyFileSync(join(ROOT, 'web', 'virtual-font.d.ts'), join(DIST, 'web', 'virtual-font.d.ts'));
const renderApp = join(DIST, 'web', 'renderApp.d.ts'); const renderApp = join(DIST, 'web', 'renderApp.d.ts');
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@koszewscy/sk-react-toolkit", "name": "@koszewscy/sk-react-toolkit",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@koszewscy/sk-react-toolkit", "name": "@koszewscy/sk-react-toolkit",
"version": "0.1.0", "version": "0.2.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.1.0", "version": "0.2.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",
+305
View File
@@ -0,0 +1,305 @@
import { writeFileSync } from 'node:fs'
import type { FastifyInstance } from 'fastify'
import type { CredentialKind, CredentialProfile, CredentialsDocument } from '../shared/credentials.ts'
import { HttpError, strict } from './http.ts'
import type { Log } from './log.ts'
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.
*/
export type CredentialStore = {
configPath: string
key: string
kinds: 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> }
/**
* 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 },
{ key: 'port', label: 'Port', help: 'Usually 5432.', type: 'port', required: true, secret: false },
{ key: 'database', label: 'Database', help: 'The database to connect to.', type: 'text', required: true, secret: false },
{ key: 'user', label: 'User', help: 'The role to log in as.', type: 'text', required: true, secret: false },
{
key: 'ssl_mode',
label: 'SSL mode',
help: 'disable: no SSL; require: SSL without checking the server\'s certificate; verify-full: SSL with the certificate checked against the trusted CAs and the host name.',
type: 'choice',
choices: ['disable', 'require', 'verify-full'],
required: true,
secret: false,
},
{ key: 'password', label: 'Password', help: 'The password of the role.', type: 'text', required: false, secret: true },
],
}
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
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> {
if (entries === undefined) {
return {}
}
if (!Array.isArray(entries)) {
return { [store.key]: 'must be a list of credentials' }
}
const problems: Record<string, string> = {}
const reserved = store.reserved ?? []
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'
return
}
const record = entry as Record<string, unknown>
const { name } = record
if (!text(name)) {
problems[`${at}.name`] = 'must be a name'
} else if (reserved.includes(name as string)) {
problems[`${at}.name`] = `must not be ${reserved.join(' or ')}`
} 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}`
}
for (const field of kind.fields) {
const value = record[field.key]
const path = `${at}.${field.key}`
if (value === undefined) {
if (field.required && !field.secret) {
problems[path] = `must be set: ${field.help}`
}
} else if (field.secret) {
if (!text(value)) {
problems[path] = 'must be an encrypted secret'
}
} else if (field.type === 'port') {
if (!(Number.isInteger(value) && (value as number) >= 1 && (value as number) <= 65535)) {
problems[path] = 'must be a port number from 1 to 65535'
}
} else if (field.type === 'guid') {
if (typeof value !== 'string' || !GUID.test(value)) {
problems[path] = 'must be a GUID'
}
} else if (field.type === 'choice') {
if (!field.choices?.includes(value as string)) {
problems[path] = `must be one of ${field.choices?.join(', ')}`
}
} else if (!text(value)) {
problems[path] = `must be set: ${field.help}`
}
}
})
return problems
}
/** 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>[] {
const entries = data[store.key]
return Array.isArray(entries) ? entries.filter((entry) => entry !== null && typeof entry === 'object') : []
}
/** The key of the secrets, a file of ENCRYPTION_KEY_FILE that cannot be read being 500. */
function key(): Buffer | null {
try {
return encryptionKey()
} catch (error) {
throw new HttpError(500, (error as Error).message)
}
}
/** config.json of the store with its version, a file that cannot be read being 500. */
function read(store: CredentialStore) {
try {
return readJsonObject(store.configPath, { missingAsEmpty: store.missingAsEmpty })
} catch (error) {
throw new HttpError(500, `${(error as Error).message}.`)
}
}
/** 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'])),
}
})
}
/**
* The entries of the credentials, with each secret: kept from the entry named original for null, none for '', else
* the new secret through seal, which encrypts it; for validation seal only marks it. A port of digits is a number,
* other text is kept as typed; an empty field that is not required is left out.
*/
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 ?? []) {
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)
if (value !== undefined) {
entry[field.key] = value
}
continue
}
const value = (profile.values[field.key] ?? '').trim()
if (value !== '' || field.required) {
entry[field.key] = field.type === 'port' && /^\d+$/.test(value) ? Number(value) : value
}
}
return entry
})
}
/**
* The credential named name of the store with its secrets decrypted. A config.json whose credentials are not valid
* is 500, a name that does not exist 404, and a secret without a key, or that the key does not decrypt, 400.
*/
export function credentialValues(store: CredentialStore, name: string): CredentialValues {
const { data } = read(store)
const [problem] = Object.entries(credentialProblems(data[store.key], store))
if (problem !== undefined) {
throw new HttpError(500, `${problem[0]} in ${store.configPath} ${problem[1]}.`)
}
const entry = entriesOf(data, store).find((item) => item.name === name)
if (entry === undefined) {
throw new HttpError(404, `The credential ${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) {
const value = entry[field.key]
if (value === undefined) {
continue
}
if (!field.secret) {
values[field.key] = value as string | number
continue
}
const secretKey = key()
if (secretKey === null) {
throw new HttpError(400, `The ${field.label.toLowerCase()} of ${name} needs ENCRYPTION_KEY or ENCRYPTION_KEY_FILE in the server's environment.`)
}
try {
values[field.key] = decrypt(value as string, secretKey)
} catch {
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 }
}
/**
* Registers the routes of the store under prefix: read, validate and write edit the store's key of config.json,
* keeping its other keys, with each new secret encrypted by the key of ENCRYPTION_KEY or ENCRYPTION_KEY_FILE. A
* secret is never answered.
*/
export function registerCredentialRoutes(app: FastifyInstance, store: CredentialStore, prefix: string, log: Log) {
const record = { type: 'object', additionalProperties: { type: 'string' } }
const secrets = { type: 'object', additionalProperties: { type: ['string', 'null'] } }
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 }),
}
/** The problems of the credentials by <index>.<field key>, a new secret included when the server has no key. */
function problemsOf(value: CredentialProfile[], data: Record<string, unknown>): Record<string, string> {
const problems: Record<string, string> = {}
for (const [path, message] of Object.entries(credentialProblems(toEntries(value, data, store, () => 'new'), store))) {
const [, index, field] = path.split('.')
problems[field === undefined ? index ?? store.key : `${index}.${field}`] = `${field ?? 'The credential'} ${message}.`
}
if (key() === null) {
value.forEach((profile, index) => {
for (const [field, secret] of Object.entries(profile.secrets)) {
if (secret !== null && secret !== '') {
problems[`${index}.${field}`] ??= 'A secret needs ENCRYPTION_KEY or ENCRYPTION_KEY_FILE in the server\'s environment.'
}
}
})
}
return problems
}
function document(file: { data: Record<string, unknown>; version: string }): CredentialsDocument {
return {
version: file.version,
value: toProfiles(file.data, store),
path: store.configPath,
encryption: key() !== null,
kinds: store.kinds,
reserved: store.reserved ?? [],
}
}
app.get(`${prefix}/read`, async (): Promise<CredentialsDocument> => document(read(store)))
app.post<{ Body: { value: CredentialProfile[] } }>(`${prefix}/validate`, {
schema: { body: strict({ value: profiles }) },
}, async (request) => ({ problems: problemsOf(request.body.value, read(store).data), message: '' }))
// The file is read, compared and written synchronously.
app.post<{ Body: { version: string; value: CredentialProfile[] } }>(`${prefix}/write`, {
schema: { body: strict({ version: { type: 'string' }, value: profiles }) },
}, async (request): Promise<CredentialsDocument> => {
const file = read(store)
if (file.version !== request.body.version) {
throw new HttpError(409, `${store.configPath} changed since it was read. Close the dialog and open it again.`)
}
const problems = problemsOf(request.body.value, file.data)
if (Object.keys(problems).length > 0) {
throw new HttpError(422, 'The credentials are not valid.', problems)
}
const sealing = key()!
const others = { ...file.data }
delete others[store.key]
const entries = toEntries(request.body.value, file.data, store, (secret) => encrypt(secret, sealing))
try {
writeFileSync(store.configPath, `${JSON.stringify({ ...others, [store.key]: entries }, null, 2)}\n`)
} catch (error) {
throw new HttpError(500, `${store.configPath} cannot be written: ${(error as Error).message}`)
}
log.event(`file write ${store.configPath} (${store.key})`)
return document(read(store))
})
}
+3
View File
@@ -1,3 +1,6 @@
export type { CredentialField, CredentialKind, CredentialProfile, CredentialsDocument } from '../shared/credentials.ts'
export { POSTGRESQL_KIND, credentialProblems, credentialValues, registerCredentialRoutes, type CredentialStore, type CredentialValues } from './credentials.ts'
export { HttpError, errorHandler, notFoundHandler, strict } from './http.ts' export { HttpError, errorHandler, notFoundHandler, strict } from './http.ts'
export { QUIET, createLog, fail, timestampsOn, type Log } from './log.ts' export { QUIET, createLog, fail, timestampsOn, type Log } from './log.ts'
export { exitOnSignals, gitVersion, readJsonObject } from './process.ts' export { exitOnSignals, gitVersion, readJsonObject } from './process.ts'
export { decrypt, encrypt, encryptionKey } from './secrets.ts'
+38
View File
@@ -0,0 +1,38 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
import { readFileSync } from 'node:fs'
const ALGORITHM = 'aes-256-gcm'
const IV_BYTES = 12
const TAG_BYTES = 16
/**
* The key of the secrets: the SHA-256 of ENCRYPTION_KEY, else of the content of the file ENCRYPTION_KEY_FILE names
* without its final newline; null when neither is set. A file that cannot be read throws with the reason.
*/
export function encryptionKey(): Buffer | null {
let secret = process.env.ENCRYPTION_KEY
if (!secret && process.env.ENCRYPTION_KEY_FILE) {
try {
secret = readFileSync(process.env.ENCRYPTION_KEY_FILE, 'utf8').replace(/\r?\n$/, '')
} catch (error) {
throw new Error(`ENCRYPTION_KEY_FILE ${process.env.ENCRYPTION_KEY_FILE} cannot be read: ${(error as Error).message}`)
}
}
return secret ? createHash('sha256').update(secret, 'utf8').digest() : null
}
/** The text encrypted with AES-256-GCM: base64 of a random IV, the ciphertext and the authentication tag. */
export function encrypt(text: string, key: Buffer): string {
const iv = randomBytes(IV_BYTES)
const cipher = createCipheriv(ALGORITHM, key, iv)
const data = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
return Buffer.concat([iv, data, cipher.getAuthTag()]).toString('base64')
}
/** The text of encrypt's answer; another key, or a changed answer, throws. */
export function decrypt(encrypted: string, key: Buffer): string {
const bytes = Buffer.from(encrypted, 'base64')
const decipher = createDecipheriv(ALGORITHM, key, bytes.subarray(0, IV_BYTES))
decipher.setAuthTag(bytes.subarray(bytes.length - TAG_BYTES))
return Buffer.concat([decipher.update(bytes.subarray(IV_BYTES, bytes.length - TAG_BYTES)), decipher.final()]).toString('utf8')
}
+49
View File
@@ -0,0 +1,49 @@
// The types of a credential store that the server helpers and the web dialog share.
/**
* A field of a kind of credential, by its key in config.json: text, a port number, a GUID or one of choices. A
* required field must be set; a secret field is kept encrypted, never answered, and may be left out.
*/
export type CredentialField = {
key: string
label: string
help: string
type: 'text' | 'port' | 'guid' | 'choice'
choices?: string[]
required: boolean
secret: boolean
}
/** A kind of credential, such as a database connection or a service principal, with its fields. */
export type CredentialKind = {
kind: string
label: string
fields: CredentialField[]
}
/**
* A credential as the dialog edits it. original is the name it was read with, null for a new one. values holds the
* fields that are not secret, as text. secrets holds the secret fields: read answers null for each; a write sends
* null to keep the secret of original, '' for none, or the new secret. stored tells which secrets are kept.
*/
export type CredentialProfile = {
original: string | null
name: string
kind: string
values: Record<string, string>
secrets: Record<string, string | null>
stored: Record<string, boolean>
}
/** The read answer of a credential store and of a write: its credentials, kinds and the names it does not allow. */
export type CredentialsDocument = {
/** Identifies the content of config.json; a write sends it back and is refused with 409 when the file changed since. */
version: string
value: CredentialProfile[]
/** The path of config.json. */
path: string
/** Whether ENCRYPTION_KEY or ENCRYPTION_KEY_FILE gives the server a key, which a secret needs. */
encryption: boolean
kinds: CredentialKind[]
reserved: string[]
}
+1 -1
View File
@@ -17,6 +17,6 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["server", "web", "vite"], "include": ["server", "web", "vite", "shared"],
"exclude": ["dist"] "exclude": ["dist"]
} }
+195
View File
@@ -0,0 +1,195 @@
import Add from '@mui/icons-material/Add'
import Delete from '@mui/icons-material/Delete'
import Key from '@mui/icons-material/Key'
import { Alert, Box, Button, IconButton, List, ListItemButton, ListItemText, MenuItem, Snackbar, Stack, TextField, Tooltip, Typography } from '@mui/material'
import { useState, type ReactNode } from 'react'
import type { CredentialKind, CredentialProfile, CredentialsDocument } from '../shared/credentials.ts'
import { request } from './api.ts'
import RowDialog from './RowDialog.tsx'
import TextInput from './TextInput.tsx'
/** A new credential of the kind, named apart from those of profiles, its choices at their first value. */
function newProfile(kind: CredentialKind, profiles: CredentialProfile[]): CredentialProfile {
let number = profiles.length + 1
while (profiles.some((profile) => profile.name === `${kind.kind}${number}`)) {
number++
}
const plain = kind.fields.filter((field) => !field.secret)
const secret = kind.fields.filter((field) => field.secret)
return {
original: null,
name: `${kind.kind}${number}`,
kind: kind.kind,
values: Object.fromEntries(plain.map((field) => [field.key, field.type === 'choice' ? field.choices?.[0] ?? '' : ''])),
secrets: Object.fromEntries(secret.map((field) => [field.key, ''])),
stored: Object.fromEntries(secret.map((field) => [field.key, false])),
}
}
/**
* A button opening the credentials of a store, served under prefix, in a dialog: the credentials in a list at the
* left, Add for each kind and Remove, and the fields of the one selected at the right, as its kind names them; a
* credential with a problem is red in the list. A secret is kept as it is until it is typed anew or removed, and is
* never shown. The server validates the credentials as they change and writes them on save, encrypting new secrets;
* onSaved follows a save. note is shown above the list.
*/
export default function CredentialsButton(props: { prefix: string; title: string; icon?: ReactNode; note?: ReactNode; onSaved?: () => void }) {
const { prefix, title } = props
const [document, setDocument] = useState<CredentialsDocument | null>(null)
const [selected, setSelected] = useState(0)
const [error, setError] = useState('')
function open() {
setSelected(0)
request<CredentialsDocument>('GET', `${prefix}/read`).then(setDocument, (caught: Error) => setError(caught.message))
}
async function save(value: CredentialProfile[]) {
await request<CredentialsDocument>('POST', `${prefix}/write`, { version: document?.version, value })
setDocument(null)
props.onSaved?.()
}
return (
<>
<Tooltip title={title}>
<IconButton aria-label={title.toLowerCase()} onClick={open}>
{props.icon ?? <Key />}
</IconButton>
</Tooltip>
{document && (
<RowDialog<CredentialProfile[]>
title={title}
initial={document.value}
validatePath={`${prefix}/validate`}
saveLabel="Save"
onSave={save}
onCancel={() => setDocument(null)}
fields={(profiles, set, problems) => {
const index = Math.min(selected, profiles.length - 1)
const profile = profiles[index]
const kind = document.kinds.find((item) => item.kind === profile?.kind)
const problem = (field: string) => problems[`${index}.${field}`]
const change = (next: Partial<CredentialProfile>) => set(profiles.map((item, at) => (at === index ? { ...item, ...next } : item)))
const add = (item: CredentialKind) => {
set([...profiles, newProfile(item, profiles)])
setSelected(profiles.length)
}
return (
<>
<Typography variant="body2" color="text.secondary">
Saved to <b>{document.path}</b>.
</Typography>
{props.note}
{!document.encryption && (
<Alert severity="info">The server has no ENCRYPTION_KEY or ENCRYPTION_KEY_FILE, so the credentials cannot have secrets.</Alert>
)}
<Stack direction="row" sx={{ gap: 2, minHeight: 420 }}>
<Stack sx={{ width: 180, flexShrink: 0 }}>
<List dense sx={{ flexGrow: 1, overflow: 'auto' }}>
{profiles.map((item, at) => (
<ListItemButton key={at} selected={at === index} onClick={() => setSelected(at)}>
<ListItemText
primary={item.name || '(no name)'}
secondary={document.kinds.length > 1 ? document.kinds.find((entry) => entry.kind === item.kind)?.label ?? item.kind : undefined}
slotProps={{ primary: { noWrap: true, color: Object.keys(problems).some((key) => key === `${at}` || key.startsWith(`${at}.`)) ? 'error' : undefined } }}
/>
</ListItemButton>
))}
</List>
<Stack sx={{ gap: 0.5, alignItems: 'flex-start' }}>
{document.kinds.map((item) => (
<Button key={item.kind} size="small" startIcon={<Add />} onClick={() => add(item)}>
{document.kinds.length > 1 ? item.label : 'Add'}
</Button>
))}
<Button size="small" color="error" startIcon={<Delete />} disabled={profile === undefined} onClick={() => set(profiles.filter((_item, at) => at !== index))}>
Remove
</Button>
</Stack>
</Stack>
{profile === undefined || kind === undefined ? (
<Typography variant="body2" color="text.secondary" sx={{ flexGrow: 1 }}>
{profile === undefined ? 'No credentials. Add creates one.' : `The kind ${profile.kind} is not known; remove the credential.`}
</Typography>
) : (
<Stack sx={{ flexGrow: 1, gap: 1 }}>
<TextInput label="Name" value={profile.name} problem={problem('name')} help={`The name of the ${kind.label.toLowerCase()}.`} onChange={(name) => change({ name })} />
{kind.fields.map((field) => {
if (field.type === 'choice' && !field.secret) {
return (
<TextField
key={field.key}
select
label={field.label}
value={profile.values[field.key] ?? ''}
onChange={(event) => change({ values: { ...profile.values, [field.key]: event.target.value } })}
error={problem(field.key) !== undefined}
helperText={problem(field.key) ?? field.help}
>
{field.choices?.map((choice) => (
<MenuItem key={choice} value={choice}>{choice}</MenuItem>
))}
</TextField>
)
}
if (!field.secret) {
return (
<TextInput
key={field.key}
label={field.label}
value={profile.values[field.key] ?? ''}
problem={problem(field.key)}
help={field.help}
onChange={(value) => change({ values: { ...profile.values, [field.key]: value } })}
/>
)
}
const secret = profile.secrets[field.key] ?? null
const stored = profile.stored[field.key] ?? false
const setSecret = (value: string | null, keep = stored) => change({ secrets: { ...profile.secrets, [field.key]: value }, stored: { ...profile.stored, [field.key]: keep } })
return secret === null ? (
<Box key={field.key}>
<Typography variant="body2" sx={{ mb: 1 }}>
{field.label}: {stored ? 'saved, encrypted.' : 'none.'}
</Typography>
<Stack direction="row" sx={{ gap: 1 }}>
<Button size="small" variant="outlined" disabled={!document.encryption} onClick={() => setSecret('')}>
{stored ? 'Change' : 'Set'}
</Button>
{stored && (
<Button size="small" color="error" onClick={() => setSecret('', false)}>
Remove
</Button>
)}
</Stack>
</Box>
) : (
<TextInput
key={field.key}
label={field.label}
kind="secret"
value={secret}
problem={problem(field.key)}
help={stored ? `The new ${field.label.toLowerCase()}; empty: none.` : `${field.help} Empty: none.`}
disabled={!document.encryption}
onChange={(value) => setSecret(value)}
/>
)
})}
</Stack>
)}
</Stack>
</>
)
}}
/>
)}
<Snackbar open={error !== ''} autoHideDuration={8000} onClose={() => setError('')}>
<Alert severity="error" onClose={() => setError('')}>
{error}
</Alert>
</Snackbar>
</>
)
}
+2
View File
@@ -1,6 +1,8 @@
export type { CredentialField, CredentialKind, CredentialProfile, CredentialsDocument } from '../shared/credentials.ts'
export { ApiError, request, type Validation } from './api.ts' export { ApiError, request, type Validation } from './api.ts'
export { createBrowserSettings } from './browserSettings.ts' export { createBrowserSettings } from './browserSettings.ts'
export { default as Card } from './Card.tsx' export { default as Card } from './Card.tsx'
export { default as CredentialsButton } from './CredentialsButton.tsx'
export { MONOSPACED, REGULAR, verbatim } from './fonts.ts' export { MONOSPACED, REGULAR, verbatim } from './fonts.ts'
export { appTheme, renderApp } from './renderApp.tsx' export { appTheme, renderApp } from './renderApp.tsx'
export { default as RowDialog } from './RowDialog.tsx' export { default as RowDialog } from './RowDialog.tsx'