@rehearsal-db/core 0.1.0-beta.1 → 0.1.0-beta.3

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.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Purpose: Inspect explicit local synthetic records and migration evidence, then
3
+ * create a fail-closed sanitization-policy draft for human review.
4
+ */
5
+
6
+ import { createReadStream } from "node:fs";
7
+ import { access, mkdir, open, readFile } from "node:fs/promises";
8
+ import { createInterface } from "node:readline";
9
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
10
+ import { loadRehearsalConfig } from "./configuration.mjs";
11
+ import { buildMigrationLedgerInventory } from "./migration_history.mjs";
12
+
13
+ const identifierPattern = /^[a-z][a-z0-9_]{0,62}$/u;
14
+
15
+ const resolveProjectInput = (projectRoot, value, label) => {
16
+ if (typeof value !== "string" || value.trim() === "") {
17
+ throw new Error(`${label} is required.`);
18
+ }
19
+ const path = resolve(projectRoot, value);
20
+ const owned = relative(projectRoot, path);
21
+ if (
22
+ owned === "" ||
23
+ owned === ".." ||
24
+ owned.startsWith(`..${sep}`) ||
25
+ isAbsolute(owned)
26
+ ) {
27
+ throw new Error(`${label} must be a file inside the project root.`);
28
+ }
29
+ return path;
30
+ };
31
+
32
+ const pathExists = (path) =>
33
+ access(path)
34
+ .then(() => true)
35
+ .catch((error) => {
36
+ if (error?.code === "ENOENT") return false;
37
+ throw error;
38
+ });
39
+
40
+ const inspectSyntheticRecords = async (path) => {
41
+ const tables = new Map();
42
+ let rowCount = 0;
43
+ const lines = createInterface({
44
+ input: createReadStream(path, { encoding: "utf8" }),
45
+ crlfDelay: Infinity,
46
+ });
47
+ for await (const line of lines) {
48
+ if (!line.trim()) continue;
49
+ let record;
50
+ try {
51
+ record = JSON.parse(line);
52
+ } catch (error) {
53
+ throw new Error("Synthetic baseline input contains invalid NDJSON.", {
54
+ cause: error,
55
+ });
56
+ }
57
+ if (
58
+ !record ||
59
+ !identifierPattern.test(record.table ?? "") ||
60
+ !record.row ||
61
+ typeof record.row !== "object" ||
62
+ Array.isArray(record.row)
63
+ ) {
64
+ throw new Error("A synthetic baseline record has an invalid shape.");
65
+ }
66
+ const columns = Object.keys(record.row);
67
+ if (
68
+ columns.length === 0 ||
69
+ columns.some((column) => !identifierPattern.test(column))
70
+ ) {
71
+ throw new Error(
72
+ `Synthetic baseline table ${record.table} has invalid or empty columns.`,
73
+ );
74
+ }
75
+ const table = tables.get(record.table) ?? {
76
+ name: record.table,
77
+ rowCount: 0,
78
+ columns: new Set(),
79
+ };
80
+ table.rowCount += 1;
81
+ for (const column of columns) table.columns.add(column);
82
+ tables.set(record.table, table);
83
+ rowCount += 1;
84
+ }
85
+ if (rowCount === 0) {
86
+ throw new Error("Synthetic baseline input contains no records.");
87
+ }
88
+ return {
89
+ rowCount,
90
+ tables: [...tables.values()]
91
+ .sort((left, right) => left.name.localeCompare(right.name))
92
+ .map((table) => ({
93
+ name: table.name,
94
+ rowCount: table.rowCount,
95
+ columns: [...table.columns].sort(),
96
+ })),
97
+ };
98
+ };
99
+
100
+ const renderPolicyDraft = ({ migrationCutoff, tables }) =>
101
+ `${JSON.stringify(
102
+ {
103
+ policyVersion: 1,
104
+ draft: true,
105
+ migrationCutoff,
106
+ tables: tables.map((table) => ({
107
+ name: table.name,
108
+ group: "synthetic",
109
+ sourceRows: "STREAM AND SANITIZE",
110
+ columns: table.columns.map((name) => ({
111
+ name,
112
+ action: "REVIEW REQUIRED",
113
+ generated: "REVIEW REQUIRED",
114
+ identity: "REVIEW REQUIRED",
115
+ foreignKey: "REVIEW REQUIRED",
116
+ })),
117
+ })),
118
+ },
119
+ null,
120
+ 2,
121
+ )}\n`;
122
+
123
+ export const planBaselinePreparation = async ({
124
+ projectRoot = process.cwd(),
125
+ configPath,
126
+ recordsPath,
127
+ ledgerPath,
128
+ }) => {
129
+ const loaded = await loadRehearsalConfig({ projectRoot, configPath });
130
+ const records = resolveProjectInput(
131
+ loaded.projectRoot,
132
+ recordsPath,
133
+ "Synthetic records path",
134
+ );
135
+ const ledger = resolveProjectInput(
136
+ loaded.projectRoot,
137
+ ledgerPath,
138
+ "Migration ledger path",
139
+ );
140
+ const [inspection, ledgerRows] = await Promise.all([
141
+ inspectSyntheticRecords(records),
142
+ readFile(ledger, "utf8").then(JSON.parse),
143
+ ]);
144
+ const migrationHistory = buildMigrationLedgerInventory(
145
+ ledgerRows,
146
+ "Synthetic migration ledger",
147
+ );
148
+ const destination = loaded.paths.sanitizationPolicy;
149
+ if (await pathExists(destination)) {
150
+ throw new Error(
151
+ `Rehearsal baseline preparation will not overwrite ${relative(loaded.projectRoot, destination)}.`,
152
+ );
153
+ }
154
+ const migrationCutoff = migrationHistory.at(-1).version;
155
+ return {
156
+ projectRoot: loaded.projectRoot,
157
+ destination,
158
+ destinationRelative: relative(loaded.projectRoot, destination),
159
+ recordsPath: relative(loaded.projectRoot, records),
160
+ ledgerPath: relative(loaded.projectRoot, ledger),
161
+ migrationCutoff,
162
+ migrationCount: migrationHistory.length,
163
+ rowCount: inspection.rowCount,
164
+ tables: inspection.tables,
165
+ content: renderPolicyDraft({
166
+ migrationCutoff,
167
+ tables: inspection.tables,
168
+ }),
169
+ };
170
+ };
171
+
172
+ export const applyBaselinePreparation = async (plan) => {
173
+ if (await pathExists(plan.destination)) {
174
+ throw new Error(
175
+ `Rehearsal baseline preparation will not overwrite ${plan.destinationRelative}.`,
176
+ );
177
+ }
178
+ await mkdir(dirname(plan.destination), { recursive: true, mode: 0o700 });
179
+ const handle = await open(plan.destination, "wx", 0o600);
180
+ try {
181
+ await handle.writeFile(plan.content);
182
+ await handle.sync();
183
+ } finally {
184
+ await handle.close();
185
+ }
186
+ };
187
+
188
+ export const summarizeBaselinePreparation = (plan, { mode }) => ({
189
+ mode,
190
+ destination: plan.destinationRelative,
191
+ recordsPath: plan.recordsPath,
192
+ ledgerPath: plan.ledgerPath,
193
+ migrationCutoff: plan.migrationCutoff,
194
+ migrationCount: plan.migrationCount,
195
+ rowCount: plan.rowCount,
196
+ tables: plan.tables,
197
+ nextAction:
198
+ mode === "written"
199
+ ? `Review every REVIEW REQUIRED decision in ${plan.destinationRelative}, remove draft only after review, then create the baseline.`
200
+ : "Review this schema-only summary, then rerun with --write to create the draft.",
201
+ });
@@ -51,7 +51,13 @@ export declare const loadRehearsalConfig: (options?: {
51
51
  export declare const inspectDetectedProject: (options?: {
52
52
  projectRoot?: string;
53
53
  }) => Promise<unknown>;
54
- export declare const renderDetectedConfig: (detected: unknown) => string;
54
+ export declare const renderDetectedConfig: (
55
+ detected: unknown,
56
+ options?: {
57
+ applicationUrl?: string;
58
+ ports?: { api: number; database: number; studio: number };
59
+ },
60
+ ) => string;
55
61
  export declare const SANITIZATION_ACTIONS: Readonly<{
56
62
  KEEP: "KEEP";
57
63
  PSEUDONYMIZE: "PSEUDONYMIZE";
@@ -75,6 +81,13 @@ export declare const validateSanitizationCoverage: (options: {
75
81
  columnCount: number;
76
82
  tables: ReadonlyArray<Record<string, unknown>>;
77
83
  }>;
84
+ export declare const validateRuntimeSanitizationPolicy: (
85
+ policy: unknown,
86
+ ) => unknown;
87
+ export declare const readBoundRuntimeSanitizationPolicy: (options: {
88
+ bytes: Uint8Array | string;
89
+ expectedSha256: string;
90
+ }) => unknown;
78
91
  export declare const applySanitizationAction: (options: {
79
92
  action: string;
80
93
  value: unknown;
@@ -13,7 +13,9 @@ export {
13
13
  SANITIZATION_ACTIONS,
14
14
  applySanitizationAction,
15
15
  normalizeSanitizationAction,
16
+ readBoundRuntimeSanitizationPolicy,
16
17
  validateSanitizationCoverage,
18
+ validateRuntimeSanitizationPolicy,
17
19
  } from "./sanitization_policy.mjs";
18
20
 
19
21
  export const REHEARSAL_CONFIG_VERSION = 1;
@@ -494,12 +496,15 @@ export const inspectDetectedProject = async ({
494
496
  ? "yarn"
495
497
  : "npm";
496
498
  const scripts = packageJson.scripts ?? {};
499
+ const normalizedProjectName = String(packageJson.name ?? basename(root))
500
+ .toLowerCase()
501
+ .replace(/[^a-z0-9-]+/gu, "-")
502
+ .replace(/^-|-$/gu, "");
503
+ const projectName =
504
+ normalizedProjectName.slice(0, 52).replace(/-$/u, "") ||
505
+ "rehearsal-project";
497
506
  return {
498
- projectName:
499
- String(packageJson.name ?? basename(root))
500
- .toLowerCase()
501
- .replace(/[^a-z0-9-]+/gu, "-")
502
- .replace(/^-|-$/gu, "") || "rehearsal-project",
507
+ projectName,
503
508
  packageManager,
504
509
  hasSupabaseConfig: await hasPath("supabase/config.toml"),
505
510
  hasMigrations: await hasPath("supabase/migrations"),
@@ -517,7 +522,12 @@ export const inspectDetectedProject = async ({
517
522
 
518
523
  export const renderDetectedConfig = (
519
524
  detected,
520
- ) => `import { defineRehearsalConfig } from "@rehearsal-db/core";
525
+ {
526
+ applicationUrl = "http://localhost:5175",
527
+ ports = { api: 58321, database: 58322, studio: 58323 },
528
+ } = {},
529
+ ) => `// @ts-check
530
+ import { defineRehearsalConfig } from "@rehearsal-db/core";
521
531
 
522
532
  export default defineRehearsalConfig({
523
533
  schemaVersion: 1,
@@ -538,11 +548,11 @@ export default defineRehearsalConfig({
538
548
  environmentFile: ".rehearsal/runtime.env",
539
549
  },
540
550
  runtime: {
541
- applicationUrl: "http://localhost:5175",
551
+ applicationUrl: ${JSON.stringify(applicationUrl)},
542
552
  projectId: "${detected.projectName}-rehearsal",
543
- apiPort: 58321,
544
- databasePort: 58322,
545
- studioPort: 58323,
553
+ apiPort: ${ports.api},
554
+ databasePort: ${ports.database},
555
+ studioPort: ${ports.studio},
546
556
  },
547
557
  safety: {
548
558
  allowedHosts: ["127.0.0.1", "::1", "localhost"],
@@ -7,7 +7,7 @@
7
7
  import { createHash } from "node:crypto";
8
8
 
9
9
  export const REHEARSAL_RESULT_VERSION = 1;
10
- export const REHEARSAL_VERSION = "0.1.0-beta.1";
10
+ export const REHEARSAL_VERSION = "0.1.0-beta.3";
11
11
 
12
12
  export const REHEARSAL_EXIT_CODES = Object.freeze({
13
13
  success: 0,
@@ -96,7 +96,9 @@ const inferCategory = (error) => {
96
96
  if (/candidate|migration history diverges/iu.test(message)) {
97
97
  return "migration_candidate_failure";
98
98
  }
99
- if (/baseline|artifact|generation/iu.test(message)) return "baseline_invalid";
99
+ if (/baseline|artifact|generation|sanitization policy/iu.test(message)) {
100
+ return "baseline_invalid";
101
+ }
100
102
  if (/migration|ledger|replay/iu.test(message)) {
101
103
  return "migration_verification_failure";
102
104
  }
@@ -106,7 +108,9 @@ const inferCategory = (error) => {
106
108
  if (/loopback|hosted|unsafe|credential|outbound/iu.test(message)) {
107
109
  return "unsafe_environment";
108
110
  }
109
- if (/docker|supabase|runtime|command|enoent/iu.test(message)) {
111
+ if (
112
+ /docker|supabase|runtime|command|enoent|node\.js|ports?\b/iu.test(message)
113
+ ) {
110
114
  return "runtime_dependency_failure";
111
115
  }
112
116
  return "internal_failure";
@@ -0,0 +1,4 @@
1
+ /** Purpose: Keep human-facing Rehearsal counts grammatically consistent. */
2
+
3
+ export const formatCount = (count, singular, plural = `${singular}s`) =>
4
+ `${count} ${count === 1 ? singular : plural}`;
@@ -19,6 +19,10 @@ import { createCandidateMigrationReceipt } from "./runtime_restore.mjs";
19
19
  import { readMigrationFileInventory } from "./migration_history.mjs";
20
20
  import { REHEARSAL_VERSION } from "./diagnostics.mjs";
21
21
  import { readRehearsalServiceEnvironment } from "./service_environment.mjs";
22
+ import {
23
+ readBoundRuntimeSanitizationPolicy,
24
+ validateRuntimeSanitizationPolicy,
25
+ } from "./sanitization_policy.mjs";
22
26
 
23
27
  const commandAvailable = (command, args = ["--version"]) => {
24
28
  const result = spawnSync(command, args, {
@@ -155,8 +159,15 @@ const assertConfiguredCommand = async ({ command, projectRoot }) => {
155
159
 
156
160
  const loadPlanInputs = async (options = {}) => {
157
161
  const loaded = await loadRehearsalConfig(options);
158
- const baseline = await verifyActiveBaseline({
159
- artifactRoot: loaded.paths.artifactDirectory,
162
+ const [baseline, policyBytes] = await Promise.all([
163
+ verifyActiveBaseline({
164
+ artifactRoot: loaded.paths.artifactDirectory,
165
+ }),
166
+ readFile(loaded.paths.sanitizationPolicy),
167
+ ]);
168
+ readBoundRuntimeSanitizationPolicy({
169
+ bytes: policyBytes,
170
+ expectedSha256: baseline.sanitizationPolicySha256,
160
171
  });
161
172
  const currentFiles = await readMigrationFileInventory(
162
173
  new URL("./", pathToFileURL(`${loaded.paths.migrationDirectory}/`)),
@@ -412,6 +423,18 @@ export const runRehearsalDoctor = async (options = {}) => {
412
423
  remediation:
413
424
  "Correct the missing project path in the Rehearsal configuration.",
414
425
  },
426
+ {
427
+ id: "sanitization-policy",
428
+ label: "Reviewed sanitization policy",
429
+ run: async () => {
430
+ const policy = validateRuntimeSanitizationPolicy(
431
+ JSON.parse(await readFile(paths.sanitizationPolicy, "utf8")),
432
+ );
433
+ return `${policy.tables.length} table classifications reviewed through ${policy.migrationCutoff}`;
434
+ },
435
+ remediation:
436
+ "Complete every REVIEW REQUIRED decision, remove draft only after review, and retry.",
437
+ },
415
438
  {
416
439
  id: "runtime-isolation",
417
440
  label: "Runtime target isolation",
@@ -3,6 +3,8 @@
3
3
  * Projects own the policy, schema inventory, pseudonym keys, and derivation logic.
4
4
  */
5
5
 
6
+ import { createHash } from "node:crypto";
7
+
6
8
  export const SANITIZATION_ACTIONS = Object.freeze({
7
9
  KEEP: "KEEP",
8
10
  PSEUDONYMIZE: "PSEUDONYMIZE",
@@ -44,6 +46,114 @@ export const normalizeSanitizationAction = (action) => {
44
46
  return normalized;
45
47
  };
46
48
 
49
+ export const validateRuntimeSanitizationPolicy = (policy) => {
50
+ if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
51
+ throw new Error("A Rehearsal sanitization policy object is required.");
52
+ }
53
+ if (policy.draft === true) {
54
+ throw new Error(
55
+ "The sanitization policy is still a REVIEW REQUIRED draft.",
56
+ );
57
+ }
58
+ if (policy.policyVersion !== 1) {
59
+ throw new Error("The sanitization policy must use policyVersion 1.");
60
+ }
61
+ if (!/^\d{14}$/u.test(policy.migrationCutoff ?? "")) {
62
+ throw new Error(
63
+ "The sanitization policy must declare a timestamp migrationCutoff.",
64
+ );
65
+ }
66
+ if (!Array.isArray(policy.tables) || policy.tables.length === 0) {
67
+ throw new Error("The sanitization policy does not list tables.");
68
+ }
69
+ const tables = new Set();
70
+ for (const table of policy.tables) {
71
+ const tableName = assertIdentifier(
72
+ table?.name,
73
+ "Sanitization policy table name",
74
+ );
75
+ if (tables.has(tableName)) {
76
+ throw new Error(`Sanitization policy duplicates table ${tableName}.`);
77
+ }
78
+ tables.add(tableName);
79
+ if (!["STREAM AND SANITIZE", "EXCLUDE"].includes(table.sourceRows)) {
80
+ throw new Error(
81
+ `Sanitization policy table ${tableName} has an invalid sourceRows decision.`,
82
+ );
83
+ }
84
+ if (!Array.isArray(table.columns) || table.columns.length === 0) {
85
+ throw new Error(
86
+ `Sanitization policy table ${tableName} must list its columns.`,
87
+ );
88
+ }
89
+ const columns = new Set();
90
+ for (const column of table.columns) {
91
+ const columnName = assertIdentifier(
92
+ column?.name,
93
+ `Sanitization policy column in ${tableName}`,
94
+ );
95
+ if (columns.has(columnName)) {
96
+ throw new Error(
97
+ `Sanitization policy duplicates column ${tableName}.${columnName}.`,
98
+ );
99
+ }
100
+ columns.add(columnName);
101
+ normalizeSanitizationAction(column.action);
102
+ if (!["ALWAYS", "NEVER"].includes(column.generated)) {
103
+ throw new Error(
104
+ `Sanitization policy column ${tableName}.${columnName} must classify generated as ALWAYS or NEVER.`,
105
+ );
106
+ }
107
+ if (!["YES", "NO"].includes(column.identity)) {
108
+ throw new Error(
109
+ `Sanitization policy column ${tableName}.${columnName} must classify identity as YES or NO.`,
110
+ );
111
+ }
112
+ if (column.foreignKey !== null) {
113
+ if (
114
+ !column.foreignKey ||
115
+ typeof column.foreignKey !== "object" ||
116
+ Array.isArray(column.foreignKey)
117
+ ) {
118
+ throw new Error(
119
+ `Sanitization policy column ${tableName}.${columnName} has an invalid foreignKey decision.`,
120
+ );
121
+ }
122
+ assertIdentifier(
123
+ column.foreignKey.schema,
124
+ `Foreign-key schema for ${tableName}.${columnName}`,
125
+ );
126
+ assertIdentifier(
127
+ column.foreignKey.table,
128
+ `Foreign-key table for ${tableName}.${columnName}`,
129
+ );
130
+ assertIdentifier(
131
+ column.foreignKey.column,
132
+ `Foreign-key column for ${tableName}.${columnName}`,
133
+ );
134
+ }
135
+ }
136
+ }
137
+ return policy;
138
+ };
139
+
140
+ export const readBoundRuntimeSanitizationPolicy = ({
141
+ bytes,
142
+ expectedSha256,
143
+ }) => {
144
+ const source = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
145
+ const policy = validateRuntimeSanitizationPolicy(
146
+ JSON.parse(source.toString("utf8")),
147
+ );
148
+ const actualSha256 = createHash("sha256").update(source).digest("hex");
149
+ if (actualSha256 !== expectedSha256) {
150
+ throw new Error(
151
+ "The active sanitization policy does not match the reviewed baseline policy checksum.",
152
+ );
153
+ }
154
+ return policy;
155
+ };
156
+
47
157
  const indexTables = (tables, label) => {
48
158
  if (!Array.isArray(tables)) throw new Error(`${label} must be an array.`);
49
159
  const index = new Map();