@starci/hfs 4.0.10 → 4.1.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 (271) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/emit/operations.mjs +1 -1
  3. package/emit/type-schema.mjs +2 -1
  4. package/lint/run.mjs +3 -2
  5. package/package.json +1 -1
  6. package/report/order.mjs +2 -0
  7. package/runtime/config.example.yaml +187 -0
  8. package/runtime/engine/by-code-unit.mjs +12 -0
  9. package/runtime/engine/config.mjs +221 -239
  10. package/runtime/engine/invalid-config.mjs +5 -5
  11. package/runtime/engine/model-config.mjs +90 -0
  12. package/runtime/engine/orca-config.mjs +3 -1
  13. package/runtime/engine/removed-vocabulary.mjs +28 -0
  14. package/runtime/engine/resources-config.mjs +52 -0
  15. package/runtime/engine/runtime-root.mjs +17 -0
  16. package/runtime/engine/secrets.mjs +35 -31
  17. package/runtime/engine/sonar-config.mjs +21 -0
  18. package/runtime/engine/temp-root.mjs +32 -0
  19. package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
  20. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  21. package/runtime/knowledge/hfs/rules.yaml +98 -65
  22. package/runtime/knowledge/hfs/slots.yaml +11 -14
  23. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  24. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  25. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  26. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  27. package/runtime/modules/kernel/failure-codes.yaml +1 -41
  28. package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
  29. package/runtime/modules/models/registry.yaml +25 -359
  30. package/runtime/modules/models/runtimes.yaml +92 -256
  31. package/runtime/modules/models/tiers.yaml +65 -0
  32. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  33. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  34. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  35. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  36. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  37. package/runtime/scripts/api/git/lib.mjs +34 -1
  38. package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
  39. package/runtime/scripts/api/process/run-program.mjs +2 -1
  40. package/runtime/scripts/api/sops/lib.mjs +198 -120
  41. package/runtime/scripts/hfs/allows.mjs +3 -3
  42. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  43. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  44. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  45. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  46. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  47. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  48. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  49. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  50. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  51. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  52. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  53. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  54. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  55. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  56. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  57. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  58. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  59. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  60. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  61. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  62. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  63. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  64. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  65. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  66. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  67. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
  68. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  69. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  70. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  71. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  72. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  73. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  74. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  75. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  76. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  77. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  78. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  79. package/runtime/scripts/hfs/architecture/index.mjs +158 -102
  80. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  81. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  82. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  83. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  84. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  85. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  86. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  87. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  88. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  89. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  90. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  91. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  92. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  93. package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
  94. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  95. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  96. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  97. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  98. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  99. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  100. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  101. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  102. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  103. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  104. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  105. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  106. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  107. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  108. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  109. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  110. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  111. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  112. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  113. package/runtime/scripts/hfs/architecture.mjs +3 -4
  114. package/runtime/scripts/hfs/check.mjs +61 -70
  115. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  116. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  117. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  118. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  119. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  120. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  121. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  122. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  123. package/runtime/scripts/hfs/repo-identity.mjs +5 -2
  124. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  125. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  126. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  127. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  128. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  129. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  130. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  131. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  132. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  133. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  134. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  135. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  136. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  137. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  138. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  139. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  140. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  141. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  142. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  143. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  144. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  145. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  146. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  147. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  148. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  149. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  150. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  151. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  152. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  153. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  154. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  155. package/runtime/scripts/hfs/secret.mjs +59 -48
  156. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  157. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  158. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  159. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  160. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  161. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  162. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  163. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  164. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  165. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  166. package/runtime/scripts/hfs/slots.mjs +72 -693
  167. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  168. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  169. package/runtime/scripts/hfs/tree.mjs +19 -14
  170. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  171. package/runtime/scripts/hfs/view.mjs +1 -1
  172. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  173. package/runtime/scripts/lib/event-contract.mjs +17 -13
  174. package/runtime/scripts/lib/git.mjs +2 -2
  175. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  176. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  177. package/runtime/scripts/lib/i18n.mjs +3 -2
  178. package/runtime/scripts/lib/in-order.mjs +71 -0
  179. package/runtime/scripts/lib/language.mjs +3 -3
  180. package/runtime/scripts/lib/list.mjs +8 -1
  181. package/runtime/scripts/lib/path-key.mjs +38 -5
  182. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  183. package/runtime/scripts/lib/regex.mjs +1 -1
  184. package/runtime/scripts/lib/same-text.mjs +1 -1
  185. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  186. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  187. package/runtime/scripts/lib/sops-envelope.mjs +95 -48
  188. package/runtime/scripts/lib/stack-services.mjs +1 -2
  189. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  190. package/runtime/scripts/lib/walk.mjs +6 -1
  191. package/scaffold/add-cli-lite.mjs +2 -1
  192. package/scaffold/add-table.mjs +2 -2
  193. package/scaffold/add.mjs +3 -2
  194. package/scaffold/app.mjs +4 -2
  195. package/scaffold/edition-gate.mjs +25 -25
  196. package/scaffold/lite-exports.mjs +2 -1
  197. package/scaffold/service.mjs +5 -4
  198. package/sync/hygiene.mjs +15 -9
  199. package/sync/index.mjs +7 -3
  200. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  201. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  202. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  203. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  204. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  205. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  206. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  207. package/templates/app/starciwork.gitignore +1 -1
  208. package/templates/be/image/api/Dockerfile +1 -1
  209. package/templates/be/image/cli/Dockerfile +1 -1
  210. package/templates/be/image/worker/Dockerfile +1 -1
  211. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  212. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  213. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  214. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  215. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  216. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  217. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  218. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  219. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  220. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  221. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  222. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  223. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  224. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  225. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  226. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  227. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  228. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  229. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  230. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  231. package/templates/fe/image/next/Dockerfile +2 -1
  232. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  233. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  234. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  235. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  236. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  237. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  238. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  239. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  240. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  241. package/upgrade/index.mjs +9 -2
  242. package/runtime/engine/admission.mjs +0 -308
  243. package/runtime/engine/canonical-json.mjs +0 -10
  244. package/runtime/engine/db/blob.mjs +0 -315
  245. package/runtime/engine/db/ledger-paths.mjs +0 -83
  246. package/runtime/engine/db/ledger.mjs +0 -1118
  247. package/runtime/engine/db/machine-connection.mjs +0 -135
  248. package/runtime/engine/db/machine-schema.mjs +0 -84
  249. package/runtime/engine/db/machine.mjs +0 -1367
  250. package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
  251. package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
  252. package/runtime/engine/db/provider-reservations.mjs +0 -101
  253. package/runtime/engine/digest.mjs +0 -16
  254. package/runtime/engine/refuse.mjs +0 -11
  255. package/runtime/modules/ops/_labels.yaml +0 -53
  256. package/runtime/scripts/api/node/lib.mjs +0 -14
  257. package/runtime/scripts/api/node/spawn-node.mjs +0 -6
  258. package/runtime/scripts/api/process/lib.mjs +0 -111
  259. package/runtime/scripts/api/process/owned-process.mjs +0 -78
  260. package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
  261. package/runtime/scripts/connectors/lib.mjs +0 -488
  262. package/runtime/scripts/lib/clip.mjs +0 -19
  263. package/runtime/scripts/lib/display-names.mjs +0 -259
  264. package/runtime/scripts/lib/example-refs.mjs +0 -158
  265. package/runtime/scripts/lib/json-schema.mjs +0 -52
  266. package/runtime/scripts/lib/process-identity.mjs +0 -6
  267. package/runtime/scripts/lib/read-yaml.mjs +0 -18
  268. package/runtime/scripts/lib/redact.mjs +0 -161
  269. package/runtime/scripts/lib/sleep.mjs +0 -7
  270. package/runtime/scripts/lib/source-phrases.mjs +0 -34
  271. package/runtime/scripts/lib/sqlite.mjs +0 -21
