@rehearsal-db/core 0.1.0-beta.10 → 0.1.0-beta.12
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.
- package/CHANGELOG.md +64 -1
- package/COMPATIBILITY.md +9 -0
- package/README.md +27 -7
- package/docs/README.md +25 -16
- package/docs/commands.md +11 -4
- package/docs/configuration.md +116 -20
- package/docs/getting-started.md +22 -4
- package/docs/index.md +47 -0
- package/docs/runtime-policies.md +67 -5
- package/docs/sanitization.md +104 -3
- package/docs/standalone-workflow.md +2 -0
- package/docs/troubleshooting.md +39 -3
- package/package.json +12 -3
- package/scripts/install/create_root_config.mjs +36 -0
- package/src/baseline/privacy_engine.mjs +358 -30
- package/src/cli/init_command.mjs +94 -0
- package/src/cli/rehearsal.mjs +6 -51
- package/src/cli/renderers.mjs +31 -2
- package/src/identity/claim.mjs +232 -44
- package/src/identity/provider_configuration.mjs +177 -0
- package/src/project/configuration.d.mts +11 -0
- package/src/project/configuration.mjs +159 -21
- package/src/project/install_scaffold.mjs +291 -0
- package/src/project/setup.mjs +157 -52
- package/src/runtime/plan.mjs +33 -0
- package/src/targets/supabase.mjs +11 -1
package/src/project/setup.mjs
CHANGED
|
@@ -3,11 +3,22 @@
|
|
|
3
3
|
* contacting hosted services or overwriting project-owned files.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
6
7
|
import { createConnection, createServer } from "node:net";
|
|
7
|
-
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
8
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
9
8
|
import {
|
|
9
|
+
access,
|
|
10
|
+
mkdir,
|
|
11
|
+
open,
|
|
12
|
+
readFile,
|
|
13
|
+
rename,
|
|
14
|
+
rm,
|
|
15
|
+
writeFile,
|
|
16
|
+
} from "node:fs/promises";
|
|
17
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
findRehearsalConfigPath,
|
|
10
20
|
inspectDetectedProject,
|
|
21
|
+
loadRehearsalConfig,
|
|
11
22
|
renderDetectedConfig,
|
|
12
23
|
renderDetectedPostgresqlConfig,
|
|
13
24
|
} from "./configuration.mjs";
|
|
@@ -178,7 +189,7 @@ enabled = false
|
|
|
178
189
|
enabled = false
|
|
179
190
|
`;
|
|
180
191
|
|
|
181
|
-
const
|
|
192
|
+
export const appendRehearsalGitignoreEntries = (source) => {
|
|
182
193
|
const original = source ?? "";
|
|
183
194
|
const lines = new Set(
|
|
184
195
|
original
|
|
@@ -200,6 +211,75 @@ const appendGitignoreEntries = (source) => {
|
|
|
200
211
|
return `${prefix}${separator}# Rehearsal local artifacts\n${missing.join("\n")}\n`;
|
|
201
212
|
};
|
|
202
213
|
|
|
214
|
+
const findExistingConfigPath = async ({ projectRoot, configPath }) => {
|
|
215
|
+
try {
|
|
216
|
+
return await findRehearsalConfigPath({ projectRoot, configPath });
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (
|
|
219
|
+
String(error?.message ?? error).startsWith("No Rehearsal configuration")
|
|
220
|
+
) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const deriveSupabasePorts = (runtimePorts) => ({
|
|
228
|
+
shadow: runtimePorts.database - 2,
|
|
229
|
+
api: runtimePorts.api,
|
|
230
|
+
database: runtimePorts.database,
|
|
231
|
+
studio: runtimePorts.studio,
|
|
232
|
+
smtp: runtimePorts.database + 2,
|
|
233
|
+
pooler: runtimePorts.database + 7,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const applyGitignoreChange = async (file) => {
|
|
237
|
+
if (file.action === "unchanged") return;
|
|
238
|
+
if (file.action === "create") {
|
|
239
|
+
await writeFile(file.absolutePath, file.content, {
|
|
240
|
+
flag: "wx",
|
|
241
|
+
mode: file.mode,
|
|
242
|
+
});
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const lockPath = `${file.absolutePath}.rehearsal.lock`;
|
|
247
|
+
let lock;
|
|
248
|
+
let temporaryPath;
|
|
249
|
+
try {
|
|
250
|
+
lock = await open(lockPath, "wx", 0o600);
|
|
251
|
+
const current = await readOptionalFile(file.absolutePath);
|
|
252
|
+
if (current !== file.originalContent) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
".gitignore changed after the setup preview; rerun rehearsal setup before writing.",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
temporaryPath = join(
|
|
258
|
+
dirname(file.absolutePath),
|
|
259
|
+
`.${basename(file.absolutePath)}.rehearsal-${process.pid}-${randomUUID()}.tmp`,
|
|
260
|
+
);
|
|
261
|
+
await writeFile(temporaryPath, file.content, {
|
|
262
|
+
flag: "wx",
|
|
263
|
+
mode: file.mode,
|
|
264
|
+
});
|
|
265
|
+
await rename(temporaryPath, file.absolutePath);
|
|
266
|
+
temporaryPath = null;
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (error?.code === "EEXIST" && !lock) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
"Another Rehearsal process is updating .gitignore; rerun setup after it finishes.",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
throw error;
|
|
274
|
+
} finally {
|
|
275
|
+
if (temporaryPath) await rm(temporaryPath, { force: true });
|
|
276
|
+
if (lock) {
|
|
277
|
+
await lock.close();
|
|
278
|
+
await rm(lockPath, { force: true });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
203
283
|
const assertCreateTargetAvailable = async (file) => {
|
|
204
284
|
if (file.action === "create" && (await pathExists(file.absolutePath))) {
|
|
205
285
|
throw new Error(
|
|
@@ -210,41 +290,63 @@ const assertCreateTargetAvailable = async (file) => {
|
|
|
210
290
|
|
|
211
291
|
export const planRehearsalSetup = async ({
|
|
212
292
|
projectRoot = process.cwd(),
|
|
293
|
+
configPath,
|
|
213
294
|
isPortAvailable,
|
|
214
|
-
target
|
|
295
|
+
target,
|
|
215
296
|
} = {}) => {
|
|
216
297
|
const root = resolve(projectRoot);
|
|
217
|
-
const runtimeTarget = resolveRuntimeTarget(target).id;
|
|
218
298
|
const detected = await inspectDetectedProject({ projectRoot: root });
|
|
219
|
-
const
|
|
220
|
-
|
|
299
|
+
const existingConfigPath = await findExistingConfigPath({
|
|
300
|
+
projectRoot: root,
|
|
301
|
+
configPath,
|
|
221
302
|
});
|
|
222
|
-
const
|
|
223
|
-
|
|
303
|
+
const existing = existingConfigPath
|
|
304
|
+
? await loadRehearsalConfig({ projectRoot: root, configPath })
|
|
305
|
+
: null;
|
|
306
|
+
const requestedTarget = resolveRuntimeTarget(
|
|
307
|
+
target ?? existing?.config.runtime.target ?? "supabase",
|
|
308
|
+
).id;
|
|
309
|
+
const runtimeTarget = existing?.config.runtime.target ?? requestedTarget;
|
|
310
|
+
if (existing && target && runtimeTarget !== requestedTarget) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`The existing Rehearsal configuration targets ${runtimeTarget}; setup will not replace it with ${requestedTarget}.`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
const ports = existing
|
|
316
|
+
? runtimeTarget === "supabase"
|
|
317
|
+
? deriveSupabasePorts(existing.config.runtime.ports)
|
|
318
|
+
: { database: existing.config.runtime.ports.database }
|
|
319
|
+
: await findAvailableRehearsalPorts({
|
|
320
|
+
...(isPortAvailable ? { isAvailable: isPortAvailable } : {}),
|
|
321
|
+
});
|
|
322
|
+
const applicationUrl =
|
|
323
|
+
existing?.config.runtime.applicationUrl ?? "http://localhost:5175";
|
|
324
|
+
const projectId =
|
|
325
|
+
existing?.config.runtime.projectId ?? `${detected.projectName}-rehearsal`;
|
|
224
326
|
const sourceSupabaseConfig =
|
|
225
327
|
runtimeTarget === "supabase"
|
|
226
328
|
? await readOptionalFile(join(root, "supabase/config.toml"))
|
|
227
329
|
: null;
|
|
228
330
|
const originalGitignore = await readOptionalFile(join(root, GITIGNORE_PATH));
|
|
229
|
-
const nextGitignore =
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
331
|
+
const nextGitignore = appendRehearsalGitignoreEntries(originalGitignore);
|
|
332
|
+
const rehearsalConfigPath = existing?.paths.rehearsalConfig;
|
|
333
|
+
const localSupabasePath = rehearsalConfigPath
|
|
334
|
+
? relative(root, rehearsalConfigPath)
|
|
335
|
+
: LOCAL_SUPABASE_CONFIG_PATH;
|
|
336
|
+
const localSupabaseExists = rehearsalConfigPath
|
|
337
|
+
? await pathExists(rehearsalConfigPath)
|
|
338
|
+
: false;
|
|
240
339
|
const files = [
|
|
241
340
|
{
|
|
242
|
-
path:
|
|
243
|
-
|
|
244
|
-
|
|
341
|
+
path: existingConfigPath
|
|
342
|
+
? relative(root, existingConfigPath)
|
|
343
|
+
: CONFIG_PATH,
|
|
344
|
+
absolutePath: existingConfigPath ?? join(root, CONFIG_PATH),
|
|
345
|
+
action: existingConfigPath ? "unchanged" : "create",
|
|
245
346
|
mode: 0o600,
|
|
246
|
-
content:
|
|
247
|
-
|
|
347
|
+
content: existingConfigPath
|
|
348
|
+
? null
|
|
349
|
+
: runtimeTarget === "supabase"
|
|
248
350
|
? renderDetectedConfig(detected, {
|
|
249
351
|
applicationUrl,
|
|
250
352
|
ports,
|
|
@@ -257,17 +359,20 @@ export const planRehearsalSetup = async ({
|
|
|
257
359
|
...(runtimeTarget === "supabase"
|
|
258
360
|
? [
|
|
259
361
|
{
|
|
260
|
-
path:
|
|
261
|
-
absolutePath:
|
|
262
|
-
|
|
362
|
+
path: localSupabasePath,
|
|
363
|
+
absolutePath:
|
|
364
|
+
rehearsalConfigPath ?? join(root, LOCAL_SUPABASE_CONFIG_PATH),
|
|
365
|
+
action: localSupabaseExists ? "unchanged" : "create",
|
|
263
366
|
mode: 0o600,
|
|
264
|
-
content:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
367
|
+
content: localSupabaseExists
|
|
368
|
+
? null
|
|
369
|
+
: renderSafeLocalSupabaseConfig({
|
|
370
|
+
projectId,
|
|
371
|
+
applicationUrl,
|
|
372
|
+
ports,
|
|
373
|
+
databaseMajorVersion:
|
|
374
|
+
databaseMajorVersionFrom(sourceSupabaseConfig),
|
|
375
|
+
}),
|
|
271
376
|
},
|
|
272
377
|
]
|
|
273
378
|
: []),
|
|
@@ -293,6 +398,11 @@ export const planRehearsalSetup = async ({
|
|
|
293
398
|
applicationUrl,
|
|
294
399
|
detected,
|
|
295
400
|
ports: runtimeTarget === "supabase" ? ports : { database: ports.database },
|
|
401
|
+
requiresAvailablePorts: files.some(
|
|
402
|
+
(file) =>
|
|
403
|
+
file.action === "create" &&
|
|
404
|
+
[CONFIG_PATH, localSupabasePath].includes(file.path),
|
|
405
|
+
),
|
|
296
406
|
isPortAvailable: isPortAvailable ?? isRehearsalPortAvailable,
|
|
297
407
|
files,
|
|
298
408
|
safety: [
|
|
@@ -314,14 +424,16 @@ export const planRehearsalSetup = async ({
|
|
|
314
424
|
|
|
315
425
|
export const applyRehearsalSetup = async (plan) => {
|
|
316
426
|
assertSupportedRehearsalNodeRuntime();
|
|
317
|
-
const unavailablePorts =
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
427
|
+
const unavailablePorts = plan.requiresAvailablePorts
|
|
428
|
+
? (
|
|
429
|
+
await Promise.all(
|
|
430
|
+
Object.values(plan.ports).map(async (port) => ({
|
|
431
|
+
port,
|
|
432
|
+
available: await plan.isPortAvailable(port),
|
|
433
|
+
})),
|
|
434
|
+
)
|
|
435
|
+
).filter(({ available }) => !available)
|
|
436
|
+
: [];
|
|
325
437
|
if (unavailablePorts.length) {
|
|
326
438
|
throw new Error(
|
|
327
439
|
`Rehearsal setup ports became occupied after the preview (${unavailablePorts.map(({ port }) => port).join(", ")}); rerun setup to choose another local port block.`,
|
|
@@ -329,16 +441,9 @@ export const applyRehearsalSetup = async (plan) => {
|
|
|
329
441
|
}
|
|
330
442
|
for (const file of plan.files) await assertCreateTargetAvailable(file);
|
|
331
443
|
const gitignore = plan.files.find((file) => file.path === GITIGNORE_PATH);
|
|
332
|
-
if (gitignore
|
|
333
|
-
const current = await readOptionalFile(gitignore.absolutePath);
|
|
334
|
-
if (current !== gitignore.originalContent) {
|
|
335
|
-
throw new Error(
|
|
336
|
-
".gitignore changed after the setup preview; rerun rehearsal setup before writing.",
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
444
|
+
if (gitignore) await applyGitignoreChange(gitignore);
|
|
340
445
|
for (const file of plan.files.filter(
|
|
341
|
-
(entry) => entry.action !== "unchanged",
|
|
446
|
+
(entry) => entry.action !== "unchanged" && entry.path !== GITIGNORE_PATH,
|
|
342
447
|
)) {
|
|
343
448
|
await mkdir(dirname(file.absolutePath), { recursive: true, mode: 0o700 });
|
|
344
449
|
await writeFile(file.absolutePath, file.content, {
|
package/src/runtime/plan.mjs
CHANGED
|
@@ -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,
|
|
@@ -233,6 +237,9 @@ export const buildRehearsalPlan = async (options = {}) => {
|
|
|
233
237
|
hostedAccess: config.safety.hostedAccess,
|
|
234
238
|
outboundNetwork: config.safety.outboundNetwork,
|
|
235
239
|
authenticationProviders: config.safety.authenticationProviders,
|
|
240
|
+
authenticationCallback: config.supabase?.authentication
|
|
241
|
+
? `http://127.0.0.1:${config.runtime.ports.api}/auth/v1/callback`
|
|
242
|
+
: null,
|
|
236
243
|
barriers: [
|
|
237
244
|
"versioned configuration accepts loopback hosts only",
|
|
238
245
|
`runtime uses a dedicated local ${config.runtime.target === "supabase" ? "Supabase workdir" : "PostgreSQL container and volume"} and project id`,
|
|
@@ -459,6 +466,32 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
459
466
|
remediation:
|
|
460
467
|
"Correct the missing project path in the Rehearsal configuration.",
|
|
461
468
|
},
|
|
469
|
+
...(config.supabase?.authentication
|
|
470
|
+
? [
|
|
471
|
+
{
|
|
472
|
+
id: "authentication-runtime-config",
|
|
473
|
+
label: "Local authentication configuration",
|
|
474
|
+
run: async () => {
|
|
475
|
+
const source = await readFile(paths.rehearsalConfig, "utf8");
|
|
476
|
+
const transition =
|
|
477
|
+
inspectSupabaseAuthenticationProviderTransition({
|
|
478
|
+
source,
|
|
479
|
+
authentication: config.supabase.authentication,
|
|
480
|
+
});
|
|
481
|
+
applySupabaseAuthenticationProviders({
|
|
482
|
+
source,
|
|
483
|
+
authentication: config.supabase.authentication,
|
|
484
|
+
apiPort: config.runtime.ports.api,
|
|
485
|
+
});
|
|
486
|
+
return transition.replacedProviders.length
|
|
487
|
+
? `compatible legacy ${transition.replacedProviders.join(", ")} provider configuration will be replaced only in the disposable runtime copy`
|
|
488
|
+
: "declarative provider configuration can be generated without changing the tracked template";
|
|
489
|
+
},
|
|
490
|
+
remediation:
|
|
491
|
+
"Review the named provider section and make skipNonceCheck or emailOptional match explicitly in config.supabase.authentication; otherwise keep the documented manual provider configuration.",
|
|
492
|
+
},
|
|
493
|
+
]
|
|
494
|
+
: []),
|
|
462
495
|
{
|
|
463
496
|
id: "sanitization-policy",
|
|
464
497
|
label: "Reviewed sanitization policy",
|
package/src/targets/supabase.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
} from "../runtime/migration_history.mjs";
|
|
45
45
|
import { loadRehearsalConfig } from "../project/configuration.mjs";
|
|
46
46
|
import { readRehearsalServiceEnvironment } from "../runtime/service_environment.mjs";
|
|
47
|
+
import { applySupabaseAuthenticationProviders } from "../identity/provider_configuration.mjs";
|
|
47
48
|
import { readBoundRuntimeSanitizationPolicy } from "../baseline/sanitization_policy.mjs";
|
|
48
49
|
import {
|
|
49
50
|
captureLocalPublicSchema,
|
|
@@ -315,7 +316,16 @@ const prepareRuntimeWorkdir = async ({ baseline, paths }) => {
|
|
|
315
316
|
recursive: true,
|
|
316
317
|
mode: 0o700,
|
|
317
318
|
});
|
|
318
|
-
await
|
|
319
|
+
const configTemplate = await readFile(configTemplatePath, "utf8");
|
|
320
|
+
await writeFile(
|
|
321
|
+
join(runtimeSupabaseDirectory, "config.toml"),
|
|
322
|
+
applySupabaseAuthenticationProviders({
|
|
323
|
+
source: configTemplate,
|
|
324
|
+
authentication: config.supabase.authentication,
|
|
325
|
+
apiPort: config.runtime.ports.api,
|
|
326
|
+
}),
|
|
327
|
+
{ mode: 0o600 },
|
|
328
|
+
);
|
|
319
329
|
for (const filename of Object.keys(baseline.migrations).sort()) {
|
|
320
330
|
await cp(
|
|
321
331
|
join(paths.migrationsDirectory, filename),
|