proof-harness 0.1.0-next.10 → 0.1.0-next.12

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
@@ -27,9 +27,105 @@ versions rather than guessing how to interpret them.
27
27
 
28
28
  ## Prerelease migration notes
29
29
 
30
- ### 0.1.0-next.10 — explicit primary mutation evidence (release candidate)
30
+ ### 0.1.0-next.12 — independent isolation fixtures and column ACL recovery (candidate)
31
31
 
32
- Prepared for release; not yet published. After publication, pin exactly
32
+ Not yet published. Keep the existing consumer pin until this release is reviewed
33
+ and published; then pin exactly `proof-harness@0.1.0-next.12`.
34
+
35
+ `assert.tenantIsolation` now accepts a caller-owned `isolationFixture` instead of
36
+ workspace seeding. Supply a stable fixture `id`, the matching `table`, an explicit
37
+ `scopeColumn`, `setup()` returning two distinct actors and scope values, and
38
+ `cleanup()` that can handle partial setup. Scope values can be user IDs or
39
+ independent account IDs. `scope: "account"` requires this fixture path. Existing
40
+ workspace fixtures and their reverse-direction behavior remain unchanged.
41
+
42
+ ```ts
43
+ await assert.tenantIsolation({
44
+ table: "notifications",
45
+ scope: "account",
46
+ scopeColumn: "account_id",
47
+ isolationFixture: {
48
+ id: "notifications-two-accounts-v1",
49
+ table: "notifications",
50
+ async setup() {
51
+ // Consumer creates real Auth customers, resolves account IDs, and seeds rows.
52
+ return {
53
+ a: { actor: { email: ownerA.email, password: ownerA.password }, scopeValue: accountA.id },
54
+ b: { actor: { email: ownerB.email, password: ownerB.password }, scopeValue: accountB.id },
55
+ };
56
+ },
57
+ async cleanup() { await cleanupOwnedResources(); },
58
+ },
59
+ });
60
+ ```
61
+
62
+ The SDK creates the service client and authenticated probe clients itself. Both
63
+ sides must contain rows before probing; each owner must read all scoped rows,
64
+ and each outsider must see none. Both directions are mandatory. Scope/criterion
65
+ filters apply identically to ground truth and probes. Setup and cleanup run
66
+ without helper provenance; callers cannot stamp SDK assertions. Cleanup errors
67
+ fail the proof. Fixture IDs appear in probe detail; existing trace source hashes
68
+ bind tracked and nonignored fixture source to evidence. Keep fixtures under the
69
+ consumer's source identity, not an excluded artifact directory. This path is
70
+ database-only: `page` is unsupported. It does not extend account-policy SQL
71
+ classification; existing explicit classifier reviews remain separate.
72
+
73
+ `tablePrivilege` mutations now snapshot the relevant table and column ACL entries
74
+ (including grant options) and compare that complete state after restoration.
75
+ Column UPDATE grants removed by PostgreSQL's table REVOKE are re-established.
76
+ Owner-issued direct grants, including grant options, are supported; delegated
77
+ grant graphs, inherited/PUBLIC effective privileges, and owner-target mutations
78
+ remain blocked before planting. No CASCADE recovery is used. Recovery fails
79
+ closed on an old Boolean snapshot: it cannot reconstruct already-lost column
80
+ grants. Keep that journal and reconcile with the protected baseline rather than
81
+ claiming automatic recovery succeeded.
82
+
83
+ After upgrading: port fixtures to the public API, regenerate baseline evidence,
84
+ restore formal table-isolation coverage, and rerun frozen install, static/unit/
85
+ browser checks, the full proof/mutation/control gate, package parity, production
86
+ build, and smoke. Verify actual profile editing and denied privileged-column
87
+ writes after mutations. Package tests are not a substitute for that consumer run.
88
+ Trace, mission, and health protocol versions remain unchanged; the internal
89
+ ACL recovery snapshot has its own version 1.
90
+
91
+ ### 0.1.0-next.11 — conservative service RPC assessment
92
+
93
+ Published release. Pin exactly `proof-harness@0.1.0-next.11`.
94
+ Regenerate
95
+ capabilities and rerun the complete baseline, coverage, inventory, and mutation
96
+ validation after adoption. Trace, mission, and health versions are unchanged.
97
+
98
+ Capability schema v1 gains additive `serviceRoleRpcCalls` entries with a literal
99
+ `function` and `assessment: "potential_privileged_write"`. Direct const-bound
100
+ service-client `.rpc("name", args)` calls support awaiting/returning the result
101
+ and zero-argument `.single()` / `.maybeSingle()` result chains. Dynamic names,
102
+ escaped builders, unsupported chains, and RLS-client RPCs remain unassessed.
103
+ An RLS client can invoke a SECURITY DEFINER function; its privilege is not inferred
104
+ from the client name. Result shaping does not establish read-only behavior.
105
+
106
+ This is conservative action-boundary assessment, not SQL function-body analysis.
107
+ It does not infer affected tables, effective grants, overloads, nested functions,
108
+ or transactional correctness. Consumer SQL/function privilege proofs and mutation
109
+ catalogs remain necessary; do not interpret this field as complete SQL write coverage.
110
+
111
+ Every service RPC action requires passing primary denial and allowed control
112
+ assertions targeting the exact `module:action`, both with `operation: "invoke"`.
113
+ The requirement applies without workspace inputs and to `_BOT` actions. Existing
114
+ assertion-origin rules remain in force; explicit authorization assertions retain
115
+ the consumer-owned trust boundary. RPC requirements cannot be waived through
116
+ `acceptedActionGaps`. No new runtime helper or caller-to-callee evidence mapping
117
+ is introduced.
118
+
119
+ Evidence for a signed webhook caller does not automatically satisfy a separately
120
+ callable write action. Protect and test the actual action boundary, including
121
+ alternative invocation paths; a staged route, `_BOT` suffix, or a generic 500
122
+ response is not proof of authorization. If that boundary cannot be established,
123
+ coverage remains blocked. Do not hide RPCs from discovery to bypass the gate.
124
+
125
+
126
+ ### 0.1.0-next.10 — explicit primary mutation evidence
127
+
128
+ Published release. Pin exactly
33
129
  `proof-harness@0.1.0-next.10` and rerun a fresh baseline before mutation checks.