@@ -37,7 +37,8 @@ function statementList(stmts, lineAt) {
37
37
  return (stmts ?? []).map((entry, index) => {
38
38
  const type = Object.keys(entry.stmt ?? {})[0] ?? '';
39
39
  const node = entry.stmt?.[type] ?? {};
40
- const offset = entry.stmt_location >= 0 ? entry.stmt_location : (node.location >= 0 ? node.location : 0);
40
+ const nodeOffset = Math.max(node.location ?? 0, 0);
41
+ const offset = entry.stmt_location >= 0 ? entry.stmt_location : nodeOffset;
41
42
  return { type, node, index, line: lineAt(offset) };
42
43
  });
43
44
  }
@@ -58,6 +59,125 @@ function searchPathSchema(node, current) {
58
59
  return schemas.find((schema) => schema && schema !== '$user') ?? 'public';
59
60
  }
60
61
 
62
+ function addTableFacts(statement, node, facts, defaultSchema) {
63
+ const relation = statement.type === 'CreateStmt' ? node.relation : node.into?.rel;
64
+ if (!relation || node.partbound || relation.relpersistence === 't') return;
65
+ facts.tables.push({ schema: schemaOf(relation, defaultSchema), name: relation.relname, index: statement.index, line: statement.line });
66
+ }
67
+
68
+ function addAlterTableFacts(statement, node, facts, defaultSchema) {
69
+ for (const command of node.cmds ?? []) {
70
+ const subtype = command?.AlterTableCmd?.subtype;
71
+ const item = { schema: schemaOf(node.relation, defaultSchema), name: node.relation?.relname, index: statement.index };
72
+ if (subtype === 'AT_EnableRowSecurity') facts.enables.push(item);
73
+ else if (subtype === 'AT_ForceRowSecurity') facts.forces.push(item);
74
+ }
75
+ }
76
+
77
+ function addCreatePolicyFacts(statement, node, facts, defaultSchema) {
78
+ const policy = {
79
+ name: node.policy_name,
80
+ schema: schemaOf(node.table, defaultSchema),
81
+ table: node.table?.relname,
82
+ cmd: String(node.cmd_name ?? 'all').toLowerCase(),
83
+ roles: node.roles?.length ? node.roles.map(roleOf) : ['public'],
84
+ implicitPublic: (node.roles ?? []).some((role) => role?.RoleSpec?.roletype === 'ROLESPEC_PUBLIC' && role.RoleSpec.location === -1),
85
+ qual: node.qual,
86
+ withCheck: node.with_check,
87
+ index: statement.index,
88
+ line: statement.line,
89
+ };
90
+ facts.policies.push(policy);
91
+ facts.policyEvents.push({ kind: 'create', policy, index: statement.index });
92
+ }
93
+
94
+ function addAlterPolicyFacts(statement, node, facts, defaultSchema) {
95
+ facts.policyEvents.push({
96
+ kind: 'alter',
97
+ schema: schemaOf(node.table, defaultSchema),
98
+ table: node.table?.relname,
99
+ name: node.policy_name,
100
+ ...(node.roles !== undefined ? {
101
+ roles: node.roles.map(roleOf),
102
+ implicitPublic: node.roles.some((role) => role?.RoleSpec?.roletype === 'ROLESPEC_PUBLIC' && role.RoleSpec.location === -1),
103
+ } : {}),
104
+ ...(node.qual !== undefined ? { qual: node.qual } : {}),
105
+ ...(node.with_check !== undefined ? { withCheck: node.with_check } : {}),
106
+ index: statement.index,
107
+ });
108
+ }
109
+
110
+ function addDropPolicyFacts(statement, node, facts, defaultSchema) {
111
+ if (node.removeType !== 'OBJECT_POLICY') return;
112
+ for (const object of node.objects ?? []) {
113
+ const parts = object?.List?.items?.map(sval).filter(Boolean) ?? [];
114
+ const [schema, table, name] = parts.length === 3 ? parts : [defaultSchema, ...parts];
115
+ facts.policyEvents.push({ kind: 'drop', schema, table, name, index: statement.index });
116
+ }
117
+ }
118
+
119
+ function addGrantFacts(statement, node, facts, defaultSchema) {
120
+ facts.grants.push({
121
+ grant: node.is_grant === true,
122
+ targtype: node.targtype,
123
+ objtype: node.objtype,
124
+ objects: node.objects ?? [],
125
+ privileges: (node.privileges ?? []).map((privilege) => String(privilege?.AccessPriv?.priv_name ?? '').toLowerCase()),
126
+ grantees: (node.grantees ?? []).map(roleOf),
127
+ defaultSchema,
128
+ index: statement.index,
129
+ line: statement.line,
130
+ });
131
+ }
132
+
133
+ function addDefaultPrivilegeFacts(statement, node, facts) {
134
+ const action = node.action ?? {};
135
+ facts.defaultPrivileges.push({
136
+ grant: action.is_grant === true,
137
+ objtype: action.objtype,
138
+ privileges: (action.privileges ?? []).map((privilege) => String(privilege?.AccessPriv?.priv_name ?? '').toLowerCase()),
139
+ grantees: (action.grantees ?? []).map(roleOf),
140
+ index: statement.index,
141
+ line: statement.line,
142
+ });
143
+ }
144
+
145
+ function addCreateFunctionFacts(statement, node, facts, defaultSchema) {
146
+ const parts = (node.funcname ?? []).map(sval).filter(Boolean);
147
+ const schema = parts.length > 1 ? parts[0] : defaultSchema;
148
+ facts.functions.push({ node, name: parts.length > 1 ? parts.join('.') : `${schema}.${parts[0]}`, schema, index: statement.index, line: statement.line });
149
+ }
150
+
151
+ function addAlterFunctionFacts(statement, node, facts, defaultSchema) {
152
+ const rawName = nameOf(node.func?.objname);
153
+ const name = rawName.includes('.') ? rawName : `${defaultSchema}.${rawName}`;
154
+ facts.alterFunctions.push({ name, setsPath: (node.actions ?? []).some((action) => isSearchPathSet(action?.DefElem)), index: statement.index, line: statement.line });
155
+ }
156
+
157
+ function addDoBlockFacts(statement, node, facts) {
158
+ const option = (key) => (node.args ?? []).find((argument) => argument?.DefElem?.defname === key)?.DefElem?.arg;
159
+ facts.doBlocks.push({ body: option('as')?.String?.sval, language: option('language')?.String?.sval ?? 'plpgsql', index: statement.index, line: statement.line });
160
+ }
161
+
162
+ function addBucketInsertFacts(statement, node, facts) {
163
+ if (node.relation?.schemaname === 'storage' && node.relation?.relname === 'buckets') facts.buckets.push({ node, index: statement.index, line: statement.line });
164
+ }
165
+
166
+ function addBucketCallFacts(statement, node, facts) {
167
+ for (const target of node.targetList ?? []) {
168
+ const call = target?.ResTarget?.val?.FuncCall;
169
+ if (call && nameOf(call.funcname).toLowerCase() === 'storage.create_bucket') facts.bucketCalls.push({ node: call, index: statement.index, line: statement.line });
170
+ }
171
+ }
172
+
173
+ const statementFactHandlers = new Map([
174
+ ['VariableSetStmt', (statement, node, facts, defaultSchema) => searchPathSchema(node, defaultSchema)],
175
+ ['CreateStmt', addTableFacts], ['CreateTableAsStmt', addTableFacts], ['AlterTableStmt', addAlterTableFacts],
176
+ ['CreatePolicyStmt', addCreatePolicyFacts], ['AlterPolicyStmt', addAlterPolicyFacts], ['DropStmt', addDropPolicyFacts],
177
+ ['GrantStmt', addGrantFacts], ['AlterDefaultPrivilegesStmt', addDefaultPrivilegeFacts], ['CreateFunctionStmt', addCreateFunctionFacts],
178
+ ['AlterFunctionStmt', addAlterFunctionFacts], ['DoStmt', addDoBlockFacts], ['InsertStmt', addBucketInsertFacts], ['SelectStmt', addBucketCallFacts],
179
+ ]);
180
+
61
181
  /** Fold each statement to the facts consumed by the rule checks. */
