@starci/hfs 4.9.9 → 4.10.0

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 (241) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +20 -8
  3. package/db/backup-receipt.mjs +10 -47
  4. package/db/drift.mjs +289 -0
  5. package/db/dump.mjs +12 -11
  6. package/db/entity-list.mjs +9 -0
  7. package/db/migrate.mjs +84 -260
  8. package/db/new.mjs +46 -0
  9. package/db/post-cutover.mjs +13 -17
  10. package/db/postgres.mjs +80 -0
  11. package/emit/contracts.mjs +5 -9
  12. package/lint/run.mjs +12 -2
  13. package/package.json +5 -3
  14. package/release/gate.mjs +82 -31
  15. package/release/index.mjs +19 -8
  16. package/release/push.mjs +4 -4
  17. package/release/records.mjs +54 -0
  18. package/release/urgent.mjs +129 -0
  19. package/runtime/knowledge/hfs/canon-pins.yaml +54 -24
  20. package/runtime/knowledge/hfs/design-truths.yaml +1 -1
  21. package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
  22. package/runtime/knowledge/hfs/rules.yaml +250 -157
  23. package/runtime/knowledge/hfs/slots.yaml +199 -174
  24. package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
  25. package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
  26. package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
  27. package/runtime/knowledge/patterns/be/projections.yaml +7 -5
  28. package/runtime/knowledge/patterns/be/queues.yaml +8 -8
  29. package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
  30. package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
  31. package/runtime/knowledge/patterns/be/saga.yaml +3 -3
  32. package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
  33. package/runtime/modules/kernel/command-policy.yaml +1 -1
  34. package/runtime/modules/kernel/failure-codes.yaml +86 -115
  35. package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
  36. package/runtime/scripts/hfs/app-wiring.mjs +23 -2
  37. package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
  38. package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
  39. package/runtime/scripts/hfs/architecture/index.mjs +2 -3
  40. package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
  41. package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
  42. package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
  43. package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
  44. package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
  45. package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
  46. package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
  47. package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
  48. package/runtime/scripts/hfs/check.mjs +8 -8
  49. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  50. package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
  51. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
  52. package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
  53. package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
  54. package/runtime/scripts/hfs/manifest-shape.mjs +6 -5
  55. package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
  56. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  57. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  58. package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
  59. package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
  60. package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
  61. package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
  62. package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
  63. package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
  64. package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
  65. package/runtime/scripts/hfs/rules/database.mjs +36 -39
  66. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  67. package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
  68. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
  69. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
  70. package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
  71. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  72. package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
  73. package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
  74. package/runtime/scripts/hfs/slots.mjs +8 -2
  75. package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
  76. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  77. package/runtime/scripts/lib/swarm-stack.mjs +7 -4
  78. package/scaffold/add-app.mjs +2 -10
  79. package/scaffold/add-cli-lite.mjs +6 -6
  80. package/scaffold/add-compose.mjs +1 -1
  81. package/scaffold/add-inbox-lite.mjs +4 -8
  82. package/scaffold/add-plan.mjs +1 -1
  83. package/scaffold/add-queue-lite.mjs +16 -24
  84. package/scaffold/add-table.mjs +34 -26
  85. package/scaffold/add.mjs +2 -2
  86. package/scaffold/app.mjs +19 -86
  87. package/scaffold/lite-exports.mjs +1 -1
  88. package/src/main.mjs +24 -22
  89. package/stack/cli.mjs +10 -2
  90. package/stack/deploy.mjs +21 -4
  91. package/stack/target-binding.mjs +0 -60
  92. package/stack/urgent-floor.mjs +147 -0
  93. package/sync/ci-commands.mjs +3 -1
  94. package/sync/index.mjs +32 -8
  95. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
  96. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
  97. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
  98. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
  99. package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
  100. package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
  101. package/templates/app/gitignore-lite +0 -3
  102. package/templates/app/package-scripts/db.json +9 -0
  103. package/templates/app/package-scripts/package.json +0 -1
  104. package/templates/app/package-scripts-lite/package.json +1 -1
  105. package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
  106. package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
  107. package/templates/app/skeleton/README.md +1 -1
  108. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
  109. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
  110. package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
  111. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
  112. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
  113. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
  114. package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
  115. package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
  116. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
  117. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
  118. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
  119. package/templates/app/skeleton-lite/README.md +11 -11
  120. package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
  121. package/templates/app/tool-config-lite/prettierignore +0 -1
  122. package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
  123. package/templates/be/inbox/database-client.ts.tpl +13 -4
  124. package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
  125. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
  126. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
  127. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
  128. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
  129. package/templates/be/inbox/platform/migration.sql.tpl +22 -0
  130. package/templates/be/inbox/service.spec.ts.tpl +2 -2
  131. package/templates/be/inbox/service.ts.tpl +1 -2
  132. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
  133. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
  134. package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
  135. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
  136. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
  137. package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
  138. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
  139. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
  140. package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
  141. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
  142. package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
  143. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
  144. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
  145. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  146. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
  147. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
  148. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -1
  149. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
  150. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
  151. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +80 -4
  152. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +39 -11
  153. package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
  154. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
  155. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
  156. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
  157. package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
  158. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
  159. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
  160. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
  161. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
  162. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
  163. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
  164. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
  165. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +23 -13
  166. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
  167. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
  168. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
  169. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
  170. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
  171. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
  172. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
  173. package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
  174. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
  175. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
  176. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
  177. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
  178. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
  179. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
  180. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
  181. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
  182. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
  183. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
  184. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
  185. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
  186. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
  187. package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
  188. package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
  189. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +6 -0
  190. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  191. package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
  192. package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
  193. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
  194. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
  195. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
  196. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
  197. package/templates/be/table/entities.index.ts.tpl +4 -0
  198. package/templates/be/table/entity.ts.tpl +21 -0
  199. package/templates/be/table/index.ts.tpl +1 -0
  200. package/templates/be/table/module.ts.tpl +1 -1
  201. package/templates/be/table/service.api.ts.tpl +10 -11
  202. package/templates/be/table/service.ts.tpl +5 -6
  203. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
  204. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
  205. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
  206. package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
  207. package/db/local-migrate.mjs +0 -194
  208. package/db/reset.mjs +0 -125
  209. package/emit/db-types.mjs +0 -271
  210. package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
  211. package/runtime/scripts/api/supabase/database-push.mjs +0 -5
  212. package/runtime/scripts/api/supabase/database-query.mjs +0 -5
  213. package/runtime/scripts/api/supabase/lib.mjs +0 -55
  214. package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
  215. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
  216. package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
  217. package/scaffold/supabase-ports.mjs +0 -26
  218. package/templates/app/package-scripts/supabase.json +0 -7
  219. package/templates/app/skeleton-lite/supabase/config.toml +0 -48
  220. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
  221. package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
  222. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
  223. package/templates/be/inbox/sql.ts.tpl +0 -16
  224. package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
  225. package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
  226. package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
  227. package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
  228. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
  229. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
  230. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
  231. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
  232. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
  233. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
  234. package/templates/be/table/rows.ts.tpl +0 -4
  235. package/templates/be/table/sql.api.ts.tpl +0 -7
  236. package/templates/be/table/sql.ts.tpl +0 -4
  237. package/templates/fe/auth/supabase-client.ts.tpl +0 -38
  238. package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
  239. package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
  240. package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
  241. /package/templates/app/skeleton-lite/{supabase → db}/seed.sql +0 -0
