@rehearsal-db/core 0.1.0-beta.13 → 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.
@@ -138,6 +138,31 @@ const uniqueProjects = (projects) => [
138
138
 
139
139
  const skip = (reason) => ({ status: "skipped", reason });
140
140
 
141
+ export const renderInstalledRehearsalScaffold = (result) => {
142
+ if (result.status !== "ready") {
143
+ return `Rehearsal did not create project files: ${result.reason}.\nNext: run npx rehearsal from the application directory.`;
144
+ }
145
+ const changed = result.files.filter((file) => file.action !== "unchanged");
146
+ return [
147
+ "Rehearsal is ready in this project.",
148
+ `Configuration: ${result.configurationPath} (${result.configurationAction === "create" ? "created" : "existing"})`,
149
+ ...(result.rootConfigGenerationSkipped
150
+ ? [
151
+ ` Existing supported configuration detected at ${result.configurationPath}.`,
152
+ " rehearsal.config.mjs was not created because Rehearsal preserves one active configuration and never creates a competing file.",
153
+ ]
154
+ : []),
155
+ ...(changed.length
156
+ ? [
157
+ ...changed.map(
158
+ (file) => ` ${file.action === "create" ? "+" : "~"} ${file.path}`,
159
+ ),
160
+ ]
161
+ : [" Existing Rehearsal files preserved."]),
162
+ `Next: review ${result.configurationPath}, then run npx rehearsal.`,
163
+ ].join("\n");
164
+ };
165
+
141
166
  export const resolveRehearsalConsumerRoot = async ({
142
167
  packageRoot,
143
168
  initCwd,
@@ -281,11 +306,20 @@ export const createInstalledRehearsalScaffold = async ({
281
306
  isPortAvailable,
282
307
  });
283
308
  const result = await applyRehearsalSetup(plan);
284
- await loadRehearsalConfig({ projectRoot: resolution.projectRoot });
309
+ const loaded = await loadRehearsalConfig({
310
+ projectRoot: resolution.projectRoot,
311
+ });
312
+ const configurationPath = relative(resolution.projectRoot, loaded.configPath);
313
+ const configurationAction =
314
+ result.files.find(({ path }) => path === configurationPath)?.action ??
315
+ "unchanged";
285
316
  return {
286
317
  status: "ready",
287
318
  projectRoot: resolution.projectRoot,
288
319
  target: plan.target,
289
320
  files: result.files,
321
+ configurationPath,
322
+ configurationAction,
323
+ rootConfigGenerationSkipped: configurationPath !== "rehearsal.config.mjs",
290
324
  };
291
325
  };
@@ -374,6 +374,9 @@ export const planRehearsalSetup = async ({
374
374
  ];
375
375
  return {
376
376
  projectRoot: root,
377
+ configurationPath: existingConfigPath
378
+ ? relative(root, existingConfigPath)
379
+ : CONFIG_PATH,
377
380
  project: detected.projectName,
378
381
  projectId,
379
382
  target: runtimeTarget,
@@ -444,6 +447,11 @@ export const applyRehearsalSetup = async (plan) => {
444
447
 
445
448
  export const summarizeRehearsalSetup = (plan, { mode }) => ({
446
449
  mode,
450
+ configurationPath: plan.configurationPath,
451
+ configurationAction:
452
+ plan.files.find((file) => file.path === plan.configurationPath)?.action ??
453
+ "unchanged",
454
+ rootConfigGenerationSkipped: plan.configurationPath !== CONFIG_PATH,
447
455
  project: plan.project,
448
456
  projectId: plan.projectId,
449
457
  target: plan.target,
@@ -461,6 +469,6 @@ export const summarizeRehearsalSetup = (plan, { mode }) => ({
461
469
  safety: plan.safety,
462
470
  nextAction:
463
471
  mode === "written"
464
- ? "Review the generated commands and sanitization policy path, then run rehearsal doctor."
472
+ ? `Review ${plan.configurationPath}, including its commands and sanitization policy path, then run rehearsal doctor.`
465
473
  : "Review this plan, then rerun rehearsal setup --write to create it.",
466
474
  });
@@ -115,6 +115,7 @@ export const validateSourceAccessPolicy = (policy) => {
115
115
  : [
116
116
  "mode",
117
117
  "role",
118
+ "allowedMemberships",
118
119
  "connectionEnvironmentVariable",
119
120
  "credentialFile",
120
121
  "maximumValidForMinutes",
@@ -137,6 +138,7 @@ export const validateSourceAccessPolicy = (policy) => {
137
138
  let validForMinutes;
138
139
  let connectionEnvironmentVariable;
139
140
  let maximumValidForMinutes;
141
+ let allowedMemberships = Object.freeze([]);
140
142
  if (readerMode === "managed") {
141
143
  ownerRole = identifier(
142
144
  policy.reader.ownerRole,
@@ -155,6 +157,33 @@ export const validateSourceAccessPolicy = (policy) => {
155
157
  }
156
158
  validForMinutes = policy.reader.validForMinutes;
157
159
  } else {
160
+ if (
161
+ policy.reader.allowedMemberships !== undefined &&
162
+ (!Array.isArray(policy.reader.allowedMemberships) ||
163
+ policy.reader.allowedMemberships.length > 8)
164
+ ) {
165
+ throw new Error(
166
+ "sourcePolicy.reader.allowedMemberships must contain at most 8 role names.",
167
+ );
168
+ }
169
+ allowedMemberships = Object.freeze(
170
+ (policy.reader.allowedMemberships ?? []).map((membership, index) =>
171
+ identifier(
172
+ membership,
173
+ `sourcePolicy.reader.allowedMemberships[${index}]`,
174
+ ),
175
+ ),
176
+ );
177
+ if (new Set(allowedMemberships).size !== allowedMemberships.length) {
178
+ throw new Error(
179
+ "sourcePolicy.reader.allowedMemberships must not contain duplicates.",
180
+ );
181
+ }
182
+ if (allowedMemberships.includes(role)) {
183
+ throw new Error(
184
+ "sourcePolicy.reader.allowedMemberships must not include the login role.",
185
+ );
186
+ }
158
187
  connectionEnvironmentVariable = envKey(
159
188
  policy.reader.connectionEnvironmentVariable,
160
189
  "sourcePolicy.reader.connectionEnvironmentVariable",
@@ -170,13 +199,22 @@ export const validateSourceAccessPolicy = (policy) => {
170
199
  }
171
200
  maximumValidForMinutes = policy.reader.maximumValidForMinutes;
172
201
  }
202
+ const credentialParts =
203
+ typeof policy.reader.credentialFile === "string"
204
+ ? policy.reader.credentialFile.split(/[\\/]/u)
205
+ : [];
206
+ const rehearsalIndex = credentialParts.lastIndexOf(".rehearsal");
173
207
  if (
174
208
  typeof policy.reader.credentialFile !== "string" ||
175
- !policy.reader.credentialFile.startsWith(".rehearsal/") ||
176
- policy.reader.credentialFile.split(/[\\/]/u).includes("..")
209
+ policy.reader.credentialFile.startsWith("/") ||
210
+ /^[A-Za-z]:[\\/]/u.test(policy.reader.credentialFile) ||
211
+ credentialParts.some((part) => !part || part === "." || part === "..") ||
212
+ rehearsalIndex < 0 ||
213
+ credentialParts[rehearsalIndex + 1] !== "secrets" ||
214
+ rehearsalIndex + 2 >= credentialParts.length
177
215
  ) {
178
216
  throw new Error(
179
- "sourcePolicy.reader.credentialFile must stay under .rehearsal/.",
217
+ "sourcePolicy.reader.credentialFile must be a project-relative file under the configured .rehearsal/secrets directory.",
180
218
  );
181
219
  }
182
220
  const exportSchema = identifier(
@@ -488,7 +526,11 @@ export const validateSourceAccessPolicy = (policy) => {
488
526
  credentialFile: policy.reader.credentialFile,
489
527
  ...(readerMode === "managed"
490
528
  ? { ownerRole, validForMinutes }
491
- : { connectionEnvironmentVariable, maximumValidForMinutes }),
529
+ : {
530
+ allowedMemberships,
531
+ connectionEnvironmentVariable,
532
+ maximumValidForMinutes,
533
+ }),
492
534
  }),
493
535
  exportSchema,
494
536
  migrationLedger,
@@ -580,6 +622,7 @@ export const createSourceAccessPlan = ({
580
622
  : {
581
623
  connectionEnvironmentVariable:
582
624
  normalized.reader.connectionEnvironmentVariable,
625
+ allowedMemberships: normalized.reader.allowedMemberships,
583
626
  maximumValidForMinutes: normalized.reader.maximumValidForMinutes,
584
627
  sourceChanges: false,
585
628
  }),
@@ -632,7 +675,9 @@ export const createSourceAccessPlan = ({
632
675
  : [
633
676
  "all non-system readable columns match the approved views and migration ledger",
634
677
  "no relation, column, schema, or database writes",
635
- "no inherited roles or privileged role attributes",
678
+ "role memberships exactly match the reviewed allowlist",
679
+ "allowed groups cannot log in, escalate, switch roles, delegate membership, or inherit other roles",
680
+ "no privileged role attributes",
636
681
  "no executable non-system security-definer functions",
637
682
  "database-enforced credential expiration",
638
683
  "unrelated private assets",
@@ -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,9 +36,20 @@ 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.`,
@@ -93,115 +106,154 @@ export const streamApprovedSupabaseAssets = async function* ({
93
106
  if (!Array.isArray(declarations) || declarations.length === 0) return;
94
107
  let objectCount = 0;
95
108
  let totalBytes = 0;
109
+ const inventoryPaths = new Set();
96
110
  for (const declaration of declarations) {
97
111
  if (!BUCKET.test(declaration.bucket ?? "")) {
98
112
  throw new Error("Approved Storage bucket is invalid.");
99
113
  }
100
- const prefix = safePath(
114
+ const approvedPrefix = safePath(
101
115
  declaration.prefix.replace(/\/$/u, ""),
102
116
  "Approved Storage prefix",
103
117
  );
104
- let offset = 0;
105
- while (true) {
106
- const response = await fetchImplementation(
107
- `${endpoint}/storage/v1/object/list/${encodeURIComponent(declaration.bucket)}`,
108
- {
109
- method: "POST",
110
- headers: {
111
- authorization: `Bearer ${token}`,
112
- "content-type": "application/json",
113
- },
114
- body: JSON.stringify({
115
- prefix,
116
- limit: 100,
117
- offset,
118
- sortBy: { column: "name", order: "asc" },
119
- }),
120
- },
121
- );
122
- if (!response.ok) throw readError("Storage inventory", response);
123
- const inventory = await response.json();
124
- if (!Array.isArray(inventory))
125
- throw new Error("Storage inventory response is invalid.");
126
- for (const object of inventory) {
127
- const name = safePath(
128
- `${prefix}/${object.name}`,
129
- "Storage object path",
130
- );
131
- if (!name.startsWith(`${prefix}/`))
132
- throw new Error("Storage inventory escaped its approved prefix.");
133
- const expectedBytes = Number(
134
- object.metadata?.size ?? object.metadata?.contentLength,
135
- );
136
- if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0) {
137
- throw new Error("Storage inventory omitted an exact object size.");
138
- }
139
- if (expectedBytes > maximumObjectBytes) {
140
- throw new Error("Storage object exceeds its reviewed size limit.");
141
- }
142
- objectCount += 1;
143
- totalBytes += expectedBytes;
144
- if (objectCount > maximumObjects || totalBytes > maximumTotalBytes) {
145
- throw new Error(
146
- "Storage inventory exceeds its reviewed transfer boundary.",
147
- );
148
- }
149
- const encodedPath = name.split("/").map(encodeURIComponent).join("/");
150
- const download = await fetchImplementation(
151
- `${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)}`,
152
126
  {
127
+ method: "POST",
153
128
  headers: {
154
129
  authorization: `Bearer ${token}`,
155
- ...(object.metadata?.eTag
156
- ? { "if-match": object.metadata.eTag }
157
- : {}),
130
+ "content-type": "application/json",
158
131
  },
132
+ body: JSON.stringify({
133
+ prefix: prefix.path,
134
+ limit: 100,
135
+ offset,
136
+ sortBy: { column: "name", order: "asc" },
137
+ }),
159
138
  },
160
139
  );
161
- if (!download.ok) throw readError("Storage object transfer", download);
162
- const responseEtag = download.headers.get("etag");
163
- if (object.metadata?.eTag) {
164
- 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) {
165
166
  throw new Error(
166
- "Storage transfer omitted the reviewed object version.",
167
+ "Storage inventory exceeds its reviewed transfer boundary.",
167
168
  );
168
169
  }
169
- if (responseEtag !== object.metadata.eTag) {
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) {
170
195
  throw new Error(
171
- "Storage object changed between inventory and transfer.",
196
+ "Storage inventory exceeds its reviewed transfer boundary.",
172
197
  );
173
198
  }
174
- }
175
- if (declaration.pathMapping && typeof pathMapper !== "function") {
176
- throw new Error(
177
- "Storage path mapping requires the package-owned privacy mapper.",
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
+ },
178
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") {
227
+ throw new Error(
228
+ "Storage path mapping requires the package-owned privacy mapper.",
229
+ );
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
+ };
179
253
  }
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;
189
- yield {
190
- bucket: declaration.bucket,
191
- objectPath,
192
- contentType: canonicalContentType(
193
- object.metadata?.mimetype,
194
- download.headers.get("content-type"),
195
- ),
196
- content: responseBody({
197
- response: download,
198
- expectedBytes,
199
- maximumBytes: maximumObjectBytes,
200
- }),
201
- };
254
+ if (inventory.length < 100) break;
255
+ offset += inventory.length;
202
256
  }
203
- if (inventory.length < 100) break;
204
- offset += inventory.length;
205
257
  }
206
258
  }
207
259
  };