62
182
  function factsOf(statements) {
63
183
  const facts = {
@@ -67,95 +187,8 @@ function factsOf(statements) {
67
187
  let defaultSchema = 'public';
68
188
  for (const statement of statements) {
69
189
  const { node } = statement;
70
- if (statement.type === 'VariableSetStmt') {
71
- defaultSchema = searchPathSchema(node, defaultSchema);
72
- } else if (statement.type === 'CreateStmt' || statement.type === 'CreateTableAsStmt') {
73
- const relation = statement.type === 'CreateStmt' ? node.relation : node.into?.rel;
74
- if (!relation || node.partbound || relation.relpersistence === 't') continue;
75
- facts.tables.push({ schema: schemaOf(relation, defaultSchema), name: relation.relname, index: statement.index, line: statement.line });
76
- } else if (statement.type === 'AlterTableStmt') {
77
- for (const command of node.cmds ?? []) {
78
- const subtype = command?.AlterTableCmd?.subtype;
79
- const item = { schema: schemaOf(node.relation, defaultSchema), name: node.relation?.relname, index: statement.index };
80
- if (subtype === 'AT_EnableRowSecurity') facts.enables.push(item);
81
- else if (subtype === 'AT_ForceRowSecurity') facts.forces.push(item);
82
- }
83
- } else if (statement.type === 'CreatePolicyStmt') {
84
- const policy = {
85
- name: node.policy_name,
86
- schema: schemaOf(node.table, defaultSchema),
87
- table: node.table?.relname,
88
- cmd: String(node.cmd_name ?? 'all').toLowerCase(),
89
- roles: node.roles?.length ? node.roles.map(roleOf) : ['public'],
90
- implicitPublic: (node.roles ?? []).some((role) => role?.RoleSpec?.roletype === 'ROLESPEC_PUBLIC' && role.RoleSpec.location === -1),
91
- qual: node.qual,
92
- withCheck: node.with_check,
93
- index: statement.index,
94
- line: statement.line,
95
- };
96
- facts.policies.push(policy);
97
- facts.policyEvents.push({ kind: 'create', policy, index: statement.index });
98
- } else if (statement.type === 'AlterPolicyStmt') {
99
- facts.policyEvents.push({
100
- kind: 'alter',
101
- schema: schemaOf(node.table, defaultSchema),
102
- table: node.table?.relname,
103
- name: node.policy_name,
104
- ...(node.roles !== undefined ? {
105
- roles: node.roles.map(roleOf),
106
- implicitPublic: node.roles.some((role) => role?.RoleSpec?.roletype === 'ROLESPEC_PUBLIC' && role.RoleSpec.location === -1),
107
- } : {}),
108
- ...(node.qual !== undefined ? { qual: node.qual } : {}),
109
- ...(node.with_check !== undefined ? { withCheck: node.with_check } : {}),
110
- index: statement.index,
111
- });
112
- } else if (statement.type === 'DropStmt' && node.removeType === 'OBJECT_POLICY') {
113
- for (const object of node.objects ?? []) {
114
- const parts = object?.List?.items?.map(sval).filter(Boolean) ?? [];
115
- const [schema, table, name] = parts.length === 3 ? parts : [defaultSchema, ...parts];
116
- facts.policyEvents.push({ kind: 'drop', schema, table, name, index: statement.index });
117
- }
118
- } else if (statement.type === 'GrantStmt') {
119
- facts.grants.push({
120
- grant: node.is_grant === true,
121
- targtype: node.targtype,
122
- objtype: node.objtype,
123
- objects: node.objects ?? [],
124
- privileges: (node.privileges ?? []).map((privilege) => String(privilege?.AccessPriv?.priv_name ?? '').toLowerCase()),
125
- grantees: (node.grantees ?? []).map(roleOf),
126
- defaultSchema,
127
- index: statement.index,
128
- line: statement.line,
129
- });
130
- } else if (statement.type === 'AlterDefaultPrivilegesStmt') {
131
- const action = node.action ?? {};
132
- facts.defaultPrivileges.push({
133
- grant: action.is_grant === true,
134
- objtype: action.objtype,
135
- privileges: (action.privileges ?? []).map((privilege) => String(privilege?.AccessPriv?.priv_name ?? '').toLowerCase()),
136
- grantees: (action.grantees ?? []).map(roleOf),
137
- index: statement.index,
138
- line: statement.line,
139
- });
140
- } else if (statement.type === 'CreateFunctionStmt') {
141
- const parts = (node.funcname ?? []).map(sval).filter(Boolean);
142
- const schema = parts.length > 1 ? parts[0] : defaultSchema;
143
- facts.functions.push({ node, name: parts.length > 1 ? parts.join('.') : `${schema}.${parts[0]}`, schema, index: statement.index, line: statement.line });
144
- } else if (statement.type === 'AlterFunctionStmt') {
145
- const rawName = nameOf(node.func?.objname);
146
- const name = rawName.includes('.') ? rawName : `${defaultSchema}.${rawName}`;
147
- facts.alterFunctions.push({ name, setsPath: (node.actions ?? []).some((action) => isSearchPathSet(action?.DefElem)), index: statement.index, line: statement.line });
148
- } else if (statement.type === 'DoStmt') {
149
- const option = (key) => (node.args ?? []).find((argument) => argument?.DefElem?.defname === key)?.DefElem?.arg;
150
- facts.doBlocks.push({ body: option('as')?.String?.sval, language: option('language')?.String?.sval ?? 'plpgsql', index: statement.index, line: statement.line });
151
- } else if (statement.type === 'InsertStmt' && node.relation?.schemaname === 'storage' && node.relation?.relname === 'buckets') {
152
- facts.buckets.push({ node, index: statement.index, line: statement.line });
153
- } else if (statement.type === 'SelectStmt') {
154
- for (const target of node.targetList ?? []) {
155
- const call = target?.ResTarget?.val?.FuncCall;
156
- if (call && nameOf(call.funcname).toLowerCase() === 'storage.create_bucket') facts.bucketCalls.push({ node: call, index: statement.index, line: statement.line });
157
- }
158
- }
190
+ const handler = statementFactHandlers.get(statement.type);
191
+ if (handler) defaultSchema = handler(statement, node, facts, defaultSchema) ?? defaultSchema;
159
192
  }
160
193
  return facts;
161
194
  }
@@ -175,105 +208,139 @@ function rlsFindings(file, facts, exposed, forceRls) {
175
208
  return findings;
176
209
  }
177
210
 
211
+ function policyNameFindings(file, policy, qualified) {
212
+ const findings = [];
213
+ if (policy.name === undefined || !policy.name.startsWith(`${policy.table}_`)) {
214
+ findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${JSON.stringify(policy.name)} on ${qualified} is not named <table>_<role>_<action>`, { line: policy.line, policy: policy.name }));
215
+ } else {
216
+ const tail = policy.name.slice(policy.table.length + 1);
217
+ if (tail !== 'public_read' && !new RegExp(`^[a-z][a-z0-9_]*_(${POLICY_ACTIONS.join('|')})$`).test(tail)) {
218
+ findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is not named <table>_<role>_<action>`, { line: policy.line, policy: policy.name }));
219
+ }
220
+ }
221
+ return findings;
222
+ }
223
+
224
+ function policyAccessFindings(file, policy, qualified) {
225
+ const findings = [];
226
+ const publicRead = typeof policy.name === 'string' && policy.name.endsWith(PUBLIC_READ_SUFFIX);
227
+ if (WRITE_COMMANDS.has(policy.cmd) && isTrue(policy.qual)) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is FOR ${policy.cmd.toUpperCase()} with USING (true)`, { line: policy.line, policy: policy.name }));
228
+ if (WRITE_COMMANDS.has(policy.cmd) && isTrue(policy.withCheck)) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is FOR ${policy.cmd.toUpperCase()} with WITH CHECK (true)`, { line: policy.line, policy: policy.name }));
229
+ if (policy.cmd === 'select' && isTrue(policy.qual) && !publicRead) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is FOR SELECT with USING (true); a world-readable table declares it with *_public_read`, { line: policy.line, policy: policy.name }));
230
+ if ((policy.roles.includes('anon') || policy.roles.includes('public')) && !(policy.cmd === 'select' && publicRead)) {
231
+ const roleName = policy.roles.includes('anon') ? 'anon' : 'public';
232
+ const role = policy.implicitPublic ? 'the implicit TO public default' : `TO ${roleName}`;
233
+ findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} reaches ${role}; anonymous access is only FOR SELECT with *_public_read`, { line: policy.line, policy: policy.name }));
234
+ }
235
+ return findings;
236
+ }
237
+
238
+ function grantPolicyFindings(file, grant) {
239
+ const findings = [];
240
+ if (!grant.grant) return findings;
241
+ if (grant.grantees.includes('service_role')) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${grant.line} grants to service_role; the service role bypasses RLS and is never granted`, { line: grant.line }));
242
+ const broadRole = grant.grantees.find((role) => ['anon', 'authenticated', 'public'].includes(role));
243
+ if (grant.targtype === 'ACL_TARGET_ALL_IN_SCHEMA' && grant.objtype === 'OBJECT_TABLE' && broadRole) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${grant.line} grants on all tables in a schema to ${broadRole}; table privileges are declared per table beside the matching policies`, { line: grant.line, role: broadRole }));
244
+ return findings;
245
+ }
246
+
247
+ function defaultPrivilegeFindings(file, defaults) {
248
+ const findings = [];
249
+ if (!defaults.grant) return findings;
250
+ if (defaults.grantees.includes('service_role')) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${defaults.line} grants default privileges to service_role; the service role bypasses RLS and is never granted`, { line: defaults.line }));
251
+ const broadRole = defaults.grantees.find((role) => ['anon', 'public'].includes(role));
252
+ if (defaults.objtype === 'OBJECT_TABLE' && broadRole) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${defaults.line} alters default table privileges for ${broadRole}; anonymous grants are explicit per table beside *_public_read policies`, { line: defaults.line, role: broadRole }));
253
+ return findings;
254
+ }
255
+
178
256
  function localPolicyFindings(file, facts) {
179
257
  const findings = [];
180
258
  for (const policy of facts.policies) {
181
259
  const qualified = `${policy.schema}.${policy.table}`;
182
- if (policy.name === undefined || !policy.name.startsWith(`${policy.table}_`)) {
183
- findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${JSON.stringify(policy.name)} on ${qualified} is not named <table>_<role>_<action>`, { line: policy.line, policy: policy.name }));
184
- } else {
185
- const tail = policy.name.slice(policy.table.length + 1);
186
- if (tail !== 'public_read' && !new RegExp(`^[a-z][a-z0-9_]*_(${POLICY_ACTIONS.join('|')})$`).test(tail)) {
187
- findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is not named <table>_<role>_<action>`, { line: policy.line, policy: policy.name }));
188
- }
189
- }
190
- const publicRead = typeof policy.name === 'string' && policy.name.endsWith(PUBLIC_READ_SUFFIX);
191
- if (WRITE_COMMANDS.has(policy.cmd) && isTrue(policy.qual)) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is FOR ${policy.cmd.toUpperCase()} with USING (true)`, { line: policy.line, policy: policy.name }));
192
- if (WRITE_COMMANDS.has(policy.cmd) && isTrue(policy.withCheck)) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is FOR ${policy.cmd.toUpperCase()} with WITH CHECK (true)`, { line: policy.line, policy: policy.name }));
193
- if (policy.cmd === 'select' && isTrue(policy.qual) && !publicRead) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is FOR SELECT with USING (true); a world-readable table declares it with *_public_read`, { line: policy.line, policy: policy.name }));
194
- if ((policy.roles.includes('anon') || policy.roles.includes('public')) && !(policy.cmd === 'select' && publicRead)) {
195
- const role = policy.implicitPublic ? 'the implicit TO public default' : `TO ${policy.roles.includes('anon') ? 'anon' : 'public'}`;
196
- findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} reaches ${role}; anonymous access is only FOR SELECT with *_public_read`, { line: policy.line, policy: policy.name }));
197
- }
260
+ findings.push(...policyNameFindings(file, policy, qualified), ...policyAccessFindings(file, policy, qualified));
198
261
  }
