@rehearsal-db/core 0.1.0-beta.16 → 0.1.0-beta.18

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.
@@ -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 loadIdentityClaimPlan = async ({ flags, planOptions, }) => {
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 openRuntimeApplication = async ({ flags, planOptions, session: commandSession, onReady = () => undefined, }) => {
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 session = await startApplicationSession({
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: application.startCommand,
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 const findVerifiedLocalIdentity: ({ client, plan, matchValue, }: {
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 const findVerifiedLocalIdentity = async ({ client, plan, matchValue, }) => {
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
- for update of u`, [matchValue, matcher.providers]);
72
+ ${lock ? "for update of u" : ""}`, [matchValue, matcher.providers]);
64
73
  if (match.rows.length !== 1) {
65
- throw new Error(match.rows.length === 0
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 Error("Matched local identity is invalid.");
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 Error("Matched local identity provider is invalid.");
85
+ throw new IdentityLookupError("invalid", "Matched local identity provider is invalid.");
77
86
  }
78
87
  if (typeof matchedRow.id !== "string") {
79
- throw new Error("Matched local identity is invalid.");
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 Error("The matched local provider identity has conflicting stable subjects.");
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 Error("Matched local identity provider is invalid.");
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 Error(matches.length === 0
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 Error("Matched local identity provider is invalid.");
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/README.md CHANGED
@@ -32,6 +32,7 @@ want to accomplish and follow that path.
32
32
  ## Follow the project
33
33
 
34
34
  - [Roadmap](roadmap.md)
35
+ - [Stability and the first stable release](stability.md)
35
36
  - [Repository architecture](architecture.md)
36
37
  - [Release process](releasing.md)
37
38
 
@@ -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 this sequence:
76
+ For a reviewed Google, GitHub, or email identity, use the guided connection:
77
77
 
78
- 1. Run a successful rehearsal, then start the persistent app:
78
+ ```bash
79
+ npx rehearsal identity connect
80
+ ```
79
81
 
80
- ```bash
81
- npx rehearsal open
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
- 2. Open the printed local URL and complete the normal provider sign-in. Press `Ctrl+C`
85
- after the callback returns. This closes only the app; the databases and Storage stay
86
- running with their current data. Provider setup and credentials belong to the local
87
- Supabase runtime, not the identity policy.
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
- 3. Preview and confirm the reviewed association:
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
@@ -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/releasing.md CHANGED
@@ -58,16 +58,21 @@ The independent Rehearsal Test Lab is the primary pre-release consumer environme
58
58
  Real product integrations provide additional compatibility evidence; they do not
59
59
  replace the package-owned release gate or Test Lab.
60
60
 
61
- The workflow publishes prereleases under the `beta` dist-tag and refuses a stable
62
- version. While Rehearsal has no stable release, the workflow also moves `latest` to the
63
- same reviewed beta. This keeps the npm package page and the ordinary
64
- `npm install @rehearsal-db/core` command current. When Rehearsal gains a stable release,
65
- `latest` must switch to the stable line while `beta` continues to identify prereleases.
61
+ The workflow publishes beta and release-candidate versions under the `beta` dist-tag.
62
+ While Rehearsal has no stable release, it also moves `latest` to the same reviewed
63
+ prerelease. This keeps the npm package page and the ordinary
64
+ `npm install @rehearsal-db/core` command current. A stable version publishes under
65
+ `latest` without changing `beta`; after that point, later prereleases update only `beta`
66
+ and cannot replace the stable `latest` line. The GitHub prerelease setting must agree
67
+ with the package version or preparation fails.
68
+
69
+ The objective promotion checklist and compatibility boundary live in
70
+ [Stability and the first stable release](stability.md).
66
71
 
67
72
  The trusted publisher allows `npm dist-tag` only so this workflow can maintain those two
68
- tags without a stored npm token. A manual workflow run from `main` can repair the tags
69
- for the exact prerelease version currently recorded in `package.json`; it cannot publish
70
- a package or select a different version. The protected `npm` environment still supplies
73
+ tags without a stored npm token. A manual workflow run from `main` can repair the correct
74
+ channel for the exact version currently recorded in `package.json`; it cannot publish a
75
+ package or select a different version. The protected `npm` environment still supplies
71
76
  the human approval gate.
72
77
 
73
78
  Creating the repository, passing CI, extracting the engine, merging a release branch,