@rehearsal-db/core 0.1.0-beta.13 → 0.1.0-beta.15

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