@starci/hfs 4.11.8 → 4.11.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +6 -3
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
- package/runtime/knowledge/hfs/rules.yaml +2 -2
- package/runtime/knowledge/hfs/slots.yaml +16 -1
- package/runtime/modules/kernel/failure-codes.yaml +5 -5
- package/runtime/scripts/api/sops/lib.mjs +30 -0
- package/runtime/scripts/api/sops/update-keys.mjs +20 -0
- package/runtime/scripts/hfs/path-findings.mjs +4 -2
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
- package/runtime/scripts/hfs/secret-sops.mjs +15 -0
- package/runtime/scripts/hfs/secret-standard.mjs +54 -0
- package/runtime/scripts/hfs/secret.mjs +36 -6
- package/runtime/scripts/hfs/stack-deploy-config.mjs +11 -4
- package/scaffold/add-inbox-lite.mjs +6 -10
- package/scaffold/add-queue-lite.mjs +7 -2
- package/scaffold/add-table.mjs +5 -0
- package/scaffold/app.mjs +15 -3
- package/scaffold/lite-exports.mjs +23 -0
- package/src/main.mjs +1 -0
- package/stack/age-install.mjs +4 -3
- package/stack/context.mjs +32 -2
- package/stack/remote.mjs +4 -2
- package/stack/server-key.mjs +296 -0
- package/stack/ssh-session.mjs +7 -1
- package/sync/index.mjs +3 -0
- package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
- package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +3 -3
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +1 -2
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +4 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +0 -2
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/index.ts +4 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/rate-window.entity.ts +17 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +260 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +133 -0
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.11.12 - 2026-10-29
|
|
4
|
+
|
|
5
|
+
- Changed: the skeleton canon gate (`src/scaffold-template-canon-be.spec.mjs`) lints every file under `be/src/modules/platform` of both editions with the starci-be rules, no longer `platform/http` alone; both editions are green.
|
|
6
|
+
- Fixed: the lite skeleton's `platform/http-security` reads and writes its rate limit windows through `RateWindowEntity` on the EntityManager (R334), no longer through full's `rate-limit.sql.ts` and `.query(`: a live window grows in one atomic `increment`, an ended one restarts with `update`, a missing one opens with an `orIgnore` insert builder, and the prune deletes a bounded `find` batch. The entity maps `public.http_security_rate_windows` of the baseline migration (`persistence/entities/rate-window.entity.ts`, `httpSecurityEntities` in its barrel, listed in `PRIMARY_ENTITIES`); a lite platform capability takes its `persistence/` from the lite overlay alone. The guard spec uses the kit's `mockEntityManager` and `queueQueryBuilders`.
|
|
7
|
+
- Changed: a fresh lite app no longer carries the raw SQL helpers nothing in it calls: `platform/database/database.sql.ts` and `database.client.ts` (with its spec) are written by the first generator that needs them (`add table` the sql helpers, `add queue`, `add job` and `add webhook` both), and the barrel exports `sql`, `callDbFunction` and `advisoryXactLock` are registered by those generators (`jobs` registers `advisoryXactLock`); `notify` is no longer exported by the starter.
|
|
8
|
+
- Fixed: `source-suffix` (BE_SOURCE_FORM, R89) admits the one file a file slot names by its path, read from the slot manifest: the lite entity registry `src/modules/platform/database/entities.ts` (slot `be.platform.database.entities`) was refused for having no role suffix.
|
|
9
|
+
- Fixed: the lite skeleton types its entity lists with `EntityClass` (`new () => object`, declared in `platform/database/database.options.ts` as full declares it): `DatabaseOptions.entities` and `PRIMARY_ENTITIES` of `entities.ts` were `ReadonlyArray<Function>`, which `starci-be/no-function-or-eval` refuses.
|
|
10
|
+
- Changed: R334 `BE_PERSISTENCE_NO_RAW_SQL` covers test code with one owner exception (ruling lite-test-sql-owner): specs, builders and fixtures seed and assert through entities on `world.db.<connection>`, and only `src/tests/world` holds raw SQL beside `platform/database`, as named world helpers for what entities cannot express. The bundled catalog law and the failure-code hint say so; the bundled canon pins name hfs 4.11.12, eslint-canon-be 3.8.11, eslint-canon-fe 9.5.13 and the Grammar family 0.15.24.
|
|
11
|
+
|
|
12
|
+
## 4.11.11 - 2026-10-29
|
|
13
|
+
|
|
14
|
+
- Changed: `server-key <env> --backup github` stores the server identity as the secret `SOPS_AGE_KEY` of the GitHub Environment `<env>` (`BACKUP_SECRET`, `gh secret set SOPS_AGE_KEY --env <env>`), the name the deploy workflows read: an Environment secret overrides a repository secret of the same name for every job bound to that Environment, so the deploy of `<env>` decrypts with that environment's own server key. `backupSecretName` and the `SOPS_AGE_KEY_<ENV>` name are gone.
|
|
15
|
+
- Changed: `server-key <env> --restore github` dispatches `stack-key-restore.yml` with `-f recipient=<deploy.remote.serverRecipient>` instead of a secret name, and is refused `STACK_SERVER_KEY_NO_RECIPIENT` when none is recorded. The managed workflow (both editions) reads `secrets.SOPS_AGE_KEY` of the bound environment, takes the `recipient` input in place of `secret`, and on the server writes the identity beside its target, keeping it only when `age-keygen -y` of it is that recipient (exit 6 otherwise, nothing written), so neither the owner key nor a stale identity can land there.
|
|
16
|
+
- Added: `--backup github` and `--restore github`, dry run or not, read the repository's Actions secret names (`gh api repos/<o>/<r>/actions/secrets`, names only, `repositoryKeyWarnings`) and warn while a repository-level `SOPS_AGE_KEY` exists, naming `gh secret delete SOPS_AGE_KEY --repo <o>/<r>`; the owner key never belongs in GitHub. An unreadable list is a warning too.
|
|
17
|
+
- Changed: R251 (`HFS_DEPLOY_WORKFLOW`, `rules/deploy-workflow.mjs`) refuses any app workflow job, deployment job or not, that reads `secrets.SOPS_AGE_KEY` (job or workflow `env`, an active step's `env`, `with` or `run`, a reusable workflow's `secrets`) without an `environment`; a disabled step does not count. The managed `stack-deploy.yml` (both jobs) and `images.yml` (when an environment declares images) already bind theirs; their headers now say the key they read is that environment's server identity.
|
|
18
|
+
- Changed: the bundled canon pins name hfs 4.11.11, eslint-canon-be 3.8.10, eslint-canon-fe 9.5.12 and the Grammar family 0.15.23.
|
|
19
|
+
|
|
20
|
+
## 4.11.10 - 2026-10-29
|
|
21
|
+
|
|
22
|
+
- Fixed: the skeleton's `platform/http` passes the canon it ships with, so an app that copies it byte-identical lints clean. `FetchHttpClient.send` hands `fetch` its deadline at the call (`fetch(request.url, { ...this.routed(init), signal })`): the signal was set inside `init` and handed through `routed`, whose declared `RequestInit` leaves `signal` optional, so `starci-be/http-needs-timeout` (R70) could not prove it. The client spec provides `HTTP_OPTIONS` with the kit's `builder<HttpOptions>({})(overrides)`, the double `starci-be/spec-infra-double-from-kit` (R48) asks for an `*_OPTIONS` token, instead of a parameter it cannot trace.
|
|
23
|
+
- Fixed: the lite skeleton's api app reads `http: parseHttpConfig(env)` into its options (`api.env.ts`, `api.options.ts`, the wiring proof's `http: {}`) and registers `HttpModule.register({ isGlobal: true, ...options.http })`, as the full app does, so a lite test world passes its fake CA as `HTTP_TRUSTED_CA` too and `parseHttpConfig` and `HttpOptions` are no longer exports nothing imports (`starci-be/dead-exports`).
|
|
24
|
+
- Changed: the bundled canon pins name hfs 4.11.10, eslint-canon-be 3.8.9, eslint-canon-fe 9.5.11 and the Grammar family 0.15.22.
|
|
25
|
+
- Spec: `src/scaffold-template-canon-be.spec.mjs` scaffolds each edition and lints its `be/src/modules/platform/http/**` (code and specs) with the starci-be rules of the sibling eslint-canon-be, typed against this package's installs and the built kit, and asserts no finding; on the skeleton before this fix it names exactly the R70 and R48 findings above.
|
|
26
|
+
|
|
27
|
+
## 4.11.9 - 2026-10-29
|
|
28
|
+
|
|
29
|
+
- Added: `starci app secret server-key <env> [--execute]` makes the deploy server's own age identity ON the server, reached through the deploy's own transport (`deploy.remote`, `withRunner`: the same host, user and key source; an environment whose deploy declares no remote is refused `STACK_SERVER_KEY_NO_REMOTE` before any ssh). The server keeps `/root/.config/sops/age/keys.txt` (`SERVER_AGE_IDENTITY`, `stack/server-key.mjs`, sops' default key file of root) when it exists, else `age-keygen -o` makes it (umask 077, mode 600, owner root); only `age-keygen -y` comes back, and any other output is refused unseen. age-keygen missing is installed by stack prepare's installer (`installAge`) or refused naming the install commands. Locally the recipient joins the environment's rule beside the owner key (`STACK_SERVER_KEY_NO_OWNER` when the rule names none), is recorded as `deploy.remote.serverRecipient`, and every member is wrapped again with `sops updatekeys` (`updateKeys` of the sops seam, `api/sops/update-keys.mjs`, `runSelectedUpdateKeys` for an inline identity): the owner identity opens the data key only, no value is decrypted. A dry run until `--execute`; a second run keeps the identity and changes nothing.
|
|
30
|
+
- Added: `server-key <env> --rotate --execute` stages a new identity at `<identity>.next`, wraps the members to it, swaps it in keeping the old file on the server as `<identity>.retired-<UTC stamp>` until the owner deletes it, then removes the old recipient and wraps them again.
|
|
31
|
+
- Added: `deploy.remote.serverRecipient` (a public age recipient) in the deploy block; `audit` raises `RECIPIENT_DRIFT` for an environment whose rule does not name it beside another recipient (the owner key), and for a production swarm deploy with a remote that records none.
|
|
32
|
+
- Added: `server-key <env> --backup github [--execute]` streams the server identity from the server's ssh stdout straight into the stdin of `gh secret set SOPS_AGE_KEY_<ENV> --env <env> --repo <origin>` (the stack context's `pipe` seam and the ssh runner's `argv(command)`): never printed, held or written locally. It is refused unless that GitHub Environment exists with required reviewers or deployment branches limited to main (`STACK_SERVER_KEY_GITHUB_ENVIRONMENT`, `STACK_SERVER_KEY_UNPROTECTED`). `--restore github` dispatches the managed `.github/workflows/stack-key-restore.yml` (slot `app.ci-stack-key-restore`, slot manifest 3.14.0), which writes the secret to the server over the deploy's CI ssh key with umask 077 and mode 600, never echoing it and never over an existing identity.
|
|
33
|
+
|
|
3
34
|
## 4.11.8 - 2026-10-29
|
|
4
35
|
|
|
5
36
|
- Added: `starci app secret migrate <env> <member> --to <existing> --into-existing` and `split <env> <member> --into-existing` merge into a member that already exists (never a legacy `.enc` one: migrate it first). Without the flag an existing target is refused, naming the flag. Every existing target is decrypted in memory and checked before anything is written: a key it already holds with another value refuses the whole move (`refused, nothing was written: <env>/<member>.enc.yaml already holds KEY with another value`, names only); an identical value merges. Each target is written once, and the source is removed only after every target is written (an own target is staged as `<member>-staged.enc.yaml` and moved over it). The dry run names each target's keys, which targets exist and the keys they already hold (`secret-standard.mjs` `plannedTargets`, `moveKeys`).
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ starci app db drift [--url-env <NAME>] [--json] [--cwd <dir>]
|
|
|
24
24
|
starci app new service <dir> <name> [--inject <binding>...] [--cwd <dir>]
|
|
25
25
|
starci app new spec <file>.service.ts [--cwd <dir>]
|
|
26
26
|
starci app new image [--cwd <dir>]
|
|
27
|
-
starci app secret init|set|get|unset|rename|remove|materialize|list|verify|recipients|migrate|split|audit ... [--cwd <dir>]
|
|
27
|
+
starci app secret init|set|get|unset|rename|remove|materialize|list|verify|recipients|migrate|split|audit|server-key ... [--cwd <dir>]
|
|
28
28
|
starci app hygiene [--cwd <dir>]
|
|
29
29
|
```
|
|
30
30
|
|
|
@@ -234,8 +234,11 @@ by itself (`localAgeIdentity`): SOPS_AGE_KEY or SOPS_AGE_KEY_FILE of the environ
|
|
|
234
234
|
`.claude/secret.env`, else of its main checkout's when the app is a git worktree, else the `hostIdentity` the harness CLI passes
|
|
235
235
|
(its own secret.env), else the sops `keys.txt`; with none, the verb refuses before asking for a value, naming every place it
|
|
236
236
|
looked and the one line that fixes it. `audit [<env>]` names every deviation and exits 1; `migrate <env> <member> [--to
|
|
237
|
-
<provider>]` and `split <env> <member>` repair them, dry runs until `--execute` (`secret-standard.mjs`).
|
|
238
|
-
|
|
237
|
+
<provider>]` and `split <env> <member>` repair them, dry runs until `--execute` (`secret-standard.mjs`). `server-key <env>` makes
|
|
238
|
+
the deploy server's own age identity on the server through the deploy's transport and seals the environment to it beside the
|
|
239
|
+
owner key, `--rotate` replaces it, `--backup github` stores it as the SOPS_AGE_KEY of the GitHub Environment `<env>` that
|
|
240
|
+
every CI job bound to `<env>` reads, and `--restore github` writes it back (`stack/server-key.mjs`). No output carries a value
|
|
241
|
+
except `get --reveal`.
|
|
239
242
|
|
|
240
243
|
## Sonar
|
|
241
244
|
|
package/package.json
CHANGED
|
@@ -31,35 +31,35 @@ pins:
|
|
|
31
31
|
source: packages/event-stream/package.json
|
|
32
32
|
why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
|
|
33
33
|
'@starci/grammar':
|
|
34
|
-
version: 0.15.
|
|
34
|
+
version: 0.15.24
|
|
35
35
|
group: starci
|
|
36
36
|
install: registry
|
|
37
37
|
side: fe
|
|
38
38
|
source: packages/grammar/package.json
|
|
39
39
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
40
40
|
'@starci/eslint-canon-be':
|
|
41
|
-
version: 3.8.
|
|
41
|
+
version: 3.8.11
|
|
42
42
|
group: starci
|
|
43
43
|
install: registry
|
|
44
44
|
side: be
|
|
45
45
|
source: packages/eslint-canon-be/package.json
|
|
46
46
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
47
47
|
'@starci/eslint-canon-fe':
|
|
48
|
-
version: 9.5.
|
|
48
|
+
version: 9.5.13
|
|
49
49
|
group: starci
|
|
50
50
|
install: registry
|
|
51
51
|
side: fe
|
|
52
52
|
source: packages/eslint-canon-fe/package.json
|
|
53
53
|
why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
|
|
54
54
|
'@starci/eslint-grammar-core':
|
|
55
|
-
version: 0.15.
|
|
55
|
+
version: 0.15.24
|
|
56
56
|
group: starci
|
|
57
57
|
install: registry
|
|
58
58
|
side: fe
|
|
59
59
|
source: packages/eslint-grammar-core/package.json
|
|
60
60
|
why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
|
|
61
61
|
'@starci/eslint-grammar-offset-pop':
|
|
62
|
-
version: 0.15.
|
|
62
|
+
version: 0.15.24
|
|
63
63
|
group: starci
|
|
64
64
|
install: registry
|
|
65
65
|
side: fe
|
|
@@ -106,7 +106,7 @@ pins:
|
|
|
106
106
|
source: packages/ai-router/package.json
|
|
107
107
|
why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
|
|
108
108
|
'@starci/hfs':
|
|
109
|
-
version: 4.11.
|
|
109
|
+
version: 4.11.12
|
|
110
110
|
group: starci
|
|
111
111
|
install: registry
|
|
112
112
|
side: both
|
|
@@ -4195,7 +4195,7 @@ rules:
|
|
|
4195
4195
|
editions: [lite, full]
|
|
4196
4196
|
docs:
|
|
4197
4197
|
- {path: docs/application-stacks.md#r251-deploy-workflow, editions: [lite, full]}
|
|
4198
|
-
law: "In lite and full apps, deployment jobs declare a protected environment, explicit permissions and concurrency, materialize sealed secrets through SOPS_AGE_KEY or starci app secret materialize, and reference only the age key and transport SSH key/known_hosts secrets.
|
|
4198
|
+
law: "In lite and full apps, deployment jobs declare a protected environment, explicit permissions and concurrency, materialize sealed secrets through SOPS_AGE_KEY or starci app secret materialize, and reference only the age key and transport SSH key/known_hosts secrets; every job that reads secrets.SOPS_AGE_KEY, a deployment job or not, is bound to the GitHub Environment whose members it opens, so it reads that environment's server key and never a repository secret of that name. Deployment jobs never embed a dotenv blob or write a dotenv file. Every remote workflow action uses a 40-hex commit SHA (local actions are allowed and container actions require a full sha256 digest); supply-chain reports each unpinned uses reference at its source line, including disabled jobs and steps; installed tools are checksum or digest verified, or supplied by a pinned action. App workflows start only on release tags [v*], the release candidate branches release/candidate-* (required in ci.yml: starci app release push waits for that run, owner ruling candidate-ci 2026-10-18) or workflow_dispatch; ci.yml also runs on main, and other branch pushes are refused."
|
|
4199
4199
|
kinds: [check]
|
|
4200
4200
|
gates: [settle, land, ci]
|
|
4201
4201
|
failureCodes: ["HFS_DEPLOY_WORKFLOW", "HFS_WORKFLOW_PIN", "HFS_CI_TOOL_INSTALL", "HFS_APP_WORKFLOW_TRIGGER"]
|
|
@@ -5828,7 +5828,7 @@ rules:
|
|
|
5828
5828
|
ruling: "Lite entities only map tables the migrations create, so each entity column is checked against the migrated schema by starci app db drift, which CI runs in the build job and the release gate runs as its db-drift row."
|
|
5829
5829
|
- id: R334
|
|
5830
5830
|
code: "BE_PERSISTENCE_NO_RAW_SQL"
|
|
5831
|
-
law: "Lite domain and
|
|
5831
|
+
law: "Lite domain, feature and test code reads and writes through TypeORM entities on an EntityManager (domain code the injected one, integration and e2e specs, builders and fixtures the world's `world.db.<connection>`) (find, findOne, findBy, count, exists, insert, save, update, delete, upsert, increment, and createQueryBuilder(Entity, alias) with named parameters for joins, aggregates, FOR UPDATE / SKIP LOCKED and jsonb or vector operators; never a table-name builder, a builder with no entity or a Repository); no .query( call and no sql template outside the two raw SQL owners, platform/database and the test world src/tests/world, which wraps only what entities cannot express (a trigger, a database function, a row-level-security probe, a pgmq queue) in a named, typed world helper specs call; a database function TypeORM cannot express is called, in domain and test code alike, through the callDbFunction, advisoryXactLock and notify helpers of platform/database/database.client.ts, and callDbFunction names only a function ruleParams.be.dbFunctions or hfs.json sides.be.dbFunctions lists with a reason: a function of a migration schema-qualified (public.job_claim), a pg built-in bare (pg_notify, called in pg_catalog). That file alone composes the call statement, from the name parts its validator checked and double-quotes and a numbered parameter list. Entities only map tables db/migrations created (synchronize stays false); entity-versus-schema drift is BE_ENTITY_SCHEMA_DRIFT of starci app db drift."
|
|
5832
5832
|
kinds: [lint]
|
|
5833
5833
|
gates: [pre-commit, settle, land, ci]
|
|
5834
5834
|
failureCodes: ["BE_PERSISTENCE_NO_RAW_SQL"]
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# catalog READ this file through scripts/hfs/slots.mjs; none of them hardcodes a path.
|
|
8
8
|
# Shape: modules/schemas/hfs-slots.schema.yaml. The repository side is hfs.json: modules/schemas/hfs-repo.schema.yaml.
|
|
9
9
|
schema: starci/hfs-slots@3
|
|
10
|
-
version: 3.
|
|
10
|
+
version: 3.14.0
|
|
11
11
|
|
|
12
12
|
versioning:
|
|
13
13
|
# MAJOR.MINOR.PATCH of this manifest. A repository pins only the MAJOR in hfs.json ("hfs": 3).
|
|
@@ -58,6 +58,8 @@ versioning:
|
|
|
58
58
|
# 3.13.0 (rulings lite-pgmq-bus-bullmq-jobs and lite-db-image-in-stacks, 2026-10-29) is a change for lite only: ruleParams.db.extensions
|
|
59
59
|
# admits pgmq, the event bus, ruleParams.db.queueOutbox is retired with the pg_cron half of R258, the lite event-bus functions replace
|
|
60
60
|
# event_bus_write in ruleParams.be.dbFunctions, and app.db.image moves to .starcistacks/images/db/Dockerfile (db/ keeps schema and data).
|
|
61
|
+
# 3.14.0 (ruling app-secrets-one-standard, 2026-10-29) adds the optional app.ci-stack-key-restore, the managed workflow that writes the
|
|
62
|
+
# deploy server's age identity back from its GitHub Environment backup.
|
|
61
63
|
|
|
62
64
|
# Field meaning, per slot.
|
|
63
65
|
# id stable name, never changes meaning inside a major.
|
|
@@ -676,6 +678,19 @@ slots:
|
|
|
676
678
|
managedBy: ci-workflows
|
|
677
679
|
liteManagedBy: ci-workflows-lite
|
|
678
680
|
rules: [HFS_MANAGED_FILE_DRIFT]
|
|
681
|
+
- id: app.ci-stack-key-restore
|
|
682
|
+
since: 3.14.0
|
|
683
|
+
profiles: [app]
|
|
684
|
+
editionWhy: "ruling:app-secrets-one-standard: both editions deploy to a Docker Swarm server whose own age identity the owner backs up as a protected GitHub Environment secret; this workflow writes it back."
|
|
685
|
+
# The server identity restore workflow: workflow_dispatch only (like app.ci-stack-deploy); `starci app secret server-key <env> --restore github` dispatches it, rendered by starci app sync.
|
|
686
|
+
path: ".github/workflows/stack-key-restore.yml"
|
|
687
|
+
presence: optional
|
|
688
|
+
tracked: tracked
|
|
689
|
+
tier: none
|
|
690
|
+
tests: none
|
|
691
|
+
managedBy: ci-workflows
|
|
692
|
+
liteManagedBy: ci-workflows-lite
|
|
693
|
+
rules: [HFS_MANAGED_FILE_DRIFT]
|
|
679
694
|
- id: app.dockerignore
|
|
680
695
|
since: 3.1.0
|
|
681
696
|
profiles: [app]
|
|
@@ -5581,12 +5581,12 @@ LITE_DB_MIGRATION:
|
|
|
5581
5581
|
kind: check-finding
|
|
5582
5582
|
|
|
5583
5583
|
BE_PERSISTENCE_NO_RAW_SQL:
|
|
5584
|
-
title: "Lite domain code runs raw SQL instead of TypeORM entities"
|
|
5585
|
-
title_vi: "Mã domain của app lite chạy SQL thô thay vì entity TypeORM"
|
|
5586
|
-
meaning_vi: "File `<path>` gọi `.query(` hoặc dùng template `sql` ngoài platform/database và
|
|
5584
|
+
title: "Lite domain, feature or test code runs raw SQL instead of TypeORM entities"
|
|
5585
|
+
title_vi: "Mã domain, feature hoặc test của app lite chạy SQL thô thay vì entity TypeORM"
|
|
5586
|
+
meaning_vi: "File `<path>` gọi `.query(` hoặc dùng template `sql` ngoài hai chủ của SQL thô là platform/database và test world src/tests/world, hoặc gọi callDbFunction với một hàm không có trong ruleParams.be.dbFunctions hay hfs.json sides.be.dbFunctions."
|
|
5587
5587
|
causes_vi:
|
|
5588
|
-
- "Vi phạm R334: dữ liệu của app lite đi qua entity TypeORM trên EntityManager (find, save, update, upsert, createQueryBuilder)."
|
|
5589
|
-
nextStep_vi: "
|
|
5588
|
+
- "Vi phạm R334: dữ liệu của app lite, kể cả spec, builder và fixture, đi qua entity TypeORM trên EntityManager (find, save, update, upsert, createQueryBuilder); spec dùng world.db.<connection>."
|
|
5589
|
+
nextStep_vi: "Đọc/ghi bằng entity trên EntityManager hoặc createQueryBuilder với tham số có tên (trong test là world.db.<connection>); hàm database TypeORM không diễn đạt được thì gọi qua callDbFunction với hàm đã khai kèm lý do; điều entity không diễn đạt được trong test (trigger, thăm dò row-level security, hàng đợi pgmq) thì gọi một helper có tên của src/tests/world."
|
|
5590
5590
|
owner: op-retry
|
|
5591
5591
|
kind: check-finding
|
|
5592
5592
|
|
|
@@ -310,6 +310,36 @@ export function runSelectedSops(bin, request, { selection, invocation, env, cwd,
|
|
|
310
310
|
}
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/**
|
|
314
|
+
* `sops --config <config> updatekeys -y --input-type <type> <file>` under an inline selected identity: the member's data key is
|
|
315
|
+
* opened with SOPS_AGE_KEY in an isolated child environment and wrapped again to the recipients the config's rule names; no value
|
|
316
|
+
* is decrypted to stdout or disk. The tool profile is the selected one; captures are wiped.
|
|
317
|
+
*/
|
|
318
|
+
export function runSelectedUpdateKeys(bin, { file, config, inputType }, { selection, invocation, env, cwd, timeout } = {}) {
|
|
319
|
+
if (selection?.error?.identityRefusal !== 'inline-context-unqualified' || typeof selection.inlineName !== 'string' || !env || typeof env !== 'object') return held('selected-context-missing');
|
|
320
|
+
if (!invocation || typeof invocation.runProgram !== 'function' || typeof invocation.resolveRealTool !== 'function') return held('selected-context-missing');
|
|
321
|
+
if (!['yaml', 'dotenv', 'json'].includes(inputType) || !path.isAbsolute(file ?? '') || !path.isAbsolute(config ?? '')) return held('invalid-request');
|
|
322
|
+
const key = env[selection.inlineName];
|
|
323
|
+
if (typeof key !== 'string' || !/^AGE-SECRET-KEY-1[0-9A-Z]+$/.test(key.trim())) return held('invalid-inline');
|
|
324
|
+
const captures = [];
|
|
325
|
+
try {
|
|
326
|
+
const exe = bin ?? resolveSops(env), age = invocation.resolveRealTool('age-keygen', { env });
|
|
327
|
+
if (!nativeFile(exe) || !nativeFile(age)) return held('native-tool-unavailable');
|
|
328
|
+
if (!auditAbsent(cwd)) return held('unsupported-audit-context');
|
|
329
|
+
const publicEnv = isolatedSopsEnv(env, null);
|
|
330
|
+
const options = { cwd, shell: false, encoding: 'buffer', windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], timeout: timeout ?? IDENTITY_TIMEOUT_MS, maxBuffer: CREDENTIAL_FILE_MAX_BYTES };
|
|
331
|
+
const run = (program, args, childEnv) => {
|
|
332
|
+
const result = invocation.runProgram(program, args, { ...options, env: childEnv });
|
|
333
|
+
captures.push(result); return result;
|
|
334
|
+
};
|
|
335
|
+
if (!toolProfileSupported(run(exe, ['--disable-version-check', '--version'], publicEnv), run(age, ['--version'], publicEnv))) return held('unsupported-tool-profile');
|
|
336
|
+
const result = run(exe, ['--disable-version-check', '--config', config, 'updatekeys', '-y', '--input-type', inputType, file], isolatedSopsEnv(env, selection.inlineName));
|
|
337
|
+
if (!captured(result)) return { status: result?.status ?? null, stdout: '', stderr: '', error: result?.error ?? null };
|
|
338
|
+
return { status: 0, stdout: '', stderr: '', error: null };
|
|
339
|
+
} catch { return held('selected-native-refused'); }
|
|
340
|
+
finally { wipeCaptures(captures); }
|
|
341
|
+
}
|
|
342
|
+
|
|
313
343
|
const identityRequestValid = ({ env, cwd, invocation, assertLease, consume }) =>
|
|
314
344
|
env && typeof env === 'object' && path.isAbsolute(cwd ?? '') && typeof consume === 'function'
|
|
315
345
|
&& typeof assertLease === 'function' && typeof invocation?.runProgram === 'function'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// update-keys.mjs — the public SOPS `updatekeys` call: a sealed member is wrapped again to the recipients its .sops.yaml rule
|
|
2
|
+
// names. Only the member's data key is opened (with the caller's identity); no value is decrypted to stdout or disk.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { sopsIdentityEnv } from '../../../engine/secrets.mjs';
|
|
5
|
+
import { resolveSops, runSelectedUpdateKeys } from './lib.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run `<bin> --config <config> updatekeys -y --input-type <inputType> <file>` (bin null: the sops resolveSops finds). An inline
|
|
9
|
+
* SOPS_AGE_KEY goes through the selected isolation (runSelectedUpdateKeys); a SOPS_AGE_KEY_FILE runs with the selected environment.
|
|
10
|
+
* {status, stdout, stderr, error}; stdout and stderr carry public key names only.
|
|
11
|
+
*/
|
|
12
|
+
export function updateKeys(bin, { file, config, inputType }, { invocation = null, env = process.env, cwd = undefined, timeout = undefined } = {}) {
|
|
13
|
+
const selected = sopsIdentityEnv(env, { required: true });
|
|
14
|
+
if (selected.error?.identityRefusal === 'inline-context-unqualified') return runSelectedUpdateKeys(bin, { file, config, inputType }, { selection: selected, invocation, env, cwd, timeout });
|
|
15
|
+
if (selected.error) return { status: null, stdout: '', stderr: '', error: selected.error };
|
|
16
|
+
const exe = bin ?? resolveSops(selected.env);
|
|
17
|
+
if (!exe) return { status: null, stdout: '', stderr: '', error: Object.assign(new Error('sops is not installed (Windows: winget install Mozilla.SOPS)'), { code: 'SOPS_MISSING' }) };
|
|
18
|
+
const r = spawnSync(exe, ['--disable-version-check', '--config', config, 'updatekeys', '-y', '--input-type', inputType, file], { cwd, env: selected.env, encoding: 'utf8', windowsHide: true, timeout, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
19
|
+
return { status: r.status, stdout: r.stdout, stderr: r.stderr, error: r.error ?? null };
|
|
20
|
+
}
|
|
@@ -29,6 +29,8 @@ function sourceFormContext(file, resolver, suffixes, bannedSuffixes) {
|
|
|
29
29
|
const base = path.posix.basename(file);
|
|
30
30
|
if (FREE_NAMES.has(base)) return null;
|
|
31
31
|
const slot = resolver.slot(c.slot);
|
|
32
|
+
// A file slot declares its one file by its path (be.platform.database.entities: src/modules/platform/database/entities.ts).
|
|
33
|
+
if (slot?.path === c.path) return null;
|
|
32
34
|
if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].includes(base)) return null;
|
|
33
35
|
const admitted = allowsFile(resolver, file);
|
|
34
36
|
// Environment parsing is an app slot role, not a source suffix admitted outside that matching app file.
|
|
@@ -57,8 +59,8 @@ function sourceFormFinding(file, { c, base, suffixes, bannedSuffixes }, boundSuf
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/**
|
|
60
|
-
* BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a
|
|
61
|
-
* be.persistence, the matching <app>.env.ts explicitly admitted by its app slot, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
|
|
62
|
+
* BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, the one file a file
|
|
63
|
+
* slot names by its path (the lite entity registry src/modules/platform/database/entities.ts), a migration of be.persistence, the matching <app>.env.ts explicitly admitted by its app slot, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
|
|
62
64
|
* as order.service.spec.ts keeps its inner words kebab-case). A suffix of ruleParams.be.bannedSuffixes anywhere in the
|
|
63
65
|
* name is refused by name. Paths no slot owns are HFS_SLOT_UNDECLARED's, not this code's.
|
|
64
66
|
*/
|
|
@@ -160,6 +160,24 @@ function deploymentJobFindings(name, job, doc, refuse) {
|
|
|
160
160
|
return findings;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
const AGE_KEY_REFERENCE = /\$\{\{[^}]*\bsecrets\s*(?:\.\s*SOPS_AGE_KEY\b|\[\s*(['"])SOPS_AGE_KEY\1\s*\])/;
|
|
164
|
+
const readsAgeKey = (value) => strings(value).some((text) => AGE_KEY_REFERENCE.test(text));
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A job that reads secrets.SOPS_AGE_KEY opens the members of one environment, so it is bound to that GitHub Environment, whose
|
|
168
|
+
* SOPS_AGE_KEY is the environment's server identity (PROJECT-STACKS-5/7); unbound, it would read a repository secret of that name.
|
|
169
|
+
* Disabled steps do not count. A deployment job is judged by deploymentJobFindings, which already requires its environment.
|
|
170
|
+
*/
|
|
171
|
+
function ageKeyJobFindings(doc, file, refuse) {
|
|
172
|
+
const deployment = new Set(deploymentJobs(doc, file).map(([name]) => name));
|
|
173
|
+
const workflowWide = readsAgeKey(doc.env);
|
|
174
|
+
const jobs = record(doc.jobs) ? Object.entries(doc.jobs).filter(([, job]) => record(job)) : [];
|
|
175
|
+
return jobs
|
|
176
|
+
.filter(([name, job]) => !deployment.has(name) && !validEnvironment(job.environment))
|
|
177
|
+
.filter(([, job]) => workflowWide || readsAgeKey({ env: job.env, with: job.with, secrets: job.secrets, steps: activeSteps(job).map((step) => ({ env: step.env, with: step.with, run: nonempty(step.run) ? executable(step.run) : undefined })) }))
|
|
178
|
+
.map(([name]) => refuse(DEPLOY, `job ${name} reads secrets.SOPS_AGE_KEY without an environment; bind it to the GitHub Environment whose members it opens (environment: <env>), which holds that environment's server key`));
|
|
179
|
+
}
|
|
180
|
+
|
|
163
181
|
function deploymentFindings(doc, file, refuse) {
|
|
164
182
|
const deployment = deploymentJobs(doc, file);
|
|
165
183
|
const named = deployName(file) || deployName(String(doc.name ?? ''));
|
|
@@ -216,7 +234,7 @@ function perWorkflow({ path: file, text }) {
|
|
|
216
234
|
const refuse = (code, message) => found(code, file, `${code} ${file}: ${message}`);
|
|
217
235
|
const doc = parseWorkflow(text);
|
|
218
236
|
if (!record(doc)) return [refuse(TRIGGER, 'the workflow must be valid YAML with a release-only on block'), ...(deployName(file) ? [refuse(DEPLOY, 'the deploy workflow must be valid YAML')] : [])];
|
|
219
|
-
return [...triggerFindings(doc, refuse, file), ...deploymentFindings(doc, file, refuse), ...toolFindings(doc, refuse)];
|
|
237
|
+
return [...triggerFindings(doc, refuse, file), ...deploymentFindings(doc, file, refuse), ...ageKeyJobFindings(doc, file, refuse), ...toolFindings(doc, refuse)];
|
|
220
238
|
}
|
|
221
239
|
|
|
222
240
|
/** Synchronous R251 findings over the tracked workflows of a lite/full app. */
|
|
@@ -5,6 +5,8 @@ import { runProgram } from "../api/process/run-program.mjs";
|
|
|
5
5
|
import { resolveRealTool } from "../api/process/resolve-real-tool.mjs";
|
|
6
6
|
import { decrypt as sopsDecrypt } from "../api/sops/decrypt.mjs";
|
|
7
7
|
import { seal as sopsSeal } from "../api/sops/seal.mjs";
|
|
8
|
+
import { updateKeys as sopsUpdateKeys } from "../api/sops/update-keys.mjs";
|
|
9
|
+
import { isLegacyName } from "../lib/sealed-member.mjs";
|
|
8
10
|
import { invocationDir } from "../lib/roots.mjs";
|
|
9
11
|
import { envelopeOf } from "./secret-document.mjs";
|
|
10
12
|
import { configuredRecipients, localAgeIdentity, secretTools } from "./secret-init.mjs";
|
|
@@ -194,6 +196,19 @@ export function defaultSops(
|
|
|
194
196
|
);
|
|
195
197
|
return String(result.stdout);
|
|
196
198
|
},
|
|
199
|
+
/**
|
|
200
|
+
* Wraps member `file` again to the recipients its environment's rule of the app's .sops.yaml names (sops updatekeys): the
|
|
201
|
+
* selected identity opens the data key only, no value is decrypted, nothing is printed.
|
|
202
|
+
*/
|
|
203
|
+
updateKeys(file) {
|
|
204
|
+
const result = sopsUpdateKeys(
|
|
205
|
+
binary,
|
|
206
|
+
{ file: path.resolve(file), config: path.join(path.resolve(repoRoot), ".sops.yaml"), inputType: isLegacyName(file) ? "dotenv" : "yaml" },
|
|
207
|
+
{ env: selectedEnv, cwd: repoRoot, invocation },
|
|
208
|
+
);
|
|
209
|
+
if (result.status !== 0)
|
|
210
|
+
throw refuse(`update the keys of ${path.basename(file)}; is the age identity a recipient of it?`, result, file);
|
|
211
|
+
},
|
|
197
212
|
seal(request) {
|
|
198
213
|
const recipients = request.recipients.length
|
|
199
214
|
? request.recipients
|
|
@@ -86,6 +86,8 @@ export function auditSecrets(repoRoot, environment = null) {
|
|
|
86
86
|
}
|
|
87
87
|
if (tracked && !tracked.has(name)) add("UNTRACKED", "is not committed; commit it (it is ciphertext)");
|
|
88
88
|
}
|
|
89
|
+
const drift = serverRecipientDrift(repoRoot, env, spec);
|
|
90
|
+
if (drift) findings.push({ code: "RECIPIENT_DRIFT", env, member: ".sops.yaml", detail: drift });
|
|
89
91
|
for (const entry of Array.isArray(spec?.secrets) ? spec.secrets : []) {
|
|
90
92
|
const member = entry?.member ?? /^secrets\/([a-z0-9-]+)/u.exec(entry?.where ?? "")?.[1];
|
|
91
93
|
if (member && !fs.existsSync(fileOf(directory, member)))
|
|
@@ -165,6 +167,58 @@ export function declareKeys(repoRoot, environment, member, keys) {
|
|
|
165
167
|
return { declared: missing, byHand: [] };
|
|
166
168
|
}
|
|
167
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Records `recipient` as `environments.<environment>.deploy.remote.serverRecipient` of application-stacks.yaml: the public age
|
|
172
|
+
* recipient of the deploy server's own identity, which `starci app secret server-key` made on the server and audit holds the
|
|
173
|
+
* environment's rule to. Edits the text in place (comments and order kept), replacing the line or adding it as the last child of
|
|
174
|
+
* `remote:`, and proves the result parses to the expected document; a shape this edit cannot place is refused.
|
|
175
|
+
*/
|
|
176
|
+
export function recordServerRecipient(repoRoot, environment, recipient) {
|
|
177
|
+
const file = path.join(repoRoot, ".starcistacks", "application-stacks.yaml");
|
|
178
|
+
const text = fs.readFileSync(file, "utf8");
|
|
179
|
+
const document = parseYaml(text) ?? {};
|
|
180
|
+
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
181
|
+
const lines = text.replace(/\r\n/gu, "\n").split("\n");
|
|
182
|
+
const root = lines.findIndex((line) => /^environments:\s*(?:#.*)?$/u.test(line));
|
|
183
|
+
const envLine = root < 0 ? -1 : childLine(lines, blockOf(lines, root), environment);
|
|
184
|
+
const deploy = envLine < 0 ? -1 : childLine(lines, blockOf(lines, envLine), "deploy");
|
|
185
|
+
const remote = deploy < 0 ? -1 : childLine(lines, blockOf(lines, deploy), "remote");
|
|
186
|
+
const unplaced = new SecretError(`cannot record the server recipient in environments.${environment}.deploy.remote of application-stacks.yaml; add serverRecipient: ${recipient} there by hand`);
|
|
187
|
+
if (remote < 0 || !/^ *remote:\s*(?:#.*)?$/u.test(lines[remote])) throw unplaced;
|
|
188
|
+
const block = blockOf(lines, remote);
|
|
189
|
+
const own = childLine(lines, block, "serverRecipient");
|
|
190
|
+
const first = lines.slice(block.from, block.to).find(meaningful);
|
|
191
|
+
const pad = " ".repeat(first === undefined ? indentOf(lines[remote]) + 2 : indentOf(first));
|
|
192
|
+
if (own >= 0) lines[own] = `${pad}serverRecipient: ${recipient}`;
|
|
193
|
+
else lines.splice(block.to, 0, `${pad}serverRecipient: ${recipient}`);
|
|
194
|
+
const expected = structuredClone(document);
|
|
195
|
+
expected.environments[environment].deploy.remote.serverRecipient = recipient;
|
|
196
|
+
const edited = lines.join(newline);
|
|
197
|
+
let parsed;
|
|
198
|
+
try { parsed = parseYaml(edited); } catch { parsed = undefined; }
|
|
199
|
+
if (JSON.stringify(parsed) !== JSON.stringify(expected)) throw unplaced;
|
|
200
|
+
if (edited !== text) fs.writeFileSync(file, edited);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The RECIPIENT_DRIFT of an environment's rule against its deploy server: a rule that does not name the recorded
|
|
205
|
+
* `deploy.remote.serverRecipient` together with another recipient (the owner key), or a production swarm deploy with a remote
|
|
206
|
+
* and no server recipient recorded. Null when the rule holds.
|
|
207
|
+
*/
|
|
208
|
+
function serverRecipientDrift(repoRoot, env, spec) {
|
|
209
|
+
const remote = spec?.deploy?.remote;
|
|
210
|
+
if (!remote || typeof remote !== "object") return null;
|
|
211
|
+
const server = remote.serverRecipient;
|
|
212
|
+
if (server === undefined)
|
|
213
|
+
return env === "production" && spec.runtime === "docker-swarm"
|
|
214
|
+
? `records no deploy server recipient; production is sealed to the server key and the owner key: starci app secret server-key ${env} --execute`
|
|
215
|
+
: null;
|
|
216
|
+
const rule = recipientsFor(repoRoot, path.join(repoRoot, ".starcistacks", env, "secrets", `member${MEMBER_SUFFIX}`));
|
|
217
|
+
if (!rule.includes(server)) return `its rule names ${keyList(rule) || "no recipient"}, not the deploy server recipient ${server}; run starci app secret server-key ${env} --execute`;
|
|
218
|
+
if (rule.length < 2) return `its rule names only the deploy server recipient ${server}; add the owner key: starci app secret recipients ${env} --add owner, then starci app secret server-key ${env} --execute`;
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
168
222
|
/** `starci app secret audit [<env>]`: each deviation on its own line and exit 1, else one clean line and exit 0. */
|
|
169
223
|
export function printAudit(repoRoot, environment, stdout) {
|
|
170
224
|
const findings = auditSecrets(repoRoot, environment);
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// Every verb runs at the app root or --cwd. Exit 0 done, 2 refusal; --reveal is the only plaintext stdout path.
|
|
6
6
|
// unset, rename and remove edit one sealed member and are dry runs until --execute (scripts/hfs/secret-edit.mjs).
|
|
7
7
|
// verify decrypts each member in memory and names only keys and violated shapes, never a value (R249).
|
|
8
|
+
// server-key makes the deploy server's own age identity on the server and seals the environment to it (stack/server-key.mjs).
|
|
8
9
|
import fs from "node:fs";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import { randomBytes } from "node:crypto";
|
|
@@ -19,11 +20,14 @@ import { SecretError, publicStorage, secretErrorMessage } from "./secret-errors.
|
|
|
19
20
|
import { editSecret, validateEditNames } from "./secret-edit.mjs";
|
|
20
21
|
import { verifySecrets } from "./rules/env-value-shape.mjs";
|
|
21
22
|
import { setValues } from "./secret-prompt.mjs";
|
|
23
|
+
import { stackContext } from "../../../stack/context.mjs";
|
|
24
|
+
import { StackError } from "../../../stack/errors.mjs";
|
|
25
|
+
import { serverKey } from "../../../stack/server-key.mjs";
|
|
22
26
|
|
|
23
27
|
export { envelopeOf, formatOf, plaintextOf } from "./secret-document.mjs";
|
|
24
28
|
export { setSecretValues } from "./secret-store.mjs";
|
|
25
29
|
|
|
26
|
-
const VERBS = new Set(["init", "list", "get", "set", "unset", "rename", "remove", "materialize", "verify", "recipients", "migrate", "split", "audit"]);
|
|
30
|
+
const VERBS = new Set(["init", "list", "get", "set", "unset", "rename", "remove", "materialize", "verify", "recipients", "migrate", "split", "audit", "server-key"]);
|
|
27
31
|
const MOVE_VERBS = new Set(["migrate", "split"]);
|
|
28
32
|
const LIST_FLAGS = ["--add", "--remove", "--from-file", "--group"];
|
|
29
33
|
const EDIT_VERBS = new Set(["unset", "rename", "remove"]);
|
|
@@ -45,9 +49,9 @@ function parse(argv) {
|
|
|
45
49
|
const value = inline ?? argv[++i];
|
|
46
50
|
if (value === undefined || value.startsWith("--")) throw new SecretError(`${flag} needs a value`);
|
|
47
51
|
(opts[flag.slice(2)] ??= []).push(value);
|
|
48
|
-
} else if (["--cwd", "--generate", "--to", "--group-file"].includes(flag)) {
|
|
52
|
+
} else if (["--cwd", "--generate", "--to", "--group-file", "--backup", "--restore"].includes(flag)) {
|
|
49
53
|
valueOption(opts, flag, inline ?? argv[++i]);
|
|
50
|
-
} else if (["--to-file", "--reveal", "--execute", "--force-nonempty", "--into-existing"].includes(flag)) {
|
|
54
|
+
} else if (["--to-file", "--reveal", "--execute", "--force-nonempty", "--into-existing", "--rotate"].includes(flag)) {
|
|
51
55
|
if (inline !== undefined && !["true", "false"].includes(inline))
|
|
52
56
|
throw new SecretError(`${flag} expects true or false`);
|
|
53
57
|
opts[flag.slice(2)] = inline !== "false";
|
|
@@ -69,12 +73,15 @@ function usageArguments(verb) {
|
|
|
69
73
|
if (verb === "migrate") return "<env> <slug> [--to <slug>] [--into-existing] [--execute]";
|
|
70
74
|
if (verb === "split") return "<env> <slug> [--group KEY=member ...] [--group-file <yaml>] [--into-existing] [--execute]";
|
|
71
75
|
if (verb === "audit") return "[<env>]";
|
|
76
|
+
if (verb === "server-key") return "<env> [--rotate | --backup github | --restore github] [--execute]";
|
|
72
77
|
return "<env>";
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
function editOptionsFor(verb, opts) {
|
|
76
|
-
if (opts.execute !== undefined && !EDIT_VERBS.has(verb) && !MOVE_VERBS.has(verb))
|
|
77
|
-
throw new SecretError("--execute is only valid for unset, rename, remove, migrate and
|
|
81
|
+
if (opts.execute !== undefined && !EDIT_VERBS.has(verb) && !MOVE_VERBS.has(verb) && verb !== "server-key")
|
|
82
|
+
throw new SecretError("--execute is only valid for unset, rename, remove, migrate, split and server-key");
|
|
83
|
+
if ((opts.rotate !== undefined || opts.backup !== undefined || opts.restore !== undefined) && verb !== "server-key")
|
|
84
|
+
throw new SecretError("--rotate, --backup and --restore are only valid for server-key");
|
|
78
85
|
if (opts.to !== undefined && verb !== "migrate") throw new SecretError("--to is only valid for migrate");
|
|
79
86
|
if ((opts.group || opts["group-file"] !== undefined) && verb !== "split") throw new SecretError("--group and --group-file are only valid for split");
|
|
80
87
|
if (opts["into-existing"] !== undefined && !MOVE_VERBS.has(verb)) throw new SecretError("--into-existing is only valid for migrate and split");
|
|
@@ -110,6 +117,7 @@ function optionsFor(verb, opts) {
|
|
|
110
117
|
migrate: [2, 2],
|
|
111
118
|
split: [2, 2],
|
|
112
119
|
audit: [0, 1],
|
|
120
|
+
"server-key": [1, 1],
|
|
113
121
|
}[verb];
|
|
114
122
|
if (count < bounds[0] || count > bounds[1])
|
|
115
123
|
throw new SecretError(
|
|
@@ -173,6 +181,22 @@ function sealedLine(environment, slug, keys, file, created, seam) {
|
|
|
173
181
|
return `${head}; a new member: adding keys to it later needs the age identity${of}${held}\n`;
|
|
174
182
|
}
|
|
175
183
|
|
|
184
|
+
/** `server-key`: the deploy server's identity through the stack transport (`serverKeyIo`: { io, deps } of the specs), lines out. */
|
|
185
|
+
async function serverKeyVerb(repoRoot, environment, opts, { env, stdout, stderr, open, serverKeyIo }) {
|
|
186
|
+
const ctx = stackContext({ cwd: repoRoot, env, stdout, stderr, openSops: open, ...serverKeyIo.io });
|
|
187
|
+
try {
|
|
188
|
+
const result = await serverKey(ctx, environment, {
|
|
189
|
+
execute: opts.execute === true, rotate: opts.rotate === true, backup: opts.backup, restore: opts.restore,
|
|
190
|
+
}, serverKeyIo.deps ?? {});
|
|
191
|
+
for (const line of result.lines) stdout(`${line}\n`);
|
|
192
|
+
return result.ok ? 0 : 1;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (!(error instanceof StackError)) throw error;
|
|
195
|
+
stderr(`starci app secret: ${error.message}\n`);
|
|
196
|
+
return error.exit;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
176
200
|
/**
|
|
177
201
|
* All app secrets verbs; stdin, prompt, sops, random and tool ports permit isolated custody specs. `hostIdentity`
|
|
178
202
|
* (`{ label, read }`) is the harness CLI's secret.env SOPS_AGE_KEY, the last place before keys.txt the age identity is looked for.
|
|
@@ -191,13 +215,14 @@ export async function secretMain(
|
|
|
191
215
|
cwd = invocationDir(),
|
|
192
216
|
platform = process.platform,
|
|
193
217
|
tools,
|
|
218
|
+
serverKeyIo = {},
|
|
194
219
|
} = {},
|
|
195
220
|
) {
|
|
196
221
|
const [verb, ...rest] = argv;
|
|
197
222
|
try {
|
|
198
223
|
if (!VERBS.has(verb))
|
|
199
224
|
throw new SecretError(
|
|
200
|
-
"usage: starci app secret init | set <env> <slug> <KEY> [KEY ...] | get <env> <slug> [KEY] --to-file | unset <env> <slug> <KEY> | rename <env> <slug> <OLD> <NEW> | remove <env> <slug> | materialize <env> | list <env> | verify <env> | recipients <env> | migrate <env> <slug> [--to <slug>] [--into-existing] | split <env> <slug> [--group KEY=member] [--group-file <yaml>] [--into-existing] | audit [<env>]",
|
|
225
|
+
"usage: starci app secret init | set <env> <slug> <KEY> [KEY ...] | get <env> <slug> [KEY] --to-file | unset <env> <slug> <KEY> | rename <env> <slug> <OLD> <NEW> | remove <env> <slug> | materialize <env> | list <env> | verify <env> | recipients <env> | migrate <env> <slug> [--to <slug>] [--into-existing] | split <env> <slug> [--group KEY=member] [--group-file <yaml>] [--into-existing] | audit [<env>] | server-key <env> [--rotate | --backup github | --restore github] [--execute]",
|
|
201
226
|
);
|
|
202
227
|
const opts = parse(rest);
|
|
203
228
|
optionsFor(verb, opts);
|
|
@@ -215,6 +240,11 @@ export async function secretMain(
|
|
|
215
240
|
}),
|
|
216
241
|
);
|
|
217
242
|
const [environment, slug, key] = opts.positional;
|
|
243
|
+
if (verb === "server-key")
|
|
244
|
+
return await serverKeyVerb(repoRoot, environment, opts, {
|
|
245
|
+
env, stdout, stderr, serverKeyIo,
|
|
246
|
+
open: () => sops ?? defaultSops(env, repoRoot, platform, tools, hostIdentity),
|
|
247
|
+
});
|
|
218
248
|
const directory = secretsDirectory(repoRoot, environment);
|
|
219
249
|
if (verb === "list") return listSecrets(directory, stdout);
|
|
220
250
|
const open = () => sops ?? defaultSops(env, repoRoot, platform, tools, hostIdentity);
|
|
@@ -13,6 +13,7 @@ export const KEY = /^[A-Z][A-Z0-9_]*$/u;
|
|
|
13
13
|
const VOLUME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u;
|
|
14
14
|
const HOST_ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
15
15
|
const REMOTE_BASE = /^\/[A-Za-z0-9._/-]+$/u;
|
|
16
|
+
const AGE_RECIPIENT = /^age1[a-z0-9]+$/u;
|
|
16
17
|
const HTTP_URL = /^https?:\/\/[^\s]+$/u;
|
|
17
18
|
const PINNED_HELPER_IMAGE = /^[a-z0-9./_-]+(?::[A-Za-z0-9._-]+)?@sha256:(?!0{64}$)[0-9a-f]{64}$/u;
|
|
18
19
|
const ARGUMENT = /^[A-Za-z0-9_@%+=:,./-]+$/u;
|
|
@@ -20,7 +21,7 @@ const UPPER_ENV = KEY;
|
|
|
20
21
|
|
|
21
22
|
const FIELDS = {
|
|
22
23
|
deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'postDeploy', 'verify', 'jobs', 'tasks', 'backup', 'ingress', 'registry'],
|
|
23
|
-
remote: ['host', 'releasesBase', 'sealed'],
|
|
24
|
+
remote: ['host', 'releasesBase', 'sealed', 'serverRecipient'],
|
|
24
25
|
sealed: ['ops', 'sshKey', 'knownHosts'],
|
|
25
26
|
image: ['app', 'buildArgs'],
|
|
26
27
|
buildArgs: ['values', 'sealed'],
|
|
@@ -51,9 +52,15 @@ function stringList(c, at, value, itemPattern, { nonEmpty = false } = {}) {
|
|
|
51
52
|
return value;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* `deploy.remote`: the ssh alias, the releases root, the sealed owner-machine session and `serverRecipient`, the public age
|
|
57
|
+
* recipient of the server's own identity that `starci app secret server-key` records.
|
|
58
|
+
*/
|
|
54
59
|
function remote(c, block) {
|
|
55
|
-
|
|
56
|
-
if (
|
|
60
|
+
const fallback = { host: 'vps', releasesBase: null, sealed: null, serverRecipient: null };
|
|
61
|
+
if (block === undefined) return fallback;
|
|
62
|
+
if (!closedFields(c, 'deploy.remote', block, FIELDS.remote)) return fallback;
|
|
63
|
+
if (block.serverRecipient !== undefined) patternField(c, 'deploy.remote.serverRecipient', block.serverRecipient, AGE_RECIPIENT);
|
|
57
64
|
if (block.host !== undefined) nameField(c, 'deploy.remote.host', block.host, HOST_ALIAS);
|
|
58
65
|
if (block.releasesBase !== undefined && (!REMOTE_BASE.test(String(block.releasesBase)) || String(block.releasesBase).includes('..')))
|
|
59
66
|
c.problem('deploy.remote.releasesBase', 'must be an absolute path without ..');
|
|
@@ -62,7 +69,7 @@ function remote(c, block) {
|
|
|
62
69
|
for (const key of FIELDS.sealed) nameField(c, `deploy.remote.sealed.${key}`, block.sealed[key]);
|
|
63
70
|
sealed = { ...block.sealed };
|
|
64
71
|
}
|
|
65
|
-
return { host: block.host ?? 'vps', releasesBase: block.releasesBase ?? null, sealed };
|
|
72
|
+
return { host: block.host ?? 'vps', releasesBase: block.releasesBase ?? null, sealed, serverRecipient: block.serverRecipient ?? null };
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
function imageBuildArgs(c, at, block, members) {
|
|
@@ -233,17 +233,13 @@ export function addLiteWebhookInbox({
|
|
|
233
233
|
);
|
|
234
234
|
if (!hasHandoffStorage)
|
|
235
235
|
planned[0].text = `${template("platform/migration.sql.tpl", renderValues)}\n${template("platform/client-operation-outcomes.sql.tpl", renderValues)}\n${planned[0].text}`;
|
|
236
|
-
|
|
237
|
-
|
|
236
|
+
// The sql tag and the database-function call (with its spec) the inbox and the event bus use; the lite starter leaves them out.
|
|
237
|
+
for (const file of ["database.sql.ts", "database.client.ts", "database.client.spec.ts"]) {
|
|
238
|
+
const relative = `be/src/modules/platform/database/${file}`;
|
|
239
|
+
if (fs.existsSync(path.join(root, ...relative.split("/")))) continue;
|
|
238
240
|
planned.push({
|
|
239
|
-
relative
|
|
240
|
-
text: render(
|
|
241
|
-
fs.readFileSync(
|
|
242
|
-
path.join(TEMPLATES_DIR, "be", "skeleton-lite", "src", "modules", "platform", "database", "database.sql.ts"),
|
|
243
|
-
"utf8",
|
|
244
|
-
),
|
|
245
|
-
{},
|
|
246
|
-
),
|
|
241
|
+
relative,
|
|
242
|
+
text: render(fs.readFileSync(path.join(TEMPLATES_DIR, "be", "skeleton-lite", "src", "modules", "platform", "database", file), "utf8"), {}),
|
|
247
243
|
});
|
|
248
244
|
}
|
|
249
245
|
const databaseClient = path.join(
|