@rehearsal-db/core 0.1.0-beta.12 → 0.1.0-beta.14

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 +116 -0
  2. package/COMPATIBILITY.md +6 -0
  3. package/README.md +5 -3
  4. package/docs/commands.md +15 -11
  5. package/docs/configuration.md +40 -5
  6. package/docs/getting-started.md +32 -3
  7. package/docs/production-source.md +175 -17
  8. package/docs/releasing.md +5 -3
  9. package/docs/roadmap.md +3 -2
  10. package/docs/sanitization.md +479 -12
  11. package/docs/security-model.md +8 -0
  12. package/docs/standalone-workflow.md +34 -13
  13. package/docs/troubleshooting.md +17 -6
  14. package/docs/tutorial.md +7 -1
  15. package/package.json +2 -2
  16. package/scripts/install/create_root_config.mjs +5 -22
  17. package/src/baseline/artifact.mjs +27 -4
  18. package/src/baseline/privacy_engine.mjs +865 -207
  19. package/src/baseline/privacy_structured_json.mjs +554 -0
  20. package/src/cli/arguments.mjs +3 -0
  21. package/src/cli/guided.mjs +8 -6
  22. package/src/cli/rehearsal.mjs +23 -3
  23. package/src/cli/renderers.mjs +29 -4
  24. package/src/cli/runtime_commands.mjs +92 -9
  25. package/src/cli/source_commands.mjs +37 -8
  26. package/src/identity/claim.mjs +23 -21
  27. package/src/project/install_scaffold.mjs +35 -1
  28. package/src/project/setup.mjs +14 -24
  29. package/src/runtime/plan.mjs +7 -3
  30. package/src/runtime/restore.mjs +13 -5
  31. package/src/shared/diagnostics.mjs +7 -0
  32. package/src/shared/node_runtime.mjs +31 -0
  33. package/src/source/access.mjs +271 -51
  34. package/src/source/asset_transfer.mjs +156 -74
  35. package/src/source/baseline.mjs +42 -6
  36. package/src/source/external_reader.mjs +368 -0
  37. package/src/source/postgresql_access.mjs +213 -55
  38. package/src/source/privacy_paths.mjs +38 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  const BUCKET = /^[a-z0-9][a-z0-9.-]{0,99}$/u;
4
4
  const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
5
+ const MAXIMUM_STORAGE_DEPTH = 64;
6
+ const MAXIMUM_STORAGE_PATH_BYTES = 4096;
5
7
 
6
8
  const safeBaseUrl = (value) => {
7
9
  let url;
@@ -34,14 +36,40 @@ const safePath = (value, label) => {
34
36
  ) {
35
37
  throw new Error(`${label} is unsafe.`);
36
38
  }
39
+ if (Buffer.byteLength(value) > MAXIMUM_STORAGE_PATH_BYTES) {
40
+ throw new Error(`${label} is too long.`);
41
+ }
37
42
  return value;
38
43
  };
39
44
 
45
+ const safeInventoryName = (value) => {
46
+ const name = safePath(value, "Storage inventory name");
47
+ if (name.includes("/") || name.includes("\\")) {
48
+ throw new Error("Storage inventory name is not a single path segment.");
49
+ }
50
+ return name;
51
+ };
52
+
40
53
  const readError = (label, response) =>
41
54
  new Error(
42
55
  `${label} failed with HTTP ${response.status}; response content was withheld.`,
43
56
  );
44
57
 
