proof-harness 0.1.0-next.7 → 0.1.0-next.8

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.
package/COMPATIBILITY.md CHANGED
@@ -11,7 +11,7 @@ Consumers must pin an exact prerelease version, for example:
11
11
  ```json
12
12
  {
13
13
  "devDependencies": {
14
- "proof-harness": "0.1.0-next.6"
14
+ "proof-harness": "0.1.0-next.7"
15
15
  }
16
16
  }
17
17
  ```
@@ -27,9 +27,146 @@ versions rather than guessing how to interpret them.
27
27
 
28
28
  ## Prerelease migration notes
29
29
 
30
- ### Unreleased audit hardening
30
+ ### 0.1.0-next.8
31
31
 
32
- Release candidate `0.1.0-next.7` tightens acceptance behavior; it has not been published. Trace
32
+ Release candidate prepared for publication. Publish from `main` with workflow
33
+ input `0.1.0-next.8` after this version change is merged. Consumers can pin
34
+ `proof-harness@0.1.0-next.8` after the publish workflow succeeds.
35
+
36
+ #### Direct RLS clients and Auth administration (#18)
37
+
38
+ The scanner recognizes immutable local bindings returned by
39
+ `createSupabaseRLSClient`, including named-import aliases and awaited calls.
40
+ Their table writes are assessed as RLS writes, never recorded as service-role
41
+ writes. Binding identity is local to its scope: shadowed identifiers,
42
+ reassignment, escaped clients/builders, and unresolved privilege still fail.
43
+ This remains convention-based discovery of the named consumer factories, not
44
+ verification of their implementation or arbitrary wrapper/dataflow analysis.
45
+
46
+ Direct `serviceClient.auth.admin.deleteUser(...)` is recorded in the additive
47
+ capability field `serviceRoleAuthOperations: ["deleteUser"]`, separate from
48
+ `serviceRoleMutations`. This does not pretend the Auth API is a SQL DELETE
49
+ (and does not infer whether its optional soft-delete argument is true).
50
+ Other administrative methods, dynamic method names, detached calls, and
51
+ unresolved administrative privilege remain unassessed.
52
+
53
+ Regenerate capabilities. Changes to Auth operations participate in drift under
54
+ `service_role_mutations_changed`. Coverage requires a package-origin denial
55
+ assertion and an allowed-path control for a user-facing Auth-admin action,
56
+ even when it has no workspace input or voluntary invariant declaration.
57
+ Existing explicit action-gap policy handling remains unchanged; a recognized
58
+ operation alone is not passing authorization evidence. Trace, mission and
59
+ health protocol versions are unchanged; capability schema remains additive v1.
60
+
61
+ #### Catalog providers and rollback INSERT controls (#19, #20)
62
+
63
+ These changes are not in the published `0.1.0-next.7`. They are opt-in; the
64
+ default SQL parser and REST INSERT/DELETE control remain available. No trace,
65
+ mission, or health protocol version changes. Pin exactly `0.1.0-next.8` only
66
+ after it is released.
67
+
68
+ #### Catalog assessment
69
+
70
+ Set `schemaProvider: "scripts/proof/catalog-provider.mjs"` in the protected
71
+ `proof.config.mjs`. `parse`, the parse step of `build`, and both sides of
72
+ `drift` use it instead of the conservative SQL parser. ALTER and DROP are then
73
+ evaluated by PostgreSQL, not inferred from historical CREATE statements.
74
+
75
+ The module must export a default async function receiving:
76
+
77
+ - `protocolVersion: 1`, `rootDir` for the tree being assessed;
78
+ - `migrations`: ordered `{ path, sql, sha256 }` entries from the configured
79
+ migration directory (top-level `.sql` files), and their combined `inputHash`;
80
+ - `withLocalPostgres(connectionUrl, callback)` and
81
+ `readPostgresCatalog(client, schemas = ["public"])` from `proof-harness/node`.
82
+ These are injected so the archived base needs no installed dependencies to
83
+ import these helpers. Keep the provider's own imports dependency-free.
84
+
85
+ The consumer provider must create an empty disposable database, apply its
86
+ versioned platform bootstrap and every supplied migration in order, read the
87
+ catalog, and drop the database in `finally`. Fail on migration or cleanup
88
+ errors. Return `{ protocolVersion: 1, assessed: true, issues: [], tables }` only
89
+ after successful evaluation and cleanup; `tables` is the helper's result.
90
+ The connection helper restricts connections to loopback hosts. The consumer
91
+ owns the local database URL, creation privileges, platform bootstrap, migration
92
+ ordering, schema selection, and any required role setup. Never use the live
93
+ head database as the base catalog, clone an application-populated database,
94
+ or catch a failed migration and return partial facts.
95
+
96
+ Catalog table facts contain `schema`, `name`, `columns` (names), `rls_enabled`,
97
+ `rls_forced`, and `policies`. Each policy contains `name`, `command`, `roles`,
98
+ `mode`, `using`, and `check`. Classification is package-owned. Drift compares
99
+ column names, RLS state including FORCE, and full policy facts; this does not
100
+ add drift coverage for column types, defaults, functions, triggers, or grants.
101
+ Malformed envelopes/facts and changed migration inputs fail assessment and
102
+ replace stale successful output with an unassessed artifact.
103
+
104
+ The schema artifact adds `assessment` with provider protocol/path/hash,
105
+ migration paths/hashes, combined input hash, and repository source hash. This
106
+ is source identity, not a signature. The provider, its platform bootstrap,
107
+ configuration, and installed harness remain protected trust inputs.
108
+
109
+ For generated migrations, retain `driftPrepare` to rebuild them in each tree.
110
+ **Adoption requires a preparatory base commit:** register the provider, its
111
+ platform bootstrap, and config in the consumer before the comparison that
112
+ upgrades the package. Both base and head must declare a provider. Drift rejects
113
+ switching assessment modes across the comparison and never substitutes head
114
+ configuration for a base without the provider. Existing next.7 ignores the new
115
+ keys; registering them does not repair its conservative parser. A consumer
116
+ already blocked on next.7 needs to land this trusted setup separately before
117
+ the candidate's first successful drift run.
118
+
119
+ #### Append-only INSERT controls
120
+
121
+ Configure the protected consumer config explicitly, using your local ports:
122
+
123
+ ```js
124
+ insertControl: {
125
+ mode: "rollback",
126
+ databaseUrlEnv: "PROOF_DATABASE_URL",
127
+ role: "service_role",
128
+ supabaseUrl: "http://127.0.0.1:54321",
129
+ }
130
+ ```
131
+
132
+ Supply the corresponding local PostgreSQL URL through that environment
133
+ variable; never commit its password. The configured REST origin must match
134
+ `NEXT_PUBLIC_SUPABASE_URL`. The database URL must refer to the same stack,
135
+ schema, and migrations as REST; the harness validates the configured origin
136
+ but cannot discover which database a REST server actually serves.
137
+
138
+ The denied INSERT still executes through the authenticated Supabase client.
139
+ Only after an explicit `42501` denial and a service read confirming no write
140
+ does the harness validate the same payload on one PostgreSQL connection:
141
+ BEGIN, SET LOCAL ROLE, INSERT, force deferred constraints, ROLLBACK. The role
142
+ must have the intended INSERT permission. Missing columns keep their database
143
+ defaults; the harness supplies no tenancy values. An invalid payload, failed
144
+ role change, database error, or unacknowledged rollback fails as incomplete
145
+ evidence. The harness records the control assertion only after success and a
146
+ REST reread confirming no matching row remains. There is no consumer callback
147
+ that can stamp a passing control.
148
+
149
+ No DELETE privilege is needed for this control. Transactional trigger writes
150
+ also roll back, but sequence advancement and external side effects do not;
151
+ use only a disposable local stack. This control checks database validity under
152
+ the configured role, not REST middleware or an authenticated user's JWT
153
+ context. Templates whose privileged insert depends on that context must supply
154
+ a suitable database design/control strategy rather than treat this mode as
155
+ equivalent. Product authorization errors must still use SQLSTATE `42501`;
156
+ generic exceptions such as `P0001` are not accepted as authorization evidence.
157
+
158
+ For maintainers, `PROOF_DATABASE_URL=... node tests/postgres-integration.mjs`
159
+ after `pnpm build` exercises a separate temporary database against PostgreSQL,
160
+ including immediate and deferred failures and catalog ALTER/DROP behavior.
161
+
162
+ ### 0.1.0-next.7 — audit hardening
163
+
164
+ Published release `0.1.0-next.7` tightens acceptance behavior. Trace
165
+ <a id="unreleased-audit-hardening"></a>
166
+
167
+ ### 0.1.0-next.7
168
+
169
+ Published September 7, 2026. This release tightens acceptance behavior. Trace
33
170
  schema remains 2, with additive source/execution identity fields. Version 1
