@starci/hfs 4.9.11 → 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 (219) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +7 -9
  3. package/db/backup-receipt.mjs +10 -47
  4. package/db/drift.mjs +1 -1
  5. package/db/dump.mjs +11 -10
  6. package/db/entity-list.mjs +1 -1
  7. package/db/migrate.mjs +83 -259
  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/package.json +1 -1
  13. package/release/gate.mjs +3 -15
  14. package/runtime/knowledge/hfs/canon-pins.yaml +44 -24
  15. package/runtime/knowledge/hfs/design-truths.yaml +1 -1
  16. package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
  17. package/runtime/knowledge/hfs/rules.yaml +177 -155
  18. package/runtime/knowledge/hfs/slots.yaml +164 -172
  19. package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
  20. package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
  21. package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
  22. package/runtime/knowledge/patterns/be/projections.yaml +7 -5
  23. package/runtime/knowledge/patterns/be/queues.yaml +8 -8
  24. package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
  25. package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
  26. package/runtime/knowledge/patterns/be/saga.yaml +3 -3
  27. package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
  28. package/runtime/modules/kernel/command-policy.yaml +1 -1
  29. package/runtime/modules/kernel/failure-codes.yaml +65 -115
  30. package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
  31. package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
  32. package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
  33. package/runtime/scripts/hfs/architecture/index.mjs +2 -3
  34. package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
  35. package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
  36. package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
  37. package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
  38. package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
  39. package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
  40. package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
  41. package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
  42. package/runtime/scripts/hfs/check.mjs +6 -6
  43. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  44. package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
  45. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
  46. package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
  47. package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
  48. package/runtime/scripts/hfs/manifest-shape.mjs +4 -4
  49. package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
  50. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  51. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  52. package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
  53. package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
  54. package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
  55. package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
  56. package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
  57. package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
  58. package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
  59. package/runtime/scripts/hfs/rules/database.mjs +36 -39
  60. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  61. package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
  62. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
  63. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
  64. package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
  65. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  66. package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
  67. package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
  68. package/runtime/scripts/hfs/slots.mjs +8 -2
  69. package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
  70. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  71. package/runtime/scripts/lib/swarm-stack.mjs +7 -4
  72. package/scaffold/add-app.mjs +2 -10
  73. package/scaffold/add-cli-lite.mjs +6 -6
  74. package/scaffold/add-compose.mjs +1 -1
  75. package/scaffold/add-inbox-lite.mjs +4 -8
  76. package/scaffold/add-plan.mjs +1 -1
  77. package/scaffold/add-queue-lite.mjs +16 -24
  78. package/scaffold/add-table.mjs +34 -26
  79. package/scaffold/add.mjs +2 -2
  80. package/scaffold/app.mjs +19 -86
  81. package/scaffold/lite-exports.mjs +1 -1
  82. package/src/main.mjs +19 -20
  83. package/stack/target-binding.mjs +0 -60
  84. package/stack/urgent-floor.mjs +2 -2
  85. package/sync/index.mjs +32 -8
  86. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
  87. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
  88. package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
  89. package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
  90. package/templates/app/gitignore-lite +0 -3
  91. package/templates/app/package-scripts/db.json +9 -0
  92. package/templates/app/package-scripts/package.json +0 -1
  93. package/templates/app/package-scripts-lite/package.json +1 -1
  94. package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
  95. package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
  96. package/templates/app/skeleton/README.md +1 -1
  97. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
  98. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
  99. package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
  100. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
  101. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
  102. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
  103. package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
  104. package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
  105. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
  106. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
  107. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
  108. package/templates/app/skeleton-lite/README.md +11 -11
  109. package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
  110. package/templates/app/tool-config-lite/prettierignore +0 -1
  111. package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
  112. package/templates/be/inbox/database-client.ts.tpl +13 -4
  113. package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
  114. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
  115. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
  116. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
  117. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
  118. package/templates/be/inbox/platform/migration.sql.tpl +22 -0
  119. package/templates/be/inbox/service.spec.ts.tpl +2 -2
  120. package/templates/be/inbox/service.ts.tpl +1 -2
  121. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
  122. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
  123. package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
  124. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
  125. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
  126. package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
  127. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
  128. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
  129. package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
  130. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
  131. package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
  132. package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
  133. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
  134. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
  135. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
  136. package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
  137. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
  138. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
  139. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
  140. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
  141. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
  142. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
  143. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
  144. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +6 -6
  145. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
  146. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
  147. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
  148. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
  149. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
  150. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
  151. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
  152. package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
  153. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
  154. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
  155. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
  156. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
  157. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
  158. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
  159. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
  160. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
  161. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
  162. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
  163. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
  164. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
  165. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
  166. package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
  167. package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
  168. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +6 -0
  169. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  170. package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
  171. package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
  172. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
  173. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
  174. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
  175. package/templates/be/table/entities.index.ts.tpl +4 -0
  176. package/templates/be/table/entity.ts.tpl +21 -0
  177. package/templates/be/table/index.ts.tpl +1 -0
  178. package/templates/be/table/module.ts.tpl +1 -1
  179. package/templates/be/table/service.api.ts.tpl +10 -11
  180. package/templates/be/table/service.ts.tpl +5 -6
  181. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
  182. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
  183. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
  184. package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
  185. package/db/local-migrate.mjs +0 -194
  186. package/db/reset.mjs +0 -125
  187. package/emit/db-types.mjs +0 -271
  188. package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
  189. package/runtime/scripts/api/supabase/database-push.mjs +0 -5
  190. package/runtime/scripts/api/supabase/database-query.mjs +0 -5
  191. package/runtime/scripts/api/supabase/lib.mjs +0 -55
  192. package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
  193. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
  194. package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
  195. package/scaffold/supabase-ports.mjs +0 -26
  196. package/templates/app/package-scripts/supabase.json +0 -7
  197. package/templates/app/skeleton-lite/supabase/config.toml +0 -48
  198. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
  199. package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
  200. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
  201. package/templates/be/inbox/sql.ts.tpl +0 -16
  202. package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
  203. package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
  204. package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
  205. package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
  206. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
  207. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
  208. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
  209. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
  210. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
  211. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
  212. package/templates/be/table/rows.ts.tpl +0 -4
  213. package/templates/be/table/sql.api.ts.tpl +0 -7
  214. package/templates/be/table/sql.ts.tpl +0 -4
  215. package/templates/fe/auth/supabase-client.ts.tpl +0 -38
  216. package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
  217. package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
  218. package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
  219. /package/templates/app/skeleton-lite/{supabase → db}/seed.sql +0 -0
@@ -1,10 +1,8 @@
1
- // R254-R256 inspect the final database access state, so later revokes and policy drops retire old browser doors.
1
+ // R255-R256 inspect the final database access state: the application role, forced RLS, policies, views and storage prefixes.
2
2
  import { found } from './read.mjs';
