@starci/hfs 4.11.0 → 4.11.11

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 (140) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +43 -1
  3. package/db/drift.mjs +8 -6
  4. package/db/image.mjs +9 -4
  5. package/db/migrate.mjs +3 -3
  6. package/package.json +2 -1
  7. package/runtime/knowledge/hfs/canon-pins.yaml +13 -8
  8. package/runtime/knowledge/hfs/rules.yaml +30 -13
  9. package/runtime/knowledge/hfs/slots.yaml +63 -37
  10. package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
  11. package/runtime/knowledge/patterns/be/queues.yaml +2 -3
  12. package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
  13. package/runtime/modules/kernel/failure-codes.yaml +17 -17
  14. package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
  15. package/runtime/scripts/api/sops/lib.mjs +30 -0
  16. package/runtime/scripts/api/sops/update-keys.mjs +20 -0
  17. package/runtime/scripts/gates/starcistacks.mjs +6 -2
  18. package/runtime/scripts/guards/push-scan.mjs +1 -1
  19. package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
  20. package/runtime/scripts/hfs/db-baseline-verb.mjs +50 -10
  21. package/runtime/scripts/hfs/db-errors.mjs +2 -0
  22. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
  23. package/runtime/scripts/hfs/db-squash.mjs +514 -0
  24. package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
  25. package/runtime/scripts/hfs/example-secrets.mjs +4 -3
  26. package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
  27. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  28. package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
  29. package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
  30. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
  31. package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
  32. package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
  33. package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
  34. package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
  35. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
  36. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  37. package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
  38. package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
  39. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
  40. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
  41. package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
  43. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
  44. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +2 -2
  45. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  46. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
  47. package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
  48. package/runtime/scripts/hfs/sealed-members.mjs +10 -6
  49. package/runtime/scripts/hfs/secret-files.mjs +16 -0
  50. package/runtime/scripts/hfs/secret-init.mjs +120 -21
  51. package/runtime/scripts/hfs/secret-output.mjs +2 -1
  52. package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
  53. package/runtime/scripts/hfs/secret-sops.mjs +108 -9
  54. package/runtime/scripts/hfs/secret-standard.mjs +369 -0
  55. package/runtime/scripts/hfs/secret-store.mjs +23 -9
  56. package/runtime/scripts/hfs/secret.mjs +140 -46
  57. package/runtime/scripts/hfs/slots.mjs +8 -1
  58. package/runtime/scripts/hfs/stack-deploy-config.mjs +31 -10
  59. package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
  60. package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
  61. package/runtime/scripts/lib/sealed-member.mjs +36 -0
  62. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  63. package/runtime/scripts/lib/swarm-stack.mjs +2 -2
  64. package/scaffold/add-inbox-lite.mjs +2 -2
  65. package/scaffold/app.mjs +2 -2
  66. package/src/main.mjs +6 -4
  67. package/stack/age-install.mjs +4 -3
  68. package/stack/build.mjs +1 -1
  69. package/stack/config.mjs +2 -1
  70. package/stack/context.mjs +32 -2
  71. package/stack/docker-transport.mjs +2 -1
  72. package/stack/remote.mjs +8 -5
  73. package/stack/sealed.mjs +9 -8
  74. package/stack/server-key.mjs +296 -0
  75. package/stack/ssh-session.mjs +7 -1
  76. package/stack/target-binding.mjs +2 -1
  77. package/sync/hygiene.mjs +1 -1
  78. package/sync/index.mjs +36 -10
  79. package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
  80. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
  81. package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
  82. package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
  83. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
  84. package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
  85. package/templates/app/db-bootstrap/db/bootstrap.sql +7 -4
  86. package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
  87. package/templates/app/gitignore +2 -1
  88. package/templates/app/gitignore-lite +3 -1
  89. package/templates/app/package-scripts/db.json +1 -1
  90. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -1
  91. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
  92. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  93. package/templates/app/skeleton-lite/README.md +2 -2
  94. package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
  95. package/templates/be/inbox/events.json.tpl +1 -1
  96. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
  97. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
  98. package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
  99. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
  100. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
  101. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
  102. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
  103. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
  104. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
  105. package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
  106. package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
  107. package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
  108. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
  109. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
  110. package/templates/be/inbox/platform/migration.sql.tpl +97 -34
  111. package/templates/be/inbox/service.spec.ts.tpl +1 -1
  112. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  113. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
  114. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
  115. package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +1 -1
  116. package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +1 -1
  117. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
  118. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
  119. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
  120. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
  121. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
  122. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  123. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
  124. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +38 -3
  125. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -4
  126. package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
  127. package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
  128. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
  129. package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
  130. package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
  131. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
  132. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
  133. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +1 -1
  134. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
  135. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
  136. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
  137. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +1 -0
  138. package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
  139. package/templates/app/db-image/db/Dockerfile +0 -8
  140. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
@@ -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.11.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).
@@ -55,6 +55,11 @@ versioning:
55
55
  # 3.11.0 (ruling:lite-official-postgres-image, 2026-10-29) is, like 3.9.0, a change for lite only: it ADDS the managed app.db.image slot
56
56
  # (db/Dockerfile, written by `starci app sync --write`), narrows ruleParams.db.extensions to the extensions that image carries, and gives
57
57
  # @nestjs/schedule and cron one owner, platform/jobs (R335).
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
+ # admits pgmq, the event bus, ruleParams.db.queueOutbox is retired with the pg_cron half of R258, the lite event-bus functions replace
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.
58
63
 