34
171
  and unversioned trace shapes remain explicitly readable, but CLI acceptance
35
172
  requires current provenance. Required consumer changes:
@@ -24,7 +24,7 @@ instead; this document is for anyone _consuming_ the template.
24
24
 
25
25
  ## Current acceptance semantics
26
26
 
27
- The unreleased audit hardening in [COMPATIBILITY.md](COMPATIBILITY.md#unreleased-audit-hardening)
27
+ The `0.1.0-next.7` audit hardening in [COMPATIBILITY.md](COMPATIBILITY.md#010-next7)
28
28
  supersedes older template-specific command examples below. Trace consumers
29
29
  must use `decodeTrace`/`decodeTraceBundle` for structural validation and
30
30
  `readTraceDirectory` for CLI-equivalent freshness checks. Evidence counts only
@@ -1221,3 +1221,28 @@ a trace version bump.
1221
1221
  `.proof/coverage-policy.json` carries its own `schemaVersion` (currently `1`).
1222
1222
  It is authored, not derived, and is read only by `pnpm proof:coverage`; consumers
1223
1223
  do not need to parse it.
1224
+
1225
+
1226
+ ## Catalog provider and rollback controls (0.1.0-next.8)
1227
+
1228
+ The protected consumer config may opt into `schemaProvider` protocol 1 and
1229
+ `insertControl.mode: "rollback"`. See [the setup and migration contract](COMPATIBILITY.md)
1230
+ for the exact provider envelope, base-commit requirements, and control configuration.
1231
+ `proof-harness/node` exports `withLocalPostgres`, `readPostgresCatalog`,
1232
+ `rollbackInsertControl`, and the `PostgresCatalogTable`/`PostgresCatalogPolicy` types.
1233
+ Providers own database materialization; the harness validates catalog facts,
1234
+ classifies policies, binds source identity, and compares both trees. A rollback
1235
+ INSERT control is package-executed and can only record passing control evidence
1236
+ after its insert, deferred checks, rollback, and absence reread succeed. The
1237
+ original authenticated request remains the primary authorization assertion.
1238
+
1239
+
1240
+ ## Direct Auth administration (0.1.0-next.8)
1241
+
1242
+ Capability schema v1 adds optional `serviceRoleAuthOperations`, currently a
1243
+ closed list containing only `"deleteUser"`. It describes direct service-client
1244
+ Auth API calls separately from `serviceRoleMutations` (SQL table writes).
1245
+ Such user-facing actions require denial and allowed-control evidence in
1246
+ coverage even without a workspace input. Changes use the existing
1247
+ `service_role_mutations_changed` drift facet. See `COMPATIBILITY.md` for
1248
+ supported factory/binding shapes and required consumer regeneration.
package/README.md CHANGED
@@ -17,13 +17,13 @@ Public prereleases are published under the npm `next` tag. Pin an exact
17
17
  prerelease version while the API is stabilizing:
18
18
 
19
19
  ```sh
20
- pnpm add proof-harness@0.1.0-next.6
20
+ pnpm add proof-harness@0.1.0-next.7
21
21
  ```
22
22
 
23
23
  The package exposes environment-specific entry points:
24
24
 
25
25
  - `proof-harness/shared` — portable types and vocabularies
26
- - `proof-harness/node` — Node mission validation helpers
26
+ - `proof-harness/node` — Node mission validation and local PostgreSQL helpers
27
27
  - `proof-harness/playwright` — Playwright proof assertions and traces
28
28
  - `proof-harness/server` — server-side fixtures, guards, and service clients
29
29
  - `proof-harness/portable-vocabulary` — dependency-free runtime constants
@@ -92,15 +92,21 @@ The package is released from this public repository with npm provenance.
92
92
  Prerelease consumers must pin an exact version rather than using `next`, `^`, or
93
93
  `~`; see [COMPATIBILITY.md](./COMPATIBILITY.md) for the compatibility policy.
94
94
 
95
- ## Audit hardening (0.1.0-next.7, unreleased)
95
+ ## Audit hardening (0.1.0-next.7)
96
96
 
97
- The working branch adds shared fail-closed trace ingestion, source freshness,
97
+ This release adds shared fail-closed trace ingestion, source freshness,
98
98
  stronger authorization probes, isolated mutation evidence and interruption
99
99
  recovery, conservative discovery, and package-owned drift regeneration.
100
- See [required migration changes](COMPATIBILITY.md#unreleased-audit-hardening)
100
+ See [required migration changes](COMPATIBILITY.md#010-next7--audit-hardening)
101
101
  before upgrading a consumer. In particular, existing traces must be rerun and
102
102
  unsupported SQL now blocks assessment.
103
103
 
104
+ Release candidate `0.1.0-next.8` adds direct RLS client and Auth-admin discovery,
105
+ a consumer-owned catalog provider for full SQL histories, and transactional
106
+ INSERT controls for append-only tables. After publication, pin exactly
107
+ `proof-harness@0.1.0-next.8`. See [compatibility and setup](COMPATIBILITY.md#010-next8)
108
+ for the required consumer changes.
109
+
104
110
  The harness establishes specific tested claims. Acceptance still requires
105
111
  consumer-owned requirements and a trusted CI job that controls the harness,
106
112
  policies, and evidence. A passing trace is not a general proof of code quality
package/cli/config.mjs CHANGED
@@ -46,6 +46,8 @@ const DEFAULT_CONFIG = Object.freeze({
46
46
  // Optional consumer-owned argv used to prepare generated migration inputs
47
47
  // in both the working tree and the archived base before parsing.
48
48
  driftPrepare: null,
49
+ schemaProvider: null,
50
+ insertControl: null,
49
51
  driftSources: ["app", "package.json"],
50
52
  mutationCatalog: null,
51
53
  });
@@ -40,6 +40,8 @@ import fs from "node:fs";
40
40
  import path from "node:path";
41
41
  import { pathToFileURL } from "node:url";
42
42
 
43
+ import { assessWithProvider } from "./proof_schema_provider.mjs";
44
+ import { classifyTable } from "./proof_schema_classification.mjs";
43
45
  import { loadProofConfig } from "../config.mjs";
44
46
 
45
47
  const CONFIG = await loadProofConfig();
@@ -283,87 +285,12 @@ function parsePolicies(sql, sourceFile) {
283
285
  // Classification
284
286
  // ---------------------------------------------------------------------------
285
287
 
286
- function policyIndicatesWorkspace(policy) {
287
- const body = `${policy.using} ${policy.check}`.toLowerCase();
288
- if (body.includes("get_user_workspace_ids(")) return true;
289
- if (
290
- /workspace_id\s+in\s*\(\s*select\s+workspace_id\s+from\s+workspace_members/i.test(
291
- `${policy.using} ${policy.check}`,
292
- )
293
- ) {
294
- return true;
295
- }
296
- return false;
297
- }
298
-
299
- function policyIndicatesUser(policy) {
300
- const body = `${policy.using} ${policy.check}`;
301
- // user_id = auth.uid() OR auth.uid() = user_id OR owner/created_by variants
302
- const patterns = [
303
- /\b(user_id|owner_id|created_by|invited_by)\s*=\s*\(?\s*(select\s+)?auth\.uid\(\)/i,
304
- /\bauth\.uid\(\)\s*=\s*(user_id|owner_id|created_by|invited_by)\b/i,
305
- /\(\s*select\s+auth\.uid\(\)\s*\)\s*=\s*(user_id|owner_id|created_by|invited_by)\b/i,
306
- /\b(user_id|owner_id|created_by|invited_by)\s*=\s*\(\s*select\s+auth\.uid\(\)\s*\)/i,
307
- ];
308
- return patterns.some((re) => re.test(body));
309
- }
310
-
311
- function policyIsPublicRead(policy) {
312
- if (policy.command !== "SELECT") return false;
313
- const body = policy.using.trim().toLowerCase();
314
- return body === "true";
315
- }
316
-
317
- function classifyTable(policies) {
318
- // No policies at all → admin_only (assumes RLS-enabled Supabase table
319
- // without authenticated access).
320
- if (policies.length === 0) return "admin_only";
321
-
322
- // A policy with no TO clause defaults to `TO public` in Postgres, so
323
- // treat an empty `roles` list as implicit public (i.e. authenticated
324
- // users are subject to it). This matches the common
325
- // `CREATE POLICY foo ON t FOR SELECT USING (...)` pattern.
326
- const isAuthFacing = (p) =>
327
- p.roles.length === 0 ||
328
- p.roles.some((r) => r === "authenticated" || r === "public");
329
- const authPolicies = policies.filter(isAuthFacing);
330
- const serviceOnly =
331
- authPolicies.length === 0 &&
332
- policies.length > 0 &&
333
- policies.every(
334
- (p) => p.roles.length === 1 && p.roles[0] === "service_role",
335
- );
336
-
337
- if (serviceOnly) return "service_only";
338
-
339
- // Check workspace first (most specific).
340
- if (authPolicies.some(policyIndicatesWorkspace)) return "workspace_scoped";
341
- if (authPolicies.some(policyIndicatesUser)) return "user_scoped";
342
-
343
- // public_read: every authenticated-accessible policy is SELECT with
344
- // USING (true). (Don't classify as public_read if any write policy
345
- // targets authenticated, that's a different pattern.)
346
- const readPolicies = authPolicies.filter(
347
- (p) => p.command === "SELECT" || p.command === "ALL",
348
- );
349
- if (
350
- readPolicies.length > 0 &&
351
- readPolicies.every(policyIsPublicRead) &&
352
- authPolicies.every((p) => p.command === "SELECT")
353
- ) {
354
- return "public_read";
355
- }
356
-
357
- if (authPolicies.length === 0) return "admin_only";
358
-
359
- return "unclassified";
360
- }
361
-
362
288
  // ---------------------------------------------------------------------------
363
289
  // Main
364
290
  // ---------------------------------------------------------------------------
365
291
 
366
- export function main() {
292
+ export async function main() {
293
+ if (CONFIG.schemaProvider) return assessWithProvider(CONFIG);
367
294
  const sources = readAllMigrations();
368
295
  const unsupported = [];
369
296
  const enabledTables = new Set();
@@ -487,5 +414,5 @@ const invokedDirectly =
487
414
  import.meta.url === pathToFileURL(process.argv[1]).href;
488
415
 
489
416
  if (invokedDirectly) {
490
- main();
417
+ await main();
491
418
  }
@@ -352,6 +352,8 @@ function buildReport() {
352
352
  // user_scoped by their owner policy, but a service-role action accepting an
353
353
  // arbitrary workspaceId still needs an action-layer cross-tenant probe.
354
354
  //
355
+ // Auth administrative operations require this boundary independently of
356
+ // workspace inputs because their target may be another user.
355
357
  // A denial alone is not enough. A broken/unregistered action also refuses
356
358
  // everyone, so the same action target needs a passing allowed-path control.
357
359
  const denialKinds = new Set(["authorization", "tenant_isolation"]);
@@ -366,14 +368,17 @@ function buildReport() {
366
368
  `${a.module}:${a.name}`.localeCompare(`${b.module}:${b.name}`),
367
369
  )) {
368
370
  if (
369
- capability.acceptsWorkspaceId !== true ||
371
+ (capability.acceptsWorkspaceId !== true &&
372
+ !(capability.serviceRoleAuthOperations ?? []).length) ||
370
373
  capability.internalOnly === true
371
374
  ) {
372
375
  continue;
373
376
  }
374
377
 
375
378
  const workspaceMutations = capability.serviceRoleMutations ?? [];
376
- if (workspaceMutations.length === 0) continue;
379
+ const authOperations = capability.serviceRoleAuthOperations ?? [];
380
+ if (workspaceMutations.length === 0 && authOperations.length === 0)
381
+ continue;
377
382
 
378
383
  const action = `${capability.module}:${capability.name}`;
379
384
  const denialEvidence = assertions.filter(
@@ -404,6 +409,7 @@ function buildReport() {
404
409
  action,
405
410
  tables: [...new Set(workspaceMutations.map((m) => m.table))].sort(),
406
411
  mutations: workspaceMutations,
412
+ authOperations,
407
413
  denial: denialProven ? "proven" : "gap",
408
414
  control: controlProven ? "proven" : "gap",
409
415
  status: proven ? "proven" : acceptance ? "accepted_gap" : "gap",
@@ -593,13 +599,20 @@ function printReport(report) {
593
599
  );
594
600
  heading(
595
601
  "SERVICE-ROLE ACTION BOUNDARIES",
596
- "Derived from caller-controlled workspaceId inputs plus service-role mutations. Each action needs both an unauthorized/cross-tenant refusal and an allowed-path control; withProof metadata is not consulted.",
602
+ "Derived from workspace-targeted service-role writes or privileged Auth operations. Each action needs both an unauthorized/cross-tenant refusal and an allowed-path control; withProof metadata is not consulted.",
597
603
  );
598
604
  console.log("");
599
605
  printTable(
600
606
  [
601
607
  { header: "action", get: (r) => r.action },
602
- { header: "tables", get: (r) => r.tables.join(",") },
608
+ {
609
+ header: "effects",
610
+ get: (r) =>
611
+ [
612
+ ...r.tables,
613
+ ...r.authOperations.map((op) => `auth.admin.${op}`),
614
+ ].join(","),
615
+ },
603
616
  { header: "denial", get: (r) => r.denial },
604
617
  { header: "control", get: (r) => r.control },
605
618
  { header: "status", get: (r) => r.status },
@@ -767,7 +780,7 @@ gap or an unproven action requirement.`);
767
780
  if (serviceRoleActionGaps.length > 0) {
768
781
  problems.push({
769
782
  line:
770
- `[PROOF_FAIL] service_role_action_gap: ${serviceRoleActionGaps.length} workspace-targeted service-role action(s) lack mandatory action evidence: ` +
783
+ `[PROOF_FAIL] service_role_action_gap: ${serviceRoleActionGaps.length} privileged action(s) lack mandatory action evidence: ` +
771
784
  serviceRoleActionGaps
772
785
  .map((g) => {
773
786
  const missing = [
@@ -162,8 +162,14 @@ function actionChanges(base, head) {
162
162
 
163
163
  const mutationKey = (m) => `${m.table}:${m.operation}`;
164
164
  const mutations = setDiff(
165
- (base.serviceRoleMutations ?? []).map(mutationKey),
166
- (head.serviceRoleMutations ?? []).map(mutationKey),
165
+ [
166
+ ...(base.serviceRoleMutations ?? []).map(mutationKey),
167
+ ...(base.serviceRoleAuthOperations ?? []).map((op) => `auth.admin:${op}`),
168
+ ],
169
+ [
170
+ ...(head.serviceRoleMutations ?? []).map(mutationKey),
171
+ ...(head.serviceRoleAuthOperations ?? []).map((op) => `auth.admin:${op}`),
172
+ ],
167
173
  );
168
174
  if (mutations.added.length > 0 || mutations.removed.length > 0) {
169
175
  facets.push("service_role_mutations_changed");
@@ -279,11 +285,12 @@ export function diffSchema(baseArtifact, headArtifact) {
279
285
  const facets = [];
280
286
  if (
281
287
  baseTable.rls_classification !== headTable.rls_classification ||
282
- baseTable.rls_enabled !== headTable.rls_enabled
288
+ baseTable.rls_enabled !== headTable.rls_enabled ||
289
+ Boolean(baseTable.rls_forced) !== Boolean(headTable.rls_forced)
283
290
  ) {
284
291
  facets.push("rls_classification_changed");
285
292
  notes.push(
286
- `rls_classification: ${baseTable.rls_classification} -> ${headTable.rls_classification}`,
293
+ `RLS: ${baseTable.rls_classification} -> ${headTable.rls_classification}; enabled ${baseTable.rls_enabled} -> ${headTable.rls_enabled}; forced ${Boolean(baseTable.rls_forced)} -> ${Boolean(headTable.rls_forced)}`,
287
294
  );
288
295
  }
289
296
  const columns = setDiff(baseTable.columns, headTable.columns);
@@ -584,6 +591,10 @@ async function generateBaseArtifacts(baseSha, tmpDir) {
584
591
  cwd: treeDir,
585
592
  configPath: baseConfigPath,
586
593
  });
594
+ if (Boolean(baseConfig.schemaProvider) !== Boolean(CONFIG.schemaProvider))
595
+ throw new Error(
596
+ "schema provider adoption requires a preparatory base commit: register the provider and platform bootstrap in both trees before switching assessment modes; head configuration is never substituted for the base",
597
+ );
587
598
  const relocated = { ...baseConfig, rootDir: treeDir };
588
599
  for (const group of ["artifacts", "roots", "repository"]) {
589
600
  for (const value of Object.values(relocated[group])) {
@@ -0,0 +1,75 @@
1
+ function policyIndicatesWorkspace(policy) {
2
+ const body = `${policy.using} ${policy.check}`.toLowerCase();
3
+ if (body.includes("get_user_workspace_ids(")) return true;
4
+ if (
5
+ /workspace_id\s+in\s*\(\s*select\s+workspace_id\s+from\s+workspace_members/i.test(
6
+ `${policy.using} ${policy.check}`,
7
+ )
8
+ ) {
9
+ return true;
10
+ }
11
+ return false;
12
+ }
13
+
14
+ function policyIndicatesUser(policy) {
15
+ const body = `${policy.using} ${policy.check}`;
16
+ // user_id = auth.uid() OR auth.uid() = user_id OR owner/created_by variants
17
+ const patterns = [
18
+ /\b(user_id|owner_id|created_by|invited_by)\s*=\s*\(?\s*(select\s+)?auth\.uid\(\)/i,
19
+ /\bauth\.uid\(\)\s*=\s*(user_id|owner_id|created_by|invited_by)\b/i,
20
+ /\(\s*select\s+auth\.uid\(\)\s*\)\s*=\s*(user_id|owner_id|created_by|invited_by)\b/i,
21
+ /\b(user_id|owner_id|created_by|invited_by)\s*=\s*\(\s*select\s+auth\.uid\(\)\s*\)/i,
22
+ ];
23
+ return patterns.some((re) => re.test(body));
24
+ }
25
+
26
+ function policyIsPublicRead(policy) {
27
+ if (policy.command !== "SELECT") return false;
28
+ const body = policy.using.trim().toLowerCase();
29
+ return body === "true";
30
+ }
31
+
32
+ export function classifyTable(policies) {
33
+ // No policies at all → admin_only (assumes RLS-enabled Supabase table
34
+ // without authenticated access).
35
+ if (policies.length === 0) return "admin_only";
36
+
37
+ // A policy with no TO clause defaults to `TO public` in Postgres, so
38
+ // treat an empty `roles` list as implicit public (i.e. authenticated
39
+ // users are subject to it). This matches the common
40
+ // `CREATE POLICY foo ON t FOR SELECT USING (...)` pattern.
41
+ const isAuthFacing = (p) =>
42
+ p.roles.length === 0 ||
43
+ p.roles.some((r) => r === "authenticated" || r === "public");
44
+ const authPolicies = policies.filter(isAuthFacing);
45
+ const serviceOnly =
46
+ authPolicies.length === 0 &&
47
+ policies.length > 0 &&
48
+ policies.every(
49
+ (p) => p.roles.length === 1 && p.roles[0] === "service_role",
50
+ );
51
+
52
+ if (serviceOnly) return "service_only";
53
+
54
+ // Check workspace first (most specific).
55
+ if (authPolicies.some(policyIndicatesWorkspace)) return "workspace_scoped";
56
+ if (authPolicies.some(policyIndicatesUser)) return "user_scoped";
57
+
58
+ // public_read: every authenticated-accessible policy is SELECT with
59
+ // USING (true). (Don't classify as public_read if any write policy
60
+ // targets authenticated, that's a different pattern.)
61
+ const readPolicies = authPolicies.filter(
62
+ (p) => p.command === "SELECT" || p.command === "ALL",
63
+ );
64
+ if (
65
+ readPolicies.length > 0 &&
66
+ readPolicies.every(policyIsPublicRead) &&
67
+ authPolicies.every((p) => p.command === "SELECT")
68
+ ) {
69
+ return "public_read";
70
+ }
71
+
72
+ if (authPolicies.length === 0) return "admin_only";
73
+
74
+ return "unclassified";
75
+ }