@lanes-sh/link 0.8.0 → 0.9.1
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/README.md +1 -25
- package/instructions/skills/lanes-link/SKILL.md +12 -12
- package/package.json +1 -1
- package/src/auth/oauth/store.ts +21 -5
- package/src/cli/accepts.ts +6 -5
- package/src/cli/brand.ts +20 -15
- package/src/cli/callback-page.ts +8 -7
- package/src/cli/commands/connect/index.ts +2 -1
- package/src/cli/commands/connect/settle.ts +12 -4
- package/src/cli/commands/connection.ts +2 -1
- package/src/cli/commands/identity.ts +4 -4
- package/src/cli/commands/knowledge/index.ts +9 -16
- package/src/cli/commands/knowledge/migrate.ts +9 -4
- package/src/cli/commands/knowledge/show.ts +14 -9
- package/src/cli/commands/operate/migrate.ts +5 -2
- package/src/cli/commands/operate/pair.ts +1 -1
- package/src/cli/commands/operate/serve.ts +1 -1
- package/src/cli/commands/owner/assets.ts +2 -2
- package/src/cli/commands/owner/entities.ts +2 -2
- package/src/cli/commands/owner/memory.ts +2 -2
- package/src/cli/commands/owner/tasks.ts +2 -2
- package/src/cli/commands/owner/vault.ts +3 -3
- package/src/cli/commands/profile/disposition.ts +236 -0
- package/src/cli/commands/profile/removal.ts +100 -13
- package/src/cli/commands/profile/remove.ts +67 -6
- package/src/cli/commands/profile.ts +33 -6
- package/src/cli/commands/secrets.ts +4 -4
- package/src/cli/commands/update-migration.ts +54 -0
- package/src/cli/commands/update.ts +36 -23
- package/src/cli/config-edit.ts +29 -8
- package/src/cli/config-repair-sweep.ts +183 -0
- package/src/cli/config-repair.ts +90 -141
- package/src/cli/config-templates.ts +26 -24
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +143 -209
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +34 -8
- package/src/cli/contract3.ts +141 -24
- package/src/cli/contract4-credentials.ts +207 -0
- package/src/cli/contract4-data.ts +399 -0
- package/src/cli/contract4-rename.ts +73 -0
- package/src/cli/contract4-yaml.ts +223 -0
- package/src/cli/contract4.ts +349 -0
- package/src/cli/identity.ts +44 -26
- package/src/cli/main.ts +6 -1
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +3 -3
- package/src/cli/publish.ts +1 -5
- package/src/cli/runtime/open.ts +5 -5
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +16 -11
- package/src/cli/runtime/vault.ts +2 -2
- package/src/cli/usage.ts +5 -1
- package/src/cli/workspace-migrate.ts +32 -11
- package/src/connectivity/manifest/provider.ts +31 -12
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/deploy.ts +5 -5
- package/src/deployments/gcp/bucket.ts +42 -6
- package/src/deployments/knowledge.ts +9 -4
- package/src/deployments/target.ts +28 -7
- package/src/deployments/upload.ts +39 -30
- package/src/profile/connections.ts +13 -1
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +5 -1
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +147 -71
- package/src/profile/load.ts +53 -17
- package/src/profile/schema.ts +11 -2
- package/src/profile/testing.ts +45 -10
- package/src/profile/workspace.ts +66 -30
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +6 -6
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/registry.ts +1 -1
- package/src/server/endpoint.ts +4 -0
- package/src/server/harness.ts +1 -1
- package/src/server/mcp/instructions.ts +21 -21
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +16 -3
- package/src/server/mcp/visibility.ts +56 -4
- package/src/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- package/src/stores/state/testing.ts +5 -1
package/src/cli/contract3.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { newConnectionsTemplate } from './config-templates.ts';
|
|
2
|
-
import {
|
|
2
|
+
import { planMoves } from './contract3-data.ts';
|
|
3
|
+
import { applyMoves } from './migrate-move.ts';
|
|
4
|
+
import {
|
|
5
|
+
mergeCredentials,
|
|
6
|
+
planCredentials,
|
|
7
|
+
connectionRefs,
|
|
8
|
+
type CredentialPlan,
|
|
9
|
+
} from './contract3-credentials.ts';
|
|
3
10
|
import { parseDocument } from 'yaml';
|
|
4
11
|
import {
|
|
5
12
|
CONNECTIONS_FILE,
|
|
6
13
|
ConfigError,
|
|
7
|
-
|
|
8
|
-
WORKSPACE_FILE,
|
|
14
|
+
isRemoteWorkspace,
|
|
9
15
|
layout,
|
|
10
16
|
listProfiles,
|
|
11
17
|
readWorkspaceFile,
|
|
@@ -15,7 +21,7 @@ import {
|
|
|
15
21
|
import { RESERVED_PROVIDER_IDS } from '#connectivity';
|
|
16
22
|
import { ConfigDocument } from './config-edit.ts';
|
|
17
23
|
import { grantsFor, hoistConnections } from './contract3-shape.ts';
|
|
18
|
-
|
|
24
|
+
import { C3 } from './contract3-layout.ts';
|
|
19
25
|
/**
|
|
20
26
|
* Contract 2 to contract 3: connections move out of the profile.
|
|
21
27
|
*
|
|
@@ -37,8 +43,8 @@ import { grantsFor, hoistConnections } from './contract3-shape.ts';
|
|
|
37
43
|
* the old flat policy, which is exactly what contract 2 meant.
|
|
38
44
|
* 4. Bytes move to their connection-keyed homes.
|
|
39
45
|
*
|
|
40
|
-
* The registry rename rides along at the end
|
|
41
|
-
*
|
|
46
|
+
* The registry rename rides along at the end: it is one document and cannot
|
|
47
|
+
* half-apply.
|
|
42
48
|
*/
|
|
43
49
|
|
|
44
50
|
/** What a divergent id was renamed to, and why. */
|
|
@@ -73,8 +79,18 @@ export interface LegacyProfile {
|
|
|
73
79
|
readonly connections?: LegacyConnection[];
|
|
74
80
|
readonly policy?: { allow?: unknown[]; deny?: unknown[] };
|
|
75
81
|
readonly oauth_apps?: Record<string, unknown>;
|
|
82
|
+
/**
|
|
83
|
+
* Read for `token_ref` alone, and read raw rather than through `authSchema`:
|
|
84
|
+
* this walks profiles that have not been validated, and a profile that fails
|
|
85
|
+
* validation for an unrelated reason still has an endpoint token to leave
|
|
86
|
+
* behind.
|
|
87
|
+
*/
|
|
88
|
+
readonly auth?: { token_ref?: string };
|
|
76
89
|
}
|
|
77
90
|
|
|
91
|
+
/** The schema default, and what every profile written by the CLI carries. */
|
|
92
|
+
const DEFAULT_TOKEN_REF = 'profile/token';
|
|
93
|
+
|
|
78
94
|
/** Whether this workspace still holds anything at contract 2. */
|
|
79
95
|
export async function needsContract3(workspaceRoot: string): Promise<boolean> {
|
|
80
96
|
for (const profile of await listProfiles(workspaceRoot)) {
|
|
@@ -85,7 +101,7 @@ export async function needsContract3(workspaceRoot: string): Promise<boolean> {
|
|
|
85
101
|
}
|
|
86
102
|
|
|
87
103
|
async function readProfile(root: string, profile: string): Promise<LegacyProfile | null> {
|
|
88
|
-
const text = await readWorkspaceFile(workspaceFiles(root),
|
|
104
|
+
const text = await readWorkspaceFile(workspaceFiles(root), C3.profile(profile));
|
|
89
105
|
if (text === null) return null;
|
|
90
106
|
try {
|
|
91
107
|
return parseDocument(text).toJSON() as LegacyProfile;
|
|
@@ -129,7 +145,14 @@ export async function migrateToContract3(
|
|
|
129
145
|
|
|
130
146
|
// Everything that can be computed is computed before the first write, so a
|
|
131
147
|
// refusal leaves the workspace exactly as it was.
|
|
132
|
-
const
|
|
148
|
+
const plans: CredentialPlan[] = [...legacy].map(([profile, config]) => ({
|
|
149
|
+
profile,
|
|
150
|
+
renames: connectionRefs(perProfile.get(profile) ?? new Map()),
|
|
151
|
+
tokenRef:
|
|
152
|
+
typeof config.auth?.token_ref === 'string' ? config.auth.token_ref : DEFAULT_TOKEN_REF,
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
const credentials = await planCredentials(workspaceRoot, plans);
|
|
133
156
|
const moves = await planMoves(files, [...legacy.keys()], perProfile);
|
|
134
157
|
|
|
135
158
|
const changes: string[] = [
|
|
@@ -137,8 +160,14 @@ export async function migrateToContract3(
|
|
|
137
160
|
...renames.map((rename) => `renamed ${rename.from} to ${rename.to} (${rename.reason})`),
|
|
138
161
|
...[...legacy.keys()].map((profile) => `profiles/${profile}.yaml: contract 3, grants`),
|
|
139
162
|
];
|
|
140
|
-
if (credentials.length > 0) {
|
|
141
|
-
changes.push(`${
|
|
163
|
+
if (credentials.refs.length > 0) {
|
|
164
|
+
changes.push(`${C3.credentials()}: ${credentials.refs.length} credential(s) merged`);
|
|
165
|
+
}
|
|
166
|
+
if (credentials.tokens.length > 0) {
|
|
167
|
+
changes.push(
|
|
168
|
+
`${credentials.tokens.join(', ')}: left behind — one endpoint token per workspace now, ` +
|
|
169
|
+
'and a fresh one is minted on the next command',
|
|
170
|
+
);
|
|
142
171
|
}
|
|
143
172
|
if (moves.length > 0) changes.push(`${moves.length} object(s) moved to their connection`);
|
|
144
173
|
|
|
@@ -147,7 +176,7 @@ export async function migrateToContract3(
|
|
|
147
176
|
profiles: [...legacy.keys()],
|
|
148
177
|
connections: rows.map(keyOf),
|
|
149
178
|
renames,
|
|
150
|
-
credentials,
|
|
179
|
+
credentials: credentials.refs,
|
|
151
180
|
moved: moves.map((move) => move.to),
|
|
152
181
|
changes,
|
|
153
182
|
alreadyCurrent: false,
|
|
@@ -170,7 +199,7 @@ export async function migrateToContract3(
|
|
|
170
199
|
// workspace" and there was no way back.
|
|
171
200
|
await rewriteRegistry(workspaceRoot);
|
|
172
201
|
await writeConnections(workspaceRoot, rows, legacy);
|
|
173
|
-
await mergeCredentials(workspaceRoot,
|
|
202
|
+
await mergeCredentials(workspaceRoot, plans);
|
|
174
203
|
|
|
175
204
|
// **The bytes move before the profile says they have.**
|
|
176
205
|
//
|
|
@@ -192,7 +221,25 @@ export async function migrateToContract3(
|
|
|
192
221
|
return result;
|
|
193
222
|
}
|
|
194
223
|
|
|
195
|
-
/**
|
|
224
|
+
/**
|
|
225
|
+
* The hoisted rows, plus every profile's `oauth_apps` merged into one block.
|
|
226
|
+
*
|
|
227
|
+
* **Anything already in the file survives**, which is the rule `writeRegistry`
|
|
228
|
+
* states for the same reason: a workspace part way through this has entries that
|
|
229
|
+
* are already right, and re-deriving them from what is left would undo a
|
|
230
|
+
* correction.
|
|
231
|
+
*
|
|
232
|
+
* Not a tidiness argument here. `rewriteProfiles` stamps profiles one at a time
|
|
233
|
+
* and `legacy` holds only those still at contract 2, so an interruption part way
|
|
234
|
+
* through it left a rerun hoisting a *subset* — and this overwrote the file with
|
|
235
|
+
* it, deleting the already-migrated profiles' rows. Their grants named those
|
|
236
|
+
* connections still. In the shape that actually bites, two profiles held
|
|
237
|
+
* `gmail.main` for different mailboxes and were hoisted to `gmail.main` and
|
|
238
|
+
* `gmail.main_2`; the rerun rebuilt the file from the second profile alone, so
|
|
239
|
+
* `gmail.main` came back naming the *other* person's mailbox and the first
|
|
240
|
+
* profile's surviving grant pointed at it. Nothing errored, and
|
|
241
|
+
* `loadProfileConfig` returned ok.
|
|
242
|
+
*/
|
|
196
243
|
async function writeConnections(
|
|
197
244
|
root: string,
|
|
198
245
|
rows: readonly LegacyConnection[],
|
|
@@ -201,9 +248,25 @@ async function writeConnections(
|
|
|
201
248
|
const apps: Record<string, unknown> = {};
|
|
202
249
|
for (const config of legacy.values()) Object.assign(apps, config.oauth_apps ?? {});
|
|
203
250
|
|
|
251
|
+
const held = await readWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE);
|
|
252
|
+
const current = held === null ? null : ConfigDocument.fromText(held, CONNECTIONS_FILE);
|
|
253
|
+
const previous = ((current?.toJSON() as { connections?: unknown } | null)?.connections ??
|
|
254
|
+
[]) as LegacyConnection[];
|
|
255
|
+
|
|
256
|
+
const merged = new Map<string, LegacyConnection>();
|
|
257
|
+
for (const row of rows) merged.set(keyOf(row), row);
|
|
258
|
+
// Second, so a row this run re-derived does not displace the one already
|
|
259
|
+
// written for it.
|
|
260
|
+
for (const row of previous) {
|
|
261
|
+
if (typeof row?.provider === 'string' && typeof row?.id === 'string') merged.set(keyOf(row), row);
|
|
262
|
+
}
|
|
263
|
+
|
|
204
264
|
const document = ConfigDocument.fromText(newConnectionsTemplate(), CONNECTIONS_FILE);
|
|
205
|
-
document.setIn(['connections'],
|
|
206
|
-
document.setIn(['oauth_apps'],
|
|
265
|
+
document.setIn(['connections'], [...merged.values()]);
|
|
266
|
+
document.setIn(['oauth_apps'], {
|
|
267
|
+
...((current?.toJSON() as { oauth_apps?: Record<string, unknown> } | null)?.oauth_apps ?? {}),
|
|
268
|
+
...apps,
|
|
269
|
+
});
|
|
207
270
|
|
|
208
271
|
await writeWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE, document.toString());
|
|
209
272
|
}
|
|
@@ -216,7 +279,9 @@ async function rewriteProfiles(
|
|
|
216
279
|
subject: string | undefined,
|
|
217
280
|
): Promise<void> {
|
|
218
281
|
for (const [profile, config] of legacy) {
|
|
219
|
-
|
|
282
|
+
// `openKey` at the contract-3 path, not `open`, which resolves the live
|
|
283
|
+
// layout: this produces contract 3, and contract 4 moves what it produced.
|
|
284
|
+
const document = await ConfigDocument.openKey(root, C3.profile(profile));
|
|
220
285
|
|
|
221
286
|
document.setIn(['contract'], 3);
|
|
222
287
|
document.setIn(['grants'], grantsFor(config, perProfile.get(profile) ?? new Map()));
|
|
@@ -237,16 +302,17 @@ async function rewriteProfiles(
|
|
|
237
302
|
document.removeIn(['policy']);
|
|
238
303
|
document.removeIn(['oauth_apps']);
|
|
239
304
|
|
|
240
|
-
await document.save();
|
|
305
|
+
await document.save({ contract: 3 });
|
|
241
306
|
}
|
|
242
307
|
}
|
|
243
308
|
|
|
244
309
|
/** `targets:` becomes `workspaces:`, and a pointer's `workspace:` becomes `at:`. */
|
|
245
310
|
async function rewriteRegistry(root: string): Promise<void> {
|
|
246
|
-
const document = await ConfigDocument.openKey(root,
|
|
311
|
+
const document = await ConfigDocument.openKey(root, C3.workspace);
|
|
247
312
|
const registry = document.toJSON() as {
|
|
248
313
|
contract?: number;
|
|
249
314
|
targets?: Record<string, { workspace?: string }>;
|
|
315
|
+
workspaces?: Record<string, unknown>;
|
|
250
316
|
} | null;
|
|
251
317
|
|
|
252
318
|
const targets = registry?.targets;
|
|
@@ -262,7 +328,33 @@ async function rewriteRegistry(root: string): Promise<void> {
|
|
|
262
328
|
workspaces[name] = workspace === undefined ? rest : { at: workspace, ...rest };
|
|
263
329
|
}
|
|
264
330
|
|
|
265
|
-
|
|
331
|
+
// **Anything already under `workspaces:` wins over what `targets:` says**,
|
|
332
|
+
// which is the same rule the 1-to-2 migration states for the same reason: a
|
|
333
|
+
// workspace part way through this has entries that are already right, and
|
|
334
|
+
// re-deriving them from a stale block would undo a correction.
|
|
335
|
+
//
|
|
336
|
+
// It is not hypothetical here. `editRegistry` used to write `workspaces:`
|
|
337
|
+
// into a contract-2 file without touching its `targets:`, so a deploy from
|
|
338
|
+
// an unmigrated laptop left two registries disagreeing — and this
|
|
339
|
+
// overwrote the newer one with the older, silently reverting a recorded
|
|
340
|
+
// deployment to whatever the last contract-2 command had written. That
|
|
341
|
+
// write is fixed at source, and this is what repairs a file already
|
|
342
|
+
// carrying both.
|
|
343
|
+
const merged: Record<string, unknown> = { ...workspaces };
|
|
344
|
+
for (const [name, entry] of Object.entries(registry?.workspaces ?? {})) {
|
|
345
|
+
const derived = merged[name];
|
|
346
|
+
// Per field, not per entry. The newer block is what `editRegistry` wrote
|
|
347
|
+
// when it could not see `targets:`, and `sync` writes only `{ at }` — so
|
|
348
|
+
// replacing the entry wholesale discarded `primary` (which schema.ts calls
|
|
349
|
+
// the one question about a deployment that must not be guessed at),
|
|
350
|
+
// `last_deploy`, and the whole `deploy:` block carrying project and
|
|
351
|
+
// region. Preserving the record was the entire point of the merge.
|
|
352
|
+
merged[name] =
|
|
353
|
+
derived !== null && typeof derived === 'object' && entry !== null && typeof entry === 'object'
|
|
354
|
+
? { ...(derived as Record<string, unknown>), ...(entry as Record<string, unknown>) }
|
|
355
|
+
: entry;
|
|
356
|
+
}
|
|
357
|
+
document.setIn(['workspaces'], merged);
|
|
266
358
|
document.removeIn(['targets']);
|
|
267
359
|
}
|
|
268
360
|
|
|
@@ -270,12 +362,37 @@ async function rewriteRegistry(root: string): Promise<void> {
|
|
|
270
362
|
const workspaces = (document.toJSON() as { workspaces?: Record<string, unknown> } | null)
|
|
271
363
|
?.workspaces ?? {};
|
|
272
364
|
|
|
273
|
-
// The
|
|
274
|
-
//
|
|
275
|
-
//
|
|
365
|
+
// The workspace on this machine, and only the *first* one when there is no
|
|
366
|
+
// such thing. Written rather than left absent so the sticky default is on
|
|
367
|
+
// from the first command after upgrading (ADR-061).
|
|
368
|
+
//
|
|
369
|
+
// This used to take the first key outright, on the stated grounds that "for
|
|
370
|
+
// every workspace this migration will ever see" that is `local`. It is not:
|
|
371
|
+
// the registry is written sorted, so a workspace that had ever deployed came
|
|
372
|
+
// out of the 1-to-2 migration with `cloud` ahead of `local`. Upgrading then
|
|
373
|
+
// pointed every subsequent command at a bucket — which is the one kind of
|
|
374
|
+
// workspace that can be unreachable, and was: the next `status` answered with
|
|
375
|
+
// a 403 from GCS rather than with the profiles sitting on the disk.
|
|
376
|
+
//
|
|
377
|
+
// A pointer carries `at:`; a workspace declaring its own adapters does not.
|
|
276
378
|
if (document.getIn(['default_workspace']) === undefined) {
|
|
277
|
-
const
|
|
278
|
-
|
|
379
|
+
const names = Object.keys(workspaces);
|
|
380
|
+
const here = names.find((name) => {
|
|
381
|
+
const entry = workspaces[name] as
|
|
382
|
+
| { at?: unknown; storage?: { adapter?: unknown } }
|
|
383
|
+
| undefined;
|
|
384
|
+
// A pointer is here when it points at a path rather than a bucket:
|
|
385
|
+
// `resolveTargetWorkspace` follows a local one just as happily.
|
|
386
|
+
if (typeof entry?.at === 'string') return !isRemoteWorkspace(entry.at);
|
|
387
|
+
// Otherwise it declares its own adapters, and only a filesystem one is on
|
|
388
|
+
// this machine. Reading "no `at:`" as "local" missed that a cloud target
|
|
389
|
+
// surveyed by `bootstrap` but never rolled out is a *declaration* — so a
|
|
390
|
+
// deploy that failed at build or IAM left the same 403 default this was
|
|
391
|
+
// written to prevent.
|
|
392
|
+
return entry?.storage?.adapter === 'filesystem';
|
|
393
|
+
});
|
|
394
|
+
const chosen = here ?? names[0];
|
|
395
|
+
if (chosen !== undefined) document.setIn(['default_workspace'], chosen);
|
|
279
396
|
}
|
|
280
397
|
|
|
281
398
|
await document.save();
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { ConfigError } from '#profile';
|
|
2
|
+
import type { SecretStore } from '#secrets';
|
|
3
|
+
import { credentialRefFor } from '#registry';
|
|
4
|
+
import type { ProviderRegistry } from '#registry';
|
|
5
|
+
import type { ConnectionConfig } from '#profile';
|
|
6
|
+
import { rotatableCredentialRefs } from '#connectivity';
|
|
7
|
+
import type { Renames } from './contract4-data.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Move each stored credential to the ref its renamed connection now derives.
|
|
11
|
+
*
|
|
12
|
+
* **The step without which the rename loses every account.** A `credential_ref`
|
|
13
|
+
* is almost never written down: `credentialRefFor` derives it as
|
|
14
|
+
* `<provider>/<connection>`, so renaming `gmail.main_2` to `gmail.con17`
|
|
15
|
+
* silently repoints the lookup at a key nothing holds. Config, blobs and state
|
|
16
|
+
* all move; the secret stays where it was, and the endpoint reports "no stored
|
|
17
|
+
* credential" for an account that is still perfectly well authorised.
|
|
18
|
+
*
|
|
19
|
+
* Found by rehearsing the migration against a real workspace, where it turned
|
|
20
|
+
* six live credentials into six re-authorisations. ADR-051 records why that is
|
|
21
|
+
* not an acceptable release note.
|
|
22
|
+
*
|
|
23
|
+
* Refs come from the same authority the runtime resolves them with, rather than
|
|
24
|
+
* from a pattern over the store's keys. A ref is not always
|
|
25
|
+
* `<provider>/<id>` — an `oauth_apps` entry puts the app's name in front
|
|
26
|
+
* instead — and matching on the last segment cannot tell `google/main` for a
|
|
27
|
+
* renamed Gmail connection from `google/main` for a renamed Drive one.
|
|
28
|
+
*/
|
|
29
|
+
export interface CredentialMove {
|
|
30
|
+
readonly from: string;
|
|
31
|
+
readonly to: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function planCredentialMoves(
|
|
35
|
+
connections: readonly ConnectionConfig[],
|
|
36
|
+
registry: ProviderRegistry,
|
|
37
|
+
renames: Renames,
|
|
38
|
+
): CredentialMove[] {
|
|
39
|
+
const moves: CredentialMove[] = [];
|
|
40
|
+
const seen = new Set<string>();
|
|
41
|
+
|
|
42
|
+
for (const connection of connections) {
|
|
43
|
+
const to = renames.get(`${connection.provider}.${connection.id}`);
|
|
44
|
+
if (to === undefined) continue;
|
|
45
|
+
|
|
46
|
+
const id = to.slice(to.indexOf('.') + 1);
|
|
47
|
+
if (id === connection.id) continue;
|
|
48
|
+
|
|
49
|
+
const manifest = registry.manifest(connection.provider);
|
|
50
|
+
const after = { ...connection, id };
|
|
51
|
+
|
|
52
|
+
// A ref the row states outright does not move: it names a key the operator
|
|
53
|
+
// chose, and the id it happens to contain is not this migration's to read.
|
|
54
|
+
const refs: [string | undefined, string | undefined][] = [
|
|
55
|
+
[credentialRefFor(connection, manifest), credentialRefFor(after, manifest)],
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// An OAuth connection also holds the token blob `saveTokens` rewrites, and
|
|
59
|
+
// it is the one that matters — the hot path of an ordinary read.
|
|
60
|
+
if (manifest) {
|
|
61
|
+
const before = rotatableCredentialRefs(manifest, connection.id);
|
|
62
|
+
const now = rotatableCredentialRefs(manifest, id);
|
|
63
|
+
for (let index = 0; index < before.length; index += 1) {
|
|
64
|
+
refs.push([before[index], now[index]]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const [from, into] of refs) {
|
|
69
|
+
if (from === undefined || into === undefined || from === into) continue;
|
|
70
|
+
|
|
71
|
+
// Keyed on the *pair*, not on the source. Several connections can derive
|
|
72
|
+
// one ref — three iCloud surfaces authorised as one account share
|
|
73
|
+
// `icloud/<id>` through `auth.app` — and giving distinct ids to each
|
|
74
|
+
// turns one credential into three. Deduping on the source sent it to
|
|
75
|
+
// whichever connection came first and orphaned the rest, which is what a
|
|
76
|
+
// real workspace showed on the first rehearsal.
|
|
77
|
+
const pair = `${from}\u0000${into}`;
|
|
78
|
+
if (seen.has(pair)) continue;
|
|
79
|
+
seen.add(pair);
|
|
80
|
+
moves.push({ from, to: into });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return moves;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Apply them, reading each value back before the old key is removed.
|
|
89
|
+
*
|
|
90
|
+
* The same rule the blob mover follows: a credential that half moved and a
|
|
91
|
+
* source already gone is the one state with nothing to retry from. A ref whose
|
|
92
|
+
* destination is already occupied is left alone rather than overwritten —
|
|
93
|
+
* that is a rerun finding its own work, and clobbering it would be the one
|
|
94
|
+
* irreversible thing here.
|
|
95
|
+
*/
|
|
96
|
+
export async function applyCredentialMoves(
|
|
97
|
+
store: SecretStore,
|
|
98
|
+
moves: readonly CredentialMove[],
|
|
99
|
+
): Promise<string[]> {
|
|
100
|
+
const applied: string[] = [];
|
|
101
|
+
const skipped: string[] = [];
|
|
102
|
+
|
|
103
|
+
// Grouped by source, because one credential may feed several destinations
|
|
104
|
+
// and the source may only go once every one of them is written. Deleting
|
|
105
|
+
// after the first would take it away from the connections still to come.
|
|
106
|
+
const bySource = new Map<string, string[]>();
|
|
107
|
+
for (const move of moves) {
|
|
108
|
+
const into = bySource.get(move.from) ?? [];
|
|
109
|
+
into.push(move.to);
|
|
110
|
+
bySource.set(move.from, into);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const [from, destinations] of bySource) {
|
|
114
|
+
const value = await store.get(from);
|
|
115
|
+
if (value === null) continue;
|
|
116
|
+
|
|
117
|
+
// **The source goes only once every destination holds this value.** The
|
|
118
|
+
// delete used to sit outside this loop while the loop skipped an occupied
|
|
119
|
+
// destination — so a ref already holding something (a stale credential a
|
|
120
|
+
// `disconnect --keep-credential` left behind, or an unrelated account's)
|
|
121
|
+
// meant nothing was written and the source was destroyed anyway, reported
|
|
122
|
+
// as a successful move. `migrate-move.ts` guards the same hazard with
|
|
123
|
+
// `sameBytes`, and this is that guard.
|
|
124
|
+
let carried = true;
|
|
125
|
+
|
|
126
|
+
for (const to of destinations) {
|
|
127
|
+
const held = await store.get(to);
|
|
128
|
+
|
|
129
|
+
if (held !== null) {
|
|
130
|
+
// Our own earlier work, so this destination is done. Anything else is
|
|
131
|
+
// somebody's credential and is not ours to overwrite or to delete the
|
|
132
|
+
// source out from under.
|
|
133
|
+
if (held !== value) {
|
|
134
|
+
carried = false;
|
|
135
|
+
skipped.push(
|
|
136
|
+
`${from} → ${to}: ${to} already holds a different value, so nothing was moved or ` +
|
|
137
|
+
'deleted. Remove or rename it and run this again.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await store.set(to, value);
|
|
144
|
+
if ((await store.get(to)) === null) {
|
|
145
|
+
throw new ConfigError(
|
|
146
|
+
`${to} did not read back after being written. Nothing has been deleted; ` +
|
|
147
|
+
'fix the credential store and run this again.',
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!carried) continue;
|
|
153
|
+
|
|
154
|
+
await store.delete(from);
|
|
155
|
+
applied.push(`${from} → ${destinations.join(', ')}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return [...applied, ...skipped];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The sealed vault document, which `credentialRefFor` cannot see.
|
|
163
|
+
*
|
|
164
|
+
* A vault connection's `auth.kind` is `none`, so `credentialRefForConnection`
|
|
165
|
+
* returns `undefined` for it and the loop above skips it entirely — while
|
|
166
|
+
* `vaultRef` names the document `vault/<profile>/<connection>` and a deployed
|
|
167
|
+
* target seals it in Secret Manager. Contract 4 renames the connection and
|
|
168
|
+
* gives the ref a profile, so without this the revision opens a name nothing
|
|
169
|
+
* created: every vault item reads as absent, with no error, and the ciphertext
|
|
170
|
+
* sits orphaned under the old ref.
|
|
171
|
+
*
|
|
172
|
+
* Invisible in a local rehearsal, because the `file` and `blob` adapters take
|
|
173
|
+
* their path from `layout` and were already correct. `secret` is the adapter
|
|
174
|
+
* every deployment uses.
|
|
175
|
+
*
|
|
176
|
+
* A `ref` the target states outright is left alone: it names a document the
|
|
177
|
+
* operator chose, and a deployment already sealing under it has to keep
|
|
178
|
+
* opening it.
|
|
179
|
+
*/
|
|
180
|
+
export function planVaultMoves(
|
|
181
|
+
profiles: ReadonlyMap<string, { grants?: { connection?: unknown }[] }>,
|
|
182
|
+
renames: Renames,
|
|
183
|
+
declaredRef: string | undefined,
|
|
184
|
+
): CredentialMove[] {
|
|
185
|
+
if (declaredRef !== undefined) return [];
|
|
186
|
+
|
|
187
|
+
const moves: CredentialMove[] = [];
|
|
188
|
+
|
|
189
|
+
for (const [profile, config] of profiles) {
|
|
190
|
+
const granted = (config.grants ?? [])
|
|
191
|
+
.map((grant) => grant.connection)
|
|
192
|
+
.find((ref): ref is string => typeof ref === 'string' && ref.startsWith('vault.'));
|
|
193
|
+
|
|
194
|
+
// `main` where the profile grants none, which is what `vaultRef` falls back
|
|
195
|
+
// to — so a profile that denied the vault still finds its own document
|
|
196
|
+
// rather than another profile's.
|
|
197
|
+
const was = granted === undefined ? 'main' : granted.slice('vault.'.length);
|
|
198
|
+
const to = granted === undefined ? undefined : renames.get(granted);
|
|
199
|
+
const now = to === undefined ? was : to.slice(to.indexOf('.') + 1);
|
|
200
|
+
|
|
201
|
+
const from = `vault/${was}`;
|
|
202
|
+
const into = `vault/${profile}/${now}`;
|
|
203
|
+
if (from !== into) moves.push({ from, to: into });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return moves;
|
|
207
|
+
}
|