3
3
  import { parseSql } from '../sql/pg-parse.mjs';
4
4
  import { eachInOrder } from '../../lib/in-order.mjs';
5
5
 
6
- export const DB_BE_ONLY_ACCESS = 'DB_BE_ONLY_ACCESS';
7
- export const DB_REALTIME_UNLISTED = 'DB_REALTIME_UNLISTED';
8
6
  export const DB_APP_ROLE = 'DB_APP_ROLE';
9
7
  export const DB_FORCE_RLS_DEFAULT = 'DB_FORCE_RLS_DEFAULT';
10
8
  export const DB_POLICY_TAUTOLOGY = 'DB_POLICY_TAUTOLOGY';
@@ -20,8 +18,6 @@ const unwrap = (node) => node?.TypeCast ? unwrap(node.TypeCast.arg) : node;
20
18
  const stringValue = (node) => unwrap(node)?.A_Const?.sval?.sval;
21
19
  const operator = (node) => names(node.name);
22
20
  const publicRead = (policy) => policy.cmd === 'select' && policy.name.endsWith('_public_read');
23
- const anonymous = (role) => role === 'anon' || role === 'public';
24
- const browserRole = (role) => anonymous(role) || role === 'authenticated';
25
21
  const AND = ['AND', 'EXPR'].join('_');
26
22
  const OR = ['OR', 'EXPR'].join('_');
27
23
  const NOT = ['NOT', 'EXPR'].join('_');
@@ -119,40 +115,10 @@ function updatePrivileges(state, granting, privileges) {
119
115
  if (privileges.some((privilege) => ['select', 'all'].includes(privilege))) state.revokedRead = true;
120
116
  }
121
117
 
