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

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.8"
15
15
  }
16
16
  }
17
17
  ```
@@ -27,9 +27,253 @@ 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.9 — anonymous actions and control sensitivity (release candidate)
31
31
 
32
- Release candidate `0.1.0-next.7` tightens acceptance behavior; it has not been published. Trace
32
+ Prepared for release; not yet published. After publication, pin exactly
33
+ `proof-harness@0.1.0-next.9` and regenerate capabilities and baseline traces.
34
+ Trace/mission/health versions remain unchanged. Authentication refusal and
35
+ control-sensitivity output contracts use protocol/schema version 1.
36
+
37
+ #### Anonymous self-service actions (#23)
38
+
39
+ `assert.authorization` accepts `actor: "anonymous"` for **action probes only**.
40
+ It sends the protected POST invoker request in a fresh API context, without
41
+ login cookies or Authorization, and disables redirect following. It does not
42
+ clear or reuse the authenticated page session. Use the Playwright project's
43
+ `use.baseURL`, an explicit `action.baseURL`, or an already navigated page URL.
44
+
45
+ ```ts
46
+ await assert.authorization({
47
+ actor: "anonymous", page,
48
+ action: {
49
+ module: "user-management", name: "selfDeleteAccount", inputParams: {},
50
+ expectedAuthRedirect: "/sign-in",
51
+ },
52
+ });
53
+ ```
54
+
55
+ The consumer must update its **guarded invoker**, after request validation and
56
+ registered-handler lookup. In the catch around actual handler execution:
57
+
58
+ ```ts
59
+ import { authenticationRedirectResponse } from "proof-harness/server";
60
+ // Existing proofGuard, body validation, registry lookup, and handler call stay.
61
+ const refusal = authenticationRedirectResponse(error, request, {
62
+ module: body.module, action: body.action, allowedRedirects: ["/sign-in"],
63
+ });
64
+ if (refusal) return refusal;
65
+ // Keep the existing failure response for all other exceptions.
66
+ ```
67
+
68
+ Choose the exact authentication destination from the consumer's own auth
69
+ middleware. The adapter recognizes Next.js redirect errors only, requires no
70
+ Cookie/Authorization request headers, and matches an exact allowlisted relative
71
+ URL. It returns HTTP 401 with `{ protocolVersion: 1,
72
+ type: "proof_action_auth_refusal", module, action,
73
+ reason: "authentication_required", redirect }`. The client binds that envelope
74
+ to the invoked action and the assertion checks `expectedAuthRedirect`.
75
+ A generic 401/500, `action_threw`, invalid-form rejection, unexpected redirect,
76
+ malformed result, or missing registry entry is incomplete evidence. Anonymous
77
+ success is a failed authorization assertion. No seeded denied role is needed.
78
+
79
+ Use `assert.actionSucceeds` with a disposable authenticated actor for the
80
+ allowed-path control; account-deletion fixtures remain consumer-owned. The
81
+ adapter's destination list and placement are protected trust inputs: it must
82
+ only normalize authentication redirects from an executed registered action,
83
+ not invoker setup errors or unrelated redirects. This is not automatic proof
84
+ of an arbitrary application's authentication semantics.
85
+
86
+ Workspace scope discovery now follows local caller-input bindings and accesses,
87
+ including destructuring aliases and direct parse/safeParse results. It no
88
+ longer counts comments, SQL/output keys, or generated/result workspace IDs.
89
+ Unknown user-input dataflow used as workspace scope and dynamic input keys
90
+ remain unassessed. Internal `_BOT.ts` actions retain their existing exemption
91
+ from caller-boundary coverage. This is bounded local analysis, not intermodule
92
+ schema inference. In particular `createTeamWorkspace` no longer inherits a
93
+ cross-tenant requirement merely because it returns a new workspace ID. Declare
94
+ any additional product invariants and add their proofs in the consumer.
95
+
96
+ #### Separate positive-control sensitivity (#24)
97
+
98
+ Move the old `TENANT-deny-all` entry out of the primary mutation catalog into
99
+ a separate protected module named by `controlSensitivityCatalog` in
100
+ `proof.config.mjs`. Keep its existing subject, apply SQL and spec; add:
101
+
102
+ ```js
103
+ claims: [],
104
+ controls: [{
105
+ kind: "tenant_isolation", target: "workspace_members", operation: "select",
106
+ emittedBy: "assert.tenantIsolation",
107
+ }],
108
+ expectedFailureCode: "tenant_isolation_control",
109
+ ```
110
+
111
+ Run `proof-harness controls --inventory`, then `proof-harness controls` (or
112
+ `--only TENANT-deny-all`). Both require fresh passing baseline evidence for
113
+ every mapped package-origin **control** in the selected spec. The initial
114
+ contract supports tenant-isolation SELECT controls emitted by
115
+ `assert.tenantIsolation` or `assert.authorization`, with the exact failure code
116
+ `tenant_isolation_control`. Other sensitivity categories remain unsupported.
117
+
118
+ The runner reuses mutation planting/readback, isolated execution, restoration,
119
+ and recovery checks. A successful test needs a non-timeout failed proof, a
120
+ failed mapped control assertion of the same helper origin, and a step error
121
+ beginning with that exact failure code. Setup errors, incomplete/skipped
122
+ assertions, another spec, primary-only failures, or a missing failed control do
123
+ not count. Unexpected owner-control database errors are setup failures rather
124
+ than control rejection; explicit permission denial or zero visible owner rows
125
+ can establish the intended anti-vacuity failure.
126
+
127
+ Results live separately in `artifacts.controlSensitivity` (default
128
+ `.proof/control-sensitivity`), with `mode: "control-sensitivity"`,
129
+ `controlRejected`/`controlsTurnedRed`, and `detected: false`. They remain planted
130
+ traces and cannot satisfy baseline claims or primary mutation coverage. Use
131
+ `proof-harness controls --recover` for an interrupted run against the same
132
+ local stack; the shared recovery journal blocks either runner until restored.
133
+ Primary `mutate` still rejects `claims: []` with no resolved primary baseline.
134
+ A failed control can no longer count as a failed primary claim of the same
135
+ kind/target/operation. Wire both commands into consumer CI if both properties
136
+ are required.
137
+
138
+ ### 0.1.0-next.8
139
+
140
+ Published release `0.1.0-next.8`. Consumers pin exactly
141
+ `proof-harness@0.1.0-next.8` when adopting these features.
142
+
143
+ #### Direct RLS clients and Auth administration (#18)
144
+
145
+ The scanner recognizes immutable local bindings returned by
146
+ `createSupabaseRLSClient`, including named-import aliases and awaited calls.
147
+ Their table writes are assessed as RLS writes, never recorded as service-role
148
+ writes. Binding identity is local to its scope: shadowed identifiers,
149
+ reassignment, escaped clients/builders, and unresolved privilege still fail.
150
+ This remains convention-based discovery of the named consumer factories, not
151
+ verification of their implementation or arbitrary wrapper/dataflow analysis.
152
+
153
+ Direct `serviceClient.auth.admin.deleteUser(...)` is recorded in the additive
154
+ capability field `serviceRoleAuthOperations: ["deleteUser"]`, separate from
155
+ `serviceRoleMutations`. This does not pretend the Auth API is a SQL DELETE
156
+ (and does not infer whether its optional soft-delete argument is true).
157
+ Other administrative methods, dynamic method names, detached calls, and
158
+ unresolved administrative privilege remain unassessed.
159
+
160
+ Regenerate capabilities. Changes to Auth operations participate in drift under
161
+ `service_role_mutations_changed`. Coverage requires a package-origin denial
162
+ assertion and an allowed-path control for a user-facing Auth-admin action,
163
+ even when it has no workspace input or voluntary invariant declaration.
164
+ Existing explicit action-gap policy handling remains unchanged; a recognized
165
+ operation alone is not passing authorization evidence. Trace, mission and
166
+ health protocol versions are unchanged; capability schema remains additive v1.
167
+
168
+ #### Catalog providers and rollback INSERT controls (#19, #20)
169
+
170
+ These changes are not in the published `0.1.0-next.7`. They are opt-in; the
171
+ default SQL parser and REST INSERT/DELETE control remain available. No trace,
172
+ mission, or health protocol version changes. Pin exactly `0.1.0-next.8` only
173
+ after it is released.
174
+
175
+ #### Catalog assessment
176
+
177
+ Set `schemaProvider: "scripts/proof/catalog-provider.mjs"` in the protected
178
+ `proof.config.mjs`. `parse`, the parse step of `build`, and both sides of
179
+ `drift` use it instead of the conservative SQL parser. ALTER and DROP are then
180
+ evaluated by PostgreSQL, not inferred from historical CREATE statements.
181
+
182
+ The module must export a default async function receiving:
183
+
184
+ - `protocolVersion: 1`, `rootDir` for the tree being assessed;
185
+ - `migrations`: ordered `{ path, sql, sha256 }` entries from the configured
186
+ migration directory (top-level `.sql` files), and their combined `inputHash`;
187
+ - `withLocalPostgres(connectionUrl, callback)` and
188
+ `readPostgresCatalog(client, schemas = ["public"])` from `proof-harness/node`.
189
+ These are injected so the archived base needs no installed dependencies to
190
+ import these helpers. Keep the provider's own imports dependency-free.
191
+
192
+ The consumer provider must create an empty disposable database, apply its
193
+ versioned platform bootstrap and every supplied migration in order, read the
194
+ catalog, and drop the database in `finally`. Fail on migration or cleanup
195
+ errors. Return `{ protocolVersion: 1, assessed: true, issues: [], tables }` only
196
+ after successful evaluation and cleanup; `tables` is the helper's result.
197
+ The connection helper restricts connections to loopback hosts. The consumer
198
+ owns the local database URL, creation privileges, platform bootstrap, migration
199
+ ordering, schema selection, and any required role setup. Never use the live
200
+ head database as the base catalog, clone an application-populated database,
201
+ or catch a failed migration and return partial facts.
202
+
203
+ Catalog table facts contain `schema`, `name`, `columns` (names), `rls_enabled`,
204
+ `rls_forced`, and `policies`. Each policy contains `name`, `command`, `roles`,
205
+ `mode`, `using`, and `check`. Classification is package-owned. Drift compares
206
+ column names, RLS state including FORCE, and full policy facts; this does not
207
+ add drift coverage for column types, defaults, functions, triggers, or grants.
208
+ Malformed envelopes/facts and changed migration inputs fail assessment and
209
+ replace stale successful output with an unassessed artifact.
210
+
211
+ The schema artifact adds `assessment` with provider protocol/path/hash,
212
+ migration paths/hashes, combined input hash, and repository source hash. This
213
+ is source identity, not a signature. The provider, its platform bootstrap,
214
+ configuration, and installed harness remain protected trust inputs.
215
+
216
+ For generated migrations, retain `driftPrepare` to rebuild them in each tree.
217
+ **Adoption requires a preparatory base commit:** register the provider, its
218
+ platform bootstrap, and config in the consumer before the comparison that
219
+ upgrades the package. Both base and head must declare a provider. Drift rejects
220
+ switching assessment modes across the comparison and never substitutes head
221
+ configuration for a base without the provider. Existing next.7 ignores the new
222
+ keys; registering them does not repair its conservative parser. A consumer
223
+ already blocked on next.7 needs to land this trusted setup separately before
224
+ the candidate's first successful drift run.
225
+
226
+ #### Append-only INSERT controls
227
+
228
+ Configure the protected consumer config explicitly, using your local ports:
229
+
230
+ ```js
231
+ insertControl: {
232
+ mode: "rollback",
233
+ databaseUrlEnv: "PROOF_DATABASE_URL",
234
+ role: "service_role",
235
+ supabaseUrl: "http://127.0.0.1:54321",
236
+ }
237
+ ```
238
+
239
+ Supply the corresponding local PostgreSQL URL through that environment
240
+ variable; never commit its password. The configured REST origin must match
241
+ `NEXT_PUBLIC_SUPABASE_URL`. The database URL must refer to the same stack,
242
+ schema, and migrations as REST; the harness validates the configured origin
243
+ but cannot discover which database a REST server actually serves.
244
+
245
+ The denied INSERT still executes through the authenticated Supabase client.
246
+ Only after an explicit `42501` denial and a service read confirming no write
247
+ does the harness validate the same payload on one PostgreSQL connection:
248
+ BEGIN, SET LOCAL ROLE, INSERT, force deferred constraints, ROLLBACK. The role
249
+ must have the intended INSERT permission. Missing columns keep their database
250
+ defaults; the harness supplies no tenancy values. An invalid payload, failed
251
+ role change, database error, or unacknowledged rollback fails as incomplete
252
+ evidence. The harness records the control assertion only after success and a
253
+ REST reread confirming no matching row remains. There is no consumer callback
254
+ that can stamp a passing control.
255
+
256
+ No DELETE privilege is needed for this control. Transactional trigger writes
257
+ also roll back, but sequence advancement and external side effects do not;
258
+ use only a disposable local stack. This control checks database validity under
259
+ the configured role, not REST middleware or an authenticated user's JWT
260
+ context. Templates whose privileged insert depends on that context must supply
261
+ a suitable database design/control strategy rather than treat this mode as
262
+ equivalent. Product authorization errors must still use SQLSTATE `42501`;
263
+ generic exceptions such as `P0001` are not accepted as authorization evidence.
264
+
265
+ For maintainers, `PROOF_DATABASE_URL=... node tests/postgres-integration.mjs`
266
+ after `pnpm build` exercises a separate temporary database against PostgreSQL,
267
+ including immediate and deferred failures and catalog ALTER/DROP behavior.
268
+
269
+ ### 0.1.0-next.7 — audit hardening
270
+
271
+ Published release `0.1.0-next.7` tightens acceptance behavior. Trace
272
+ <a id="unreleased-audit-hardening"></a>
273
+
274
+ ### 0.1.0-next.7
275
+
276
+ Published September 7, 2026. This release tightens acceptance behavior. Trace
33
277
  schema remains 2, with additive source/execution identity fields. Version 1
34
278
  and unversioned trace shapes remain explicitly readable, but CLI acceptance
35
279
  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,48 @@ 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.
1249
+
1250
+
1251
+ ## Anonymous invocations and control sensitivity (0.1.0-next.9 candidate)
1252
+
1253
+ The versioned anonymous refusal envelope is `ActionAuthenticationRefusal`
1254
+ (protocol 1), validated by `isActionAuthenticationRefusal` from the shared
1255
+ entry point. The server exports `authenticationRedirectResponse`; Playwright
1256
+ exports `actAsUser.invokeAnonymousAction` and accepts `actor: "anonymous"` in
1257
+ `assert.authorization` action probes. Only an action-bound, expected
1258
+ authentication redirect can establish a passing anonymous denial; generic
1259
+ exceptions are not evidence. See `COMPATIBILITY.md` for the required protected
1260
+ invoker integration and allowed authenticated control.
1261
+
1262
+ `proof-harness controls` consumes `controlSensitivityCatalog` independently
1263
+ from primary mutation inventory and writes `mode: "control-sensitivity"`
1264
+ results under `artifacts.controlSensitivity`. Its mapped failed controls never
1265
+ count as primary mutation detection. The initial contract is limited to
1266
+ tenant-isolation SELECT anti-vacuity checks with `tenant_isolation_control`;
1267
+ see `COMPATIBILITY.md` for exact selectors, fresh baseline requirements and
1268
+ recovery. Consumer claims and product fixtures remain consumer-owned.
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.8
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
@@ -61,7 +61,7 @@ code via `allowProofServiceHosts`; there is deliberately no environment
61
61
  override.
62
62
 
63
63
  The package also provides one `proof-harness` executable with `scan`, `parse`,
64
- `registry`, `build`, `verify`, `coverage`, `inventory`, `drift`, and `mutate`
64
+ `registry`, `build`, `verify`, `coverage`, `inventory`, `drift`, `mutate`, and `controls`
65
65
  subcommands. Commands read repository paths from
66
66
  `proof.config.mjs`; without one they use a conventional application layout.
67
67
  Product migration aggregation and explicit mutation definitions remain
@@ -92,16 +92,28 @@ 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 `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. 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
107
113
  or a defense against an executor that can rewrite the acceptance gate.