199
262
  for (const grant of facts.grants) {
200
- if (!grant.grant) continue;
201
- if (grant.grantees.includes('service_role')) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${grant.line} grants to service_role; the service role bypasses RLS and is never granted`, { line: grant.line }));
202
- const broadRole = grant.grantees.find((role) => ['anon', 'authenticated', 'public'].includes(role));
203
- if (grant.targtype === 'ACL_TARGET_ALL_IN_SCHEMA' && grant.objtype === 'OBJECT_TABLE' && broadRole) {
204
- findings.push(found(DB_POLICY_SHAPE, file, `${file}:${grant.line} grants on all tables in a schema to ${broadRole}; table privileges are declared per table beside the matching policies`, { line: grant.line, role: broadRole }));
205
- }
206
- }
207
- for (const defaults of facts.defaultPrivileges) {
208
- if (!defaults.grant) continue;
209
- if (defaults.grantees.includes('service_role')) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${defaults.line} grants default privileges to service_role; the service role bypasses RLS and is never granted`, { line: defaults.line }));
210
- const broadRole = defaults.grantees.find((role) => ['anon', 'public'].includes(role));
211
- if (defaults.objtype === 'OBJECT_TABLE' && broadRole) {
212
- findings.push(found(DB_POLICY_SHAPE, file, `${file}:${defaults.line} alters default table privileges for ${broadRole}; anonymous grants are explicit per table beside *_public_read policies`, { line: defaults.line, role: broadRole }));
213
- }
263
+ findings.push(...grantPolicyFindings(file, grant));
214
264
  }
