@rehearsal-db/core 0.1.0-beta.7 → 0.1.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/COMPATIBILITY.md +15 -3
  3. package/README.md +31 -13
  4. package/docs/README.md +29 -0
  5. package/docs/adapters.md +15 -6
  6. package/docs/architecture.md +58 -0
  7. package/docs/baselines.md +22 -1
  8. package/docs/commands.md +81 -14
  9. package/docs/configuration.md +144 -11
  10. package/docs/getting-started.md +12 -2
  11. package/docs/glossary.md +4 -0
  12. package/docs/production-source.md +176 -33
  13. package/docs/roadmap.md +30 -28
  14. package/docs/runtime-policies.md +191 -0
  15. package/docs/sanitization.md +24 -8
  16. package/docs/security-model.md +29 -8
  17. package/docs/standalone-workflow.md +107 -0
  18. package/docs/troubleshooting.md +8 -0
  19. package/package.json +25 -22
  20. package/scripts/runtime/manage_database.mjs +18 -0
  21. package/src/README.md +17 -0
  22. package/src/application/session.mjs +313 -0
  23. package/{scripts/lib/rehearsal/baseline_artifact.mjs → src/baseline/artifact.mjs} +110 -14
  24. package/{scripts/lib/rehearsal/baseline_builder.mjs → src/baseline/builder.mjs} +8 -4
  25. package/{scripts/lib/rehearsal/baseline_preparation.mjs → src/baseline/preparation.mjs} +11 -4
  26. package/src/baseline/privacy_engine.mjs +413 -0
  27. package/{scripts/lib/rehearsal → src/baseline}/sanitization_policy.mjs +27 -7
  28. package/{scripts/lib/rehearsal → src/baseline}/schema_snapshot.mjs +1 -1
  29. package/src/cli/arguments.mjs +120 -0
  30. package/src/cli/guided.mjs +807 -0
  31. package/src/cli/rehearsal.mjs +933 -0
  32. package/src/cli/renderers.mjs +584 -0
  33. package/src/cli/runtime_commands.mjs +553 -0
  34. package/src/cli/source_commands.mjs +326 -0
  35. package/src/cli/terminal.mjs +275 -0
  36. package/src/identity/claim.mjs +975 -0
  37. package/src/identity/storage.mjs +165 -0
  38. package/{scripts/lib/rehearsal → src/project}/configuration.d.mts +34 -0
  39. package/{scripts/lib/rehearsal → src/project}/configuration.mjs +353 -3
  40. package/{scripts/lib/rehearsal → src/project}/setup.mjs +1 -1
  41. package/{scripts/lib/rehearsal → src/project}/support_report.mjs +5 -2
  42. package/{scripts/lib/rehearsal → src/runtime}/cleanup.mjs +3 -3
  43. package/{scripts/lib/rehearsal → src/runtime}/plan.mjs +5 -5
  44. package/src/runtime/policy.mjs +438 -0
  45. package/{scripts/lib/rehearsal/runtime_restore.mjs → src/runtime/restore.mjs} +40 -25
  46. package/src/runtime/topology.mjs +177 -0
  47. package/{scripts/lib/rehearsal → src/shared}/diagnostics.mjs +1 -1
  48. package/src/shared/operation_guard.mjs +162 -0
  49. package/{scripts/lib/rehearsal → src/shared}/process_environment.mjs +5 -1
  50. package/src/source/access.mjs +578 -0
  51. package/src/source/asset_transfer.mjs +177 -0
  52. package/src/source/baseline.mjs +446 -0
  53. package/src/source/postgresql_access.mjs +480 -0
  54. package/{scripts/lib/runtime/postgresql_runtime.mjs → src/targets/postgresql.mjs} +49 -13
  55. package/{scripts/lib/runtime/supabase_runtime.mjs → src/targets/supabase.mjs} +54 -16
  56. package/{scripts/lib/environment/local_supabase.mjs → src/targets/supabase_environment.mjs} +52 -20
  57. package/{scripts/lib/runtime/runtime_target.mjs → src/targets/target.mjs} +27 -2
  58. package/scripts/operations/database/manage_rehearsal_database.mjs +0 -11
  59. package/scripts/operations/rehearsal/rehearsal_cli.mjs +0 -2166
  60. /package/{scripts/lib/rehearsal → src/baseline}/input_discovery.mjs +0 -0
  61. /package/{scripts/lib/rehearsal → src/baseline}/policy_review.mjs +0 -0
  62. /package/{scripts/lib/rehearsal → src/runtime}/migration_history.mjs +0 -0
  63. /package/{scripts/lib/rehearsal → src/runtime}/service_environment.mjs +0 -0
  64. /package/{scripts/lib/rehearsal → src/shared}/human_output.mjs +0 -0