34
130
  Trace, mission, and health protocol versions remain unchanged.
35
131
 
@@ -1278,3 +1278,25 @@ specs and protected catalogs remain responsible for these assertions' meaning.
1278
1278
  Control sensitivity requires recognized SDK-helper provenance and cannot count
1279
1279
  as primary detection. Skipped, incomplete, and setup-only failures are not
1280
1280
  mapped decisive primary evidence. No trace protocol change is introduced.
1281
+
1282
+ ## Conservative service RPC assessment (0.1.0-next.11)
1283
+
1284
+ Capability schema v1 adds `serviceRoleRpcCalls: { function: string,
1285
+ assessment: "potential_privileged_write" }[]`. Missing fields in older artifacts
1286
+ are not evidence that a fresh source scan contains no RPCs. Regenerate artifacts
1287
+ when adopting this change. The scanner deliberately over-approximates service
1288
+ RPC privilege without inventing SQL table effects. Coverage requires an exact
1289
+ action-targeted primary denial and allowed control with operation `invoke`,
1290
+ including internal actions and actions without workspace inputs. Caller evidence
1291
+ is not transitively inherited. See COMPATIBILITY.md for supported syntax and
1292
+ remaining unassessable cases.
1293
+
1294
+ ## Independent scope fixtures (0.1.0-next.12 candidate)
1295
+
1296
+ The Playwright entry point exports `IsolationFixture`, `IsolationParticipant`,
1297
+ and `TenantIsolationOptions`. `isolationFixture` is an alternative to the legacy
1298
+ workspace `setup`/`fixture` options. Its setup and cleanup are consumer-owned;
1299
+ clients, ground-truth checks, both owner controls, and both outsider probes stay
1300
+ SDK-owned. Missing either side, malformed actors/scopes, failed probes or cleanup
1301
+ prevent a passing proof. The SDK does not emit provenance on callback assertions.
1302
+ See COMPATIBILITY.md for the public call shape and source-identity requirements.
package/README.md CHANGED
@@ -1,16 +1,45 @@
1
1
  # proof-harness
