proof-harness 0.1.0-next.1

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 (53) hide show
  1. package/COMPATIBILITY.md +26 -0
  2. package/LICENSE +201 -0
  3. package/PROOF_SDK_CONTRACT.md +1255 -0
  4. package/README.md +60 -0
  5. package/cli/config.mjs +116 -0
  6. package/cli/engines/generate_proof_action_registry.mjs +271 -0
  7. package/cli/engines/parse_proof_schema.mjs +457 -0
  8. package/cli/engines/proof_coverage.mjs +871 -0
  9. package/cli/engines/proof_drift.mjs +980 -0
  10. package/cli/engines/proof_module_check.mjs +498 -0
  11. package/cli/engines/proof_mutation_check.mjs +1084 -0
  12. package/cli/engines/proof_mutation_inventory.mjs +508 -0
  13. package/cli/engines/proof_source_mutation.mjs +35 -0
  14. package/cli/engines/proof_verify.mjs +620 -0
  15. package/cli/engines/scan_module_meta.mjs +352 -0
  16. package/cli/engines/scan_proof_capabilities.mjs +359 -0
  17. package/cli/proof-harness.mjs +107 -0
  18. package/dist/action-types-C3OmHRyH.d.ts +26 -0
  19. package/dist/fixture-B-dmnG5G.d.ts +105 -0
  20. package/dist/mission-types-Dr5rBHYO.d.ts +299 -0
  21. package/dist/node.d.ts +79 -0
  22. package/dist/node.js +553 -0
  23. package/dist/node.js.map +1 -0
  24. package/dist/playwright.d.ts +433 -0
  25. package/dist/playwright.js +1824 -0
  26. package/dist/playwright.js.map +1 -0
  27. package/dist/portable-vocabulary.d.ts +86 -0
  28. package/dist/portable-vocabulary.js +43 -0
  29. package/dist/portable-vocabulary.js.map +1 -0
  30. package/dist/server.d.ts +55 -0
  31. package/dist/server.js +376 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/shared.d.ts +169 -0
  34. package/dist/shared.js +121 -0
  35. package/dist/shared.js.map +1 -0
  36. package/dist/trace-types-DkIzq-l9.d.ts +333 -0
  37. package/package.json +109 -0
  38. package/proof-consumer-fixtures/README.md +60 -0
  39. package/proof-consumer-fixtures/aggregates/M-CONFORMANCE-FAIL.json +112 -0
  40. package/proof-consumer-fixtures/aggregates/M-CONFORMANCE-PASS.json +101 -0
  41. package/proof-consumer-fixtures/baseline/failing/widgets-tenant-isolation.json +46 -0
  42. package/proof-consumer-fixtures/baseline/passing/widgets-tenant-isolation.json +45 -0
  43. package/proof-consumer-fixtures/corpus.json +102 -0
  44. package/proof-consumer-fixtures/duplicates/first/widgets-tenant-isolation.json +12 -0
  45. package/proof-consumer-fixtures/duplicates/second/widgets-tenant-isolation.json +12 -0
  46. package/proof-consumer-fixtures/inputs/capabilities.json +4 -0
  47. package/proof-consumer-fixtures/inputs/mission.json +20 -0
  48. package/proof-consumer-fixtures/inputs/schema.json +4 -0
  49. package/proof-consumer-fixtures/malformed/invalid-trace.json.invalid +4 -0
  50. package/proof-consumer-fixtures/malformed/wrong-manifest-version.json +11 -0
  51. package/proof-consumer-fixtures/malformed/wrong-trace-shape.json +8 -0
  52. package/proof-consumer-fixtures/mutations/MUT-WIDGETS-LEAK/mutation.json +17 -0
  53. package/proof-consumer-fixtures/mutations/MUT-WIDGETS-LEAK/widgets-tenant-isolation.json +50 -0
