# sk-react-toolkit The shared code of the web tools built as a Fastify server in TypeScript and a React application with MUI built by Vite, such as azure-platform's manage-ui and sql-query-webui. It is TypeScript source: Node 26 runs the server helpers, and the application's Vite build compiles the web code. ## 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 depends on a published version and imports `@koszewscy/sk-react-toolkit/server`, `/shared`, `/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": { "@koszewscy/sk-react-toolkit": "^0.2.0" } } ``` 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 '@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, })) ``` 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. ## sk-react-toolkit/server | Export | Purpose | | --- | --- | | `HttpError` | an error answered with its status and `{ error }`, with `problems` and a `detail` message for a refused write | | `strict(properties)` | the JSON schema of an object with exactly these properties, all required | | `errorHandler(log)` | Fastify's error handler: `{ error }`, or `{ error, problems, message }`; unexpected 5xx errors logged with their stack | | `notFoundHandler(webDir, buildHint)` | 404 for `/api` paths, `index.html` for the others, 503 with the hint while the frontend is not built | | `createLog`, `timestampsOn`, `QUIET`, `Log` | the log of a server, with optional timestamps and requests; `QUIET` for tests | | `fail(message)` | prints the error in red and ends the process with status 2 | | `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/shared The types and constants of `/server` and `/web` that a server and its web application share, for API types imported by both, without the code of either: `CredentialField`, `CredentialKind`, `CredentialProfile`, `CredentialsDocument`, `TfModule`, `TfInstance`, `TfvarsFileStatus`, `TF_NAME_PATTERN`, `TFVARS_FILE_PATTERN` and `STATE_SCHEMA_PATTERN`. ## sk-react-toolkit/web | Export | Purpose | | --- | --- | | `request`, `ApiError`, `Validation` | a JSON call to the backend, its error with the problems of a refused write, and the answer of a validate endpoint | | `appTheme(colorSchemes?)`, `renderApp(options)` | the theme (light, dark and system, Regular font, small text fields) and the startup that retries the first call while showing Connecting | | `REGULAR`, `MONOSPACED`, `verbatim` | the font styles and the attributes of inputs the browser must not change | | `Card` | a centered panel with a title and a message | | `ThemeMenu` | the button cycling light, dark and system theme | | `SwitcherMenu`, `SwitcherItem` | a button naming the item chosen, whose menu chooses another, such as a page or a connection | | `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; with `body` and `select` the dialog posts another value, such as a whole document with the row put in, and takes the row's problems from the answer | | `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 | Export | Purpose | | --- | --- | | `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": "" } ] } ``` 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 `..`, 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 /read`, `POST /validate` and `POST /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 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 `./release.mjs` commits, tags the version of the newest CHANGELOG.md entry and pushes `main` with the tag; `-n` prints the git commands instead. The version of a release of the code, `x.y.z`, is that of `package.json`, so a release raises both. A tag `x.y.z-n` releases changes that are not code changes; its version is already published, so its run publishes nothing. The `Release` workflow, `.gitea/workflows/release.yml`, builds and publishes the package on Gitea Actions for each tag and on demand: it installs the dependencies, typechecks and lints the code, builds the package and publishes `dist/` to the npm registry of the owner `CI_OWNER` on the server `CI_URL`, with `CI_TOKEN`, a token with the package scope; all three are secrets of the repository. A version already published is skipped, as the registry refuses the same version again.