package/src/main.mjs CHANGED
@@ -49,7 +49,7 @@
49
49
  // starci app ci secret set <NAME> [--env E | --dependabot] [--repo owner/name] reads the value from stdin only.
50
50
  // starci app ci var set <NAME> [--env E] [--value v] | var sync [--execute] | verify [--json]
51
51
  // declared GitHub CI custody; verify compares names, scopes and public values.
52
- // starci app db baseline | restamp --map <restamp.yaml> | plan
52
+ // starci app db new <name> | baseline | restamp --map <restamp.yaml> | plan | migrate | post-cutover | dump
53
53
  // preview the database cutover or compare its state with the repo; baseline and restamp mutate only with --apply.
54
54
  // starci app add <api|webhook|realtime|saga|job|reactor|queue|projection> <name> [--event <event> --from <service> --service <Class>=<module>] [--owner <service> --failed <event> --done <event>] [--connection <name>] [--cwd <dir>]
55
55
  // exactly that kind's file tree, generated FROM the files: tree of its pattern topic (knowledge/patterns/be) with the
@@ -79,7 +79,6 @@ import { main as syncMain } from '../sync/cli.mjs';
79
79
  import { SyncError, bundledPresets } from '../sync/index.mjs';
80
80
  import { managedFindings } from '../sync/managed.mjs';
81
81
  import { emitContracts } from '../emit/contracts.mjs';
82
- import { dbTypesEmitter } from '../emit/db-types.mjs';
83
82
  import { ScaffoldError, newService, newSpec } from '../scaffold/service.mjs';
84
83
  import { EditionRefusal } from '../scaffold/edition-gate.mjs';
85
84
  import { addKind } from '../scaffold/add.mjs';
@@ -95,30 +94,33 @@ import { releaseMain } from '../release/index.mjs';
95
94
  import { dbMigrateMain } from '../db/migrate.mjs';
96
95
  import { dbPostCutoverMain } from '../db/post-cutover.mjs';
97
96
  import { dbDumpMain } from '../db/dump.mjs';
98
- import { dbResetMain } from '../db/reset.mjs';
97
+ import { dbNewMain } from '../db/new.mjs';
98
+ import { dbDriftMain } from '../db/drift.mjs';
99
99
  import { codemodMain } from '../codemod/button-intent.mjs';
100
100
 
101
101
  export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'ci', 'db', 'hygiene', 'coverage', 'codemod']);
102
- const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['reset', dbResetMain]]);
102
+ const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['new', dbNewMain], ['drift', dbDriftMain]]);
103
103
 
