@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/cli/rehearsal.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
|
-
import { readFile
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
10
|
import { join, relative } from "node:path";
|
|
11
11
|
import { performance } from "node:perf_hooks";
|
|
12
12
|
import { parseEnv } from "node:util";
|
|
@@ -18,12 +18,7 @@ import {
|
|
|
18
18
|
renderHumanError,
|
|
19
19
|
serializeRehearsalError,
|
|
20
20
|
} from "../shared/diagnostics.mjs";
|
|
21
|
-
import {
|
|
22
|
-
inspectDetectedProject,
|
|
23
|
-
findRehearsalConfigPath,
|
|
24
|
-
loadRehearsalConfig,
|
|
25
|
-
renderDetectedConfig,
|
|
26
|
-
} from "../project/configuration.mjs";
|
|
21
|
+
import { loadRehearsalConfig } from "../project/configuration.mjs";
|
|
27
22
|
import {
|
|
28
23
|
inspectRehearsalBaseline,
|
|
29
24
|
inspectRehearsalMigrations,
|
|
@@ -113,6 +108,7 @@ import {
|
|
|
113
108
|
renderSourceRetirement,
|
|
114
109
|
renderSupportReport,
|
|
115
110
|
} from "./renderers.mjs";
|
|
111
|
+
import { runRehearsalInit } from "./init_command.mjs";
|
|
116
112
|
|
|
117
113
|
const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
118
114
|
const projectRoot = process.cwd();
|
|
@@ -148,54 +144,13 @@ const emit = ({ command, data, flags, render, status = "success" }) => {
|
|
|
148
144
|
else console.log(render(data, flags.verbosity));
|
|
149
145
|
};
|
|
150
146
|
|
|
151
|
-
const runInit = async ({ flags }) => {
|
|
152
|
-
const detected = await inspectDetectedProject({ projectRoot });
|
|
153
|
-
const source = renderDetectedConfig(detected);
|
|
154
|
-
const destination = join(projectRoot, "rehearsal.config.mjs");
|
|
155
|
-
let existingPath = null;
|
|
156
|
-
try {
|
|
157
|
-
existingPath = await findRehearsalConfigPath({ projectRoot });
|
|
158
|
-
} catch (error) {
|
|
159
|
-
if (
|
|
160
|
-
!String(error?.message ?? error).startsWith("No Rehearsal configuration")
|
|
161
|
-
) {
|
|
162
|
-
throw error;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
if (existingPath) {
|
|
166
|
-
if (flags.write) {
|
|
167
|
-
throw new Error(
|
|
168
|
-
`A Rehearsal configuration already exists at ${relative(projectRoot, existingPath)}; Rehearsal will not overwrite it.`,
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
return {
|
|
172
|
-
mode: "existing",
|
|
173
|
-
destination: relative(projectRoot, existingPath),
|
|
174
|
-
detected,
|
|
175
|
-
source: "",
|
|
176
|
-
nextAction:
|
|
177
|
-
"Review the existing configuration, then run rehearsal doctor.",
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
if (flags.write)
|
|
181
|
-
await writeFile(destination, source, { flag: "wx", mode: 0o600 });
|
|
182
|
-
return {
|
|
183
|
-
mode: flags.write ? "written" : "preview",
|
|
184
|
-
destination: relative(projectRoot, destination),
|
|
185
|
-
detected,
|
|
186
|
-
source,
|
|
187
|
-
nextAction: flags.write
|
|
188
|
-
? "Review the generated safety settings and run rehearsal doctor."
|
|
189
|
-
: "Review this preview, then rerun rehearsal init --write to create it.",
|
|
190
|
-
};
|
|
191
|
-
};
|
|
192
|
-
|
|
193
147
|
const runSetup = async ({ flags, planOptions }) => {
|
|
194
148
|
const plan =
|
|
195
149
|
flags.setupPlan ??
|
|
196
150
|
(await planRehearsalSetup({
|
|
197
151
|
projectRoot,
|
|
198
|
-
|
|
152
|
+
configPath: planOptions.configPath,
|
|
153
|
+
target: flags.target,
|
|
199
154
|
}));
|
|
200
155
|
if (flags.write) await applyRehearsalSetup(plan);
|
|
201
156
|
const result = summarizeRehearsalSetup(plan, {
|
|
@@ -417,7 +372,7 @@ const executeCommand = async ({ command, flags, planOptions, guided }) => {
|
|
|
417
372
|
return;
|
|
418
373
|
}
|
|
419
374
|
if (command === "init") {
|
|
420
|
-
const data = await
|
|
375
|
+
const data = await runRehearsalInit({ projectRoot, flags });
|
|
421
376
|
emit({ command, data, flags, render: renderInit });
|
|
422
377
|
return;
|
|
423
378
|
}
|
package/src/cli/renderers.mjs
CHANGED
|
@@ -14,6 +14,16 @@ export const renderPlan = (plan, verbosity) => {
|
|
|
14
14
|
...plan.targets.flatMap((target) => [
|
|
15
15
|
`${target.name} — ${target.plan.config.project}`,
|
|
16
16
|
` ✓ ${target.plan.environment.kind}`,
|
|
17
|
+
...(target.plan.environment.authenticationProviders.length
|
|
18
|
+
? [
|
|
19
|
+
` ✓ External identity providers ${target.plan.environment.authenticationProviders.join(", ")}`,
|
|
20
|
+
...(target.plan.environment.authenticationCallback
|
|
21
|
+
? [
|
|
22
|
+
` → Register callback ${target.plan.environment.authenticationCallback}`,
|
|
23
|
+
]
|
|
24
|
+
: []),
|
|
25
|
+
]
|
|
26
|
+
: []),
|
|
17
27
|
` ✓ baseline ${target.plan.baseline.generationId}`,
|
|
18
28
|
` ✓ ${formatCount(target.plan.baseline.tableCount, "table")}; ${formatCount(target.plan.baseline.rowCount, "row")}`,
|
|
19
29
|
...(target.plan.migrations.candidates.length
|
|
@@ -69,6 +79,11 @@ export const renderPlan = (plan, verbosity) => {
|
|
|
69
79
|
...(plan.environment.authenticationProviders.length
|
|
70
80
|
? [
|
|
71
81
|
` ✓ External identity providers ${plan.environment.authenticationProviders.join(", ")}`,
|
|
82
|
+
...(plan.environment.authenticationCallback
|
|
83
|
+
? [
|
|
84
|
+
` → Register callback ${plan.environment.authenticationCallback}`,
|
|
85
|
+
]
|
|
86
|
+
: []),
|
|
72
87
|
]
|
|
73
88
|
: []),
|
|
74
89
|
"",
|
|
@@ -194,6 +209,15 @@ export const renderInit = (result) =>
|
|
|
194
209
|
`Detected package manager: ${result.detected.packageManager}`,
|
|
195
210
|
`Detected Supabase config: ${result.detected.hasSupabaseConfig ? "yes" : "no"}`,
|
|
196
211
|
`Detected migrations: ${result.detected.hasMigrations ? "yes" : "no"}`,
|
|
212
|
+
...(result.availableOptions?.length
|
|
213
|
+
? [
|
|
214
|
+
"",
|
|
215
|
+
"AVAILABLE BUT NOT ENABLED",
|
|
216
|
+
...result.availableOptions.map(
|
|
217
|
+
(option) => ` → ${option.path}: ${option.summary}`,
|
|
218
|
+
),
|
|
219
|
+
]
|
|
220
|
+
: []),
|
|
197
221
|
...(result.source ? ["", result.source] : []),
|
|
198
222
|
result.nextAction,
|
|
199
223
|
].join("\n");
|
|
@@ -566,8 +590,13 @@ export const renderIdentityPlan = ({ mode, plan }) =>
|
|
|
566
590
|
[
|
|
567
591
|
`LOCAL IDENTITY — ${mode.toUpperCase()}`,
|
|
568
592
|
`Identity: ${plan.review.name}`,
|
|
569
|
-
`
|
|
570
|
-
|
|
593
|
+
`Matcher: ${plan.review.matcher.type}`,
|
|
594
|
+
plan.review.matcher.type === "verified-email"
|
|
595
|
+
? `Providers: ${plan.review.matcher.providers.join(", ")}`
|
|
596
|
+
: `Provider: ${plan.review.matcher.provider}`,
|
|
597
|
+
plan.review.matcher.type === "verified-email"
|
|
598
|
+
? `Approved email receipt: ${plan.review.matcher.approvedEmailSha256}`
|
|
599
|
+
: `Approved subject receipt: ${plan.review.matcher.approvedSubjectSha256}`,
|
|
571
600
|
`Relational references: ${plan.review.references.length}`,
|
|
572
601
|
`Nested JSON references: ${plan.review.jsonReferences.length}`,
|
|
573
602
|
`Signup defaults: ${plan.review.signupDefaults.length}`,
|
package/src/identity/claim.mjs
CHANGED
|
@@ -12,6 +12,12 @@ const UUID =
|
|
|
12
12
|
/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/iu;
|
|
13
13
|
const SHA256 = /^[a-f0-9]{64}$/u;
|
|
14
14
|
const ENVIRONMENT_KEY = /^[A-Z][A-Z0-9_]*$/u;
|
|
15
|
+
const SUPPORTED_IDENTITY_PROVIDERS = Object.freeze([
|
|
16
|
+
"email",
|
|
17
|
+
"github",
|
|
18
|
+
"google",
|
|
19
|
+
]);
|
|
20
|
+
const identityPlanValues = new WeakMap();
|
|
15
21
|
|
|
16
22
|
const isObject = (value) =>
|
|
17
23
|
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -78,6 +84,114 @@ const defaultMatcher = (value, label) => {
|
|
|
78
84
|
return Object.freeze({ kind: "exact", value: jsonValue(value, label) });
|
|
79
85
|
};
|
|
80
86
|
|
|
87
|
+
const provider = (value, label, { subject = false } = {}) => {
|
|
88
|
+
if (!SUPPORTED_IDENTITY_PROVIDERS.includes(value)) {
|
|
89
|
+
throw new Error(`${label} is unsupported.`);
|
|
90
|
+
}
|
|
91
|
+
if (subject && value === "email") {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`${label} cannot use email with a provider-subject matcher.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const normalizeIdentityMatcher = (entry, label) => {
|
|
100
|
+
const legacyKeys = [
|
|
101
|
+
"provider",
|
|
102
|
+
"emailEnvironmentVariable",
|
|
103
|
+
"approvedEmailSha256",
|
|
104
|
+
];
|
|
105
|
+
const hasLegacyMatcher = legacyKeys.some((key) => entry[key] !== undefined);
|
|
106
|
+
if (entry.matcher !== undefined && hasLegacyMatcher) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`${label} cannot combine matcher with legacy provider and email fields.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (entry.matcher === undefined) {
|
|
112
|
+
const legacyProvider = provider(entry.provider, `${label}.provider`);
|
|
113
|
+
if (!ENVIRONMENT_KEY.test(entry.emailEnvironmentVariable ?? "")) {
|
|
114
|
+
throw new Error(`${label}.emailEnvironmentVariable is invalid.`);
|
|
115
|
+
}
|
|
116
|
+
if (!SHA256.test(entry.approvedEmailSha256 ?? "")) {
|
|
117
|
+
throw new Error(`${label}.approvedEmailSha256 must be a SHA-256.`);
|
|
118
|
+
}
|
|
119
|
+
return Object.freeze({
|
|
120
|
+
type: "verified-email",
|
|
121
|
+
providers: Object.freeze([legacyProvider]),
|
|
122
|
+
emailEnvironmentVariable: entry.emailEnvironmentVariable,
|
|
123
|
+
approvedEmailSha256: entry.approvedEmailSha256,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (!isObject(entry.matcher)) {
|
|
127
|
+
throw new Error(`${label}.matcher must be an object.`);
|
|
128
|
+
}
|
|
129
|
+
const matcherLabel = `${label}.matcher`;
|
|
130
|
+
if (entry.matcher.type === "verified-email") {
|
|
131
|
+
keys(
|
|
132
|
+
entry.matcher,
|
|
133
|
+
["type", "providers", "emailEnvironmentVariable", "approvedEmailSha256"],
|
|
134
|
+
matcherLabel,
|
|
135
|
+
);
|
|
136
|
+
if (
|
|
137
|
+
!Array.isArray(entry.matcher.providers) ||
|
|
138
|
+
entry.matcher.providers.length === 0
|
|
139
|
+
) {
|
|
140
|
+
throw new Error(`${matcherLabel}.providers must not be empty.`);
|
|
141
|
+
}
|
|
142
|
+
const providers = entry.matcher.providers.map((value, index) =>
|
|
143
|
+
provider(value, `${matcherLabel}.providers[${index}]`),
|
|
144
|
+
);
|
|
145
|
+
if (new Set(providers).size !== providers.length) {
|
|
146
|
+
throw new Error(`${matcherLabel}.providers must not contain duplicates.`);
|
|
147
|
+
}
|
|
148
|
+
if (!ENVIRONMENT_KEY.test(entry.matcher.emailEnvironmentVariable ?? "")) {
|
|
149
|
+
throw new Error(`${matcherLabel}.emailEnvironmentVariable is invalid.`);
|
|
150
|
+
}
|
|
151
|
+
if (!SHA256.test(entry.matcher.approvedEmailSha256 ?? "")) {
|
|
152
|
+
throw new Error(`${matcherLabel}.approvedEmailSha256 must be a SHA-256.`);
|
|
153
|
+
}
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
type: "verified-email",
|
|
156
|
+
providers: Object.freeze(providers),
|
|
157
|
+
emailEnvironmentVariable: entry.matcher.emailEnvironmentVariable,
|
|
158
|
+
approvedEmailSha256: entry.matcher.approvedEmailSha256,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (entry.matcher.type === "provider-subject") {
|
|
162
|
+
keys(
|
|
163
|
+
entry.matcher,
|
|
164
|
+
[
|
|
165
|
+
"type",
|
|
166
|
+
"provider",
|
|
167
|
+
"subjectEnvironmentVariable",
|
|
168
|
+
"approvedSubjectSha256",
|
|
169
|
+
],
|
|
170
|
+
matcherLabel,
|
|
171
|
+
);
|
|
172
|
+
const subjectProvider = provider(
|
|
173
|
+
entry.matcher.provider,
|
|
174
|
+
`${matcherLabel}.provider`,
|
|
175
|
+
{ subject: true },
|
|
176
|
+
);
|
|
177
|
+
if (!ENVIRONMENT_KEY.test(entry.matcher.subjectEnvironmentVariable ?? "")) {
|
|
178
|
+
throw new Error(`${matcherLabel}.subjectEnvironmentVariable is invalid.`);
|
|
179
|
+
}
|
|
180
|
+
if (!SHA256.test(entry.matcher.approvedSubjectSha256 ?? "")) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`${matcherLabel}.approvedSubjectSha256 must be a SHA-256.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return Object.freeze({
|
|
186
|
+
type: "provider-subject",
|
|
187
|
+
provider: subjectProvider,
|
|
188
|
+
subjectEnvironmentVariable: entry.matcher.subjectEnvironmentVariable,
|
|
189
|
+
approvedSubjectSha256: entry.matcher.approvedSubjectSha256,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
throw new Error(`${matcherLabel}.type is unsupported.`);
|
|
193
|
+
};
|
|
194
|
+
|
|
81
195
|
export const validateIdentityPolicy = (policy) => {
|
|
82
196
|
if (
|
|
83
197
|
!isObject(policy) ||
|
|
@@ -100,6 +214,7 @@ export const validateIdentityPolicy = (policy) => {
|
|
|
100
214
|
if (
|
|
101
215
|
![
|
|
102
216
|
"name",
|
|
217
|
+
"matcher",
|
|
103
218
|
"provider",
|
|
104
219
|
"emailEnvironmentVariable",
|
|
105
220
|
"approvedEmailSha256",
|
|
@@ -121,14 +236,7 @@ export const validateIdentityPolicy = (policy) => {
|
|
|
121
236
|
const name = entry.name;
|
|
122
237
|
if (names.has(name)) throw new Error(`Identity policy duplicates ${name}.`);
|
|
123
238
|
names.add(name);
|
|
124
|
-
|
|
125
|
-
throw new Error(`${label}.provider is unsupported.`);
|
|
126
|
-
if (!ENVIRONMENT_KEY.test(entry.emailEnvironmentVariable ?? "")) {
|
|
127
|
-
throw new Error(`${label}.emailEnvironmentVariable is invalid.`);
|
|
128
|
-
}
|
|
129
|
-
if (!SHA256.test(entry.approvedEmailSha256 ?? "")) {
|
|
130
|
-
throw new Error(`${label}.approvedEmailSha256 must be a SHA-256.`);
|
|
131
|
-
}
|
|
239
|
+
const matcher = normalizeIdentityMatcher(entry, label);
|
|
132
240
|
if (!UUID.test(entry.placeholderUserId ?? ""))
|
|
133
241
|
throw new Error(`${label}.placeholderUserId is invalid.`);
|
|
134
242
|
const references = (entry.references ?? []).map(
|
|
@@ -341,9 +449,7 @@ export const validateIdentityPolicy = (policy) => {
|
|
|
341
449
|
}
|
|
342
450
|
return Object.freeze({
|
|
343
451
|
name,
|
|
344
|
-
|
|
345
|
-
emailEnvironmentVariable: entry.emailEnvironmentVariable,
|
|
346
|
-
approvedEmailSha256: entry.approvedEmailSha256,
|
|
452
|
+
matcher,
|
|
347
453
|
placeholderUserId: entry.placeholderUserId.toLowerCase(),
|
|
348
454
|
references: Object.freeze(references),
|
|
349
455
|
jsonReferences: Object.freeze(jsonReferences),
|
|
@@ -368,20 +474,32 @@ export const createIdentityClaimPlan = ({
|
|
|
368
474
|
const normalized = validateIdentityPolicy(policy);
|
|
369
475
|
const identity = normalized.identities.find((entry) => entry.name === name);
|
|
370
476
|
if (!identity) throw new Error(`Identity policy does not declare ${name}.`);
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
.
|
|
374
|
-
|
|
477
|
+
const matcher = identity.matcher;
|
|
478
|
+
const environmentVariable =
|
|
479
|
+
matcher.type === "verified-email"
|
|
480
|
+
? matcher.emailEnvironmentVariable
|
|
481
|
+
: matcher.subjectEnvironmentVariable;
|
|
482
|
+
const rawValue = environment[environmentVariable];
|
|
483
|
+
const matchValue =
|
|
484
|
+
matcher.type === "verified-email"
|
|
485
|
+
? rawValue?.trim().toLowerCase()
|
|
486
|
+
: rawValue?.trim();
|
|
487
|
+
const approvedSha256 =
|
|
488
|
+
matcher.type === "verified-email"
|
|
489
|
+
? matcher.approvedEmailSha256
|
|
490
|
+
: matcher.approvedSubjectSha256;
|
|
491
|
+
if (!matchValue || hash(matchValue) !== approvedSha256) {
|
|
375
492
|
throw new Error(
|
|
376
|
-
|
|
493
|
+
matcher.type === "verified-email"
|
|
494
|
+
? "Local identity email does not match the reviewed receipt."
|
|
495
|
+
: "Local identity provider subject does not match the reviewed receipt.",
|
|
377
496
|
);
|
|
378
497
|
}
|
|
379
498
|
const review = {
|
|
380
|
-
planVersion:
|
|
499
|
+
planVersion: 3,
|
|
381
500
|
operation: "claim-local-identity",
|
|
382
501
|
name: identity.name,
|
|
383
|
-
|
|
384
|
-
approvedEmailSha256: identity.approvedEmailSha256,
|
|
502
|
+
matcher,
|
|
385
503
|
placeholderUserId: identity.placeholderUserId,
|
|
386
504
|
references: identity.references,
|
|
387
505
|
jsonReferences: identity.jsonReferences,
|
|
@@ -404,12 +522,13 @@ export const createIdentityClaimPlan = ({
|
|
|
404
522
|
}
|
|
405
523
|
: null,
|
|
406
524
|
};
|
|
407
|
-
|
|
525
|
+
const plan = {
|
|
408
526
|
identity,
|
|
409
|
-
email,
|
|
410
527
|
review: Object.freeze(review),
|
|
411
528
|
digest: hash(canonical(review)),
|
|
412
|
-
}
|
|
529
|
+
};
|
|
530
|
+
identityPlanValues.set(plan, matchValue);
|
|
531
|
+
return Object.freeze(plan);
|
|
413
532
|
};
|
|
414
533
|
|
|
415
534
|
const inspectDeclaredDefault = ({ row, declaration }) => {
|
|
@@ -492,6 +611,84 @@ const countIdentityRows = async ({ client, reference, userId }) => {
|
|
|
492
611
|
return count;
|
|
493
612
|
};
|
|
494
613
|
|
|
614
|
+
const findVerifiedLocalIdentity = async ({ client, plan, matchValue }) => {
|
|
615
|
+
const matcher = plan.identity.matcher;
|
|
616
|
+
if (matcher.type === "verified-email") {
|
|
617
|
+
const match = await client.query(
|
|
618
|
+
`select u.id::text, i.provider
|
|
619
|
+
from auth.users u
|
|
620
|
+
join auth.identities i on i.user_id = u.id
|
|
621
|
+
where lower(coalesce(i.identity_data->>'email', u.email)) = $1
|
|
622
|
+
and i.provider = any($2::text[])
|
|
623
|
+
and (
|
|
624
|
+
coalesce((i.identity_data->>'email_verified')::boolean, false)
|
|
625
|
+
or u.email_confirmed_at is not null
|
|
626
|
+
)
|
|
627
|
+
for update of u`,
|
|
628
|
+
[matchValue, matcher.providers],
|
|
629
|
+
);
|
|
630
|
+
if (match.rows.length !== 1) {
|
|
631
|
+
throw new Error(
|
|
632
|
+
match.rows.length === 0
|
|
633
|
+
? "No verified local identity matches the reviewed email and provider allowlist."
|
|
634
|
+
: "More than one verified local identity matches the reviewed email and provider allowlist.",
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
const matchedProvider =
|
|
638
|
+
match.rows[0].provider ??
|
|
639
|
+
(matcher.providers.length === 1 ? matcher.providers[0] : null);
|
|
640
|
+
if (!matcher.providers.includes(matchedProvider)) {
|
|
641
|
+
throw new Error("Matched local identity provider is invalid.");
|
|
642
|
+
}
|
|
643
|
+
return { id: match.rows[0].id, provider: matchedProvider };
|
|
644
|
+
}
|
|
645
|
+
const candidates = await client.query(
|
|
646
|
+
`select u.id::text,
|
|
647
|
+
i.provider,
|
|
648
|
+
i.provider_id::text,
|
|
649
|
+
i.identity_data->>'sub' as subject
|
|
650
|
+
from auth.users u
|
|
651
|
+
join auth.identities i on i.user_id = u.id
|
|
652
|
+
where i.provider = $1
|
|
653
|
+
and (
|
|
654
|
+
coalesce((i.identity_data->>'email_verified')::boolean, false)
|
|
655
|
+
or u.email_confirmed_at is not null
|
|
656
|
+
)
|
|
657
|
+
for update of u`,
|
|
658
|
+
[matcher.provider],
|
|
659
|
+
);
|
|
660
|
+
const matches = [];
|
|
661
|
+
for (const candidate of candidates.rows) {
|
|
662
|
+
const subjects = [candidate.provider_id, candidate.subject]
|
|
663
|
+
.filter((value) => typeof value === "string" && value.trim())
|
|
664
|
+
.map((value) => value.trim());
|
|
665
|
+
const uniqueSubjects = [...new Set(subjects)];
|
|
666
|
+
if (
|
|
667
|
+
uniqueSubjects.some(
|
|
668
|
+
(subject) => hash(subject) === matcher.approvedSubjectSha256,
|
|
669
|
+
)
|
|
670
|
+
) {
|
|
671
|
+
if (uniqueSubjects.length !== 1) {
|
|
672
|
+
throw new Error(
|
|
673
|
+
"The matched local provider identity has conflicting stable subjects.",
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
matches.push({ id: candidate.id, provider: candidate.provider });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (matches.length !== 1) {
|
|
680
|
+
throw new Error(
|
|
681
|
+
matches.length === 0
|
|
682
|
+
? "No verified local identity matches the reviewed provider subject."
|
|
683
|
+
: "More than one verified local identity matches the reviewed provider subject.",
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
if (matches[0].provider !== matcher.provider) {
|
|
687
|
+
throw new Error("Matched local identity provider is invalid.");
|
|
688
|
+
}
|
|
689
|
+
return matches[0];
|
|
690
|
+
};
|
|
691
|
+
|
|
495
692
|
export const applyIdentityClaim = async ({
|
|
496
693
|
plan,
|
|
497
694
|
confirmation,
|
|
@@ -504,6 +701,12 @@ export const applyIdentityClaim = async ({
|
|
|
504
701
|
`Identity claim confirmation does not match. Expected ${plan.digest}.`,
|
|
505
702
|
);
|
|
506
703
|
}
|
|
704
|
+
const matchValue = identityPlanValues.get(plan);
|
|
705
|
+
if (!matchValue) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
"Identity claim plans must be created from the reviewed local matcher environment.",
|
|
708
|
+
);
|
|
709
|
+
}
|
|
507
710
|
const target = new URL(connectionString);
|
|
508
711
|
if (!["127.0.0.1", "::1", "localhost"].includes(target.hostname)) {
|
|
509
712
|
throw new Error(
|
|
@@ -525,27 +728,12 @@ export const applyIdentityClaim = async ({
|
|
|
525
728
|
let storageObjectsTransferred = 0;
|
|
526
729
|
try {
|
|
527
730
|
await client.query("begin");
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
and (
|
|
535
|
-
coalesce((i.identity_data->>'email_verified')::boolean, false)
|
|
536
|
-
or u.email_confirmed_at is not null
|
|
537
|
-
)
|
|
538
|
-
for update of u`,
|
|
539
|
-
[plan.email, plan.identity.provider],
|
|
540
|
-
);
|
|
541
|
-
if (match.rows.length !== 1) {
|
|
542
|
-
throw new Error(
|
|
543
|
-
match.rows.length === 0
|
|
544
|
-
? "No single verified local provider identity matches the reviewed email."
|
|
545
|
-
: "More than one local provider identity matches the reviewed email.",
|
|
546
|
-
);
|
|
547
|
-
}
|
|
548
|
-
const localUserId = match.rows[0].id;
|
|
731
|
+
const localIdentity = await findVerifiedLocalIdentity({
|
|
732
|
+
client,
|
|
733
|
+
plan,
|
|
734
|
+
matchValue,
|
|
735
|
+
});
|
|
736
|
+
const localUserId = localIdentity.id;
|
|
549
737
|
if (!UUID.test(localUserId))
|
|
550
738
|
throw new Error("Matched local identity is invalid.");
|
|
551
739
|
const placeholderUser = await client.query(
|
|
@@ -932,7 +1120,7 @@ export const applyIdentityClaim = async ({
|
|
|
932
1120
|
}
|
|
933
1121
|
return {
|
|
934
1122
|
name: plan.identity.name,
|
|
935
|
-
provider:
|
|
1123
|
+
provider: localIdentity.provider,
|
|
936
1124
|
claimed: true,
|
|
937
1125
|
removedSignupDefaults,
|
|
938
1126
|
tokenHookVerified,
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/** Render reviewed local OAuth declarations into the disposable Supabase runtime. */
|
|
2
|
+
|
|
3
|
+
const escapePattern = (value) =>
|
|
4
|
+
value.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
5
|
+
|
|
6
|
+
const LEGACY_PROVIDER_KEYS = new Set([
|
|
7
|
+
"client_id",
|
|
8
|
+
"email_optional",
|
|
9
|
+
"enabled",
|
|
10
|
+
"redirect_uri",
|
|
11
|
+
"secret",
|
|
12
|
+
"skip_nonce_check",
|
|
13
|
+
]);
|
|
14
|
+
const SAFE_BOOLEAN_PROVIDER_KEYS = new Set([
|
|
15
|
+
"email_optional",
|
|
16
|
+
"skip_nonce_check",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const readTomlSections = (source) => {
|
|
20
|
+
const lines = source.split("\n");
|
|
21
|
+
const sections = [];
|
|
22
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
23
|
+
const match = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/u.exec(lines[index]);
|
|
24
|
+
if (!match) continue;
|
|
25
|
+
if (sections.length) sections.at(-1).end = index;
|
|
26
|
+
sections.push({ name: match[1], start: index, end: lines.length });
|
|
27
|
+
}
|
|
28
|
+
return { lines, sections };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const validateLegacyProviderSection = ({ lines, section, provider }) => {
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
for (const line of lines.slice(section.start + 1, section.end)) {
|
|
34
|
+
if (!line.trim() || /^\s*#/u.test(line)) continue;
|
|
35
|
+
const assignment = /^\s*([A-Za-z0-9_]+)\s*=/u.exec(line);
|
|
36
|
+
if (!assignment || !LEGACY_PROVIDER_KEYS.has(assignment[1])) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`The local Supabase template auth.external.${provider.name} section contains an unsupported setting. Rehearsal left the tracked template unchanged.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const key = assignment[1];
|
|
42
|
+
if (seen.has(key)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`The local Supabase template auth.external.${provider.name} section contains duplicate ${key} settings.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
seen.add(key);
|
|
48
|
+
if (
|
|
49
|
+
SAFE_BOOLEAN_PROVIDER_KEYS.has(key) &&
|
|
50
|
+
!new RegExp(
|
|
51
|
+
`^\\s*${escapePattern(key)}\\s*=\\s*${
|
|
52
|
+
key === "skip_nonce_check"
|
|
53
|
+
? (provider.skipNonceCheck ?? false)
|
|
54
|
+
: (provider.emailOptional ?? false)
|
|
55
|
+
}\\s*(?:#.*)?$`,
|
|
56
|
+
"u",
|
|
57
|
+
).test(line)
|
|
58
|
+
) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`The local Supabase template auth.external.${provider.name}.${key} setting must match its explicit declarative authentication option before Rehearsal can replace the legacy provider in its disposable runtime copy.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const inspectSupabaseAuthenticationProviderTransition = ({
|
|
67
|
+
source,
|
|
68
|
+
authentication,
|
|
69
|
+
}) => {
|
|
70
|
+
if (!authentication) return Object.freeze({ replacedProviders: [] });
|
|
71
|
+
const { lines, sections } = readTomlSections(source);
|
|
72
|
+
const replacementSections = [];
|
|
73
|
+
for (const provider of authentication.providers) {
|
|
74
|
+
const sectionName = `auth.external.${provider.name}`;
|
|
75
|
+
const nested = sections.filter((section) =>
|
|
76
|
+
section.name.startsWith(`${sectionName}.`),
|
|
77
|
+
);
|
|
78
|
+
if (nested.length) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`The local Supabase template contains unsupported nested ${sectionName} configuration. Rehearsal left the tracked template unchanged.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const matching = sections.filter((section) => section.name === sectionName);
|
|
84
|
+
if (matching.length > 1) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`The local Supabase template declares ${sectionName} more than once.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (matching.length === 1) {
|
|
90
|
+
validateLegacyProviderSection({
|
|
91
|
+
lines,
|
|
92
|
+
section: matching[0],
|
|
93
|
+
provider,
|
|
94
|
+
});
|
|
95
|
+
replacementSections.push({ ...matching[0], provider: provider.name });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
replacedProviders: Object.freeze(
|
|
100
|
+
replacementSections.map(({ provider }) => provider),
|
|
101
|
+
),
|
|
102
|
+
replacementSections: Object.freeze(replacementSections),
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const renderSupabaseAuthenticationProviders = ({
|
|
107
|
+
authentication,
|
|
108
|
+
apiPort,
|
|
109
|
+
}) => {
|
|
110
|
+
if (!authentication) return "";
|
|
111
|
+
return authentication.providers
|
|
112
|
+
.map(
|
|
113
|
+
(
|
|
114
|
+
provider,
|
|
115
|
+
) => `# Generated by Rehearsal from config.supabase.authentication.
|
|
116
|
+
[auth.external.${provider.name}]
|
|
117
|
+
enabled = true
|
|
118
|
+
client_id = "env(${provider.clientIdEnvironmentVariable})"
|
|
119
|
+
secret = "env(${provider.clientSecretEnvironmentVariable})"
|
|
120
|
+
redirect_uri = "http://127.0.0.1:${apiPort}/auth/v1/callback"
|
|
121
|
+
${provider.skipNonceCheck ? "skip_nonce_check = true\n" : ""}${provider.emailOptional ? "email_optional = true\n" : ""}
|
|
122
|
+
`,
|
|
123
|
+
)
|
|
124
|
+
.join("\n");
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const enableLocalSignup = (source) => {
|
|
128
|
+
const lines = source.split("\n");
|
|
129
|
+
let inAuth = false;
|
|
130
|
+
let authSections = 0;
|
|
131
|
+
let signupSettings = 0;
|
|
132
|
+
const updated = lines.map((line) => {
|
|
133
|
+
const section = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/u.exec(line);
|
|
134
|
+
if (section) {
|
|
135
|
+
inAuth = section[1] === "auth";
|
|
136
|
+
if (inAuth) authSections += 1;
|
|
137
|
+
return line;
|
|
138
|
+
}
|
|
139
|
+
if (!inAuth || !/^\s*enable_signup\s*=/u.test(line)) return line;
|
|
140
|
+
signupSettings += 1;
|
|
141
|
+
const comment = line.match(/\s+#.*$/u)?.[0] ?? "";
|
|
142
|
+
return `enable_signup = true${comment}`;
|
|
143
|
+
});
|
|
144
|
+
if (authSections !== 1 || signupSettings !== 1) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
"The local Supabase template must declare exactly one [auth] section with one enable_signup setting before Rehearsal can configure OAuth.",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return updated.join("\n");
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const applySupabaseAuthenticationProviders = ({
|
|
153
|
+
source,
|
|
154
|
+
authentication,
|
|
155
|
+
apiPort,
|
|
156
|
+
}) => {
|
|
157
|
+
if (!authentication) return source;
|
|
158
|
+
const transition = inspectSupabaseAuthenticationProviderTransition({
|
|
159
|
+
source,
|
|
160
|
+
authentication,
|
|
161
|
+
});
|
|
162
|
+
const { lines } = readTomlSections(source);
|
|
163
|
+
const replacedIndexes = new Set(
|
|
164
|
+
transition.replacementSections.flatMap((section) =>
|
|
165
|
+
Array.from(
|
|
166
|
+
{ length: section.end - section.start },
|
|
167
|
+
(_, offset) => section.start + offset,
|
|
168
|
+
),
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
const transitionedSource = lines
|
|
172
|
+
.filter((_, index) => !replacedIndexes.has(index))
|
|
173
|
+
.join("\n");
|
|
174
|
+
const runtimeSource = enableLocalSignup(transitionedSource);
|
|
175
|
+
const separator = runtimeSource.endsWith("\n") ? "\n" : "\n\n";
|
|
176
|
+
return `${runtimeSource}${separator}${renderSupabaseAuthenticationProviders({ authentication, apiPort })}`;
|
|
177
|
+
};
|