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

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 (38) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/COMPATIBILITY.md +6 -0
  3. package/README.md +11 -2
  4. package/docs/commands.md +18 -13
  5. package/docs/configuration.md +78 -12
  6. package/docs/getting-started.md +14 -4
  7. package/docs/production-source.md +138 -16
  8. package/docs/runtime-policies.md +4 -2
  9. package/docs/sanitization.md +73 -7
  10. package/docs/security-model.md +8 -0
  11. package/docs/standalone-workflow.md +24 -9
  12. package/docs/troubleshooting.md +26 -8
  13. package/docs/tutorial.md +2 -1
  14. package/package.json +4 -3
  15. package/src/baseline/artifact.mjs +27 -4
  16. package/src/baseline/privacy_engine.mjs +220 -9
  17. package/src/cli/arguments.mjs +3 -0
  18. package/src/cli/guided.mjs +8 -6
  19. package/src/cli/init_command.mjs +17 -0
  20. package/src/cli/rehearsal.mjs +20 -2
  21. package/src/cli/renderers.mjs +41 -4
  22. package/src/cli/runtime_commands.mjs +92 -9
  23. package/src/cli/source_commands.mjs +26 -5
  24. package/src/identity/provider_configuration.mjs +177 -0
  25. package/src/project/configuration.d.mts +11 -0
  26. package/src/project/configuration.mjs +155 -19
  27. package/src/project/setup.mjs +5 -23
  28. package/src/runtime/plan.mjs +40 -3
  29. package/src/runtime/restore.mjs +13 -5
  30. package/src/shared/diagnostics.mjs +7 -0
  31. package/src/shared/node_runtime.mjs +31 -0
  32. package/src/source/access.mjs +224 -49
  33. package/src/source/asset_transfer.mjs +35 -5
  34. package/src/source/baseline.mjs +42 -6
  35. package/src/source/external_reader.mjs +255 -0
  36. package/src/source/postgresql_access.mjs +143 -42
  37. package/src/source/privacy_paths.mjs +38 -0
  38. package/src/targets/supabase.mjs +11 -1
@@ -11,6 +11,10 @@ import { join } from "node:path";
11
11
  import { pathToFileURL } from "node:url";
12
12
  import { performance } from "node:perf_hooks";
13
13
  import { verifyActiveBaseline } from "../baseline/artifact.mjs";