104
104
  const USAGE = `starci app scaffold <name> [--into <dir>] [--edition full|lite] [--registry-visibility public|private] [--cwd <dir>]
105
105
  starci app add <api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app|block|composite|branch|leaf|page|overlay|layout> <name> [options] [--cwd <dir>]
106
106
  starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--format text|json] [--sonar <file>]
107
107
  starci app codemod button-intent [--write] [--format json|markdown] [--report <file.json|file.md>] [--cwd <dir>]
108
108
  starci app sync (--check | --write) [--cwd <dir>]
109
- starci app check [--cwd <dir>] [--json] [--fast] [--base <ref>] [--db-types] [--wiring] [--coverage-from <lcov>]
110
- starci app db baseline [--stamp <version>] [--local] [--apply] [--cwd <dir>]
111
- starci app db restamp --map <restamp.yaml> [--local] [--apply] [--cwd <dir>]
112
- starci app db plan [--local] [--json] [--cwd <dir>]
113
- starci app db dump (--linked | --local | --url-env <NAME>) --out <dir> [--schema-only] [--json] [--cwd <dir>]
114
- starci app db reset --linked --auth keep|drop --storage keep|drop --backup <receipt.json> [--execute] [--json] [--cwd <dir>]
115
- starci app db migrate [--execute] [--local | --linked | --url-env <NAME>] [--json] [--cwd <dir>]
116
- starci app db post-cutover [--execute] [--green-at <ISO> | --owner-confirms-old-app-gone] [--linked | --url-env <NAME>] [--json] [--cwd <dir>]
117
- starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--json] [--cwd <dir>]
118
- starci app release push [--execute] [--remote <name>] [--json] [--cwd <dir>]
109
+ starci app check [--cwd <dir>] [--json] [--fast] [--base <ref>] [--wiring] [--coverage-from <lcov>]
110
+ starci app db new <kebab-name> [--json] [--cwd <dir>]
111
+ starci app db baseline [--stamp <version>] [--url-env <NAME>] [--apply] [--cwd <dir>]
112
+ starci app db restamp --map <restamp.yaml> [--url-env <NAME>] [--apply] [--cwd <dir>]
113
+ starci app db plan [--url-env <NAME>] [--json] [--cwd <dir>]
114
+ starci app db dump [--url-env <NAME>] --out <dir> [--schema-only] [--json] [--cwd <dir>]
115
+ starci app db migrate [--execute] [--url-env <NAME>] [--json] [--cwd <dir>]
116
+ starci app db post-cutover [--execute] [--green-at <ISO> | --owner-confirms-old-app-gone] [--url-env <NAME>] [--json] [--cwd <dir>]
117
+ starci app db drift [--url-env <NAME>] [--json] [--cwd <dir>]
118
+ starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--backfill <tag>] [--json] [--cwd <dir>]
119
+ starci app release push [--execute] [--remote <name>] [--urgent --reason <text>] [--json] [--cwd <dir>]
119
120
  starci app upgrade [--plan] [--cwd <dir>]
120
121
  starci app stack <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--cwd <dir>]
121
122
  starci app stack <prepare|doctor|connect|disconnect|build-args|render|deploy|converge|verify|rollback|prune|backup|jobs> --env <env> [--execute] [--json] [--cwd <dir>]
123
+ starci app stack deploy --env <env> --source-sha <sha> --urgent [--db-url-env <NAME>] [--backup-out <dir>] [--execute] [--json] [--cwd <dir>]
122
124
  starci app explain <path> [--cwd <dir>] [--json]
123
125
  starci app emit [--cwd <dir>]
124
126
  starci app new service <dir> <name> [--inject <binding>...] [--cwd <dir>]
@@ -144,7 +146,7 @@ const PER_CODE_LIMIT = 25;
144
146
  const VALUE_FLAGS = new Set(['--base', '--inject', '--into', '--event', '--from', '--service', '--connection', '--owner', '--failed', '--done', '--coverage-from', '--registry-visibility', '--shapes', '--read', '--app', '--kind', '--blocks', '--tabs']);
145
147
  /** Flags that may repeat: their values are collected in order. */
146
148
  const LIST_FLAGS = new Set(['--inject']);
147
- const BOOL_FLAGS = new Set(['--json', '--fast', '--fe', '--no-types', '--db-types', '--wiring', '--form']);
149
+ const BOOL_FLAGS = new Set(['--json', '--fast', '--fe', '--wiring', '--form']);
148
150
 
149
151
  function coverageArgument(arg, args, opts) {
150
152
  const inline = arg.startsWith('--coverage-from=');
@@ -247,11 +249,11 @@ function printExplain(e, out) {
247
249
  * The app pass: slots, managed files, formatter, contract snapshots and the architecture machine's check surface, root and sides.
248
250
  * `only` limits the formatter to those (app-relative) files.
249
251
  */
250
- async function runCheck({ repoRoot, harness, fast = false, base, only, presets, prettier, dbTypes = false }) {
252
+ async function runCheck({ repoRoot, harness, fast = false, base, only, presets, prettier }) {
251
253
  const tracked = trackedFiles(repoRoot);
252
254
  // Managed files of the root and both sides, the .gitignore block and sonar against their render (R04, R05, R11, ...), and prettier
253
255
  // through the app's own install (R19, never under --fast).
254
- const extraFindings = [...await managedFindings({ repoRoot, tracked, presets }), ...await checkDatabase({ repoRoot, files: tracked, base, ...(dbTypes ? { emitTypes: dbTypesEmitter() } : {}) }), ...(fast ? [] : await formatFindings({ repoRoot, files: only ?? tracked, prettier }))];
256
+ const extraFindings = [...await managedFindings({ repoRoot, tracked, presets }), ...await checkDatabase({ repoRoot, files: tracked, base }), ...(fast ? [] : await formatFindings({ repoRoot, files: only ?? tracked, prettier }))];
255
257
  // R23 against the be side itself (full pass only): committed snapshots equal what `starci app emit` writes now.
256
258
  let contracts = null;
257
259
  let be = null;
@@ -342,7 +344,7 @@ async function installedStackMain(cwd) {
342
344
  function databaseMain(argv, io) {
343
345
  const [verb, ...rest] = argv;
344
346
  const dispatch = DATABASE_VERBS.get(verb);
345
- if (!dispatch) throw new Error('starci app db takes baseline, restamp, plan, migrate, post-cutover, dump or reset');
347
+ if (!dispatch) throw new Error('starci app db takes new, baseline, restamp, plan, migrate, post-cutover, dump or drift');
346
348
  return dispatch(rest, io);
347
349
  }
348
350
 
@@ -354,7 +356,7 @@ function printCheckProofs(result, out) {
354
356
  async function checkMain(opts, { cwd: repoRoot, harness, edition, stdout: out, presets, prettier, checkApp = runCheck, runLinter }) {
355
357
  if (opts.positional.length) throw new Error('starci app check takes no path');
356
358
  if (opts.base !== undefined && opts.fast !== true) throw new Error('--base names the ref --fast compares with; it needs --fast');
357
- const checked = await checkEdition({ repoRoot, edition, normal: () => checkApp({ repoRoot, harness, fast: opts.fast === true, base: opts.base, presets, prettier, dbTypes: opts['db-types'] === true }), presets, prettier });
359
+ const checked = await checkEdition({ repoRoot, edition, normal: () => checkApp({ repoRoot, harness, fast: opts.fast === true, base: opts.base, presets, prettier }), presets, prettier });
358
360
  const proof = withCheckProofs(checked, { repoRoot, edition, wiring: opts.wiring === true, coverageFrom: opts['coverage-from'] });
359
361
  const changed = opts.fast === true ? changedSince(repoRoot, proof.fast?.base ?? opts.base).files : null;
360
362
  const existing = changed ? changed.filter(file => fs.existsSync(path.join(repoRoot, file))) : [];
@@ -395,7 +397,7 @@ function newMain(opts, { cwd: repoRoot, stdout: out }) {
395
397
  function addMain(opts, { cwd: repoRoot, stdout: out }) {
396
398
  const [noun, name, ...extra] = opts.positional;
397
399
  if (!noun || !name || extra.length) throw new Error('starci app add takes `<noun> <name>` and the options of the noun');
398
- const { created, registered } = addKind({ repoRoot, noun, name, options: { event: opts.event, from: opts.from, service: opts.service, connection: opts.connection, owner: opts.owner, failed: opts.failed, done: opts.done, fe: opts.fe, noTypes: opts['no-types'], shapes: opts.shapes, read: opts.read, app: opts.app, kind: opts.kind, form: opts.form, blocks: opts.blocks, tabs: opts.tabs } });
400
+ const { created, registered } = addKind({ repoRoot, noun, name, options: { event: opts.event, from: opts.from, service: opts.service, connection: opts.connection, owner: opts.owner, failed: opts.failed, done: opts.done, fe: opts.fe, shapes: opts.shapes, read: opts.read, app: opts.app, kind: opts.kind, form: opts.form, blocks: opts.blocks, tabs: opts.tabs } });
399
401
  for (const file of created) out(`created ${file}\n`);
400
402
  const kinds = registered.kinds.length ? `; kinds ${registered.kinds.join(', ')}` : '';
401
403
  // A noun that registers nothing in hfs.json (a table, an app, a front-end unit) ends with what it wrote.
@@ -434,7 +436,7 @@ function declaredEdition(cwd) {
434
436
  */
435
437
  async function stackAppMain(argv, { cwd, edition, stackMain, stdout: out, stderr: err, env, sops, stackIo }) {
436
438
  if (STACK_ACTIONS.includes(argv[0])) return deployStackMain(argv, { ...stackIo, cwd, stdout: out, stderr: err, env, ...(sops ? { sops } : {}) });
437
- if ((edition ?? declaredEdition(cwd)) === 'lite') throw new Error('starci app stack up|down|status manage the local test world of the full edition; a lite app starts its local infrastructure with the Supabase CLI');
439
+ if ((edition ?? declaredEdition(cwd)) === 'lite') throw new Error('starci app stack up|down|status manage the local test world of the full edition; a lite app starts its local database with npm run db:start');
438
440
  const run = stackMain ?? await installedStackMain(cwd);
439
441
  return run(argv, { cwd, out, err });
440
442
  }
@@ -457,7 +459,7 @@ function errorMessage(error) {
457
459
 
458
460
  /**
459
461
  * `harness` ({ version }) is the CLI that runs this package in-process: its version is the starci-harness an app is judged and
460
- * scaffolded at (runtime/scripts/hfs/canon-pins.mjs). `run` injects the Supabase runner, `runLinter` the linter transport, `checkApp`
462
+ * scaffolded at (runtime/scripts/hfs/canon-pins.mjs). `run` injects the database runner, `runLinter` the linter transport, `checkApp`
461
463
  * the HFS check and `stackIo` Swarm tools; production defaults execute every check.
462
464
  */
463
465
  export async function main(argv, {
package/stack/cli.mjs CHANGED
@@ -14,8 +14,8 @@ const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollb
14
14
 
15
15
  export const ACTIONS = Object.freeze(['prepare', 'doctor', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept']);
16
16
 
17
- const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push']);
18
- const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding']);
17
+ const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent']);
18
+ const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out']);
19
19
  const REPEAT = new Set(['--image', '--keep', '--extra-compose', '--project']);
20
20
 
21
21
  /** Splits argv into { flags, positional }; an unknown flag or a flag without its value is a refusal. */
@@ -71,6 +71,7 @@ const OPTIONS = {
71
71
  deploy: ({ flags, cwd }) => ({
72
72
  images: parseImages(flags.image), sourceSha: flags['source-sha'], execute: flags.execute === true,
73
73
  out: flags.out ? path.resolve(cwd, flags.out) : null, timeoutSeconds: number(flags, 'timeout'), retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true,
74
+ urgent: flags.urgent === true, dbUrlEnv: flags['db-url-env'], backupOut: flags['backup-out'] ? path.resolve(cwd, flags['backup-out']) : undefined,
74
75
  }),
75
76
  converge: ({ flags }) => ({ since: flags.since, timeoutSeconds: number(flags, 'timeout') }),
76
77
  verify: ({ flags }) => ({ retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true, skipInternal: flags['skip-internal'] === true }),
@@ -125,6 +126,8 @@ export async function stackMain(argv, io = {}) {
125
126
  if (binding && (!['deploy', 'verify'].includes(action) || flags['ssh-cmd'] || flags['skip-public'] || flags['skip-internal']))
126
127
  throw refusal('STACK_USAGE', 'the bound native consumer supports only complete deploy or verify');
127
128
  const config = bindStackTarget(ctx, loadDeployConfig({ repoRoot: ctx.cwd, env: flags.env ?? 'production' }), binding);
129
+ if (action !== 'deploy' && (flags.urgent || flags['db-url-env'] || flags['backup-out'])) throw refusal('STACK_USAGE', '--urgent, --db-url-env and --backup-out belong to deploy');
130
+ if (action === 'deploy' && !flags.urgent && (flags['db-url-env'] || flags['backup-out'])) throw refusal('STACK_USAGE', '--db-url-env and --backup-out choose the urgent floor\'s database and backup folder: add --urgent');
128
131
  const options = OPTIONS[action]({ flags, positional, cwd: ctx.cwd });
129
132
  const handler = await handlerOf(action, io.importer ?? ((file) => import(file)));
130
133
  const sshCmd = flags['ssh-cmd'] ?? null;
@@ -132,6 +135,11 @@ export async function stackMain(argv, io = {}) {
132
135
  const toolContext = nativeToolContext(ctx, binding);
133
136
  const result = DOCKER_ACTIONS.has(action) ? await withDockerTransport(toolContext, config, { sshCmd }, call) : await call(toolContext);
134
137
  print(ctx, action, flags, result);
138
+ // A floor step of an urgent deploy that failed is a refusal with its own code (urgent-floor.mjs), printed after the lines that led to it.
139
+ if (result.refused) {
140
+ ctx.stderr(`starci app stack: ${result.refused.code}: ${result.refused.message}\n`);
141
+ return EXIT.refused;
142
+ }
135
143
  return result.ok ? EXIT.ok : EXIT.failed;
136
144
  } catch (error) {
137
145
  if (!(error instanceof StackError)) throw error;
package/stack/deploy.mjs CHANGED
@@ -2,6 +2,8 @@
2
2
  // deploy: render, provision, store the release record, pre-pull, `docker stack deploy`, converge, post-deploy tasks (deploy.postDeploy, when declared), verify, accept, prune. A failure at or
3
3
  // after the stack deploy redeploys the previous accepted release from its STORED bytes; a failure before it leaves the services untouched.
4
4
  // Without `execute` a deploy renders locally and prints the plan: NO remote call at all. A rollback cannot undo data effects.
5
+ // An urgent deploy (`urgent`, owner ruling urgent-release 2026-10-29) runs the safety floor of urgent-floor.mjs first (fresh verified encrypted
6
+ // backup with a test decrypt, rollback target, migrate preview then execute) and refuses with the floor's own code when a step of it fails.
5
7
  //
6
8
  // `deps` are the collaborators, all factories or functions with the signature of their module, defaulting to lazy dynamic imports so this
7
9
  // module never needs render.mjs / provision.mjs / remote.mjs at load time (the specs inject stubs):
@@ -21,6 +23,7 @@ import { postDeployStack } from './post-deploy.mjs';
21
23
  import { pruneStack } from './prune.mjs';
22
24
  import { parseYaml } from '../runtime/engine/yaml.mjs';
23
25
  import { createReleaseStore } from './release-store.mjs';
26
+ import { urgentDeploy } from './urgent-floor.mjs';
24
27
  import { verifyStack } from './verify.mjs';
25
28
 
26
29
  /** Loads `file` (relative to this module). Split out so a spec can load a real sibling module through `lazy`. */
@@ -39,8 +42,11 @@ export const DEFAULT_DEPS = Object.freeze({
39
42
  pruneStack,
40
43
  releaseStore: createReleaseStore,
41
44
  ssh: lazy('./remote.mjs', 'ownerSshRunner'),
45
+ floor: {},
42
46
  });
43
47
 
48
+ const fileSha256 = (file) => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
49
+
44
50
  const IMAGE_LINE = /^[ \t]+image:[ \t]*(\S+)[ \t]*$/gmu;
45
51
  const QUOTE = /^["']|["']$/gu;
46
52
  const IMAGE_REF = /^[A-Za-z0-9][A-Za-z0-9./_:@-]*$/u;
@@ -212,7 +218,7 @@ async function deployExecute(ctx, config, options, d, out, cleanups) {
212
218
  }
213
219
 
214
220
  /**
215
- * Deploys a release: `options` = { images, sourceSha, execute, out, timeoutSeconds, retrySeconds, skipPublic, sshCmd }.
221
+ * Deploys a release: `options` = { images, sourceSha, execute, out, timeoutSeconds, retrySeconds, skipPublic, sshCmd, urgent, dbUrlEnv, backupOut }.
216
222
  * skipPublic (the first deploy into a shared ingress, whose public routes exist only after `ingress publish`): the verification checks only what runs
217
223
  * inside the stack, and the release stays PENDING (stored and deployed, pointer not moved) until `acceptRelease` has verified it in full.
218
224
  * Returns { ok, lines, data: { releaseId, previous, steps: [{ step, ok }], rolledBack } }.
@@ -224,15 +230,26 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
224
230
  const out = options.out ?? made;
225
231
  const cleanups = [];
226
232
  try {
227
- const rendered = await d.renderRelease(ctx, config, renderOptions(options, out, null));
228
- const { releaseId } = inspectRendered(out, config);
229
- if (options.execute !== true) {
233
+ // The local render and its inspection: a release that cannot render is refused before anything remote happens.
234
+ const render = async () => {
235
+ const rendered = await d.renderRelease(ctx, config, renderOptions(options, out, null));
236
+ return { rendered, releaseId: inspectRendered(out, config).releaseId };
237
+ };
238
+ const dryRun = async () => {
239
+ const { rendered, releaseId } = await render();
230
240
  const post = config.postDeploy.length > 0 ? ` post-deploy tasks (${config.postDeploy.join(', ')}),` : '';
231
241
  const plan = options.skipPublic
232
242
  ? `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} and verified inside the stack (public hosts skipped); it would stay pending until ingress publish and stack accept`
233
243
  : `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} verified, accepted`;
234
244
  return { ok: true, lines: [...(rendered.lines ?? []), plan], data: { releaseId, steps: [{ step: 'render', ok: true }], executed: false } };
245
+ };
246
+ if (options.urgent === true) {
247
+ return await urgentDeploy(ctx, config, options, {
248
+ render, dryRun, openStore: () => openStore(ctx, config, options, d, cleanups), deployExecute: () => deployExecute(ctx, config, options, d, out, cleanups), sha256: fileSha256,
249
+ }, d.floor);
235
250
  }
251
+ if (options.execute !== true) return await dryRun();
252
+ await render();
236
253
  return await deployExecute(ctx, config, options, d, out, cleanups);
237
254
  } finally {
238
255
  cleanups.forEach((close) => close());
@@ -6,7 +6,6 @@ import { refusal } from './errors.mjs';
6
6
  const slug = value => typeof value === 'string' && /^[a-z][a-z0-9-]*$/.test(value);
7
7
  const label = value => typeof value === 'string' && /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(value);
8
8
  const host = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(value);
9
- const port = value => Number.isInteger(value) && value > 0 && value <= 65535;
10
9
  const denied = () => { throw refusal('STACKS_DEPLOY_CONFIG_INVALID', 'the native endpoint binding is invalid or differs from the selected owner target'); };
11
10
  const exact = (value, fields) => value && typeof value === 'object' && !Array.isArray(value)
12
11
  && Object.keys(value).length === fields.length && fields.every(key => Object.hasOwn(value, key));
@@ -28,44 +27,11 @@ function ordinaryInput(root, file) {
28
27
  /** A bound native owner opens only an ordinary envelope below its actual app, without ancestor links. */
29
28
  export const assertBoundCustody = (root, file) => ordinaryInput(root, file);
30
29
 
31
- /** Bound database commands require the app-local launcher; they cannot discover a global replacement. */
32
- export function boundSupabaseLauncher(root) {
33
- const launcher = path.join(root, 'node_modules', 'supabase', 'dist', 'supabase.js');
34
- ordinaryInput(root, launcher);
35
- return launcher;
36
- }
37
-
38
30
  /** Native custody can use AGE; tools that do not decrypt custody must not inherit that identity. */
39
31
  export function nativeToolEnvironment(env) {
40
32
  return Object.fromEntries(Object.entries(env).filter(([key]) => !/^SOPS_/i.test(key)));
41
33
  }
42
34
 
43
- /** Bound database tools receive only their selected private password and native routing, without ambient libpq or provider credentials. */
44
- export function nativeDatabaseEnvironment(env, binding, selected = {}, omit = []) {
45
- const omitted = new Set(omit.filter(value => typeof value === 'string').map(value => value.toUpperCase()));
46
- return { ...Object.fromEntries(Object.entries(nativeToolEnvironment(env)).filter(([key]) => !/^(?:PG[A-Z_]*|SUPABASE_)/i.test(key) && !omitted.has(key.toUpperCase()))),
47
- 'SUPABASE_ENV': binding.environment, ...selected };
48
- }
49
-
50
- /** Preserve a logical linked project while selecting a fixed native SQL endpoint without the CLI's linked Management finalizer. */
51
- export function boundLinkedDatabaseUrl(binding, savedPooler = null) {
52
- if (!binding || binding.mode !== 'linked' || !/^[a-z0-9]{20}$/.test(binding.projectRef)) denied();
53
- if (savedPooler === null) return `postgresql://postgres@db.${binding.projectRef}.supabase.co:5432/postgres?sslmode=require`;
54
- let url;
55
- try {
56
- if (typeof savedPooler !== 'string' || savedPooler.length > 8192) denied();
57
- url = new URL(savedPooler.trim());
58
- if (!['postgres:', 'postgresql:'].includes(url.protocol) || !/^[A-Za-z0-9.-]+\.pooler\.supabase\.com$/.test(url.hostname)
59
- || decodeURIComponent(url.username) !== `postgres.${binding.projectRef}` || url.pathname !== '/postgres'
60
- || url.port && !['5432', '6543'].includes(url.port) || url.hash
61
- || [...url.searchParams].some(([key, value]) => key !== 'sslmode' || !['require', 'verify-full'].includes(value))) denied();
62
- } catch { denied(); }
63
- url.password = '';
64
- url.port = '5432';
65
- if (!url.searchParams.has('sslmode')) url.searchParams.set('sslmode', 'require');
66
- return url.toString();
67
- }
68
-
69
35
  /** Parse the internal public binding; ordinary commands without this flag retain their native behavior. */