265
+ for (const defaults of facts.defaultPrivileges) findings.push(...defaultPrivilegeFindings(file, defaults));
215
266
  return findings;
216
267
  }
217
268
 
218
269
  const policyKey = (policy) => `${policy.schema}\0${policy.table}\0${policy.name}`;
219
270
 
220
- /** Anonymous table grants are covered by the final policy state across the ordered migration set. */
221
- export function migrationSetPolicyFindings(analyses) {
271
+ function applyPolicyEvent(policies, event) {
272
+ const key = policyKey(event.kind === 'create' ? event.policy : event);
273
+ if (event.kind === 'create') policies.set(key, event.policy);
274
+ else if (event.kind === 'drop') policies.delete(key);
275
+ else if (policies.has(key)) policies.set(key, { ...policies.get(key), ...event });
276
+ }
277
+
278
+ function policiesAfterMigrations(analyses) {
222
279
  const policies = new Map();
223
280
  for (const { facts } of analyses) {
224
- for (const event of [...facts.policyEvents].sort((left, right) => left.index - right.index)) {
225
- const key = policyKey(event.kind === 'create' ? event.policy : event);
226
- if (event.kind === 'create') policies.set(key, event.policy);
227
- else if (event.kind === 'drop') policies.delete(key);
228
- else if (policies.has(key)) policies.set(key, { ...policies.get(key), ...event });
229
- }
281
+ for (const event of [...facts.policyEvents].sort((left, right) => left.index - right.index)) applyPolicyEvent(policies, event);
230
282
  }
231
- const finalPolicies = [...policies.values()];
283
+ return [...policies.values()];
284
+ }
285
+
286
+ function anonymousTableGrant(grant) {
287
+ if (!grant.grant || grant.targtype !== 'ACL_TARGET_OBJECT' || grant.objtype !== 'OBJECT_TABLE') return false;
288
+ return grant.grantees.includes('anon') || grant.grantees.includes('public');
289
+ }
290
+
291
+ function publicReadPolicyCovers(finalPolicies, schema, table) {
292
+ return finalPolicies.some((policy) => policy.schema === schema && policy.table === table && policy.cmd === 'select'
293
+ && typeof policy.name === 'string' && policy.name.endsWith(PUBLIC_READ_SUFFIX)
294
+ && (policy.roles.includes('anon') || policy.roles.includes('public')));
295
+ }
296
+
297
+ function anonymousGrantFindings(file, grant, finalPolicies) {
298
+ if (!anonymousTableGrant(grant)) return [];
299
+ const findings = [];
300
+ for (const object of grant.objects) {
301
+ const relation = object?.RangeVar;
302
+ if (!relation) continue;
303
+ const schema = schemaOf(relation, grant.defaultSchema);
304
+ const table = relation.relname;
305
+ if (!publicReadPolicyCovers(finalPolicies, schema, table)) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${grant.line} grants an anonymous role on ${schema}.${table} but the migration set ends without a *_public_read FOR SELECT policy`, { line: grant.line, table: `${schema}.${table}` }));
306
+ }
307
+ return findings;
308
+ }
309
+
310
+ /** Anonymous table grants are covered by the final policy state across the ordered migration set. */
311
+ export function migrationSetPolicyFindings(analyses) {
312
+ const finalPolicies = policiesAfterMigrations(analyses);
232
313
  const findings = [];
233
314
  for (const { file, facts } of analyses) {
234
- for (const grant of facts.grants) {
235
- if (!grant.grant || grant.targtype !== 'ACL_TARGET_OBJECT' || grant.objtype !== 'OBJECT_TABLE') continue;
236
- if (!grant.grantees.includes('anon') && !grant.grantees.includes('public')) continue;
237
- for (const object of grant.objects) {
238
- const relation = object?.RangeVar;
239
- if (!relation) continue;
240
- const schema = schemaOf(relation, grant.defaultSchema);
241
- const table = relation.relname;
242
- const covered = finalPolicies.some((policy) => policy.schema === schema && policy.table === table && policy.cmd === 'select'
243
- && typeof policy.name === 'string' && policy.name.endsWith(PUBLIC_READ_SUFFIX)
244
- && (policy.roles.includes('anon') || policy.roles.includes('public')));
245
- if (!covered) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${grant.line} grants an anonymous role on ${schema}.${table} but the migration set ends without a *_public_read FOR SELECT policy`, { line: grant.line, table: `${schema}.${table}` }));
246
- }
247
- }
315
+ for (const grant of facts.grants) findings.push(...anonymousGrantFindings(file, grant, finalPolicies));
248
316
  }
