@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
@@ -18,7 +18,8 @@ import { propertyValue } from './function-spec-providers.mjs';
18
18
  * - no file outside the client folder and the stream reader folder (`fe.transport.stream`, `modules/api/sse/clients/`) references the global `fetch`: a call, `globalThis.fetch`, `window.fetch`, or a
19
19
  * reference passed as a value (`useSWR(key, fetch)`, `const send = fetch`), all of which a spelling-based rule misses; with no
20
20
  * client at all every such reference is a finding that says the repository has no client;
21
- * - axios is imported only by the api owner (modules/api, the api package) for its rest calls; every other HTTP library is a
21
+ * - axios is imported only by the api owner (modules/api, the api package) for its rest calls and by the auth door
22
+ * (modules/auth/auth-door.ts) for the app's own auth doors; every other HTTP library is a
22
23
  * second transport wherever it is imported, by static import, `import()` or `require()`;
23
24
  * - an `ApolloClient` is constructed only inside the client folder;
24
25
  * - every GraphQL operation (`graphql/queries/query-*.ts`, `graphql/mutations/mutation-*.ts`, `graphql/subscriptions/subscription-*.ts`) imports the client (or the api
@@ -103,13 +104,16 @@ function isApolloClientConstruction(ts, kit, checker, node) {
103
104
  return Boolean(binding?.name === 'ApolloClient' && binding.module.startsWith('@apollo/client'));
104
105
  }
105
106
 
107
+ /** The one auth door of the session issuer's client (`modules/auth/auth-door.ts`, slot fe.modules.auth): it calls the app's own auth doors with axios. */
108
+ const isAuthDoor = (file) => file.slot === 'fe.modules.auth' && path.posix.basename(file.rel) === 'auth-door.ts';
109
+
106
110
  function inspectLibraryImport({ ts, file, node, transport, report }) {
107
111
  const specifier = moduleSpecifierOf(ts, node);
108
112
  if (!specifier || !ts.isStringLiteralLike(specifier)) return;
109
113
  const library = specifier.text.split('/')[0];
110
- if (!HTTP_LIBRARIES.has(library) || (library === REST_LIBRARY && API_OWNER_SLOTS.has(file.slot))) return;
114
+ if (!HTTP_LIBRARIES.has(library) || (library === REST_LIBRARY && (API_OWNER_SLOTS.has(file.slot) || isAuthDoor(file)))) return;
111
115
  const where = library === REST_LIBRARY
112
- ? 'axios belongs to the api owner only (modules/api/rest, the api package): call its rest functions'
116
+ ? 'axios belongs to the api owner (modules/api/rest, the api package) and the auth door (modules/auth/auth-door.ts) only: call their functions'
113
117
  : `the transport is Apollo GraphQL (${transport}) with axios for the rest calls of modules/api/rest`;
114
118
  report(file, node, `${specifier.text} is a second HTTP transport in ${file.rel}; ${where}.`, { library: specifier.text });
115
119
  }
@@ -26,12 +26,12 @@
26
26
  // FE_NO_TESTS (R97, rules/fe-no-tests.mjs) a front end holds a spec, e2e or test-tool file, a test script or a test dependency; no exception
27
27
  // FE_STACK_FORBIDDEN (rules/fe-stack.mjs) a front-end manifest (the fe side's, or the app root's minus the back end's own libraries)
28
28
  // declares a package knowledge/hfs/fe-stack.yaml forbids
29
- // HFS_LITE_SECRET_CUSTODY (R220, rules/supabase-secrets.mjs) under edition lite: secret material or a forbidden custody path; the R06 scan itself runs for every edition
29
+ // HFS_LITE_SECRET_CUSTODY (R220, rules/lite-secrets.mjs) under edition lite: secret material or a forbidden custody path; the R06 scan itself runs for every edition
30
30
  // HFS_EDITION_FORBIDDEN_PRESENT (R212, rules/edition.mjs) under edition lite: an e2e, contract, test-stack or browser script, or a
31
31
  // dependency of infrastructure lite does not run (test containers, fault proxy, Kafka)
32
32
  // HFS_BACKING_UNDECLARED, HFS_BACKING_UNUSED (R212, rules/edition.mjs) under edition lite: a client library or a pattern of a backing
33
33
  // service hfs.json sides.be.backing does not declare or the edition does not admit, or a declared service no dependency consumes
34
- // DB_MIGRATION_SHAPE ... DB_TYPES_DRIFT (R213-R216, rules/database.mjs, async: passed in as extraFindings) the Supabase migrations, policies, definer functions, buckets, config.toml and generated types
34
+ // DB_MIGRATION_SHAPE ... LITE_DB_MIGRATION (R213-R215, R332, rules/database.mjs, async: passed in as extraFindings) the db/ migrations, policies, definer functions and buckets; generated types
35
35
  // BE_SPEC_PLACEMENT (R102, rules/spec-placement.mjs) a spec or test file outside the four test layers, scripts/ and tools/ included
36
36
  // HFS_REPO_LOCAL_CHECK (R103, rules/repo-local-checks.mjs) a local eslint rule or plugin, a `check-*` script, a relative import in eslint.config
37
37
  // HFS_LINT_SUPPRESSION_FILE (R104, rules/lint-suppression.mjs) an eslint suppressions file, script or option
@@ -61,7 +61,7 @@
61
61
  // findings fail the check.
62
62
  import fs from 'node:fs';
63
63
  import path from 'node:path';
64
- import { appWiring, BOOT_PROOF_CODE } from './app-wiring.mjs';
64
+ import { appWiring, BOOT_PROOF_CODE, BOOT_PROOF_TIMEOUT } from './app-wiring.mjs';
65
65
  import { coverageFrom as coverageFromReport } from './coverage-from.mjs';
66
66
  import { skillRoot } from '../../engine/runtime-root.mjs';
67
67
  import { ARCHITECTURE_RULE_IDS, checkArchitecture } from './architecture/index.mjs';
@@ -83,7 +83,7 @@ import { frontendFindings } from './rules/frontend-tree.mjs';
83
83
  import { lintSuppressionFindings } from './rules/lint-suppression.mjs';
84
84
  import { bypassMarkerFindings } from './rules/bypass-markers.mjs';
85
85
  import { repoLocalCheckFindings } from './rules/repo-local-checks.mjs';
86
- import { supabaseSecretFindings } from './rules/supabase-secrets.mjs';
86
+ import { liteSecretFindings } from './rules/lite-secrets.mjs';
87
87
  import { untrackedSecretFindings } from './rules/secrets.mjs';
88
88
  import { liteStarciworkFinding, pathFindings } from './path-findings.mjs';
89
89
  import { onLintSurface } from './architecture/surface.mjs';
@@ -111,8 +111,8 @@ export const CHECK_CODES = Object.freeze([
111
111
  'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT', 'HFS_MCP_TOOLS_SNAPSHOT',
112
112
  'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_BYPASS_MARKER', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'BE_INTEGRATION_SPEC_MISSING', 'FE_GRAPHQL_CONTRACT', 'FE_NO_TESTS', 'HFS_MONO_WORKSPACES', 'HFS_MONO_FE_WORKSPACE', 'HFS_MONO_NEST_PROJECTS', 'HFS_MONO_WORKSPACE_DEP', 'BE_CLI_REQUIRED', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG', 'HFS_SERVICE_PLACEMENT', 'HFS_IMAGE_UNPINNED', 'HFS_SERVICE_STACK_DECLARATION', 'HFS_EVENT_CONTRACT', 'BE_ASYNC_SPEC_MISSING', 'BE_SAGA_STEP_COMPENSATION', 'BE_SAGA_STATE_VERSIONED', 'BE_SAGA_EVENT_CONTRACT', 'BE_SAGA_CONSUMER_DEDUPE', 'BE_EVENT_CLASS_CONTRACT', 'BE_PATTERN_SPEC_MISSING', 'BE_CONTRACT_BREAKING', 'BE_KIND_DECLARATION', 'BE_KIND_EMPTY', 'HFS_DOCKER_BUILD_CONTEXT', 'HFS_DOCKER_STAGES', 'HFS_DOCKER_ENTRY', 'HFS_DOCKER_BASE_PIN', 'HFS_DOCKER_SECRETS', 'BE_SAGA_E2E_MISSING',
113
113
  'FE_STACK_FORBIDDEN', 'HFS_EDITION_FORBIDDEN_PRESENT', 'HFS_BACKING_UNDECLARED', 'HFS_BACKING_UNUSED', 'HFS_LITE_SECRET_CUSTODY', 'HFS_STARCIWORK_LITE_FORBIDDEN',
114
- 'DB_MIGRATION_SHAPE', 'DB_RLS_REQUIRED', 'DB_DYNAMIC_DDL', 'DB_POLICY_SHAPE', 'DB_DEFINER_SAFE', 'DB_STORAGE_POLICY', 'DB_CONFIG_POLICY', 'DB_TYPES_DRIFT',
115
- 'HFS_DOCKER_HEALTHCHECK_FAKE', 'DB_BE_ONLY_ACCESS', 'DB_REALTIME_UNLISTED', 'DB_APP_ROLE', 'DB_FORCE_RLS_DEFAULT',
114
+ 'DB_MIGRATION_SHAPE', 'DB_RLS_REQUIRED', 'DB_DYNAMIC_DDL', 'DB_POLICY_SHAPE', 'DB_DEFINER_SAFE', 'DB_STORAGE_POLICY', 'LITE_DB_MIGRATION',
115
+ 'HFS_DOCKER_HEALTHCHECK_FAKE', 'DB_APP_ROLE', 'DB_FORCE_RLS_DEFAULT',
116
116
  'DB_POLICY_TAUTOLOGY', 'DB_VIEW_UNSAFE', 'DB_STORAGE_PREFIX', 'DB_DEFINER_REVOKE',
117
117
  'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_COVERAGE_SCOPE_DRIFT', 'HFS_FORMAT',
118
118
  'HFS_EMPTY_DIR', 'HFS_GHOST_TREE', 'HFS_UNTRACKED_ROOT_ENTRY',
@@ -209,7 +209,7 @@ function minimumInstanceFindings(resolver, required, instances) {
209
209
  function contentRuleFindings({ repoRoot, repo, resolver, files, all, scoped, pins, inScope, isRoot, appSide, editionDeclaration, manifest, phase }) {
210
210
  const findings = [
211
211
  ...pinFindings({ repoRoot, files, profile: repo.profile, pins, only: scoped }),
212
- ...supabaseSecretFindings({ repoRoot, files: files.filter((file) => inScope(file)), resolver, repo }),
212
+ ...liteSecretFindings({ repoRoot, files: files.filter((file) => inScope(file)), resolver, repo }),
213
213
  ...editionFindings({ repoRoot, files: files.filter((file) => inScope(file)), repo, resolver, manifest, withDeclaration: editionDeclaration }),
214
214
  ...feStackFindings({ repoRoot, files: files.filter((file) => inScope(file)), repo, manifest }),
215
215
  ...repoLocalCheckFindings({ repoRoot, files }),
@@ -410,7 +410,7 @@ export function withCheckProofs(result, { repoRoot, root = skillRoot, manifest =
410
410
  const proofFindings = Object.values(reports).flatMap(report => report.findings);
411
411
  const why = readWhy(root, [...new Set(proofFindings.map(finding => finding.code))]);
412
412
  // The explicit whole-tree wiring proof supersedes the ordinary finder's scoped result.
413
- const previous = result.findings.filter(finding => !wiring || finding.code !== BOOT_PROOF_CODE);
413
+ const previous = result.findings.filter(finding => !wiring || (finding.code !== BOOT_PROOF_CODE && finding.code !== BOOT_PROOF_TIMEOUT));
414
414
  const findings = [...previous, ...withWhy(judgedFindings(proofFindings, repo.edition ?? 'full', root), why)];
415
415
  const counts = summarize(findings);
416
416
  return { ...result, ...reports, findings, counts, ok: counts.error === 0 };
@@ -52,7 +52,7 @@ export function nested(inner, outer) {
52
52
  const variantsOf = (slot) => braceVariants(slot.path).map(star);
53
53
 
54
54
  /**
55
- * The tracked slots of the be profile, in manifest order. A slot gated by a connection provider (be.integrations.supabase) holds files only in an
55
+ * The tracked slots of the be profile, in manifest order. A slot gated by a connection provider holds files only in an
56
56
  * app whose connection declares that provider (`providers`), so it takes no part in the scope of any other app.
57
57
  */
58
58
  const beSlots = (manifest, providers) => manifest.slots.filter((slot) => slot.profiles.includes('be') && slot.tracked === 'tracked' && (slot.provider === undefined || providers.includes(slot.provider)));
@@ -4,9 +4,7 @@ import path from 'node:path';
4
4
  import pg from 'libpg-query';
5
5
  import { invocationDir } from '../lib/roots.mjs';
6
6
  import { sqlStringParts as names, walkSqlAst as walk, sqlParser } from '../lib/sql-ast.mjs';
7
- import { databaseDump } from '../api/supabase/database-dump.mjs';
8
- import { databaseQuery } from '../api/supabase/database-query.mjs';
9
- import { migrationRepair } from '../api/supabase/migration-repair.mjs';
7
+ import { spawnSync } from 'node:child_process';
10
8
  import { migrationStamp, isMigrationName, databaseSqlTokens, databaseExecutableTokens } from './rules/database-migrations.mjs';
11
9
  import { baselineMarkers, baselineSourceFindings } from './rules/database-baseline.mjs';
12
10
  import { databaseQueueDeclarations } from './rules/database-queues.mjs';
@@ -16,10 +14,6 @@ import { dbPlanMain } from './db-plan-verb.mjs';
16
14
  await pg.loadModule();
17
15
 
18
16
  export class DbVerbError extends Error {}
19
- const LINKED_PASSWORD_ENV = 'SUPABASE_DB_PASSWORD';
20
- const TELEMETRY_DISABLED_ENV = 'SUPABASE_TELEMETRY_DISABLED';
21
- const TELEMETRY_DEBUG_ENV = 'SUPABASE_TELEMETRY_DEBUG';
22
- const CLI_DEBUG_ENV = 'SUPABASE_DEBUG';
23
17
 
24
18
  /** Strict shared options; unknown flags never reach the database CLI. */
25
19
  export function dbOptions(argv, allowed) {
@@ -28,7 +22,7 @@ export function dbOptions(argv, allowed) {
28
22
  const [flag, inline] = argv[index].split(/=(.*)/s);
29
23
  const name = flag.slice(2);
30
24
  if (!flag.startsWith('--') || !allowed.includes(name) || Object.hasOwn(opts, name)) throw new DbVerbError('unknown or duplicate database option');
31
- if (['apply', 'local', 'json'].includes(name)) {
25
+ if (['apply', 'json'].includes(name)) {
32
26
  if (inline !== undefined && !['true', 'false'].includes(inline)) throw new DbVerbError(`${flag} expects true or false`);
33
27
  opts[name] = inline !== 'false';
34
28
  } else {
@@ -48,7 +42,7 @@ export function dbAppRoot(cwd, opts) {
48
42
  return root;
49
43
  }
50
44
 
51
- /** Immediate SQL files only, never secrets or Supabase's temporary credential files. */
45
+ /** Immediate SQL files only, never secrets or credential files. */
52
46
  export function dbSqlFiles(root, directory = MIGRATIONS_DIR) {
53
47
  const dir = path.join(root, directory);
54
48
  if (!fs.existsSync(dir)) return [];
@@ -59,27 +53,50 @@ export function dbSqlFiles(root, directory = MIGRATIONS_DIR) {
59
53
  }).sort((left, right) => left.name < right.name ? -1 : Number(left.name > right.name));
60
54
  }
61
55
 
62
- /** Supabase adapter uses supabaseSpawn's (repoRoot, args, options) contract; raw failures are private. */
63
- const DB_CALLS = { 'db dump': databaseDump, 'db query': databaseQuery, 'migration repair': migrationRepair };
56
+ /** The database of `--url-env` (default DATABASE_URL): the URL without its password and the password for PGPASSWORD. */
57
+ function connectionOf(env, urlEnv) {
58
+ if (!/^[A-Z][A-Z0-9_]*$/.test(urlEnv)) throw new DbVerbError('--url-env must name an UPPER_CASE environment variable');
59
+ let url;
60
+ try { url = new URL(String(env[urlEnv] ?? '')); } catch { throw new DbVerbError(`${urlEnv} must hold a postgres connection URL`); }
61
+ if (!/^postgres(?:ql)?:$/.test(url.protocol)) throw new DbVerbError(`${urlEnv} must hold a postgres connection URL`);
62
+ const password = decodeURIComponent(url.password);
63
+ url.password = '';
64
+ return { url: url.toString(), env: { ...env, ...(password ? { PGPASSWORD: password } : {}) } };
65
+ }
66
+
67
+ const HISTORY = 'supabase_migrations.schema_migrations';
68
+ const sqlLiteral = (value) => `'${String(value).replaceAll("'", "''")}'`;
64
69
 
65
- function runDatabaseCommand(root, args, options) {
66
- const call = DB_CALLS[args.slice(0, 2).join(' ')];
67
- if (!call) throw new DbVerbError('unsupported database command');
68
- return call(root, args.slice(2), options);
70
+ /** The psql and pg_dump invocation of one operation: a JSON rows query, a schema-only dump, or one history row applied or reverted. */
71
+ function commandOf(op, url) {
72
+ const psql = ['-X', '-q', '-v', 'ON_ERROR_STOP=1', '-d', url];
73
+ if (op.kind === 'query') return ['psql', [...psql, '-A', '-t', '-c', `select coalesce(json_agg(row_to_json(q)), '[]'::json) from (${op.sql.replace(/;\s*$/, '')}) q`]];
74
+ if (op.kind === 'dump') return ['pg_dump', ['--schema-only', '--schema', 'public', '--dbname', url]];
75
+ if (op.kind === 'repair') {
76
+ const change = op.status === 'applied'
77
+ ? `insert into ${HISTORY} (version, name) values (${sqlLiteral(op.stamp)}, ${sqlLiteral(op.name ?? 'baseline')}) on conflict (version) do nothing`
78
+ : `delete from ${HISTORY} where version = ${sqlLiteral(op.stamp)}`;
79
+ return ['psql', [...psql, '-1', '-c', `create schema if not exists supabase_migrations; create table if not exists ${HISTORY} (version text primary key, statements text[], name text)`, '-c', change]];
80
+ }
81
+ throw new DbVerbError('unsupported database command');
69
82
  }
70
83
 
71
- export function runDbSupabase(root, args, ports = {}) {
84
+ /** Runs one database operation through psql or pg_dump (ports.runPostgres in specs); raw failures and output are private. */
85
+ export function runDbPostgres(root, op, ports = {}, urlEnv = 'DATABASE_URL') {
72
86
  const env = ports.env ?? process.env;
73
- // The linked CLI can mint a temporary remote login when no existing password is supplied.
74
- // Check custody presence only; the value stays within the CLI environment.
75
- if (args.includes('--linked') && !ports.runSupabase && !ports.run && (typeof env[LINKED_PASSWORD_ENV] !== 'string' || env[LINKED_PASSWORD_ENV].length === 0)) throw new DbVerbError('linked database operations require an existing SUPABASE_DB_PASSWORD in the CLI environment');
76
- const cliEnv = { ...env, [TELEMETRY_DISABLED_ENV]: '1', [TELEMETRY_DEBUG_ENV]: '0', [CLI_DEBUG_ENV]: '0' };
77
- const result = (ports.runSupabase ?? runDatabaseCommand)(root, args, { env: cliEnv, ...(ports.run ? { run: ports.run } : {}) });
78
- if (result?.error || !(result?.ok === true || result?.status === 0)) throw new DbVerbError('Supabase command failed; inspect the CLI privately');
87
+ const result = ports.runPostgres ? ports.runPostgres(root, op) : (() => {
88
+ const connection = connectionOf(env, urlEnv);
89
+ const [command, args] = commandOf(op, connection.url);
90
+ return spawnSync(command, args, { cwd: root, encoding: 'utf8', env: connection.env, timeout: 120_000, windowsHide: true });
91
+ })();
92
+ if (result?.error || !(result?.ok === true || result?.status === 0)) throw new DbVerbError('database command failed; inspect psql privately');
79
93
  return String(result.stdout ?? '');
80
94
  }
81
95
 
82
- export const repairArgs = (stamp, status, local) => ['migration', 'repair', stamp, '--status', status, local ? '--local' : '--linked'];
96
+ /** A history change: `stamp` recorded as applied (named `name`) or reverted. */
97
+ export const repairOp = (stamp, status, name) => ({ kind: 'repair', stamp, status, ...(name ? { name } : {}) });
98
+ /** The printed form of a history change. */
99
+ export const repairLine = (op) => `history ${op.status} ${op.stamp}${op.name ? ` ${op.name}` : ''}`;
83
100
  export const dbFailure = (verb, error, stderr) => { stderr(`starci app db ${verb}: ${error instanceof DbVerbError ? error.message : 'operation failed; database output was withheld'}\n`); return 2; };
84
101
 
85
102
  function baselinePlan(root, opts, now) {
@@ -114,7 +131,7 @@ function baselineFiles(root) {
114
131
  else if (entry.isFile() && /\.(?:sql|[cm]?[jt]sx?)$/.test(entry.name)) files.push(file);
115
132
  }
116
133
  };
117
- for (const directory of ['supabase', 'be/src', 'be/apps', 'src', 'apps']) visit(directory);
134
+ for (const directory of ['db', 'be/src', 'be/apps', 'src', 'apps']) visit(directory);
118
135
  return files;
119
136
  }
120
137
 
@@ -257,20 +274,21 @@ function metadataSql(metadata) {
257
274
  /** Numeric CLI exit code, injectable IO/clock/CLI; neither files nor history change without --apply. */
258
275
  export function dbBaselineMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), now = () => new Date(), ...ports } = {}) {
259
276
  try {
260
- const opts = dbOptions(argv, ['cwd', 'stamp', 'apply', 'local']);
277
+ const opts = dbOptions(argv, ['cwd', 'stamp', 'apply', 'url-env']);
278
+ const urlEnv = opts['url-env'] ?? 'DATABASE_URL';
261
279
  const root = dbAppRoot(cwd, opts);
262
280
  const plan = baselinePlan(root, opts, now);
263
281
  const files = baselineFiles(root);
264
282
  if (baselineSourceFindings({ repoRoot: root, files, all: files, git: ports.git }).length) throw new DbVerbError('baseline sources and archives must match committed migration history');
265
283
  const metadata = baselineMetadata(root, plan, files);
266
- if (dbPlanMain(['--json', ...(opts.local ? ['--local'] : [])], { ...ports, cwd: root, stdout: () => {}, stderr: () => {} }) !== 0) throw new DbVerbError('baseline requires a clean database plan with all local migrations already applied');
267
- const schema = runDbSupabase(root, ['db', 'dump', opts.local ? '--local' : '--linked'], ports);
284
+ if (dbPlanMain(['--json', '--url-env', urlEnv], { ...ports, cwd: root, stdout: () => {}, stderr: () => {} }) !== 0) throw new DbVerbError('baseline requires a clean database plan with every migration already applied');
285
+ const schema = runDbPostgres(root, { kind: 'dump' }, ports, urlEnv);
268
286
  if (!schema.trim() || baselineMarkers(schema).length || /\bvault\.(?:create_secret|update_secret)\s*\(|\bpassword\s+'[^']+'/i.test(schema)) throw new DbVerbError('schema dump is empty or contains unsupported secret/baseline statements');
269
- const repairs = [...plan.active.map((entry) => repairArgs(entry.stamp, 'reverted', opts.local)), repairArgs(plan.stamp, 'applied', opts.local)];
287
+ const repairs = [...plan.active.map((entry) => repairOp(entry.stamp, 'reverted')), repairOp(plan.stamp, 'applied', 'baseline')];
270
288
  stdout(`${opts.apply ? 'apply' : 'preview'} baseline ${plan.file}; archive ${plan.active.length} migrations\n`);
271
- for (const args of repairs) stdout(`supabase ${args.join(' ')}\n`);
289
+ for (const op of repairs) stdout(`${repairLine(op)}\n`);
272
290
  if (!opts.apply) return 0;
273
- for (const args of repairs) runDbSupabase(root, args, ports);
291
+ for (const op of repairs) runDbPostgres(root, op, ports, urlEnv);
274
292
  fs.mkdirSync(path.join(root, MIGRATIONS_ARCHIVE_DIR), { recursive: true });
275
293
  fs.writeFileSync(path.join(root, plan.file), `-- starci:baseline ${plan.last}\n${metadata.extensions}${metadata.roles}${schema.replace(/^\s+/, '')}\n${metadataSql(metadata)}`, { flag: 'wx' });
276
294
  for (const entry of plan.active) fs.renameSync(path.join(root, entry.file), path.join(root, MIGRATIONS_ARCHIVE_DIR, entry.name));
@@ -1,27 +1,23 @@
1
- // app db plan: compare migration-owned state without fetching commands or Vault values.
1
+ // app db plan: compare migration-owned state (history, pg_cron jobs, pgmq queues) without fetching commands.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { sha256 } from '../../engine/digest.mjs';
5
5
  import pg from 'libpg-query';
6
- import { parse as parseToml } from 'smol-toml';
7
6
  import { invocationDir } from '../lib/roots.mjs';
8
7
  import { sqlStringParts, walkSqlAst as walk, sqlParser, sqlCallArgument as argument, sqlStringLiteral as literal } from '../lib/sql-ast.mjs';
9
- import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles, runDbSupabase, dbFailure } from './db-baseline-verb.mjs';
8
+ import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles, runDbPostgres, dbFailure } from './db-baseline-verb.mjs';
10
9
 
11
10
  await pg.loadModule();
12
11
 
13
- // The pinned Supabase CLI uses positional SQL, --output json and --agent no for a plain rows array.
14
- // Contract: supabase/cli apps/cli/src/commands/db/query/{query.command,query.handler}.ts at the pinned CLI tag.
12
+ // Each inventory is one read-only psql query whose rows come back as JSON (db-baseline-verb.mjs runDbPostgres).
15
13
  const PRESENCE_SQL = `select
16
14
  pg_catalog.to_regclass('supabase_migrations.schema_migrations') is not null as migrations,
17
15
  pg_catalog.to_regclass('cron.job') is not null as cron,
18
- pg_catalog.to_regclass('pgmq.meta') is not null as queues,
19
- pg_catalog.to_regclass('vault.secrets') is not null as vault;`;
16
+ pg_catalog.to_regclass('pgmq.meta') is not null as queues;`;
20
17
  const INVENTORIES = {
21
18
  migrations: 'select version from supabase_migrations.schema_migrations order by version;',
22
19
  cron: 'select jobname as name, schedule, pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(command, \'UTF8\')), \'hex\') as "commandHash", active from cron.job order by jobid;',
23
20
  queues: 'select queue_name as name from pgmq.meta order by queue_name;',
24
- vault: 'select name from vault.secrets order by name;',
25
21
  };
26
22
  const names = (nodes) => sqlStringParts(nodes).join('.');
27
23
  const OWNERSHIP_CALLS = new Set(['pgmq.create', 'pgmq.drop_queue', 'cron.schedule', 'cron.unschedule', 'cron.schedule_in_database', 'cron.alter_job']);
@@ -99,91 +95,9 @@ function inventoryDo(state, node) {
99
95
  for (const statement of statements) inventoryTree(state, statement);
100
96
  }
101
97
 
102
- function quotedEnd(text, start) {
103
- const quote = text[start];
104
- const delimiter = text.startsWith(quote.repeat(3), start) ? quote.repeat(3) : quote;
105
- let cursor = start + delimiter.length;
106
- while (cursor < text.length) {
107
- if (quote === '"' && text[cursor] === '\\') { cursor += 2; continue; }
108
- if (text.startsWith(delimiter, cursor)) {
109
- let end = cursor + delimiter.length;
110
- if (delimiter.length === 3) while (text[end] === quote) end += 1;
111
- return end;
112
- }
113
- cursor += 1;
114
- }
115
- throw new DbVerbError('Supabase config contains an unterminated string');
116
- }
117
-
118
- // Advance over an assignment value without collecting, decoding or resolving its content.
119
- function valueEnd(text, start) {
120
- let cursor = start;
121
- let depth = 0;
122
- while (cursor < text.length) {
123
- const character = text[cursor];
124
- if (character === '"' || character === "'") { cursor = quotedEnd(text, cursor); continue; }
125
- if (character === '#') {
126
- cursor = text.indexOf('\n', cursor);
127
- if (cursor < 0) return text.length;
128
- if (depth === 0) return cursor + 1;
129
- } else if (character === '[' || character === '{') depth += 1;
130
- else if (character === ']' || character === '}') depth -= 1;
131
- else if (character === '\n' && depth === 0) return cursor + 1;
132
- cursor += 1;
133
- }
134
- return cursor;
135
- }
136
-
137
- function assignmentEnd(text, start) {
138
- let cursor = start;
139
- while (cursor < text.length && !['=', '\n', '#'].includes(text[cursor])) {
140
- if (text[cursor] === '"' || text[cursor] === "'") cursor = quotedEnd(text, cursor);
141
- else cursor += 1;
142
- }
143
- return cursor;
144
- }
145
-
146
- function vaultKeys(table, key) {
147
- // The TOML parser sees only the table/key syntax and an invented empty value.
148
- let metadata;
149
- try { metadata = parseToml(`${table}\n${key} = ""`); }
150
- catch { throw new DbVerbError('Supabase config keys could not be inventoried safely'); }
151
- const vault = metadata.db?.vault ?? {};
152
- if (typeof vault !== 'object' || Object.values(vault).some((value) => typeof value !== 'string')) throw new DbVerbError('Vault config requires individual named keys');
153
- return Object.keys(vault);
154
- }
155
-
156
- function configToken(text, cursor, table) {
157
- if (/\s/.test(text[cursor])) return { cursor: cursor + 1, table, keys: [] };
158
- if (text[cursor] === '#' || text[cursor] === '[') {
159
- const end = text.indexOf('\n', cursor);
160
- const line = text.slice(cursor, end < 0 ? undefined : end);
161
- return { cursor: end < 0 ? text.length : end + 1, table: text[cursor] === '[' ? line : table, keys: [] };
162
- }
163
- const end = assignmentEnd(text, cursor);
164
- if (text[end] !== '=') throw new DbVerbError('Supabase config keys could not be inventoried safely');
165
- return { cursor: valueEnd(text, end + 1), table, keys: vaultKeys(table, text.slice(cursor, end)) };
166
- }
167
-
168
- function configVaultNames(root) {
169
- const file = path.join(root, 'supabase/config.toml');
170
- if (!fs.existsSync(file)) return new Map();
171
- const text = fs.readFileSync(file, 'utf8');
172
- const configured = new Map();
173
- let token = { cursor: 0, table: '' };
174
- while (token.cursor < text.length) {
175
- token = configToken(text, token.cursor, token.table);
176
- for (const name of token.keys) {
177
- if (configured.has(name)) throw new DbVerbError('Vault config names must be unique');
178
- configured.set(name, null);
179
- }
180
- }
181
- return configured;
182
- }
183
-
184
98
  function repositoryState(root) {
185
99
  const migrations = dbSqlFiles(root);
186
- const state = { migrations: new Map(migrations.map((entry) => [entry.stamp, null])), cron: new Map(), queues: new Map(), vault: configVaultNames(root) };
100
+ const state = { migrations: new Map(migrations.map((entry) => [entry.stamp, null])), cron: new Map(), queues: new Map() };
187
101
  if (state.migrations.size !== migrations.length) throw new DbVerbError('active migrations must have unique versions');
188
102
  for (const entry of migrations) {
189
103
  const tree = parseSql(fs.readFileSync(path.join(root, entry.file), 'utf8'));
@@ -197,22 +111,19 @@ function repositoryState(root) {
197
111
  return state;
198
112
  }
199
113
 
200
- function queryRows(root, sql, local, ports) {
201
- // The pinned CLI otherwise creates this cache in its linked-query finalizer.
202
- // Existing metadata is sufficient; never inspect the cache or credential files.
203
- if (!local && !ports.runSupabase && !ports.run && !fs.existsSync(path.join(root, 'supabase/.temp/linked-project.json'))) throw new DbVerbError('link the existing project before running a read-only database plan');
204
- const output = runDbSupabase(root, ['db', 'query', sql, local ? '--local' : '--linked', '--output', 'json', '--agent', 'no'], ports);
114
+ function queryRows(root, sql, urlEnv, ports) {
115
+ const output = runDbPostgres(root, { kind: 'query', sql }, ports, urlEnv);
205
116
  let rows;
206
- try { rows = JSON.parse(output); } catch { throw new DbVerbError('Supabase returned unreadable inventory; database output was withheld'); }
207
- if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) throw new DbVerbError('Supabase returned an invalid inventory; database output was withheld');
117
+ try { rows = JSON.parse(output); } catch { throw new DbVerbError('the database returned unreadable inventory; database output was withheld'); }
118
+ if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) throw new DbVerbError('the database returned an invalid inventory; database output was withheld');
208
119
  return rows;
209
120
  }
210
121
 
211
- function remoteState(root, local, ports) {
212
- const rows = queryRows(root, PRESENCE_SQL, local, ports);
213
- if (rows.length !== 1 || Object.keys(INVENTORIES).some((key) => typeof rows[0][key] !== 'boolean')) throw new DbVerbError('Supabase returned invalid inventory availability');
122
+ function remoteState(root, urlEnv, ports) {
123
+ const rows = queryRows(root, PRESENCE_SQL, urlEnv, ports);
124
+ if (rows.length !== 1 || Object.keys(INVENTORIES).some((key) => typeof rows[0][key] !== 'boolean')) throw new DbVerbError('the database returned invalid inventory availability');
214
125
  const state = {};
215
- for (const [key, sql] of Object.entries(INVENTORIES)) state[key] = rows[0][key] ? queryRows(root, sql, local, ports) : [];
126
+ for (const [key, sql] of Object.entries(INVENTORIES)) state[key] = rows[0][key] ? queryRows(root, sql, urlEnv, ports) : [];
216
127
  return state;
217
128
  }
218
129
 
@@ -222,8 +133,8 @@ function compare(expected, actual, kind) {
222
133
  let changed = 0;
223
134
  for (const row of actual) {
224
135
  const key = kind === 'migrations' ? row.version : row.name;
225
- if (key !== null && (typeof key !== 'string' || key.length === 0)) throw new DbVerbError('Supabase returned an invalid inventory identifier');
226
- if (kind === 'cron' && (typeof row.schedule !== 'string' || !/^[a-f0-9]{64}$/.test(row.commandHash ?? '') || typeof row.active !== 'boolean')) throw new DbVerbError('Supabase returned invalid cron metadata');
136
+ if (key !== null && (typeof key !== 'string' || key.length === 0)) throw new DbVerbError('the database returned an invalid inventory identifier');
137
+ if (kind === 'cron' && (typeof row.schedule !== 'string' || !/^[a-f0-9]{64}$/.test(row.commandHash ?? '') || typeof row.active !== 'boolean')) throw new DbVerbError('the database returned invalid cron metadata');
227
138
  if (!unmatched.delete(key)) unexpected += 1;
228
139
  else if (kind === 'cron') {
229
140
  const desired = expected.get(key);
@@ -236,10 +147,10 @@ function compare(expected, actual, kind) {
236
147
  /** Numeric exit codes: 0 equal, 1 drift, 2 invalid/failed; all IO and CLI calls are injectable. */
237
148
  export function dbPlanMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), ...ports } = {}) {
238
149
  try {
239
- const opts = dbOptions(argv, ['cwd', 'local', 'json', 'apply']);
150
+ const opts = dbOptions(argv, ['cwd', 'url-env', 'json', 'apply']);
240
151
  const root = dbAppRoot(cwd, opts);
241
152
  const expected = repositoryState(root);
242
- const actual = remoteState(root, opts.local, ports);
153
+ const actual = remoteState(root, opts['url-env'] ?? 'DATABASE_URL', ports);
243
154
  const summaries = Object.fromEntries(Object.keys(INVENTORIES).map((kind) => [kind, compare(expected[kind], actual[kind], kind)]));
244
155
  const drift = Object.values(summaries).some((item) => item.missing || item.unexpected || item.changed);
245
156
  // --apply is accepted for shared verb ergonomics; a plan never mutates history, queues or jobs.
@@ -5,7 +5,7 @@ import { parseYaml } from '../../engine/yaml.mjs';
5
5
  import { invocationDir } from '../lib/roots.mjs';
6
6
  import { migrationStamp } from './rules/database-migrations.mjs';
7
7
  import { MIGRATIONS_DIR } from './rules/database-constants.mjs';
8
- import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles, runDbSupabase, repairArgs, dbFailure } from './db-baseline-verb.mjs';
8
+ import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles, runDbPostgres, repairOp, repairLine, dbFailure } from './db-baseline-verb.mjs';
9
9
 
10
10
  /** The map value of one old version: a new version, or { file, to, applied } for the colliding-versions case. */
11
11
  function mapEntry(old, raw) {
@@ -39,16 +39,16 @@ function sourceOf(entry, active) {
39
39
  * of the file that ran). No row means nothing ran; a row named for the other file means the one that stays ran. A history that cannot be read is
40
40
  * not guessed: the entry has to say `applied`.
41
41
  */
42
- function recordedAsApplied(root, source, old, local, ports) {
42
+ function recordedAsApplied(root, source, old, urlEnv, ports) {
43
43
  const unreadable = new DbVerbError(`version ${old} is shared by several migrations and the history could not be read; say which one ran with "${old}": { file: <name>, applied: true|false }`);
44
44
  let rows;
45
- try { rows = JSON.parse(runDbSupabase(root, ['db', 'query', `select name from supabase_migrations.schema_migrations where version = '${old}'`, local ? '--local' : '--linked', '--output', 'json', '--agent', 'no'], ports)); } catch { throw unreadable; }
45
+ try { rows = JSON.parse(runDbPostgres(root, { kind: 'query', sql: `select name from supabase_migrations.schema_migrations where version = '${old}'` }, ports, urlEnv)); } catch { throw unreadable; }
46
46
  if (!Array.isArray(rows) || (rows.length > 0 && typeof rows[0]?.name !== 'string')) throw unreadable;
47
47
  return rows.length > 0 && rows[0].name === source.name.slice(15).replace(/\.sql$/, '');
48
48
  }
49
49
 
50
50
  /** YAML maps use quoted UTC stamps as keys and a new version, or { file, to?, applied? }, as values (or a versions: mapping). */
51
- function restampPlan(root, mapFile, { local = false, ports = {} } = {}) {
51
+ function restampPlan(root, mapFile, { urlEnv = 'DATABASE_URL', ports = {} } = {}) {
52
52
  if (!mapFile) throw new DbVerbError('restamp requires --map <restamp.yaml>');
53
53
  let document;
54
54
  try { document = parseYaml(fs.readFileSync(path.resolve(root, mapFile), 'utf8')); }
@@ -67,7 +67,7 @@ function restampPlan(root, mapFile, { local = false, ports = {} } = {}) {
67
67
  if (occupied.has(next) || targets.has(next)) throw new DbVerbError('restamp targets must be unique unoccupied versions');
68
68
  targets.add(next);
69
69
  // a version two files share ran for at most one of them: the history says which, unless the entry does
70
- const applied = entry.applied ?? (shared ? recordedAsApplied(root, source, old, local, ports) : true);
70
+ const applied = entry.applied ?? (shared ? recordedAsApplied(root, source, old, urlEnv, ports) : true);
71
71
  return { old, next, source: source.file, target: `${MIGRATIONS_DIR}/${source.name.replace(old, next)}`, applied };
72
72
  });
73
73
  const moved = new Map(steps.map((step) => [step.source, step.next]));
@@ -79,16 +79,17 @@ function restampPlan(root, mapFile, { local = false, ports = {} } = {}) {
79
79
  /** The complete map is validated before any repair or file mutation. */
80
80
  export function dbRestampMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), ...ports } = {}) {
81
81
  try {
82
- const opts = dbOptions(argv, ['cwd', 'map', 'apply', 'local']);
82
+ const opts = dbOptions(argv, ['cwd', 'map', 'apply', 'url-env']);
83
+ const urlEnv = opts['url-env'] ?? 'DATABASE_URL';
83
84
  const root = dbAppRoot(cwd, opts);
84
- const steps = restampPlan(root, opts.map, { local: opts.local, ports });
85
- const repairs = steps.filter((step) => step.applied).flatMap((step) => [repairArgs(step.old, 'reverted', opts.local), repairArgs(step.next, 'applied', opts.local)]);
85
+ const steps = restampPlan(root, opts.map, { urlEnv, ports });
86
+ const repairs = steps.filter((step) => step.applied).flatMap((step) => [repairOp(step.old, 'reverted'), repairOp(step.next, 'applied', step.target.split('/').at(-1).slice(15).replace(/\.sql$/, ''))]);
86
87
  stdout(`${opts.apply ? 'apply' : 'preview'} restamp ${steps.length} migrations\n`);
87
88
  for (const step of steps) stdout(`${step.source} -> ${step.target}\n`);
88
- for (const args of repairs) stdout(`supabase ${args.join(' ')}\n`);
89
+ for (const op of repairs) stdout(`${repairLine(op)}\n`);
89
90
  if (!opts.apply) return 0;
90
91
  // CLI failures never echo credentials and leave the local files intact for an explicit retry.
91
- for (const args of repairs) runDbSupabase(root, args, ports);
92
+ for (const op of repairs) runDbPostgres(root, op, ports, urlEnv);
92
93
  for (const step of steps) {
93
94
  const source = path.join(root, step.source);
94
95
  const text = fs.readFileSync(source, 'utf8').replace(/^\s*--\s*starci:restamp\s+\d{14}\s+\d{14}\s*\r?\n/, '');
@@ -2,27 +2,23 @@
2
2
  // against a loaded manifest remain in slots.mjs; this module mirrors modules/schemas/hfs-repo.schema.yaml only.
3
3
  import { isPlainObject } from '../../engine/plain-object.mjs';
4
4
  import { APP_KIND, connectionShapeProblems, NAME, PACKAGE_NAME, RUNTIME_KIND, SLOT_ID } from './manifest-shape.mjs';
5
+ import { dbFunctionsOk } from './rule-params-shape.mjs';
5
6
  import { kindShapeProblems, patternShapeProblems } from './declaration-slots.mjs';
6
7
  import { byCodeUnit } from '../lib/list.mjs';
7
8
 
8
- /** Shape problems of the optional hfs.json `supabase` block whose auth posture DB_CONFIG_POLICY checks. */
9
- const isSupabaseStringList = (value) => Array.isArray(value) && value.every((entry) => typeof entry === 'string' && entry.trim());
9
+ /** Shape problems of the optional hfs.json `db` block of a lite app: the environment variable naming its database, its image, forced RLS and its application role. */
10
+ const isStringList = (value) => Array.isArray(value) && value.every((entry) => typeof entry === 'string' && entry.trim());
10
11
 
11
- function supabaseValueProblems(block) {
12
- const bad = [];
13
- if (block.enableSignup !== undefined && typeof block.enableSignup !== 'boolean') bad.push('supabase.enableSignup must be true or false');
14
- if (block.jwtExpiry !== undefined && !(Number.isInteger(block.jwtExpiry) && block.jwtExpiry > 0 && block.jwtExpiry <= 3600)) bad.push('supabase.jwtExpiry must be an integer of seconds, 3600 at most');
15
- if (block.siteUrl !== undefined && (typeof block.siteUrl !== 'string' || !block.siteUrl.trim())) bad.push('supabase.siteUrl must be a URL string');
16
- for (const key of ['redirectUrls', 'forceRls']) if (block[key] !== undefined && !isSupabaseStringList(block[key])) bad.push(`supabase.${key} must be a list of strings`);
17
- return bad;
18
- }
19
-
20
- function supabaseBlockProblems(block) {
12
+ function dbBlockProblems(block) {
21
13
  if (block === undefined) return [];
22
- if (!isPlainObject(block)) return ['supabase must be an object'];
14
+ if (!isPlainObject(block)) return ['db must be an object'];
23
15
  const bad = [];
24
- for (const key of Object.keys(block)) if (!['enableSignup', 'jwtExpiry', 'siteUrl', 'redirectUrls', 'forceRls'].includes(key)) bad.push(`supabase has unknown key ${key}`);
25
- bad.push(...supabaseValueProblems(block));
16
+ for (const key of Object.keys(block)) if (!['urlEnv', 'image', 'forceRls', 'forceRlsExemptions', 'appRole'].includes(key)) bad.push(`db has unknown key ${key}`);
17
+ if (block.urlEnv !== undefined && (typeof block.urlEnv !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(block.urlEnv))) bad.push('db.urlEnv must name an UPPER_CASE environment variable');
18
+ if (block.image !== undefined && (typeof block.image !== 'string' || !/@sha256:[0-9a-f]{64}$/.test(block.image))) bad.push('db.image must be a digest-pinned image reference');
19
+ if (block.forceRls !== undefined && !isStringList(block.forceRls)) bad.push('db.forceRls must be a list of strings');
20
+ if (block.appRole !== undefined && (typeof block.appRole !== 'string' || !/^[a-z][a-z0-9_]*$/.test(block.appRole))) bad.push('db.appRole must name a role');
21
+ if (block.forceRlsExemptions !== undefined && !Array.isArray(block.forceRlsExemptions)) bad.push('db.forceRlsExemptions must be a list of { table, reason }');
26
22
  return bad;
27
23
  }
28
24
 
@@ -95,7 +91,8 @@ function declarationSideProblems(side, value, names) {
95
91
  const bad = [];
96
92
  const at = `sides.${side}`;
97
93
  if (!isPlainObject(value)) return [`${at} must be an object`];
98
- for (const key of Object.keys(value)) if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', 'queueOutbox'].includes(key)) bad.push(`${at} has unknown key ${key}`);
94
+ for (const key of Object.keys(value)) if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', 'queueOutbox', 'dbFunctions'].includes(key)) bad.push(`${at} has unknown key ${key}`);
95
+ if (value.dbFunctions !== undefined && (side !== 'be' || !dbFunctionsOk(value.dbFunctions))) bad.push(`${at}.dbFunctions belongs to the be side and lists {name, reason} database functions with unique names`);
99
96
  bad.push(...appShapeProblems(value.apps, at, side, names));
100
97
  if (isBadUniqueList(value.optionalSlots, (slot) => SLOT_ID.test(String(slot)))) bad.push(`${at}.optionalSlots must be a unique list of slot ids`);
101
98
  if (value.backing !== undefined && side !== 'be') bad.push('backing belongs to the be side');
@@ -127,13 +124,13 @@ function observeBlockProblems(observe) {
127
124
  return bad;
128
125
  }
129
126
 
130
- const DECLARATION_KEYS = new Set(['hfs', 'kind', 'project', 'edition', 'supabase', 'sides', 'browser', 'lexicon', 'observe']);
127
+ const DECLARATION_KEYS = new Set(['hfs', 'kind', 'project', 'edition', 'db', 'sides', 'browser', 'lexicon', 'observe']);
131
128
  const RUNTIME_DECLARATION_KEYS = new Set(['hfs', 'kind', 'project']);
132
129
 
133
130
  function productDeclarationProblems(declaration, profiles) {
134
131
  const bad = [];
135
132
  for (const key of Object.keys(declaration)) if (!DECLARATION_KEYS.has(key)) bad.push(`unknown key ${key}`);
136
- bad.push(...supabaseBlockProblems(declaration.supabase), ...lexiconBlockProblems(declaration.lexicon), ...observeBlockProblems(declaration.observe));
133
+ bad.push(...dbBlockProblems(declaration.db), ...lexiconBlockProblems(declaration.lexicon), ...observeBlockProblems(declaration.observe));
137
134
  if (declaration.browser !== undefined && declaration.browser !== true) bad.push('browser is `true` when the app has a browser journey (slot app.browser), and is left out otherwise');
138
135
  if (declaration.kind !== APP_KIND) bad.push(`kind must be ${APP_KIND} (a product is one app repository with a be and an fe side) or ${RUNTIME_KIND} (the StarCi runtime repository)`);
139
136
  if (!isPlainObject(declaration.sides) || Object.keys(declaration.sides).sort(byCodeUnit).join() !== profiles.join()) { bad.push('sides must declare exactly be and fe'); return bad; }
@@ -15,12 +15,12 @@ const ENV_PREFIX =/^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
15
15
  const CONTEXT_ISOLATIONS = Object.freeze(["database", "schema"]);
16
16
  /** The app kinds that may own a context: the services, never the migrate or cli apps that only run migrations. */
17
17
  const CONTEXT_OWNER_KINDS = Object.freeze(["api", "worker"]);
18
- /** The database engines a connection may run on: a self-described Postgres, or the Postgres of a Supabase project. */
19
- const CONNECTION_PROVIDERS = Object.freeze(["postgres", "supabase"]);
18
+ /** The database engine a connection runs on. */
19
+ const CONNECTION_PROVIDERS = Object.freeze(["postgres"]);
20
20
  /** The editions an app repository may declare in hfs.json (`full` when absent): the filter slots.mjs applies. */
21
21
  export const EDITIONS = Object.freeze(["full", "lite"]);
22
- /** What decides where schema changes live (ruleParams.<side>.schemaAuthority): TypeORM migrations under persistence/, or the Supabase migration tree. */
23
- export const SCHEMA_AUTHORITIES = Object.freeze(["typeorm", "supabase"]);
22
+ /** What decides where schema changes live (ruleParams.<side>.schemaAuthority): TypeORM migrations under persistence/, or the SQL migration tree db/migrations. */
23
+ export const SCHEMA_AUTHORITIES = Object.freeze(["typeorm", "sql"]);
24
24
  /**
25
25
  * The shape problems of the `connections` of one side: a list of {name, envPrefix, owner, isolation}, the owner an api or worker app
26
26
  * of the same side (a connection is a bounded context and one service owns it), isolation `database` or `schema`.
@@ -87,7 +87,7 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
87
87
  export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
88
88
  const strList = (v) => stringList(v);
89
89
 
90
- const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy'];
90
+ const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy', 'rewrittenBy'];
91
91
  /** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
92
92
  const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
93
93
  /** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
@@ -114,6 +114,7 @@ function appIdentityProblems(slot, at, appScope, scopes) {
114
114
  if (!SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like be.transport.http`);
115
115
  if (profilesInvalid(slot.profiles, appScope, scopes)) bad.push(`${at}: profiles must be [app] or a unique non-empty subset of be, fe`);
116
116
  else if ((slot.profiles[0] === appScope) !== String(slot.id).startsWith(`${appScope}.`)) bad.push(`${at}: an app-root slot has profiles [app] and an id app.<name>, and only it`);
117
+ if (slot.rewrittenBy !== undefined && (typeof slot.rewrittenBy !== 'string' || !slot.rewrittenBy.trim() || slot.tracked !== 'ignored' || String(slot.path).endsWith('/'))) bad.push(`${at}: rewrittenBy names the dev server that writes the files of an ignored file slot`);
117
118
  return bad;
118
119
  }
119
120