70
36
  export function parseTargetBinding(text, kind) {
71
37
  if (text === undefined) return null;
@@ -75,16 +41,6 @@ export function parseTargetBinding(text, kind) {
75
41
  if (kind === 'docker-swarm') {
76
42
  if (!exact(binding, ['kind', 'environment', 'stack', 'host', 'user', 'port']) || !slug(binding.stack)
77
43
  || !host(binding.host) || !label(binding.user) || binding.port !== 22) denied();
78
- } else if (kind === 'supabase-db') {
79
- if (binding.mode === 'local') {
80
- if (!exact(binding, ['kind', 'mode', 'environment', 'localProject', 'port']) || !label(binding.localProject) || !port(binding.port)) denied();
81
- } else if (binding.mode === 'linked') {
82
- if (!exact(binding, ['kind', 'mode', 'environment', 'projectRef']) || !/^[a-z0-9]{20}$/.test(binding.projectRef ?? '')) denied();
83
- } else if (binding.mode === 'url-env') {
84
- if (!exact(binding, ['kind', 'mode', 'environment', 'urlEnv', 'host', 'port', 'database', 'user'])
85
- || !/^[A-Z][A-Z0-9_]*$/.test(binding.urlEnv ?? '') || !host(binding.host) || !port(binding.port)
86
- || !label(binding.database) || !label(binding.user)) denied();
87
- } else denied();
88
44
  } else denied();
89
45
  return Object.freeze(binding);
90
46
  }
@@ -107,19 +63,3 @@ export function assertStackAccount(binding, account, knownHosts) {
107
63
  if (!binding) return;
108
64
  if (account !== `${binding.user}@${binding.host}` || binding.port !== 22 || !knownHosts.trim()) denied();
109
65
  }
110
-
111
- /** Compare the effective local Supabase identity after its existing config/environment resolver. */
112
- export function assertLocalDatabase(binding, target) {
113
- if (binding && (binding.mode !== 'local' || binding.localProject !== target.project || binding.port !== target.port)) denied();
114
- }
115
-
116
- /** Refuse a selected URL unless its non-secret endpoint exactly matches the accepted target. */
117
- export function assertUrlDatabase(binding, value) {
118
- if (!binding) return;
119
- let url;
120
- try { url = new URL(value); } catch { denied(); }
121
- if (binding.mode !== 'url-env' || !['postgres:', 'postgresql:'].includes(url.protocol)
122
- || url.hostname !== binding.host || Number(url.port || '5432') !== binding.port
123
- || decodeURIComponent(url.username) !== binding.user || decodeURIComponent(url.pathname.slice(1)) !== binding.database
124
- || url.search || url.hash || !url.password) denied();
125
- }
@@ -0,0 +1,147 @@
1
+ // urgent-floor.mjs - the safety floor of an urgent deploy (`starci app stack deploy --urgent`, owner ruling urgent-release 2026-10-29). An urgent
2
+ // release skips CI and the full gate, never this floor; each step that fails is a refusal with its own code, never a warning:
3
+ // record the commit carries a pushed URGENT record (release/urgent.mjs) STACK_URGENT_NO_RECORD, STACK_URGENT_NOT_PUSHED
4
+ // backup a fresh full database backup, encrypted to the app's age recipients (db dump) STACK_URGENT_BACKUP_FAILED
5
+ // verified its receipt names this run, its size, age header and sha256 match the archive STACK_URGENT_BACKUP_UNVERIFIED
6
+ // decrypt a test decrypt with the operator's age identity (SOPS_AGE_KEY_FILE) lists every part STACK_URGENT_DECRYPT_UNAVAILABLE, STACK_URGENT_DECRYPT_FAILED
7
+ // render the release renders locally (deploy.mjs inspectRendered) its own STACKS_/STACK_DEPLOY_ refusal
8
+ // rollback an accepted previous release exists, so a red deploy has somewhere to go back to STACK_URGENT_NO_ROLLBACK_TARGET
9
+ // migrate db migrate previews the plan, then executes it STACK_URGENT_MIGRATE_PREVIEW_RED, STACK_URGENT_MIGRATE_FAILED
10
+ // deploy the release with its readiness verify (deploy.verify); red rolls back by itself STACK_URGENT_RED_ROLLED_BACK, STACK_URGENT_ROLLBACK_FAILED,
11
+ // STACK_URGENT_DEPLOY_NOT_STARTED (red before the stack deploy)
12
+ // The public-host checks are never skipped (STACK_URGENT_SKIP_PUBLIC). A rollback cannot undo the migrations: the backup above is their way back.
13
+ // `deps` are the collaborators (backup, decrypt, migrate), each defaulting to the app's own db verbs; a spec injects stubs.
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { readDeclaration } from '../release/index.mjs';
18
+ import { releaseDir } from '../release/gate.mjs';
19
+ import { readUrgent } from '../release/records.mjs';
20
+ import { refusal } from './errors.mjs';
21
+
22
+ const AGE_HEADER = 'age-encryption.org/v1\n';
23
+ const SHA = /^[a-f0-9]{64}$/u;
24
+
25
+ /** The database a floor step reaches: the connection named by --db-url-env (default DATABASE_URL). */
26
+ const targetOf = (options) => (options.dbUrlEnv ? { urlEnv: options.dbUrlEnv } : {});
27
+
28
+ /** The backups of an urgent deploy live outside every repository: --backup-out, else ~/.starci-backups/<project>. */
29
+ const backupDir = (config, options) => options.backupOut ?? path.join(os.homedir(), '.starci-backups', config.project);
30
+
31
+ async function defaultBackup(ctx, { target, out }) {
32
+ const { dumpDatabase } = await import('../db/dump.mjs');
33
+ return dumpDatabase(ctx, { ...target, out }).data;
34
+ }
35
+
36
+ async function defaultDecrypt(ctx, { archive, identity, parts }) {
37
+ const { backupTool } = await import('../db/backup-receipt.mjs');
38
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-decrypt-'));
39
+ try {
40
+ backupTool(ctx, 'age', ['-d', '-i', identity, '-o', path.join(scratch, 'backup.tar'), archive]);
41
+ const listed = String(backupTool(ctx, 'tar', ['-tf', 'backup.tar'], { cwd: scratch }).stdout ?? '').split(/\r?\n/u).map((line) => line.replace(/^\.\//u, '').trim());
42
+ const missing = parts.filter((part) => !listed.includes(part));
43
+ if (missing.length > 0) throw new Error(`the decrypted archive lacks ${missing.join(', ')}`);
44
+ return { parts: listed.filter(Boolean) };
45
+ } finally {
46
+ fs.rmSync(scratch, { recursive: true, force: true });
47
+ }
48
+ }
49
+
50
+ async function defaultMigrate(ctx, { target, execute }) {
51
+ const { loadDatabase, migrateDatabase } = await import('../db/migrate.mjs');
52
+ // The urgent deploy's --execute is the arming of this one migration: the floor runs it, it never waits for a second switch.
53
+ const armed = Object.create(ctx, { env: { value: { ...ctx.env, STARCI_DB_ARMED: '1' } } });
54
+ return migrateDatabase(armed, loadDatabase(ctx.cwd), { ...target, execute });
55
+ }
56
+
57
+ export const FLOOR_DEPS = Object.freeze({ backup: defaultBackup, decrypt: defaultDecrypt, migrate: defaultMigrate });
58
+
59
+ /** A refused floor step: the lines so far and the code; the CLI prints both and exits 2. */
60
+ const refused = (lines, code, message, data = {}) => ({ ok: false, refused: { code, message }, lines, data: { ...data, refused: code } });
61
+
62
+ /** Problems of the backup receipt against its archive: written by this run, a full backup, its size, age header and sha256 equal to the file's. */
63
+ function receiptProblems(receipt, archive, startedMs, sha256) {
64
+ const problems = [];
65
+ if (!receipt || typeof receipt !== 'object') return ['no receipt'];
66
+ if (receipt.schemaOnly !== false) problems.push('the backup is not a full (data) backup');
67
+ if (!Number.isFinite(Date.parse(receipt.at)) || Date.parse(receipt.at) < startedMs - 1000) problems.push('the receipt is not from this run');
68
+ if (!fs.existsSync(archive)) return [...problems, 'the archive is missing'];
69
+ const size = fs.statSync(archive).size;
70
+ const header = Buffer.alloc(AGE_HEADER.length);
71
+ const fd = fs.openSync(archive, 'r');
72
+ try { fs.readSync(fd, header, 0, header.length, 0); } finally { fs.closeSync(fd); }
73
+ if (header.toString('ascii') !== AGE_HEADER) problems.push('the archive has no age header');
74
+ if (size !== receipt.bytes) problems.push(`the archive is ${size} bytes, the receipt says ${receipt.bytes}`);
75
+ if (!SHA.test(String(receipt.sha256 ?? '')) || sha256(archive) !== receipt.sha256) problems.push('the archive sha256 differs from the receipt');
76
+ return problems;
77
+ }
78
+
79
+ /** What the floor needs before anything runs: the pushed urgent record, a declared database, the identity of the test decrypt, the full public verify. */
80
+ function preconditions(ctx, config, options) {
81
+ if (options.skipPublic) throw refusal('STACK_URGENT_SKIP_PUBLIC', 'an urgent deploy verifies the public hosts and accepts the release: drop --skip-public');
82
+ const record = readUrgent(releaseDir(ctx), options.sourceSha);
83
+ if (!record) throw refusal('STACK_URGENT_NO_RECORD', `${options.sourceSha} has no urgent record: push it with starci app release push --urgent --reason "<text>" --execute first`);
84
+ if (record.pushed !== true) throw refusal('STACK_URGENT_NOT_PUSHED', `the urgent release ${record.tag} of ${options.sourceSha.slice(0, 10)} was never pushed`);
85
+ if (!readDeclaration(config.repoRoot ?? ctx.cwd).database) throw refusal('STACK_URGENT_NO_DATABASE', 'the floor backs up and migrates the database the `database:` block declares, and this app declares none');
86
+ const identity = ctx.env.SOPS_AGE_KEY_FILE;
87
+ if (!identity || !fs.existsSync(identity)) throw refusal('STACK_URGENT_DECRYPT_UNAVAILABLE', 'the test decrypt of the backup reads the age identity named by SOPS_AGE_KEY_FILE, which is not set or not a file');
88
+ return { record, identity };
89
+ }
90
+
91
+ /**
92
+ * The floor's steps before the deploy: backup, verify and test decrypt (before anything remote), the local render, the rollback target, migrate
93
+ * preview and execute. Returns null when all passed, else the refused result.
94
+ */
95
+ async function guard(ctx, config, options, { f, run, identity, lines }) {
96
+ const target = targetOf(options);
97
+ const started = ctx.now();
98
+ let backup;
99
+ try { backup = await f.backup(ctx, { target, out: backupDir(config, options) }); } catch (error) {
100
+ return refused(lines, 'STACK_URGENT_BACKUP_FAILED', `the fresh database backup failed (${String(error?.code ?? error?.message ?? error)}); nothing was migrated or deployed`);
101
+ }
102
+ const archive = path.join(path.dirname(backup.receiptPath), backup.receipt?.file ?? '');
103
+ const problems = receiptProblems(backup.receipt, archive, started, run.sha256);
104
+ if (problems.length > 0) return refused(lines, 'STACK_URGENT_BACKUP_UNVERIFIED', `${problems.join('; ')}; nothing was migrated or deployed`);
105
+ lines.push(`floor: backup ${path.basename(archive)} (${backup.receipt.bytes} bytes, sha256 ${backup.receipt.sha256.slice(0, 12)}) receipt ${backup.receiptPath}`);
106
+ try { await f.decrypt(ctx, { archive, identity, parts: backup.receipt.parts }); } catch (error) {
107
+ return refused(lines, 'STACK_URGENT_DECRYPT_FAILED', `the test decrypt of the backup failed (${String(error?.code ?? error?.message ?? error)}); nothing was migrated or deployed`);
108
+ }
109
+ lines.push(`floor: test decrypt listed ${backup.receipt.parts.length} part(s)`);
110
+ const { releaseId } = await run.render();
111
+ lines.push(`floor: release ${releaseId} rendered`);
112
+ const store = await run.openStore();
113
+ if (!(await store.current())) return refused(lines, 'STACK_URGENT_NO_ROLLBACK_TARGET', 'no accepted previous release is stored on the manager, so a red urgent deploy could not roll back; nothing was migrated or deployed');
114
+ let preview;
115
+ try { preview = await f.migrate(ctx, { target, execute: false }); } catch (error) { preview = { ok: false, lines: [String(error?.message ?? error)] }; }
116
+ lines.push(...(preview.lines ?? []).map((line) => `floor: migrate preview: ${line}`));
117
+ if (!preview.ok) return refused(lines, 'STACK_URGENT_MIGRATE_PREVIEW_RED', 'the migration preview is red; nothing was migrated or deployed');
118
+ let applied;
119
+ try { applied = await f.migrate(ctx, { target, execute: true }); } catch (error) { applied = { ok: false, lines: [String(error?.message ?? error)] }; }
120
+ lines.push(...(applied.lines ?? []).map((line) => `floor: migrate: ${line}`));
121
+ if (!applied.ok) return refused(lines, 'STACK_URGENT_MIGRATE_FAILED', `the migration failed; nothing was deployed (restore from ${backup.receiptPath} if the database is half migrated)`);
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * An urgent deploy: the floor's preconditions, then (with execute) its guard steps, then `deployExecute` (the release, its readiness verify and the
127
+ * automatic rollback). `run` = { render(), openStore(), deployExecute(), dryRun(), sha256 }. Returns { ok, lines, data } or a refused result whose
128
+ * data names the urgent release.
129
+ */
130
+ export async function urgentDeploy(ctx, config, options, run, deps = {}) {
131
+ const f = { ...FLOOR_DEPS, ...deps };
132
+ const { record, identity } = preconditions(ctx, config, options);
133
+ const lines = [`urgent ${record.tag} (${options.sourceSha.slice(0, 10)}) by ${record.role}: ${record.reason}`];
134
+ if (options.execute !== true) {
135
+ const plan = await run.dryRun();
136
+ return { ...plan, lines: [...lines, 'floor (never skipped): fresh encrypted database backup, receipt verify, test decrypt, rollback target, migrate preview then execute, deploy with readiness verify and automatic rollback', ...plan.lines] };
137
+ }
138
+ const stop = await guard(ctx, config, options, { f, run, identity, lines });
139
+ if (stop) return { ...stop, data: { ...stop.data, urgent: record.tag } };
140
+ const deployed = await run.deployExecute();
141
+ const all = [...lines, ...deployed.lines];
142
+ if (deployed.ok) return { ...deployed, lines: all, data: { ...deployed.data, urgent: record.tag } };
143
+ const data = { ...deployed.data, urgent: record.tag };
144
+ if (!(deployed.data?.steps ?? []).some((step) => step.step === 'deploy')) return refused(all, 'STACK_URGENT_DEPLOY_NOT_STARTED', 'the release failed before the stack deploy, so the services are untouched; the migrations stay (backup above)', data);
145
+ if (deployed.data?.rolledBack) return refused(all, 'STACK_URGENT_RED_ROLLED_BACK', `the release failed its deploy or readiness verify and was rolled back to ${deployed.data.previous}; the migrations stay (backup above)`, data);
146
+ return refused(all, 'STACK_URGENT_ROLLBACK_FAILED', 'the release is red and the automatic rollback did not converge: follow the stack runbook, Rollback', data);
147
+ }
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import { parseYaml } from '../runtime/engine/yaml.mjs';
3
3
  import { refusal } from '../stack/errors.mjs';
4
4
  import { byCodeUnit } from '../report/order.mjs';
5
+ import { ENTITY_LIST } from '../db/entity-list.mjs';
5
6
 
6
7
  // One command vocabulary for managed CI rendering and the runtime coverage gate.
7
8
  const exampleCommand = (source, command) => source?.runtimeExample
@@ -195,7 +196,8 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
195
196
  ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
196
197
  // The coverage judgement alone over the merged report: the fast and lint jobs already ran every other rule of the app check.
197
198
  ciCoverageProof: exampleCommand(source, 'npx starci app coverage verify ' + lcovReport),
198
- ciDatabaseLint: exampleCommand(source, 'npm run db:lint'),
199
+ // An app with an entity list also checks its entities against the schema its migrations build, in the build job (a runner with Docker).
200
+ ciDatabaseLint: exampleCommand(source, (source?.files ?? []).includes(ENTITY_LIST.slice('be/'.length)) ? 'npm run db:lint && npx starci app db drift' : 'npm run db:lint'),
199
201
  auditCommand: `npm audit${audit.omitDev ? ' --omit=dev' : ''} --audit-level=${audit.level}`,
200
202
  sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
201
203
  sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
package/sync/index.mjs CHANGED
@@ -157,6 +157,8 @@ function trailingNewline(text) {
157
157
  }
158
158
 
159
159
  /** The digest-pinned image of a lite app's declared Redis, in every environment that runs it (ruling:R212/case-10). */
160
+ /** The pinned image of a lite app's db service (contexts/stack/pins.yaml images): PostgreSQL with pg_cron, pg_net, pgmq and pgvector. */
161
+ export const DB_IMAGE = 'supabase/postgres:17.11.0.006@sha256:c141fd17ac6a76c4323e90b3aee8ff1a23d2e89e547c2b963cfadc309c079cc2';
160
162
  export const REDIS_IMAGE = 'redis:7@sha256:c6eabf748fc7a61dbb5a705c78bcf3d6377b1127a97d0ce965c11c44ba46896f';
161
163
 
162
164
  const redisDevText = project => render(readBundled('app/package-scripts/redis-dev.json'), { project, redisImage: REDIS_IMAGE });
@@ -286,7 +288,7 @@ export function appScripts(app, source) {
286
288
  const watch = entry => `cd be && ts-node-dev --respawn -r tsconfig-paths/register apps/${entry.name}/src/main.ts`;
287
289
  const built = entry => `node be/dist/apps/${entry.name}/src/main.js`;
288
290
  const workspace = entry => feAppPackageName(app.project, entry.name);
289
- // Both editions generate before they run (owner ruling 2026-10-13): the fe's GraphQL wire types, and under lite the Supabase types check.
291
+ // Both editions generate before they run (owner ruling 2026-10-13): the fe's GraphQL wire types, and under lite the database types check.
290
292
  const dev = entry => `npm run codegen --silent && turbo run dev --filter=${workspace(entry)}`;
291
293
  const images = imageEntries(app, source);
292
294
  const imageCommand = ({ side, entry }) => `docker build -f ${dockerfilePath(side, entry.name)} -t ${app.project}/${entry.name}:dev .`;
@@ -347,7 +349,7 @@ export function imageFiles(app, source) {
347
349
  const side = app.sides.fe;
348
350
  const inputs = [...(opensPackages(side) ? ['fe/packages'] : []), ...((side.reads ?? []).includes('be/contracts/') ? ['be/contracts'] : [])];
349
351
  const copyInputs = inputs.map(folder => `COPY ${folder} ${folder}\n`).join('');
350
- // The folders outside be/ that the back end declares it reads (the generated Supabase types of a lite back end, which an upgraded app keeps reading) are in every one of its images.
352
+ // The folders outside be/ that the back end declares it reads (the generated database types of a lite back end, db/types/) are in every one of its images.
351
353
  const beFolders = (app.sides.be.reads ?? []).map(folder => folder.replace(/\/$/, ''));
352
354
  const copyBeInputs = beFolders.map(folder => `COPY ${folder} ${folder}\n`).join('');
353
355
  const entries = imageEntries(app, source).map(({ side: which, entry: item }) => ({ side: which, item }));
@@ -373,8 +375,8 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
373
375
  project: app.project,
374
376
  appBinary: APP_BINARY,
375
377
  appScripts: appScripts(app, source),
376
- // A full upgrade keeps the app's declared Supabase connection and its local database commands.
377
- supabaseScripts: providers.includes('supabase') ? render(readBundled('app/package-scripts/supabase.json'), { appBinary: APP_BINARY }) : '',
378
+ // A lite app's own database runs in development as one container of the pinned db image (ruling:lite-self-hosted-postgres-passport-minio).
379
+ dbImage: DB_IMAGE,
378
380
  // A lite app's declared Redis (sides.be.backing, ruling:R212/case-10) runs in development as one container of its pinned image.
379
381
  backingScripts: lite && (app.sides.be.backing ?? []).includes('redis') ? redisDevText(app.project) : '',
380
382
  // turbo builds every fe app workspace; ^build (turbo.json) builds the packages each app imports first, and caches both.
@@ -555,6 +557,24 @@ export async function renderRepo(root, { presets, parseYaml, notices, manifest =
555
557
  return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source: appSource(root, { parseYaml, notices }) }) };
556
558
  }
557
559
 
560
+ /** The folders a lite app's sync retires (ruling lite-self-hosted-postgres-passport-minio): the generated Supabase row types nothing reads any more. */
561
+ const LITE_RETIRED_DIRS = Object.freeze(['supabase/types']);
562
+
563
+ /** The app-relative files of the retired folders that still exist under `root` of a lite app; sync --write deletes them, --check reports them. */
564
+ export function retiredFiles(root, hfs) {
565
+ if (hfs?.edition !== 'lite') return [];
566
+ const found = [];
567
+ const walk = (rel) => {
568
+ const full = path.join(root, rel);
569
+ const stat = fs.lstatSync(full, { throwIfNoEntry: false });
570
+ if (!stat) return;
571
+ if (stat.isDirectory()) for (const name of fs.readdirSync(full).sort(byCodeUnit)) walk(`${rel}/${name}`);
572
+ else found.push(rel);
573
+ };
574
+ for (const dir of LITE_RETIRED_DIRS) walk(dir);
575
+ return found;
576
+ }
577
+
558
578
  /** `starci app sync --check | --write [--cwd <dir>]` at the app root; returns 0 clean, 1 drift, 2 usage. */
559
579
  function syncOptions(argv) {
560
580
  const check = argv.includes('--check'), write = argv.includes('--write');
@@ -567,16 +587,17 @@ function syncOptions(argv) {
567
587
  return { write, cwdAt };
568
588
  }
569
589
 
570
- function reportSyncCheck(root, targets, out) {
590
+ function reportSyncCheck(root, targets, out, retired = []) {
571
591
  const results = checkTargets(root, targets);
572
592
  const bad = results.filter(item => item.status !== 'ok');
593
+ for (const file of retired) out(`HFS_SYNC_DRIFT ${file}: retired, the lite edition generates no database types; run "npx starci app sync --write" to delete it`);
573
594
  for (const result of bad) {
574
595
  const found = result.actualHash ? `, found ${result.actualHash.slice(0, 12)}` : '';
575
596
  const where = result.difference ? ` (line ${result.difference.line}: expected ${JSON.stringify(result.difference.expected)}, found ${JSON.stringify(result.difference.actual)})` : '';
576
597
  out(`HFS_SYNC_DRIFT ${result.path}: ${result.status}, expected sha256 ${result.expectedHash.slice(0, 12)}${found}${where}`);
577
598
  }
578
599
  out(`starci app sync --check: ${results.length - bad.length} of ${results.length} in sync${bad.length ? '; run "npx starci app sync --write"' : ''}`);
579
- return bad.length ? 1 : 0;
600
+ return bad.length || retired.length ? 1 : 0;
580
601
  }
581
602
 
582
603
  export async function runSync(argv, { cwd = process.cwd(), out = line => process.stdout.write(`${line}\n`), presets, parseYaml } = {}) {
@@ -589,15 +610,18 @@ export async function runSync(argv, { cwd = process.cwd(), out = line => process
589
610
  const root = cwdAt >= 0 ? path.resolve(cwd, argv[cwdAt + 1]) : cwd;
590
611
  try {
591
612
  const notices = [];
592
- const { targets } = await renderRepo(root, { presets, parseYaml, notices });
613
+ const { hfs, targets } = await renderRepo(root, { presets, parseYaml, notices });
614
+ const retired = retiredFiles(root, hfs);
593
615
  if (targets.some(target => target.path === 'README.md')) for (const notice of new Set(notices)) out(notice);
594
616
  if (write) {
595
617
  const written = writeTargets(root, targets);
596
618
  for (const file of written) out(`wrote ${file}`);
619
+ for (const file of retired) { fs.rmSync(path.join(root, file)); out(`removed ${file} (retired)`); }
620
+ for (const dir of LITE_RETIRED_DIRS) if (retired.length && fs.existsSync(path.join(root, dir)) && !fs.readdirSync(path.join(root, dir)).length) fs.rmdirSync(path.join(root, dir));
597
621
  out(`starci app sync: ${written.length} written, ${targets.length - written.length} already in sync`);
598
622
  return 0;
599
623
  }
600
- return reportSyncCheck(root, targets, out);
624
+ return reportSyncCheck(root, targets, out, retired);
601
625
  } catch (error) {
602
626
  if (!(error instanceof SyncError)) throw error;
603
627
  out(error.message);