@rehearsal-db/core 0.1.0-beta.16 → 0.1.0-beta.17
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 +13 -0
- package/dist/package.json +1 -1
- package/dist/src/application/interactive_task.d.mts +30 -0
- package/dist/src/application/interactive_task.mjs +83 -0
- package/dist/src/application/session.d.mts +1 -0
- package/dist/src/cli/arguments.mjs +2 -0
- package/dist/src/cli/basic_command_handlers.mjs +1 -0
- package/dist/src/cli/cli_commands.mjs +2 -0
- package/dist/src/cli/guided.mjs +8 -0
- package/dist/src/cli/identity_command_handlers.d.mts +6 -2
- package/dist/src/cli/identity_command_handlers.mjs +216 -20
- package/dist/src/cli/identity_renderer.d.mts +8 -0
- package/dist/src/cli/identity_renderer.mjs +33 -0
- package/dist/src/cli/project_commands.d.mts +125 -1
- package/dist/src/cli/project_commands.mjs +11 -2
- package/dist/src/cli/runtime_commands.d.mts +25 -0
- package/dist/src/cli/runtime_commands.mjs +24 -6
- package/dist/src/identity/claim_identity_lookup.d.mts +7 -1
- package/dist/src/identity/claim_identity_lookup.mjs +20 -11
- package/dist/src/identity/connect.d.mts +30 -0
- package/dist/src/identity/connect.mjs +111 -0
- package/dist/src/project/configuration_rendering.mjs +2 -0
- package/docs/architecture.md +2 -0
- package/docs/commands.md +18 -15
- package/docs/configuration.md +1 -0
- package/docs/runtime-policies.md +12 -3
- package/docs/troubleshooting.md +17 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ a Changelog, and versions will follow Semantic Versioning after the package exis
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## [0.1.0-beta.17] - 2026-10-08
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `rehearsal identity connect` and the guided home screen now keep the sandbox app
|
|
13
|
+
available during ordinary provider sign-in, detect exactly one approved verified local
|
|
14
|
+
identity, show a redacted copied-account preview, and apply the existing exact claim
|
|
15
|
+
after one deliberate confirmation. The app is reopened automatically with an honest
|
|
16
|
+
fresh-session instruction; explicit `identity plan` and `identity claim` commands remain
|
|
17
|
+
available for scripts.
|
|
18
|
+
|
|
19
|
+
## [0.1.0-beta.16] - 2026-10-08
|
|
20
|
+
|
|
8
21
|
### Changed
|
|
9
22
|
|
|
10
23
|
- The CLI now routes guided and scripted requests through focused typed command
|
package/dist/package.json
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Run identity discovery while an owned local application remains available. */
|
|
2
|
+
import type { EventEmitter } from "node:events";
|
|
3
|
+
import { type ApplicationDiagnostics, type ApplicationSession, type ApplicationSessionSignal } from "./session.mjs";
|
|
4
|
+
type SignalTarget = Pick<EventEmitter, "prependListener" | "removeListener">;
|
|
5
|
+
interface SessionInput {
|
|
6
|
+
readonly isTTY?: boolean;
|
|
7
|
+
prependListener(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
8
|
+
removeListener(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A terminal interruption aborts the task and retires the owned process group.
|
|
12
|
+
* A completed task leaves the session running so its caller can deliberately
|
|
13
|
+
* stop, reuse, or hand it back to the persistent application holder.
|
|
14
|
+
*/
|
|
15
|
+
export declare const runApplicationSessionTask: <Value>({ session, task, signalTarget, input, onReady, }: {
|
|
16
|
+
session: ApplicationSession;
|
|
17
|
+
task: (signal: AbortSignal) => Promise<Value>;
|
|
18
|
+
signalTarget?: SignalTarget;
|
|
19
|
+
input?: SessionInput;
|
|
20
|
+
onReady?: (ready: ApplicationSession["ready"]) => void;
|
|
21
|
+
}) => Promise<Readonly<{
|
|
22
|
+
completed: true;
|
|
23
|
+
value: Value;
|
|
24
|
+
diagnostics: ApplicationDiagnostics;
|
|
25
|
+
}> | Readonly<{
|
|
26
|
+
completed: false;
|
|
27
|
+
signal: ApplicationSessionSignal;
|
|
28
|
+
diagnostics: ApplicationDiagnostics;
|
|
29
|
+
}>>;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Run identity discovery while an owned local application remains available. */
|
|
2
|
+
import { summarizeProjectCommandFailure, } from "./session.mjs";
|
|
3
|
+
const SESSION_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGTSTP"];
|
|
4
|
+
/**
|
|
5
|
+
* A terminal interruption aborts the task and retires the owned process group.
|
|
6
|
+
* A completed task leaves the session running so its caller can deliberately
|
|
7
|
+
* stop, reuse, or hand it back to the persistent application holder.
|
|
8
|
+
*/
|
|
9
|
+
export const runApplicationSessionTask = async ({ session, task, signalTarget = process, input = process.stdin, onReady = () => undefined, }) => {
|
|
10
|
+
if (!session ||
|
|
11
|
+
typeof session.wait !== "function" ||
|
|
12
|
+
typeof session.stop !== "function") {
|
|
13
|
+
throw new Error("A started application session is required.");
|
|
14
|
+
}
|
|
15
|
+
let finishInterruption = () => undefined;
|
|
16
|
+
const interruption = new Promise((resolve) => {
|
|
17
|
+
finishInterruption = resolve;
|
|
18
|
+
});
|
|
19
|
+
const handlers = new Map(SESSION_SIGNALS.map((signal) => [
|
|
20
|
+
signal,
|
|
21
|
+
() => finishInterruption({ kind: "interrupted", signal }),
|
|
22
|
+
]));
|
|
23
|
+
for (const [signal, handler] of handlers) {
|
|
24
|
+
signalTarget.prependListener(signal, handler);
|
|
25
|
+
}
|
|
26
|
+
const onInput = (chunk) => {
|
|
27
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
28
|
+
if (bytes.includes(0x03)) {
|
|
29
|
+
finishInterruption({ kind: "interrupted", signal: "SIGINT" });
|
|
30
|
+
}
|
|
31
|
+
else if (bytes.includes(0x1a)) {
|
|
32
|
+
finishInterruption({ kind: "interrupted", signal: "SIGTSTP" });
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
if (input?.isTTY)
|
|
36
|
+
input.prependListener("data", onInput);
|
|
37
|
+
const abortController = new AbortController();
|
|
38
|
+
try {
|
|
39
|
+
onReady(session.ready);
|
|
40
|
+
const outcome = await Promise.race([
|
|
41
|
+
interruption,
|
|
42
|
+
task(abortController.signal).then((value) => ({
|
|
43
|
+
kind: "completed",
|
|
44
|
+
value,
|
|
45
|
+
})),
|
|
46
|
+
session.wait().then((exit) => ({ kind: "exited", ...exit })),
|
|
47
|
+
]);
|
|
48
|
+
if (outcome.kind === "exited") {
|
|
49
|
+
throw new Error(`Application exited before identity connection finished (${summarizeProjectCommandFailure({
|
|
50
|
+
status: outcome.status,
|
|
51
|
+
signal: outcome.signal,
|
|
52
|
+
stdoutByteCount: session.diagnostics().stdoutBytes,
|
|
53
|
+
stderrByteCount: session.diagnostics().stderrBytes,
|
|
54
|
+
})}).`);
|
|
55
|
+
}
|
|
56
|
+
if (outcome.kind === "interrupted") {
|
|
57
|
+
abortController.abort(new Error(`Identity connection was interrupted by ${outcome.signal}.`));
|
|
58
|
+
await session.stop();
|
|
59
|
+
return Object.freeze({
|
|
60
|
+
completed: false,
|
|
61
|
+
signal: outcome.signal,
|
|
62
|
+
diagnostics: session.diagnostics(),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return Object.freeze({
|
|
66
|
+
completed: true,
|
|
67
|
+
value: outcome.value,
|
|
68
|
+
diagnostics: session.diagnostics(),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
abortController.abort(error);
|
|
73
|
+
await session.stop();
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
for (const [signal, handler] of handlers) {
|
|
78
|
+
signalTarget.removeListener(signal, handler);
|
|
79
|
+
}
|
|
80
|
+
if (input?.isTTY)
|
|
81
|
+
input.removeListener("data", onInput);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import type { EventEmitter } from "node:events";
|
|
4
4
|
declare const SESSION_SIGNALS: string[];
|
|
5
5
|
type SessionSignal = (typeof SESSION_SIGNALS)[number];
|
|
6
|
+
export type ApplicationSessionSignal = SessionSignal;
|
|
6
7
|
export interface ApplicationReadiness {
|
|
7
8
|
readonly url: string;
|
|
8
9
|
readonly expectedStatus?: number;
|
|
@@ -113,6 +113,8 @@ export const commandMutatesProject = ({ command, flags, guided, }) => {
|
|
|
113
113
|
return Boolean(flags.refreshConfirmation);
|
|
114
114
|
if (command === "identity claim")
|
|
115
115
|
return Boolean(flags.identityConfirmation);
|
|
116
|
+
if (command === "identity connect")
|
|
117
|
+
return true;
|
|
116
118
|
if (command === "run")
|
|
117
119
|
return !flags.dryRun;
|
|
118
120
|
return [
|
|
@@ -18,6 +18,7 @@ Commands:
|
|
|
18
18
|
refresh [--confirm-refresh=] Replace the database copy, reset locally, and remove old copies
|
|
19
19
|
identity plan --identity= Preview an approved local identity association
|
|
20
20
|
identity claim --identity= --confirm-identity= Apply the exact local association
|
|
21
|
+
identity connect [--identity=] Keep the app open, detect sign-in, and connect once
|
|
21
22
|
doctor Check whether Rehearsal is safe and ready
|
|
22
23
|
support Print a safe, copy-ready support report
|
|
23
24
|
explain Show the immutable execution plan
|
|
@@ -18,6 +18,8 @@ export const createCliCommandRegistry = ({ projectRoot, projectCommands, runtime
|
|
|
18
18
|
...createSourceCommandHandlers({ runRefreshWorkflow }),
|
|
19
19
|
...createIdentityCommandHandlers({
|
|
20
20
|
loadIdentityClaimPlan: projectCommands.loadIdentityClaimPlan,
|
|
21
|
+
loadIdentityPolicy: projectCommands.loadIdentityPolicy,
|
|
22
|
+
startRuntimeApplication: runtimeCommands.startRuntimeApplication,
|
|
21
23
|
}),
|
|
22
24
|
...createInspectionCommandHandlers(),
|
|
23
25
|
...createApplicationCommandHandlers({
|
package/dist/src/cli/guided.mjs
CHANGED
|
@@ -95,6 +95,7 @@ const inspectGuidedProject = async ({ projectRoot, planOptions, topologyCandidat
|
|
|
95
95
|
policyAbsolutePath: loaded.paths.sanitizationPolicy,
|
|
96
96
|
...(migrationSummary === undefined ? {} : { migrationSummary }),
|
|
97
97
|
refreshConfigured: Boolean(loaded.config.preparation),
|
|
98
|
+
identityConfigured: Boolean(loaded.paths.identityPolicy),
|
|
98
99
|
dependentCount: topology?.dependents.length ?? 0,
|
|
99
100
|
runtime: topology
|
|
100
101
|
? (await Promise.all(topology.targets.map((target) => pathExists(join(target.paths.runtimeWorkdir, "baseline.json"))))).every(Boolean)
|
|
@@ -334,6 +335,13 @@ const guidedOptions = (state, hasLastDetails) => {
|
|
|
334
335
|
options.push({ label: "Check readiness", command: "doctor" });
|
|
335
336
|
}
|
|
336
337
|
if (state.node.supported && state.runtime) {
|
|
338
|
+
if (state.identityConfigured) {
|
|
339
|
+
options.push({
|
|
340
|
+
label: "Connect my copied account",
|
|
341
|
+
hint: "Sign in normally, review once, then open the copied profile",
|
|
342
|
+
command: "identity connect",
|
|
343
|
+
});
|
|
344
|
+
}
|
|
337
345
|
options.push({
|
|
338
346
|
label: "Open the sandbox app",
|
|
339
347
|
hint: "Keep it running for hands-on testing",
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
/** Provider-neutral copied-account planning and
|
|
1
|
+
/** Provider-neutral copied-account planning, application, and guided connection. */
|
|
2
2
|
import type { CommandDefinition } from "./command_contract.mjs";
|
|
3
3
|
import type { createProjectCommands } from "./project_commands.mjs";
|
|
4
|
+
import type { createRuntimeCommands } from "./runtime_commands.mjs";
|
|
4
5
|
type ProjectCommands = ReturnType<typeof createProjectCommands>;
|
|
5
|
-
|
|
6
|
+
type RuntimeCommands = ReturnType<typeof createRuntimeCommands>;
|
|
7
|
+
export declare const createIdentityCommandHandlers: ({ loadIdentityClaimPlan, loadIdentityPolicy, startRuntimeApplication, }: {
|
|
6
8
|
loadIdentityClaimPlan: ProjectCommands["loadIdentityClaimPlan"];
|
|
9
|
+
loadIdentityPolicy: ProjectCommands["loadIdentityPolicy"];
|
|
10
|
+
startRuntimeApplication: RuntimeCommands["startRuntimeApplication"];
|
|
7
11
|
}) => readonly CommandDefinition[];
|
|
8
12
|
export {};
|
|
@@ -1,36 +1,232 @@
|
|
|
1
|
-
/** Provider-neutral copied-account planning and
|
|
1
|
+
/** Provider-neutral copied-account planning, application, and guided connection. */
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { parseEnv } from "node:util";
|
|
4
|
+
import * as prompts from "@clack/prompts";
|
|
5
|
+
import { holdApplicationSession } from "../application/session.mjs";
|
|
6
|
+
import { runApplicationSessionTask } from "../application/interactive_task.mjs";
|
|
4
7
|
import { applyIdentityClaim } from "../identity/claim.mjs";
|
|
8
|
+
import { waitForIdentityConnection } from "../identity/connect.mjs";
|
|
5
9
|
import { createSupabaseStorageTransfer } from "../identity/storage.mjs";
|
|
6
|
-
import { exactCommands, renderedCommand } from "./command_contract.mjs";
|
|
10
|
+
import { exactCommands, renderedCommand, textCommand, } from "./command_contract.mjs";
|
|
11
|
+
import { renderIdentityConnection } from "./identity_renderer.mjs";
|
|
7
12
|
import { renderIdentityPlan } from "./renderers.mjs";
|
|
8
|
-
|
|
9
|
-
|
|
13
|
+
import { isHumanTerminal, promptForConfirmation, promptForSelection, useStyledPrompts, } from "./terminal.mjs";
|
|
14
|
+
const loadIdentityRuntime = async (loaded) => {
|
|
15
|
+
const environment = parseEnv(await readFile(loaded.paths.applicationEnvironment, "utf8"));
|
|
16
|
+
if (!environment.DATABASE_URL) {
|
|
17
|
+
throw new Error("The local runtime environment has no DATABASE_URL.");
|
|
18
|
+
}
|
|
19
|
+
return environment;
|
|
20
|
+
};
|
|
21
|
+
const storageTransferFor = ({ plan, environment, }) => {
|
|
22
|
+
const needsStorageTransfer = plan.identity.assets.some((asset) => asset.rewritePath);
|
|
23
|
+
if (!needsStorageTransfer)
|
|
24
|
+
return undefined;
|
|
25
|
+
if (!environment.SUPABASE_URL || !environment.SUPABASE_SERVICE_ROLE_KEY) {
|
|
26
|
+
throw new Error("Identity asset transfer requires local Supabase URL and service-role credentials.");
|
|
27
|
+
}
|
|
28
|
+
return createSupabaseStorageTransfer({
|
|
29
|
+
url: environment.SUPABASE_URL,
|
|
30
|
+
serviceRoleKey: environment.SUPABASE_SERVICE_ROLE_KEY,
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
export const createIdentityCommandHandlers = ({ loadIdentityClaimPlan, loadIdentityPolicy, startRuntimeApplication, }) => [
|
|
34
|
+
exactCommands("identity", ["identity plan", "identity claim", "identity connect"], async ({ command, flags, planOptions, guided, state }) => {
|
|
35
|
+
if (command === "identity connect") {
|
|
36
|
+
if (!isHumanTerminal(flags)) {
|
|
37
|
+
throw new Error("identity connect requires an interactive terminal. Scripts should keep using identity plan and identity claim.");
|
|
38
|
+
}
|
|
39
|
+
if (!flags.identityName) {
|
|
40
|
+
const { policy } = await loadIdentityPolicy({
|
|
41
|
+
flags,
|
|
42
|
+
planOptions,
|
|
43
|
+
session: state,
|
|
44
|
+
});
|
|
45
|
+
if (policy.identities.length === 1) {
|
|
46
|
+
flags.identityName = policy.identities[0].name;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const selected = await promptForSelection({
|
|
50
|
+
message: "Which copied account would you like to connect?",
|
|
51
|
+
flags,
|
|
52
|
+
options: policy.identities.map((identity) => ({
|
|
53
|
+
label: identity.name,
|
|
54
|
+
value: identity.name,
|
|
55
|
+
})),
|
|
56
|
+
});
|
|
57
|
+
if (!selected) {
|
|
58
|
+
return textCommand("COPIED ACCOUNT CONNECTION CANCELLED\nNothing was changed.");
|
|
59
|
+
}
|
|
60
|
+
flags.identityName = selected;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const initial = await loadIdentityClaimPlan({
|
|
64
|
+
flags,
|
|
65
|
+
planOptions,
|
|
66
|
+
session: state,
|
|
67
|
+
});
|
|
68
|
+
const initialEnvironment = await loadIdentityRuntime(initial.loaded);
|
|
69
|
+
let active;
|
|
70
|
+
let reopened;
|
|
71
|
+
try {
|
|
72
|
+
active = await startRuntimeApplication({
|
|
73
|
+
flags,
|
|
74
|
+
planOptions,
|
|
75
|
+
session: state,
|
|
76
|
+
});
|
|
77
|
+
const observed = await runApplicationSessionTask({
|
|
78
|
+
session: active.session,
|
|
79
|
+
onReady: (ready) => {
|
|
80
|
+
const lines = [
|
|
81
|
+
`Application: ${ready.url}`,
|
|
82
|
+
"Sign in normally with the provider allowed by your reviewed identity policy.",
|
|
83
|
+
"Rehearsal will continue when exactly one approved, verified local identity appears.",
|
|
84
|
+
"It never reads your provider password, passkey, MFA secret, browser cookies, or session tokens.",
|
|
85
|
+
"Press Ctrl+C to cancel or Ctrl+Z to exit Rehearsal.",
|
|
86
|
+
];
|
|
87
|
+
if (useStyledPrompts(flags)) {
|
|
88
|
+
prompts.note(lines.join("\n"), "SIGN IN TO THE SANDBOX");
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
console.log(["", "SIGN IN TO THE SANDBOX", ...lines, ""].join("\n"));
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
task: (signal) => waitForIdentityConnection({
|
|
95
|
+
plan: initial.plan,
|
|
96
|
+
connectionString: initialEnvironment.DATABASE_URL,
|
|
97
|
+
signal,
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
if (!observed.completed) {
|
|
101
|
+
if (guided && observed.signal === "SIGTSTP") {
|
|
102
|
+
flags.exitGuidedSession = true;
|
|
103
|
+
}
|
|
104
|
+
return textCommand([
|
|
105
|
+
"COPIED ACCOUNT CONNECTION CANCELLED",
|
|
106
|
+
`Application stopped after ${observed.signal}.`,
|
|
107
|
+
"Nothing was changed. The database runtime was preserved.",
|
|
108
|
+
].join("\n"));
|
|
109
|
+
}
|
|
110
|
+
const preview = observed.value;
|
|
111
|
+
if (preview.associated) {
|
|
112
|
+
const closed = await holdApplicationSession({
|
|
113
|
+
session: active.session,
|
|
114
|
+
onReady: () => {
|
|
115
|
+
const message = renderIdentityConnection({
|
|
116
|
+
mode: "already-connected",
|
|
117
|
+
preview,
|
|
118
|
+
});
|
|
119
|
+
if (useStyledPrompts(flags)) {
|
|
120
|
+
prompts.note(message, "SANDBOX READY");
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
console.log(`\n${message}\n`);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
if (guided && closed.signal === "SIGTSTP") {
|
|
128
|
+
flags.exitGuidedSession = true;
|
|
129
|
+
}
|
|
130
|
+
return renderedCommand({
|
|
131
|
+
data: {
|
|
132
|
+
mode: "already-connected",
|
|
133
|
+
preview,
|
|
134
|
+
application: { stoppedBy: closed.signal },
|
|
135
|
+
},
|
|
136
|
+
render: ({ mode, preview: renderedPreview }) => renderIdentityConnection({ mode, preview: renderedPreview }),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
await active.session.stop();
|
|
140
|
+
console.log(`\n${renderIdentityConnection({ mode: "preview", preview })}\n`);
|
|
141
|
+
const accepted = await promptForConfirmation("Connect my copied account using exactly this plan?", flags);
|
|
142
|
+
if (!accepted) {
|
|
143
|
+
return renderedCommand({
|
|
144
|
+
data: { mode: "cancelled", preview },
|
|
145
|
+
render: ({ mode, preview: renderedPreview }) => renderIdentityConnection({ mode, preview: renderedPreview }),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const refreshed = await loadIdentityClaimPlan({
|
|
149
|
+
flags,
|
|
150
|
+
planOptions,
|
|
151
|
+
session: state,
|
|
152
|
+
});
|
|
153
|
+
if (refreshed.plan.digest !== preview.planDigest) {
|
|
154
|
+
throw new Error("The reviewed identity plan changed after sign-in. Nothing was changed; start the connection again.");
|
|
155
|
+
}
|
|
156
|
+
const refreshedEnvironment = await loadIdentityRuntime(refreshed.loaded);
|
|
157
|
+
const storageTransfer = storageTransferFor({
|
|
158
|
+
plan: refreshed.plan,
|
|
159
|
+
environment: refreshedEnvironment,
|
|
160
|
+
});
|
|
161
|
+
const result = await applyIdentityClaim({
|
|
162
|
+
plan: refreshed.plan,
|
|
163
|
+
confirmation: preview.planDigest,
|
|
164
|
+
connectionString: refreshedEnvironment.DATABASE_URL,
|
|
165
|
+
...(storageTransfer === undefined ? {} : { storageTransfer }),
|
|
166
|
+
});
|
|
167
|
+
reopened = await startRuntimeApplication({
|
|
168
|
+
flags,
|
|
169
|
+
planOptions,
|
|
170
|
+
session: state,
|
|
171
|
+
});
|
|
172
|
+
const closed = await holdApplicationSession({
|
|
173
|
+
session: reopened.session,
|
|
174
|
+
onReady: (ready) => {
|
|
175
|
+
const lines = [
|
|
176
|
+
renderIdentityConnection({
|
|
177
|
+
mode: "connected",
|
|
178
|
+
preview,
|
|
179
|
+
result,
|
|
180
|
+
}),
|
|
181
|
+
"",
|
|
182
|
+
`Application: ${ready.url}`,
|
|
183
|
+
"The app is running again. Sign out and sign in once for fresh claims.",
|
|
184
|
+
"Press Ctrl+C to close the app or Ctrl+Z to exit Rehearsal.",
|
|
185
|
+
];
|
|
186
|
+
if (useStyledPrompts(flags)) {
|
|
187
|
+
prompts.note(lines.join("\n"), "ACCOUNT CONNECTED");
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
console.log(["", ...lines, ""].join("\n"));
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
if (guided && closed.signal === "SIGTSTP") {
|
|
195
|
+
flags.exitGuidedSession = true;
|
|
196
|
+
}
|
|
197
|
+
return renderedCommand({
|
|
198
|
+
data: {
|
|
199
|
+
mode: "connected",
|
|
200
|
+
preview,
|
|
201
|
+
result,
|
|
202
|
+
application: { stoppedBy: closed.signal },
|
|
203
|
+
},
|
|
204
|
+
render: ({ mode, preview: renderedPreview, result: claimResult }) => renderIdentityConnection({
|
|
205
|
+
mode,
|
|
206
|
+
preview: renderedPreview,
|
|
207
|
+
result: claimResult,
|
|
208
|
+
}),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await active?.session.stop();
|
|
213
|
+
await reopened?.session.stop();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
10
216
|
const { loaded, plan } = await loadIdentityClaimPlan({
|
|
11
217
|
flags,
|
|
12
218
|
planOptions,
|
|
219
|
+
session: state,
|
|
13
220
|
});
|
|
14
221
|
if (command === "identity plan" || !flags.identityConfirmation) {
|
|
15
222
|
const data = { mode: "preview", plan };
|
|
16
223
|
return renderedCommand({ data, render: renderIdentityPlan });
|
|
17
224
|
}
|
|
18
|
-
const runtimeEnvironment =
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (needsStorageTransfer &&
|
|
24
|
-
(!runtimeEnvironment.SUPABASE_URL ||
|
|
25
|
-
!runtimeEnvironment.SUPABASE_SERVICE_ROLE_KEY)) {
|
|
26
|
-
throw new Error("Identity asset transfer requires local Supabase URL and service-role credentials.");
|
|
27
|
-
}
|
|
28
|
-
const storageTransfer = needsStorageTransfer
|
|
29
|
-
? createSupabaseStorageTransfer({
|
|
30
|
-
url: runtimeEnvironment.SUPABASE_URL,
|
|
31
|
-
serviceRoleKey: runtimeEnvironment.SUPABASE_SERVICE_ROLE_KEY,
|
|
32
|
-
})
|
|
33
|
-
: undefined;
|
|
225
|
+
const runtimeEnvironment = await loadIdentityRuntime(loaded);
|
|
226
|
+
const storageTransfer = storageTransferFor({
|
|
227
|
+
plan,
|
|
228
|
+
environment: runtimeEnvironment,
|
|
229
|
+
});
|
|
34
230
|
const result = await applyIdentityClaim({
|
|
35
231
|
plan,
|
|
36
232
|
confirmation: flags.identityConfirmation,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Redacted terminal output for the interactive copied-account connection. */
|
|
2
|
+
import type { IdentityClaimResult } from "../identity/claim_contract.mjs";
|
|
3
|
+
import type { IdentityConnectionPreview } from "../identity/connect.mjs";
|
|
4
|
+
export declare const renderIdentityConnection: ({ mode, preview, result, }: {
|
|
5
|
+
mode: "preview" | "connected" | "already-connected" | "cancelled";
|
|
6
|
+
preview: IdentityConnectionPreview;
|
|
7
|
+
result?: IdentityClaimResult;
|
|
8
|
+
}) => string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Redacted terminal output for the interactive copied-account connection. */
|
|
2
|
+
export const renderIdentityConnection = ({ mode, preview, result, }) => [
|
|
3
|
+
`COPIED ACCOUNT — ${mode.replaceAll("-", " ").toUpperCase()}`,
|
|
4
|
+
`Account label: ${preview.name}`,
|
|
5
|
+
`Verified provider: ${preview.provider}`,
|
|
6
|
+
`Data groups: ${preview.relationalGroups} relational; ${preview.nestedJsonGroups} nested JSON`,
|
|
7
|
+
`Images: ${preview.assetScopes} reviewed Storage scope${preview.assetScopes === 1 ? "" : "s"}; ${preview.storagePathGroups} path reference group${preview.storagePathGroups === 1 ? "" : "s"}`,
|
|
8
|
+
`Permissions: ${preview.claimKeys} reviewed claim${preview.claimKeys === 1 ? "" : "s"}; token hook ${preview.tokenHookConfigured ? "verified during connection" : "not configured"}`,
|
|
9
|
+
`Immutable history: ${preview.preservedAuditGroups} reference group${preview.preservedAuditGroups === 1 ? "" : "s"} stays with the copied audit identity`,
|
|
10
|
+
`Plan: ${preview.planDigest.slice(0, 12)}`,
|
|
11
|
+
"",
|
|
12
|
+
...(mode === "preview"
|
|
13
|
+
? [
|
|
14
|
+
"This changes only the disposable local sandbox.",
|
|
15
|
+
"Edited or independent local account data will make the connection stop safely.",
|
|
16
|
+
"Nothing has changed yet.",
|
|
17
|
+
]
|
|
18
|
+
: mode === "cancelled"
|
|
19
|
+
? [
|
|
20
|
+
"Nothing was changed. Your local database runtime is still available.",
|
|
21
|
+
]
|
|
22
|
+
: mode === "already-connected"
|
|
23
|
+
? [
|
|
24
|
+
"This copied account is already connected in the current local runtime.",
|
|
25
|
+
"No transfer was repeated.",
|
|
26
|
+
]
|
|
27
|
+
: [
|
|
28
|
+
`Transferred images: ${result?.storageObjectsTransferred ?? 0}`,
|
|
29
|
+
`Removed untouched signup defaults: ${result?.removedSignupDefaults ?? 0}`,
|
|
30
|
+
"The copied account is connected and verified.",
|
|
31
|
+
"Sign out and sign in once more so the application receives fresh role claims.",
|
|
32
|
+
]),
|
|
33
|
+
].join("\n");
|
|
@@ -27,7 +27,7 @@ export declare const createProjectCommands: ({ projectRoot, runManager, }: {
|
|
|
27
27
|
projectRoot: string;
|
|
28
28
|
runManager: RunManager;
|
|
29
29
|
}) => Readonly<{
|
|
30
|
-
loadIdentityClaimPlan: ({ flags, planOptions, }: ProjectCommandInput) => Promise<{
|
|
30
|
+
loadIdentityClaimPlan: ({ flags, planOptions, session, }: ProjectCommandInput) => Promise<{
|
|
31
31
|
loaded: Readonly<{
|
|
32
32
|
config: Readonly<{
|
|
33
33
|
schemaVersion: 1;
|
|
@@ -151,6 +151,130 @@ export declare const createProjectCommands: ({ projectRoot, runManager, }: {
|
|
|
151
151
|
}>;
|
|
152
152
|
plan: import("../identity/claim_contract.mjs").IdentityClaimPlan;
|
|
153
153
|
}>;
|
|
154
|
+
loadIdentityPolicy: ({ planOptions }: ProjectCommandInput) => Promise<{
|
|
155
|
+
loaded: Readonly<{
|
|
156
|
+
config: Readonly<{
|
|
157
|
+
schemaVersion: 1;
|
|
158
|
+
project: Readonly<{
|
|
159
|
+
name: string;
|
|
160
|
+
}>;
|
|
161
|
+
supabase: Readonly<{
|
|
162
|
+
workdir: string;
|
|
163
|
+
migrationDirectory: string;
|
|
164
|
+
rehearsalConfig: string;
|
|
165
|
+
runtimeWorkdir: string;
|
|
166
|
+
authentication: Readonly<{
|
|
167
|
+
enableLocalSignup: true;
|
|
168
|
+
environmentFile: string;
|
|
169
|
+
providers: readonly Readonly<{
|
|
170
|
+
name: "github" | "google";
|
|
171
|
+
clientIdEnvironmentVariable: string;
|
|
172
|
+
clientSecretEnvironmentVariable: string;
|
|
173
|
+
skipNonceCheck: boolean;
|
|
174
|
+
emailOptional: boolean;
|
|
175
|
+
}>[];
|
|
176
|
+
}> | null;
|
|
177
|
+
serviceEnvironmentFile: string | null;
|
|
178
|
+
serviceEnvironmentVariables: readonly string[];
|
|
179
|
+
}> | null;
|
|
180
|
+
postgresql: Readonly<{
|
|
181
|
+
migrationDirectory: string;
|
|
182
|
+
runtimeWorkdir: string;
|
|
183
|
+
image: string;
|
|
184
|
+
database: string;
|
|
185
|
+
user: string;
|
|
186
|
+
}> | null;
|
|
187
|
+
baseline: Readonly<{
|
|
188
|
+
artifactDirectory: string;
|
|
189
|
+
sanitizationPolicy: string;
|
|
190
|
+
}>;
|
|
191
|
+
containerRuntime: Readonly<{
|
|
192
|
+
autoStartColima: boolean;
|
|
193
|
+
}>;
|
|
194
|
+
cleanup: Readonly<{
|
|
195
|
+
retainBaselineGenerations: number;
|
|
196
|
+
}>;
|
|
197
|
+
preparation: Readonly<{
|
|
198
|
+
sourcePolicy: string;
|
|
199
|
+
privacyKey: string;
|
|
200
|
+
batchRows: number;
|
|
201
|
+
maximumRows: number;
|
|
202
|
+
maximumBytes: number;
|
|
203
|
+
diskHeadroomBytes: number;
|
|
204
|
+
}> | null;
|
|
205
|
+
runtimePolicy: string | null;
|
|
206
|
+
identityPolicy: string | null;
|
|
207
|
+
dependentTargets: readonly Readonly<{
|
|
208
|
+
name: string;
|
|
209
|
+
configPath: string;
|
|
210
|
+
prepareCommand: string | null;
|
|
211
|
+
}>[];
|
|
212
|
+
application: Readonly<{
|
|
213
|
+
startCommand: string;
|
|
214
|
+
proofCommand: string;
|
|
215
|
+
environmentFile: string;
|
|
216
|
+
environmentVariables: Readonly<Record<string, string>>;
|
|
217
|
+
readiness: Readonly<{
|
|
218
|
+
url: string;
|
|
219
|
+
expectedStatus: number;
|
|
220
|
+
timeoutSeconds: number;
|
|
221
|
+
}> | null;
|
|
222
|
+
httpProofs: readonly Readonly<{
|
|
223
|
+
name: string;
|
|
224
|
+
kind: "positive" | "negative";
|
|
225
|
+
url: string;
|
|
226
|
+
method: string;
|
|
227
|
+
expectedStatus: number;
|
|
228
|
+
json: Readonly<{
|
|
229
|
+
path: readonly string[];
|
|
230
|
+
equals?: unknown;
|
|
231
|
+
notEquals?: unknown;
|
|
232
|
+
minimumItems?: number;
|
|
233
|
+
}> | null;
|
|
234
|
+
}>[];
|
|
235
|
+
runtimeAdapter: string | null;
|
|
236
|
+
}>;
|
|
237
|
+
runtime: Readonly<{
|
|
238
|
+
target: "postgresql" | "supabase";
|
|
239
|
+
applicationUrl: string;
|
|
240
|
+
projectId: string;
|
|
241
|
+
ports: Readonly<{
|
|
242
|
+
api?: number;
|
|
243
|
+
database: number;
|
|
244
|
+
studio?: number;
|
|
245
|
+
}>;
|
|
246
|
+
}>;
|
|
247
|
+
safety: Readonly<{
|
|
248
|
+
allowedHosts: readonly string[];
|
|
249
|
+
blockedEnvironmentVariables: readonly string[];
|
|
250
|
+
authenticationProviders: readonly string[];
|
|
251
|
+
hostedAccess: "disabled";
|
|
252
|
+
outboundNetwork: "deny";
|
|
253
|
+
}>;
|
|
254
|
+
verification: Readonly<{
|
|
255
|
+
commands: readonly string[];
|
|
256
|
+
}>;
|
|
257
|
+
}>;
|
|
258
|
+
configPath: string;
|
|
259
|
+
projectRoot: string;
|
|
260
|
+
paths: Readonly<{
|
|
261
|
+
artifactDirectory: string;
|
|
262
|
+
applicationEnvironment: string;
|
|
263
|
+
migrationDirectory: string;
|
|
264
|
+
rehearsalConfig: string | null;
|
|
265
|
+
runtimeWorkdir: string;
|
|
266
|
+
serviceEnvironment: string | null;
|
|
267
|
+
sanitizationPolicy: string;
|
|
268
|
+
sourcePolicy: string | null;
|
|
269
|
+
privacyKey: string | null;
|
|
270
|
+
runtimePolicy: string | null;
|
|
271
|
+
identityPolicy: string | null;
|
|
272
|
+
supabaseWorkdir: string | null;
|
|
273
|
+
runtimeAdapter: string | null;
|
|
274
|
+
}>;
|
|
275
|
+
}>;
|
|
276
|
+
policy: import("../identity/claim_contract.mjs").IdentityPolicy;
|
|
277
|
+
}>;
|
|
154
278
|
planRuntimeStackCleanup: ({ flags, planOptions, }: ProjectCommandInput) => Promise<RehearsalCleanupPlan | CleanupStackPlan>;
|
|
155
279
|
runBaselinePreparation: ({ flags, planOptions, session, }: ProjectCommandInput) => Promise<ReturnType<typeof summarizeBaselinePreparation>>;
|
|
156
280
|
runCleanup: ({ flags, planOptions, session, }: ProjectCommandInput) => Promise<unknown>;
|
|
@@ -156,15 +156,23 @@ export const createProjectCommands = ({ projectRoot, runManager, }) => {
|
|
|
156
156
|
});
|
|
157
157
|
return { mode: "written", plan, applied };
|
|
158
158
|
};
|
|
159
|
-
const
|
|
159
|
+
const loadIdentityPolicy = async ({ planOptions }) => {
|
|
160
160
|
const loaded = await loadRehearsalConfig(planOptions);
|
|
161
161
|
if (!loaded.paths.identityPolicy) {
|
|
162
162
|
throw new Error("No config.identityPolicy is configured.");
|
|
163
163
|
}
|
|
164
|
+
const policy = validateIdentityPolicy(JSON.parse(await readFile(loaded.paths.identityPolicy, "utf8")));
|
|
165
|
+
return { loaded, policy };
|
|
166
|
+
};
|
|
167
|
+
const loadIdentityClaimPlan = async ({ flags, planOptions, session, }) => {
|
|
168
|
+
const { loaded, policy } = await loadIdentityPolicy({
|
|
169
|
+
flags,
|
|
170
|
+
planOptions,
|
|
171
|
+
...(session === undefined ? {} : { session }),
|
|
172
|
+
});
|
|
164
173
|
if (!flags.identityName) {
|
|
165
174
|
throw new Error("Choose a declared identity with --identity=<name>.");
|
|
166
175
|
}
|
|
167
|
-
const policy = validateIdentityPolicy(JSON.parse(await readFile(loaded.paths.identityPolicy, "utf8")));
|
|
168
176
|
return {
|
|
169
177
|
loaded,
|
|
170
178
|
plan: createIdentityClaimPlan({
|
|
@@ -175,6 +183,7 @@ export const createProjectCommands = ({ projectRoot, runManager, }) => {
|
|
|
175
183
|
};
|
|
176
184
|
return Object.freeze({
|
|
177
185
|
loadIdentityClaimPlan,
|
|
186
|
+
loadIdentityPolicy,
|
|
178
187
|
planRuntimeStackCleanup,
|
|
179
188
|
runBaselinePreparation,
|
|
180
189
|
runCleanup,
|
|
@@ -223,6 +223,31 @@ export declare const createRuntimeCommands: ({ packageRoot, projectRoot, manager
|
|
|
223
223
|
diagnostics: import("../application/session.mjs").ApplicationDiagnostics;
|
|
224
224
|
};
|
|
225
225
|
}>;
|
|
226
|
+
startRuntimeApplication: ({ flags, planOptions, session: commandSession, }: {
|
|
227
|
+
flags: RehearsalCliFlags;
|
|
228
|
+
planOptions: RehearsalConfigPathOptions;
|
|
229
|
+
session: CliSessionState;
|
|
230
|
+
}) => Promise<{
|
|
231
|
+
started: ManagerReceipt | Readonly<{
|
|
232
|
+
action: RuntimeAction;
|
|
233
|
+
durationMs: number;
|
|
234
|
+
output: string;
|
|
235
|
+
targets: readonly ManagerReceipt[];
|
|
236
|
+
}>;
|
|
237
|
+
verified: ManagerReceipt | Readonly<{
|
|
238
|
+
action: RuntimeAction;
|
|
239
|
+
durationMs: number;
|
|
240
|
+
output: string;
|
|
241
|
+
targets: readonly ManagerReceipt[];
|
|
242
|
+
}>;
|
|
243
|
+
preparations: {
|
|
244
|
+
command: string;
|
|
245
|
+
output: string;
|
|
246
|
+
target: string;
|
|
247
|
+
}[];
|
|
248
|
+
command: string;
|
|
249
|
+
session: import("../application/session.mjs").ApplicationSession;
|
|
250
|
+
}>;
|
|
226
251
|
prepareCandidateConfirmation: ({ command, flags, planOptions, session, }: {
|
|
227
252
|
command: RuntimeAction;
|
|
228
253
|
flags: RehearsalCliFlags;
|
|
@@ -479,7 +479,7 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
|
|
|
479
479
|
});
|
|
480
480
|
return { applicationProof, dependentProofs };
|
|
481
481
|
};
|
|
482
|
-
const
|
|
482
|
+
const startRuntimeApplication = async ({ flags, planOptions, session: commandSession, }) => {
|
|
483
483
|
const topology = await loadRuntimeTopology(planOptions);
|
|
484
484
|
const application = topology.primary.config.application;
|
|
485
485
|
if (!application.readiness) {
|
|
@@ -515,22 +515,39 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
|
|
|
515
515
|
topology,
|
|
516
516
|
environment: topologyCommandEnvironment(topology),
|
|
517
517
|
});
|
|
518
|
-
const
|
|
518
|
+
const applicationSession = await startApplicationSession({
|
|
519
519
|
command: application.startCommand,
|
|
520
520
|
cwd: topology.projectRoot,
|
|
521
521
|
files: topologyEnvironmentFiles(topology),
|
|
522
522
|
mappings: application.environmentVariables,
|
|
523
523
|
readiness: application.readiness,
|
|
524
524
|
});
|
|
525
|
-
const closed = await holdApplicationSession({ session, onReady });
|
|
526
525
|
return {
|
|
527
|
-
action: "open",
|
|
528
526
|
started: started.runtime,
|
|
529
527
|
verified: verified.runtime,
|
|
530
528
|
preparations,
|
|
529
|
+
command: application.startCommand,
|
|
530
|
+
session: applicationSession,
|
|
531
|
+
};
|
|
532
|
+
};
|
|
533
|
+
const openRuntimeApplication = async ({ flags, planOptions, session: commandSession, onReady = () => undefined, }) => {
|
|
534
|
+
const active = await startRuntimeApplication({
|
|
535
|
+
flags,
|
|
536
|
+
planOptions,
|
|
537
|
+
session: commandSession,
|
|
538
|
+
});
|
|
539
|
+
const closed = await holdApplicationSession({
|
|
540
|
+
session: active.session,
|
|
541
|
+
onReady,
|
|
542
|
+
});
|
|
543
|
+
return {
|
|
544
|
+
action: "open",
|
|
545
|
+
started: active.started,
|
|
546
|
+
verified: active.verified,
|
|
547
|
+
preparations: active.preparations,
|
|
531
548
|
application: {
|
|
532
|
-
command:
|
|
533
|
-
readiness: session.ready,
|
|
549
|
+
command: active.command,
|
|
550
|
+
readiness: active.session.ready,
|
|
534
551
|
stoppedBy: closed.signal,
|
|
535
552
|
diagnostics: closed.diagnostics,
|
|
536
553
|
},
|
|
@@ -592,6 +609,7 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
|
|
|
592
609
|
};
|
|
593
610
|
return {
|
|
594
611
|
openRuntimeApplication,
|
|
612
|
+
startRuntimeApplication,
|
|
595
613
|
prepareCandidateConfirmation,
|
|
596
614
|
prepareDependentTargets,
|
|
597
615
|
proveRuntimeStack,
|
|
@@ -10,8 +10,14 @@ export interface VerifiedLocalIdentity {
|
|
|
10
10
|
readonly id: string;
|
|
11
11
|
readonly provider: IdentityProvider;
|
|
12
12
|
}
|
|
13
|
-
export declare
|
|
13
|
+
export declare class IdentityLookupError extends Error {
|
|
14
|
+
readonly reason: "none" | "ambiguous" | "conflict" | "invalid";
|
|
15
|
+
constructor(reason: "none" | "ambiguous" | "conflict" | "invalid", message: string);
|
|
16
|
+
}
|
|
17
|
+
export declare const isIdentityNotFoundError: (error: unknown) => error is IdentityLookupError;
|
|
18
|
+
export declare const findVerifiedLocalIdentity: ({ client, plan, matchValue, lock, }: {
|
|
14
19
|
client: IdentityDatabaseClient;
|
|
15
20
|
plan: IdentityClaimPlan;
|
|
16
21
|
matchValue: string;
|
|
22
|
+
lock?: boolean;
|
|
17
23
|
}) => Promise<VerifiedLocalIdentity>;
|
|
@@ -48,7 +48,16 @@ export const countIdentityRows = async ({ client, reference, userId, }) => {
|
|
|
48
48
|
}
|
|
49
49
|
return count;
|
|
50
50
|
};
|
|
51
|
-
export
|
|
51
|
+
export class IdentityLookupError extends Error {
|
|
52
|
+
reason;
|
|
53
|
+
constructor(reason, message) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "IdentityLookupError";
|
|
56
|
+
this.reason = reason;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export const isIdentityNotFoundError = (error) => error instanceof IdentityLookupError && error.reason === "none";
|
|
60
|
+
export const findVerifiedLocalIdentity = async ({ client, plan, matchValue, lock = true, }) => {
|
|
52
61
|
const matcher = plan.identity.matcher;
|
|
53
62
|
if (matcher.type === "verified-email") {
|
|
54
63
|
const match = await client.query(`select u.id::text, i.provider
|
|
@@ -60,23 +69,23 @@ export const findVerifiedLocalIdentity = async ({ client, plan, matchValue, }) =
|
|
|
60
69
|
coalesce((i.identity_data->>'email_verified')::boolean, false)
|
|
61
70
|
or u.email_confirmed_at is not null
|
|
62
71
|
)
|
|
63
|
-
|
|
72
|
+
${lock ? "for update of u" : ""}`, [matchValue, matcher.providers]);
|
|
64
73
|
if (match.rows.length !== 1) {
|
|
65
|
-
throw new
|
|
74
|
+
throw new IdentityLookupError(match.rows.length === 0 ? "none" : "ambiguous", match.rows.length === 0
|
|
66
75
|
? "No verified local identity matches the reviewed email and provider allowlist."
|
|
67
76
|
: "More than one verified local identity matches the reviewed email and provider allowlist.");
|
|
68
77
|
}
|
|
69
78
|
const matchedRow = match.rows[0];
|
|
70
79
|
if (!matchedRow)
|
|
71
|
-
throw new
|
|
80
|
+
throw new IdentityLookupError("invalid", "Matched local identity is invalid.");
|
|
72
81
|
const matchedProvider = matchedRow.provider ??
|
|
73
82
|
(matcher.providers.length === 1 ? matcher.providers[0] : null);
|
|
74
83
|
if (typeof matchedProvider !== "string" ||
|
|
75
84
|
!matcher.providers.includes(matchedProvider)) {
|
|
76
|
-
throw new
|
|
85
|
+
throw new IdentityLookupError("invalid", "Matched local identity provider is invalid.");
|
|
77
86
|
}
|
|
78
87
|
if (typeof matchedRow.id !== "string") {
|
|
79
|
-
throw new
|
|
88
|
+
throw new IdentityLookupError("invalid", "Matched local identity is invalid.");
|
|
80
89
|
}
|
|
81
90
|
return {
|
|
82
91
|
id: matchedRow.id,
|
|
@@ -94,7 +103,7 @@ export const findVerifiedLocalIdentity = async ({ client, plan, matchValue, }) =
|
|
|
94
103
|
coalesce((i.identity_data->>'email_verified')::boolean, false)
|
|
95
104
|
or u.email_confirmed_at is not null
|
|
96
105
|
)
|
|
97
|
-
for update of u`, [matcher.provider]);
|
|
106
|
+
${lock ? "for update of u" : ""}`, [matcher.provider]);
|
|
98
107
|
const matches = [];
|
|
99
108
|
for (const candidate of candidates.rows) {
|
|
100
109
|
const subjects = [candidate.provider_id, candidate.subject]
|
|
@@ -103,23 +112,23 @@ export const findVerifiedLocalIdentity = async ({ client, plan, matchValue, }) =
|
|
|
103
112
|
const uniqueSubjects = [...new Set(subjects)];
|
|
104
113
|
if (uniqueSubjects.some((subject) => hash(subject) === matcher.approvedSubjectSha256)) {
|
|
105
114
|
if (uniqueSubjects.length !== 1) {
|
|
106
|
-
throw new
|
|
115
|
+
throw new IdentityLookupError("conflict", "The matched local provider identity has conflicting stable subjects.");
|
|
107
116
|
}
|
|
108
117
|
if (typeof candidate.id !== "string" ||
|
|
109
118
|
candidate.provider !== matcher.provider) {
|
|
110
|
-
throw new
|
|
119
|
+
throw new IdentityLookupError("invalid", "Matched local identity provider is invalid.");
|
|
111
120
|
}
|
|
112
121
|
matches.push({ id: candidate.id, provider: matcher.provider });
|
|
113
122
|
}
|
|
114
123
|
}
|
|
115
124
|
if (matches.length !== 1) {
|
|
116
|
-
throw new
|
|
125
|
+
throw new IdentityLookupError(matches.length === 0 ? "none" : "ambiguous", matches.length === 0
|
|
117
126
|
? "No verified local identity matches the reviewed provider subject."
|
|
118
127
|
: "More than one verified local identity matches the reviewed provider subject.");
|
|
119
128
|
}
|
|
120
129
|
const match = matches[0];
|
|
121
130
|
if (!match || match.provider !== matcher.provider) {
|
|
122
|
-
throw new
|
|
131
|
+
throw new IdentityLookupError("invalid", "Matched local identity provider is invalid.");
|
|
123
132
|
}
|
|
124
133
|
return match;
|
|
125
134
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Read-only discovery and redacted review for the guided account connection. */
|
|
2
|
+
import type { IdentityClaimPlan, IdentityClientFactory, IdentityProvider } from "./claim_contract.mjs";
|
|
3
|
+
export interface IdentityConnectionPreview {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly provider: IdentityProvider;
|
|
6
|
+
readonly planDigest: string;
|
|
7
|
+
readonly relationalGroups: number;
|
|
8
|
+
readonly preservedAuditGroups: number;
|
|
9
|
+
readonly nestedJsonGroups: number;
|
|
10
|
+
readonly signupDefaults: number;
|
|
11
|
+
readonly storagePathGroups: number;
|
|
12
|
+
readonly assetScopes: number;
|
|
13
|
+
readonly claimKeys: number;
|
|
14
|
+
readonly tokenHookConfigured: boolean;
|
|
15
|
+
readonly associated: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare const inspectIdentityConnection: ({ plan, connectionString, clientFactory, }: {
|
|
18
|
+
plan: IdentityClaimPlan;
|
|
19
|
+
connectionString: string;
|
|
20
|
+
clientFactory?: IdentityClientFactory;
|
|
21
|
+
}) => Promise<IdentityConnectionPreview>;
|
|
22
|
+
export declare const waitForIdentityConnection: ({ plan, connectionString, clientFactory, inspect, signal, pollIntervalMs, timeoutMs, }: {
|
|
23
|
+
plan: IdentityClaimPlan;
|
|
24
|
+
connectionString: string;
|
|
25
|
+
clientFactory?: IdentityClientFactory;
|
|
26
|
+
inspect?: typeof inspectIdentityConnection;
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
pollIntervalMs?: number;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
}) => Promise<IdentityConnectionPreview>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** Read-only discovery and redacted review for the guided account connection. */
|
|
2
|
+
import pg from "pg";
|
|
3
|
+
import { findVerifiedLocalIdentity, isIdentityNotFoundError, } from "./claim_identity_lookup.mjs";
|
|
4
|
+
import { getIdentityPlanMatchValue } from "./claim_plan.mjs";
|
|
5
|
+
const { Client } = pg;
|
|
6
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/iu;
|
|
7
|
+
const assertLocalDatabase = (connectionString) => {
|
|
8
|
+
let target;
|
|
9
|
+
try {
|
|
10
|
+
target = new URL(connectionString);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error("Identity connection requires a valid local database URL.");
|
|
14
|
+
}
|
|
15
|
+
if (!["postgres:", "postgresql:"].includes(target.protocol) ||
|
|
16
|
+
!["127.0.0.1", "::1", "localhost"].includes(target.hostname)) {
|
|
17
|
+
throw new Error("Identity connection may inspect only the local Rehearsal database.");
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const hasAssociationReceipt = async ({ client, name, placeholderUserId, localUserId, }) => {
|
|
21
|
+
const relation = await client.query("select to_regclass('rehearsal_internal.identity_claims')::text as relation");
|
|
22
|
+
if (relation.rows[0]?.relation !== "rehearsal_internal.identity_claims") {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const receipt = await client.query(`select 1
|
|
26
|
+
from rehearsal_internal.identity_claims
|
|
27
|
+
where identity_name = $1
|
|
28
|
+
and placeholder_user_id = $2::uuid
|
|
29
|
+
and local_user_id = $3::uuid
|
|
30
|
+
limit 1`, [name, placeholderUserId, localUserId]);
|
|
31
|
+
return receipt.rows.length === 1;
|
|
32
|
+
};
|
|
33
|
+
export const inspectIdentityConnection = async ({ plan, connectionString, clientFactory, }) => {
|
|
34
|
+
assertLocalDatabase(connectionString);
|
|
35
|
+
const matchValue = getIdentityPlanMatchValue(plan);
|
|
36
|
+
if (!matchValue) {
|
|
37
|
+
throw new Error("Identity connection plans must use the reviewed local matcher environment.");
|
|
38
|
+
}
|
|
39
|
+
const client = clientFactory
|
|
40
|
+
? await clientFactory(connectionString)
|
|
41
|
+
: new Client({
|
|
42
|
+
connectionString,
|
|
43
|
+
application_name: "rehearsal-identity-connect",
|
|
44
|
+
});
|
|
45
|
+
await client.connect?.();
|
|
46
|
+
try {
|
|
47
|
+
const localIdentity = await findVerifiedLocalIdentity({
|
|
48
|
+
client,
|
|
49
|
+
plan,
|
|
50
|
+
matchValue,
|
|
51
|
+
lock: false,
|
|
52
|
+
});
|
|
53
|
+
if (!UUID.test(localIdentity.id)) {
|
|
54
|
+
throw new Error("Matched local identity is invalid.");
|
|
55
|
+
}
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
name: plan.review.name,
|
|
58
|
+
provider: localIdentity.provider,
|
|
59
|
+
planDigest: plan.digest,
|
|
60
|
+
relationalGroups: plan.review.references.filter((reference) => reference.strategy === "transfer").length,
|
|
61
|
+
preservedAuditGroups: plan.review.references.filter((reference) => reference.strategy === "preserve-audit").length,
|
|
62
|
+
nestedJsonGroups: plan.review.jsonReferences.length,
|
|
63
|
+
signupDefaults: plan.review.signupDefaults.length,
|
|
64
|
+
storagePathGroups: plan.review.pathReferences.length,
|
|
65
|
+
assetScopes: plan.review.assets.length,
|
|
66
|
+
claimKeys: plan.review.claimKeys.length,
|
|
67
|
+
tokenHookConfigured: plan.review.tokenHook !== null,
|
|
68
|
+
associated: await hasAssociationReceipt({
|
|
69
|
+
client,
|
|
70
|
+
name: plan.identity.name,
|
|
71
|
+
placeholderUserId: plan.identity.placeholderUserId,
|
|
72
|
+
localUserId: localIdentity.id,
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
await client.end?.();
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const abortableDelay = (milliseconds, signal) => new Promise((resolve, reject) => {
|
|
81
|
+
if (signal?.aborted) {
|
|
82
|
+
reject(signal.reason ?? new Error("Identity connection was cancelled."));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const timeout = setTimeout(resolve, milliseconds);
|
|
86
|
+
signal?.addEventListener("abort", () => {
|
|
87
|
+
clearTimeout(timeout);
|
|
88
|
+
reject(signal.reason ?? new Error("Identity connection was cancelled."));
|
|
89
|
+
}, { once: true });
|
|
90
|
+
});
|
|
91
|
+
export const waitForIdentityConnection = async ({ plan, connectionString, clientFactory, inspect = inspectIdentityConnection, signal, pollIntervalMs = 750, timeoutMs = 15 * 60 * 1_000, }) => {
|
|
92
|
+
const deadline = Date.now() + timeoutMs;
|
|
93
|
+
while (Date.now() < deadline) {
|
|
94
|
+
if (signal?.aborted) {
|
|
95
|
+
throw signal.reason ?? new Error("Identity connection was cancelled.");
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return await inspect({
|
|
99
|
+
plan,
|
|
100
|
+
connectionString,
|
|
101
|
+
...(clientFactory === undefined ? {} : { clientFactory }),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (!isIdentityNotFoundError(error))
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
await abortableDelay(pollIntervalMs, signal);
|
|
109
|
+
}
|
|
110
|
+
throw new Error("Timed out waiting for the approved verified local identity. Nothing was changed.");
|
|
111
|
+
};
|
|
@@ -58,6 +58,7 @@ export default defineRehearsalConfig({
|
|
|
58
58
|
// runtimePolicy: "infrastructure/rehearsal/runtime-policy.json",
|
|
59
59
|
// Optional copied-account association. The JSON stores only reviewed matcher hashes;
|
|
60
60
|
// raw emails/subjects and provider credentials stay in ignored environment files.
|
|
61
|
+
// After setup, run: npx rehearsal identity connect
|
|
61
62
|
// identityPolicy: "infrastructure/rehearsal/identity-policy.json",
|
|
62
63
|
|
|
63
64
|
// Reuse any running Docker-compatible engine; start Colima only when needed.
|
|
@@ -162,6 +163,7 @@ export default defineRehearsalConfig({
|
|
|
162
163
|
// runtimePolicy: "infrastructure/rehearsal/runtime-policy.json",
|
|
163
164
|
// Optional copied-account association. The JSON stores only reviewed matcher hashes;
|
|
164
165
|
// raw emails/subjects and provider credentials stay in ignored environment files.
|
|
166
|
+
// After setup, run: npx rehearsal identity connect
|
|
165
167
|
// identityPolicy: "infrastructure/rehearsal/identity-policy.json",
|
|
166
168
|
|
|
167
169
|
// Reuse any running Docker-compatible engine; start Colima only when needed.
|
package/docs/architecture.md
CHANGED
|
@@ -142,6 +142,8 @@ and confirmed execution while keeping those responsibilities separate:
|
|
|
142
142
|
6. `claim_verification.mts` checks signup defaults and token-hook claim shapes.
|
|
143
143
|
7. `claim_executor.mts` alone owns the database transaction, Storage staging, rollback,
|
|
144
144
|
commit, old-object cleanup, and final receipt.
|
|
145
|
+
8. `connect.mts` performs read-only, provider-neutral sign-in discovery and produces the
|
|
146
|
+
redacted preview used by the interactive copied-account connection.
|
|
145
147
|
|
|
146
148
|
Repository contracts keep the facade at 50 lines or fewer and every identity-claim
|
|
147
149
|
stage at 600 lines or fewer. Provider callbacks and consumer application behavior stay
|
package/docs/commands.md
CHANGED
|
@@ -73,20 +73,26 @@ In short: `refresh` gets a new source copy; `reset` restores the copy you alread
|
|
|
73
73
|
|
|
74
74
|
## Local identity association
|
|
75
75
|
|
|
76
|
-
For a reviewed Google, GitHub, or email identity, use
|
|
76
|
+
For a reviewed Google, GitHub, or email identity, use the guided connection:
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
```bash
|
|
79
|
+
npx rehearsal identity connect
|
|
80
|
+
```
|
|
79
81
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
Rehearsal starts and checks the local runtime, keeps the configured application open,
|
|
83
|
+
and waits for an ordinary provider sign-in. It reads only the local Auth database—not
|
|
84
|
+
browser cookies, passwords, passkeys, MFA secrets, or session tokens. When exactly one
|
|
85
|
+
approved verified identity matches, it stops the owned app, shows a redacted preview,
|
|
86
|
+
and offers **Connect my copied account**. One confirmation binds the exact current plan.
|
|
87
|
+
After the verified transfer, Rehearsal reopens the app and asks for one sign-out/sign-in
|
|
88
|
+
so the application receives fresh role claims.
|
|
83
89
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
If the policy declares more than one copied identity, choose one with
|
|
91
|
+
`--identity=<reviewed-name>`. The interactive guide presents the reviewed names. A
|
|
92
|
+
completed connection is recognized from its local runtime receipt and is not repeated.
|
|
93
|
+
Resetting the runtime removes that receipt and requires a fresh review and connection.
|
|
88
94
|
|
|
89
|
-
|
|
95
|
+
For automation or detailed inspection, keep using the explicit commands:
|
|
90
96
|
|
|
91
97
|
```bash
|
|
92
98
|
npx rehearsal identity plan --identity=approved-owner
|
|
@@ -94,14 +100,11 @@ npx rehearsal identity claim --identity=approved-owner \
|
|
|
94
100
|
--confirm-identity=<sha256>
|
|
95
101
|
```
|
|
96
102
|
|
|
97
|
-
4. Run `npx rehearsal open` again. Sign out and sign in again so the browser receives a
|
|
98
|
-
fresh session, then check the copied account. Rehearsal does not bypass application
|
|
99
|
-
role checks, account blocks, RLS, or MFA.
|
|
100
|
-
|
|
101
103
|
The first command shows only hashes and declared reference counts. The second updates
|
|
102
104
|
only the reviewed local relational, JSON, Storage-owner, and claim locations in one
|
|
103
105
|
transaction. It rejects hosted database URLs, wrong or unverified people, ambiguity, and
|
|
104
|
-
stale confirmation digests.
|
|
106
|
+
stale confirmation digests. Reopen the application and sign in again afterward to obtain
|
|
107
|
+
fresh claims. Neither flow bypasses application roles, account blocks, RLS, or MFA.
|
|
105
108
|
|
|
106
109
|
If a runtime-policy seed moves with that identity, declare its `identityAssociation` in
|
|
107
110
|
the runtime policy. Later `verify` calls then require the transferred row instead of the
|
package/docs/configuration.md
CHANGED
|
@@ -79,6 +79,7 @@ export default defineRehearsalConfig({
|
|
|
79
79
|
// runtimePolicy: "infrastructure/rehearsal/runtime-policy.json",
|
|
80
80
|
// Optional copied-account association. The JSON stores reviewed matcher hashes only;
|
|
81
81
|
// raw emails/subjects and provider credentials stay in ignored environment files.
|
|
82
|
+
// After setup, run: npx rehearsal identity connect
|
|
82
83
|
// identityPolicy: "infrastructure/rehearsal/identity-policy.json",
|
|
83
84
|
containerRuntime: {
|
|
84
85
|
autoStartColima: true,
|
package/docs/runtime-policies.md
CHANGED
|
@@ -166,9 +166,18 @@ Authentication has three separate parts:
|
|
|
166
166
|
3. The identity policy matches that verified local identity and transfers only the
|
|
167
167
|
reviewed copied-account graph.
|
|
168
168
|
|
|
169
|
-
The email stays in an environment variable; only its reviewed hash is committed.
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
The email stays in an environment variable; only its reviewed hash is committed. The
|
|
170
|
+
guided command keeps the app available for ordinary sign-in, detects the reviewed local
|
|
171
|
+
Auth match, presents a redacted preview, and supplies the exact plan fingerprint
|
|
172
|
+
internally after one confirmation:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
npx rehearsal identity connect
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
It never intercepts provider credentials or reads browser state. After connection it
|
|
179
|
+
reopens the app; sign out and sign in once to receive fresh claims. Scripts may retain
|
|
180
|
+
the explicit plan and claim sequence:
|
|
172
181
|
|
|
173
182
|
```bash
|
|
174
183
|
npx rehearsal identity plan --identity=approved-owner
|
package/docs/troubleshooting.md
CHANGED
|
@@ -172,6 +172,23 @@ need; skipping nonce validation reduces replay protection. Otherwise keep using
|
|
|
172
172
|
documented legacy configuration.
|
|
173
173
|
Never paste provider secrets into tracked config or terminal output.
|
|
174
174
|
|
|
175
|
+
For the shortest supported copied-account flow, run:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
npx rehearsal identity connect
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Leave that terminal open, then sign in through the sandbox application normally. Rehearsal
|
|
182
|
+
waits for one verified match and shows a redacted plan before it changes anything. If it
|
|
183
|
+
keeps waiting, finish the provider callback and confirm the reviewed matcher environment
|
|
184
|
+
value is available in the terminal that started Rehearsal. `Ctrl+C`, `Ctrl+Z`, or choosing
|
|
185
|
+
**Not now** stops only the owned application and leaves the database unchanged.
|
|
186
|
+
|
|
187
|
+
After a successful connection, sign out and sign in once so the application receives
|
|
188
|
+
fresh role claims. Rehearsal remembers the connection in the current disposable runtime;
|
|
189
|
+
`reset` deliberately removes that receipt, so the next copied-account connection requires
|
|
190
|
+
review again.
|
|
191
|
+
|
|
175
192
|
If `identity plan` refuses the matcher, read the message literally:
|
|
176
193
|
|
|
177
194
|
- **Provider or matcher type is unsupported:** use only the combinations documented in
|
package/package.json
CHANGED