58
+ const canonicalContentType = (...values) => {
59
+ for (const value of values) {
60
+ if (typeof value !== "string") continue;
61
+ const mediaType = value.split(";", 1)[0].trim().toLowerCase();
62
+ if (
63
+ /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/u.test(
64
+ mediaType,
65
+ )
66
+ ) {
67
+ return mediaType;
68
+ }
69
+ }
70
+ return "application/octet-stream";
71
+ };
72
+
45
73
  const responseBody = async function* ({
46
74
  response,
47
75
  expectedBytes,
@@ -69,6 +97,7 @@ export const streamApprovedSupabaseAssets = async function* ({
69
97
  maximumObjects = 10_000,
70
98
  maximumObjectBytes = 50 * 1024 * 1024,
71
99
  maximumTotalBytes = 2 * 1024 * 1024 * 1024,
100
+ pathMapper,
72
101
  }) {
73
102
  const endpoint = safeBaseUrl(baseUrl);
74
103
  if (typeof token !== "string" || token.length < 16) {
@@ -77,101 +106,154 @@ export const streamApprovedSupabaseAssets = async function* ({
77
106
  if (!Array.isArray(declarations) || declarations.length === 0) return;
78
107
  let objectCount = 0;
79
108
  let totalBytes = 0;
109
+ const inventoryPaths = new Set();
80
110
  for (const declaration of declarations) {
81
111
  if (!BUCKET.test(declaration.bucket ?? "")) {
82
112
  throw new Error("Approved Storage bucket is invalid.");
83
113
  }
84
- const prefix = safePath(
114
+ const approvedPrefix = safePath(
85
115
  declaration.prefix.replace(/\/$/u, ""),
86
116
  "Approved Storage prefix",
87
117
  );
88
- let offset = 0;
89
- while (true) {
90
- const response = await fetchImplementation(
91
- `${endpoint}/storage/v1/object/list/${encodeURIComponent(declaration.bucket)}`,
92
- {
93
- method: "POST",
94
- headers: {
95
- authorization: `Bearer ${token}`,
96
- "content-type": "application/json",
97
- },
98
- body: JSON.stringify({
99
- prefix,
100
- limit: 100,
101
- offset,
102
- sortBy: { column: "name", order: "asc" },
103
- }),
104
- },
105
- );
106
- if (!response.ok) throw readError("Storage inventory", response);
107
- const inventory = await response.json();
108
- if (!Array.isArray(inventory))
109
- throw new Error("Storage inventory response is invalid.");
110
- for (const object of inventory) {
111
- const name = safePath(
112
- `${prefix}/${object.name}`,
113
- "Storage object path",
114
- );
115
- if (!name.startsWith(`${prefix}/`))
116
- throw new Error("Storage inventory escaped its approved prefix.");
117
- const expectedBytes = Number(
118
- object.metadata?.size ?? object.metadata?.contentLength,
119
- );
120
- if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0) {
121
- throw new Error("Storage inventory omitted an exact object size.");
122
- }
123
- if (expectedBytes > maximumObjectBytes) {
124
- throw new Error("Storage object exceeds its reviewed size limit.");
125
- }
126
- objectCount += 1;
127
- totalBytes += expectedBytes;
128
- if (objectCount > maximumObjects || totalBytes > maximumTotalBytes) {
129
- throw new Error(
130
- "Storage inventory exceeds its reviewed transfer boundary.",
131
- );
132
- }
133
- const encodedPath = name.split("/").map(encodeURIComponent).join("/");
134
- const download = await fetchImplementation(
135
- `${endpoint}/storage/v1/object/authenticated/${encodeURIComponent(declaration.bucket)}/${encodedPath}`,
118
+ const prefixes = [{ path: approvedPrefix, depth: 0 }];
119
+ const scheduledPrefixes = new Set([approvedPrefix]);
120
+ for (let prefixIndex = 0; prefixIndex < prefixes.length; prefixIndex += 1) {
121
+ const prefix = prefixes[prefixIndex];
122
+ let offset = 0;
123
+ while (true) {
124
+ const response = await fetchImplementation(
125
+ `${endpoint}/storage/v1/object/list/${encodeURIComponent(declaration.bucket)}`,
136
126
  {
127
+ method: "POST",
137
128
  headers: {
138
129
  authorization: `Bearer ${token}`,
139
- ...(object.metadata?.eTag
140
- ? { "if-match": object.metadata.eTag }
141
- : {}),
130
+ "content-type": "application/json",
142
131
  },
132
+ body: JSON.stringify({
133
+ prefix: prefix.path,
134
+ limit: 100,
135
+ offset,
136
+ sortBy: { column: "name", order: "asc" },
137
+ }),
143
138
  },
144
139
  );
145
- if (!download.ok) throw readError("Storage object transfer", download);
146
- const responseEtag = download.headers.get("etag");
147
- if (object.metadata?.eTag) {
148
- if (!responseEtag) {
140
+ if (!response.ok) throw readError("Storage inventory", response);
141
+ const inventory = await response.json();
142
+ if (!Array.isArray(inventory))
143
+ throw new Error("Storage inventory response is invalid.");
144
+ for (const object of inventory) {
145
+ if (
146
+ object === null ||
147
+ typeof object !== "object" ||
148
+ Array.isArray(object)
149
+ ) {
150
+ throw new Error("Storage inventory entry is invalid.");
151
+ }
152
+ const entryName = safeInventoryName(object.name);
153
+ const name = safePath(
154
+ `${prefix.path}/${entryName}`,
155
+ "Storage object path",
156
+ );
157
+ if (!name.startsWith(`${approvedPrefix}/`))
158
+ throw new Error("Storage inventory escaped its approved prefix.");
159
+ const inventoryKey = `${declaration.bucket}\0${name}`;
160
+ if (inventoryPaths.has(inventoryKey)) {
161
+ throw new Error("Storage inventory returned a duplicate path.");
162
+ }
163
+ inventoryPaths.add(inventoryKey);
164
+ objectCount += 1;
165
+ if (objectCount > maximumObjects) {
166
+ throw new Error(
167
+ "Storage inventory exceeds its reviewed transfer boundary.",
168
+ );
169
+ }
170
+ const directory = object.id === null && object.metadata === null;
171
+ if (directory) {
172
+ if (prefix.depth >= MAXIMUM_STORAGE_DEPTH) {
173
+ throw new Error(
174
+ "Storage inventory exceeds its safe folder depth.",
175
+ );
176
+ }
177
+ if (scheduledPrefixes.has(name)) {
178
+ throw new Error("Storage inventory returned a duplicate folder.");
179
+ }
180
+ scheduledPrefixes.add(name);
181
+ prefixes.push({ path: name, depth: prefix.depth + 1 });
182
+ continue;
183
+ }
184
+ const expectedBytes = Number(
185
+ object.metadata?.size ?? object.metadata?.contentLength,
186
+ );
187
+ if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0) {
188
+ throw new Error("Storage inventory omitted an exact object size.");
189
+ }
190
+ if (expectedBytes > maximumObjectBytes) {
191
+ throw new Error("Storage object exceeds its reviewed size limit.");
192
+ }
193
+ totalBytes += expectedBytes;
194
+ if (totalBytes > maximumTotalBytes) {
149
195
  throw new Error(
150
- "Storage transfer omitted the reviewed object version.",
196
+ "Storage inventory exceeds its reviewed transfer boundary.",
151
197
  );
152
198
  }
153
- if (responseEtag !== object.metadata.eTag) {
199
+ const encodedPath = name.split("/").map(encodeURIComponent).join("/");
200
+ const download = await fetchImplementation(
201
+ `${endpoint}/storage/v1/object/authenticated/${encodeURIComponent(declaration.bucket)}/${encodedPath}`,
202
+ {
203
+ headers: {
204
+ authorization: `Bearer ${token}`,
205
+ ...(object.metadata?.eTag
206
+ ? { "if-match": object.metadata.eTag }
207
+ : {}),
208
+ },
209
+ },
210
+ );
211
+ if (!download.ok)
212
+ throw readError("Storage object transfer", download);
213
+ const responseEtag = download.headers.get("etag");
214
+ if (object.metadata?.eTag) {
215
+ if (!responseEtag) {
216
+ throw new Error(
217
+ "Storage transfer omitted the reviewed object version.",
218
+ );
219
+ }
220
+ if (responseEtag !== object.metadata.eTag) {
221
+ throw new Error(
222
+ "Storage object changed between inventory and transfer.",
223
+ );
224
+ }
225
+ }
226
+ if (declaration.pathMapping && typeof pathMapper !== "function") {
154
227
  throw new Error(
155
- "Storage object changed between inventory and transfer.",
228
+ "Storage path mapping requires the package-owned privacy mapper.",
156
229
  );
157
230
  }
231
+ const objectPath = declaration.pathMapping
232
+ ? safePath(
233
+ pathMapper({
234
+ mapping: declaration.pathMapping,
235
+ value: name,
236
+ }),
237
+ "Mapped Storage object path",
238
+ )
239
+ : name;
240
+ yield {
241
+ bucket: declaration.bucket,
242
+ objectPath,
243
+ contentType: canonicalContentType(
244
+ object.metadata?.mimetype,
245
+ download.headers.get("content-type"),
246
+ ),
247
+ content: responseBody({
248
+ response: download,
249
+ expectedBytes,
250
+ maximumBytes: maximumObjectBytes,
251
+ }),
252
+ };
158
253
  }
159
- yield {
160
- bucket: declaration.bucket,
161
- objectPath: name,
162
- contentType:
163
- download.headers.get("content-type") ??
164
- object.metadata?.mimetype ??
165
- "application/octet-stream",
166
- content: responseBody({
167
- response: download,
168
- expectedBytes,
169
- maximumBytes: maximumObjectBytes,
170
- }),
171
- };
254
+ if (inventory.length < 100) break;
255
+ offset += inventory.length;
172
256
  }
173
- if (inventory.length < 100) break;
174
- offset += inventory.length;
175
257
  }
176
258
  }
177
259
  };
@@ -24,6 +24,7 @@ import {
24
24
  validateExecutablePrivacyPolicy,
25
25
  } from "../baseline/privacy_engine.mjs";
26
26
  import {
27
+ sourceAccessPolicyFingerprint,
27
28
  sourceTargetFingerprint,
28
29
  validateSourceAccessPolicy,
29
30
  } from "./access.mjs";
@@ -45,6 +46,15 @@ const quoteIdentifier = (value) => {
45
46
 
46
47
  const canonicalJson = (value) => `${JSON.stringify(value, null, "\t")}\n`;
47
48
  const sha256 = (value) => createHash("sha256").update(value).digest("hex");
49
+ const parseEstimateValue = (value) => {
50
+ if (
51
+ (typeof value !== "string" && typeof value !== "number") ||
52
+ (typeof value === "string" && !/^(?:0|[1-9]\d*)$/u.test(value))
53
+ ) {
54
+ return Number.NaN;
55
+ }
56
+ return Number(value);
57
+ };
48
58
 
49
59
  const normalizeLimits = (limits = {}) => {
50
60
  const normalized = { ...DEFAULT_LIMITS, ...limits };
@@ -132,10 +142,20 @@ const estimateExport = async (client, policy) => {
132
142
  coalesce(sum(pg_column_size(export_row)), 0)::bigint as bytes
133
143
  from ${quoteIdentifier(policy.exportSchema)}.${quoteIdentifier(relation.view)} export_row`,
134
144
  );
135
- const tableRows = Number(result.rows[0].rows);
136
- const tableBytes = Number(result.rows[0].bytes);
137
- if (!Number.isSafeInteger(tableRows) || !Number.isSafeInteger(tableBytes)) {
138
- throw new Error("Source export estimate exceeds safe numeric limits.");
145
+ const estimate = result.rows?.[0];
146
+ const tableRows = parseEstimateValue(estimate?.rows);
147
+ const tableBytes = parseEstimateValue(estimate?.bytes);
148
+ if (
149
+ !Number.isSafeInteger(tableRows) ||
150
+ !Number.isSafeInteger(tableBytes) ||
151
+ tableRows < 0 ||
152
+ tableBytes < 0 ||
153
+ !Number.isSafeInteger(rows + tableRows) ||
154
+ !Number.isSafeInteger(bytes + tableBytes)
155
+ ) {
156
+ throw new Error(
157
+ "Source export estimate is missing, negative, or exceeds safe numeric limits.",
158
+ );
139
159
  }
140
160
  rows += tableRows;
141
161
  bytes += tableBytes;
@@ -238,6 +258,14 @@ const streamSanitizedRecords = async function* ({
238
258
  const batch = await client.query(
239
259
  `fetch forward ${limits.batchRows} from ${quoteIdentifier(cursor)}`,
240
260
  );
261
+ if (
262
+ !Array.isArray(batch?.rows) ||
263
+ batch.rows.length > limits.batchRows
264
+ ) {
265
+ throw new Error(
266
+ "Source reader returned a malformed or oversized cursor batch.",
267
+ );
268
+ }
241
269
  if (batch.rows.length === 0) break;
242
270
  for (const row of batch.rows) {
243
271
  const sanitized = privacyEngine.sanitize({
@@ -295,6 +323,7 @@ export const refreshPostgresqlBaseline = async ({
295
323
  );
296
324
  }
297
325
  const expectedReceiptShape = {
326
+ accessMode: sourcePolicy.reader.mode,
298
327
  exportSchema: sourcePolicy.exportSchema,
299
328
  views: sourcePolicy.relations.map((relation) => relation.view).sort(),
300
329
  relations: sourcePolicy.relations.map((relation) => ({
@@ -303,19 +332,25 @@ export const refreshPostgresqlBaseline = async ({
303
332
  })),
304
333
  migrationLedger: sourcePolicy.migrationLedger,
305
334
  readerRole: sourcePolicy.reader.role,
306
- ownerRole: sourcePolicy.reader.ownerRole,
335
+ ownerRole: sourcePolicy.reader.ownerRole ?? null,
307
336
  };
308
337
  const actualReceiptShape = {
338
+ accessMode: sourceAccessReceipt?.accessMode ?? "managed",
309
339
  exportSchema: sourceAccessReceipt?.exportSchema,
310
340
  views: [...(sourceAccessReceipt?.views ?? [])].sort(),
311
341
  relations: sourceAccessReceipt?.relations,
312
342
  migrationLedger: sourceAccessReceipt?.migrationLedger,
313
343
  readerRole: sourceAccessReceipt?.readerRole,
314
- ownerRole: sourceAccessReceipt?.ownerRole,
344
+ ownerRole: sourceAccessReceipt?.ownerRole ?? null,
315
345
  };
346
+ const policyFingerprint = sourceAccessPolicyFingerprint(sourcePolicy);
316
347
  if (
317
348
  sourceAccessReceipt?.targetFingerprint !== sourcePolicy.targetFingerprint ||
318
349
  !/^[a-f0-9]{64}$/u.test(sourceAccessReceipt?.planDigest ?? "") ||
350
+ (sourcePolicy.reader.mode === "external" &&
351
+ sourceAccessReceipt?.policyFingerprint !== policyFingerprint) ||
352
+ (sourceAccessReceipt?.policyFingerprint !== undefined &&
353
+ sourceAccessReceipt.policyFingerprint !== policyFingerprint) ||
319
354
  new Date(sourceAccessReceipt.expiresAt).valueOf() <= Date.now() ||
320
355
  JSON.stringify(actualReceiptShape) !== JSON.stringify(expectedReceiptShape)
321
356
  ) {
@@ -388,6 +423,7 @@ export const refreshPostgresqlBaseline = async ({
388
423
  canonicalJson({
389
424
  targetFingerprint: sourceAccessReceipt.targetFingerprint,
390
425
  planDigest: sourceAccessReceipt.planDigest,
426
+ policyFingerprint: sourceAccessReceipt.policyFingerprint ?? null,
391
427
  expiresAt: sourceAccessReceipt.expiresAt,
392
428
  }),
393
429
  ),