2
2
 
3
- Prerelease verification and conformance toolkit for proof traces, missions,
4
- fixtures, and Playwright probes.
3
+ Verification tools for making specific claims about agent-generated code—and
4
+ checking that deliberately broken behavior makes those claims fail.
5
5
 
6
- This repository is the standalone source of the package. It requires Node 24
7
- and pnpm 11.9.0:
6
+ Developed alongside a consumer template, this is an opinionated prerelease,
7
+ not a universal agent acceptance system. It can help teams that already have
8
+ executable requirements and want structured evidence, coverage checks, and
9
+ mutation checks around them.
10
+
11
+ ## Try it without the template
12
+
13
+ The [standalone authorization example](https://github.com/TiKa-Nexus/proof-harness/tree/main/examples/authorization)
14
+ uses a tiny JavaScript rule and the public package. It needs Node 24 and pnpm
15
+ 11.9.0; no database, credentials, app server, or browser download.
8
16
 
9
17
  ```sh
10
- pnpm install --frozen-lockfile
11
- pnpm check
18
+ git clone https://github.com/TiKa-Nexus/proof-harness.git
19
+ cd proof-harness/examples/authorization
20
+ pnpm install
21
+ pnpm demo
12
22
  ```
13
23
 
24
+ You will see a passing proof, an intentionally failed proof after the rule is
25
+ broken, and confirmation that the source was restored. The example validates
26
+ recorded evidence; it is an introduction, not the full CLI mutation workflow.
27
+
28
+ ## What it does—and what it establishes
29
+
30
+ The harness records structured proof traces, validates evidence and missions,
31
+ and provides coverage, drift, mutation, and control-sensitivity engines.
32
+ It can establish that a configured claim was observed and that a mapped defect
33
+ was detected. The consuming project supplies the requirements and trust inputs.
34
+
35
+ A passing trace does not establish overall correctness, adequate requirements,
36
+ or security against an agent that can rewrite the acceptance gate. Keep code
37
+ review, conventional tests, and trusted CI alongside it. See the
38
+ [integration guide](https://github.com/TiKa-Nexus/proof-harness/blob/main/docs/INTEGRATING.md)
39
+ for ownership, adoption steps, and current environment constraints, and
40
+ [contributor guide](https://github.com/TiKa-Nexus/proof-harness/blob/main/CONTRIBUTING.md)
41
+ for setup and approachable contributions.
42
+
14
43
  ## Installation
15
44
 
16
45
  Public prereleases are published under the npm `next` tag. Pin an exact
@@ -37,10 +66,11 @@ and consumer pinning requirements.
37
66
  The package has two layers; consumers should know which one they are leaning
38
67
  on:
39
68
 
40
- - **Assumes nothing about your schema** — trace recording and the assertion
69
+ - **Reusable verification mechanics** — trace recording and the assertion
41
70
  vocabulary, mission validation, coverage and its ratchet, drift, mutation
42
71
  testing, and the CLI. These read your generated schema and config, or take
43
- what you give them.
72
+ what you give them. CLI discovery supports specific source and SQL patterns;
73
+ configurable paths do not make every framework compatible.
44
74
  - **Assumes the conventional Supabase/workspace layout** — `seed.*` (a
45
75
  `workspaces` table with a `name` column, a `workspace_members` join table,
46
76
  and a `public.users` mirror maintained by a `handle_new_user` trigger) and
@@ -118,6 +148,15 @@ separate positive-control sensitivity runner. It also distinguishes caller
118
148
  workspace inputs from generated result IDs. See [next.9 setup and migration](COMPATIBILITY.md)
119
149
  before upgrading; consumer invoker and catalog changes are required.
120
150
 
121
- Release candidate `0.1.0-next.10` restores explicit consumer primary assertions
151
+ Release `0.1.0-next.10` restores explicit consumer primary assertions
122
152
  for mutation detection while retaining strict control provenance. See the
123
- [next.10 migration notes](COMPATIBILITY.md) before adopting it after publication.
153
+ [next.10 migration notes](COMPATIBILITY.md) before adopting it.
154
+
155
+ Release `0.1.0-next.11` discovers direct service RPC calls as potentially
156
+ privileged writes and requires action-boundary evidence even without workspace
157
+ inputs or for internal actions. See [required migration steps](COMPATIBILITY.md)
158
+ before adopting the exact version.
159
+
160
+ Release candidate `0.1.0-next.12` adds caller-owned account/user isolation fixtures
161
+ and restores column grants and grant options after table-privilege mutations.
162
+ Keep existing pins until publication; see [migration requirements](COMPATIBILITY.md).
@@ -367,17 +367,23 @@ function buildReport() {
367
367
  for (const capability of [...(capabilities.capabilities ?? [])].sort((a, b) =>
368
368
  `${a.module}:${a.name}`.localeCompare(`${b.module}:${b.name}`),
369
369
  )) {
370
+ const rpcCalls = capability.serviceRoleRpcCalls ?? [];
370
371
  if (
371
- (capability.acceptsWorkspaceId !== true &&
372
+ !rpcCalls.length &&
373
+ ((capability.acceptsWorkspaceId !== true &&
372
374
  !(capability.serviceRoleAuthOperations ?? []).length) ||
373
- capability.internalOnly === true
375
+ capability.internalOnly === true)
374
376
  ) {
375
377
  continue;
376
378
  }
377
379
 
378
380
  const workspaceMutations = capability.serviceRoleMutations ?? [];
379
381
  const authOperations = capability.serviceRoleAuthOperations ?? [];
380
- if (workspaceMutations.length === 0 && authOperations.length === 0)
382
+ if (
383
+ workspaceMutations.length === 0 &&
384
+ authOperations.length === 0 &&
385
+ rpcCalls.length === 0
386
+ )
381
387
  continue;
382
388
 
383
389
  const action = `${capability.module}:${capability.name}`;
@@ -385,6 +391,7 @@ function buildReport() {
385
391
  (assertion) =>
386
392
  assertion.passed === true &&
387
393
  assertion.target === action &&
394
+ (!rpcCalls.length || assertion.operation === "invoke") &&
388
395
  normalizedRole(assertion) === "primary" &&
389
396
  denialKinds.has(assertion.kind) &&
390
397
  verifiedOrigin(assertion, { isTable: false }),
@@ -393,13 +400,16 @@ function buildReport() {
393
400
  (assertion) =>
394
401
  assertion.passed === true &&
395
402
  assertion.target === action &&
403
+ (!rpcCalls.length || assertion.operation === "invoke") &&
396
404
  normalizedRole(assertion) === "control" &&
397
405
  controlKinds.has(assertion.kind),
398
406
  );
399
407
  const denialProven = denialEvidence.length > 0;
400
408
  const controlProven = controlEvidence.length > 0;
401
409
  const proven = denialProven && controlProven;
402
- const acceptance = acceptedActionGaps.get(action);
410
+ const acceptance = rpcCalls.length
411
+ ? undefined
412
+ : acceptedActionGaps.get(action);
403
413
  if (!proven && acceptance) usedActionAcceptances.add(action);
404
414
 
405
415
  // These targets are now resolved by a derived action requirement.
@@ -410,6 +420,7 @@ function buildReport() {
410
420
  tables: [...new Set(workspaceMutations.map((m) => m.table))].sort(),
411
421
  mutations: workspaceMutations,
412
422
  authOperations,
423
+ rpcCalls,
413
424
  denial: denialProven ? "proven" : "gap",
414
425
  control: controlProven ? "proven" : "gap",
415
426
  status: proven ? "proven" : acceptance ? "accepted_gap" : "gap",
@@ -599,7 +610,7 @@ function printReport(report) {
599
610
  );
600
611
  heading(
601
612
  "SERVICE-ROLE ACTION BOUNDARIES",
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.",
613
+ "Derived from workspace-targeted service-role writes, privileged Auth operations, or service RPC calls (including internal actions). Each action needs both an unauthorized/cross-tenant refusal and an allowed-path control; withProof metadata is not consulted.",
603
614
  );
604
615
  console.log("");
605
616
  printTable(
@@ -611,6 +622,9 @@ function printReport(report) {
611
622
  [
612
623
  ...r.tables,
613
624
  ...r.authOperations.map((op) => `auth.admin.${op}`),
625
+ ...r.rpcCalls.map(
626
+ (call) => `rpc.${call.function} (potential write)`,
627
+ ),
614
628
  ].join(","),
615
629
  },
616
630
  { header: "denial", get: (r) => r.denial },
@@ -1,4 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ tableAclSnapshotSql,
4
+ validateTableAcl,
5
+ restoreTableAclSql,
6
+ } from "./proof_table_acl.mjs";
2
7
  import {
3
8
  validateControlBaseline,
4
9
  assertionSelectorsTurnedRed,
@@ -234,13 +239,13 @@ function assertDirectPrivilege(container, subject, functionSubject = false) {
234
239
  `SELECT NOT EXISTS (
235
240
  SELECT 1 FROM ${catalog} c, LATERAL aclexplode(coalesce(c.${acl},acldefault('${defaultKind}',c.${owner}))) a
236
241
  WHERE c.oid = ${object} AND a.privilege_type = ${quoteLiteral(subject.privilege.toUpperCase())}
237
- AND ((a.grantee = ${quoteLiteral(subject.role)}::regrole AND (a.is_grantable OR a.grantor <> c.${owner}))
242
+ AND ((a.grantee = ${quoteLiteral(subject.role)}::regrole AND (${functionSubject ? "a.is_grantable OR " : ""}a.grantor <> c.${owner}))
238
243
  OR (a.grantee <> ${quoteLiteral(subject.role)}::regrole AND (a.grantee = 0 OR pg_has_role(${quoteLiteral(subject.role)},a.grantee,'USAGE'))))
239
244
  ) AND NOT EXISTS (SELECT 1 FROM ${catalog} WHERE oid = ${object} AND ${owner} = ${quoteLiteral(subject.role)}::regrole)`,
240
245
  );
241
246
  if (supported !== "t")
242
247
  throw new Error(
243
- "[PROOF_FAIL] mutation_unassessed: privilege restoration supports owner-issued direct grants without grant options; inherited, PUBLIC, and owner privileges require a dedicated catalog mutation",
248
+ `[PROOF_FAIL] mutation_unassessed: privilege restoration requires owner-issued direct grants${functionSubject ? " without grant options" : ""}; inherited, PUBLIC, and owner privileges require a dedicated catalog mutation`,
244
249
  );
245
250
  }
246
251
 
@@ -314,17 +319,13 @@ const SUBJECTS = {
314
319
  },
315
320
 
316
321
  tablePrivilege: {
317
- preflight: (container, s) => assertDirectPrivilege(container, s),
322
+ preflight: (container, s) => {
323
+ assertDirectPrivilege(container, s);
324
+ validateTableAcl(queryScalar(container, tableAclSnapshotSql(s)));
325
+ },
318
326
  describe: (s) => `${s.privilege} on ${s.table} for ${s.role}`,
319
- snapshot: (container, s) =>
320
- queryScalar(
321
- container,
322
- `SELECT has_table_privilege(${quoteLiteral(s.role)}, ${quoteLiteral(s.table)}, ${quoteLiteral(s.privilege)})`,
323
- ),
324
- restore: (s, snapshot) =>
325
- snapshot === "t"
326
- ? `GRANT ${s.privilege} ON ${s.table} TO ${s.role};`
327
- : `REVOKE ${s.privilege} ON ${s.table} FROM ${s.role};`,
327
+ snapshot: (container, s) => queryScalar(container, tableAclSnapshotSql(s)),
328
+ restore: restoreTableAclSql,
328
329
  },
329
330
 
330
331
  functionPrivilege: {
@@ -0,0 +1,62 @@
1
+ // Canonical relevant ACL state, not effective has_table_privilege booleans.
2
+ const lit = (value) => "'" + String(value).replaceAll("'", "''") + "'";
3
+ const ident = (value) => '"' + String(value).replaceAll('"', '""') + '"';
4
+ export function tableAclSnapshotSql(subject) {
5
+ return `WITH target AS (SELECT oid, relowner, relacl FROM pg_class WHERE oid = ${lit(subject.table)}::regclass), entries AS (
6
+ SELECT NULL::text AS column_name, a.* FROM target t, LATERAL aclexplode(coalesce(t.relacl,acldefault('r',t.relowner))) a
7
+ UNION ALL
8
+ SELECT c.attname::text, a.* FROM target t JOIN pg_attribute c ON c.attrelid=t.oid AND c.attnum>0 AND NOT c.attisdropped,
9
+ LATERAL aclexplode(c.attacl) a
10
+ ) SELECT json_build_object('version',1,'owner',(SELECT pg_get_userbyid(relowner) FROM target),
11
+ 'entries',coalesce((SELECT json_agg(json_build_object('column',column_name,'grantor',pg_get_userbyid(grantor),
12
+ 'grantee',CASE WHEN grantee=0 THEN 'PUBLIC' ELSE pg_get_userbyid(grantee) END,'grantable',is_grantable)
13
+ ORDER BY column_name NULLS FIRST,grantor,grantee,is_grantable)
14
+ FROM entries WHERE privilege_type=${lit(subject.privilege.toUpperCase())}), '[]'::json))::text`;
15
+ }
16
+ export function validateTableAcl(snapshot) {
17
+ const value = JSON.parse(snapshot);
18
+ if (
19
+ value?.version !== 1 ||
20
+ typeof value.owner !== "string" ||
21
+ !Array.isArray(value.entries) ||
22
+ value.entries.some(
23
+ (e) =>
24
+ !(e.column === null || typeof e.column === "string") ||
25
+ typeof e.grantee !== "string" ||
26
+ typeof e.grantable !== "boolean" ||
27
+ e.grantor !== value.owner,
28
+ )
29
+ )
30
+ throw new Error(
31
+ "[PROOF_FAIL] mutation_unassessed: table ACL requires versioned owner-issued grants; delegated grant graphs need a dedicated mutation",
32
+ );
33
+ return value;
34
+ }
35
+ export function restoreTableAclSql(subject, snapshot) {
36
+ const value = validateTableAcl(snapshot);
37
+ const privilege = subject.privilege.toUpperCase();
38
+ if (
39
+ ![
40
+ "SELECT",
41
+ "INSERT",
42
+ "UPDATE",
43
+ "DELETE",
44
+ "TRUNCATE",
45
+ "REFERENCES",
46
+ "TRIGGER",
47
+ "MAINTAIN",
48
+ ].includes(privilege)
49
+ )
50
+ throw new Error("unsupported table privilege");
51
+ const role = subject.role === "PUBLIC" ? "PUBLIC" : ident(subject.role);
52
+ // Revoking a table privilege also removes this role's column grants. Restore
53
+ // those explicitly, preserving grant options. No CASCADE: unexpected dependent
54
+ // grants abort instead of destroying state not represented by the snapshot.
55
+ const sql = [`REVOKE ${privilege} ON ${subject.table} FROM ${role};`];
56
+ for (const entry of value.entries.filter((e) => e.grantee === subject.role)) {
57
+ sql.push(
58
+ `GRANT ${privilege}${entry.column === null ? "" : ` (${ident(entry.column)})`} ON ${subject.table} TO ${role}${entry.grantable ? " WITH GRANT OPTION" : ""};`,
59
+ );
60
+ }
61
+ return sql.join("\n");
62
+ }
@@ -317,6 +317,7 @@ function scanFile(file, constants) {
317
317
  : undefined;
318
318
  }
319
319
  const mutations = new Map();
320
+ const rpcCalls = new Set();
320
321
  visit(source, (n) => {
321
322
  if (ts.isIdentifier(n) && clientKind(n)) {
322
323
  const parent = n.parent;
@@ -324,7 +325,7 @@ function scanFile(file, constants) {
324
325
  const query =
325
326
  ts.isPropertyAccessExpression(parent) &&
326
327
  parent.expression === n &&
327
- parent.name.text === "from" &&
328
+ ["from", "rpc"].includes(parent.name.text) &&
328
329
  ts.isCallExpression(parent.parent) &&
329
330
  parent.parent.expression === parent;
330
331
  const adminOperation =
@@ -360,6 +361,49 @@ function scanFile(file, constants) {
360
361
  }
361
362
  if (!ts.isCallExpression(n) || !ts.isPropertyAccessExpression(n.expression))
362
363
  return;
364
+ if (n.expression.name.text === "rpc") {
365
+ const kind = clientKind(n.expression.expression);
366
+ const target = literal(n.arguments[0]);
367
+ if (kind !== "service")
368
+ problems.push(
369
+ "RPC privilege/effects cannot be resolved: only direct service-client RPC assessment is supported",
370
+ );
371
+ if (!target || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(target))
372
+ problems.push("RPC requires a literal unqualified function name");
373
+ let chain = n;
374
+ while (
375
+ ts.isPropertyAccessExpression(chain.parent) &&
376
+ chain.parent.expression === chain
377
+ ) {
378
+ const member = chain.parent;
379
+ if (
380
+ !["single", "maybeSingle"].includes(member.name.text) ||
381
+ !ts.isCallExpression(member.parent) ||
382
+ member.parent.expression !== member ||
383
+ member.parent.arguments.length !== 0
384
+ ) {
385
+ problems.push("RPC result chain is unsupported");
386
+ break;
387
+ }
388
+ chain = member.parent;
389
+ }
390
+ let parent = chain.parent;
391
+ let awaited = false;
392
+ while (
393
+ ts.isAwaitExpression(parent) ||
394
+ ts.isParenthesizedExpression(parent)
395
+ ) {
396
+ if (ts.isAwaitExpression(parent)) awaited = true;
397
+ parent = parent.parent;
398
+ }
399
+ if (
400
+ !awaited &&
401
+ !ts.isReturnStatement(parent) &&
402
+ !(ts.isArrowFunction(parent) && parent.body === chain)
403
+ )
404
+ problems.push("RPC query builder escapes its recognized chain");
405
+ if (kind === "service" && target) rpcCalls.add(target);
406
+ }
363
407
  if (
364
408
  n.expression.name.text === "from" &&
365
409
  ts.isIdentifier(n.expression.expression) &&
@@ -507,6 +551,10 @@ function scanFile(file, constants) {
507
551
  `${a.table}:${a.operation}`.localeCompare(`${b.table}:${b.operation}`),
508
552
  ),
509
553
  serviceRoleAuthOperations: [...authOperations].sort(),
554
+ serviceRoleRpcCalls: [...rpcCalls].sort().map((name) => ({
555
+ function: name,
556
+ assessment: "potential_privileged_write",
557
+ })),
510
558
  middleware: {
511
559
  auth: /\bwithAuth\s*\(/.test(body),
512
560
  tenantIsolation: /\bwithTenantIsolation\s*\(/.test(body),
package/dist/node.d.ts CHANGED
@@ -26,6 +26,11 @@ interface CapabilitiesArtifact {
26
26
  }>;
27
27
  /** Direct supported privileged Auth API calls, separate from SQL writes. */
28
28
  serviceRoleAuthOperations?: readonly "deleteUser"[];
29
+ /** Conservative RPC assessment; does not infer SQL tables, grants or read-only behavior. */
30
+ serviceRoleRpcCalls?: ReadonlyArray<{
31
+ function: string;
32
+ assessment: "potential_privileged_write";
33
+ }>;
29
34
  /** Relevant guards observed in the action pipeline. */
30
35
  middleware?: {
31
36
  auth: boolean;