249
317
  return findings;
250
318
  }
251
319
 
252
320
  const valueRows = (node) => (node.selectStmt?.SelectStmt?.valuesLists ?? []).map((list) => list?.List?.items ?? []);
253
321
  const unwrap = (expression) => expression?.TypeCast ? unwrap(expression.TypeCast.arg) : expression;
254
- const columnName = (expression) => unwrap(expression)?.ColumnRef?.fields?.map(sval).filter(Boolean).at(-1);
322
+ const columnName = (expression) => unwrap(expression)?.ColumnRef?.fields?.map(sval).findLast(Boolean);
255
323
  const literalString = (expression) => asString(unwrap(expression));
256
324
 
325
+ function operationScopesBucket(operation, bucket) {
326
+ const operator = operation.name?.map(sval).filter(Boolean).join('');
327
+ if (operator === '=' && ((columnName(operation.lexpr) === 'bucket_id' && literalString(operation.rexpr) === bucket)
328
+ || (columnName(operation.rexpr) === 'bucket_id' && literalString(operation.lexpr) === bucket))) return true;
329
+ return operation.kind === 'AEXPR_IN' && columnName(operation.lexpr) === 'bucket_id'
330
+ && (operation.rexpr ?? []).some((item) => literalString(item) === bucket);
331
+ }
332
+
257
333
  function expressionScopesBucket(expression, bucket) {
258
334
  if (!expression || typeof expression !== 'object') return false;
259
335
  if (Array.isArray(expression)) return expression.some((item) => expressionScopesBucket(item, bucket));
260
336
  const operation = expression.A_Expr;
261
- if (operation) {
262
- const operator = operation.name?.map(sval).filter(Boolean).join('');
263
- if (operator === '=') {
264
- if ((columnName(operation.lexpr) === 'bucket_id' && literalString(operation.rexpr) === bucket)
265
- || (columnName(operation.rexpr) === 'bucket_id' && literalString(operation.lexpr) === bucket)) return true;
266
- }
267
- if (operation.kind === 'AEXPR_IN' && columnName(operation.lexpr) === 'bucket_id'
268
- && (operation.rexpr ?? []).some((item) => literalString(item) === bucket)) return true;
269
- }
337
+ if (operation && operationScopesBucket(operation, bucket)) return true;
270
338
  return Object.values(expression).some((child) => expressionScopesBucket(child, bucket));
271
339
  }