122
- function grantAccessFindings(grants, policies, exposed, directAccess) {
123
- const findings = [];
124
- for (const state of grants.values()) {
125
- if (!state.privileges.size || !browserRole(state.role)) continue;
126
- if (!exposed.has(state.target.split('.')[0]) || directAccess.has(state.target)) continue;
127
- const readOnly = state.privileges.size === 1 && state.privileges.has('select');
128
- const policy = policies.some((item) => `${item.schema}.${item.table}` === state.target && publicRead(item)
129
- && item.roles.some((role) => role === state.role || role === 'public'));
130
- if (anonymous(state.role) && state.objtype === 'OBJECT_TABLE' && readOnly && policy) continue;
131
- findings.push(note(DB_BE_ONLY_ACCESS, state, `grants ${state.role} data privileges on ${state.target}; browser data access requires supabase.directAccess, or anonymous SELECT beside *_public_read`, { table: state.target }));
132
- }
133
- return findings;
134
- }
135
-
136
- function defaultAccessFindings(analyses, exposed) {
137
- const findings = [];
138
- for (const { file, facts } of analyses) {
139
- for (const defaults of facts.defaultPrivileges) {
140
- if (!defaults.grant || !['OBJECT_TABLE', SEQUENCE].includes(defaults.objtype)) continue;
141
- if (defaults.schemas?.length && !defaults.schemas.some((schema) => exposed.has(schema))) continue;
142
- if (defaults.grantees.some(browserRole)) findings.push(note(DB_BE_ONLY_ACCESS, { ...defaults, file }, 'grants default browser data privileges; exposed data grants must be explicit per table'));
143
- }
144
- }
145
- return findings;
146
- }
147
-
148
- function policyFindings(policies, exposed, directAccess, appRole, helpers) {
118
+ function policyFindings(policies, appRole, helpers) {
149
119
  const findings = [];
150
120
  for (const policy of policies) {
151
121
  const table = `${policy.schema}.${policy.table}`;
152
- const listed = directAccess.has(table);
153
- if (exposed.has(policy.schema) && !listed && policy.roles.some((role) => browserRole(role) && !(anonymous(role) && publicRead(policy)))) {
154
- findings.push(note(DB_BE_ONLY_ACCESS, policy, `policy ${policy.name} opens ${table} to browser roles; list intentional access in supabase.directAccess`, { table, policy: policy.name }));
155
- }
156
122
  if (policy.roles.every((role) => role === appRole) || publicRead(policy)) continue;
157
123
  const predicates = [policy.qual, policy.withCheck].filter((expression) => expression !== undefined);
158
124
  if (!predicates.length || predicates.some((expression) => !principalPredicate(expression, helpers))) {
@@ -187,34 +153,6 @@ function roleStatementFindings(statement, appRole) {
187
153
  return [note(DB_APP_ROLE, statement, `${appRole} must LOGIN NOINHERIT without SUPERUSER, BYPASSRLS or administrative role attributes`)];
188
154
  }
189
155
 
190
- function publicationTargets(node, defaultSchema) {
191
- if (node.for_all_tables) return ['*'];
192
- return (node.pubobjects ?? []).map(({ PublicationObjSpec: spec }) => {
193
- if (spec.pubtable?.relation) return relationName(spec.pubtable.relation, defaultSchema);
194
- return `${spec.name ?? defaultSchema}.*`;
195
- });
196
- }
197
-
198
- function publicationFindings(analyses, directAccess) {
199
- const tables = new Map();
200
- for (const { file, facts } of analyses) {
201
- for (const statement of facts.statements) {
202
- applyPublication(tables, { ...statement, file });
203
- }
204
- }
205
- return [...tables].filter(([table]) => !directAccess.has(table)).map(([table, source]) => note(DB_REALTIME_UNLISTED, source, `supabase_realtime publishes unlisted ${table}; every browser data table must be in supabase.directAccess`, { table }));
206
- }
207
-
208
- function applyPublication(tables, statement) {
209
- const { type, node, defaultSchema } = statement;
210
- if (!['CreatePublicationStmt', 'AlterPublicationStmt'].includes(type) || node.pubname !== 'supabase_realtime') return;
211
- if (type === 'CreatePublicationStmt' || node.action === 'AP_SetObjects') tables.clear();
212
- for (const table of publicationTargets(node, defaultSchema)) {
213
- if (node.action === 'AP_DropObjects') tables.delete(table);
214
- else tables.set(table, statement);
215
- }
216
- }
217
-
218
156
  function tableStates(analyses) {
219
157
  const tables = new Map();
220
158
  let owner = null;
@@ -251,10 +189,10 @@ function applyTableAlter(tables, statement) {
251
189
  tables.set(key, table);
252
190
  }
253
191
 
254
- function forceFindings(analyses, supabase, appRole) {
255
- const exemptions = new Set((supabase.forceRlsExemptions ?? []).filter((item) => typeof item?.table === 'string' && typeof item.reason === 'string' && item.reason.trim()).map((item) => qualify(item.table)));
192
+ function forceFindings(analyses, db, appRole) {
193
+ const exemptions = new Set((db.forceRlsExemptions ?? []).filter((item) => typeof item?.table === 'string' && typeof item.reason === 'string' && item.reason.trim()).map((item) => qualify(item.table)));
256
194
  return [...tableStates(analyses)].filter(([name, table]) => table.owner === appRole && !table.forced && !exemptions.has(name))
257
- .map(([table, source]) => note(DB_FORCE_RLS_DEFAULT, source, `${appRole} owns ${table} without FORCE ROW LEVEL SECURITY; an exemption needs supabase.forceRlsExemptions with a table and reason`, { table }));
195
+ .map(([table, source]) => note(DB_FORCE_RLS_DEFAULT, source, `${appRole} owns ${table} without FORCE ROW LEVEL SECURITY; an exemption needs hfs.json db.forceRlsExemptions with a table and reason`, { table }));
258
196
  }
259
197
 
260
198
  function viewFindings(analyses, grants, exposed) {
@@ -318,10 +256,9 @@ function storagePrefixFindings(policies, helpers) {
318
256
  }
319
257
 
320
258
  /** All new access checks run on the ordered active migrations in either edition, through checkDatabase. */
321
- export async function databaseAccessFindings({ analyses, exposed, supabase = {} }) {
322
- const declared = supabase ?? {};
259
+ export async function databaseAccessFindings({ analyses, exposed, db = {} }) {
260
+ const declared = db ?? {};
323
261
  const appRole = declared.appRole ?? 'app_be';
324
- const directAccess = new Set((declared.directAccess ?? []).filter((table) => typeof table === 'string').map(qualify));
325
262
  const policyMap = new Map();
326
263
  const grants = new Map();
327
264
  for (const { file, facts } of analyses) {
@@ -331,10 +268,7 @@ export async function databaseAccessFindings({ analyses, exposed, supabase = {}
331
268
  const policies = [...policyMap.values()];
332
269
  const helpers = await principalHelpers(analyses);
333
270
  return [
334
- ...grantAccessFindings(grants, policies, exposed, directAccess),
335
- ...defaultAccessFindings(analyses, exposed),
336
- ...policyFindings(policies, exposed, directAccess, appRole, helpers),
337
- ...publicationFindings(analyses, directAccess),
271
+ ...policyFindings(policies, appRole, helpers),
338
272
  ...roleFindings(analyses, appRole),
339
273
  ...forceFindings(analyses, declared, appRole),
340
274
  ...viewFindings(analyses, grants, exposed),
@@ -8,8 +8,8 @@ import { found, readText } from './read.mjs';
8
8
  import { migrationStamp } from './database-migrations.mjs';
9
9
  import { DB_BASELINE_SHAPE, DB_ARCHIVE_MUTATED, MIGRATIONS_DIR, MIGRATIONS_ARCHIVE_DIR } from './database-constants.mjs';
10
10
 
11
- const ACTIVE = /^supabase\/migrations\/\d{14}_[^/]+\.sql$/;
12
- const ARCHIVED = /^supabase\/migrations\/_archive\/\d{14}_[^/]+\.sql$/;
11
+ const ACTIVE = /^db\/migrations\/\d{14}_[^/]+\.sql$/;
12
+ const ARCHIVED = /^db\/migrations\/archive\/\d{14}_[^/]+\.sql$/;
13
13
  const stampOf = (file) => /(?:^|\/)(\d{14})_/.exec(file)?.[1] ?? null;
14
14
 
15
15
  /** Header comments only: SQL literals and later comments cannot impersonate a baseline marker. */
@@ -68,7 +68,7 @@ function shapeFindings(repoRoot, active, archived) {
68
68
  if (!archived.length) return { findings: [], marked };
69
69
  const findings = [];
70
70
  if (marked.length !== 1) {
71
- findings.push(found(DB_BASELINE_SHAPE, MIGRATIONS_DIR, 'a nonempty _archive requires exactly one active -- starci:baseline <archived-last-stamp> header marker'));
71
+ findings.push(found(DB_BASELINE_SHAPE, MIGRATIONS_DIR, 'a nonempty archive requires exactly one active -- starci:baseline <archived-last-stamp> header marker'));
72
72
  return { findings, marked };
73
73
  }
74
74
  const latest = archived.map(stampOf).filter((stamp) => stamp && migrationStamp(stamp) !== null).sort((left, right) => left.localeCompare(right)).at(-1);
@@ -1,141 +1,13 @@
1
- // database-config.mjs - L08 Supabase TOML policy and L09 generated-type drift checks. Parsing and type emission stay
2
- // lazy/injected so applications without Supabase do not load either tool and this rule starts no Docker process.
3
- import { sameText } from '../../lib/same-text.mjs';
4
- import path from 'node:path';
5
- import { parse as parseTomlSync } from 'smol-toml';
6
- import { found, readJson, readText } from './read.mjs';
1
+ // database-config.mjs - the database content checks of db/ (R259: extensions, custody SQL, destructive migrations, seed data). It starts no
2
+ // database and no Docker process.
3
+ import { found, readText } from './read.mjs';
7
4
  import {
8
- CONFIG_FILE, DB_API_EXPOSED, DB_CONFIG_POLICY, DB_EXTENSION_ALLOWED, DB_TYPES_DRIFT, DB_VAULT_WRITE,
9
- HFS_SEED_PII, isActiveMigration, TYPES_FILE,
5
+ DB_EXTENSION_ALLOWED, DB_VAULT_WRITE, HFS_SEED_PII, isActiveMigration, SEED_FILE,
10
6
  } from './database-constants.mjs';
11
7
  import { databaseExecutableTokens, databaseSqlStatements, databaseSqlTokens, destructiveMigrationFindings } from './database-migrations.mjs';
12
8
  import { byCodeUnit } from '../../lib/list.mjs';
13
- const ENV_REF = /^env\([A-Za-z_]\w*\)$/;
14
- const PUBLIC_KEYS = new Set(['anon_key', 'publishable_key', 'public_key']);
15
-
16
-
17
- function tomlEntries(value, path = []) {
18
- if (!value || typeof value !== 'object') return [];
19
- if (Array.isArray(value)) return value.flatMap((item) => tomlEntries(item, path));
20
- return Object.entries(value).flatMap(([key, child]) => {
21
- const at = [...path, key];
22
- if (child && typeof child === 'object') {
23
- if (Array.isArray(child)) {
24
- if (child.every((item) => item && typeof item === 'object' && !Array.isArray(item))) return child.flatMap((item) => tomlEntries(item, at));
25
- } else return tomlEntries(child, at);
26
- }
27
- return [[at.join('.'), key, child]];
28
- });
29
- }
30
-
31
- const isCredentialKey = (key) => {
32
- const leaf = key.toLowerCase();
33
- return ['secret', 'client_id', 'key', 'pass', 'password', 'token'].includes(leaf)
34
- || /_(secret|password|token|client_id|api_key)$/.test(leaf)
35
- || (leaf.endsWith('_key') && !PUBLIC_KEYS.has(leaf));
36
- };
37
-
38
- const escapeRegexPart = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
39
-
40
- const tomlLine = (text, key) => {
41
- const expression = new RegExp(String.raw`^\s*["']?${escapeRegexPart(key)}["']?\s*=`, 'm');
42
- const match = expression.exec(text);
43
- return match ? text.slice(0, match.index).split('\n').length : undefined;
44
- };
45
-
46
- function credentialConfigFindings(file, text, toml) {
47
- const findings = [];
48
- for (const [at, key, value] of tomlEntries(toml)) {
49
- if (!isCredentialKey(key)) continue;
50
- const line = tomlLine(text, key);
51
- if (typeof value !== 'string' || !ENV_REF.test(value)) {
52
- const lineNote = line ? `:${line}` : '';
53
- findings.push(found(DB_CONFIG_POLICY, file, `${file}${lineNote} ${at} holds a literal credential; a secret in config.toml is written env(NAME), never a value`, { ...(line ? { line } : {}), key: at }));
54
- }
55
- }
56
- return findings;
57
- }
58
-
59
- // A finding for an auth setting of config.toml that differs from what hfs.json supabase.<field> declares; `shown` is the setting as read.
60
- const settingFinding = (file, key, field, shown, declaredValue, note = '') => found(DB_CONFIG_POLICY, file, `${file} ${key} is ${shown}; hfs.json supabase.${field} declares ${declaredValue}${note}`, { key, declared: declaredValue });
61
-
62
- const shownSetting = (value) => JSON.stringify(value ?? 'absent');
63
-
64
- function signupFindings(file, auth, declared) {
65
- const findings = [];
66
- if (declared.enableSignup === undefined) return findings;
67
- if (auth.enable_signup !== declared.enableSignup) findings.push(settingFinding(file, 'auth.enable_signup', 'enableSignup', shownSetting(auth.enable_signup), declared.enableSignup, ' - the invite posture is a checked fact, not a doc claim'));
68
- if (auth.email?.enable_signup !== undefined && auth.email.enable_signup !== declared.enableSignup) {
69
- findings.push(settingFinding(file, 'auth.email.enable_signup', 'enableSignup', JSON.stringify(auth.email.enable_signup), declared.enableSignup));
70
- }
71
- return findings;
72
- }
73
-
74
- function redirectUrlFindings(file, auth, declared) {
75
- const findings = [];
76
- if (declared.redirectUrls === undefined) return findings;
77
- const configured = Array.isArray(auth.additional_redirect_urls) ? auth.additional_redirect_urls : [];
78
- for (const url of configured) {
79
- if (!declared.redirectUrls.includes(url)) findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.additional_redirect_urls holds ${url}, which hfs.json supabase.redirectUrls does not declare; the config states nothing the app did not declare`, { key: 'auth.additional_redirect_urls', url }));
80
- }
81
- for (const url of declared.redirectUrls) {
82
- if (!configured.includes(url)) findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.additional_redirect_urls omits ${url}, which hfs.json supabase.redirectUrls declares; the config and declaration must carry the same redirect set`, { key: 'auth.additional_redirect_urls', url }));
83
- }
84
- return findings;
85
- }
86
-
87
- function declarationConfigFindings(file, auth, declared) {
88
- const findings = [];
89
- if (declared.jwtExpiry !== undefined && auth.jwt_expiry !== declared.jwtExpiry) findings.push(settingFinding(file, 'auth.jwt_expiry', 'jwtExpiry', shownSetting(auth.jwt_expiry), declared.jwtExpiry));
90
- findings.push(...signupFindings(file, auth, declared));
91
- if (declared.siteUrl !== undefined && auth.site_url !== declared.siteUrl) findings.push(settingFinding(file, 'auth.site_url', 'siteUrl', shownSetting(auth.site_url), declared.siteUrl));
92
- findings.push(...redirectUrlFindings(file, auth, declared));
93
- return findings;
94
- }
95
-
96
- /** The parsed TOML of `text`, or null when it does not parse. */
97
- export async function parseToml(text) {
98
- try {
99
- const module = await import('smol-toml');
100
- return module.parse(text);
101
- } catch {
102
- return null;
103
- }
104
- }
105
-
106
- /** L08 policy findings in one already-parsed supabase/config.toml. */
107
- export function configFindings({ file, text, toml, supabase }) {
108
- const findings = credentialConfigFindings(file, text, toml);
109
- const auth = toml?.auth ?? {};
110
- if (auth.jwt_expiry !== undefined && (typeof auth.jwt_expiry !== 'number' || auth.jwt_expiry > 3600)) {
111
- findings.push(found(DB_CONFIG_POLICY, file, `${file} auth.jwt_expiry is ${JSON.stringify(auth.jwt_expiry)}; the access token lives at most 3600 seconds`, { key: 'auth.jwt_expiry' }));
112
- }
113
- if (!supabase) return findings;
114
- const declared = {
115
- enableSignup: supabase.enableSignup, jwtExpiry: supabase.jwtExpiry, siteUrl: supabase.siteUrl,
116
- redirectUrls: Array.isArray(supabase.redirectUrls) ? supabase.redirectUrls : undefined,
117
- };
118
- findings.push(...declarationConfigFindings(file, auth, declared));
119
- return findings;
120
- }
121
-
122
- /** L09 drift findings for the committed generated database type file. */
123
- export async function typesFindings({ repoRoot, files, emitTypes, declaration }) {
124
- if (typeof emitTypes !== 'function') return [];
125
- const tracked = new Set(files);
126
- let emitted;
127
- try { emitted = await emitTypes({ repoRoot, declaration }); } catch (error) {
128
- return [found(DB_TYPES_DRIFT, TYPES_FILE, `${TYPES_FILE} cannot be verified: the types emit failed (${String(error?.message ?? error).split('\n').filter(Boolean).slice(0, 3).join(' | ')})`, { drift: 'emit-failed' })];
129
- }
130
- const committed = tracked.has(TYPES_FILE) ? readText(repoRoot, TYPES_FILE) : null;
131
- if (committed === null) return [found(DB_TYPES_DRIFT, TYPES_FILE, `${TYPES_FILE} is not committed; run \`npm run contract:emit\` and commit the generated types`, { drift: 'not-committed' })];
132
- if (!sameText(committed, String(emitted))) return [found(DB_TYPES_DRIFT, TYPES_FILE, `${TYPES_FILE} differs from what \`contract:emit\` generates from the migrations now; run \`npm run contract:emit\` and commit the result`, { drift: 'stale' })];
133
- return [];
134
- }
135
9
 
136
10
  const DEFAULT_EXTENSIONS = ['pgmq', 'pg_cron', 'pgcrypto', 'pg_trgm', 'vector', 'uuid-ossp', 'pg_net', 'supabase_vault', 'unaccent'];
137
- const DEFAULT_API_SCHEMAS = ['public', 'graphql_public'];
138
- const FORBIDDEN_API_SCHEMAS = new Set(['pgmq', 'pgmq_public']);
139
11
  const word = (token, value) => token?.t === 'word' && token.v === value;
140
12
  const identifier = (token) => token && ['word', 'ident'].includes(token.t) ? token.v : null;
141
13
 
@@ -143,8 +15,6 @@ function dbParameters(manifest) {
143
15
  const declared = manifest?.ruleParams?.db ?? {};
144
16
  return {
145
17
  extensions: Array.isArray(declared.extensions) ? declared.extensions : DEFAULT_EXTENSIONS,
146
- apiSchemas: Array.isArray(declared.apiSchemas) ? declared.apiSchemas : DEFAULT_API_SCHEMAS,
147
- majorVersion: declared.majorVersion ?? 17,
148
18
  };
149
19
  }
150
20
 
@@ -257,76 +127,6 @@ function applyPublicReadPolicy(statement, policies) {
257
127
  policies.set(key, { name, schema: target.schema, select: word(statement[forAt + 1], 'select'), roles: roles ?? ['public'] });
258
128
  }
259
129
 
260
- function publicReadSchemas(sources) {
261
- const policies = new Map();
262
- for (const { tokens } of sources) for (const statement of databaseSqlStatements(tokens)) applyPublicReadPolicy(statement, policies);
263
- return new Set([...policies.values()].filter((policy) => policy.select && policy.name.endsWith('_public_read')
264
- && policy.roles.some((role) => ['anon', 'public'].includes(role))).map((policy) => policy.schema));
265
- }
266
-
267
- function schemaConfigFindings(file, config, parameters, edition, publicSchemas) {
268
- const schemas = config.api?.schemas;
269
- const findings = [];
270
- const allowed = new Set(parameters.apiSchemas);
271
- if (!Array.isArray(schemas) || schemas.some((schema) => typeof schema !== 'string' || !allowed.has(schema))) {
272
- findings.push(found(DB_CONFIG_POLICY, file, `${file} api.schemas must be an array drawn from ruleParams.db.apiSchemas`, { key: 'api.schemas' }));
273
- }
274
- if (Array.isArray(schemas) && schemas.some((schema) => FORBIDDEN_API_SCHEMAS.has(schema))) findings.push(found(DB_API_EXPOSED, file,
275
- `${file} api.schemas exposes a pgmq schema; queues are accessible only to the application role`, { key: 'api.schemas' }));
276
- const exposed = Array.isArray(schemas) ? schemas : DEFAULT_API_SCHEMAS;
277
- if (edition === 'lite' && config.api?.enabled !== false && exposed.length > 0 && !exposed.some((schema) => publicSchemas.has(schema))) {
278
- findings.push(found(DB_API_EXPOSED, file, `${file} enables the lite data API without an exposed *_public_read FOR SELECT policy; disable api or set schemas = []`, { key: 'api.enabled' }));
279
- }
280
- return findings;
281
- }
282
-
283
- function safetyConfigFindings(file, config, parameters, supabase) {
284
- const findings = [];
285
- const anonymous = config.auth?.enable_anonymous_sign_ins;
286
- if (anonymous !== undefined && (typeof anonymous !== 'boolean' || (anonymous && supabase?.enableAnonymousSignIns !== true))) findings.push(found(DB_CONFIG_POLICY, file,
287
- `${file} auth.enable_anonymous_sign_ins is enabled without hfs.json supabase.enableAnonymousSignIns`, { key: 'auth.enable_anonymous_sign_ins' }));
288
- if (config.edge_runtime?.enabled !== false) findings.push(found(DB_CONFIG_POLICY, file,
289
- `${file} edge_runtime.enabled must be false; application execution belongs to the back end`, { key: 'edge_runtime.enabled' }));
290
- const storageLimit = config.storage?.file_size_limit;
291
- if (config.storage?.enabled !== false && (typeof storageLimit !== 'string' || !/^[1-9]\d*(?:B|KB|MB|GB|KiB|MiB|GiB)$/.test(storageLimit))) findings.push(found(DB_CONFIG_POLICY, file,
292
- `${file} storage.file_size_limit must declare a positive byte limit`, { key: 'storage.file_size_limit' }));
293
- if (config.db?.major_version !== parameters.majorVersion) findings.push(found(DB_CONFIG_POLICY, file,
294
- `${file} db.major_version must equal the ruleParams.db.majorVersion pin (${parameters.majorVersion})`, { key: 'db.major_version' }));
295
- return findings;
296
- }
297
-
298
- function seedPattern(value) {
299
- if (typeof value !== 'string' || !value || value.includes('\\') || path.posix.isAbsolute(value) || /^[A-Za-z]:/.test(value)) return null;
300
- const relative = path.posix.normalize(`supabase/${value}`);
301
- if (!relative.startsWith('supabase/')) return null;
302
- const expression = relative.split(/(\*\*|\*|\?)/).map((part) => {
303
- if (part === '**') return '.*';
304
- if (part === '*') return '[^/]*';
305
- if (part === '?') return '[^/]';
306
- return escapeRegexPart(part);
307
- }).join('');
308
- return new RegExp(`^${expression}$`);
309
- }
310
-
311
- function seedConfigResult(repoRoot, files, config) {
312
- const seeds = new Set(files.filter((file) => /^(?:supabase\/(?:seed\.sql|seeds?\/.+\.sql)|\.starcistacks\/[^/]+\/seeds\/.+\.sql)$/.test(file)));
313
- const seed = config?.db?.seed;
314
- if (!seed || seed.enabled === false) return { findings: [], seeds };
315
- const paths = seed.sql_paths ?? ['./seed.sql'];
316
- const findings = [];
317
- if (!Array.isArray(paths) || paths.length === 0) return {
318
- seeds, findings: [found(DB_CONFIG_POLICY, CONFIG_FILE, `${CONFIG_FILE} db.seed.sql_paths must list tracked SQL seed files`, { key: 'db.seed.sql_paths' })],
319
- };
320
- for (const value of paths) {
321
- const pattern = seedPattern(value);
322
- const matches = pattern ? files.filter((file) => file.endsWith('.sql') && pattern.test(file) && readText(repoRoot, file) !== null) : [];
323
- if (matches.length === 0) findings.push(found(DB_CONFIG_POLICY, CONFIG_FILE,
324
- `${CONFIG_FILE} db.seed.sql_paths contains a path with no readable tracked SQL seed file`, { key: 'db.seed.sql_paths' }));
325
- for (const file of matches) seeds.add(file);
326
- }
327
- return { findings, seeds };
328
- }
329
-
330
130
  const EMAIL_LITERAL = /^[A-Z0-9._%+-]+@([A-Z0-9-]+(?:\.[A-Z0-9-]+)+)$/i;
331
131
  function piiLiteral(value) {
332
132
  if (value.split(/[^\w.%+@-]+/).some((part) => {
@@ -356,20 +156,12 @@ function seedPiiFindings(repoRoot, seeds) {
356
156
  export function databaseConfigFindings({ repoRoot, files = [], all = files, repo = {}, manifest, isRoot, inScope = () => true }) {
357
157
  if (isRoot === false || ['be', 'fe'].includes(repo.profile)) return [];
358
158
  const parameters = dbParameters(manifest);
359
- const declaration = readJson(repoRoot, 'hfs.json');
360
- const edition = repo.edition ?? declaration?.edition ?? 'full';
361
159
  const migrations = all.filter((file) => isActiveMigration(file) && file.endsWith('.sql')).sort(byCodeUnit);
362
160
  const sources = migrations.map((file) => ({ file, tokens: databaseExecutableTokens(readText(repoRoot, file) ?? '') }));
363
161
  const findings = [];
364
162
  for (const { file, tokens } of sources) findings.push(...extensionFindings(file, tokens, new Set(parameters.extensions)), ...custodySqlFindings(file, tokens));
365
163
  findings.push(...destructiveMigrationFindings({ repoRoot, migrations, files: all }));
366
- let config = null;
367
- if (all.includes(CONFIG_FILE)) {
368
- try { const text = readText(repoRoot, CONFIG_FILE); if (text !== null) config = parseTomlSync(text); } catch { /* L08 owns invalid TOML. */ }
369
- }
370
- if (config) findings.push(...schemaConfigFindings(CONFIG_FILE, config, parameters, edition, publicReadSchemas(sources)),
371
- ...safetyConfigFindings(CONFIG_FILE, config, parameters, declaration?.supabase));
372
- const seeds = seedConfigResult(repoRoot, all, config);
373
- findings.push(...seeds.findings, ...seedPiiFindings(repoRoot, seeds.seeds));
164
+ const seeds = all.filter((file) => file === SEED_FILE || /^\.starcistacks\/[^/]+\/seeds\/.+\.sql$/.test(file));
165
+ findings.push(...seedPiiFindings(repoRoot, seeds));
374
166
  return findings.filter((finding) => inScope(finding.path));
375
167
  }
@@ -1,26 +1,24 @@
1
- // Single source of database-rule finding codes and owned Supabase paths.
1
+ // Single source of database-rule finding codes and the owned db/ paths of a lite app (slot app.db).
2
2
  export const DB_MIGRATION_SHAPE = 'DB_MIGRATION_SHAPE';
3
3
  export const DB_RLS_REQUIRED = 'DB_RLS_REQUIRED';
4
4
  export const DB_DYNAMIC_DDL = 'DB_DYNAMIC_DDL';
5
5
  export const DB_POLICY_SHAPE = 'DB_POLICY_SHAPE';
6
6
  export const DB_DEFINER_SAFE = 'DB_DEFINER_SAFE';
7
7
  export const DB_STORAGE_POLICY = 'DB_STORAGE_POLICY';
8
- export const DB_CONFIG_POLICY = 'DB_CONFIG_POLICY';
9
- export const DB_TYPES_DRIFT = 'DB_TYPES_DRIFT';
8
+ export const LITE_DB_MIGRATION = 'LITE_DB_MIGRATION';
10
9
  export const DB_BASELINE_SHAPE = 'DB_BASELINE_SHAPE';
11
10
  export const DB_ARCHIVE_MUTATED = 'DB_ARCHIVE_MUTATED';
12
11
  export const DB_PGMQ_DECLARED = 'DB_PGMQ_DECLARED';
13
12
  export const DB_CRON_OWNED = 'DB_CRON_OWNED';
14
13
  export const DB_EXTENSION_ALLOWED = 'DB_EXTENSION_ALLOWED';
15
- export const DB_API_EXPOSED = 'DB_API_EXPOSED';
16
14
  export const DB_MIGRATION_DESTRUCTIVE = 'DB_MIGRATION_DESTRUCTIVE';
17
15
  export const HFS_SEED_PII = 'HFS_SEED_PII';
18
16
  export const DB_VAULT_WRITE = 'DB_VAULT_WRITE';
19
17
 
20
- export const MIGRATIONS_DIR = 'supabase/migrations';
21
- /** The migrations retired at a baseline cutover (slot app.supabase.migrations.archive): never applied, never judged by L02-L08. */
22
- export const MIGRATIONS_ARCHIVE_DIR = `${MIGRATIONS_DIR}/_archive`;
23
- /** True for a migration the Supabase CLI applies: a path under supabase/migrations/ outside the archive. */
18
+ export const DB_DIR = 'db';
19
+ export const MIGRATIONS_DIR = `${DB_DIR}/migrations`;
20
+ /** The migrations retired at a baseline cutover (slot app.db.migrations.archive): never applied, never judged by L02-L08. */
21
+ export const MIGRATIONS_ARCHIVE_DIR = `${MIGRATIONS_DIR}/archive`;
22
+ /** True for a migration `starci app db migrate` applies: a path under db/migrations/ outside the archive. */
24
23
  export const isActiveMigration = (file) => file.startsWith(`${MIGRATIONS_DIR}/`) && !file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`);
25
- export const CONFIG_FILE = 'supabase/config.toml';
26
- export const TYPES_FILE = 'supabase/types/database.types.ts';
24
+ export const SEED_FILE = `${DB_DIR}/seed.sql`;
@@ -74,7 +74,7 @@ function localMigrationFindings(local) {
74
74
  const stamps = new Map();
75
75
  for (const entry of local) {
76
76
  if (!entry.match) {
77
- findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is not named <ts14>_<kebab>.sql; a migration is created by \`npm run db:new\` (supabase migration new), never renamed by hand`, { expected: '<ts14>_<kebab>.sql' }));
77
+ findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is not named <ts14>_<kebab>.sql; a migration is created by \`npm run db:new\` (starci app db new), never renamed by hand`, { expected: '<ts14>_<kebab>.sql' }));
78
78
  continue;
79
79
  }
80
80
  const stamp = migrationStamp(entry.match[1]);
@@ -143,8 +143,8 @@ function removedMigrationFindings(local, retired, baseNames, baseSha) {
143
143
  }
144
144
 
145
145
  /**
146
- * L02 findings for names, stamps, ordering and immutability of supabase/migrations/*.sql. `archived` are the files of
147
- * supabase/migrations/_archive/ (app.supabase.migrations.archive): never judged, and a migration moved there at a baseline
146
+ * L02 findings for names, stamps, ordering and immutability of db/migrations/*.sql. `archived` are the files of
147
+ * db/migrations/archive/ (app.db.migrations.archive): never judged, and a migration moved there at a baseline
148
148
  * cutover is retired from the ordering chain, not removed. New files append after HEAD's active migrations; timestamps
149
149
  * on archived migrations, remote branches and the wall clock do not constrain a replacement baseline.
150
150
  */
@@ -172,7 +172,7 @@ function migrationStatement(state, node, file) {
172
172
  }
173
173
 
174
174
  function migrationFacts(state, files) {
175
- for (const file of files.filter((item) => item.endsWith('.sql') && !item.includes('/_archive/')).sort((left, right) => left.localeCompare(right))) {
175
+ for (const file of files.filter((item) => item.endsWith('.sql') && !item.startsWith('db/migrations/archive/')).sort((left, right) => left.localeCompare(right))) {
176
176
  const text = readText(state.repoRoot, file);
177
177
  if (text === null) continue;
178
178
  const tree = sqlTree(text);
@@ -373,9 +373,8 @@ function tickOwner(state, tick, name, schedule, ticks) {
373
373
  function queueState({ repoRoot, files = [], all = files, repo = {}, manifest }) {
374
374
  const paths = [...new Set(all)];
375
375
  const declaration = readJson(repoRoot, 'hfs.json') ?? repo;
376
- const connected = ['be', 'fe'].some((side) => (declaration.sides?.[side]?.connections ?? []).some((item) => item.provider === 'supabase'));
377
- if (repo.edition !== 'lite' && declaration.edition !== 'lite' && !declaration.supabase && !connected && !paths.some((file) => file.startsWith('supabase/'))) return null;
378
- const state = { repoRoot, appRole: declaration.supabase?.appRole ?? 'app_be', findings: [], outbox: queueOutbox(manifest ?? loadSlotManifest()), declaredOutbox: queueOutboxDeclaration(declaration), scheduledNames: new Set(), constants: new Map(), topics: new Map(), created: new Map(), functions: new Map(), schedules: [], activeCron: new Map(), liveSchedules: new Map(), unschedules: [], drops: [], grants: [], queueAcls: new Map() };
376
+ if (repo.edition !== 'lite' && declaration.edition !== 'lite' && !paths.some((file) => file.startsWith('db/'))) return null;
377
+ const state = { repoRoot, appRole: declaration.db?.appRole ?? 'app_be', findings: [], outbox: queueOutbox(manifest ?? loadSlotManifest()), declaredOutbox: queueOutboxDeclaration(declaration), scheduledNames: new Set(), constants: new Map(), topics: new Map(), created: new Map(), functions: new Map(), schedules: [], activeCron: new Map(), liveSchedules: new Map(), unschedules: [], drops: [], grants: [], queueAcls: new Map() };
379
378
  state.sources = queueSourceFacts(state, paths, inspectCalls);
380
379
  migrationFacts(state, paths);
381
380
  return state;
@@ -394,7 +393,7 @@ function validateQueues(state, inScope) {
394
393
  return state.findings.filter((finding) => inScope(finding.path));
395
394
  }
396
395
 
397
- /** Cross-file R258 findings. A full app participates when it declares or contains Supabase files. */
396
+ /** Cross-file R258 findings. A full app participates when it contains a db/ tree. */
398
397
  export function databaseQueuesFindings(context) {
399
398
  // The app-root pass owns the cross-file inventory; a side pass cannot see its migrations.
400
399
  if (context.appSide === true) return [];
@@ -283,7 +283,7 @@ function rlsFindings(file, facts, exposed, forceRls) {
283
283
  findings.push(found(DB_RLS_REQUIRED, file, `${file}:${table.line} ${operation} ${qualified} into the exposed schema ${table.schema} without a later \`alter table ${qualified} enable row level security\` in the same migration`, { line: table.line, table: qualified }));
284
284
  }
285
285
  if (forceRls.has(qualified) && !relationHasLaterEvent(facts.forces, table)) {
286
- findings.push(found(DB_RLS_REQUIRED, file, `${file}:${table.line} ${operation} ${qualified}, which hfs.json supabase.forceRls declares definer-owned, without \`alter table ${qualified} force row level security\` in the same migration`, { line: table.line, table: qualified }));
286
+ findings.push(found(DB_RLS_REQUIRED, file, `${file}:${table.line} ${operation} ${qualified}, which hfs.json db.forceRls declares definer-owned, without \`alter table ${qualified} force row level security\` in the same migration`, { line: table.line, table: qualified }));
287
287
  }
288
288
  }
289
289
  findings.push(...rlsWeakeningFindings(file, facts.rlsEvents, exposed));
@@ -1,41 +1,50 @@
1
- // Supabase database rules (design 4.2, L02-L09). SQL migrations are parsed with libpg-query through
2
- // database-sql.mjs; config, Git history and generated-type checks are split into bounded sibling modules.
1
+ // The database rules of a lite app's db/ tree (design 4.2, L02-L09). SQL migrations are parsed with libpg-query through
2
+ // database-sql.mjs; Git history and generated-type checks are split into bounded sibling modules.
3
3
  import { found, readJson, readText } from './read.mjs';
4
- import { configFindings, parseToml, typesFindings } from './database-config.mjs';
5
4
  import { defaultGit, migrationShapeFindings } from './database-migrations.mjs';
6
5
  import { migrationSetPolicyFindings, sqlAnalysis } from './database-sql.mjs';
7
6
  import { migrationSetDefinerFindings } from './database-plpgsql.mjs';
8
7
  import { databaseAccessFindings } from './database-access.mjs';
9
- import { CONFIG_FILE, DB_CONFIG_POLICY, DB_MIGRATION_SHAPE, isActiveMigration, MIGRATIONS_ARCHIVE_DIR, TYPES_FILE } from './database-constants.mjs';
8
+ import { DB_DIR, DB_MIGRATION_SHAPE, isActiveMigration, LITE_DB_MIGRATION, MIGRATIONS_ARCHIVE_DIR, MIGRATIONS_DIR } from './database-constants.mjs';
10
9
  import { byCodeUnit } from '../../lib/list.mjs';
11
10
  import { eachInOrder } from '../../lib/in-order.mjs';
12
11
 
13
12
  export { migrationStamp } from './database-migrations.mjs';
14
13
  export {
15
- DB_BE_ONLY_ACCESS, DB_REALTIME_UNLISTED, DB_APP_ROLE, DB_FORCE_RLS_DEFAULT,
16
- DB_POLICY_TAUTOLOGY, DB_VIEW_UNSAFE, DB_STORAGE_PREFIX,
14
+ DB_APP_ROLE, DB_FORCE_RLS_DEFAULT, DB_POLICY_TAUTOLOGY, DB_VIEW_UNSAFE, DB_STORAGE_PREFIX,
17
15
  } from './database-access.mjs';
18
16
  export const DB_DEFINER_REVOKE = 'DB_DEFINER_REVOKE';
19
17
  export {
20
- CONFIG_FILE,
21
- DB_CONFIG_POLICY,
22
18
  DB_DEFINER_SAFE,
23
19
  DB_DYNAMIC_DDL,
24
20
  DB_MIGRATION_SHAPE,
25
21
  DB_POLICY_SHAPE,
26
22
  DB_RLS_REQUIRED,
27
23
  DB_STORAGE_POLICY,
28
- DB_TYPES_DRIFT,
29
- TYPES_FILE,
24
+ LITE_DB_MIGRATION,
30
25
  } from './database-constants.mjs';
31
26
 
32
- const declaresSupabaseConnection = (declaration) => ['be', 'fe'].some((side) => (declaration?.sides?.[side]?.connections ?? []).some((connection) => connection?.provider === 'supabase'));
27
+ /** The schemas the app's tables live in; no schema is exposed to a browser, which reaches data only through the back end. */
28
+ const APP_SCHEMAS = new Set(['public']);
33
29
 
34
- async function configResult(repoRoot, declared) {
35
- const configText = readText(repoRoot, CONFIG_FILE);
36
- const config = configText === null ? null : await parseToml(configText);
37
- if (config === null) return { config, findings: [found(DB_CONFIG_POLICY, CONFIG_FILE, `${CONFIG_FILE} is not readable valid TOML; the config is policy, so it must parse`, {})] };
38
- return { config, findings: configFindings({ file: CONFIG_FILE, text: configText, toml: config, supabase: declared ?? null }) };
30
+ /** A .starcistacks seed is the one SQL file a lite app keeps outside db/. */
31
+ const STACK_SEED = /^\.starcistacks\/[^/]+\/seeds\/.+\.sql$/;
32
+
33
+ /**
34
+ * LITE_DB_MIGRATION: a lite app's schema lives only under db/: no supabase/ tree and no SQL file outside db/ (but a
35
+ * .starcistacks seed), and every SQL file under db/ is a migration, the archive, the seed or nothing.
36
+ */
37
+ export function liteDbPathFindings(files) {
38
+ const findings = [];
39
+ for (const file of files) {
40
+ if (file === 'supabase' || file.startsWith('supabase/')) {
41
+ findings.push(found(LITE_DB_MIGRATION, file, `${file} is a Supabase project file; a lite app keeps its schema in ${MIGRATIONS_DIR}/<ts14>_<kebab>.sql (move the migrations, delete supabase/)`, {}));
42
+ continue;
43
+ }
44
+ if (!file.endsWith('.sql') || STACK_SEED.test(file)) continue;
45
+ if (!file.startsWith(`${DB_DIR}/`)) findings.push(found(LITE_DB_MIGRATION, file, `${file} is SQL outside ${DB_DIR}/; a schema change is one ${MIGRATIONS_DIR}/<ts14>_<kebab>.sql`, {}));
46
+ }
47
+ return findings;
39
48
  }
40
49
 
41
50
  async function migrationAnalyses({ repoRoot, migrations, exposed, forceRls, findings }) {
@@ -54,38 +63,26 @@ async function migrationAnalyses({ repoRoot, migrations, exposed, forceRls, find
54
63
  }
55
64
 
56
65
  /**
57
- * Judge the Supabase paths in one app. Git and type emission are injected for deterministic specs; this function
58
- * starts neither Docker nor the Supabase CLI. A full app is judged when it declares a Supabase connection, while
59
- * lite apps are judged even before their initial Supabase files have all been scaffolded.
66
+ * Judge the db/ tree of one lite app. Git is injected for deterministic specs; this function starts no
67
+ * database and no Docker process. A full app keeps its TypeORM migrations under be/ and is judged here only for a stray db/ tree.
60
68
  */
61
- export async function checkDatabase({ repoRoot, files, base, git, edition, supabase, emitTypes } = {}) {
69
+ export async function checkDatabase({ repoRoot, files, base, git, edition, db } = {}) {
62
70
  const findings = [];
71
+ const declaration = readJson(repoRoot, 'hfs.json');
72
+ const lite = (edition ?? declaration?.edition) === 'lite';
63
73
  const migrations = files.filter(isActiveMigration).sort(byCodeUnit);
74
+ if (!lite && !migrations.length) return findings;
75
+ const declared = db === undefined ? declaration?.db : db;
64
76
  const archived = files.filter((file) => file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`));
65
- const declaration = readJson(repoRoot, 'hfs.json');
66
- const declared = supabase === undefined ? declaration?.supabase : supabase;
67
- const hasConfig = files.includes(CONFIG_FILE);
68
- const supabaseConnection = declaresSupabaseConnection(declaration);
69
- if (!migrations.length && !hasConfig && !files.includes(TYPES_FILE) && !declared && !supabaseConnection && (edition ?? declaration?.edition) !== 'lite') return findings;
70
-
71
- let config = null;
72
- if (hasConfig) {
73
- const result = await configResult(repoRoot, declared);
74
- config = result.config;
75
- findings.push(...result.findings);
76
- }
77
-
78
- const apiSchemas = (config?.api?.schemas ?? []).filter((schema) => typeof schema === 'string');
79
- const exposed = new Set(['public', ...apiSchemas]);
77
+ if (lite) findings.push(...liteDbPathFindings(files));
80
78
  const forceRls = new Set((declared?.forceRls ?? []).map((name) => (name.includes('.') ? name : `public.${name}`)));
81
79
  findings.push(...await migrationShapeFindings({ repoRoot, migrations, archived, git: git ?? defaultGit, base }));
82
80
 
83
- const analyses = await migrationAnalyses({ repoRoot, migrations, exposed, forceRls, findings });
81
+ const analyses = await migrationAnalyses({ repoRoot, migrations, exposed: APP_SCHEMAS, forceRls, findings });
84
82
  findings.push(
85
83
  ...migrationSetPolicyFindings(analyses),
86
- ...migrationSetDefinerFindings(analyses, exposed),
87
- ...await databaseAccessFindings({ analyses, exposed, supabase: declared }),
88
- ...await typesFindings({ repoRoot, files, emitTypes, declaration: declared }),
84
+ ...migrationSetDefinerFindings(analyses, APP_SCHEMAS),
85
+ ...await databaseAccessFindings({ analyses, exposed: APP_SCHEMAS, db: declared }),
89
86
  );
90
87
  return findings;
91
88
  }