59
64
  # Field meaning, per slot.
60
65
  # id stable name, never changes meaning inside a major.
@@ -129,8 +134,8 @@ testValues: [unit-beside, e2e, none]
129
134
  # event bus, saga, reactors, projection, realtime, webhooks, fenced job), declared in hfs.json the same way, and the same unit
130
135
  # specs (unitRoles, logicRoles, 100 per file, @starci/jest-preset doubles, builders). What differs is ADAPTERS and DELIVERY
131
136
  # (ruling:R212/case-10): schema authority in db/migrations, the queue port on BullMQ over the Redis hfs.json
132
- # sides.be.backing declares (as in full, fed by the Postgres outbox), event-bus ports on pgmq-backed topics or in-process
133
- # delivery, cache and locks on Postgres or the declared Redis, schedules as platform/jobs ticks enqueueing through the queue
137
+ # sides.be.backing declares (as in full, fed by the Postgres outbox), event-bus ports on pgmq topics sent in the publisher's
138
+ # transaction (ruling:lite-pgmq-bus-bullmq-jobs), cache and locks on Postgres or the declared Redis, schedules as platform/jobs ticks enqueueing through the queue
134
139
  # outbox (R335), storage on S3-compatible MinIO (integrations/object-storage) and auth through integrations/passport. Realtime uses SSE or the socket.io gateway
135
140
  # in the api process, which scales horizontally and stateless (memory hub for one replica or tests; the Redis hub and @socket.io/redis-adapter when Redis is declared, else the Postgres hub and @socket.io/postgres-adapter, for N replicas; websocket only, no sticky sessions). Integration and provider sandbox contract specs are optional;
136
141
  # declared patterns owe their integration proofs (R284). Lite has no e2e world. Production uses declared docker-swarm under
@@ -209,11 +214,9 @@ ruleParams:
209
214
  db:
210
215
  # R259: btree_gist supplies UUID equality for the booking interval exclusion constraint.
211
216
  # R259: the extensions the db image carries (ruling:lite-official-postgres-image): the official postgres image's contrib set and pgvector
212
- # from PGDG; unaccent serves Vietnamese-content search functions. No pg_cron, pg_net, pgmq or supabase_vault.
213
- extensions: [pgcrypto, pg_trgm, vector, uuid-ossp, unaccent, btree_gist]
214
- # R258 DB_CRON_OWNED: THE queue outbox of a lite schema (the relay hands its unsent rows to BullMQ, ruling:R212/case-10). A pg_cron
215
- # job enqueues only by inserting one row into this table, writing only these columns, one of which is `queue`.
216
- queueOutbox: {table: public.queue, columns: [queue, payload, created_at, correlation_id]}
217
+ # from PGDG; unaccent serves Vietnamese-content search functions; pgmq is the event bus (ruling:lite-pgmq-bus-bullmq-jobs), installed
218
+ # from its pinned release. No pg_cron, pg_net or supabase_vault.
219
+ extensions: [pgcrypto, pg_trgm, vector, uuid-ossp, unaccent, btree_gist, pgmq]
217
220
  common:
218
221
  # HFS_SIZE_GROWTH: a file above soft may not grow against its parent commit; a new file stays within soft. Slot
219
222
  # budgets (component.tsx 300, hooks 200, modules 400) are stricter than this cap where they apply.
@@ -429,29 +432,36 @@ ruleParams:
429
432
  # logic roles and spec doubles are full's (owner ruling 2026-10-12 (owner:lite-production-swarm), with the Work boundary amended by ruling:R212/case-4). infraOwners: full's owners,
430
433
  # bullmq in platform/queue and ioredis/redis in integrations/cache and integrations/redis (ruling:R212/case-10: the queue is
431
434
  # Redis in both editions; a backing library is legal only when hfs.json sides.be.backing declares its service, which
432
- # HFS_BACKING_UNDECLARED checks), except Kafka, which is owned nowhere (platform/event-bus runs pgmq queues through
435
+ # HFS_BACKING_UNDECLARED checks), except Kafka, which is owned nowhere (platform/event-bus runs pgmq topics through
433
436
  # the shared EntityManager); realtime is the same socket.io gateway as full, run inside the api process, scaled horizontally and stateless (hub redis when
434
437
  # Redis is declared, else hub postgres with @socket.io/postgres-adapter; no sticky sessions).
435
438
  # R334: the database functions code may call through platform/database's callDbFunction, advisoryXactLock and notify helpers,
436
- # each {name, reason}; the canon lists the pg built-ins it uses itself, and an app adds its own in hfs.json sides.be.dbFunctions.
439
+ # each {name, reason}; a pg built-in is listed bare (called in pg_catalog), every other function schema-qualified (public.job_claim),
440
+ # and an app adds its own, schema-qualified, in hfs.json sides.be.dbFunctions.
437
441
  dbFunctions:
438
442
  - {name: pg_advisory_xact_lock, reason: "advisoryXactLock: a transaction-scoped lock TypeORM has no API for"}
439
443
  - {name: hashtextextended, reason: "advisoryXactLock: the 64-bit key of a text lock name"}
440
444
  - {name: pg_notify, reason: "notify: LISTEN/NOTIFY of the Postgres realtime hub"}
441
445
  - {name: to_regproc, reason: "callDbFunction: whether an optional database function exists"}