@@ -0,0 +1,975 @@
1
+ /** Associate a reviewed copied owner graph with one verified local Auth identity. */
2
+
3
+ import { createHash } from "node:crypto";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import pg from "pg";
6
+ import { IDENTITY_CLAIM_RECEIPT_SQL } from "../runtime/policy.mjs";
7
+
8
+ const { Client } = pg;
9
+ const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
10
+ const LABEL = /^[a-z0-9][a-z0-9-]{1,62}$/u;
11
+ const UUID =
12
+ /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/iu;
13
+ const SHA256 = /^[a-f0-9]{64}$/u;
14
+ const ENVIRONMENT_KEY = /^[A-Z][A-Z0-9_]*$/u;
15
+
16
+ const isObject = (value) =>
17
+ value !== null && typeof value === "object" && !Array.isArray(value);
18
+ const id = (value, label) => {
19
+ if (!IDENTIFIER.test(value ?? ""))
20
+ throw new Error(`${label} is not a safe identifier.`);
21
+ return value;
22
+ };
23
+ const qid = (value) => `"${id(value, "SQL identifier")}"`;
24
+ const canonical = (value) => `${JSON.stringify(value, null, "\t")}\n`;
25
+ const hash = (value) => createHash("sha256").update(value).digest("hex");
26
+ const keys = (value, allowed, label) => {
27
+ for (const key of Object.keys(value ?? {})) {
28
+ if (!allowed.includes(key)) throw new Error(`${label}.${key} is unknown.`);
29
+ }
30
+ };
31
+ const relation = (value, label) => {
32
+ if (!isObject(value)) throw new Error(`${label} must be an object.`);
33
+ keys(value, ["schema", "table"], label);
34
+ return Object.freeze({
35
+ schema: id(value.schema, `${label}.schema`),
36
+ table: id(value.table, `${label}.table`),
37
+ });
38
+ };
39
+ const jsonValue = (value, label) => {
40
+ let serialized;
41
+ try {
42
+ serialized = JSON.stringify(value);
43
+ } catch {
44
+ throw new Error(`${label} must be JSON-compatible.`);
45
+ }
46
+ if (serialized === undefined) {
47
+ throw new Error(`${label} must be JSON-compatible.`);
48
+ }
49
+ return JSON.parse(serialized);
50
+ };
51
+ const defaultMatcher = (value, label) => {
52
+ if (isObject(value) && value.kind === "pattern") {
53
+ keys(value, ["kind", "pattern"], label);
54
+ if (
55
+ typeof value.pattern !== "string" ||
56
+ value.pattern.length > 256 ||
57
+ !value.pattern.startsWith("^") ||
58
+ !value.pattern.endsWith("$")
59
+ ) {
60
+ throw new Error(
61
+ `${label}.pattern must be anchored and at most 256 characters.`,
62
+ );
63
+ }
64
+ try {
65
+ new RegExp(value.pattern, "u");
66
+ } catch {
67
+ throw new Error(`${label}.pattern is invalid.`);
68
+ }
69
+ return Object.freeze({ kind: "pattern", pattern: value.pattern });
70
+ }
71
+ if (isObject(value) && value.kind === "exact") {
72
+ keys(value, ["kind", "value"], label);
73
+ return Object.freeze({
74
+ kind: "exact",
75
+ value: jsonValue(value.value, `${label}.value`),
76
+ });
77
+ }
78
+ return Object.freeze({ kind: "exact", value: jsonValue(value, label) });
79
+ };
80
+
81
+ export const validateIdentityPolicy = (policy) => {
82
+ if (
83
+ !isObject(policy) ||
84
+ policy.identityVersion !== 1 ||
85
+ !Array.isArray(policy.identities)
86
+ ) {
87
+ throw new Error(
88
+ "Identity policy must use identityVersion 1 and list identities.",
89
+ );
90
+ }
91
+ for (const key of Object.keys(policy)) {
92
+ if (!["identityVersion", "identities"].includes(key)) {
93
+ throw new Error(`identityPolicy.${key} is unknown.`);
94
+ }
95
+ }
96
+ const names = new Set();
97
+ const identities = policy.identities.map((entry, index) => {
98
+ const label = `identityPolicy.identities[${index}]`;
99
+ for (const key of Object.keys(entry ?? {})) {
100
+ if (
101
+ ![
102
+ "name",
103
+ "provider",
104
+ "emailEnvironmentVariable",
105
+ "approvedEmailSha256",
106
+ "placeholderUserId",
107
+ "references",
108
+ "jsonReferences",
109
+ "signupDefaults",
110
+ "pathReferences",
111
+ "assets",
112
+ "claims",
113
+ "tokenHook",
114
+ ].includes(key)
115
+ ) {
116
+ throw new Error(`${label}.${key} is unknown.`);
117
+ }
118
+ }
119
+ if (!LABEL.test(entry.name ?? ""))
120
+ throw new Error(`${label}.name is invalid.`);
121
+ const name = entry.name;
122
+ if (names.has(name)) throw new Error(`Identity policy duplicates ${name}.`);
123
+ names.add(name);
124
+ if (!["google", "email"].includes(entry.provider))
125
+ throw new Error(`${label}.provider is unsupported.`);
126
+ if (!ENVIRONMENT_KEY.test(entry.emailEnvironmentVariable ?? "")) {
127
+ throw new Error(`${label}.emailEnvironmentVariable is invalid.`);
128
+ }
129
+ if (!SHA256.test(entry.approvedEmailSha256 ?? "")) {
130
+ throw new Error(`${label}.approvedEmailSha256 must be a SHA-256.`);
131
+ }
132
+ if (!UUID.test(entry.placeholderUserId ?? ""))
133
+ throw new Error(`${label}.placeholderUserId is invalid.`);
134
+ const references = (entry.references ?? []).map(
135
+ (reference, referenceIndex) => {
136
+ const referenceLabel = `${label}.references[${referenceIndex}]`;
137
+ if (!isObject(reference))
138
+ throw new Error(`${referenceLabel} must be an object.`);
139
+ keys(
140
+ reference,
141
+ ["schema", "table", "column", "required", "strategy"],
142
+ referenceLabel,
143
+ );
144
+ if (
145
+ reference.required !== undefined &&
146
+ typeof reference.required !== "boolean"
147
+ ) {
148
+ throw new Error(`${referenceLabel}.required must be boolean.`);
149
+ }
150
+ if (
151
+ reference.strategy !== undefined &&
152
+ !["transfer", "preserve-audit"].includes(reference.strategy)
153
+ ) {
154
+ throw new Error(
155
+ `${referenceLabel}.strategy must be transfer or preserve-audit.`,
156
+ );
157
+ }
158
+ return Object.freeze({
159
+ schema: id(reference.schema, `${referenceLabel}.schema`),
160
+ table: id(reference.table, `${referenceLabel}.table`),
161
+ column: id(reference.column, `${referenceLabel}.column`),
162
+ required: reference.required === true,
163
+ strategy: reference.strategy ?? "transfer",
164
+ });
165
+ },
166
+ );
167
+ const jsonReferences = (entry.jsonReferences ?? []).map(
168
+ (reference, referenceIndex) => {
169
+ const referenceLabel = `${label}.jsonReferences[${referenceIndex}]`;
170
+ if (!isObject(reference))
171
+ throw new Error(`${referenceLabel} must be an object.`);
172
+ keys(reference, ["schema", "table", "column", "path"], referenceLabel);
173
+ if (!Array.isArray(reference.path) || reference.path.length === 0) {
174
+ throw new Error(`${referenceLabel}.path is invalid.`);
175
+ }
176
+ return {
177
+ schema: id(reference.schema, `${referenceLabel}.schema`),
178
+ table: id(reference.table, `${referenceLabel}.table`),
179
+ column: id(reference.column, `${referenceLabel}.column`),
180
+ path: reference.path.map((part, pathIndex) =>
181
+ id(part, `${referenceLabel}.path[${pathIndex}]`),
182
+ ),
183
+ };
184
+ },
185
+ );
186
+ const signupDefaults = (entry.signupDefaults ?? []).map(
187
+ (declaration, declarationIndex) => {
188
+ const declarationLabel = `${label}.signupDefaults[${declarationIndex}]`;
189
+ if (!isObject(declaration))
190
+ throw new Error(`${declarationLabel} must be an object.`);
191
+ keys(
192
+ declaration,
193
+ ["table", "identityColumn", "ignoredColumns", "values"],
194
+ declarationLabel,
195
+ );
196
+ const table = relation(declaration.table, `${declarationLabel}.table`);
197
+ const identityColumn = id(
198
+ declaration.identityColumn,
199
+ `${declarationLabel}.identityColumn`,
200
+ );
201
+ if (!isObject(declaration.values)) {
202
+ throw new Error(`${declarationLabel}.values must be an object.`);
203
+ }
204
+ const values = Object.fromEntries(
205
+ Object.entries(declaration.values).map(([column, value]) => [
206
+ id(column, `${declarationLabel}.values key`),
207
+ defaultMatcher(value, `${declarationLabel}.values.${column}`),
208
+ ]),
209
+ );
210
+ if (Object.keys(values).length === 0) {
211
+ throw new Error(`${declarationLabel}.values must not be empty.`);
212
+ }
213
+ const ignoredColumns = (declaration.ignoredColumns ?? []).map(
214
+ (column, columnIndex) =>
215
+ id(column, `${declarationLabel}.ignoredColumns[${columnIndex}]`),
216
+ );
217
+ if (
218
+ new Set([identityColumn, ...ignoredColumns, ...Object.keys(values)])
219
+ .size !==
220
+ 1 + ignoredColumns.length + Object.keys(values).length
221
+ ) {
222
+ throw new Error(`${declarationLabel} repeats a column.`);
223
+ }
224
+ if (
225
+ !references.some(
226
+ (reference) =>
227
+ reference.schema === table.schema &&
228
+ reference.table === table.table &&
229
+ reference.column === identityColumn,
230
+ )
231
+ ) {
232
+ throw new Error(
233
+ `${declarationLabel} must match a declared relational reference.`,
234
+ );
235
+ }
236
+ return Object.freeze({
237
+ table,
238
+ identityColumn,
239
+ ignoredColumns: Object.freeze(ignoredColumns),
240
+ values: Object.freeze(values),
241
+ });
242
+ },
243
+ );
244
+ const pathReferences = (entry.pathReferences ?? []).map(
245
+ (reference, referenceIndex) => {
246
+ const referenceLabel = `${label}.pathReferences[${referenceIndex}]`;
247
+ if (!isObject(reference))
248
+ throw new Error(`${referenceLabel} must be an object.`);
249
+ keys(
250
+ reference,
251
+ ["schema", "table", "column", "valueType"],
252
+ referenceLabel,
253
+ );
254
+ if (!["text", "jsonb"].includes(reference.valueType)) {
255
+ throw new Error(`${referenceLabel}.valueType is unsupported.`);
256
+ }
257
+ return Object.freeze({
258
+ schema: id(reference.schema, `${referenceLabel}.schema`),
259
+ table: id(reference.table, `${referenceLabel}.table`),
260
+ column: id(reference.column, `${referenceLabel}.column`),
261
+ valueType: reference.valueType,
262
+ });
263
+ },
264
+ );
265
+ const assets = (entry.assets ?? []).map((asset, assetIndex) => {
266
+ const assetLabel = `${label}.assets[${assetIndex}]`;
267
+ if (!isObject(asset)) throw new Error(`${assetLabel} must be an object.`);
268
+ keys(asset, ["bucket", "prefix", "rewritePath"], assetLabel);
269
+ if (!/^[a-z0-9][a-z0-9.-]{0,99}$/u.test(asset.bucket ?? "")) {
270
+ throw new Error(`${assetLabel}.bucket is invalid.`);
271
+ }
272
+ if (
273
+ typeof asset.prefix !== "string" ||
274
+ !asset.prefix ||
275
+ asset.prefix.startsWith("/") ||
276
+ asset.prefix
277
+ .replace(/\/$/u, "")
278
+ .split("/")
279
+ .some(
280
+ (part) =>
281
+ !part || part === "." || part === ".." || /[%_]/u.test(part),
282
+ )
283
+ ) {
284
+ throw new Error(`${assetLabel}.prefix is invalid.`);
285
+ }
286
+ if (
287
+ asset.rewritePath !== undefined &&
288
+ typeof asset.rewritePath !== "boolean"
289
+ ) {
290
+ throw new Error(`${assetLabel}.rewritePath must be boolean.`);
291
+ }
292
+ return Object.freeze({
293
+ bucket: asset.bucket,
294
+ prefix: asset.prefix,
295
+ rewritePath: asset.rewritePath === true,
296
+ });
297
+ });
298
+ for (const asset of assets) {
299
+ if (
300
+ asset.rewritePath &&
301
+ asset.prefix !== `${entry.placeholderUserId.toLowerCase()}/`
302
+ ) {
303
+ throw new Error(
304
+ `${label}.assets rewritePath requires the placeholder UUID prefix.`,
305
+ );
306
+ }
307
+ }
308
+ if (!isObject(entry.claims ?? {}))
309
+ throw new Error(`${label}.claims must be an object.`);
310
+ JSON.stringify(entry.claims);
311
+ let tokenHook = null;
312
+ if (entry.tokenHook != null) {
313
+ const hookLabel = `${label}.tokenHook`;
314
+ if (!isObject(entry.tokenHook))
315
+ throw new Error(`${hookLabel} must be an object.`);
316
+ keys(entry.tokenHook, ["function", "expectedClaims"], hookLabel);
317
+ if (!isObject(entry.tokenHook.function))
318
+ throw new Error(`${hookLabel}.function must be an object.`);
319
+ keys(
320
+ entry.tokenHook.function,
321
+ ["schema", "name"],
322
+ `${hookLabel}.function`,
323
+ );
324
+ if (!isObject(entry.tokenHook.expectedClaims))
325
+ throw new Error(`${hookLabel}.expectedClaims must be an object.`);
326
+ tokenHook = Object.freeze({
327
+ function: Object.freeze({
328
+ schema: id(
329
+ entry.tokenHook.function.schema,
330
+ `${hookLabel}.function.schema`,
331
+ ),
332
+ name: id(entry.tokenHook.function.name, `${hookLabel}.function.name`),
333
+ }),
334
+ expectedClaims: Object.freeze(
335
+ jsonValue(
336
+ entry.tokenHook.expectedClaims,
337
+ `${hookLabel}.expectedClaims`,
338
+ ),
339
+ ),
340
+ });
341
+ }
342
+ return Object.freeze({
343
+ name,
344
+ provider: entry.provider,
345
+ emailEnvironmentVariable: entry.emailEnvironmentVariable,
346
+ approvedEmailSha256: entry.approvedEmailSha256,
347
+ placeholderUserId: entry.placeholderUserId.toLowerCase(),
348
+ references: Object.freeze(references),
349
+ jsonReferences: Object.freeze(jsonReferences),
350
+ signupDefaults: Object.freeze(signupDefaults),
351
+ pathReferences: Object.freeze(pathReferences),
352
+ assets: Object.freeze(assets),
353
+ claims: Object.freeze(entry.claims ?? {}),
354
+ tokenHook,
355
+ });
356
+ });
357
+ return Object.freeze({
358
+ identityVersion: 1,
359
+ identities: Object.freeze(identities),
360
+ });
361
+ };
362
+
363
+ export const createIdentityClaimPlan = ({
364
+ policy,
365
+ name,
366
+ environment = process.env,
367
+ }) => {
368
+ const normalized = validateIdentityPolicy(policy);
369
+ const identity = normalized.identities.find((entry) => entry.name === name);
370
+ if (!identity) throw new Error(`Identity policy does not declare ${name}.`);
371
+ const email = environment[identity.emailEnvironmentVariable]
372
+ ?.trim()
373
+ .toLowerCase();
374
+ if (!email || hash(email) !== identity.approvedEmailSha256) {
375
+ throw new Error(
376
+ "Local identity email does not match the reviewed receipt.",
377
+ );
378
+ }
379
+ const review = {
380
+ planVersion: 2,
381
+ operation: "claim-local-identity",
382
+ name: identity.name,
383
+ provider: identity.provider,
384
+ approvedEmailSha256: identity.approvedEmailSha256,
385
+ placeholderUserId: identity.placeholderUserId,
386
+ references: identity.references,
387
+ jsonReferences: identity.jsonReferences,
388
+ signupDefaults: identity.signupDefaults.map((declaration) => ({
389
+ table: declaration.table,
390
+ identityColumn: declaration.identityColumn,
391
+ ignoredColumns: declaration.ignoredColumns,
392
+ valuesSha256: hash(canonical(declaration.values)),
393
+ })),
394
+ pathReferences: identity.pathReferences,
395
+ assets: identity.assets,
396
+ claimKeys: Object.keys(identity.claims).sort(),
397
+ claimsSha256: hash(canonical(identity.claims)),
398
+ tokenHook: identity.tokenHook
399
+ ? {
400
+ function: identity.tokenHook.function,
401
+ expectedClaimsSha256: hash(
402
+ canonical(identity.tokenHook.expectedClaims),
403
+ ),
404
+ }
405
+ : null,
406
+ };
407
+ return Object.freeze({
408
+ identity,
409
+ email,
410
+ review: Object.freeze(review),
411
+ digest: hash(canonical(review)),
412
+ });
413
+ };
414
+
415
+ const inspectDeclaredDefault = ({ row, declaration }) => {
416
+ if (!isObject(row)) {
417
+ return { matches: false, actualColumns: [], mismatchedColumns: [] };
418
+ }
419
+ const comparable = { ...row };
420
+ delete comparable[declaration.identityColumn];
421
+ for (const column of declaration.ignoredColumns) delete comparable[column];
422
+ const actualColumns = Object.keys(comparable).sort();
423
+ const declaredColumns = Object.keys(declaration.values).sort();
424
+ const mismatchedColumns = Object.entries(declaration.values)
425
+ .filter(([column, matcher]) =>
426
+ matcher.kind === "pattern"
427
+ ? typeof comparable[column] !== "string" ||
428
+ !new RegExp(matcher.pattern, "u").test(comparable[column])
429
+ : !isDeepStrictEqual(comparable[column], matcher.value),
430
+ )
431
+ .map(([column]) => column)
432
+ .sort();
433
+ return {
434
+ matches:
435
+ isDeepStrictEqual(actualColumns, declaredColumns) &&
436
+ mismatchedColumns.length === 0,
437
+ actualColumns,
438
+ mismatchedColumns,
439
+ };
440
+ };
441
+
442
+ const containsJson = (actual, expected) => {
443
+ if (!isObject(expected)) return isDeepStrictEqual(actual, expected);
444
+ if (!isObject(actual)) return false;
445
+ return Object.entries(expected).every(([key, value]) =>
446
+ containsJson(actual[key], value),
447
+ );
448
+ };
449
+
450
+ const valueKind = (value) =>
451
+ value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
452
+
453
+ const discoverPublicAuthReferences = async (client) => {
454
+ const result = await client.query(`select distinct
455
+ source_namespace.nspname as schema,
456
+ source_table.relname as table,
457
+ source_column.attname as column
458
+ from pg_constraint constraint_record
459
+ join pg_class source_table on source_table.oid = constraint_record.conrelid
460
+ join pg_namespace source_namespace on source_namespace.oid = source_table.relnamespace
461
+ join unnest(constraint_record.conkey) with ordinality source_key(attnum, position) on true
462
+ join unnest(constraint_record.confkey) with ordinality target_key(attnum, position)
463
+ on target_key.position = source_key.position
464
+ join pg_attribute source_column
465
+ on source_column.attrelid = constraint_record.conrelid
466
+ and source_column.attnum = source_key.attnum
467
+ join pg_attribute target_column
468
+ on target_column.attrelid = constraint_record.confrelid
469
+ and target_column.attnum = target_key.attnum
470
+ where constraint_record.contype = 'f'
471
+ and constraint_record.confrelid = 'auth.users'::regclass
472
+ and target_column.attname = 'id'
473
+ and source_namespace.nspname = 'public'`);
474
+ return result.rows.map((reference, index) => ({
475
+ schema: id(reference.schema, `discoveredReference[${index}].schema`),
476
+ table: id(reference.table, `discoveredReference[${index}].table`),
477
+ column: id(reference.column, `discoveredReference[${index}].column`),
478
+ }));
479
+ };
480
+
481
+ const countIdentityRows = async ({ client, reference, userId }) => {
482
+ const result = await client.query(
483
+ `select count(*)::integer as count
484
+ from ${qid(reference.schema)}.${qid(reference.table)}
485
+ where ${qid(reference.column)} = $1::uuid`,
486
+ [userId],
487
+ );
488
+ const count = Number(result.rows[0]?.count);
489
+ if (!Number.isSafeInteger(count) || count < 0) {
490
+ throw new Error("A local identity reference returned an invalid count.");
491
+ }
492
+ return count;
493
+ };
494
+
495
+ export const applyIdentityClaim = async ({
496
+ plan,
497
+ confirmation,
498
+ connectionString,
499
+ clientFactory,
500
+ storageTransfer,
501
+ }) => {
502
+ if (confirmation !== plan.digest) {
503
+ throw new Error(
504
+ `Identity claim confirmation does not match. Expected ${plan.digest}.`,
505
+ );
506
+ }
507
+ const target = new URL(connectionString);
508
+ if (!["127.0.0.1", "::1", "localhost"].includes(target.hostname)) {
509
+ throw new Error(
510
+ "Identity claims may target only the local Rehearsal database.",
511
+ );
512
+ }
513
+ const client = clientFactory
514
+ ? await clientFactory(connectionString)
515
+ : new Client({
516
+ connectionString,
517
+ application_name: "rehearsal-identity-claim",
518
+ });
519
+ await client.connect?.();
520
+ let removedSignupDefaults = 0;
521
+ let tokenHookVerified = false;
522
+ let placeholderRetainedForAudit = false;
523
+ const stagedStorageCopies = [];
524
+ let committed = false;
525
+ let storageObjectsTransferred = 0;
526
+ try {
527
+ await client.query("begin");
528
+ const match = await client.query(
529
+ `select u.id::text
530
+ from auth.users u
531
+ join auth.identities i on i.user_id = u.id
532
+ where lower(coalesce(i.identity_data->>'email', u.email)) = $1
533
+ and i.provider = $2
534
+ and (
535
+ coalesce((i.identity_data->>'email_verified')::boolean, false)
536
+ or u.email_confirmed_at is not null
537
+ )
538
+ for update of u`,
539
+ [plan.email, plan.identity.provider],
540
+ );
541
+ if (match.rows.length !== 1) {
542
+ throw new Error(
543
+ match.rows.length === 0
544
+ ? "No single verified local provider identity matches the reviewed email."
545
+ : "More than one local provider identity matches the reviewed email.",
546
+ );
547
+ }
548
+ const localUserId = match.rows[0].id;
549
+ if (!UUID.test(localUserId))
550
+ throw new Error("Matched local identity is invalid.");
551
+ const placeholderUser = await client.query(
552
+ "select 1 from auth.users where id = $1::uuid limit 1",
553
+ [plan.identity.placeholderUserId],
554
+ );
555
+ const placeholderReferenceCounts =
556
+ localUserId === plan.identity.placeholderUserId
557
+ ? []
558
+ : await Promise.all(
559
+ plan.identity.references.map((reference) =>
560
+ countIdentityRows({
561
+ client,
562
+ reference,
563
+ userId: plan.identity.placeholderUserId,
564
+ }),
565
+ ),
566
+ );
567
+ const transferReferences = plan.identity.references.filter(
568
+ (reference) => reference.strategy === "transfer",
569
+ );
570
+ const preservedReferences = plan.identity.references.filter(
571
+ (reference) => reference.strategy === "preserve-audit",
572
+ );
573
+ const transferPlaceholderCounts = transferReferences.map((reference) => {
574
+ const index = plan.identity.references.indexOf(reference);
575
+ return placeholderReferenceCounts[index] ?? 0;
576
+ });
577
+ const transferLocalCounts =
578
+ localUserId === plan.identity.placeholderUserId ||
579
+ preservedReferences.length === 0
580
+ ? []
581
+ : await Promise.all(
582
+ transferReferences.map((reference) =>
583
+ countIdentityRows({ client, reference, userId: localUserId }),
584
+ ),
585
+ );
586
+ const alreadyClaimed =
587
+ localUserId !== plan.identity.placeholderUserId &&
588
+ transferPlaceholderCounts.every((count) => count === 0) &&
589
+ (preservedReferences.length > 0
590
+ ? transferLocalCounts.some((count) => count > 0)
591
+ : placeholderUser.rows.length === 0);
592
+ const publicAuthReferences =
593
+ localUserId === plan.identity.placeholderUserId
594
+ ? []
595
+ : await discoverPublicAuthReferences(client);
596
+ if (localUserId !== plan.identity.placeholderUserId) {
597
+ const placeholderIdentity = await client.query(
598
+ "select 1 from auth.identities where user_id = $1::uuid limit 1",
599
+ [plan.identity.placeholderUserId],
600
+ );
601
+ if (placeholderIdentity.rows.length > 0) {
602
+ throw new Error(
603
+ "The copied placeholder already belongs to a conflicting local identity.",
604
+ );
605
+ }
606
+ }
607
+ for (const reference of alreadyClaimed ? [] : publicAuthReferences) {
608
+ const declared = plan.identity.references.find(
609
+ (candidate) =>
610
+ candidate.schema === reference.schema &&
611
+ candidate.table === reference.table &&
612
+ candidate.column === reference.column,
613
+ );
614
+ if (declared?.strategy === "preserve-audit") continue;
615
+ const localRows = await countIdentityRows({
616
+ client,
617
+ reference,
618
+ userId: localUserId,
619
+ });
620
+ if (localRows === 0) continue;
621
+ const declaredDefault = plan.identity.signupDefaults.find(
622
+ (declaration) =>
623
+ declaration.table.schema === reference.schema &&
624
+ declaration.table.table === reference.table &&
625
+ declaration.identityColumn === reference.column,
626
+ );
627
+ if (!declaredDefault) {
628
+ throw new Error(
629
+ "The local account already contains independent application data; nothing was changed.",
630
+ );
631
+ }
632
+ }
633
+ if (localUserId !== plan.identity.placeholderUserId && !alreadyClaimed) {
634
+ for (const declaration of plan.identity.signupDefaults) {
635
+ const target = `${qid(declaration.table.schema)}.${qid(declaration.table.table)}`;
636
+ const rows = await client.query(
637
+ `select candidate.${qid(declaration.identityColumn)}::text as identity,
638
+ to_jsonb(candidate) as row
639
+ from ${target} candidate
640
+ where candidate.${qid(declaration.identityColumn)} in ($1::uuid, $2::uuid)
641
+ for update`,
642
+ [localUserId, plan.identity.placeholderUserId],
643
+ );
644
+ const localRows = rows.rows.filter(
645
+ (row) => row.identity === localUserId,
646
+ );
647
+ const placeholderRows = rows.rows.filter(
648
+ (row) => row.identity === plan.identity.placeholderUserId,
649
+ );
650
+ if (localRows.length > 1 || placeholderRows.length > 1) {
651
+ throw new Error(
652
+ "The declared signup-default relation is ambiguous; nothing was changed.",
653
+ );
654
+ }
655
+ if (localRows.length === 1 && placeholderRows.length === 1) {
656
+ const defaultInspection = inspectDeclaredDefault({
657
+ row: localRows[0].row,
658
+ declaration,
659
+ });
660
+ if (!defaultInspection.matches) {
661
+ const declaredColumns = Object.keys(declaration.values).sort();
662
+ const mismatchKinds = defaultInspection.mismatchedColumns.map(
663
+ (column) =>
664
+ `${column}(${valueKind(localRows[0].row[column])}/${declaration.values[column].kind === "exact" ? valueKind(declaration.values[column].value) : "pattern"})`,
665
+ );
666
+ throw new Error(
667
+ `The local signup row was edited or does not match its complete declared default; nothing was changed. Declared columns: ${declaredColumns.join(", ")}. Actual columns: ${defaultInspection.actualColumns.join(", ") || "none"}. Mismatched column shapes: ${mismatchKinds.join(", ") || "none"}.`,
668
+ );
669
+ }
670
+ await client.query(
671
+ `delete from ${target}
672
+ where ${qid(declaration.identityColumn)} = $1::uuid`,
673
+ [localUserId],
674
+ );
675
+ removedSignupDefaults += 1;
676
+ }
677
+ }
678
+ }
679
+ for (const reference of plan.identity.references) {
680
+ const expectedUserId =
681
+ reference.strategy === "preserve-audit"
682
+ ? plan.identity.placeholderUserId
683
+ : localUserId;
684
+ if (reference.strategy === "transfer") {
685
+ await client.query(
686
+ `update ${qid(reference.schema)}.${qid(reference.table)}
687
+ set ${qid(reference.column)} = $1::uuid
688
+ where ${qid(reference.column)} = $2::uuid`,
689
+ [localUserId, plan.identity.placeholderUserId],
690
+ );
691
+ }
692
+ if (reference.required) {
693
+ const required = await client.query(
694
+ `select 1
695
+ from ${qid(reference.schema)}.${qid(reference.table)}
696
+ where ${qid(reference.column)} = $1::uuid
697
+ limit 1`,
698
+ [expectedUserId],
699
+ );
700
+ if (required.rows.length !== 1) {
701
+ throw new Error(
702
+ "A required local identity reference is missing; nothing was changed.",
703
+ );
704
+ }
705
+ }
706
+ }
707
+ for (const reference of plan.identity.jsonReferences) {
708
+ await client.query(
709
+ `update ${qid(reference.schema)}.${qid(reference.table)}
710
+ set ${qid(reference.column)} = jsonb_set(
711
+ ${qid(reference.column)}, $3::text[], to_jsonb($1::text), false
712
+ )
713
+ where ${qid(reference.column)} #>> $3::text[] = $2`,
714
+ [localUserId, plan.identity.placeholderUserId, reference.path],
715
+ );
716
+ }
717
+ const sourcePathPrefix = `${plan.identity.placeholderUserId}/`;
718
+ let sourcePathReferenceCount = 0;
719
+ if (plan.identity.assets.some((asset) => asset.rewritePath)) {
720
+ for (const reference of plan.identity.pathReferences) {
721
+ const target = `${qid(reference.schema)}.${qid(reference.table)}`;
722
+ const column = qid(reference.column);
723
+ const result = await client.query(
724
+ reference.valueType === "text"
725
+ ? `select count(*)::integer as count from ${target} where ${column} like $1::text || '%'`
726
+ : `select count(*)::integer as count from ${target} where ${column}::text like '%' || $1::text || '%'`,
727
+ [sourcePathPrefix],
728
+ );
729
+ const count = Number(result.rows[0]?.count);
730
+ if (!Number.isSafeInteger(count) || count < 0) {
731
+ throw new Error(
732
+ "A Storage path reference returned an invalid count.",
733
+ );
734
+ }
735
+ sourcePathReferenceCount += count;
736
+ }
737
+ }
738
+ for (const asset of plan.identity.assets) {
739
+ const sourceObjects = await client.query(
740
+ `select name, owner_id
741
+ from storage.objects
742
+ where bucket_id = $1
743
+ and name like $2::text || '%'
744
+ order by name`,
745
+ [asset.bucket, asset.prefix],
746
+ );
747
+ if (
748
+ sourceObjects.rows.some(
749
+ ({ owner_id: ownerId }) =>
750
+ ownerId !== null &&
751
+ ownerId !== plan.identity.placeholderUserId &&
752
+ !(alreadyClaimed && ownerId === localUserId),
753
+ )
754
+ ) {
755
+ throw new Error(
756
+ "A Storage object in the reviewed placeholder path belongs to another identity; nothing was changed.",
757
+ );
758
+ }
759
+ if (sourceObjects.rows.length === 0) continue;
760
+ if (!asset.rewritePath) {
761
+ await client.query(
762
+ `update storage.objects
763
+ set owner_id = $1::text
764
+ where bucket_id = $2 and name = any($3::text[])`,
765
+ [
766
+ localUserId,
767
+ asset.bucket,
768
+ sourceObjects.rows.map(({ name }) => name),
769
+ ],
770
+ );
771
+ continue;
772
+ }
773
+ if (!storageTransfer) {
774
+ throw new Error(
775
+ "Storage path transfer requires the local Supabase Storage service; nothing was changed.",
776
+ );
777
+ }
778
+ const copies = sourceObjects.rows.map(({ name }) => {
779
+ if (typeof name !== "string" || !name.startsWith(asset.prefix)) {
780
+ throw new Error(
781
+ "A Storage object escaped its reviewed identity scope.",
782
+ );
783
+ }
784
+ return {
785
+ bucket: asset.bucket,
786
+ source: name,
787
+ destination: `${localUserId}/${name.slice(asset.prefix.length)}`,
788
+ };
789
+ });
790
+ const collisions = await client.query(
791
+ `select name
792
+ from storage.objects
793
+ where bucket_id = $1 and name = any($2::text[])
794
+ limit 1`,
795
+ [asset.bucket, copies.map((copy) => copy.destination)],
796
+ );
797
+ if (collisions.rows.length > 0) {
798
+ throw new Error(
799
+ "A destination Storage path already exists; nothing was changed.",
800
+ );
801
+ }
802
+ for (const copy of copies) {
803
+ await storageTransfer.copyAndVerify(copy);
804
+ stagedStorageCopies.push(copy);
805
+ }
806
+ await client.query(
807
+ `update storage.objects
808
+ set owner_id = $1::text
809
+ where bucket_id = $2 and name = any($3::text[])`,
810
+ [localUserId, asset.bucket, copies.map((copy) => copy.destination)],
811
+ );
812
+ const transferred = await client.query(
813
+ `select count(*)::integer as count
814
+ from storage.objects
815
+ where owner_id = $1::text
816
+ and bucket_id = $2
817
+ and name = any($3::text[])`,
818
+ [localUserId, asset.bucket, copies.map((copy) => copy.destination)],
819
+ );
820
+ if (Number(transferred.rows[0]?.count) !== copies.length) {
821
+ throw new Error(
822
+ "Storage ownership did not match the verified physical copies; nothing was changed.",
823
+ );
824
+ }
825
+ storageObjectsTransferred += copies.length;
826
+ }
827
+ if (sourcePathReferenceCount > 0 && storageObjectsTransferred === 0) {
828
+ throw new Error(
829
+ "Copied records reference Storage paths, but no matching objects were available; nothing was changed.",
830
+ );
831
+ }
832
+ const targetPathPrefix = `${localUserId}/`;
833
+ for (const reference of plan.identity.pathReferences) {
834
+ const target = `${qid(reference.schema)}.${qid(reference.table)}`;
835
+ const column = qid(reference.column);
836
+ await client.query(
837
+ reference.valueType === "text"
838
+ ? `update ${target}
839
+ set ${column} = $1::text || substring(${column} from char_length($2::text) + 1)
840
+ where ${column} like $2::text || '%'`
841
+ : `update ${target}
842
+ set ${column} = replace(${column}::text, $2::text, $1::text)::jsonb
843
+ where ${column}::text like '%' || $2::text || '%'`,
844
+ [targetPathPrefix, sourcePathPrefix],
845
+ );
846
+ }
847
+ await client.query(
848
+ `update auth.users
849
+ set raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || $2::jsonb,
850
+ updated_at = now()
851
+ where id = $1::uuid`,
852
+ [localUserId, JSON.stringify(plan.identity.claims)],
853
+ );
854
+ if (plan.identity.tokenHook) {
855
+ const hook = plan.identity.tokenHook;
856
+ const tokenResult = await client.query(
857
+ `select ${qid(hook.function.schema)}.${qid(hook.function.name)}(
858
+ jsonb_build_object('user_id', $1::text, 'claims', '{}'::jsonb)
859
+ ) as event`,
860
+ [localUserId],
861
+ );
862
+ const claims = tokenResult.rows[0]?.event?.claims;
863
+ if (!containsJson(claims, hook.expectedClaims)) {
864
+ throw new Error(
865
+ "The configured local token hook did not return the declared claims; nothing was changed.",
866
+ );
867
+ }
868
+ tokenHookVerified = true;
869
+ }
870
+ await client.query(IDENTITY_CLAIM_RECEIPT_SQL);
871
+ await client.query(
872
+ `insert into rehearsal_internal.identity_claims (
873
+ identity_name, placeholder_user_id, local_user_id
874
+ ) values ($1, $2::uuid, $3::uuid)
875
+ on conflict (identity_name) do nothing`,
876
+ [plan.identity.name, plan.identity.placeholderUserId, localUserId],
877
+ );
878
+ const claimReceipt = await client.query(
879
+ `select 1
880
+ from rehearsal_internal.identity_claims
881
+ where identity_name = $1
882
+ and placeholder_user_id = $2::uuid
883
+ and local_user_id = $3::uuid`,
884
+ [plan.identity.name, plan.identity.placeholderUserId, localUserId],
885
+ );
886
+ if (claimReceipt.rows.length !== 1) {
887
+ throw new Error(
888
+ "The local identity claim conflicts with an existing association receipt; nothing was changed.",
889
+ );
890
+ }
891
+ if (localUserId !== plan.identity.placeholderUserId) {
892
+ for (const reference of publicAuthReferences) {
893
+ const remaining = await countIdentityRows({
894
+ client,
895
+ reference,
896
+ userId: plan.identity.placeholderUserId,
897
+ });
898
+ if (remaining === 0) continue;
899
+ const declared = preservedReferences.find(
900
+ (candidate) =>
901
+ candidate.schema === reference.schema &&
902
+ candidate.table === reference.table &&
903
+ candidate.column === reference.column,
904
+ );
905
+ if (!declared) {
906
+ throw new Error(
907
+ "The identity policy omitted a copied application reference; nothing was changed.",
908
+ );
909
+ }
910
+ placeholderRetainedForAudit = true;
911
+ }
912
+ if (!placeholderRetainedForAudit) {
913
+ await client.query(
914
+ `delete from auth.users
915
+ where id = $1::uuid
916
+ and not exists (select 1 from auth.identities where user_id = $1::uuid)`,
917
+ [plan.identity.placeholderUserId],
918
+ );
919
+ }
920
+ }
921
+ await client.query("commit");
922
+ committed = true;
923
+ const copiesByBucket = Map.groupBy(
924
+ stagedStorageCopies,
925
+ (copy) => copy.bucket,
926
+ );
927
+ for (const [bucket, copies] of copiesByBucket) {
928
+ await storageTransfer.remove({
929
+ bucket,
930
+ paths: copies.map((copy) => copy.source),
931
+ });
932
+ }
933
+ return {
934
+ name: plan.identity.name,
935
+ provider: plan.identity.provider,
936
+ claimed: true,
937
+ removedSignupDefaults,
938
+ tokenHookVerified,
939
+ placeholderRetainedForAudit,
940
+ storageObjectsTransferred,
941
+ idempotent: alreadyClaimed,
942
+ };
943
+ } catch (error) {
944
+ if (committed) {
945
+ throw new Error(
946
+ "The identity claim committed and the new Storage copies are usable, but old Storage cleanup failed.",
947
+ { cause: error },
948
+ );
949
+ }
950
+ await client.query("rollback").catch(() => undefined);
951
+ if (stagedStorageCopies.length > 0) {
952
+ const cleanupErrors = [];
953
+ for (const [bucket, copies] of Map.groupBy(
954
+ stagedStorageCopies,
955
+ (copy) => copy.bucket,
956
+ )) {
957
+ await storageTransfer
958
+ .remove({
959
+ bucket,
960
+ paths: copies.map((copy) => copy.destination),
961
+ })
962
+ .catch((cleanupError) => cleanupErrors.push(cleanupError));
963
+ }
964
+ if (cleanupErrors.length > 0) {
965
+ throw new AggregateError(
966
+ [error, ...cleanupErrors],
967
+ "The identity claim rolled back, but copied Storage cleanup needs attention.",
968
+ );
969
+ }
970
+ }
971
+ throw error;
972
+ } finally {
973
+ await client.end?.();
974
+ }
975
+ };