114
+
115
+
116
+ Release candidate `0.1.0-next.9` adds isolated anonymous action probes and a
117
+ separate positive-control sensitivity runner. It also distinguishes caller
118
+ workspace inputs from generated result IDs. See [next.9 setup and migration](COMPATIBILITY.md)
119
+ before upgrading; consumer invoker and catalog changes are required.
package/cli/config.mjs CHANGED
@@ -9,6 +9,7 @@ const DEFAULT_CONFIG = Object.freeze({
9
9
  traces: ".proof/traces",
10
10
  drift: ".proof/drift.json",
11
11
  mutations: ".proof/mutations",
12
+ controlSensitivity: ".proof/control-sensitivity",
12
13
  },
13
14
  // The module-descriptor system (module.meta.ts → modules.json and its
14
15
  // checker) moved to the consumer template in 0.1.0-next.6: it serves the
@@ -46,8 +47,11 @@ const DEFAULT_CONFIG = Object.freeze({
46
47
  // Optional consumer-owned argv used to prepare generated migration inputs
47
48
  // in both the working tree and the archived base before parsing.
48
49
  driftPrepare: null,
50
+ schemaProvider: null,
51
+ insertControl: null,
49
52
  driftSources: ["app", "package.json"],
50
53
  mutationCatalog: null,
54
+ controlSensitivityCatalog: null,
51
55
  });
52
56
 
53
57
  function mergeConfig(config = {}) {
@@ -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
  }
@@ -0,0 +1,95 @@
1
+ const assertionHelpers = new Set([
2
+ "assert.tenantIsolation",
3
+ "assert.authorization",
4
+ "assert.actionSucceeds",
5
+ "assert.httpResponse",
6
+ ]);
7
+ const helpers = new Set(["assert.tenantIsolation", "assert.authorization"]);
8
+ const primary = (a) =>
9
+ a.role === undefined ||
10
+ a.role === null ||
11
+ ["primary", "negative"].includes(a.role);
12
+ const control = (a) => ["control", "positive_control"].includes(a.role);
13
+ const decisive = (a) => a.status !== "incomplete" && a.status !== "skipped";
14
+ const matches = (a, s) =>
15
+ a.kind === s.kind &&
16
+ a.target === s.target &&
17
+ a.operation === s.operation &&
18
+ (!s.emittedBy || a.emittedBy === s.emittedBy);
19
+
20
+ export function validateControlBaseline(mutation, traces) {
21
+ if (
22
+ !Array.isArray(mutation.claims) ||
23
+ mutation.claims.length ||
24
+ !Array.isArray(mutation.controls) ||
25
+ !mutation.controls.length ||
26
+ mutation.expectedFailureCode !== "tenant_isolation_control"
27
+ )
28
+ throw new Error(
29
+ `[PROOF_FAIL] control_contract: ${mutation.id} requires claims: [], nonempty controls, and expectedFailureCode: tenant_isolation_control`,
30
+ );
31
+ for (const selector of mutation.controls) {
32
+ if (
33
+ selector.kind !== "tenant_isolation" ||
34
+ selector.operation !== "select" ||
35
+ typeof selector.target !== "string" ||
36
+ !selector.target ||
37
+ !helpers.has(selector.emittedBy)
38
+ )
39
+ throw new Error(
40
+ `[PROOF_FAIL] control_contract: ${mutation.id} has an unsupported control selector`,
41
+ );
42
+ const exists = traces.some(
43
+ (t) =>
44
+ t.specFile === mutation.spec &&
45
+ t.passed === true &&
46
+ !t.mutation &&
47
+ t.steps.some(
48
+ (step) =>
49
+ step.passed === true &&
50
+ (step.assertions ?? []).some(
51
+ (a) =>
52
+ a.passed === true &&
53
+ decisive(a) &&
54
+ control(a) &&
55
+ matches(a, selector),
56
+ ),
57
+ ),
58
+ );
59
+ if (!exists)
60
+ throw new Error(
61
+ `[PROOF_FAIL] control_baseline_missing: ${mutation.id} has no fresh passing package-origin control for ${selector.target}`,
62
+ );
63
+ }
64
+ return mutation.controls;
65
+ }
66
+
67
+ /** Control failures cannot satisfy primary claim detection, even for the same key. */
68
+ export function assertionSelectorsTurnedRed(
69
+ traces,
70
+ selectors,
71
+ role,
72
+ expectedFailureCode,
73
+ ) {
74
+ if (!selectors.length) return false;
75
+ return selectors.every((selector) =>
76
+ traces.some(
77
+ (t) =>
78
+ t.passed === false &&
79
+ t.steps.some(
80
+ (step) =>
81
+ step.passed === false &&
82
+ (!expectedFailureCode ||
83
+ step.error?.startsWith(`[PROOF_FAIL] ${expectedFailureCode}:`)) &&
84
+ (step.assertions ?? []).some(
85
+ (a) =>
86
+ a.passed === false &&
87
+ decisive(a) &&
88
+ (role === "control" ? control(a) : primary(a)) &&
89
+ assertionHelpers.has(a.emittedBy) &&
90
+ matches(a, selector),
91
+ ),
92
+ ),
93
+ ),
94
+ );
95
+ }
@@ -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 = [