442
- - {name: job_claim, reason: "platform/jobs (add job, lite): the fenced claim upsert of jobs.sql"}
443
- - {name: job_advance, reason: "platform/jobs (add job, lite): the token-guarded step write of jobs.sql"}
444
- - {name: job_complete, reason: "platform/jobs (add job, lite): the token-guarded completion of jobs.sql"}
445
- - {name: job_fail, reason: "platform/jobs (add job, lite): the token-guarded failure of jobs.sql"}
446
- - {name: job_tick_claim, reason: "platform/jobs (add job, lite): the once-per-minute claim of a scheduler tick, jobs.sql (R335)"}
447
- - {name: queue_write, reason: "platform/queue (add queue, lite): the outbox insert of queue-outbox.sql"}
448
- - {name: queue_waiting, reason: "platform/queue (add queue, lite): the relay read FOR UPDATE SKIP LOCKED of queue-outbox.sql"}
449
- - {name: queue_mark_sent, reason: "platform/queue (add queue, lite): the relay mark of queue-outbox.sql"}
450
- - {name: inbox_claim_event, reason: "platform/inbox (add webhook, lite): the conflict-safe delivery claim"}
451
- - {name: inbox_claim_client, reason: "platform/inbox (add webhook, lite): the client claim upsert returning its committed outcome"}
452
- - {name: inbox_save_client_outcome, reason: "platform/inbox (add webhook, lite): the saved client outcome"}
453
- - {name: inbox_sweep_clients, reason: "platform/inbox (add webhook, lite): the bounded retention sweep FOR UPDATE SKIP LOCKED"}
454
- - {name: event_bus_write, reason: "platform/event-bus (add webhook, lite): the typed envelope insert of the outbox"}
446
+ - {name: gen_random_uuid, reason: "callDbFunction: a random uuid from the database (PG13+ core, pg_catalog) where a token is minted in the transaction"}
447
+ - {name: public.job_claim, reason: "platform/jobs (add job, lite): the fenced claim upsert of jobs.sql"}
448
+ - {name: public.job_advance, reason: "platform/jobs (add job, lite): the token-guarded step write of jobs.sql"}
449
+ - {name: public.job_complete, reason: "platform/jobs (add job, lite): the token-guarded completion of jobs.sql"}
450
+ - {name: public.job_fail, reason: "platform/jobs (add job, lite): the token-guarded failure of jobs.sql"}
451
+ - {name: public.job_tick_claim, reason: "platform/jobs (add job, lite): the once-per-minute claim of a scheduler tick, jobs.sql (R335)"}
452
+ - {name: public.queue_write, reason: "platform/queue (add queue, lite): the outbox insert of queue-outbox.sql"}
453
+ - {name: public.queue_waiting, reason: "platform/queue (add queue, lite): the relay read FOR UPDATE SKIP LOCKED of queue-outbox.sql"}
454
+ - {name: public.queue_mark_sent, reason: "platform/queue (add queue, lite): the relay mark of queue-outbox.sql"}
455
+ - {name: public.inbox_claim_event, reason: "platform/inbox (add webhook, lite): the conflict-safe delivery claim"}
456
+ - {name: public.inbox_claim_client, reason: "platform/inbox (add webhook, lite): the client claim upsert returning its committed outcome"}
457
+ - {name: public.inbox_save_client_outcome, reason: "platform/inbox (add webhook, lite): the saved client outcome"}
458
+ - {name: public.inbox_sweep_clients, reason: "platform/inbox (add webhook, lite): the bounded retention sweep FOR UPDATE SKIP LOCKED"}
459
+ - {name: public.event_bus_send, reason: "platform/event-bus (add webhook, lite): the send of a typed envelope to its pgmq topic in the publisher's transaction"}
460
+ - {name: public.event_bus_read, reason: "platform/event-bus (add webhook, lite): a consumer's pgmq read with a visibility timeout"}
461
+ - {name: public.event_bus_archive, reason: "platform/event-bus (add webhook, lite): the archive of a handled message"}
462
+ - {name: public.event_bus_bury, reason: "platform/event-bus (add webhook, lite): the move of an exhausted or malformed message to its consumer's dead-letter queue"}
463
+ - {name: public.event_bus_dead_letters, reason: "platform/event-bus (add webhook, lite): the operator read of a consumer's dead-letter queue"}
464
+ - {name: public.event_bus_requeue, reason: "platform/event-bus (add webhook, lite): the operator requeue of one dead letter"}
455
465
 
456
466
  lite:
457
467
  schemaAuthority: sql
@@ -668,6 +678,19 @@ slots:
668
678
  managedBy: ci-workflows
669
679
  liteManagedBy: ci-workflows-lite
670
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]
671
694
  - id: app.dockerignore
672
695
  since: 3.1.0
673
696
  profiles: [app]
@@ -870,7 +893,7 @@ slots:
870
893
  # The db service supplies the database; schedules are platform/jobs ticks (R335); production has no Compose or host cron.
871
894
  lite:
872
895
  requires: [application-stacks.yaml]