14
+ import {
15
+ applySupabaseAuthenticationProviders,
16
+ inspectSupabaseAuthenticationProviderTransition,
17
+ } from "../identity/provider_configuration.mjs";
14
18
  import {
15
19
  inspectDetectedProject,
16
20
  loadRehearsalConfig,
@@ -18,6 +22,10 @@ import {
18
22
  import { createCandidateMigrationReceipt } from "./restore.mjs";
19
23
  import { readMigrationFileInventory } from "./migration_history.mjs";
20
24
  import { REHEARSAL_VERSION } from "../shared/diagnostics.mjs";
25
+ import {
26
+ inspectRehearsalNodeRuntime,
27
+ REHEARSAL_NODE_LABEL,
28
+ } from "../shared/node_runtime.mjs";
21
29
  import { readRehearsalServiceEnvironment } from "./service_environment.mjs";
22
30
  import {
23
31
  readBoundRuntimeSanitizationPolicy,
@@ -233,6 +241,9 @@ export const buildRehearsalPlan = async (options = {}) => {
233
241
  hostedAccess: config.safety.hostedAccess,
234
242
  outboundNetwork: config.safety.outboundNetwork,
235
243
  authenticationProviders: config.safety.authenticationProviders,
244
+ authenticationCallback: config.supabase?.authentication
245
+ ? `http://127.0.0.1:${config.runtime.ports.api}/auth/v1/callback`
246
+ : null,
236
247
  barriers: [
237
248
  "versioned configuration accepts loopback hosts only",
238
249
  `runtime uses a dedicated local ${config.runtime.target === "supabase" ? "Supabase workdir" : "PostgreSQL container and volume"} and project id`,
@@ -350,17 +361,17 @@ export const runRehearsalDoctor = async (options = {}) => {
350
361
  };
351
362
  }
352
363
  const { config, paths, projectRoot } = loaded;
353
- const nodeMajor = Number(process.versions.node.split(".")[0]);
364
+ const nodeRuntime = inspectRehearsalNodeRuntime();
354
365
  const checkDefinitions = [
355
366
  {
356
367
  id: "node",
357
368
  label: "Node.js compatibility",
358
369
  run: async () => {
359
- if (nodeMajor !== 24)
370
+ if (!nodeRuntime.supported)
360
371
  throw new Error(`Node ${process.versions.node} is unsupported.`);
361
372
  return `Node ${process.versions.node}`;
362
373
  },
363
- remediation: "Use Node.js 24.",
374
+ remediation: `Use ${REHEARSAL_NODE_LABEL}. Node.js 24 LTS is recommended.`,
364
375
  },
365
376
  {
366
377
  id: "operating-system",
@@ -459,6 +470,32 @@ export const runRehearsalDoctor = async (options = {}) => {
459
470
  remediation:
460
471
  "Correct the missing project path in the Rehearsal configuration.",
461
472
  },
473
+ ...(config.supabase?.authentication
474
+ ? [
475
+ {
476
+ id: "authentication-runtime-config",
477
+ label: "Local authentication configuration",
478
+ run: async () => {
479
+ const source = await readFile(paths.rehearsalConfig, "utf8");
480
+ const transition =
481
+ inspectSupabaseAuthenticationProviderTransition({
482
+ source,
483
+ authentication: config.supabase.authentication,
484
+ });
485
+ applySupabaseAuthenticationProviders({
486
+ source,
487
+ authentication: config.supabase.authentication,
488
+ apiPort: config.runtime.ports.api,
489
+ });
490
+ return transition.replacedProviders.length
491
+ ? `compatible legacy ${transition.replacedProviders.join(", ")} provider configuration will be replaced only in the disposable runtime copy`
492
+ : "declarative provider configuration can be generated without changing the tracked template";
493
+ },
494
+ remediation:
495
+ "Review the named provider section and make skipNonceCheck or emailOptional match explicitly in config.supabase.authentication; otherwise keep the documented manual provider configuration.",
496
+ },
497
+ ]
498
+ : []),
462
499
  {
463
500
  id: "sanitization-policy",
464
501
  label: "Reviewed sanitization policy",
@@ -194,11 +194,19 @@ const buildIdentitySequenceSql = (table) =>
194
194
  const schema = tableSchema(table);
195
195
  const relation = qualifiedRelation(table);
196
196
  const columnName = quoteIdentifier(column.name);
197
- return `select setval(
198
- pg_get_serial_sequence(${quoteLiteral(`${schema}.${table.name}`)}, ${quoteLiteral(column.name)}),
199
- coalesce((select max(${columnName}) from ${relation}), 1),
200
- exists(select 1 from ${relation})
201
- );`;
197
+ const sequenceValueQuery = `select max(${columnName})::bigint, exists(select 1 from ${relation}) from ${relation}`;
198
+ return `do $rehearsal_sequence$
199
+ declare
200
+ sequence_name text := pg_get_serial_sequence(${quoteLiteral(`${schema}.${table.name}`)}, ${quoteLiteral(column.name)});
201
+ maximum_value bigint;
202
+ has_rows boolean;
203
+ begin
204
+ if sequence_name is not null then
205
+ execute ${quoteLiteral(sequenceValueQuery)} into maximum_value, has_rows;
206
+ perform setval(sequence_name, coalesce(maximum_value, 1), has_rows);
207
+ end if;
208
+ end
209
+ $rehearsal_sequence$;`;
202
210
  })
203
211
  .join("\n");
204
212
 
@@ -94,6 +94,13 @@ export class RehearsalError extends Error {
94
94
  const inferCategory = (error) => {
95
95
  const message = String(error?.message ?? error);
96
96
  if (/checksum/iu.test(message)) return "baseline_checksum_mismatch";
97
+ if (
98
+ /container\b.*\b(?:is not ready|starting|unhealthy)|health(?:check)?\b.*\b(?:failed|starting|unhealthy)|out of memory|\boom\b|no space left|resource temporarily unavailable/iu.test(
99
+ message,
100
+ )
101
+ ) {
102
+ return "runtime_dependency_failure";
103
+ }
97
104
  if (/candidate|migration history diverges/iu.test(message)) {
98
105
  return "migration_candidate_failure";
99
106
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Supported Node.js release lines. Keep this aligned with package.json and the
3
+ * verification matrix.
4
+ */
5
+
6
+ export const REHEARSAL_NODE_MAJORS = Object.freeze([22, 24, 26]);
7
+ export const REHEARSAL_NODE_LABEL = "Node.js 22, 24, or 26";
8
+ export const REHEARSAL_RECOMMENDED_NODE_MAJOR = 24;
9
+
10
+ export const inspectRehearsalNodeRuntime = (
11
+ version = process.versions.node,
12
+ ) => {
13
+ const major = Number.parseInt(String(version).split(".")[0], 10);
14
+ return {
15
+ version: String(version),
16
+ major,
17
+ supported: REHEARSAL_NODE_MAJORS.includes(major),
18
+ };
19
+ };
20
+
21
+ export const assertSupportedRehearsalNodeRuntime = (
22
+ version = process.versions.node,
23
+ ) => {
24
+ const runtime = inspectRehearsalNodeRuntime(version);
25
+ if (!runtime.supported) {
26
+ throw new Error(
27
+ `Rehearsal requires ${REHEARSAL_NODE_LABEL}; this shell is using Node.js ${runtime.version}. Switch to a supported release, then rerun setup. With nvm: nvm install --lts && nvm use --lts.`,
28
+ );
29
+ }
30
+ return runtime;
31
+ };
@@ -50,6 +50,13 @@ const positiveInteger = (value, label) => {
50
50
  const canonicalJson = (value) => `${JSON.stringify(value, null, "\t")}\n`;
51
51
  const sha256 = (value) => createHash("sha256").update(value).digest("hex");
52
52
 
53
+ export const externalViewDefinitionFingerprint = (definition) => {
54
+ if (typeof definition !== "string" || !definition.trim()) {
55
+ throw new Error("External view definition must be a non-empty string.");
56
+ }
57
+ return sha256(definition.trim());
58
+ };
59
+
53
60
  export const sourceTargetFingerprint = (connectionString) => {
54
61
  let target;
55
62
  try {
@@ -95,24 +102,74 @@ export const validateSourceAccessPolicy = (policy) => {
95
102
  throw new Error("Source access policy must use accessVersion 1.");
96
103
  }
97
104
  fingerprint(policy.targetFingerprint, "sourcePolicy.targetFingerprint");
98
- envKey(
99
- policy.administratorEnvironmentVariable,
100
- "sourcePolicy.administratorEnvironmentVariable",
101
- );
102
105
  if (!isObject(policy.reader))
103
106
  throw new Error("sourcePolicy.reader must be an object.");
107
+ const readerMode = policy.reader.mode ?? "managed";
108
+ if (!["managed", "external"].includes(readerMode)) {
109
+ throw new Error("sourcePolicy.reader.mode must be managed or external.");
110
+ }
104
111
  knownKeys(
105
112
  policy.reader,
106
- ["role", "ownerRole", "credentialFile", "validForMinutes"],
113
+ readerMode === "managed"
114
+ ? ["mode", "role", "ownerRole", "credentialFile", "validForMinutes"]
115
+ : [
116
+ "mode",
117
+ "role",
118
+ "connectionEnvironmentVariable",
119
+ "credentialFile",
120
+ "maximumValidForMinutes",
121
+ ],
107
122
  "sourcePolicy.reader",
108
123
  );
124
+ let administratorEnvironmentVariable;
125
+ if (readerMode === "managed") {
126
+ administratorEnvironmentVariable = envKey(
127
+ policy.administratorEnvironmentVariable,
128
+ "sourcePolicy.administratorEnvironmentVariable",
129
+ );
130
+ } else if (policy.administratorEnvironmentVariable !== undefined) {
131
+ throw new Error(
132
+ "sourcePolicy.administratorEnvironmentVariable is not allowed for an external reader.",
133
+ );
134
+ }
109
135
  const role = identifier(policy.reader.role, "sourcePolicy.reader.role");
110
- const ownerRole = identifier(
111
- policy.reader.ownerRole,
112
- "sourcePolicy.reader.ownerRole",
113
- );
114
- if (role === ownerRole)
115
- throw new Error("Source reader and owner roles must differ.");
136
+ let ownerRole;
137
+ let validForMinutes;
138
+ let connectionEnvironmentVariable;
139
+ let maximumValidForMinutes;
140
+ if (readerMode === "managed") {
141
+ ownerRole = identifier(
142
+ policy.reader.ownerRole,
143
+ "sourcePolicy.reader.ownerRole",
144
+ );
145
+ if (role === ownerRole)
146
+ throw new Error("Source reader and owner roles must differ.");
147
+ if (
148
+ !Number.isSafeInteger(policy.reader.validForMinutes) ||
149
+ policy.reader.validForMinutes < 5 ||
150
+ policy.reader.validForMinutes > 240
151
+ ) {
152
+ throw new Error(
153
+ "sourcePolicy.reader.validForMinutes must be from 5 through 240.",
154
+ );
155
+ }
156
+ validForMinutes = policy.reader.validForMinutes;
157
+ } else {
158
+ connectionEnvironmentVariable = envKey(
159
+ policy.reader.connectionEnvironmentVariable,
160
+ "sourcePolicy.reader.connectionEnvironmentVariable",
161
+ );
162
+ if (
163
+ !Number.isSafeInteger(policy.reader.maximumValidForMinutes) ||
164
+ policy.reader.maximumValidForMinutes < 5 ||
165
+ policy.reader.maximumValidForMinutes > 240
166
+ ) {
167
+ throw new Error(
168
+ "sourcePolicy.reader.maximumValidForMinutes must be from 5 through 240.",
169
+ );
170
+ }
171
+ maximumValidForMinutes = policy.reader.maximumValidForMinutes;
172
+ }
116
173
  if (
117
174
  typeof policy.reader.credentialFile !== "string" ||
118
175
  !policy.reader.credentialFile.startsWith(".rehearsal/") ||
@@ -122,15 +179,6 @@ export const validateSourceAccessPolicy = (policy) => {
122
179
  "sourcePolicy.reader.credentialFile must stay under .rehearsal/.",
123
180
  );
124
181
  }
125
- if (
126
- !Number.isSafeInteger(policy.reader.validForMinutes) ||
127
- policy.reader.validForMinutes < 5 ||
128
- policy.reader.validForMinutes > 240
129
- ) {
130
- throw new Error(
131
- "sourcePolicy.reader.validForMinutes must be from 5 through 240.",
132
- );
133
- }
134
182
  const exportSchema = identifier(
135
183
  policy.exportSchema,
136
184
  "sourcePolicy.exportSchema",
@@ -185,6 +233,7 @@ export const validateSourceAccessPolicy = (policy) => {
185
233
  "columns",
186
234
  "orderBy",
187
235
  "rowScope",
236
+ "viewDefinitionSha256",
188
237
  ],
189
238
  label,
190
239
  );
@@ -235,6 +284,17 @@ export const validateSourceAccessPolicy = (policy) => {
235
284
  throw new Error(`${label}.orderBy must name unique exported columns.`);
236
285
  }
237
286
  const orderBy = Object.freeze([...relation.orderBy]);
287
+ let viewDefinitionSha256;
288
+ if (readerMode === "external") {
289
+ viewDefinitionSha256 = fingerprint(
290
+ relation.viewDefinitionSha256,
291
+ `${label}.viewDefinitionSha256`,
292
+ );
293
+ } else if (relation.viewDefinitionSha256 !== undefined) {
294
+ throw new Error(
295
+ `${label}.viewDefinitionSha256 is supported only for an external reader.`,
296
+ );
297
+ }
238
298
  if (!isObject(relation.rowScope))
239
299
  throw new Error(`${label}.rowScope must be an object.`);
240
300
  knownKeys(
@@ -282,6 +342,7 @@ export const validateSourceAccessPolicy = (policy) => {
282
342
  columns,
283
343
  orderBy,
284
344
  rowScope,
345
+ ...(viewDefinitionSha256 ? { viewDefinitionSha256 } : {}),
285
346
  });
286
347
  });
287
348
  const assets = policy.assets ?? [];
@@ -290,29 +351,79 @@ export const validateSourceAccessPolicy = (policy) => {
290
351
  const normalizedAssets = assets.map((asset, index) => {
291
352
  const label = `sourcePolicy.assets[${index}]`;
292
353
  if (!isObject(asset)) throw new Error(`${label} must be an object.`);
293
- knownKeys(asset, ["bucket", "prefix", "rights"], label);
354
+ knownKeys(
355
+ asset,
356
+ [
357
+ "bucket",
358
+ "prefix",
359
+ "prefixEnvironmentVariable",
360
+ "rights",
361
+ "pathMapping",
362
+ ],
363
+ label,
364
+ );
294
365
  if (!/^[a-z0-9][a-z0-9.-]{0,99}$/u.test(asset.bucket ?? "")) {
295
366
  throw new Error(`${label}.bucket is invalid.`);
296
367
  }
297
- const prefixParts =
298
- typeof asset.prefix === "string"
299
- ? asset.prefix.replace(/\/$/u, "").split("/")
300
- : [];
301
- if (
302
- typeof asset.prefix !== "string" ||
303
- !asset.prefix ||
304
- asset.prefix.startsWith("/") ||
305
- prefixParts.some((part) => !part || part === "." || part === "..")
306
- ) {
307
- throw new Error(`${label}.prefix is unsafe.`);
368
+ const hasFixedPrefix = asset.prefix !== undefined;
369
+ const hasPrivatePrefix = asset.prefixEnvironmentVariable !== undefined;
370
+ if (hasFixedPrefix === hasPrivatePrefix) {
371
+ throw new Error(
372
+ `${label} must declare exactly one of prefix or prefixEnvironmentVariable.`,
373
+ );
374
+ }
375
+ if (hasFixedPrefix) {
376
+ const prefixParts =
377
+ typeof asset.prefix === "string"
378
+ ? asset.prefix.replace(/\/$/u, "").split("/")
379
+ : [];
380
+ if (
381
+ typeof asset.prefix !== "string" ||
382
+ !asset.prefix ||
383
+ asset.prefix.startsWith("/") ||
384
+ prefixParts.some((part) => !part || part === "." || part === "..")
385
+ ) {
386
+ throw new Error(`${label}.prefix is unsafe.`);
387
+ }
388
+ } else {
389
+ envKey(
390
+ asset.prefixEnvironmentVariable,
391
+ `${label}.prefixEnvironmentVariable`,
392
+ );
308
393
  }
309
394
  if (!["approved-public", "approved-owner"].includes(asset.rights)) {
310
395
  throw new Error(`${label}.rights must be explicitly approved.`);
311
396
  }
397
+ if (
398
+ asset.pathMapping !== undefined &&
399
+ !/^[a-z][a-z0-9-]{0,62}$/u.test(asset.pathMapping)
400
+ ) {
401
+ throw new Error(`${label}.pathMapping is invalid.`);
402
+ }
403
+ if (hasPrivatePrefix && asset.rights !== "approved-owner") {
404
+ throw new Error(
405
+ `${label}.prefixEnvironmentVariable requires approved-owner rights.`,
406
+ );
407
+ }
408
+ if (hasPrivatePrefix && asset.pathMapping === undefined) {
409
+ throw new Error(
410
+ `${label}.prefixEnvironmentVariable requires pathMapping.`,
411
+ );
412
+ }
413
+ if (hasFixedPrefix && asset.pathMapping !== undefined) {
414
+ throw new Error(
415
+ `${label}.pathMapping requires prefixEnvironmentVariable so the raw identity stays out of tracked policy.`,
416
+ );
417
+ }
312
418
  return Object.freeze({
313
419
  bucket: asset.bucket,
314
- prefix: asset.prefix,
420
+ ...(hasFixedPrefix
421
+ ? { prefix: asset.prefix }
422
+ : { prefixEnvironmentVariable: asset.prefixEnvironmentVariable }),
315
423
  rights: asset.rights,
424
+ ...(asset.pathMapping === undefined
425
+ ? {}
426
+ : { pathMapping: asset.pathMapping }),
316
427
  });
317
428
  });
318
429
  let assetReader = null;
@@ -368,12 +479,16 @@ export const validateSourceAccessPolicy = (policy) => {
368
479
  return Object.freeze({
369
480
  accessVersion: 1,
370
481
  targetFingerprint: policy.targetFingerprint,
371
- administratorEnvironmentVariable: policy.administratorEnvironmentVariable,
482
+ ...(administratorEnvironmentVariable
483
+ ? { administratorEnvironmentVariable }
484
+ : {}),
372
485
  reader: Object.freeze({
486
+ mode: readerMode,
373
487
  role,
374
- ownerRole,
375
488
  credentialFile: policy.reader.credentialFile,
376
- validForMinutes: policy.reader.validForMinutes,
489
+ ...(readerMode === "managed"
490
+ ? { ownerRole, validForMinutes }
491
+ : { connectionEnvironmentVariable, maximumValidForMinutes }),
377
492
  }),
378
493
  exportSchema,
379
494
  migrationLedger,
@@ -389,10 +504,18 @@ export const createSourceAccessPlan = ({
389
504
  }) => {
390
505
  const normalized = validateSourceAccessPolicy(policy);
391
506
  const sourceCredential =
392
- environment[normalized.administratorEnvironmentVariable];
507
+ environment[
508
+ normalized.reader.mode === "managed"
509
+ ? normalized.administratorEnvironmentVariable
510
+ : normalized.reader.connectionEnvironmentVariable
511
+ ];
393
512
  if (!sourceCredential) {
394
513
  throw new Error(
395
- `Source preparation requires ${normalized.administratorEnvironmentVariable}; the value is read from the environment and never printed.`,
514
+ `Source preparation requires ${
515
+ normalized.reader.mode === "managed"
516
+ ? normalized.administratorEnvironmentVariable
517
+ : normalized.reader.connectionEnvironmentVariable
518
+ }; the value is read from the environment and never printed.`,
396
519
  );
397
520
  }
398
521
  const actualTarget = sourceTargetFingerprint(sourceCredential);
@@ -411,6 +534,16 @@ export const createSourceAccessPlan = ({
411
534
  return [relation.view, sha256(value)];
412
535
  }),
413
536
  );
537
+ const assetPrefixReceipts = Object.fromEntries(
538
+ normalized.assets
539
+ .filter((asset) => asset.prefixEnvironmentVariable)
540
+ .map((asset) => {
541
+ const key = asset.prefixEnvironmentVariable;
542
+ const value = environment[key];
543
+ if (!value) throw new Error(`Source preparation requires ${key}.`);
544
+ return [`${asset.bucket}:${asset.pathMapping}`, sha256(value)];
545
+ }),
546
+ );
414
547
  if (normalized.assetReader) {
415
548
  const baseUrl =
416
549
  environment[normalized.assetReader.baseUrlEnvironmentVariable];
@@ -435,10 +568,21 @@ export const createSourceAccessPlan = ({
435
568
  targetFingerprint: normalized.targetFingerprint,
436
569
  exportSchema: normalized.exportSchema,
437
570
  reader: {
571
+ mode: normalized.reader.mode,
438
572
  role: normalized.reader.role,
439
- ownerRole: normalized.reader.ownerRole,
440
- validForMinutes: normalized.reader.validForMinutes,
441
573
  credentialFile: normalized.reader.credentialFile,
574
+ ...(normalized.reader.mode === "managed"
575
+ ? {
576
+ ownerRole: normalized.reader.ownerRole,
577
+ validForMinutes: normalized.reader.validForMinutes,
578
+ sourceChanges: true,
579
+ }
580
+ : {
581
+ connectionEnvironmentVariable:
582
+ normalized.reader.connectionEnvironmentVariable,
583
+ maximumValidForMinutes: normalized.reader.maximumValidForMinutes,
584
+ sourceChanges: false,
585
+ }),
442
586
  },
443
587
  relations: normalized.relations.map((relation) => ({
444
588
  source: relation.source,
@@ -446,6 +590,9 @@ export const createSourceAccessPlan = ({
446
590
  targetTable: relation.targetTable,
447
591
  columns: relation.columns,
448
592
  orderBy: relation.orderBy,
593
+ ...(relation.viewDefinitionSha256
594
+ ? { viewDefinitionSha256: relation.viewDefinitionSha256 }
595
+ : {}),
449
596
  rowScope: {
450
597
  kind: relation.rowScope.kind,
451
598
  ...(relation.rowScope.kind === "approved-owner"
@@ -456,7 +603,15 @@ export const createSourceAccessPlan = ({
456
603
  : {}),
457
604
  },
458
605
  })),
459
- assets: normalized.assets,
606
+ assets: normalized.assets.map((asset) =>
607
+ asset.prefixEnvironmentVariable
608
+ ? {
609
+ ...asset,
610
+ prefixValueSha256:
611
+ assetPrefixReceipts[`${asset.bucket}:${asset.pathMapping}`],
612
+ }
613
+ : asset,
614
+ ),
460
615
  assetReader: normalized.assetReader
461
616
  ? {
462
617
  endpointFingerprint: normalized.assetReader.endpointFingerprint,
@@ -465,13 +620,23 @@ export const createSourceAccessPlan = ({
465
620
  maximumTotalBytes: normalized.assetReader.maximumTotalBytes,
466
621
  }
467
622
  : null,
468
- denyChecks: [
469
- "raw relation reads",
470
- "writes",
471
- "role creation and escalation",
472
- "network functions",
473
- "unrelated private assets",
474
- ],
623
+ denyChecks:
624
+ normalized.reader.mode === "managed"
625
+ ? [
626
+ "raw relation reads",
627
+ "writes",
628
+ "role creation and escalation",
629
+ "network functions",
630
+ "unrelated private assets",
631
+ ]
632
+ : [
633
+ "all non-system readable columns match the approved views and migration ledger",
634
+ "no relation, column, schema, or database writes",
635
+ "no inherited roles or privileged role attributes",
636
+ "no executable non-system security-definer functions",
637
+ "database-enforced credential expiration",
638
+ "unrelated private assets",
639
+ ],
475
640
  };
476
641
  return Object.freeze({
477
642
  policy: normalized,
@@ -480,6 +645,9 @@ export const createSourceAccessPlan = ({
480
645
  });
481
646
  };
482
647
 
648
+ export const sourceAccessPolicyFingerprint = (policy) =>
649
+ sha256(canonicalJson(validateSourceAccessPolicy(policy)));
650
+
483
651
  export const assetEndpointFingerprint = (value) => {
484
652
  let endpoint;
485
653
  try {
@@ -525,6 +693,10 @@ export const createSourceRetirementPlan = ({
525
693
  if (!Array.isArray(receipt.views) || !Array.isArray(receipt.relations)) {
526
694
  throw new Error("Source access receipt object inventory is invalid.");
527
695
  }
696
+ const accessMode = receipt.accessMode ?? "managed";
697
+ if (!["managed", "external"].includes(accessMode)) {
698
+ throw new Error("Source access receipt mode is invalid.");
699
+ }
528
700
  const relations = receipt.relations.map((relation, index) => {
529
701
  if (!isObject(relation))
530
702
  throw new Error(`receipt.relations[${index}] is invalid.`);
@@ -560,6 +732,7 @@ export const createSourceRetirementPlan = ({
560
732
  const review = {
561
733
  planVersion: 1,
562
734
  operation: "retire-source-access",
735
+ accessMode,
563
736
  targetFingerprint: target,
564
737
  preparationDigest: fingerprint(receipt.planDigest, "receipt.planDigest"),
565
738
  exportSchema: identifier(receipt.exportSchema, "receipt.exportSchema"),
@@ -569,7 +742,9 @@ export const createSourceRetirementPlan = ({
569
742
  relations,
570
743
  migrationLedger: ledger,
571
744
  readerRole: identifier(receipt.readerRole, "receipt.readerRole"),
572
- ownerRole: identifier(receipt.ownerRole, "receipt.ownerRole"),
745
+ ...(accessMode === "managed"
746
+ ? { ownerRole: identifier(receipt.ownerRole, "receipt.ownerRole") }
747
+ : {}),
573
748
  };
574
749
  return Object.freeze({
575
750
  review: Object.freeze(review),
@@ -42,6 +42,21 @@ const readError = (label, response) =>
42
42
  `${label} failed with HTTP ${response.status}; response content was withheld.`,
43
43
  );
44
44
 
45
+ const canonicalContentType = (...values) => {
46
+ for (const value of values) {
47
+ if (typeof value !== "string") continue;
48
+ const mediaType = value.split(";", 1)[0].trim().toLowerCase();
49
+ if (
50
+ /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/u.test(
51
+ mediaType,
52
+ )
53
+ ) {
54
+ return mediaType;
55
+ }
56
+ }
57
+ return "application/octet-stream";
58
+ };
59
+
45
60
  const responseBody = async function* ({
46
61
  response,
47
62
  expectedBytes,
@@ -69,6 +84,7 @@ export const streamApprovedSupabaseAssets = async function* ({
69
84
  maximumObjects = 10_000,
70
85
  maximumObjectBytes = 50 * 1024 * 1024,
71
86
  maximumTotalBytes = 2 * 1024 * 1024 * 1024,
87
+ pathMapper,
72
88
  }) {
73
89
  const endpoint = safeBaseUrl(baseUrl);
74
90
  if (typeof token !== "string" || token.length < 16) {
@@ -156,13 +172,27 @@ export const streamApprovedSupabaseAssets = async function* ({
156
172
  );
157
173
  }
158
174
  }
175
+ if (declaration.pathMapping && typeof pathMapper !== "function") {
176
+ throw new Error(
177
+ "Storage path mapping requires the package-owned privacy mapper.",
178
+ );
179
+ }
180
+ const objectPath = declaration.pathMapping
181
+ ? safePath(
182
+ pathMapper({
183
+ mapping: declaration.pathMapping,
184
+ value: name,
185
+ }),
186
+ "Mapped Storage object path",
187
+ )
188
+ : name;
159
189
  yield {
160
190
  bucket: declaration.bucket,
161
- objectPath: name,
162
- contentType:
163
- download.headers.get("content-type") ??
164
- object.metadata?.mimetype ??
165
- "application/octet-stream",
191
+ objectPath,
192
+ contentType: canonicalContentType(
193
+ object.metadata?.mimetype,
194
+ download.headers.get("content-type"),
195
+ ),
166
196
  content: responseBody({
167
197
  response: download,
168
198
  expectedBytes,