Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97c46ff0d8 | ||
|
|
7959c90012 | ||
|
|
b0b7a4bb7f |
No files matched your search
@@ -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,50 @@ 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`, all of one
|
||||||
|
kind; each entry has a name and the fields of the store's kind, and no kind of its own, as the key
|
||||||
|
of `config.json`, whose schema the application defines, already says what the entries are. An
|
||||||
|
application with several kinds of credentials has a store, a key, for each. The application gives
|
||||||
|
the kind 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 kind, so a new kind needs no new code;
|
||||||
|
the dialog edits the stores of one `config.json` in tabs, and its Save writes them one after the
|
||||||
|
other, each with the version the previous write answers. 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -1,5 +1,38 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
fix: a credential store holds credentials of one kind, without a kind field in its entries, and the dialog edits several stores in tabs.
|
||||||
|
|
||||||
|
- A `CredentialStore` has one `kind`, and its entries hold the name and the fields of that kind
|
||||||
|
only; `kind` is no longer a field of an entry, of `CredentialProfile`, or of `CredentialKind`. The
|
||||||
|
read answer has the store's `kind` in place of `kinds`.
|
||||||
|
- `CredentialsButton` takes `stores`, the prefixes and labels of the stores of the same
|
||||||
|
`config.json`, with a tab for each when there are several; Save writes each store that changed.
|
||||||
|
The message of an empty store is centered in the dialog, and the kind's label keeps its case.
|
||||||
|
- The README's Using it shows the `dev-link` script of the applications, `npm link --no-save` in
|
||||||
|
both their packages, in place of the two commands.
|
||||||
|
|
||||||
|
## 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
|
## 0.1.1
|
||||||
|
|
||||||
fix: the declarations of the published package import the other declarations by their .js names.
|
fix: the declarations of the published package import the other declarations by their .js names.
|
||||||
|
|||||||
@@ -7,39 +7,44 @@ 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, whatever
|
||||||
|
version it names. The applications run it for both their packages as `npm run dev-link`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "scripts": { "dev-link": "npm link --no-save --no-fund ../sk-react-toolkit && cd web && npm link --no-save --no-fund ../../sk-react-toolkit" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
`package.json` and the lock files keep the published version; `npm ci` or `npm install` replaces
|
||||||
|
the link with it again, and `npm run dev-link` links the checkout again. A change of the toolkit
|
||||||
|
reaches the released 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 +62,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 +78,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 +87,78 @@ 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`, all of
|
||||||
|
the one kind the application gives the store, such as a database connection or a service
|
||||||
|
principal; an application with several kinds has a store, a key, for each. Each entry has a
|
||||||
|
`name`, unique and not one of the store's reserved names, and the fields of the store's kind:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pg_connections": [
|
||||||
|
{
|
||||||
|
"name": "state",
|
||||||
|
"host": "db.example.com",
|
||||||
|
"port": 5432,
|
||||||
|
"database": "platform",
|
||||||
|
"user": "tofu",
|
||||||
|
"ssl_mode": "verify-full",
|
||||||
|
"password": "<encrypted>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A kind (`CredentialKind`) has a label for one credential and 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 kind, the reserved
|
||||||
|
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>`,
|
||||||
|
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 kind, 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` (`stores`, `title`, `icon`, `note`, `onSaved`) opens the credentials of the
|
||||||
|
stores in a dialog. `stores` lists the prefix of each store's routes and the label of its tab; the
|
||||||
|
tabs are shown when there are several stores, which share one `config.json`. Each tab has a list at
|
||||||
|
the left with Add and Remove, and the fields of the credential selected, as the store's kind names
|
||||||
|
them; a tab with a problem has a red label. A secret shows only whether it is kept: Set or Change
|
||||||
|
replaces it and Remove drops it. Save writes each store that changed, each write with the version
|
||||||
|
the previous one answers.
|
||||||
|
|
||||||
## 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`
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ 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.
|
// The declarations import the other declarations by their .js names, as the compiled JavaScript does.
|
||||||
for (const entry of ENTRIES) {
|
for (const entry of [...ENTRIES, 'shared']) {
|
||||||
for (const file of readdirSync(join(DIST, entry)).filter((name) => name.endsWith('.d.ts'))) {
|
for (const file of readdirSync(join(DIST, entry)).filter((name) => name.endsWith('.d.ts'))) {
|
||||||
const path = join(DIST, entry, file);
|
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'));
|
writeFileSync(path, readFileSync(path, 'utf8').replace(/(from\s+|import\s+)(['"])(\.{1,2}\/[^'"]+)\.tsx?\2/g, '$1$2$3.js$2'));
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@koszewscy/sk-react-toolkit",
|
"name": "@koszewscy/sk-react-toolkit",
|
||||||
"version": "0.1.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.1.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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@koszewscy/sk-react-toolkit",
|
"name": "@koszewscy/sk-react-toolkit",
|
||||||
"version": "0.1.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",
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
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, every entry of the kind the application gives,
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export type CredentialStore = {
|
||||||
|
configPath: string
|
||||||
|
key: string
|
||||||
|
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; 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 = {
|
||||||
|
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' | 'kind' | '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 ?? []
|
||||||
|
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 ${keys.join(', ')}`
|
||||||
|
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 unknown = Object.keys(record).filter((key) => !keys.includes(key))
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
problems[at] = `must hold only ${keys.join(', ')}; ${unknown.join(', ')} is not one of them`
|
||||||
|
}
|
||||||
|
for (const field of store.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
|
||||||
|
}
|
||||||
|
|
||||||
|
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. */
|
||||||
|
function entriesOf(data: Record<string, unknown>, store: CredentialStore): Record<string, unknown>[] {
|
||||||
|
const entries = valueAt(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[] {
|
||||||
|
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'])),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 previous = profile.original === null ? undefined : stored.find((entry) => entry.name === profile.original)
|
||||||
|
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?.[field.key] : 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(valueAt(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 ${store.kind.label} ${name} does not exist.`)
|
||||||
|
}
|
||||||
|
const values: Record<string, string | number> = {}
|
||||||
|
for (const field of store.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, 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' }, 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.slice(store.key.length + 1).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,
|
||||||
|
kind: store.kind,
|
||||||
|
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 entries = toEntries(request.body.value, file.data, store, (secret) => encrypt(secret, sealing))
|
||||||
|
try {
|
||||||
|
writeFileSync(store.configPath, `${JSON.stringify(withValue(file.data, 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kind of the credentials of a store, such as a database connection or a service principal: a label for one
|
||||||
|
* credential and the fields of each. Every credential of a store is of its kind.
|
||||||
|
*/
|
||||||
|
export type CredentialKind = {
|
||||||
|
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
|
||||||
|
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, its kind 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
|
||||||
|
kind: CredentialKind
|
||||||
|
reserved: string[]
|
||||||
|
}
|
||||||
+1
-1
@@ -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"]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
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, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, List, ListItemButton, ListItemText, MenuItem, Snackbar, Stack, Tab, Tabs,
|
||||||
|
TextField, Tooltip, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
|
import type { CredentialProfile, CredentialsDocument } from '../shared/credentials.ts'
|
||||||
|
import { request } from './api.ts'
|
||||||
|
import TextInput from './TextInput.tsx'
|
||||||
|
import { useValidation } from './useValidation.ts'
|
||||||
|
|
||||||
|
/** A credential store the dialog edits: the prefix of its routes and the label of its tab. */
|
||||||
|
export type CredentialsStoreTab = { prefix: string; label: string }
|
||||||
|
|
||||||
|
/** The state of the validation of a store's credentials: its problems, and whether the answer is pending. */
|
||||||
|
type Check = { problems: Record<string, string>; pending: boolean; error: string }
|
||||||
|
|
||||||
|
/** A new credential of the kind of document, named apart from those of profiles, its choices at their first value. */
|
||||||
|
function newProfile(document: CredentialsDocument, profiles: CredentialProfile[]): CredentialProfile {
|
||||||
|
let number = profiles.length + 1
|
||||||
|
while (profiles.some((profile) => profile.name === `credential${number}`)) {
|
||||||
|
number++
|
||||||
|
}
|
||||||
|
const plain = document.kind.fields.filter((field) => !field.secret)
|
||||||
|
const secret = document.kind.fields.filter((field) => field.secret)
|
||||||
|
return {
|
||||||
|
original: null,
|
||||||
|
name: `credential${number}`,
|
||||||
|
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])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor of one store: the credentials in a list at the left, Add and Remove, and the fields of the one selected
|
||||||
|
* at the right, as the store's kind names them; a credential with a problem is red in the list. A secret shows only
|
||||||
|
* whether it is kept: Set or Change replaces it and Remove drops it. The server validates the credentials as they
|
||||||
|
* change, and onCheck follows the validation.
|
||||||
|
*/
|
||||||
|
function StoreEditor(props: { prefix: string; document: CredentialsDocument; profiles: CredentialProfile[]; onChange: (profiles: CredentialProfile[]) => void; onCheck: (check: Check) => void }) {
|
||||||
|
const { document, profiles, onChange, onCheck } = props
|
||||||
|
const [selected, setSelected] = useState(0)
|
||||||
|
const validation = useValidation(`${props.prefix}/validate`, profiles)
|
||||||
|
const problems = validation.result?.problems ?? {}
|
||||||
|
// The latest onCheck; the validation's answers, not a new onCheck, report the check.
|
||||||
|
const report = useRef(onCheck)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
report.current = onCheck
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
report.current({ problems: validation.result?.problems ?? {}, pending: validation.pending, error: validation.error })
|
||||||
|
}, [validation.result, validation.pending, validation.error])
|
||||||
|
|
||||||
|
const index = Math.min(selected, profiles.length - 1)
|
||||||
|
const profile = profiles[index]
|
||||||
|
const problem = (field: string) => problems[`${index}.${field}`]
|
||||||
|
const change = (next: Partial<CredentialProfile>) => onChange(profiles.map((item, at) => (at === index ? { ...item, ...next } : item)))
|
||||||
|
const { kind } = document
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack sx={{ gap: 1 }}>
|
||||||
|
{validation.error && <Alert severity="error">{validation.error}</Alert>}
|
||||||
|
<Stack direction="row" sx={{ gap: 2, minHeight: 420, position: 'relative' }}>
|
||||||
|
<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)'}
|
||||||
|
slotProps={{ primary: { noWrap: true, color: Object.keys(problems).some((key) => key === `${at}` || key.startsWith(`${at}.`)) ? 'error' : undefined } }}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<Stack direction="row" sx={{ gap: 1 }}>
|
||||||
|
<Button size="small" startIcon={<Add />} onClick={() => {
|
||||||
|
onChange([...profiles, newProfile(document, profiles)])
|
||||||
|
setSelected(profiles.length)
|
||||||
|
}}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
<Button size="small" color="error" startIcon={<Delete />} disabled={profile === undefined} onClick={() => onChange(profiles.filter((_item, at) => at !== index))}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
{profile === undefined ? (
|
||||||
|
// Centered over the list and the fields together.
|
||||||
|
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
None yet. Add creates a {kind.label}.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Stack sx={{ flexGrow: 1, gap: 1 }}>
|
||||||
|
<TextInput label="Name" value={profile.name} problem={problem('name')} help={`The name of the ${kind.label}.`} 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>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A button opening the credentials of one or more stores of the same config.json, served under their prefixes, in a
|
||||||
|
* dialog, with a tab for each store when there are several; a tab whose store has a problem has a red label. Save
|
||||||
|
* writes each store that changed, encrypting new secrets, after the server found no problem in any; onSaved follows a
|
||||||
|
* save. note is shown above the tabs.
|
||||||
|
*/
|
||||||
|
export default function CredentialsButton(props: { stores: CredentialsStoreTab[]; title: string; icon?: ReactNode; note?: ReactNode; onSaved?: () => void }) {
|
||||||
|
const { stores, title } = props
|
||||||
|
const [documents, setDocuments] = useState<CredentialsDocument[] | null>(null)
|
||||||
|
const [values, setValues] = useState<CredentialProfile[][]>([])
|
||||||
|
const [checks, setChecks] = useState<Check[]>([])
|
||||||
|
const [tab, setTab] = useState(0)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveError, setSaveError] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
setTab(0)
|
||||||
|
setSaveError('')
|
||||||
|
Promise.all(stores.map((store) => request<CredentialsDocument>('GET', `${store.prefix}/read`))).then((answers) => {
|
||||||
|
setValues(answers.map((answer) => answer.value))
|
||||||
|
setChecks(answers.map(() => ({ problems: {}, pending: true, error: '' })))
|
||||||
|
setDocuments(answers)
|
||||||
|
}, (caught: Error) => setError(caught.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (documents === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setSaveError('')
|
||||||
|
try {
|
||||||
|
// The stores share config.json: each write answers the version the next one sends.
|
||||||
|
let version = documents[0].version
|
||||||
|
for (const [index, store] of stores.entries()) {
|
||||||
|
if (JSON.stringify(values[index]) !== JSON.stringify(documents[index].value)) {
|
||||||
|
version = (await request<CredentialsDocument>('POST', `${store.prefix}/write`, { version, value: values[index] })).version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDocuments(null)
|
||||||
|
props.onSaved?.()
|
||||||
|
} catch (caught) {
|
||||||
|
setSaveError((caught as Error).message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = checks.some((check) => check.pending || check.error !== '' || Object.keys(check.problems).length > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip title={title}>
|
||||||
|
<IconButton aria-label={title.toLowerCase()} onClick={open}>
|
||||||
|
{props.icon ?? <Key />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
{documents && (
|
||||||
|
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && setDocuments(null)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{saveError && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{saveError}</Alert>}
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Saved to <b>{documents[0].path}</b>.
|
||||||
|
</Typography>
|
||||||
|
{props.note}
|
||||||
|
{!documents[0].encryption && (
|
||||||
|
<Alert severity="info">The server has no ENCRYPTION_KEY or ENCRYPTION_KEY_FILE, so the credentials cannot have secrets.</Alert>
|
||||||
|
)}
|
||||||
|
{stores.length > 1 && (
|
||||||
|
<Tabs value={tab} onChange={(_event, next: number) => setTab(next)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
{stores.map((store, index) => (
|
||||||
|
<Tab key={store.prefix} value={index} label={store.label} sx={Object.keys(checks[index]?.problems ?? {}).length > 0 ? { color: 'error.main' } : undefined} />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: 'grid' }}>
|
||||||
|
{stores.map((store, index) => (
|
||||||
|
<Box key={store.prefix} sx={{ gridArea: '1 / 1', visibility: tab === index ? 'visible' : 'hidden' }}>
|
||||||
|
<StoreEditor
|
||||||
|
prefix={store.prefix}
|
||||||
|
document={documents[index]}
|
||||||
|
profiles={values[index]}
|
||||||
|
onChange={(profiles) => setValues((current) => current.map((item, at) => (at === index ? profiles : item)))}
|
||||||
|
onCheck={(check) => setChecks((current) => current.map((item, at) => (at === index ? check : item)))}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDocuments(null)}>Cancel</Button>
|
||||||
|
<Button variant="contained" disabled={saving || blocked} onClick={() => void save()}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
<Snackbar open={error !== ''} autoHideDuration={8000} onClose={() => setError('')}>
|
||||||
|
<Alert severity="error" onClose={() => setError('')}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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, type CredentialsStoreTab } 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'
|
||||||
|
|||||||
Reference in new issue
Block a user