873
- allows: [application-stacks.yaml, "<env>/README.md", "<env>/environment.json", "<env>/infra/swarm/**", "<env>/infra/metadata.json",
896
+ allows: [application-stacks.yaml, images/db/Dockerfile, "<env>/README.md", "<env>/environment.json", "<env>/infra/swarm/**", "<env>/infra/metadata.json",
874
897
  "<env>/runtime/{config,env}/**", "<env>/runtime/env/KEYS.md", "<env>/secrets/<slug>.enc", "<env>/secrets/.gitkeep", "<env>/seeds/**"]
875
898
  forbids: ["<env>/runtime/files/**", "**/*.enc outside <env>/secrets/", DESIGN.md, deployment.json, "k8s/ at root"]
876
899
  rules: [HFS_STACKS_SHAPE, HFS_STACKS_IN_SIDE, HFS_PLAINTEXT_SECRET, HFS_IDENTITY_CUSTODY]
@@ -931,7 +954,7 @@ slots:
931
954
  rules: [HFS_PLAINTEXT_SECRET]
932
955
 
933
956
  # ----- app root: the database of a lite app (ruling:lite-self-hosted-postgres-passport-minio): a self-hosted PostgreSQL in the app's
934
- # stack (the `db` service, built from the managed db/Dockerfile: the official postgres image plus pgvector, ruling:lite-official-postgres-image), migrated by
957
+ # stack (the `db` service, built from the managed .starcistacks/images/db/Dockerfile: the official postgres image plus pgvector and pgmq, ruling:lite-db-image-in-stacks), migrated by
935
958
  # `starci app db migrate --url-env <NAME>`. A full app keeps its TypeORM migrations under be/ and has no such folder.
936
959
  - id: app.db
937
960
  profiles: [app]
@@ -984,10 +1007,11 @@ slots:
984
1007
  - id: app.db.image
985
1008
  profiles: [app]
986
1009
  editions: [lite]
987
- # The managed image of the db service (templates/app/db-image, ruling:lite-official-postgres-image): FROM the digest-pinned official
988
- # postgres image plus postgresql-17-pgvector from PGDG. The stack's db service, the local db container and the scratch database
989
- # of `starci app db drift` all build it.
990
- path: "db/Dockerfile"
1010
+ # The managed image of the db service (templates/app/db-image, rulings lite-official-postgres-image and lite-db-image-in-stacks): FROM
1011
+ # the digest-pinned official postgres image plus postgresql-17-pgvector from PGDG and the sha256-verified pgmq release. It lives with
1012
+ # the stacks; the stack's db service, the local db container, the scratch database of `starci app db drift` and the squash replay
1013
+ # all build it.
1014
+ path: ".starcistacks/images/db/Dockerfile"
991
1015
  presence: required
992
1016
  tracked: tracked
993
1017
  managedBy: db-image
@@ -998,9 +1022,11 @@ slots:
998
1022
  - id: app.db.bootstrap
999
1023
  profiles: [app]
1000
1024
  editions: [lite]
1001
- # The managed pre-migration bootstrap (templates/app/db-bootstrap): the auth and storage schemas, auth.users with the GoTrue column
1002
- # set, auth.uid/role/jwt stubs and the NOLOGIN browser roles, so migrations written for a hosted Supabase apply to the db service's
1003
- # official postgres image (nothing else of Supabase: no supabase_admin, extensions schema or realtime publication). `starci app db migrate` applies it before the first migration of an empty database.
1025
+ # The managed pre-migration bootstrap (templates/app/db-bootstrap): the extensions schema every allowed extension is created in, the
1026
+ # auth and storage schemas, auth.users with the GoTrue column set, auth.uid/role/jwt stubs and the NOLOGIN browser roles, so migrations
1027
+ # written for a hosted Supabase apply to the db service's official postgres image (nothing else of Supabase: no supabase_admin or
1028
+ # realtime publication). `starci app db migrate` applies it before the first migration of an empty database; sync renders all of it
1029
+ # but the one marked section `starci app db baseline --squash` writes (the source's app roles and the grants a per-database dump loses).
1004
1030
  path: "db/bootstrap.sql"
1005
1031
  presence: required
1006
1032
  tracked: tracked
@@ -1785,7 +1811,7 @@ slots:
1785
1811
  - id: be.platform.event-bus
1786
1812
  since: 3.1.0
1787
1813
  profiles: [be]
1788
- editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite implements the event ports with outbox and pgmq; full uses its Kafka adapter."
1814
+ editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite implements the event ports on pgmq (ruling:lite-pgmq-bus-bullmq-jobs); full uses its Kafka adapter."
1789
1815
  path: "src/modules/platform/event-bus/"
1790
1816
  presence: opt-in
1791
1817
  pattern: event-bus # the ONE durable event transport: transactional outbox, relay, Kafka, inbox, retry and dead letter (knowledge/patterns/be/event-bus.yaml)
@@ -1794,10 +1820,10 @@ slots:
1794
1820
  owner: true
1795
1821
  lite:
1796
1822
  why: >-
1797
- the same EventPublisher and EventConsumer ports on pgmq queues: the outbox relay sends each committed row to the
1798
- pgmq queue of every consumer group of its topic through the shared EntityManager, a consumer reads with a visibility
1799
- timeout, retries by letting it lapse and archives the message on dead letter (read_ct over the budget); a single api may
1800
- deliver in process. No Kafka (knowledge/patterns/be/event-bus.yaml liteAdapter)
1823
+ the EventBus and EventConsumer ports on pgmq (ruling:lite-pgmq-bus-bullmq-jobs): publish sends the envelope to its
1824
+ topic in the publisher's transaction, so no outbox or relay runs; each consumer group reads its own queue bound to the
1825
+ event's name with a visibility timeout, archives a handled message, lets a failed one reappear and buries one past its
1826
+ maxDeliveries in its __dead queue (BE-EVENT-BUS-6). Jobs stay on BullMQ (platform/queue). No Kafka (knowledge/patterns/be/event-bus.yaml liteAdapter)
1801
1827
  requires: [index.ts, event-bus.module.ts, event-bus.port.ts, event-bus.contracts.ts, event-bus.service.ts, event-bus.service.spec.ts]
1802
1828
  tests: unit-beside
1803
1829
  coverage: required
@@ -288,19 +288,21 @@ files:
288
288
  role: "The consumed event's e2e flow; titles event-bus/<scenario>: ... prove the pattern."
289
289
  spec: event-bus/atomic-with-transaction, event-bus/relay-only-after-commit, event-bus/redelivery-then-dead-letter, event-bus/duplicate-delivery-is-a-noop
290
290
  rules: [BE_PATTERN_SPEC_MISSING]
291
- # Edition lite (R212): the same ports and rules, a self-hosted Postgres adapter behind them. ruling:R212/case-10 moved the lite queue to Redis
292
- # because full's queue is Redis; full's event bus is Kafka, so the lite event-bus adapter below is unchanged by that ruling.
291
+ # Edition lite (R212): the same ports and rules, a self-hosted Postgres adapter behind them. ruling:lite-pgmq-bus-bullmq-jobs makes pgmq the
292
+ # lite event bus and keeps jobs on BullMQ (platform/queue).
293
293
  liteAdapter:
294
294
  summary: >-
295
- Under lite the event bus keeps typed events, the vendored contract, publish(event, tx, correlationId) in the domain transaction, the outbox,
296
- the inbox and thin consumers; Kafka is replaced by pgmq-backed topics, or by in-process delivery after commit for a back end
297
- with a single api app. Each consumer group of a topic has its own pgmq queue `<service>__<event>__<group>`, created by a
298
- db migration, named by a literal `<GROUP>_TOPIC_QUEUE` constant under platform/event-bus (its owner, R258) and
299
- granted only to the application role, so each group keeps its own cursor and dead letter.
295
+ Under lite the event bus keeps typed events, the vendored contract, publish(event, tx, correlationId) in the domain transaction,
296
+ the inbox and thin consumers; Kafka is replaced by pgmq topics in the db service. Publish sends the envelope to its topic
297
+ (the event name) in the publisher's transaction, so no outbox or relay stands between them. Each consumer of an event has its
298
+ own pgmq queue `<topic>__<consumer>` and dead-letter queue `<topic>__<consumer>__dead`, created and bound to the event's name
299
+ by a db migration and named by a literal `<TOPIC>_<CONSUMER>_TOPIC_QUEUE` constant under platform/event-bus (its owner, R258);
300
+ every payload carries a business dedupeKey the consumer inbox claims, and subscriptions are static (R336); the application
301
+ role reaches pgmq only through the security-definer event_bus_* functions of the migration (contexts/code/be/event-bus.md BE-EVENT-BUS-6).
300
302
  ports:
301
- - {port: EventPublisher (platform/event-bus), adapter: the same outbox row in the domain transaction}
302
- - {port: relay, adapter: "fans each committed event out with pgmq.send to one queue per consumer group of its topic, marking the row in the same transaction"}
303
- - {port: EventConsumer (platform/event-bus), adapter: "pgmq.read with a visibility timeout, the inbox claim on the event id, pgmq.delete on success, pgmq.archive as the dead letter"}
303
+ - {port: EventBus.publish (platform/event-bus), adapter: "event_bus_send: pgmq.send_topic of the envelope in the domain transaction"}
304
+ - {port: EventConsumer (platform/event-bus), adapter: "event_bus_read with a visibility timeout, the inbox claim on (consumer, dedupeKey) in the dispatched command, event_bus_archive on success, a failed delivery left to reappear, event_bus_bury into <queue>__dead past maxDeliveries (default 5)"}
305
+ - {port: EventBus.deadLetters and requeue (platform/event-bus), adapter: "event_bus_dead_letters and event_bus_requeue over a consumer's dead-letter queue"}
304
306
  forbids: [kafkajs]
305
307
  proof: >-
306
308
  Unit specs as full; declaring event-bus requires integration specs titled `event-bus/<scenario>: ...` for every declared
@@ -98,12 +98,11 @@ rules:
98
98
  write: 'order-expiry.queue.ts declares scheduler { every: 5 minutes } for the expire-orders job; the processor dispatches ExpireOrdersCommand.'
99
99
  - id: case-2
100
100
  when: A lite app sends booking reminders every fifteen minutes
101
- write: "A migration declares private.booking_reminder_tick() as one insert into public.queue (queue, payload, created_at) values ('booking_reminder', jsonb_build_object('tick', true), now()) and schedules it with cron.schedule('booking_reminder_tick', '*/15 * * * *', ...); the send-reminder processor is the queue's."
101
+ write: "platform/jobs declares a booking_reminder tick every fifteen minutes (BE-JOBS-6): each firing claims its slot and writes one booking_reminder row through the queue outbox; the send-reminder processor is the queue's."
102
102
  verification:
103
103
  automated:
104
104
  - BE_INFRA_OWNER
105
105
  - DB_PGMQ_DECLARED
106
- - DB_CRON_OWNED
107
106
  manual:
108
107
  - Confirm the cadence and the job's idempotence.
109
108
  relatedExamples: [queue]
@@ -277,7 +276,7 @@ liteAdapter:
277
276
  its append-only file (WAITAOF) and still holds it, so a job Redis loses inside the at most one second fsync window leaves
278
277
  its row waiting and is added again; that is why a lite queue does not lose a job. Without a declared Redis there is no queue
279
278
  pattern: the work is a direct call inside the request, or a declared replicated-job running a cli command. pgmq
280
- is not a queue transport. `starci app add queue|job` writes this adapter in a lite app, with the outbox and job
279
+ is the event bus, not a queue transport (ruling:lite-pgmq-bus-bullmq-jobs). `starci app add queue|job` writes this adapter in a lite app, with the outbox and job
281
280
  tables as one db migration, the Redis declaration and the persisted Redis of every docker-swarm environment.
282
281
  ports:
283
282
  - {port: QueueOutbox (platform/queue), adapter: "the outbox row written through the caller's tx by queue_write, and the relay's queue_waiting and queue_mark_sent, functions of the queue migration in db/migrations called through callDbFunction of platform/database (R334)"}
@@ -158,7 +158,7 @@ files:
158
158
  liteAdapter:
159
159
  summary: >-
160
160
  Under lite a reactor is unchanged: a thin consumer of another context's events that dispatches one command. Its deliveries
161
- come from the lite event bus (its own pgmq queue per consumer group).
161
+ come from the lite event bus (its own pgmq queue per consumer group, bound to the event's topic).
162
162
  ports:
163
163
  - {port: EventConsumer delivery, adapter: "the reactor's pgmq queue (see event-bus.yaml liteAdapter)"}
164
164
  proof: >-
@@ -1827,17 +1827,6 @@ DB_BASELINE_SHAPE:
1827
1827
 
1828
1828
 
1829
1829
 
1830
- DB_CRON_OWNED:
1831
- title: "A uniquely owned migration cron job only enqueues through the queue outbox"
1832
- title_vi: "Lịch pg_cron thiếu chủ sở hữu hoặc thực hiện nghiệp vụ"
1833
- meaning_vi: "Tên lịch bị trùng, hằng hàng đợi hoặc hàm tick không tồn tại, lệnh thực hiện DML nghiệp vụ hay HTTP thay vì chỉ chèn một dòng của hằng hàng đợi vào bảng outbox của ruleParams.db.queueOutbox (chỉ các cột đã khai báo), lịch đang chạy vẫn gọi pgmq.send, hoặc cùng tick còn được lập lịch ở ứng dụng."
1834
- causes_vi:
1835
- - "Vi phạm R258: pg_cron chỉ đưa việc vào hàng đợi qua outbox, mỗi tick có đúng một nơi lập lịch và được hủy lịch khi loại bỏ."
1836
- - "Lịch đang chạy (hoặc tick hiện tại của nó) vẫn gửi bằng pgmq.send; pgmq.send chỉ hợp lệ trong thân gốc của lịch cũ."
1837
- nextStep_vi: "Đặt lịch trong migration với tên duy nhất, chèn đúng một dòng của hằng hàng đợi vào outbox hàng đợi (trực tiếp hoặc qua một hàm *_tick chỉ làm việc đó), bỏ lịch trùng và gọi cron.unschedule khi xóa tick."
1838
- owner: op-retry
1839
- kind: check-finding
1840
-
1841
1830
  DB_DEFINER_REVOKE:
1842
1831
  title: "Exposed security definer functions revoke browser execution"
1843
1832
  title_vi: "Function security definer chưa thu hồi quyền trình duyệt"
@@ -1909,14 +1898,14 @@ DB_MIGRATION_SHAPE:
1909
1898
  kind: check-finding
1910
1899
 
1911
1900
  DB_PGMQ_DECLARED:
1912
- title: "A queue constant keeps a pgmq queue, a pgmq queue has no event-bus owner, or a pgmq object is declared or granted unsafely"
1913
- title_vi: "Hằng hàng đợi còn hàng đợi pgmq, hàng đợi pgmq không có chủ event-bus, hoặc đối tượng pgmq khai báo hay cấp quyền sai"
1914
- meaning_vi: "Hàng đợi là BullMQ trên Redis (ruling:R212/case-10), nhưng các migration đang hoạt động vẫn để lại một hàng đợi pgmq trùng tên một hằng hàng đợi trong modules/queues, hoặc một hàng đợi pgmq không được hằng literal *_TOPIC_QUEUE nào trong platform/event-bus đặt tên (hay một hằng như vậy không có hàng đợi pgmq); hoặc pgmq.create không có tên literal trong migration, tên hằng không phải literal, hay quyền trên đối tượng pgmq vượt vai trò ứng dụng."
1901
+ title: "A queue constant keeps a pgmq queue, a pgmq queue has no event-bus owner, or a pgmq queue, binding or grant is declared unsafely"
1902
+ title_vi: "Hằng hàng đợi còn hàng đợi pgmq, hàng đợi pgmq không có chủ event-bus, hoặc hàng đợi, liên kết topic hay quyền pgmq khai báo sai"
1903
+ meaning_vi: "pgmq là event bus và job chạy trên BullMQ (ruling:lite-pgmq-bus-bullmq-jobs), nhưng các migration đang hoạt động vẫn để lại một hàng đợi pgmq trùng tên một hằng hàng đợi trong modules/queues, hoặc một hàng đợi pgmq không được hằng literal *_TOPIC_QUEUE nào trong platform/event-bus đặt tên (hay một hằng như vậy không có hàng đợi pgmq); hoặc pgmq.create hay pgmq.bind_topic không có tên literal trong migration, liên kết một hàng đợi chưa được tạo, tên hằng không phải literal, hay quyền trên đối tượng pgmq vượt vai trò ứng dụng."
1915
1904
  causes_vi:
1916
- - "Vi phạm R258: hàng đợi của app vẫn chạy trên Supabase Queues (pgmq) thay vì BullMQ trên Redis."
1917
- - "Một hàng đợi pgmq bị bỏ lại hoặc đổi tên mà không có chủ event-bus."
1905
+ - "Vi phạm R258: một job của app chạy trên pgmq thay vì BullMQ trên Redis."
1906
+ - "Một hàng đợi pgmq bị bỏ lại hoặc đổi tên mà không có chủ event-bus, hoặc một liên kết topic trỏ tới hàng đợi chưa tạo."
1918
1907
  - "Migration, mã nguồn hoặc quyền truy cập khai báo đối tượng pgmq ngoài migration tĩnh hoặc cho vai trò khác vai trò ứng dụng."
1919
- nextStep_vi: "Chuyển hàng đợi sang BullMQ trên Redis đã khai báo, rồi xóa hàng đợi pgmq bằng pgmq.drop_queue trong một migration mới; hàng đợi topic của event bus được đặt tên bằng một hằng literal *_TOPIC_QUEUE trong platform/event-bus; chỉ cấp quyền trên q_<queue>, a_<queue> cho vai trò ứng dụng."
1908
+ nextStep_vi: "Chuyển job sang BullMQ trên Redis đã khai báo, rồi xóa hàng đợi pgmq bằng pgmq.drop_queue trong một migration mới; hàng đợi của một nhóm consumer được đặt tên bằng một hằng literal *_TOPIC_QUEUE trong platform/event-bus, tạo bằng pgmq.create rồi liên kết với tên event bằng pgmq.bind_topic trong cùng migration; vai trò ứng dụng đi qua các hàm event_bus_* thay vì nhận quyền trên schema pgmq."
1920
1909
  owner: op-retry
1921
1910
  kind: check-finding
1922
1911
 
@@ -5601,6 +5590,17 @@ BE_PERSISTENCE_NO_RAW_SQL:
5601
5590
  owner: op-retry
5602
5591
  kind: check-finding
5603
5592
 
5593
+ BE_EVENT_BUS_LITE_SHAPE:
5594
+ title: "A lite event payload without dedupeKey, or a subscription that is not static"
5595
+ title_vi: "Payload event của bản lite thiếu dedupeKey, hoặc đăng ký consumer không tĩnh"
5596
+ meaning_vi: "File `<path>` khai một event (lớp kế thừa BaseEvent trong modules/events) mà kiểu payload không có `dedupeKey`, hoặc gọi `subscribe` với tên hàng đợi không phải hằng `*_TOPIC_QUEUE` import từ `@modules/platform/event-bus`, hay với options không phải object literal của giá trị literal."
5597
+ causes_vi:
5598
+ - "Vi phạm R336: event bus pgmq giao ít nhất một lần và không theo thứ tự, nên inbox của consumer claim (consumer, dedupeKey); thiếu dedupeKey thì không khử trùng được."
5599
+ - "Một đăng ký tính tên hàng đợi lúc chạy, nên không ai thấy được nó trong hằng và migration (R258)."
5600
+ nextStep_vi: "Thêm `readonly dedupeKey: string` (khoá nghiệp vụ, như id đơn hàng) vào kiểu payload hoặc kế thừa `EventPayload`; khai hằng `<TOPIC>_<CONSUMER>_TOPIC_QUEUE` trong platform/event-bus, tạo hàng đợi, hàng đợi `__dead` và bind_topic trong migration, rồi subscribe bằng hằng đó."
5601
+ owner: op-retry
5602
+ kind: check-finding
5603
+
5604
5604
  BE_JOBS_SCHEDULER_OWNER:
5605
5605
  title: "A schedule outside the platform/jobs tick scheduler"
5606
5606
  title_vi: "Lịch chạy định kỳ nằm ngoài bộ lập lịch tick của platform/jobs"
@@ -85,7 +85,7 @@ properties:
85
85
  command: {type: string, minLength: 1}
86
86
  backing:
87
87
  description: >-
88
- The self-hosted database of a lite app: its db component (the image its managed db/Dockerfile builds), a Docker container in
88
+ The self-hosted database of a lite app: its db component (the image its managed .starcistacks/images/db/Dockerfile builds), a Docker container in
89
89
  development and a Swarm service in production. Every other dependency is a Docker component too.
90
90
  type: object
91
91
  additionalProperties:
@@ -855,5 +855,7 @@ $defs:
855
855
  - {type: integer, minimum: 1}
856
856
  - {type: string, pattern: '^[1-9][0-9]*$'}
857
857
  where: {type: string, minLength: 1}
858
- recipientPolicy: {type: string, minLength: 1, description: "Which age recipient the envelope is encrypted to: the one shared master recipient every project uses."}
858
+ member: {type: string, pattern: '^[a-z0-9][a-z0-9-]*$', description: "The provider member holding this key (`secrets/<member>`, the slug of where); with key, the entry declares one key of it."}
859
+ key: {type: string, pattern: '^[A-Z][A-Z0-9_]*$', description: "The one key of the member this entry declares; materialized as its own file, read by the app as <KEY>_FILE."}
860
+ recipientPolicy: {type: string, minLength: 1, description: "Which age recipients the envelope is encrypted to: its environment's rule of the app's managed .sops.yaml."}
859
861
  keyCustody: {type: string, minLength: 1, description: "Where the shared decrypting identity is held; docs/host-secrets.md owns host custody and its execution prerequisites."}
@@ -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
+ }
@@ -329,7 +329,10 @@ const checkTrackedCustody = ({ repo, root, add }) => {
329
329
  // infra/** is tracked source, but a value file inside it (.env*, *.env, *.key, *.pem, *.tfvars)
330
330
  // is plaintext custody (inc-5360513a96b3: infra/compose/.env.generated was trackable).
331
331
  const infraValue = parts.length > 3 && parts[2] === 'infra' && isInfrastructureValueFile(base) && !base.endsWith('.enc') && !base.endsWith('.example');
332
- if ((custodyArea && !base.endsWith('.enc') && !['KEYS.md', '.gitkeep'].includes(base)) || infraValue)
332
+ // An app's sealed member is `<env>/secrets/<member>.enc.yaml` (ruling app-secrets-one-standard, scripts/lib/sealed-member.mjs);
333
+ // runtime custody keeps `<name>.<fmt>.enc`.
334
+ const appMember = parts.length === 4 && parts[2] === 'secrets' && base.endsWith('.enc.yaml');
335
+ if ((custodyArea && !base.endsWith('.enc') && !appMember && !['KEYS.md', '.gitkeep'].includes(base)) || infraValue)
333
336
  add('refuse', 'STACKS_PLAINTEXT_TRACKED', file, /\.enc\.(?:ya?ml|json|env)$/u.test(base)
334
337
  ? `${base} is named the wrong way round: a custody file is <name>.<fmt>.enc (rename it to ${base.replace(/\.enc\.(ya?ml|json|env)$/u, '.$1.enc')}) and every read states the format (scripts/api/sops/exec-env.mjs)`
335
338
  : 'a custody member is tracked in plaintext; untrack it (git rm --cached), rotate the value and commit only its .enc twin');
@@ -363,7 +366,8 @@ const checkIgnoreRules = ({ repo, root, add }) => {
363
366
  }
364
367
  };
365
368
  // Custody layout (modules/schemas/stacks-layout.yaml custody, the product repositories' secrets-guard).
366
- const envDirs = (repo, root) => fs.readdirSync(path.join(repo, root), { withFileTypes: true }).filter((entry) => entry.isDirectory());
369
+ // The images folder holds the managed db image of a lite app (slot app.db.image), never custody or an environment.
370
+ const envDirs = (repo, root) => fs.readdirSync(path.join(repo, root), { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name !== 'images');
367
371
  const checkCustodyLayout = ({ repo, own, add }) => {
368
372
  if (!own.rooted) return;
369
373
  const root = STACK_ROOT;
@@ -54,7 +54,7 @@ function diffScanner(files = [], { encText = null, keepHit = null } = {}) {
54
54
  if (raw.startsWith('+++ ')) {
55
55
  flushEnc();
56
56
  file = raw.slice(4).replace(/^b\//, '');
57
- if (/\.enc$/i.test(file)) enc = { file, line: null, added: [], findings: [] };
57
+ if (/\.enc(?:\.yaml)?$/i.test(file)) enc = { file, line: null, added: [], findings: [] };
58
58
  return;
59
59
  }
60
60
  const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { isSealedName, memberOfName } from '../lib/sealed-member.mjs';
2
3
  import { list } from '../lib/list.mjs';
3
4
  import { parseYaml } from '../../engine/yaml.mjs';
4
5
 
@@ -70,7 +71,7 @@ function entries(dir, fs) {
70
71
  export function sealedKeyNames(root, { fs }) {
71
72
  const out = [], base = path.join(root, '.starcistacks');
72
73
  for (const env of entries(base, fs).filter((entry) => entry.isDirectory())) {
73
- for (const member of entries(path.join(base, env.name, 'secrets'), fs).filter((entry) => entry.isFile() && entry.name.endsWith('.enc'))) {
74
+ for (const member of entries(path.join(base, env.name, 'secrets'), fs).filter((entry) => entry.isFile() && isSealedName(entry.name))) {
74
75
  const file = `.starcistacks/${env.name}/secrets/${member.name}`;
75
76
  const source = fs.readFileSync(path.join(root, file), 'utf8');
76
77
  const dotenv = [...source.matchAll(/^([A-Za-z_]\w*)=ENC\[/gm)].map((match) => match[1]).filter((key) => !key.startsWith('sops_'));
@@ -79,7 +80,7 @@ export function sealedKeyNames(root, { fs }) {
79
80
  const doc = parseYaml(source);
80
81
  keys = doc && typeof doc === 'object' && !Array.isArray(doc) ? Object.keys(doc).filter((key) => key !== 'sops' && !key.startsWith('sops_')) : [];
81
82
  }
82
- for (const key of keys) out.push({ env: env.name, member: member.name.slice(0, -4), key, file });
83
+ for (const key of keys) out.push({ env: env.name, member: memberOfName(member.name), key, file });
83
84
  }
84
85
  }
85
86
  return out;