5 Commits
18 changed files with 1556 additions and 34 deletions

No files matched your search

+82 -10
View File
@@ -1,25 +1,27 @@
# 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
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.
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:
`./server`, `./web` and `./vite`. The applications depend on the checkout by a `file:` path, which
npm links; Node runs the server helpers from the checkout's real path, where type stripping
Decision: The source is `.ts` and `.tsx` files with three entry points: `./server`, `./web` and
`./vite`. While the toolkit and an application change together, `npm link --no-save` replaces the
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
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
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.
Consequences: The applications need the checkout at `../sk-react-toolkit`, and their container
builds cannot install it until it is published. Node refuses type stripping in `node_modules`,
so the published package is compiled (ADR-2).
Consequences: The dependency of an application never names a path; `npm ci` replaces a link
with the published version. Node refuses type stripping in `node_modules`, so the published
package is compiled (ADR-2).
## 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
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
Gitea; it imports it as `sk-react-toolkit` by an npm alias, so the switch from the checkout to the
published package changes only the dependency. The package is built and published only by the
Gitea; it depends on a published version and imports the package by this name, which is also 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
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
@@ -46,3 +48,73 @@ 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
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/`.
## 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.
## ADR-5: Terraform modules, instances and their variable files are tables shared through the toolkit
Status: Accepted
Context: Cloud Platform deploys many instances of many Terraform modules, and each module's
configurator, a separate application, edits the variables of its instances. Both have to read
and write the same modules, instances and variable files, with the same versions, so that a
write from an outdated read is refused whichever application made it.
Decision: The toolkit holds the tables and their functions and procedures,
`server/sql/tfvars_store.sql`, and `createTfvarsStore`, which both applications use with their own
`pg` pool. An instance names the schema of its state, unique among instances. A variable file is
a row of its instance, its object as `jsonb` with the one it replaced. The version of an
instance's files is the name and the SHA-256 of each file's text, `tfvarsText` of its object, in
name order; a write locks the table, compares the version and writes in one transaction. A change
the tables refuse is an `HttpError` with the status the routes answer. `pg` is an optional peer
dependency, as the applications that do not use the store do not need it.
Consequences: Every application of the store works on the same rows and refuses the same
conflicts, and changes of the tables reach them with a release of the toolkit. The objects' keys
are in `jsonb`'s order. The store is tested against a real PostgreSQL server, which the release
workflow does not have, so `npm test` runs on the developer's machine.
+54
View File
@@ -1,5 +1,59 @@
# Changelog
## 0.4.0
feature: the tfvars store keeps Terraform modules, their instances and the instances' variable files in PostgreSQL.
- `createTfvarsStore(pool)`: the tables `tf_modules`, `tf_instances` and `tf_variable_files`,
created at the first use, with `modules` (`list`, `put`, `delete`), `instances` (`list`,
`create`, `delete`) and `variables(module, instance)` (`read`, `write`, `remove`), whose writes
take the version read and answer `false` when the files changed since.
- `versionOf`, `tfvarsText`, and the types `TfModule`, `TfInstance` and `TfvarsFileStatus` with
the patterns of their names, also exported from `/web`.
- `pg` is an optional peer dependency, for the store.
- `npm test` tests the store against a scratch database of the server the `PG*` variables name.
## 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
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
feature: sk-react-toolkit holds the server helpers, web components and Vite font plugin shared by manage-ui and sql-query-webui.
+131 -17
View File
@@ -7,39 +7,44 @@ source: Node 26 runs the server helpers, and the application's Vite build compil
## Using it
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
`sk-react-toolkit`, by an alias. Its `.npmrc` names the registry of the scope:
`https://gitea.koszewscy.waw.pl/api/packages/koszewscy/npm/`. An application depends on a
published version and imports `@koszewscy/sk-react-toolkit/server`, `/web` and `/vite`. Its
`.npmrc` names the registry of the scope:
```ini
@koszewscy:registry=https://gitea.koszewscy.waw.pl/api/packages/koszewscy/npm/
```
```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
next to its own repository instead, the TypeScript source:
```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:
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`:
```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'
export default defineConfig(({ mode }) => ({
plugins: [react(), font(fontChoice(loadEnv(mode, import.meta.dirname)))],
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
helpers; `react`, `react-dom`, `@mui/material`, `@mui/icons-material` and Emotion for the web
code; `vite` for the Vite helpers.
@@ -57,6 +62,9 @@ code; `vite` for the Vite helpers.
| `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 |
| `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) |
| `createTfvarsStore`, `versionOf`, `tfvarsText`, `TfModule`, `TfInstance`, `TfvarsFileStatus`, `TF_NAME_PATTERN`, `TFVARS_FILE_PATTERN`, `STATE_SCHEMA_PATTERN` | the Terraform modules, their instances and their variable files in PostgreSQL; see [Tfvars store](#tfvars-store) |
## sk-react-toolkit/web
@@ -71,6 +79,8 @@ code; `vite` for the Vite helpers.
| `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 |
| `createBrowserSettings(keys, event)` | switches kept in localStorage and followed across tabs |
| `CredentialsButton` | the dialog of a credential store; see [Credential store](#credential-store) |
| `TfModule`, `TfInstance`, `TfvarsFileStatus`, `TF_NAME_PATTERN`, `TFVARS_FILE_PATTERN`, `STATE_SCHEMA_PATTERN` | the types and name patterns of the [Tfvars store](#tfvars-store) |
## sk-react-toolkit/vite
@@ -79,11 +89,115 @@ 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` |
| `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.
## Tfvars store
The tfvars store keeps Terraform modules, their instances and the variable files of each
instance in PostgreSQL, for Cloud Platform, which deploys the instances, and the configurators,
which edit their variables. `createTfvarsStore(pool)` takes a `pg` pool and applies
`server/sql/tfvars_store.sql` at its first use, in the schema of the connection's search path:
| Table | Rows |
| --- | --- |
| `tf_modules` | `name`, `root`, the folder of the Terraform code, `description` and `configurator_url` |
| `tf_instances` | `module`, `name` and `state_schema`, the schema of the instance's state in the pg backend, unique, and `created` |
| `tf_variable_files` | `module`, `instance`, `name` (`*.tfvars.json`), `content` and `previous`, the object it replaced, as `jsonb`, and `updated` |
Names of modules and instances are `TF_NAME_PATTERN`, of variable files `TFVARS_FILE_PATTERN`,
of state schemas `STATE_SCHEMA_PATTERN`.
- `modules.list()`, `modules.put(module)`, which creates or changes, and `modules.delete(name)`.
- `instances.list(module)`, `instances.create(module, name, stateSchema)` and
`instances.delete(module, name)`, which deletes its variable files too.
- `variables(module, instance)`: `read()` answers the files, their sizes and times and the
version, `versionOf` the files, the name and the SHA-256 of each in name order. `write(version,
contents)` writes files given by name as JSON text and `remove(version, name)` deletes one; both
lock the table, compare the version and change in one transaction, and answer `false`, changing
nothing, when the files changed since the version.
A change the tables refuse throws `HttpError`: 409 for a name or a state schema in use and for a
module that has instances, 404 for a module or an instance that does not exist, 400 for a name or
a content the tables do not accept. The content of a file is the object as `jsonb` keeps it,
written by `tfvarsText`, so the order of object keys is `jsonb`'s; arrays keep theirs.
## Development
`npm install` installs the peer dependencies as development dependencies; `npm run typecheck`
and `npm run lint` check the code. `npm run build` (`build.mjs`) builds the published package in
`dist/`: the code compiled to JavaScript with its declarations, README.md, CHANGELOG.md and a
and `npm run lint` check the code. `npm test` tests the tfvars store in a scratch database it
creates and drops on the PostgreSQL server the `PG*` variables name, connected to through the
database `postgres`. `npm run build` (`build.mjs`) builds the published package in
`dist/`: the code compiled to JavaScript with its declarations, the SQL of the server, README.md, CHANGELOG.md and a
`package.json` whose exports name the compiled files.
## Releases
+14 -2
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env node
// Builds the published package in dist/: the toolkit compiled to JavaScript with its declarations, the declaration of
// 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, the SQL of the server, README.md, CHANGELOG.md and a package.json whose exports
// name the compiled files.
import { spawnSync } from 'node:child_process';
import { copyFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { copyFileSync, cpSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const ROOT = import.meta.dirname;
@@ -21,11 +22,22 @@ 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.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.
copyFileSync(join(ROOT, 'web', 'virtual-font.d.ts'), join(DIST, 'web', 'virtual-font.d.ts'));
const renderApp = join(DIST, 'web', 'renderApp.d.ts');
writeFileSync(renderApp, `/// <reference path="./virtual-font.d.ts" />\n${readFileSync(renderApp, 'utf8')}`);
// The tfvars store reads its SQL next to the compiled module.
cpSync(join(ROOT, 'server', 'sql'), join(DIST, 'server', 'sql'), { recursive: true });
for (const file of ['README.md', 'CHANGELOG.md']) copyFileSync(join(ROOT, file), join(DIST, file));
const source = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
+170 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@koszewscy/sk-react-toolkit",
"version": "0.1.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@koszewscy/sk-react-toolkit",
"version": "0.1.0",
"version": "0.4.0",
"devDependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
@@ -14,10 +14,12 @@
"@mui/icons-material": "^9.5.0",
"@mui/material": "^9.5.0",
"@types/node": "^26.6.5",
"@types/pg": "^8.23.1",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"fastify": "^5.12.7",
"oxlint": "^1.89.0",
"pg": "^8.23.1",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"typescript": "^7.0.2",
@@ -33,6 +35,7 @@
"@mui/icons-material": "^9.5.0",
"@mui/material": "^9.5.0",
"fastify": "^5.12.7",
"pg": "^8.23.1",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"vite": "^8.3.5"
@@ -56,6 +59,9 @@
"fastify": {
"optional": true
},
"pg": {
"optional": true
},
"react": {
"optional": true
},
@@ -1539,6 +1545,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/pg": {
"version": "8.23.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
"integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -3131,6 +3149,103 @@
"node": ">=8"
}
},
"node_modules/pg": {
"version": "8.23.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
"integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.1",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.16.1",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.1"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3220,6 +3335,49 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process-warning": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz",
@@ -3744,6 +3902,16 @@
"optional": true
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@koszewscy/sk-react-toolkit",
"version": "0.1.0",
"version": "0.4.0",
"description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font plugin.",
"private": true,
"type": "module",
@@ -19,6 +19,7 @@
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"lint": "oxlint",
"test": "node --test server/tfvarsStore.test.ts",
"build": "node build.mjs"
},
"peerDependencies": {
@@ -28,6 +29,7 @@
"@mui/icons-material": "^9.5.0",
"@mui/material": "^9.5.0",
"fastify": "^5.12.7",
"pg": "^8.23.1",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"vite": "^8.3.5"
@@ -35,6 +37,7 @@
"peerDependenciesMeta": {
"@fastify/static": { "optional": true },
"fastify": { "optional": true },
"pg": { "optional": true },
"vite": { "optional": true },
"@emotion/react": { "optional": true },
"@emotion/styled": { "optional": true },
@@ -50,10 +53,12 @@
"@mui/icons-material": "^9.5.0",
"@mui/material": "^9.5.0",
"@types/node": "^26.6.5",
"@types/pg": "^8.23.1",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"fastify": "^5.12.7",
"oxlint": "^1.89.0",
"pg": "^8.23.1",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"typescript": "^7.0.2",
+313
View File
@@ -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))
})
}
+6
View File
@@ -1,3 +1,9 @@
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 { QUIET, createLog, fail, timestampsOn, type Log } from './log.ts'
export { exitOnSignals, gitVersion, readJsonObject } from './process.ts'
export { decrypt, encrypt, encryptionKey } from './secrets.ts'
export type { TfInstance, TfModule, TfvarsFileStatus } from '../shared/tfvars.ts'
export { STATE_SCHEMA_PATTERN, TFVARS_FILE_PATTERN, TF_NAME_PATTERN } from '../shared/tfvars.ts'
export { createTfvarsStore, tfvarsText, versionOf, type InstanceVariables, type TfvarsDocument, type TfvarsFile, type TfvarsStore } from './tfvarsStore.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')
}
+117
View File
@@ -0,0 +1,117 @@
-- The store of Terraform modules, their instances and the instances' variable files: the tables tf_modules,
-- tf_instances and tf_variable_files, and the functions and procedures the store calls. The store applies the file on
-- its first use in the schema its connection's search path names; every statement can run again.
CREATE TABLE IF NOT EXISTS tf_modules (
name text PRIMARY KEY CHECK (name ~ '^[a-z][a-z0-9-]*$'),
root text NOT NULL CHECK (root <> ''),
description text NOT NULL DEFAULT '',
configurator_url text
);
CREATE TABLE IF NOT EXISTS tf_instances (
module text NOT NULL REFERENCES tf_modules (name),
name text NOT NULL CHECK (name ~ '^[a-z][a-z0-9-]*$'),
state_schema text NOT NULL UNIQUE CHECK (state_schema ~ '^[a-z_][a-z0-9_]*$'),
created timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (module, name)
);
CREATE TABLE IF NOT EXISTS tf_variable_files (
module text NOT NULL,
instance text NOT NULL,
name text NOT NULL CHECK (name ~ '^[A-Za-z0-9_.-]+\.tfvars\.json$'),
content jsonb NOT NULL CHECK (jsonb_typeof(content) = 'object'),
previous jsonb CHECK (jsonb_typeof(previous) = 'object'),
updated timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (module, instance, name),
FOREIGN KEY (module, instance) REFERENCES tf_instances (module, name) ON DELETE CASCADE
);
-- The modules, by name.
CREATE OR REPLACE FUNCTION tf_modules_list()
RETURNS TABLE (name text, root text, description text, configurator_url text)
LANGUAGE sql STABLE
SET search_path FROM CURRENT
AS $$
SELECT m.name, m.root, m.description, m.configurator_url FROM tf_modules m ORDER BY m.name
$$;
-- Creates a module, or changes the one of that name.
CREATE OR REPLACE PROCEDURE tf_module_put(module_name text, module_root text, module_description text, module_configurator_url text)
LANGUAGE sql
SET search_path FROM CURRENT
AS $$
INSERT INTO tf_modules (name, root, description, configurator_url)
VALUES (module_name, module_root, module_description, module_configurator_url)
ON CONFLICT (name) DO UPDATE SET root = excluded.root, description = excluded.description, configurator_url = excluded.configurator_url
$$;
-- Deletes a module; the foreign key of tf_instances refuses it while the module has instances.
CREATE OR REPLACE PROCEDURE tf_module_delete(module_name text)
LANGUAGE sql
SET search_path FROM CURRENT
AS $$
DELETE FROM tf_modules WHERE name = module_name
$$;
-- The instances of a module, by name.
CREATE OR REPLACE FUNCTION tf_instances_list(module_name text)
RETURNS TABLE (module text, name text, state_schema text, created timestamptz)
LANGUAGE sql STABLE
SET search_path FROM CURRENT
AS $$
SELECT i.module, i.name, i.state_schema, i.created FROM tf_instances i WHERE i.module = module_name ORDER BY i.name
$$;
-- Creates an instance of a module with the schema of its state.
CREATE OR REPLACE PROCEDURE tf_instance_create(module_name text, instance_name text, instance_state_schema text)
LANGUAGE sql
SET search_path FROM CURRENT
AS $$
INSERT INTO tf_instances (module, name, state_schema) VALUES (module_name, instance_name, instance_state_schema)
$$;
-- Deletes an instance with its variable files.
CREATE OR REPLACE PROCEDURE tf_instance_delete(module_name text, instance_name text)
LANGUAGE sql
SET search_path FROM CURRENT
AS $$
DELETE FROM tf_instances WHERE module = module_name AND name = instance_name
$$;
-- The variable files of an instance, by name: content, whether the content it replaced is kept, and the time of the
-- last write.
CREATE OR REPLACE FUNCTION tf_variable_files_read(module_name text, instance_name text)
RETURNS TABLE (name text, content jsonb, previous boolean, updated timestamptz)
LANGUAGE sql STABLE
SET search_path FROM CURRENT
AS $$
SELECT f.name, f.content, f.previous IS NOT NULL, f.updated FROM tf_variable_files f
WHERE f.module = module_name AND f.instance = instance_name ORDER BY f.name
$$;
-- Keeps other writers out until the end of the transaction, from the comparison of the version to the commit.
CREATE OR REPLACE PROCEDURE tf_variable_files_lock()
LANGUAGE sql
SET search_path FROM CURRENT
AS $$
LOCK TABLE tf_variable_files IN EXCLUSIVE MODE
$$;
-- Writes a variable file of an instance, keeping the content it replaces as previous.
CREATE OR REPLACE PROCEDURE tf_variable_file_put(module_name text, instance_name text, file_name text, file_content jsonb)
LANGUAGE sql
SET search_path FROM CURRENT
AS $$
INSERT INTO tf_variable_files (module, instance, name, content) VALUES (module_name, instance_name, file_name, file_content)
ON CONFLICT (module, instance, name) DO UPDATE SET previous = tf_variable_files.content, content = excluded.content, updated = now()
$$;
-- Deletes a variable file of an instance.
CREATE OR REPLACE PROCEDURE tf_variable_file_delete(module_name text, instance_name text, file_name text)
LANGUAGE sql
SET search_path FROM CURRENT
AS $$
DELETE FROM tf_variable_files WHERE module = module_name AND instance = instance_name AND name = file_name
$$;
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict'
import { after, before, test } from 'node:test'
import pg from 'pg'
import { HttpError } from './http.ts'
import { createTfvarsStore, tfvarsText, type TfvarsStore } from './tfvarsStore.ts'
// The tests create a scratch database on the server the PG* variables name, connected to through the database
// postgres, and drop it at the end.
const database = `tfvars_store_test_${process.pid}`
const admin = new pg.Client({ database: 'postgres' })
let pool: pg.Pool
let store: TfvarsStore
before(async () => {
await admin.connect()
await admin.query(`CREATE DATABASE ${database}`)
pool = new pg.Pool({ database })
store = createTfvarsStore(pool)
})
after(async () => {
await pool?.end()
await admin.query(`DROP DATABASE IF EXISTS ${database}`)
await admin.end()
})
const status = async (promise: Promise<unknown>) => {
try {
await promise
} catch (error) {
assert.ok(error instanceof HttpError, String(error))
return error.statusCode
}
return 0
}
test('modules are created, changed, listed and deleted', async () => {
await store.modules.put({ name: 'router', root: 'router/terraform', description: 'VPN router', configuratorUrl: null })
await store.modules.put({ name: 'router', root: 'router/terraform', description: 'VPN router', configuratorUrl: 'http://localhost:7083/' })
await store.modules.put({ name: 'app', root: 'app', description: '', configuratorUrl: null })
assert.deepEqual(await store.modules.list(), [
{ name: 'app', root: 'app', description: '', configuratorUrl: null },
{ name: 'router', root: 'router/terraform', description: 'VPN router', configuratorUrl: 'http://localhost:7083/' },
])
await store.modules.delete('app')
assert.deepEqual((await store.modules.list()).map((module) => module.name), ['router'])
assert.equal(await status(store.modules.put({ name: 'Bad', root: 'x', description: '', configuratorUrl: null })), 400)
})
test('instances are created and refused for a missing module or a name in use', async () => {
await store.instances.create('router', 'platform', 'terraform_remote_state')
await store.instances.create('router', 'test', 'tf_router_test')
assert.deepEqual((await store.instances.list('router')).map((instance) => [instance.name, instance.stateSchema]), [
['platform', 'terraform_remote_state'],
['test', 'tf_router_test'],
])
assert.equal(await status(store.instances.create('missing', 'x', 'tf_missing_x')), 404)
assert.equal(await status(store.instances.create('router', 'test', 'tf_other')), 409)
assert.equal(await status(store.instances.create('router', 'other', 'tf_router_test')), 409)
assert.equal(await status(store.modules.delete('router')), 409)
})
test('variable files are written with their version and refused against another version', async () => {
const variables = store.variables('router', 'test')
const empty = await variables.read()
assert.deepEqual(empty.files, [])
assert.equal(empty.version, '')
assert.equal(await variables.write(empty.version, { 'platform.tfvars.json': tfvarsText({ a: 1 }), 'secrets.tfvars.json': tfvarsText({ s: 'x' }) }), true)
const written = await variables.read()
assert.deepEqual(written.files.map((file) => [file.name, file.content.toString('utf8')]), [
['platform.tfvars.json', tfvarsText({ a: 1 })],
['secrets.tfvars.json', tfvarsText({ s: 'x' })],
])
assert.deepEqual(written.status.map((file) => file.previous), [false, false])
assert.equal(await variables.write(empty.version, { 'platform.tfvars.json': tfvarsText({ a: 2 }) }), false)
assert.equal(await variables.write(written.version, { 'platform.tfvars.json': tfvarsText({ a: 2 }) }), true)
const changed = await variables.read()
assert.deepEqual(changed.status.map((file) => file.previous), [true, false])
assert.notEqual(changed.version, written.version)
assert.equal(await variables.remove(written.version, 'secrets.tfvars.json'), false)
assert.equal(await variables.remove(changed.version, 'secrets.tfvars.json'), true)
assert.deepEqual((await variables.read()).files.map((file) => file.name), ['platform.tfvars.json'])
assert.deepEqual((await store.variables('router', 'platform').read()).files, [])
assert.equal(await status(variables.write((await variables.read()).version, { 'notes.json': tfvarsText({}) })), 400)
assert.equal(await status(store.variables('router', 'missing').write('', { 'a.tfvars.json': tfvarsText({}) })), 404)
})
test('an instance is deleted with its variable files, then its module', async () => {
await store.instances.delete('router', 'test')
await store.instances.delete('router', 'platform')
assert.deepEqual(await store.instances.list('router'), [])
assert.equal((await pool.query('SELECT count(*)::int AS n FROM tf_variable_files')).rows[0].n, 0)
await store.modules.delete('router')
assert.deepEqual(await store.modules.list(), [])
})
+158
View File
@@ -0,0 +1,158 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import type { Pool, PoolClient } from 'pg'
import type { TfInstance, TfModule, TfvarsFileStatus } from '../shared/tfvars.ts'
import { HttpError } from './http.ts'
/** A variable file of an instance: its name and its content, the object jsonb keeps written as JSON text. */
export type TfvarsFile = { name: string; content: Buffer }
/** The variable files of an instance, by name, with their version, versionOf the files. */
export type TfvarsDocument = { files: TfvarsFile[]; status: TfvarsFileStatus[]; version: string }
/** The variable files of one instance. */
export type InstanceVariables = {
read(): Promise<TfvarsDocument>
/**
* Writes the given files, name and JSON text of an object, when the files are still those of version, keeping each
* replaced content as its previous one; answers false, writing nothing, when they changed since.
*/
write(version: string, contents: Record<string, string>): Promise<boolean>
/** Deletes the file name when the files are still those of version; answers false, deleting nothing, otherwise. */
remove(version: string, name: string): Promise<boolean>
}
/** The modules, the instances and their variable files in PostgreSQL. */
export type TfvarsStore = {
modules: {
list(): Promise<TfModule[]>
/** Creates the module, or changes the one of its name. */
put(module: TfModule): Promise<void>
/** Deletes the module; HttpError 409 while it has instances. */
delete(name: string): Promise<void>
}
instances: {
list(module: string): Promise<TfInstance[]>
/** HttpError 409 for a name or a state schema in use, 404 for a module that does not exist. */
create(module: string, name: string, stateSchema: string): Promise<void>
/** Deletes the instance with its variable files. */
delete(module: string, name: string): Promise<void>
}
variables(module: string, instance: string): InstanceVariables
}
/** The text of a variable file as both stores write it: the object as JSON with two spaces, and a final newline. */
export function tfvarsText(content: unknown): string {
return `${JSON.stringify(content, null, 2)}\n`
}
/** Identifies the variable files: the name and the SHA-256 of the content of each, in name order. */
export function versionOf(files: TfvarsFile[]): string {
return [...files].sort((a, b) => a.name.localeCompare(b.name))
.map((file) => `${file.name}=${createHash('sha256').update(file.content).digest('hex')}`).join(':')
}
const SQL = new URL('./sql/tfvars_store.sql', import.meta.url)
type FileRow = { name: string; content: object; previous: boolean; updated: Date }
/**
* The store of sql/tfvars_store.sql in the database of pool, applied at the first use in the schema of the
* connection's search path. A refused change is an HttpError: 409 for a name in use or a module that has instances,
* 404 for a module or an instance that does not exist, 400 for a value the tables do not accept.
*/
export function createTfvarsStore(pool: Pool): TfvarsStore {
let created: Promise<unknown> | null = null
const ready = () => (created ??= pool.query(readFileSync(SQL, 'utf8')))
const query = async <T extends object>(text: string, values: unknown[]): Promise<T[]> => {
await ready()
try {
return (await pool.query<T>(text, values)).rows
} catch (error) {
throw refused(error)
}
}
const document = (rows: FileRow[]): TfvarsDocument => {
const files = rows.map((row) => ({ name: row.name, content: Buffer.from(tfvarsText(row.content), 'utf8') }))
return {
files,
status: rows.map((row, index) => ({ name: row.name, bytes: files[index].content.length, modified: row.updated.toISOString(), previous: row.previous })),
version: versionOf(files),
}
}
/** Runs change in a transaction that holds the lock of the variable files, when they are still those of version. */
const locked = async (module: string, instance: string, version: string, change: (client: PoolClient) => Promise<void>) => {
await ready()
const client = await pool.connect()
try {
await client.query('BEGIN')
await client.query('CALL tf_variable_files_lock()')
const { rows } = await client.query<FileRow>('SELECT * FROM tf_variable_files_read($1, $2)', [module, instance])
if (document(rows).version !== version) {
await client.query('ROLLBACK')
return false
}
await change(client)
await client.query('COMMIT')
return true
} catch (error) {
await client.query('ROLLBACK')
throw refused(error)
} finally {
client.release()
}
}
return {
modules: {
list: async () => (await query<{ name: string; root: string; description: string; configurator_url: string | null }>('SELECT * FROM tf_modules_list()', []))
.map((row) => ({ name: row.name, root: row.root, description: row.description, configuratorUrl: row.configurator_url })),
async put(module) {
await query('CALL tf_module_put($1, $2, $3, $4)', [module.name, module.root, module.description, module.configuratorUrl])
},
async delete(name) {
await query('CALL tf_module_delete($1)', [name])
},
},
instances: {
list: async (module) => (await query<{ module: string; name: string; state_schema: string; created: Date }>('SELECT * FROM tf_instances_list($1)', [module]))
.map((row) => ({ module: row.module, name: row.name, stateSchema: row.state_schema, created: row.created.toISOString() })),
async create(module, name, stateSchema) {
await query('CALL tf_instance_create($1, $2, $3)', [module, name, stateSchema])
},
async delete(module, name) {
await query('CALL tf_instance_delete($1, $2)', [module, name])
},
},
variables: (module, instance) => ({
read: async () => document(await query<FileRow>('SELECT * FROM tf_variable_files_read($1, $2)', [module, instance])),
write: (version, contents) => locked(module, instance, version, async (client) => {
for (const [name, text] of Object.entries(contents)) {
await client.query('CALL tf_variable_file_put($1, $2, $3, $4::jsonb)', [module, instance, name, text])
}
}),
remove: (version, name) => locked(module, instance, version, async (client) => {
await client.query('CALL tf_variable_file_delete($1, $2, $3)', [module, instance, name])
}),
}),
}
}
/** The HttpError of an error of PostgreSQL that refuses a change, by its SQLSTATE; any other error as it is. */
function refused(error: unknown): unknown {
const { code, constraint, detail } = error as { code?: string; constraint?: string; detail?: string }
const reason = detail ?? (error as Error).message
switch (code) {
case '23505':
return new HttpError(409, `The name is in use (${reason}).`)
case '23503':
if (reason.includes('is still referenced')) {
return new HttpError(409, `The module has instances; delete them first (${reason}).`)
}
return new HttpError(404, `The ${constraint === 'tf_instances_module_fkey' ? 'module' : 'instance'} does not exist (${reason}).`)
case '23514':
case '22P02':
return new HttpError(400, `The value is not allowed (${reason}).`)
default:
return error
}
}
+50
View File
@@ -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[]
}
+36
View File
@@ -0,0 +1,36 @@
/** A registered Terraform module: a folder of Terraform code, relative to the modules folder of Cloud Platform. */
export type TfModule = {
name: string
root: string
description: string
/** The address of the module's configurator, the application that edits the variables of its instances. */
configuratorUrl: string | null
}
/** A deployment of a module, whose state is kept in the schema stateSchema of the pg backend. */
export type TfInstance = {
module: string
name: string
stateSchema: string
/** ISO 8601. */
created: string
}
/** A variable file of an instance, without its content. */
export type TfvarsFileStatus = {
name: string
bytes: number
/** ISO 8601, the time of the last write. */
modified: string
/** Whether the content the last write replaced is kept. */
previous: boolean
}
/** The names of modules and instances: a lower-case letter, then lower-case letters, digits and hyphens. */
export const TF_NAME_PATTERN = /^[a-z][a-z0-9-]*$/
/** The names of variable files: letters, digits, '_', '.' and '-', ending in .tfvars.json. */
export const TFVARS_FILE_PATTERN = /^[A-Za-z0-9_.-]+\.tfvars\.json$/
/** The names of pg schemas of a state: a lower-case letter or '_', then lower-case letters, digits and '_'. */
export const STATE_SCHEMA_PATTERN = /^[a-z_][a-z0-9_]*$/
+2 -1
View File
@@ -6,5 +6,6 @@
"outDir": "dist",
"declaration": true,
"rewriteRelativeImportExtensions": true
}
},
"exclude": ["dist", "**/*.test.ts"]
}
+1 -1
View File
@@ -17,6 +17,6 @@
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true
},
"include": ["server", "web", "vite"],
"include": ["server", "web", "vite", "shared"],
"exclude": ["dist"]
}
+279
View File
@@ -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>
</>
)
}
+4
View File
@@ -1,6 +1,10 @@
export type { CredentialField, CredentialKind, CredentialProfile, CredentialsDocument } from '../shared/credentials.ts'
export type { TfInstance, TfModule, TfvarsFileStatus } from '../shared/tfvars.ts'
export { STATE_SCHEMA_PATTERN, TFVARS_FILE_PATTERN, TF_NAME_PATTERN } from '../shared/tfvars.ts'
export { ApiError, request, type Validation } from './api.ts'
export { createBrowserSettings } from './browserSettings.ts'
export { default as Card } from './Card.tsx'
export { default as CredentialsButton, type CredentialsStoreTab } from './CredentialsButton.tsx'
export { MONOSPACED, REGULAR, verbatim } from './fonts.ts'
export { appTheme, renderApp } from './renderApp.tsx'
export { default as RowDialog } from './RowDialog.tsx'