272
340
 
273
- function storageFindings(file, facts) {
274
- const findings = [];
341
+ function insertedBucketDeclarations(file, buckets, findings) {
275
342
  const declarations = [];
276
- for (const { node, line } of facts.buckets) {
343
+ for (const { node, line } of buckets) {
277
344
  const columns = (node.cols ?? []).map((column) => column?.ResTarget?.name);
278
345
  const rows = valueRows(node);
279
346
  if (!columns.length || !rows.length) {
@@ -285,7 +352,12 @@ function storageFindings(file, facts) {
285
352
  declarations.push({ id: asString(cell('id')), publicValue: asBool(cell('public')), fileSize: cell('file_size_limit'), mimeTypes: cell('allowed_mime_types'), line });
286
353
  }
287
354
  }
288
- for (const { node, line } of facts.bucketCalls) {
355
+ return declarations;
356
+ }
357
+
358
+ function calledBucketDeclarations(bucketCalls) {
359
+ const declarations = [];
360
+ for (const { node, line } of bucketCalls) {
289
361
  const positional = (node.args ?? []).filter((argument) => !argument?.NamedArgExpr);
290
362
  const named = new Map((node.args ?? []).filter((argument) => argument?.NamedArgExpr).map((argument) => [argument.NamedArgExpr.name, argument.NamedArgExpr.arg]));
291
363
  declarations.push({
@@ -296,22 +368,30 @@ function storageFindings(file, facts) {
296
368
  line,
297
369
  });
298
370
  }
299
- for (const declaration of declarations) {
300
- const { id, publicValue, fileSize, mimeTypes, line } = declaration;
301
- if (id === undefined) {
302
- findings.push(found(DB_STORAGE_POLICY, file, `${file}:${line} storage.buckets row has no literal id`, { line }));
303
- continue;
304
- }
305
- if (publicValue === null || (publicValue && !id.endsWith('_public'))) {
306
- findings.push(found(DB_STORAGE_POLICY, file, `${file}:${line} bucket ${JSON.stringify(id)} declares public = ${publicValue === null ? 'absent' : publicValue}; public must be explicit and only an *_public id may be public`, { line, bucket: id }));
307
- }
308
- for (const [column, value] of [['file_size_limit', fileSize], ['allowed_mime_types', mimeTypes]]) {
309
- if (value === undefined || isNull(value)) findings.push(found(DB_STORAGE_POLICY, file, `${file}:${line} bucket ${JSON.stringify(id)} declares no ${column}`, { line, bucket: id, column }));
310
- }
311
- const covered = facts.policies.some((policy) => policy.schema === 'storage' && policy.table === 'objects'
312
- && (expressionScopesBucket(policy.qual, id) || expressionScopesBucket(policy.withCheck, id)));
313
- if (!covered) findings.push(found(DB_STORAGE_POLICY, file, `${file}:${line} bucket ${JSON.stringify(id)} has no storage.objects policy whose bucket_id predicate names it in this migration`, { line, bucket: id }));
371
+ return declarations;
372
+ }
373
+
374
+ function bucketDeclarationFindings(file, declaration, policies) {
375
+ const { id, publicValue, fileSize, mimeTypes, line } = declaration;
376
+ const findings = [];
377
+ if (id === undefined) return [found(DB_STORAGE_POLICY, file, `${file}:${line} storage.buckets row has no literal id`, { line })];
378
+ if (publicValue === null || (publicValue && !id.endsWith('_public'))) {
379
+ findings.push(found(DB_STORAGE_POLICY, file, `${file}:${line} bucket ${JSON.stringify(id)} declares public = ${publicValue === null ? 'absent' : publicValue}; public must be explicit and only an *_public id may be public`, { line, bucket: id }));
314
380
  }
381
+ for (const [column, value] of [['file_size_limit', fileSize], ['allowed_mime_types', mimeTypes]]) {
382
+ if (value === undefined || isNull(value)) findings.push(found(DB_STORAGE_POLICY, file, `${file}:${line} bucket ${JSON.stringify(id)} declares no ${column}`, { line, bucket: id, column }));
383
+ }
384
+ const covered = policies.some((policy) => policy.schema === 'storage' && policy.table === 'objects'
385
+ && (expressionScopesBucket(policy.qual, id) || expressionScopesBucket(policy.withCheck, id)));
386
+ if (!covered) findings.push(found(DB_STORAGE_POLICY, file, `${file}:${line} bucket ${JSON.stringify(id)} has no storage.objects policy whose bucket_id predicate names it in this migration`, { line, bucket: id }));
387
+ return findings;
388
+ }
389
+
390
+ function storageFindings(file, facts) {
391
+ const findings = [];
392
+ const declarations = insertedBucketDeclarations(file, facts.buckets, findings);
393
+ declarations.push(...calledBucketDeclarations(facts.bucketCalls));
394
+ for (const declaration of declarations) findings.push(...bucketDeclarationFindings(file, declaration, facts.policies));
315
395
  return findings;
316
396
  }
317
397
 
@@ -324,7 +404,8 @@ export async function sqlAnalysis({ file, text, exposed, forceRls }) {
324
404
  } catch (error) {
325
405
  const position = error?.sqlDetails?.cursorPosition;
326
406
  const line = position ? lineAt(position - 1) : undefined;
327
- return { findings: [found(DB_MIGRATION_SHAPE, file, `${file}${line ? `:${line}` : ''} is not valid PostgreSQL (${String(error?.message ?? error).split('\n')[0]})`, { ...(line ? { line } : {}) })], facts: null };
407
+ const lineNote = line ? `:${line}` : '';
408
+ return { findings: [found(DB_MIGRATION_SHAPE, file, `${file}${lineNote} is not valid PostgreSQL (${String(error?.message ?? error).split('\n')[0]})`, { ...(line ? { line } : {}) })], facts: null };
328
409
  }
329
410
  const facts = factsOf(statementList(parsed.stmts, lineAt));
330
411
  return { facts, findings: [
@@ -5,6 +5,8 @@ import { configFindings, parseToml, typesFindings } from './database-config.mjs'
5
5
  import { defaultGit, migrationShapeFindings } from './database-migrations.mjs';
6
6
  import { migrationSetPolicyFindings, sqlAnalysis } from './database-sql.mjs';
7
7
  import { CONFIG_FILE, DB_CONFIG_POLICY, DB_MIGRATION_SHAPE, MIGRATIONS_DIR, TYPES_FILE } from './database-constants.mjs';
8
+ import { byCodeUnit } from '../../lib/list.mjs';
9
+ import { eachInOrder } from '../../lib/in-order.mjs';
8
10
 
9
11
  export { migrationStamp } from './database-migrations.mjs';
10
12
  export {
@@ -20,6 +22,30 @@ export {
20
22
  TYPES_FILE,
21
23
  } from './database-constants.mjs';
22
24
 
25
+ const declaresSupabaseConnection = (declaration) => ['be', 'fe'].some((side) => (declaration?.sides?.[side]?.connections ?? []).some((connection) => connection?.provider === 'supabase'));
26
+
27
+ async function configResult(repoRoot, declared) {
28
+ const configText = readText(repoRoot, CONFIG_FILE);
29
+ const config = configText === null ? null : await parseToml(configText);
30
+ 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`, {})] };
31
+ return { config, findings: configFindings({ file: CONFIG_FILE, text: configText, toml: config, supabase: declared ?? null }) };
32
+ }
33
+
34
+ async function migrationAnalyses({ repoRoot, migrations, exposed, forceRls, findings }) {
35
+ const analyses = [];
36
+ await eachInOrder(migrations, async (file) => {
37
+ const text = readText(repoRoot, file);
38
+ if (text === null) {
39
+ findings.push(found(DB_MIGRATION_SHAPE, file, `${file} is not a readable SQL file`, {}));
40
+ return;
41
+ }
42
+ const analysis = await sqlAnalysis({ file, text, exposed, forceRls });
43
+ findings.push(...analysis.findings);
44
+ if (analysis.facts) analyses.push({ file, facts: analysis.facts });
45
+ });
46
+ return analyses;
47
+ }
48
+
23
49
  /**
24
50
  * Judge the Supabase paths in one app. Git and type emission are injected for deterministic specs; this function
25
51
  * starts neither Docker nor the Supabase CLI. A full app is judged when it declares a Supabase connection, while
@@ -27,22 +53,18 @@ export {
27
53
  */
28
54
  export async function checkDatabase({ repoRoot, files, base, git, edition, supabase, emitTypes, now = () => Date.now() } = {}) {
29
55
  const findings = [];
30
- const migrations = files.filter((file) => file.startsWith(`${MIGRATIONS_DIR}/`)).sort();
56
+ const migrations = files.filter((file) => file.startsWith(`${MIGRATIONS_DIR}/`)).sort(byCodeUnit);
31
57
  const declaration = readJson(repoRoot, 'hfs.json');
32
58
  const declared = supabase === undefined ? declaration?.supabase : supabase;
33
59
  const hasConfig = files.includes(CONFIG_FILE);
34
- const supabaseConnection = ['be', 'fe'].some((side) => (declaration?.sides?.[side]?.connections ?? []).some((connection) => connection?.provider === 'supabase'));
60
+ const supabaseConnection = declaresSupabaseConnection(declaration);
35
61
  if (!migrations.length && !hasConfig && !files.includes(TYPES_FILE) && !declared && !supabaseConnection && (edition ?? declaration?.edition) !== 'lite') return findings;
36
62
 
37
63
  let config = null;
38
64
  if (hasConfig) {
39
- const configText = readText(repoRoot, CONFIG_FILE);
40
- config = configText === null ? null : await parseToml(configText);
41
- if (config === null) {
42
- findings.push(found(DB_CONFIG_POLICY, CONFIG_FILE, `${CONFIG_FILE} is not readable valid TOML; the config is policy, so it must parse`, {}));
43
- } else {
44
- findings.push(...configFindings({ file: CONFIG_FILE, text: configText, toml: config, supabase: declared ?? null }));
45
- }
65
+ const result = await configResult(repoRoot, declared);
66
+ config = result.config;
67
+ findings.push(...result.findings);
46
68
  }
47
69
 
48
70
  const apiSchemas = (config?.api?.schemas ?? []).filter((schema) => typeof schema === 'string');
@@ -50,19 +72,10 @@ export async function checkDatabase({ repoRoot, files, base, git, edition, supab
50
72
  const forceRls = new Set((declared?.forceRls ?? []).map((name) => (name.includes('.') ? name : `public.${name}`)));
51
73
  findings.push(...await migrationShapeFindings({ repoRoot, migrations, git: git ?? defaultGit, base, now }));
52
74
 
53
- const analyses = [];
54
- for (const file of migrations) {
55
- const text = readText(repoRoot, file);
56
- if (text === null) {
57
- findings.push(found(DB_MIGRATION_SHAPE, file, `${file} is not a readable SQL file`, {}));
58
- continue;
59
- }
60
- const analysis = await sqlAnalysis({ file, text, exposed, forceRls });
61
- findings.push(...analysis.findings);
62
- if (analysis.facts) analyses.push({ file, facts: analysis.facts });
63
- }
64
- findings.push(...migrationSetPolicyFindings(analyses));
65
-
66
- findings.push(...await typesFindings({ repoRoot, files, emitTypes, declaration: declared }));
75
+ const analyses = await migrationAnalyses({ repoRoot, migrations, exposed, forceRls, findings });
76
+ findings.push(
77
+ ...migrationSetPolicyFindings(analyses),
78
+ ...await typesFindings({ repoRoot, files, emitTypes, declaration: declared }),
79
+ );
67
80
  return findings;
68
81
  }