@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +6 -3
  3. package/package.json +1 -1
  4. package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
  5. package/runtime/knowledge/hfs/rules.yaml +2 -2
  6. package/runtime/knowledge/hfs/slots.yaml +16 -1
  7. package/runtime/modules/kernel/failure-codes.yaml +5 -5
  8. package/runtime/scripts/api/sops/lib.mjs +30 -0
  9. package/runtime/scripts/api/sops/update-keys.mjs +20 -0
  10. package/runtime/scripts/hfs/path-findings.mjs +4 -2
  11. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
  12. package/runtime/scripts/hfs/secret-sops.mjs +15 -0
  13. package/runtime/scripts/hfs/secret-standard.mjs +54 -0
  14. package/runtime/scripts/hfs/secret.mjs +36 -6
  15. package/runtime/scripts/hfs/stack-deploy-config.mjs +11 -4
  16. package/scaffold/add-inbox-lite.mjs +6 -10
  17. package/scaffold/add-queue-lite.mjs +7 -2
  18. package/scaffold/add-table.mjs +5 -0
  19. package/scaffold/app.mjs +15 -3
  20. package/scaffold/lite-exports.mjs +23 -0
  21. package/src/main.mjs +1 -0
  22. package/stack/age-install.mjs +4 -3
  23. package/stack/context.mjs +32 -2
  24. package/stack/remote.mjs +4 -2
  25. package/stack/server-key.mjs +296 -0
  26. package/stack/ssh-session.mjs +7 -1
  27. package/sync/index.mjs +3 -0
  28. package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
  29. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
  30. package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
  31. package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
  32. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
  33. package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
  34. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +3 -3
  35. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +1 -2
  36. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
  37. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
  38. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +1 -1
  39. package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -1
  40. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +4 -1
  41. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +0 -2
  42. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -0
  43. package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/index.ts +4 -0
  44. package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/rate-window.entity.ts +17 -0
  45. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +260 -0
  46. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +133 -0
  47. 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`). No output carries a
238
- value except `get --reveal`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.11.8",
3
+ "version": "4.11.12",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.20
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.7
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.9
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.20
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.20
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.8
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. They 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."
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 feature code reads and writes through TypeORM entities on the injected EntityManager (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 platform/database; a database function TypeORM cannot express is called 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."
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.13.0
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à ngoài danh sách hàm ruleParams.be.lite.rawSqlFunctions."
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: "Khai entity ở persistence/entities/<table>.entity.ts và đọc/ghi bằng EntityManager hoặc createQueryBuilder với tham số có tên; hàm pg/SECURITY DEFINER không diễn đạt được thì khai vào rawSqlFunctions kèm lý do."
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 migration of
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 split");
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
- if (block === undefined) return { host: 'vps', releasesBase: null, sealed: null };
56
- if (!closedFields(c, 'deploy.remote', block, FIELDS.remote)) return { host: 'vps', releasesBase: null, sealed: null };
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
- const databaseSql = "be/src/modules/platform/database/database.sql.ts";
237
- if (!fs.existsSync(path.join(root, ...databaseSql.split("/")))) {
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: databaseSql,
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(