@@ -0,0 +1,1824 @@
1
+ // src/playwright/actAsUser.ts
2
+ import { createClient } from "@supabase/supabase-js";
3
+
4
+ // src/server/transient.ts
5
+ var TRANSIENT_MESSAGES = [
6
+ /invalid response was received from the upstream server/i,
7
+ /upstream connect error/i,
8
+ /(service|server) (is )?temporarily unavailable/i,
9
+ /bad gateway/i,
10
+ /gateway time-?out/i,
11
+ /fetch failed/i,
12
+ /network( request)? failed/i,
13
+ /socket hang up/i,
14
+ /ECONNRESET|ECONNREFUSED|EPIPE|ETIMEDOUT|EAI_AGAIN|UND_ERR/
15
+ ];
16
+ function messageChain(error, depth = 0) {
17
+ if (!error || depth > 3) return "";
18
+ if (typeof error === "string") return error;
19
+ const record = error;
20
+ const own = typeof record.message === "string" ? record.message : "";
21
+ return `${own} ${messageChain(record.cause, depth + 1)}`;
22
+ }
23
+ var GATEWAY_STATUSES = /* @__PURE__ */ new Set([502, 503, 504]);
24
+ function isTransient(error, status) {
25
+ if (!error) return false;
26
+ const httpStatus = status ?? error.status ?? null;
27
+ if (typeof httpStatus === "number") {
28
+ if (GATEWAY_STATUSES.has(httpStatus)) return true;
29
+ if (httpStatus >= 400) return false;
30
+ }
31
+ const text = messageChain(error);
32
+ return TRANSIENT_MESSAGES.some((pattern) => pattern.test(text));
33
+ }
34
+ var TRANSIENT_MAX_ATTEMPTS = 4;
35
+ var BASE_DELAY_MS = 150;
36
+ function backoffMs(attempt) {
37
+ return BASE_DELAY_MS * 2 ** (attempt - 1);
38
+ }
39
+ function announce(label, attempt, maxAttempts, reason, delayMs) {
40
+ console.log(
41
+ `[proof:retry] ${label} failed transiently (attempt ${attempt}/${maxAttempts}): ${reason} \u2014 retrying in ${delayMs}ms`
42
+ );
43
+ }
44
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
45
+ function createRetryingFetch(label, baseFetch = fetch, { maxAttempts = TRANSIENT_MAX_ATTEMPTS } = {}) {
46
+ return async (input, init) => {
47
+ const replayable = init?.body == null || typeof init.body === "string" || init.body instanceof URLSearchParams;
48
+ for (let attempt = 1; ; attempt++) {
49
+ const lastAttempt = attempt >= maxAttempts || !replayable;
50
+ try {
51
+ const response = await baseFetch(input, init);
52
+ if (lastAttempt || !GATEWAY_STATUSES.has(response.status)) {
53
+ return response;
54
+ }
55
+ const delayMs = backoffMs(attempt);
56
+ announce(
57
+ label,
58
+ attempt,
59
+ maxAttempts,
60
+ `HTTP ${response.status}`,
61
+ delayMs
62
+ );
63
+ await sleep(delayMs);
64
+ } catch (error) {
65
+ if (lastAttempt || !isTransient(error))
66
+ throw error;
67
+ const delayMs = backoffMs(attempt);
68
+ announce(
69
+ label,
70
+ attempt,
71
+ maxAttempts,
72
+ messageChain(error).trim() || "transport failure",
73
+ delayMs
74
+ );
75
+ await sleep(delayMs);
76
+ }
77
+ }
78
+ };
79
+ }
80
+ async function retryTransient(label, run, { maxAttempts = TRANSIENT_MAX_ATTEMPTS } = {}) {
81
+ let result = await run();
82
+ for (let attempt = 1; attempt < maxAttempts; attempt++) {
83
+ if (!isTransient(result.error, result.status)) return result;
84
+ const delayMs = backoffMs(attempt);
85
+ announce(
86
+ label,
87
+ attempt,
88
+ maxAttempts,
89
+ result.error?.message ?? "unknown error",
90
+ delayMs
91
+ );
92
+ await sleep(delayMs);
93
+ result = await run();
94
+ }
95
+ return result;
96
+ }
97
+
98
+ // src/server/auth-rate-limit.ts
99
+ var AUTH_RATE_LIMIT_MAX_ATTEMPTS = 3;
100
+ var AUTH_RATE_LIMIT_MAX_DELAY_MS = 5e3;
101
+ var AUTH_RATE_LIMIT_MAX_TOTAL_WAIT_MS = 1e4;
102
+ var AUTH_RATE_LIMIT_FALLBACK_DELAY_MS = 1e3;
103
+ function isAuthRateLimited(error, status, body) {
104
+ const httpStatus = status ?? error?.status ?? null;
105
+ if (httpStatus === 429) return true;
106
+ if (typeof httpStatus === "number" && httpStatus >= 400) return false;
107
+ const text = `${error?.message ?? ""} ${body ?? ""}`;
108
+ return /\[PROOF_FAIL\]\s*(auth_)?rate_limited:/i.test(text) || /\b(rate limit|too many (requests|attempts|sign-?ins?))\b/i.test(text);
109
+ }
110
+ function parseRetryAfterMs(value, now = Date.now()) {
111
+ if (typeof value === "number" && Number.isFinite(value)) {
112
+ return Math.max(0, value * 1e3);
113
+ }
114
+ if (typeof value !== "string" || !value.trim()) return null;
115
+ const seconds = Number(value);
116
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
117
+ const at = Date.parse(value);
118
+ return Number.isFinite(at) ? Math.max(0, at - now) : null;
119
+ }
120
+ function clampAuthRateLimitDelay(retryAfterMs, remainingBudgetMs = AUTH_RATE_LIMIT_MAX_TOTAL_WAIT_MS) {
121
+ const requested = retryAfterMs ?? AUTH_RATE_LIMIT_FALLBACK_DELAY_MS;
122
+ return Math.max(
123
+ 0,
124
+ Math.min(requested, AUTH_RATE_LIMIT_MAX_DELAY_MS, remainingBudgetMs)
125
+ );
126
+ }
127
+ async function retryAuthRateLimit(label, run, classify, {
128
+ maxAttempts = AUTH_RATE_LIMIT_MAX_ATTEMPTS,
129
+ maxTotalWaitMs = AUTH_RATE_LIMIT_MAX_TOTAL_WAIT_MS,
130
+ sleep: sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
131
+ } = {}) {
132
+ let result = await run();
133
+ let waitedMs = 0;
134
+ for (let attempt = 1; attempt < maxAttempts; attempt += 1) {
135
+ const signal = classify(result);
136
+ if (!signal.limited) return result;
137
+ const remaining = maxTotalWaitMs - waitedMs;
138
+ if (remaining <= 0) return result;
139
+ const delayMs = clampAuthRateLimitDelay(
140
+ parseRetryAfterMs(signal.retryAfter),
141
+ remaining
142
+ );
143
+ if (delayMs <= 0) return result;
144
+ console.log(
145
+ `[proof:retry] ${label} auth rate limited (attempt ${attempt}/${maxAttempts}) \u2014 retrying in ${delayMs}ms`
146
+ );
147
+ await sleep2(delayMs);
148
+ waitedMs += delayMs;
149
+ result = await run();
150
+ }
151
+ return result;
152
+ }
153
+
154
+ // src/playwright/actAsUser.ts
155
+ function isActionResult(body) {
156
+ if (typeof body !== "object" || body === null) return false;
157
+ const b = body;
158
+ if (b.success === true) return "data" in b;
159
+ if (b.success === false) return typeof b.error === "string";
160
+ return false;
161
+ }
162
+ function getProofSecret() {
163
+ const secret = process.env.API_SECRET_KEY;
164
+ if (!secret) {
165
+ throw new Error(
166
+ "[PROOF_FAIL] proof_secret_missing: API_SECRET_KEY is not set in the test runner env\n file: src/playwright/actAsUser.ts\n suggestion: Run setup-local-env.sh or ensure .env.local is loaded. Playwright tests need API_SECRET_KEY to sign proof requests."
167
+ );
168
+ }
169
+ return secret;
170
+ }
171
+ async function callLoginRoute(page, payload) {
172
+ const { response } = await retryAuthRateLimit(
173
+ "proof login route",
174
+ () => retryTransient("proof login route", async () => {
175
+ const res = await page.request.post("/api/proof/login-as-seed-user", {
176
+ headers: {
177
+ "x-proof-secret": getProofSecret(),
178
+ "Content-Type": "application/json"
179
+ },
180
+ data: payload
181
+ });
182
+ return {
183
+ response: res,
184
+ status: res.status(),
185
+ error: res.ok() ? null : { message: res.statusText(), status: res.status() }
186
+ };
187
+ }),
188
+ (result) => ({
189
+ limited: result.status === 429,
190
+ retryAfter: typeof result.response.headers === "function" ? result.response.headers()["retry-after"] : void 0
191
+ })
192
+ );
193
+ if (!response.ok()) {
194
+ let body = {};
195
+ try {
196
+ body = await response.json();
197
+ } catch {
198
+ }
199
+ const label = "email" in payload ? payload.email : `role=${payload.role}`;
200
+ const category = isAuthRateLimited(
201
+ { message: body.error, status: response.status() },
202
+ response.status(),
203
+ body.error
204
+ ) ? "auth_rate_limited" : "auth_signin";
205
+ throw new Error(
206
+ `[PROOF_FAIL] ${category}: login failed for ${label} (status ${response.status()})
207
+ file: src/playwright/actAsUser.ts
208
+ suggestion: ${body.suggestion ?? body.error ?? "Check that the dev server is running and seed users exist."}`
209
+ );
210
+ }
211
+ const json = await response.json();
212
+ if (!json.ok) {
213
+ throw new Error(
214
+ "[PROOF_FAIL] auth_signin: login route returned ok:false\n file: src/playwright/actAsUser.ts\n suggestion: Inspect the response body of /api/proof/login-as-seed-user."
215
+ );
216
+ }
217
+ if (page.url() !== "about:blank") {
218
+ await page.reload();
219
+ }
220
+ }
221
+ var actAsUser = {
222
+ /**
223
+ * Log in as one of the seeded development users (`admin` or `member`).
224
+ * The consumer application's proof login route resolves the role to its
225
+ * configured server-only seed credentials.
226
+ */
227
+ async login(page, role) {
228
+ await callLoginRoute(page, { role });
229
+ },
230
+ /**
231
+ * Log in as an arbitrary user — typically one created by `seed.user()`
232
+ * inside a `.proof.ts` spec. Used by `assert.tenantIsolation` and any
233
+ * spec that creates fresh users per run.
234
+ */
235
+ async loginAs(page, email, password) {
236
+ await callLoginRoute(page, { email, password });
237
+ },
238
+ /**
239
+ * Invoke a server action end-to-end while authenticated as the given user.
240
+ *
241
+ * Unlike `actAsUser.supabaseClient` — which goes directly to the DB and
242
+ * therefore only proves RLS — this helper goes through the full Next.js
243
+ * request lifecycle: route handler → middleware pipeline → `withAuth`,
244
+ * `withTenantIsolation`, the action's own logic, and any Zod validation.
245
+ * It is the primitive you reach for when proving action-layer invariants
246
+ * (RBAC rejection, validation errors, business-logic branching) that RLS
247
+ * alone cannot cover.
248
+ *
249
+ * The action must be listed in
250
+ * the application's generated Proof action registry. Unregistered actions
251
+ * return a 404 with a structured [PROOF_FAIL] message.
252
+ *
253
+ * Authentication:
254
+ * - `as: "admin" | "member"` — uses the seeded dev users
255
+ * - `as: { email, password }` — uses arbitrary credentials (typically a
256
+ * user created by `seed.user()` earlier in the proof)
257
+ *
258
+ * A registered action returns an `ActionResult<TData>`. Harness failures
259
+ * (missing registry entry, malformed request, action escaping the pipeline)
260
+ * throw instead: absence or breakage of the thing under test must never
261
+ * satisfy a refusal proof.
262
+ *
263
+ * @example
264
+ * const result = await actAsUser.invokeAction<{ removed: boolean }>(
265
+ * page,
266
+ * {
267
+ * as: "member",
268
+ * module: "workspace",
269
+ * action: "removeMember",
270
+ * inputParams: { formData: { workspaceId, memberId } },
271
+ * },
272
+ * );
273
+ * expect(result.success, "[PROOF_FAIL] authorization: ...").toBe(false);
274
+ */
275
+ async invokeAction(page, opts) {
276
+ if (typeof opts.as === "string") {
277
+ await actAsUser.login(page, opts.as);
278
+ } else {
279
+ await actAsUser.loginAs(page, opts.as.email, opts.as.password);
280
+ }
281
+ const response = await page.request.post("/api/proof/invoke-action", {
282
+ headers: {
283
+ "x-proof-secret": getProofSecret(),
284
+ "Content-Type": "application/json"
285
+ },
286
+ data: {
287
+ module: opts.module,
288
+ action: opts.action,
289
+ inputParams: opts.inputParams
290
+ }
291
+ });
292
+ let body;
293
+ try {
294
+ body = await response.json();
295
+ } catch {
296
+ throw new Error(
297
+ `[PROOF_FAIL] action_invoke: response from /api/proof/invoke-action is not valid JSON (status ${response.status()})
298
+ file: src/playwright/actAsUser.ts
299
+ suggestion: The dev server likely returned an error page. Check server logs and confirm the route file at app/api/proof/invoke-action/route.ts is registered.`
300
+ );
301
+ }
302
+ if (!response.ok()) {
303
+ const harnessError = body;
304
+ throw new Error(
305
+ `${typeof harnessError.error === "string" ? harnessError.error : `[PROOF_FAIL] action_invoke: harness request failed (status ${response.status()})`}
306
+ file: src/playwright/actAsUser.ts
307
+ suggestion: ${harnessError.suggestion ?? "Inspect /api/proof/invoke-action and confirm the action is registered and pipeline-wrapped."}`
308
+ );
309
+ }
310
+ if (!isActionResult(body)) {
311
+ throw new Error(
312
+ `[PROOF_FAIL] action_invoke: response body is not a valid ActionResult shape (status ${response.status()})
313
+ file: src/playwright/actAsUser.ts
314
+ body: ${JSON.stringify(body)}
315
+ suggestion: The invoke route must always return { success, data } or { success: false, error }. Confirm PROOF_ACTION_REGISTRY contains the action and that it uses createAction().`
316
+ );
317
+ }
318
+ return body;
319
+ },
320
+ /**
321
+ * Return a Supabase client authenticated as the given user via
322
+ * `signInWithPassword` against a *fresh* client (no cookies, no session
323
+ * persistence). Queries made through the returned client run with that
324
+ * user's RLS privileges.
325
+ *
326
+ * Use for server-side proofs where the browser is irrelevant and you want
327
+ * direct evidence of whether the DB allows or denies a specific operation
328
+ * for a specific user. This is the primitive behind the primary check in
329
+ * `assert.tenantIsolation` and is intentionally exposed for specs that
330
+ * need finer-grained RLS probes.
331
+ */
332
+ async supabaseClient(opts) {
333
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
334
+ const publishableKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
335
+ if (!url || !publishableKey) {
336
+ throw new Error(
337
+ "[PROOF_FAIL] config_missing: expected NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY to be set, found one or both missing\n file: src/playwright/actAsUser.ts\n suggestion: Run setup-local-env.sh or load .env.local before running proof specs."
338
+ );
339
+ }
340
+ const client = createClient(url, publishableKey, {
341
+ auth: {
342
+ persistSession: false,
343
+ autoRefreshToken: false,
344
+ detectSessionInUrl: false
345
+ },
346
+ // Gateway 502s are retried at the transport, so every probe written
347
+ // against this client — including ad-hoc ones in specs — is covered.
348
+ global: { fetch: createRetryingFetch(`request as ${opts.email}`) }
349
+ });
350
+ const { error } = await retryAuthRateLimit(
351
+ `signInWithPassword as ${opts.email}`,
352
+ () => client.auth.signInWithPassword({
353
+ email: opts.email,
354
+ password: opts.password
355
+ }),
356
+ (result) => ({
357
+ limited: isAuthRateLimited(result.error, result.error?.status)
358
+ })
359
+ );
360
+ if (error) {
361
+ const category = isAuthRateLimited(error, error.status) ? "auth_rate_limited" : "auth_signin";
362
+ throw new Error(
363
+ `[PROOF_FAIL] ${category}: expected successful sign-in for ${opts.email}, found signInWithPassword error: ${error.message}
364
+ file: src/playwright/actAsUser.ts
365
+ suggestion: ${category === "auth_rate_limited" ? "The auth budget is exhausted, not the credentials. Wait for the window to reset or reduce parallel proof workers." : "Confirm the user was created by seed.user() and that password/email match."}`
366
+ );
367
+ }
368
+ return client;
369
+ }
370
+ };
371
+
372
+ // src/playwright/trace.ts
373
+ import * as crypto from "crypto";
374
+ import * as fs from "fs";
375
+ import * as path from "path";
376
+ import { AsyncLocalStorage } from "async_hooks";
377
+ import { test } from "@playwright/test";
378
+
379
+ // src/server/code-provenance.ts
380
+ import { spawnSync } from "child_process";
381
+ var PROOF_COMMIT_ENV = "PROOF_COMMIT";
382
+ var PROOF_DIRTY_ENV = "PROOF_DIRTY";
383
+ var readGit = (args) => {
384
+ try {
385
+ const result = spawnSync("git", args, { encoding: "utf8" });
386
+ if (result.status !== 0) return void 0;
387
+ return result.stdout;
388
+ } catch {
389
+ return void 0;
390
+ }
391
+ };
392
+ function resolveCodeProvenance(env = process.env, git = readGit) {
393
+ const fromRunner = env[PROOF_COMMIT_ENV]?.trim();
394
+ if (fromRunner) {
395
+ const dirty = env[PROOF_DIRTY_ENV]?.trim();
396
+ return {
397
+ commit: fromRunner,
398
+ // Only "true"/"false" carry meaning; anything else means the runner could
399
+ // not tell, which is different from "clean".
400
+ ...dirty === "true" || dirty === "false" ? { dirty: dirty === "true" } : {}
401
+ };
402
+ }
403
+ const commit = git(["rev-parse", "HEAD"])?.trim();
404
+ if (!commit) return {};
405
+ const status = git(["status", "--porcelain"]);
406
+ return {
407
+ commit,
408
+ ...status === void 0 ? {} : { dirty: status.trim().length > 0 }
409
+ };
410
+ }
411
+ var cached;
412
+ function codeProvenance() {
413
+ cached ??= resolveCodeProvenance();
414
+ return cached;
415
+ }
416
+
417
+ // src/shared/trace-types.ts
418
+ function normalizeAssertionStatus(status, passed) {
419
+ if (status === "passed" || status === "failed" || status === "incomplete" || status === "skipped") {
420
+ return status;
421
+ }
422
+ return passed ? "passed" : "failed";
423
+ }
424
+ var TRACE_ARTIFACT_SCHEMA_VERSION = 2;
425
+
426
+ // src/playwright/trace.ts
427
+ var TRACE_DIR_RELATIVE = [".proof", "traces"];
428
+ var currentStepAssertions = new AsyncLocalStorage();
429
+ var currentHelperProvenance = new AsyncLocalStorage();
430
+ async function withAssertionProvenance(helper, fn) {
431
+ return currentHelperProvenance.run(helper, fn);
432
+ }
433
+ async function withoutAssertionProvenance(fn) {
434
+ return currentHelperProvenance.exit(fn);
435
+ }
436
+ function recordAssertion(assertion) {
437
+ const bucket = currentStepAssertions.getStore();
438
+ if (!bucket) return;
439
+ const { emittedBy: _claimed, ...rest } = assertion;
440
+ const emittedBy = currentHelperProvenance.getStore();
441
+ bucket.push({
442
+ ...rest,
443
+ ...emittedBy ? { emittedBy } : {},
444
+ status: normalizeAssertionStatus(assertion.status, assertion.passed)
445
+ });
446
+ }
447
+ var TraceRecorderImpl = class {
448
+ constructor(proofId) {
449
+ this.proofId = proofId;
450
+ }
451
+ proofId;
452
+ steps = [];
453
+ getSteps() {
454
+ return this.steps;
455
+ }
456
+ async step(opts, fn) {
457
+ const startedAt = performance.now();
458
+ const assertions = [];
459
+ try {
460
+ const result = await currentStepAssertions.run(
461
+ assertions,
462
+ async () => await fn()
463
+ );
464
+ const durationMs = performance.now() - startedAt;
465
+ const observation = result && typeof result === "object" && "observation" in result ? String(result.observation ?? "") : "";
466
+ this.steps.push({
467
+ intent: opts.intent,
468
+ kind: opts.kind,
469
+ target: opts.target,
470
+ actor: opts.actor,
471
+ workspaceId: opts.workspaceId,
472
+ observation,
473
+ passed: true,
474
+ durationMs: Math.round(durationMs),
475
+ ...assertions.length > 0 ? { assertions: [...assertions] } : {}
476
+ });
477
+ return result;
478
+ } catch (error) {
479
+ const durationMs = performance.now() - startedAt;
480
+ const message = error instanceof Error ? error.message : String(error);
481
+ this.steps.push({
482
+ intent: opts.intent,
483
+ kind: opts.kind,
484
+ target: opts.target,
485
+ actor: opts.actor,
486
+ workspaceId: opts.workspaceId,
487
+ observation: "",
488
+ passed: false,
489
+ durationMs: Math.round(durationMs),
490
+ error: message,
491
+ ...assertions.length > 0 ? { assertions: [...assertions] } : {}
492
+ });
493
+ throw error;
494
+ }
495
+ }
496
+ };
497
+ var specHashCache = /* @__PURE__ */ new Map();
498
+ function specProvenance() {
499
+ let absolutePath;
500
+ try {
501
+ absolutePath = test.info().file;
502
+ } catch {
503
+ return {};
504
+ }
505
+ if (!absolutePath) return {};
506
+ const specFile = path.relative(process.cwd(), absolutePath);
507
+ const cached2 = specHashCache.get(absolutePath);
508
+ if (cached2) return { specFile, specHash: cached2 };
509
+ try {
510
+ const contents = fs.readFileSync(absolutePath);
511
+ const specHash = crypto.createHash("sha256").update(contents).digest("hex").slice(0, 12);
512
+ specHashCache.set(absolutePath, specHash);
513
+ return { specFile, specHash };
514
+ } catch {
515
+ return { specFile };
516
+ }
517
+ }
518
+ function writeArtifact(proofId, artifact) {
519
+ const traceDir = path.join(process.cwd(), ...TRACE_DIR_RELATIVE);
520
+ fs.mkdirSync(traceDir, { recursive: true });
521
+ const target = path.join(traceDir, `${proofId}.json`);
522
+ fs.writeFileSync(target, JSON.stringify(artifact, null, 2), "utf8");
523
+ }
524
+ function normalizeOptions(arg) {
525
+ return typeof arg === "string" ? { proofId: arg } : arg;
526
+ }
527
+ function mutationProvenance() {
528
+ const id = process.env.PROOF_MUTATION_ID?.trim();
529
+ return id ? { id, planted: true } : void 0;
530
+ }
531
+ var trace = {
532
+ /**
533
+ * Start a proof. The callback receives a recorder whose `.step()` method
534
+ * wraps individual observations. When the callback resolves (or throws),
535
+ * a JSON artifact is written to `.proof/traces/<proofId>.json`.
536
+ *
537
+ * The proof rethrows any thrown error so the Playwright test still fails —
538
+ * the trace is written first so you can inspect the failure.
539
+ *
540
+ * @example
541
+ * // Common v0.5 case: no mission attached
542
+ * await trace.proof("auth-login-admin", async (t) => { ... });
543
+ *
544
+ * @example
545
+ * // v1 case: proof belongs to an external mission
546
+ * await trace.proof(
547
+ * { proofId: "auth-login-admin", missionId: "M-042" },
548
+ * async (t) => { ... },
549
+ * );
550
+ */
551
+ async proof(arg, fn) {
552
+ const { proofId, missionId } = normalizeOptions(arg);
553
+ const startedAt = performance.now();
554
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
555
+ const recorder = new TraceRecorderImpl(proofId);
556
+ let proofError;
557
+ try {
558
+ await fn(recorder);
559
+ } catch (error) {
560
+ proofError = error;
561
+ }
562
+ const durationMs = Math.round(performance.now() - startedAt);
563
+ const steps = recorder.getSteps();
564
+ const passed = !proofError && steps.every((s) => s.passed);
565
+ const mutation = mutationProvenance();
566
+ const artifact = {
567
+ schemaVersion: TRACE_ARTIFACT_SCHEMA_VERSION,
568
+ proofId,
569
+ ...missionId ? { missionId } : {},
570
+ ...specProvenance(),
571
+ ...codeProvenance(),
572
+ ...mutation ? { mutation } : {},
573
+ timestamp,
574
+ durationMs,
575
+ passed,
576
+ steps: [...steps]
577
+ };
578
+ writeArtifact(proofId, artifact);
579
+ if (proofError) throw proofError;
580
+ }
581
+ };
582
+
583
+ // src/server/service-client.ts
584
+ import { createClient as createClient2 } from "@supabase/supabase-js";
585
+ function requireProofEnv(name) {
586
+ const value = process.env[name];
587
+ if (!value) {
588
+ throw new Error(
589
+ `[PROOF_FAIL] missing_env: expected ${name}, found empty
590
+ file: src/server/service-client.ts
591
+ suggestion: Ensure CI exports the Supabase env vars before running pnpm proof:verify.`
592
+ );
593
+ }
594
+ return value;
595
+ }
596
+ function createProofServiceClient() {
597
+ return createClient2(
598
+ requireProofEnv("NEXT_PUBLIC_SUPABASE_URL"),
599
+ requireProofEnv("SUPABASE_SECRET_KEY"),
600
+ {
601
+ auth: {
602
+ persistSession: false,
603
+ autoRefreshToken: false,
604
+ detectSessionInUrl: false
605
+ },
606
+ // Local Supabase sits behind Kong, which intermittently 502s under the
607
+ // load of a proof run. Installing the retry here covers every query made
608
+ // through this client, including ad-hoc ones inside proof specs.
609
+ global: { fetch: createRetryingFetch("service-role request") }
610
+ }
611
+ );
612
+ }
613
+
614
+ // src/server/seed.ts
615
+ function failProof(category, expected, found, suggestion = `Check that Supabase is running (pnpm exec supabase status) and that SUPABASE_SECRET_KEY is set in .env.local.`) {
616
+ throw new Error(
617
+ `[PROOF_FAIL] ${category}: expected ${expected}, found ${found}
618
+ file: src/server/seed.ts
619
+ suggestion: ${suggestion}`
620
+ );
621
+ }
622
+ function failSeed(category, expected, found, error, status) {
623
+ if (isTransient(error, status)) {
624
+ failProof(
625
+ `${category}_transient`,
626
+ expected,
627
+ `${found} \u2014 still failing after ${TRANSIENT_MAX_ATTEMPTS} attempts`,
628
+ `This is a gateway/transport failure, not a schema problem: the request never reached Postgres. Check that the local Supabase stack is healthy (pnpm exec supabase status) and is not restarting mid-run.`
629
+ );
630
+ }
631
+ failProof(category, expected, found);
632
+ }
633
+ async function waitForPublicUser(sb, userId, email) {
634
+ const maxAttempts = 10;
635
+ const delayMs = 50;
636
+ for (let i = 0; i < maxAttempts; i++) {
637
+ const { data } = await sb.from("users").select("id").eq("id", userId).maybeSingle();
638
+ if (data) return;
639
+ await new Promise((r) => setTimeout(r, delayMs));
640
+ }
641
+ failProof(
642
+ "seed_user_trigger",
643
+ `public.users row for ${email} (id ${userId}) within ${maxAttempts * delayMs}ms`,
644
+ "row still missing \u2014 the handle_new_user trigger did not materialize the public.users mirror row"
645
+ );
646
+ }
647
+ var seed = {
648
+ /**
649
+ * Create a workspace with no owner. Use `seed.user({ workspace, role: "owner" })`
650
+ * afterwards to attach an owner. Returns the inserted row.
651
+ *
652
+ * Bypasses RLS via the service-role client, so it works even when no user
653
+ * is authenticated.
654
+ */
655
+ async workspace(name) {
656
+ const sb = createProofServiceClient();
657
+ const { data, error, status } = await sb.from("workspaces").insert({
658
+ name,
659
+ type: "team"
660
+ }).select("id, name, owner_id").single();
661
+ if (error || !data) {
662
+ failSeed(
663
+ "seed_workspace",
664
+ "a workspace row to be created",
665
+ `insert error: ${error?.message ?? "no row returned"}`,
666
+ error,
667
+ status
668
+ );
669
+ }
670
+ return {
671
+ id: data.id,
672
+ name: data.name,
673
+ ownerId: data.owner_id
674
+ };
675
+ },
676
+ /**
677
+ * Create an auth user with the given credentials and attach them to the
678
+ * provided workspace with the given role. Relies on the `handle_new_user`
679
+ * trigger to mirror the row into `public.users` automatically.
680
+ */
681
+ async user(opts) {
682
+ const sb = createProofServiceClient();
683
+ const { data: userData, error: createErr } = await sb.auth.admin.createUser(
684
+ {
685
+ email: opts.email,
686
+ password: opts.password,
687
+ email_confirm: true
688
+ }
689
+ );
690
+ if (createErr || !userData?.user) {
691
+ failSeed(
692
+ "seed_user_auth",
693
+ `auth user to be created for ${opts.email}`,
694
+ `admin.createUser error: ${createErr?.message ?? "no user returned"}`,
695
+ createErr
696
+ );
697
+ }
698
+ const userId = userData.user.id;
699
+ await waitForPublicUser(sb, userId, opts.email);
700
+ const {
701
+ data: memberRow,
702
+ error: memberErr,
703
+ status: memberStatus
704
+ } = await sb.from("workspace_members").insert({
705
+ workspace_id: opts.workspace.id,
706
+ user_id: userId,
707
+ role: opts.role
708
+ }).select("id").single();
709
+ if (memberErr || !memberRow) {
710
+ failSeed(
711
+ "seed_user_membership",
712
+ `workspace_members row for user ${opts.email} in workspace ${opts.workspace.id}`,
713
+ `insert error: ${memberErr?.message ?? "no row returned"}`,
714
+ memberErr,
715
+ memberStatus
716
+ );
717
+ }
718
+ return {
719
+ id: userId,
720
+ email: opts.email,
721
+ password: opts.password,
722
+ workspaceId: opts.workspace.id,
723
+ role: opts.role,
724
+ membershipId: memberRow.id
725
+ };
726
+ },
727
+ /**
728
+ * Delete a workspace and cascade-clean its members. Used by helpers for
729
+ * teardown. Safe to call on a workspace that no longer exists.
730
+ */
731
+ async deleteWorkspace(workspaceId) {
732
+ const sb = createProofServiceClient();
733
+ await sb.from("workspaces").delete().eq("id", workspaceId);
734
+ },
735
+ /**
736
+ * Delete an auth user (cascades to public.users and workspace_members via
737
+ * the schema's ON DELETE CASCADE). Safe to call if the user no longer
738
+ * exists.
739
+ */
740
+ async deleteUser(userId) {
741
+ const sb = createProofServiceClient();
742
+ await sb.auth.admin.deleteUser(userId);
743
+ }
744
+ };
745
+
746
+ // src/server/fixture.ts
747
+ var ProofFixturePendingError = class extends Error {
748
+ constructor(table, reason) {
749
+ super(
750
+ `Fixture factory for "${table}" is incomplete: ${reason}. Complete e2e/fixtures/${table}.ts with values valid for the final schema.`
751
+ );
752
+ this.table = table;
753
+ this.reason = reason;
754
+ this.name = "ProofFixturePendingError";
755
+ }
756
+ table;
757
+ reason;
758
+ code = "fixture_factory_required";
759
+ };
760
+ function isProofFixturePendingError(error) {
761
+ return error instanceof ProofFixturePendingError;
762
+ }
763
+
764
+ // src/playwright/assert.ts
765
+ function randomSuffix() {
766
+ return Math.random().toString(36).slice(2, 10);
767
+ }
768
+ function proofFail(category, expected, found, extras) {
769
+ return new Error(
770
+ `[PROOF_FAIL] ${category}: expected ${expected}, found ${found}
771
+ file: ${extras.file ?? "src/playwright/assert.ts"}
772
+ suggestion: ${extras.suggestion}`
773
+ );
774
+ }
775
+ function failIfInfrastructure(error, status, setupCategory, what) {
776
+ if (isAuthRateLimited(error, status)) {
777
+ throw proofFail(
778
+ setupCategory.replace(/_setup$/, "_rate_limited"),
779
+ `${what} to receive a database verdict`,
780
+ `HTTP 429/auth budget exhaustion: ${error?.message ?? "rate limited"}`,
781
+ {
782
+ suggestion: "This is infrastructure, not an RLS denial. Wait for the auth window to reset or reduce parallel proof workers."
783
+ }
784
+ );
785
+ }
786
+ if (!isTransient(error, status)) return;
787
+ throw proofFail(
788
+ setupCategory,
789
+ `${what} to reach the database, so that the result means something`,
790
+ `gateway/transport failure after ${TRANSIENT_MAX_ATTEMPTS} attempts: ${error?.message ?? "unknown error"}`,
791
+ {
792
+ suggestion: `The request never got an answer from Postgres, so neither "denied" nor "allowed" was observed \u2014 reading it as a denial would certify an invariant that nothing exercised. Check that the local Supabase stack is healthy (pnpm exec supabase status) and re-run.`
793
+ }
794
+ );
795
+ }
796
+ function validateTenantIsolationCriterion(table, scopeColumn, criterion) {
797
+ if (!criterion) return;
798
+ const invalidReason = criterion.description.trim().length === 0 ? "criterion.description is empty" : Object.keys(criterion.where).length === 0 ? "criterion.where has no equality filters" : Object.prototype.hasOwnProperty.call(criterion.where, scopeColumn) ? `criterion.where contains the tenant scope column "${scopeColumn}"` : null;
799
+ if (!invalidReason) return;
800
+ recordAssertion({
801
+ kind: "tenant_isolation",
802
+ target: table,
803
+ operation: "select",
804
+ passed: false,
805
+ status: "incomplete",
806
+ role: "primary",
807
+ detail: `planner-owned fixture criterion is invalid: ${invalidReason}`
808
+ });
809
+ throw proofFail(
810
+ "fixture_criterion_invalid",
811
+ "a non-empty semantic equality filter that does not redefine tenant scope",
812
+ invalidReason,
813
+ {
814
+ suggestion: `Keep "${scopeColumn}" out of criterion.where; assert.tenantIsolation applies the current side's scope automatically. Use criterion.where only for semantic state such as status, type, or visibility.`
815
+ }
816
+ );
817
+ }
818
+ async function teardown(orgA, orgB, userA, userB) {
819
+ await Promise.allSettled([
820
+ seed.deleteWorkspace(orgA.id),
821
+ seed.deleteWorkspace(orgB.id)
822
+ ]);
823
+ await Promise.allSettled([
824
+ seed.deleteUser(userA.id),
825
+ seed.deleteUser(userB.id)
826
+ ]);
827
+ }
828
+ function describeActor(actor) {
829
+ if (typeof actor === "string") {
830
+ const emailEnv = actor === "admin" ? process.env.SEED_ADMIN_EMAIL : process.env.SEED_MEMBER_EMAIL;
831
+ const passwordEnv = actor === "admin" ? process.env.SEED_ADMIN_PASSWORD : process.env.SEED_MEMBER_PASSWORD;
832
+ if (!emailEnv || !passwordEnv) {
833
+ const prefix = actor === "admin" ? "SEED_ADMIN" : "SEED_MEMBER";
834
+ throw new Error(
835
+ `[PROOF_FAIL] seed_credentials_missing: ${prefix}_EMAIL and ${prefix}_PASSWORD must be set when using the "${actor}" seed actor`
836
+ );
837
+ }
838
+ return {
839
+ email: emailEnv,
840
+ password: passwordEnv,
841
+ label: `seed:${actor}`
842
+ };
843
+ }
844
+ return { email: actor.email, password: actor.password, label: actor.email };
845
+ }
846
+ function applyEq(query, filter) {
847
+ let out = query;
848
+ for (const [k, v] of Object.entries(filter)) {
849
+ out = out.eq(k, v);
850
+ }
851
+ return out;
852
+ }
853
+ async function countMatchingAsService(sb, table, filter) {
854
+ const { count, error } = await applyEq(
855
+ sb.from(table).select("*", { count: "exact", head: true }),
856
+ filter
857
+ );
858
+ if (error) {
859
+ throw proofFail(
860
+ "authorization_setup",
861
+ `to verify the state of "${table}" with the service role`,
862
+ `Supabase error: ${error.message}`,
863
+ {
864
+ suggestion: `The probe cannot confirm whether the write was actually blocked without reading the table as service_role. Check the table name and that SUPABASE_SERVICE_ROLE_KEY is set.`
865
+ }
866
+ );
867
+ }
868
+ return count ?? 0;
869
+ }
870
+ async function countVisibleToActor(client, table, filter) {
871
+ const { count, error, status } = await applyEq(
872
+ client.from(table).select("*", { count: "exact", head: true }),
873
+ filter
874
+ );
875
+ failIfInfrastructure(
876
+ error,
877
+ status,
878
+ "authorization_setup",
879
+ `the actor's visibility check on ${table}`
880
+ );
881
+ if (error) return 0;
882
+ return count ?? 0;
883
+ }
884
+ async function requireWriteTargetVisible(args) {
885
+ const { client, probe, target, filter, existing, actorLabel } = args;
886
+ const visible = await countVisibleToActor(client, probe.table, filter);
887
+ if (visible > 0) {
888
+ recordAssertion({
889
+ kind: "authorization",
890
+ target,
891
+ operation: "select",
892
+ passed: true,
893
+ role: "control",
894
+ detail: `${actorLabel} can read ${visible} of the ${existing} row(s) matching ${JSON.stringify(filter)} in ${probe.table}, so a refused ${probe.op.toUpperCase()} is distinguishable from one that never reached a row`
895
+ });
896
+ return;
897
+ }
898
+ recordAssertion({
899
+ kind: "authorization",
900
+ target,
901
+ operation: "select",
902
+ passed: false,
903
+ status: "incomplete",
904
+ role: "control",
905
+ detail: `${actorLabel} cannot read any of the ${existing} row(s) matching ${JSON.stringify(filter)} in ${probe.table}, so its ${probe.op.toUpperCase()} matches zero rows whether or not the operation is permitted`
906
+ });
907
+ throw proofFail(
908
+ "authorization_blind",
909
+ `${actorLabel} to be able to read at least one of the ${existing} target row(s) in "${probe.table}", so that a refused ${probe.op.toUpperCase()} means the write was rejected`,
910
+ `0 visible row(s) \u2014 this probe cannot fail, because PostgreSQL filters the rows an ${probe.op.toUpperCase()} reaches through its WHERE clause by the SELECT policy`,
911
+ {
912
+ suggestion: `Point the probe at a row the actor can read, or use an actor who can \u2014 for an admin-only table that is the admin, and "not even the actor who can read every row may change one" is usually the stronger claim anyway. If the invariant you actually want is "the actor cannot SEE these rows", use \`op: "select"\` or assert.tenantIsolation, which grade visibility directly.`
913
+ }
914
+ );
915
+ }
916
+ async function runRlsProbe(probe, actorCreds) {
917
+ const target = probe.target ?? probe.table;
918
+ const client = await actAsUser.supabaseClient({
919
+ email: actorCreds.email,
920
+ password: actorCreds.password
921
+ });
922
+ const sb = createProofServiceClient();
923
+ if (probe.op === "select") {
924
+ const existing = await countMatchingAsService(
925
+ sb,
926
+ probe.table,
927
+ probe.filter ?? {}
928
+ );
929
+ if (existing === 0) {
930
+ recordAssertion({
931
+ kind: "authorization",
932
+ target,
933
+ operation: probe.op,
934
+ passed: false,
935
+ status: "incomplete",
936
+ role: "primary",
937
+ detail: `no rows in ${probe.table} matching ${JSON.stringify(probe.filter ?? {})}, so "${actorCreds.label} read 0 rows" proves nothing`
938
+ });
939
+ throw proofFail(
940
+ "authorization_vacuous",
941
+ `at least one row in "${probe.table}" matching ${JSON.stringify(probe.filter ?? {})} for the select probe to be denied access to`,
942
+ "0 row(s) \u2014 there was nothing for the actor to fail to read",
943
+ {
944
+ suggestion: `Seed the fixture row(s) with the service-role client before probing. Reading zero rows from an empty table is not evidence that RLS filtered anything.`
945
+ }
946
+ );
947
+ }
948
+ let query = client.from(probe.table).select("*");
949
+ for (const [k, v] of Object.entries(probe.filter ?? {})) {
950
+ query = query.eq(k, v);
951
+ }
952
+ const { data, error: error2, status: status2 } = await query;
953
+ failIfInfrastructure(
954
+ error2,
955
+ status2,
956
+ "authorization_setup",
957
+ `the actor's SELECT on ${probe.table}`
958
+ );
959
+ const rows = data ?? [];
960
+ if (!error2 && rows.length > 0) {
961
+ recordAssertion({
962
+ kind: "authorization",
963
+ target,
964
+ operation: probe.op,
965
+ passed: false,
966
+ role: "primary",
967
+ detail: `actor ${actorCreds.label} SELECTed ${rows.length} row(s) from ${probe.table}; expected RLS to filter to 0`
968
+ });
969
+ throw proofFail(
970
+ "authorization",
971
+ `0 rows from ${probe.table} when selected by ${actorCreds.label} with filter ${JSON.stringify(probe.filter ?? {})}`,
972
+ `${rows.length} row(s)`,
973
+ {
974
+ suggestion: `Check SELECT RLS policy on ${probe.table}. The actor should not have read access to these rows.`
975
+ }
976
+ );
977
+ }
978
+ recordAssertion({
979
+ kind: "authorization",
980
+ target,
981
+ operation: probe.op,
982
+ passed: true,
983
+ role: "primary",
984
+ detail: `${actorCreds.label} SELECT on ${probe.table} returned 0 of the ${existing} row(s) that exist (RLS honored)${error2 ? `; the query itself errored: ${error2.message}` : ""}`
985
+ });
986
+ return;
987
+ }
988
+ if (probe.op === "insert") {
989
+ if (!probe.payload) {
990
+ throw new Error(
991
+ "[PROOF_FAIL] bad_probe: rls.op='insert' requires `payload`\n file: src/playwright/assert.ts"
992
+ );
993
+ }
994
+ const { error: error2, status: status2 } = await client.from(probe.table).insert(probe.payload);
995
+ failIfInfrastructure(
996
+ error2,
997
+ status2,
998
+ "authorization_setup",
999
+ `the actor's INSERT on ${probe.table}`
1000
+ );
1001
+ const committed = await countMatchingAsService(
1002
+ sb,
1003
+ probe.table,
1004
+ probe.payload
1005
+ );
1006
+ if (committed > 0) {
1007
+ await applyEq(sb.from(probe.table).delete(), probe.payload);
1008
+ recordAssertion({
1009
+ kind: "authorization",
1010
+ target,
1011
+ operation: probe.op,
1012
+ passed: false,
1013
+ role: "primary",
1014
+ detail: `actor ${actorCreds.label} INSERT on ${probe.table} COMMITTED ${committed} row(s)${error2 ? ` even though the API returned "${error2.message}"` : ""}; expected RLS/policy denial`
1015
+ });
1016
+ throw proofFail(
1017
+ "authorization",
1018
+ `INSERT on ${probe.table} to be denied for ${actorCreds.label}`,
1019
+ error2 ? `the API returned an error ("${error2.message}") but a service-role read found ${committed} row(s) committed \u2014 the write went through` : `insert succeeded (${committed} row(s) committed)`,
1020
+ {
1021
+ suggestion: `Check INSERT RLS policy and grants on ${probe.table}.`
1022
+ }
1023
+ );
1024
+ }
1025
+ recordAssertion({
1026
+ kind: "authorization",
1027
+ target,
1028
+ operation: probe.op,
1029
+ passed: true,
1030
+ role: "primary",
1031
+ detail: `${actorCreds.label} INSERT on ${probe.table} denied${error2 ? ` (${error2.message})` : ""}; service-role read confirmed 0 row(s) committed`
1032
+ });
1033
+ return;
1034
+ }
1035
+ if (!probe.filter) {
1036
+ throw new Error(
1037
+ `[PROOF_FAIL] bad_probe: rls.op='${probe.op}' requires \`filter\`
1038
+ file: src/playwright/assert.ts`
1039
+ );
1040
+ }
1041
+ if (probe.op === "delete") {
1042
+ const before2 = await countMatchingAsService(sb, probe.table, probe.filter);
1043
+ if (before2 === 0) {
1044
+ recordAssertion({
1045
+ kind: "authorization",
1046
+ target,
1047
+ operation: probe.op,
1048
+ passed: false,
1049
+ role: "primary",
1050
+ detail: `no rows in ${probe.table} matched ${JSON.stringify(probe.filter)} before the DELETE probe, so the probe could not have failed`
1051
+ });
1052
+ throw proofFail(
1053
+ "authorization_vacuous",
1054
+ `at least one row in "${probe.table}" matching ${JSON.stringify(probe.filter)} so the DELETE has a target`,
1055
+ "0 matching rows before the probe ran",
1056
+ {
1057
+ suggestion: `Seed the row you expect the actor to be unable to delete before calling assert.authorization.`
1058
+ }
1059
+ );
1060
+ }
1061
+ await requireWriteTargetVisible({
1062
+ client,
1063
+ probe,
1064
+ target,
1065
+ filter: probe.filter,
1066
+ existing: before2,
1067
+ actorLabel: actorCreds.label
1068
+ });
1069
+ const { error: error2, status: status2 } = await applyEq(
1070
+ client.from(probe.table).delete(),
1071
+ probe.filter
1072
+ );
1073
+ failIfInfrastructure(
1074
+ error2,
1075
+ status2,
1076
+ "authorization_setup",
1077
+ `the actor's DELETE on ${probe.table}`
1078
+ );
1079
+ const after2 = await countMatchingAsService(sb, probe.table, probe.filter);
1080
+ if (after2 < before2) {
1081
+ recordAssertion({
1082
+ kind: "authorization",
1083
+ target,
1084
+ operation: probe.op,
1085
+ passed: false,
1086
+ role: "primary",
1087
+ detail: `actor ${actorCreds.label} DELETE on ${probe.table} removed ${before2 - after2} row(s) (${before2} \u2192 ${after2})${error2 ? ` even though the API returned "${error2.message}"` : ""}; expected none`
1088
+ });
1089
+ throw proofFail(
1090
+ "authorization",
1091
+ `0 rows removed from ${probe.table} by ${actorCreds.label}`,
1092
+ `${before2 - after2} row(s) removed (${before2} \u2192 ${after2})`,
1093
+ {
1094
+ suggestion: `Check the DELETE RLS policy on ${probe.table}.`
1095
+ }
1096
+ );
1097
+ }
1098
+ recordAssertion({
1099
+ kind: "authorization",
1100
+ target,
1101
+ operation: probe.op,
1102
+ passed: true,
1103
+ role: "primary",
1104
+ detail: `${actorCreds.label} DELETE on ${probe.table} removed 0 of ${before2} matching row(s)${error2 ? ` (${error2.message})` : ""}; service-role recount confirmed ${after2} (RLS honored)`
1105
+ });
1106
+ return;
1107
+ }
1108
+ if (!probe.payload || Object.keys(probe.payload).length === 0) {
1109
+ throw new Error(
1110
+ "[PROOF_FAIL] bad_probe: rls.op='update' requires a non-empty `payload`\n file: src/playwright/assert.ts\n suggestion: The payload is both the attempted change and the way the probe verifies whether it was applied."
1111
+ );
1112
+ }
1113
+ const columns = Object.keys(probe.payload);
1114
+ const readTargetRows = async () => {
1115
+ const { data, error: error2 } = await applyEq(
1116
+ sb.from(probe.table).select(columns.join(",")),
1117
+ probe.filter
1118
+ );
1119
+ if (error2) {
1120
+ throw proofFail(
1121
+ "authorization_setup",
1122
+ `to read ${columns.join(", ")} from "${probe.table}" with the service role`,
1123
+ `Supabase error: ${error2.message}`,
1124
+ {
1125
+ suggestion: `The probe compares these columns before and after the update to decide whether the write landed.`
1126
+ }
1127
+ );
1128
+ }
1129
+ return data ?? [];
1130
+ };
1131
+ const rowMatchesPayload = (row) => columns.every(
1132
+ (c) => JSON.stringify(row[c]) === JSON.stringify(probe.payload?.[c])
1133
+ );
1134
+ const before = await readTargetRows();
1135
+ if (before.length === 0) {
1136
+ recordAssertion({
1137
+ kind: "authorization",
1138
+ target,
1139
+ operation: probe.op,
1140
+ passed: false,
1141
+ role: "primary",
1142
+ detail: `no rows in ${probe.table} matched ${JSON.stringify(probe.filter)} before the UPDATE probe, so the probe could not have failed`
1143
+ });
1144
+ throw proofFail(
1145
+ "authorization_vacuous",
1146
+ `at least one row in "${probe.table}" matching ${JSON.stringify(probe.filter)} so the UPDATE has a target`,
1147
+ "0 matching rows before the probe ran",
1148
+ {
1149
+ suggestion: `Seed the row you expect the actor to be unable to modify before calling assert.authorization.`
1150
+ }
1151
+ );
1152
+ }
1153
+ await requireWriteTargetVisible({
1154
+ client,
1155
+ probe,
1156
+ target,
1157
+ filter: probe.filter,
1158
+ existing: before.length,
1159
+ actorLabel: actorCreds.label
1160
+ });
1161
+ const { error, status } = await applyEq(
1162
+ client.from(probe.table).update(probe.payload),
1163
+ probe.filter
1164
+ );
1165
+ failIfInfrastructure(
1166
+ error,
1167
+ status,
1168
+ "authorization_setup",
1169
+ `the actor's UPDATE on ${probe.table}`
1170
+ );
1171
+ const after = await readTargetRows();
1172
+ const beforeApplied = before.filter(rowMatchesPayload).length;
1173
+ const afterApplied = after.filter(rowMatchesPayload).length;
1174
+ if (afterApplied > beforeApplied) {
1175
+ recordAssertion({
1176
+ kind: "authorization",
1177
+ target,
1178
+ operation: probe.op,
1179
+ passed: false,
1180
+ role: "primary",
1181
+ detail: `actor ${actorCreds.label} UPDATE on ${probe.table} applied ${columns.join(", ")} to ${afterApplied - beforeApplied} row(s)${error ? ` even though the API returned "${error.message}"` : ""}; expected the write to be rejected`
1182
+ });
1183
+ throw proofFail(
1184
+ "authorization",
1185
+ `${columns.join(", ")} on ${probe.table} to be unchanged for ${actorCreds.label}`,
1186
+ `${afterApplied - beforeApplied} row(s) now hold the attempted value(s) ${JSON.stringify(probe.payload)}`,
1187
+ {
1188
+ suggestion: `Check the UPDATE RLS policy and any column guard on ${probe.table}.`
1189
+ }
1190
+ );
1191
+ }
1192
+ recordAssertion({
1193
+ kind: "authorization",
1194
+ target,
1195
+ operation: probe.op,
1196
+ passed: true,
1197
+ role: "primary",
1198
+ detail: `${actorCreds.label} UPDATE on ${probe.table} left ${columns.join(", ")} unchanged across ${before.length} targeted row(s)${error ? ` (${error.message})` : ""}; verified by service-role re-read (RLS honored)`
1199
+ });
1200
+ }
1201
+ async function countRowsAsService(sb, table, filter) {
1202
+ const { count, error } = await applyEq(
1203
+ sb.from(table).select("*", { count: "exact", head: true }),
1204
+ filter
1205
+ );
1206
+ if (error) {
1207
+ throw proofFail(
1208
+ "tenant_isolation_setup",
1209
+ `to count rows in "${table}" with the service role to establish ground truth`,
1210
+ `Supabase error: ${error.message}`,
1211
+ {
1212
+ suggestion: `Confirm "${table}" exists and supports the planner-owned filter ${JSON.stringify(filter)}. Tenant isolation cannot be measured without first knowing how many matching rows are actually there.`
1213
+ }
1214
+ );
1215
+ }
1216
+ return count ?? 0;
1217
+ }
1218
+ async function probeTenantIsolation(args) {
1219
+ const {
1220
+ table,
1221
+ viewer,
1222
+ viewerLabel,
1223
+ owner,
1224
+ ownerLabel,
1225
+ scopeColumn,
1226
+ scopeValue,
1227
+ scopeLabel,
1228
+ criterion,
1229
+ scopePattern,
1230
+ sb
1231
+ } = args;
1232
+ const filter = {
1233
+ [scopeColumn]: scopeValue,
1234
+ ...criterion?.where ?? {}
1235
+ };
1236
+ const criterionDetail = criterion ? ` matching planner criterion "${criterion.description}" (${JSON.stringify(criterion.where)})` : "";
1237
+ const actualRowCount = await countRowsAsService(sb, table, filter);
1238
+ if (actualRowCount === 0) {
1239
+ return "skipped_no_data";
1240
+ }
1241
+ const ownerClient = await actAsUser.supabaseClient({
1242
+ email: owner.email,
1243
+ password: owner.password
1244
+ });
1245
+ const {
1246
+ data: ownRows,
1247
+ error: ownError,
1248
+ status: ownStatus
1249
+ } = await applyEq(ownerClient.from(table).select("*"), filter);
1250
+ failIfInfrastructure(
1251
+ ownError,
1252
+ ownStatus,
1253
+ "tenant_isolation_setup",
1254
+ `the owner's control SELECT on ${table}`
1255
+ );
1256
+ if (ownError || (ownRows?.length ?? 0) === 0) {
1257
+ recordAssertion({
1258
+ kind: "tenant_isolation",
1259
+ target: table,
1260
+ operation: "select",
1261
+ passed: false,
1262
+ role: "control",
1263
+ detail: `positive control FAILED: ${ownerLabel} (owner of ${scopeLabel}) read ${ownRows?.length ?? 0} of ${actualRowCount} row(s) in ${table}${criterionDetail}${ownError ? `; error: ${ownError.message}` : ""}`
1264
+ });
1265
+ throw proofFail(
1266
+ "tenant_isolation_control",
1267
+ `${ownerLabel} to read the ${actualRowCount} row(s) that exist in "${table}" for ${scopeLabel}${criterionDetail}`,
1268
+ ownError ? `Supabase error: ${ownError.message}` : `0 row(s) \u2014 their own data is invisible to them`,
1269
+ {
1270
+ suggestion: `The negative probe would have "passed" here for the wrong reason: if the owner cannot read their own rows, then an outsider seeing 0 rows proves nothing. Check the SELECT policy/grant on "${table}" \u2014 the canonical pattern for this scope is ${scopePattern}. If this table is intentionally service-role-only, it is not a tenant-isolation target: use assert.authorization instead.`
1271
+ }
1272
+ );
1273
+ }
1274
+ recordAssertion({
1275
+ kind: "tenant_isolation",
1276
+ target: table,
1277
+ operation: "select",
1278
+ passed: true,
1279
+ role: "control",
1280
+ detail: `${ownerLabel} read ${ownRows?.length ?? 0} of ${actualRowCount} row(s) in ${table} for ${scopeLabel}${criterionDetail} \u2014 the probe can observe these rows, so a leak would be detected`
1281
+ });
1282
+ const client = await actAsUser.supabaseClient({
1283
+ email: viewer.email,
1284
+ password: viewer.password
1285
+ });
1286
+ const {
1287
+ data: leakedRows,
1288
+ error: queryError,
1289
+ status: queryStatus
1290
+ } = await applyEq(client.from(table).select("*"), filter);
1291
+ failIfInfrastructure(
1292
+ queryError,
1293
+ queryStatus,
1294
+ "tenant_isolation_setup",
1295
+ `the outsider's SELECT on ${table}`
1296
+ );
1297
+ if (queryError) {
1298
+ recordAssertion({
1299
+ kind: "tenant_isolation",
1300
+ target: table,
1301
+ operation: "select",
1302
+ passed: false,
1303
+ role: "primary",
1304
+ detail: `${viewerLabel} select on ${table} scoped to ${scopeLabel}${criterionDetail} failed: ${queryError.message}`
1305
+ });
1306
+ throw proofFail(
1307
+ "tenant_isolation",
1308
+ `query against ${table} as ${viewerLabel} to succeed (possibly returning 0 rows)`,
1309
+ `Supabase error: ${queryError.message}`,
1310
+ {
1311
+ suggestion: `Verify "${table}" has a ${scopeColumn} column and a SELECT grant for authenticated users. Tenant isolation cannot be proven if the query itself is denied at the grant level.`
1312
+ }
1313
+ );
1314
+ }
1315
+ if (leakedRows && leakedRows.length > 0) {
1316
+ const sampleIds = leakedRows.slice(0, 3).map((r) => r.id ?? "?").join(", ");
1317
+ recordAssertion({
1318
+ kind: "tenant_isolation",
1319
+ target: table,
1320
+ operation: "select",
1321
+ passed: false,
1322
+ role: "primary",
1323
+ detail: `${viewerLabel} saw ${leakedRows.length} row(s) in ${table} belonging to ${scopeLabel}${criterionDetail} (sample ids: ${sampleIds})`
1324
+ });
1325
+ throw proofFail(
1326
+ "tenant_isolation",
1327
+ `0 rows in "${table}" for ${viewerLabel} filtered to ${JSON.stringify(filter)}`,
1328
+ `${leakedRows.length} row(s) (sample ids: ${sampleIds})`,
1329
+ {
1330
+ suggestion: `Inspect the RLS policy on "${table}". The canonical pattern for this scope is ${scopePattern}.`
1331
+ }
1332
+ );
1333
+ }
1334
+ recordAssertion({
1335
+ kind: "tenant_isolation",
1336
+ target: table,
1337
+ operation: "select",
1338
+ passed: true,
1339
+ role: "primary",
1340
+ detail: `${viewerLabel} saw 0 of the ${actualRowCount} row(s) that exist in ${table} for ${scopeLabel}${criterionDetail}, which ${ownerLabel} can read (RLS honored)`
1341
+ });
1342
+ return "proved";
1343
+ }
1344
+ async function runActionProbe(probe, actorCreds, page) {
1345
+ const target = probe.target ?? `${probe.module}:${probe.name}`;
1346
+ const kind = probe.kind ?? "authorization";
1347
+ const result = await actAsUser.invokeAction(page, {
1348
+ as: actorCreds.label === "seed:admin" ? "admin" : actorCreds.label === "seed:member" ? "member" : { email: actorCreds.email, password: actorCreds.password },
1349
+ module: probe.module,
1350
+ action: probe.name,
1351
+ inputParams: probe.inputParams
1352
+ });
1353
+ if (result.success) {
1354
+ recordAssertion({
1355
+ kind,
1356
+ target,
1357
+ operation: "invoke",
1358
+ passed: false,
1359
+ role: "primary",
1360
+ detail: `actor ${actorCreds.label} successfully invoked ${target}; expected action-layer rejection`
1361
+ });
1362
+ throw proofFail(
1363
+ kind,
1364
+ `action "${target}" to return { success: false } for ${actorCreds.label}`,
1365
+ "action returned { success: true }",
1366
+ {
1367
+ suggestion: `Check withRBAC / role-check logic inside ${probe.module}/${probe.name}. The action should reject the actor.`
1368
+ }
1369
+ );
1370
+ }
1371
+ if (probe.expectedErrorMatch && !probe.expectedErrorMatch.test(result.error)) {
1372
+ recordAssertion({
1373
+ kind,
1374
+ target,
1375
+ operation: "invoke",
1376
+ passed: false,
1377
+ role: "primary",
1378
+ detail: `action "${target}" rejected ${actorCreds.label} but with unexpected message: ${result.error}`
1379
+ });
1380
+ throw proofFail(
1381
+ kind,
1382
+ `rejection message matching ${probe.expectedErrorMatch}`,
1383
+ `error: "${result.error}"`,
1384
+ {
1385
+ suggestion: `The action rejected the caller (good) but not for the expected reason. Confirm the proof is asserting on the right invariant.`
1386
+ }
1387
+ );
1388
+ }
1389
+ recordAssertion({
1390
+ kind,
1391
+ target,
1392
+ operation: "invoke",
1393
+ passed: true,
1394
+ role: "primary",
1395
+ detail: `action "${target}" rejected ${actorCreds.label} with: ${result.error}`
1396
+ });
1397
+ }
1398
+ async function runSuccessfulActionProbe(opts) {
1399
+ const actorCreds = describeActor(opts.actor);
1400
+ const target = opts.action.target ?? `${opts.action.module}:${opts.action.name}`;
1401
+ const kind = opts.kind ?? opts.action.kind ?? "happy_path";
1402
+ const role = opts.role ?? "control";
1403
+ const result = await actAsUser.invokeAction(opts.page, {
1404
+ as: actorCreds.label === "seed:admin" ? "admin" : actorCreds.label === "seed:member" ? "member" : { email: actorCreds.email, password: actorCreds.password },
1405
+ module: opts.action.module,
1406
+ action: opts.action.name,
1407
+ inputParams: opts.action.inputParams
1408
+ });
1409
+ if (!result.success) {
1410
+ recordAssertion({
1411
+ kind,
1412
+ target,
1413
+ operation: "invoke",
1414
+ passed: false,
1415
+ role,
1416
+ detail: `action "${target}" rejected ${actorCreds.label}; expected success: ${result.error}`
1417
+ });
1418
+ throw proofFail(
1419
+ kind,
1420
+ `action "${target}" to return { success: true } for ${actorCreds.label}`,
1421
+ `action returned { success: false, error: "${result.error}" }`,
1422
+ {
1423
+ suggestion: `A refusal proof needs a working positive path. Check the action registry, validation input, and the permissions for ${actorCreds.label}.`
1424
+ }
1425
+ );
1426
+ }
1427
+ recordAssertion({
1428
+ kind,
1429
+ target,
1430
+ operation: "invoke",
1431
+ passed: true,
1432
+ role,
1433
+ detail: `action "${target}" succeeded for ${actorCreds.label}`
1434
+ });
1435
+ return result.data;
1436
+ }
1437
+ var assertMethods = {
1438
+ /**
1439
+ * Prove that workspace-scoped data in `table` is invisible to members of a
1440
+ * different workspace. See file header for full semantics.
1441
+ *
1442
+ * Records two assertions per proved direction: a `control` showing the org's
1443
+ * own member CAN read the rows, and the `primary` showing the outsider
1444
+ * cannot. Only the `primary` can satisfy a mission's `trace_must_prove`
1445
+ * entry.
1446
+ *
1447
+ * Fails rather than passing vacuously when `setup()` leaves orgA empty.
1448
+ */
1449
+ async tenantIsolation(opts) {
1450
+ const { table, page } = opts;
1451
+ const tag = opts.tag ?? randomSuffix();
1452
+ const scope = opts.scope ?? "workspace";
1453
+ const scopeColumn = opts.scopeColumn ?? (scope === "user" ? "user_id" : "workspace_id");
1454
+ validateTenantIsolationCriterion(table, scopeColumn, opts.criterion);
1455
+ const orgA = await seed.workspace(`Proof OrgA ${tag}`);
1456
+ const orgB = await seed.workspace(`Proof OrgB ${tag}`);
1457
+ const userA = await seed.user({
1458
+ email: `proof-a-${tag}@proof.test`,
1459
+ password: `ProofPass!${tag}1`,
1460
+ workspace: orgA,
1461
+ role: "owner"
1462
+ });
1463
+ const userB = await seed.user({
1464
+ email: `proof-b-${tag}@proof.test`,
1465
+ password: `ProofPass!${tag}1`,
1466
+ workspace: orgB,
1467
+ role: "owner"
1468
+ });
1469
+ const sb = createProofServiceClient();
1470
+ const scopePattern = scope === "user" ? "user_id = auth.uid()" : "workspace_id IN (SELECT get_user_workspace_ids(auth.uid()))";
1471
+ const sideA = scope === "user" ? userA.id : orgA.id;
1472
+ const sideB = scope === "user" ? userB.id : orgB.id;
1473
+ const labelA = scope === "user" ? "user A's rows" : "orgA";
1474
+ const labelB = scope === "user" ? "user B's rows" : "orgB";
1475
+ try {
1476
+ const fixtureContext = {
1477
+ orgA,
1478
+ orgB,
1479
+ userA,
1480
+ userB,
1481
+ sb
1482
+ };
1483
+ if (opts.fixture && opts.fixture.table !== table) {
1484
+ recordAssertion({
1485
+ kind: "tenant_isolation",
1486
+ target: table,
1487
+ operation: "select",
1488
+ passed: false,
1489
+ status: "incomplete",
1490
+ role: "primary",
1491
+ detail: `fixture for "${opts.fixture.table}" cannot seed proof target "${table}"`
1492
+ });
1493
+ throw proofFail(
1494
+ "fixture_factory_mismatch",
1495
+ `the imported fixture factory to declare table "${table}"`,
1496
+ `factory declares "${opts.fixture.table}"`,
1497
+ {
1498
+ file: `e2e/fixtures/${table}.ts`,
1499
+ suggestion: `Import the factory owned by "${table}", or correct its table declaration. A copied factory must not silently seed one table while the proof probes another.`
1500
+ }
1501
+ );
1502
+ }
1503
+ try {
1504
+ if (opts.fixture) {
1505
+ await withoutAssertionProvenance(
1506
+ () => opts.fixture.create(fixtureContext)
1507
+ );
1508
+ } else {
1509
+ await withoutAssertionProvenance(() => opts.setup(fixtureContext));
1510
+ }
1511
+ } catch (error) {
1512
+ if (!isProofFixturePendingError(error)) throw error;
1513
+ recordAssertion({
1514
+ kind: "tenant_isolation",
1515
+ target: table,
1516
+ operation: "select",
1517
+ passed: false,
1518
+ status: "incomplete",
1519
+ role: "primary",
1520
+ detail: `fixture factory incomplete: ${error.reason}`
1521
+ });
1522
+ throw proofFail(
1523
+ error.code,
1524
+ `a completed fixture factory for "${table}"`,
1525
+ error.reason,
1526
+ {
1527
+ file: `e2e/fixtures/${table}.ts`,
1528
+ suggestion: `Replace pendingProofFixture(...) with defineProofFixture(...), using values valid for the final schema. Resolve required foreign keys from seeded/existing rows and honor NOT NULL, CHECK, domain, and enum constraints. Do not weaken a product constraint to make the proof easier to seed.`
1529
+ }
1530
+ );
1531
+ }
1532
+ const forward = await probeTenantIsolation({
1533
+ table,
1534
+ viewer: userB,
1535
+ viewerLabel: "user B",
1536
+ owner: userA,
1537
+ ownerLabel: "user A",
1538
+ scopeColumn,
1539
+ scopeValue: sideA,
1540
+ scopeLabel: labelA,
1541
+ criterion: opts.criterion,
1542
+ scopePattern,
1543
+ sb
1544
+ });
1545
+ if (forward === "skipped_no_data") {
1546
+ recordAssertion({
1547
+ kind: "tenant_isolation",
1548
+ target: table,
1549
+ operation: "select",
1550
+ passed: false,
1551
+ status: "incomplete",
1552
+ role: "primary",
1553
+ detail: `fixture setup created 0 rows in "${table}" where ${scopeColumn} = ${sideA}` + (opts.criterion ? ` matching planner criterion "${opts.criterion.description}" (${JSON.stringify(opts.criterion.where)})` : "")
1554
+ });
1555
+ throw proofFail(
1556
+ "tenant_isolation_vacuous",
1557
+ `setup() to create at least one "${table}" row for ${labelA}${opts.criterion ? ` matching planner criterion "${opts.criterion.description}"` : ""} so there is something for user B to fail to see`,
1558
+ `0 rows in "${table}" matching ${JSON.stringify({
1559
+ [scopeColumn]: sideA,
1560
+ ...opts.criterion?.where ?? {}
1561
+ })} after setup() returned`,
1562
+ {
1563
+ suggestion: `An empty table cannot demonstrate isolation: user B seeing 0 rows would be indistinguishable from working RLS. Insert the fixture row(s) in e2e/fixtures/${table}.ts (or the inline setup) using the provided service-role client (\`sb\`), and make sure they carry ${scopeColumn} = ${sideA}${opts.criterion ? ` and satisfy planner criterion "${opts.criterion.description}" (${JSON.stringify(opts.criterion.where)})` : ""}.`
1564
+ }
1565
+ );
1566
+ }
1567
+ const reverse = await probeTenantIsolation({
1568
+ table,
1569
+ viewer: userA,
1570
+ viewerLabel: "user A",
1571
+ owner: userB,
1572
+ ownerLabel: "user B",
1573
+ scopeColumn,
1574
+ scopeValue: sideB,
1575
+ scopeLabel: labelB,
1576
+ criterion: opts.criterion,
1577
+ scopePattern,
1578
+ sb
1579
+ });
1580
+ if (reverse === "skipped_no_data") {
1581
+ recordAssertion({
1582
+ kind: "tenant_isolation",
1583
+ target: table,
1584
+ operation: "select",
1585
+ passed: false,
1586
+ status: "skipped",
1587
+ role: "primary",
1588
+ detail: `reverse direction (A\u2192B) not measured: setup() left ${labelB}${opts.criterion ? ` without rows matching planner criterion "${opts.criterion.description}"` : " empty"}, so there is nothing for user A to fail to see. Populate both sides in setup() to prove isolation in both directions.`
1589
+ });
1590
+ console.log(
1591
+ `[proof] tenantIsolation(${table}): reverse probe (A\u2192B) skipped \u2014 setup() left ${labelB}${opts.criterion ? ` without rows matching criterion "${opts.criterion.description}"` : " empty"}, so there is nothing for user A to fail to see. The A\u2192B direction is unproven; populate it in setup() if you need it proven in both directions.`
1592
+ );
1593
+ }
1594
+ if (page) {
1595
+ await actAsUser.loginAs(page, userB.email, userB.password);
1596
+ }
1597
+ } finally {
1598
+ await teardown(orgA, orgB, userA, userB);
1599
+ }
1600
+ },
1601
+ /**
1602
+ * Prove that an operation is rejected when performed by an unauthorized
1603
+ * actor. Can probe either the RLS layer (direct Supabase client), the
1604
+ * action layer (via POST /api/proof/invoke-action), or both.
1605
+ *
1606
+ * RLS probes record `kind: "authorization"`. Action probes default to the
1607
+ * same kind but may set `action.kind` to the withProof invariant they
1608
+ * establish. The helper throws on the first probe that SUCCEEDS when it was
1609
+ * expected to fail — that's the PROOF_FAIL signal.
1610
+ *
1611
+ * Caller is responsible for seeding any fixture rows needed; this helper
1612
+ * creates no users or workspaces on its own.
1613
+ *
1614
+ * @example
1615
+ * // Action-layer: member cannot remove admin
1616
+ * await assert.authorization({
1617
+ * actor: { email: member.email, password: member.password },
1618
+ * action: {
1619
+ * module: "workspace",
1620
+ * name: "removeMember",
1621
+ * inputParams: { formData: { workspaceId, memberId } },
1622
+ * expectedErrorMatch: /permission|unauthorized/i,
1623
+ * },
1624
+ * page,
1625
+ * });
1626
+ *
1627
+ * @example
1628
+ * // RLS-layer: member cannot DELETE admin's workspace_members row
1629
+ * await assert.authorization({
1630
+ * actor: { email: member.email, password: member.password },
1631
+ * rls: {
1632
+ * table: "workspace_members",
1633
+ * op: "delete",
1634
+ * filter: { user_id: admin.id, workspace_id: orgA.id },
1635
+ * },
1636
+ * });
1637
+ */
1638
+ async authorization(opts) {
1639
+ if (!opts.rls && !opts.action) {
1640
+ throw new Error(
1641
+ "[PROOF_FAIL] bad_options: assert.authorization requires at least one of `rls` or `action`\n file: src/playwright/assert.ts\n suggestion: Pass an `rls` probe for DB-layer denial or an `action` probe for action-layer denial (or both)."
1642
+ );
1643
+ }
1644
+ if (opts.action && !opts.page) {
1645
+ throw new Error(
1646
+ "[PROOF_FAIL] bad_options: assert.authorization's `action` probe requires a Playwright `page`\n file: src/playwright/assert.ts\n suggestion: Destructure `page` from the test callback and pass it in."
1647
+ );
1648
+ }
1649
+ const actorCreds = describeActor(opts.actor);
1650
+ if (opts.rls) {
1651
+ await runRlsProbe(opts.rls, actorCreds);
1652
+ }
1653
+ if (opts.action && opts.page) {
1654
+ await runActionProbe(opts.action, actorCreds, opts.page);
1655
+ }
1656
+ },
1657
+ /**
1658
+ * Prove that a registered action succeeds for an actor who should be allowed.
1659
+ *
1660
+ * This is the positive-control companion to action-level refusal probes.
1661
+ * Assertions default to `role: "control"`, so a working happy path cannot
1662
+ * satisfy the mission's primary requirement by itself. Override `role` only
1663
+ * when success is the primary invariant being proved.
1664
+ *
1665
+ * Returns the action's data so the caller can make domain-specific checks.
1666
+ */
1667
+ async actionSucceeds(opts) {
1668
+ return runSuccessfulActionProbe(opts);
1669
+ },
1670
+ /**
1671
+ * Assert on an HTTP response from a page route. Streaming-RSC aware: when
1672
+ * only body markers (`mustContain` / `mustNotContain`) are provided, the
1673
+ * helper does NOT check the status code — crucial for routes guarded by
1674
+ * a server component's `notFound()` call that Next.js can only signal via
1675
+ * an embedded NOT_FOUND marker because headers were already committed as
1676
+ * 200 by `loading.tsx`. See
1677
+ * [proof-authoring.mdc](mdc:.cursor/rules/proof-authoring.mdc) for the
1678
+ * streaming-RSC gotcha in full.
1679
+ *
1680
+ * Defaults:
1681
+ * - method: "GET"
1682
+ * - maxRedirects: 0 (intentional — silent redirect-following has caused
1683
+ * real false passes on auth-gated pages)
1684
+ *
1685
+ * Records one TraceAssertion with caller-provided `kind` and `target`.
1686
+ * Returns the raw Playwright APIResponse so callers can run additional
1687
+ * custom checks (headers, etc.) after.
1688
+ *
1689
+ * @example
1690
+ * // Streaming-RSC page: assert body markers, not status code
1691
+ * await assert.httpResponse({
1692
+ * page,
1693
+ * path: "/secret-admin",
1694
+ * as: "member",
1695
+ * kind: "authorization",
1696
+ * target: "/secret-admin",
1697
+ * expect: {
1698
+ * mustContain: [/Sorry, we could not find this page/i],
1699
+ * mustNotContain: [/Here you can see what.?s happening on your platform/i],
1700
+ * },
1701
+ * });
1702
+ *
1703
+ * @example
1704
+ * // Flat API route: assert status
1705
+ * await assert.httpResponse({
1706
+ * page,
1707
+ * path: "/api/public-ping",
1708
+ * kind: "happy_path",
1709
+ * expect: { status: 200 },
1710
+ * });
1711
+ */
1712
+ async httpResponse(opts) {
1713
+ const target = opts.target ?? opts.path;
1714
+ const method = opts.method ?? "GET";
1715
+ const role = opts.role ?? "primary";
1716
+ if (opts.as !== void 0) {
1717
+ if (typeof opts.as === "string") {
1718
+ await actAsUser.login(opts.page, opts.as);
1719
+ } else {
1720
+ await actAsUser.loginAs(opts.page, opts.as.email, opts.as.password);
1721
+ }
1722
+ }
1723
+ const requestCtx = opts.page.request;
1724
+ const requestOpts = { maxRedirects: 0 };
1725
+ let response;
1726
+ switch (method) {
1727
+ case "GET":
1728
+ response = await requestCtx.get(opts.path, requestOpts);
1729
+ break;
1730
+ case "POST":
1731
+ response = await requestCtx.post(opts.path, requestOpts);
1732
+ break;
1733
+ case "PUT":
1734
+ response = await requestCtx.put(opts.path, requestOpts);
1735
+ break;
1736
+ case "PATCH":
1737
+ response = await requestCtx.patch(opts.path, requestOpts);
1738
+ break;
1739
+ case "DELETE":
1740
+ response = await requestCtx.delete(opts.path, requestOpts);
1741
+ break;
1742
+ }
1743
+ const status = response.status();
1744
+ let body = null;
1745
+ const failures = [];
1746
+ if (opts.expect.status !== void 0 && status !== opts.expect.status) {
1747
+ failures.push(
1748
+ `status mismatch: expected ${opts.expect.status}, got ${status}`
1749
+ );
1750
+ }
1751
+ if (opts.expect.mustContain && opts.expect.mustContain.length > 0 || opts.expect.mustNotContain && opts.expect.mustNotContain.length > 0) {
1752
+ body = await response.text();
1753
+ for (const re of opts.expect.mustContain ?? []) {
1754
+ if (!re.test(body)) {
1755
+ failures.push(`mustContain not matched: ${re}`);
1756
+ }
1757
+ }
1758
+ for (const re of opts.expect.mustNotContain ?? []) {
1759
+ if (re.test(body)) {
1760
+ failures.push(
1761
+ `mustNotContain matched (should have been absent): ${re}`
1762
+ );
1763
+ }
1764
+ }
1765
+ }
1766
+ if (failures.length > 0) {
1767
+ recordAssertion({
1768
+ kind: opts.kind,
1769
+ target,
1770
+ operation: "request",
1771
+ passed: false,
1772
+ role,
1773
+ detail: `${method} ${opts.path} -> ${status}; failures: ${failures.join("; ")}`
1774
+ });
1775
+ throw new Error(
1776
+ `[PROOF_FAIL] ${opts.kind}: response from ${method} ${opts.path} did not match expectations
1777
+ status: ${status}
1778
+ failures:
1779
+ ${failures.map((f) => ` - ${f}`).join("\n")}
1780
+ file: src/playwright/assert.ts
1781
+ suggestion: ${opts.expect.mustContain || opts.expect.mustNotContain ? "For streaming RSC routes, assert on body markers rather than status. See .cursor/rules/proof-authoring.mdc." : "Check the route handler or page component and confirm the expected response shape."}`
1782
+ );
1783
+ }
1784
+ recordAssertion({
1785
+ kind: opts.kind,
1786
+ target,
1787
+ operation: "request",
1788
+ passed: true,
1789
+ role,
1790
+ detail: `${method} ${opts.path} -> ${status}${opts.expect.mustContain || opts.expect.mustNotContain ? " (body markers matched)" : ""}`
1791
+ });
1792
+ return response;
1793
+ }
1794
+ };
1795
+ function withHelperProvenance(name, fn) {
1796
+ return (...args) => withAssertionProvenance(name, () => fn(...args));
1797
+ }
1798
+ var assert = {
1799
+ tenantIsolation: withHelperProvenance(
1800
+ "assert.tenantIsolation",
1801
+ assertMethods.tenantIsolation
1802
+ ),
1803
+ authorization: withHelperProvenance(
1804
+ "assert.authorization",
1805
+ assertMethods.authorization
1806
+ ),
1807
+ // Cast preserves the <TData> generic that the wrapper's closed-over
1808
+ // signature would otherwise collapse to `unknown`.
1809
+ actionSucceeds: withHelperProvenance(
1810
+ "assert.actionSucceeds",
1811
+ assertMethods.actionSucceeds
1812
+ ),
1813
+ httpResponse: withHelperProvenance(
1814
+ "assert.httpResponse",
1815
+ assertMethods.httpResponse
1816
+ )
1817
+ };
1818
+ export {
1819
+ actAsUser,
1820
+ assert,
1821
+ recordAssertion,
1822
+ trace
1823
+ };
1824
+ //# sourceMappingURL=playwright.js.map