@lanes-sh/link 0.7.2 → 0.9.0
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 +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +148 -73
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +85 -4
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +109 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/callback-page.ts +36 -115
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +90 -88
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +16 -6
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +183 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +24 -12
- package/src/cli/commands/knowledge/index.ts +49 -89
- package/src/cli/commands/knowledge/migrate.ts +79 -13
- package/src/cli/commands/knowledge/show.ts +97 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +33 -13
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +53 -5
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- 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/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- 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 +154 -64
- package/src/cli/commands/profile/remove.ts +83 -7
- package/src/cli/commands/profile.ts +79 -16
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +39 -17
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update-migration.ts +54 -0
- package/src/cli/commands/update.ts +78 -24
- package/src/cli/config-edit.ts +99 -143
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair-sweep.ts +119 -0
- package/src/cli/config-repair.ts +131 -125
- package/src/cli/config-templates.ts +200 -0
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +262 -0
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +212 -0
- package/src/cli/contract3.ts +399 -0
- 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 +342 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/identity.ts +44 -26
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +94 -14
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +6 -7
- package/src/cli/runtime/open.ts +64 -99
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +58 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +40 -20
- package/src/cli/workspace-migrate.ts +152 -22
- package/src/connectivity/manifest/provider.ts +34 -13
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +38 -29
- package/src/deployments/gcp/bucket.ts +58 -11
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/knowledge.ts +9 -4
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +34 -13
- package/src/deployments/upload.ts +60 -27
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +195 -0
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +35 -6
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +163 -90
- package/src/profile/load.ts +133 -64
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +181 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +104 -2
- package/src/profile/workspace.ts +124 -33
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +2 -2
- package/src/providers/memory/provider.ts +26 -8
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/slack/index.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/registry/registry.ts +1 -1
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +89 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +22 -22
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +25 -6
- package/src/server/mcp/visibility.ts +74 -7
- package/src/server/oauth.ts +29 -109
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- package/src/server/read/state.ts +171 -0
- package/src/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- package/src/stores/state/testing.ts +5 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { ConfigError, LEGACY_DATA_DIR, isRemoteWorkspace } from '#profile';
|
|
2
|
+
import { C3 } from './contract3-layout.ts';
|
|
3
|
+
import { createFileSecretStore } from '#secrets';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The credential half of the contract-3 migration.
|
|
7
|
+
*
|
|
8
|
+
* Split from `contract3-data.ts` when that file outgrew the budget, on the seam
|
|
9
|
+
* the migration already had: objects move, credentials merge, and the two obey
|
|
10
|
+
* different rules. A moved object has one home and the move is reversible by
|
|
11
|
+
* moving it back. A merged credential has to survive two profiles claiming one
|
|
12
|
+
* ref, and the wrong resolution points a live connection at somebody else's
|
|
13
|
+
* account — so nothing here picks between two values, ever.
|
|
14
|
+
*
|
|
15
|
+
* The whole file is ordered around one promise: every refusal happens before
|
|
16
|
+
* the first write.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What this migration needs to know about one profile's credentials.
|
|
21
|
+
*
|
|
22
|
+
* Assembled by `contract3.ts` from the hoist, because the two facts below are
|
|
23
|
+
* decisions that file already made and this one must not make differently.
|
|
24
|
+
*/
|
|
25
|
+
export interface CredentialPlan {
|
|
26
|
+
readonly profile: string;
|
|
27
|
+
/** Every connection-derived credential ref, and where it is going. */
|
|
28
|
+
readonly renames: ReadonlyMap<string, RefTarget>;
|
|
29
|
+
/** This profile's endpoint token ref, which is deliberately not migrated. */
|
|
30
|
+
readonly tokenRef: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What the merged credential store will hold, and what was left out of it. */
|
|
34
|
+
export interface CredentialMerge {
|
|
35
|
+
/** Refs the merged store will hold, sorted. */
|
|
36
|
+
readonly refs: readonly string[];
|
|
37
|
+
/** Endpoint token refs left behind rather than merged, sorted. */
|
|
38
|
+
readonly tokens: readonly string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where a credential ref is going, and which connection it belongs to after. */
|
|
42
|
+
export interface RefTarget {
|
|
43
|
+
readonly to: string;
|
|
44
|
+
/** The settled `provider.id` this ref's connection became. */
|
|
45
|
+
readonly connection: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The credential ref of every connection the hoist touched, renamed or not.
|
|
50
|
+
*
|
|
51
|
+
* `credentialRefForConnection` derives `${app ?? provider}/${connectionId}` for
|
|
52
|
+
* every auth kind that has a per-connection credential, and `hoistConnections`
|
|
53
|
+
* renames the connection with `{ ...connection, id }` — which leaves the derived
|
|
54
|
+
* ref pointing at the old id. So a profile whose `github.main` became
|
|
55
|
+
* `github.main_2` still claimed `github/main`, and two profiles claiming one ref
|
|
56
|
+
* with two different tokens is what aborted the migration.
|
|
57
|
+
*
|
|
58
|
+
* **Unrenamed connections are in here too**, which is not redundancy: the
|
|
59
|
+
* `connection` half is what tells `readMerged` that two refs landing on one
|
|
60
|
+
* target belong to the same connection and are therefore a merge rather than a
|
|
61
|
+
* clash. Without it a single profile declaring `gmail.main` and `gmail.archive`
|
|
62
|
+
* for one mailbox — legal under contract 2, and the reason tokens are
|
|
63
|
+
* per-connection at all, since the scopes differ — was hoisted into one row and
|
|
64
|
+
* then refused with "Two profiles hold different values", naming one profile
|
|
65
|
+
* twice and prescribing a rename that cannot help.
|
|
66
|
+
*
|
|
67
|
+
* Only the `<provider>/<id>` spelling is derived here. A provider declaring
|
|
68
|
+
* `auth.app` stores under `<app>/<id>` and a row carrying an explicit
|
|
69
|
+
* `credential_ref` stores wherever it says — neither is reconstructable without
|
|
70
|
+
* the manifest, which this migration does not load. Those refs are carried
|
|
71
|
+
* across untouched, and a genuine clash between two of them is refused by name.
|
|
72
|
+
*/
|
|
73
|
+
export function connectionRefs(mapping: ReadonlyMap<string, string>): Map<string, RefTarget> {
|
|
74
|
+
const refs = new Map<string, RefTarget>();
|
|
75
|
+
|
|
76
|
+
for (const [from, to] of mapping) {
|
|
77
|
+
const before = from.indexOf('.');
|
|
78
|
+
const after = to.indexOf('.');
|
|
79
|
+
if (before < 0 || after < 0) continue;
|
|
80
|
+
refs.set(`${from.slice(0, before)}/${from.slice(before + 1)}`, {
|
|
81
|
+
to: `${to.slice(0, after)}/${to.slice(after + 1)}`,
|
|
82
|
+
connection: to,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return refs;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every credential the merged store will hold, addressed as contract 3 will
|
|
91
|
+
* address it.
|
|
92
|
+
*
|
|
93
|
+
* One walk, shared by the preview and the apply, because the two disagreeing is
|
|
94
|
+
* the defect this replaces: `planCredentials` collected the union of ref *names*
|
|
95
|
+
* and never compared values, so a clash it could not see aborted
|
|
96
|
+
* `mergeCredentials` — at which point `rewriteRegistry` and `writeConnections`
|
|
97
|
+
* had already run, and the workspace was half-migrated behind a message saying
|
|
98
|
+
* nothing had been written.
|
|
99
|
+
*
|
|
100
|
+
* Read-only. Everything that can refuse, refuses here.
|
|
101
|
+
*
|
|
102
|
+
* **A workspace in a bucket has nothing to merge, and this says so rather than
|
|
103
|
+
* finding out.** `workspacePath` refuses a filesystem adapter against a remote
|
|
104
|
+
* root, so the only credential store such a workspace can declare is
|
|
105
|
+
* `gcp-secret-manager` — whose refs were never scoped by profile, and are
|
|
106
|
+
* therefore already what contract 3 wants. Without the guard the path below is
|
|
107
|
+
* built by string interpolation into `gs://bucket/data/<profile>/credentials.enc`
|
|
108
|
+
* and handed to `Bun.file`, where the failure is swallowed by the `catch` and
|
|
109
|
+
* reads exactly like a workspace with no credentials in it.
|
|
110
|
+
*/
|
|
111
|
+
async function readMerged(
|
|
112
|
+
root: string,
|
|
113
|
+
plans: readonly CredentialPlan[],
|
|
114
|
+
): Promise<{ merged: Map<string, string>; tokens: Set<string> }> {
|
|
115
|
+
const merged = new Map<string, string>();
|
|
116
|
+
const held = new Map<string, { profile: string; connection?: string }>();
|
|
117
|
+
// One entry per endpoint-token ref, holding what each profile had under it.
|
|
118
|
+
// Decided after the walk, because whether it can be carried across depends on
|
|
119
|
+
// whether the profiles agree — which is not known until they have all been read.
|
|
120
|
+
const endpoint = new Map<string, Map<string, string>>();
|
|
121
|
+
const tokens = new Set<string>();
|
|
122
|
+
|
|
123
|
+
if (isRemoteWorkspace(root)) return { merged, tokens };
|
|
124
|
+
|
|
125
|
+
for (const plan of plans) {
|
|
126
|
+
const store = createFileSecretStore({
|
|
127
|
+
path: `${root}/${LEGACY_DATA_DIR}/${plan.profile}/credentials.enc`,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
let refs: string[];
|
|
131
|
+
try {
|
|
132
|
+
refs = await store.list();
|
|
133
|
+
} catch {
|
|
134
|
+
// A store that will not open is reported by `doctor`, not here. This runs
|
|
135
|
+
// as a preview too, and must not fail on a workspace that is already
|
|
136
|
+
// broken.
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const ref of refs) {
|
|
141
|
+
// The endpoint's own bearer token, which every profile keeps under the
|
|
142
|
+
// same ref because `authSchema` defaults `token_ref` to `profile/token`.
|
|
143
|
+
// Under contract 2 that was unambiguous — one store per profile. Under
|
|
144
|
+
// contract 3 there is one store, so three profiles' tokens are three
|
|
145
|
+
// values for one key, and there is no merge that means anything.
|
|
146
|
+
//
|
|
147
|
+
// Left behind rather than picked between. It is minted locally rather
|
|
148
|
+
// than granted by anybody, `ensureProfileToken` writes a fresh one the
|
|
149
|
+
// first time a command asks, and the old stores are not deleted — so the
|
|
150
|
+
// cost is re-registering a client, and no account has to be authorised
|
|
151
|
+
// again.
|
|
152
|
+
let value: string | null;
|
|
153
|
+
try {
|
|
154
|
+
value = await store.get(ref);
|
|
155
|
+
} catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (value === null) continue;
|
|
159
|
+
|
|
160
|
+
// The endpoint's own bearer token, held under one ref by every profile
|
|
161
|
+
// because `authSchema` defaults `token_ref` to `profile/token`. Set aside
|
|
162
|
+
// rather than merged here; see below.
|
|
163
|
+
if (ref === plan.tokenRef) {
|
|
164
|
+
const seen = endpoint.get(ref) ?? new Map<string, string>();
|
|
165
|
+
seen.set(plan.profile, value);
|
|
166
|
+
endpoint.set(ref, seen);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const mapped = plan.renames.get(ref);
|
|
171
|
+
const target = mapped?.to ?? ref;
|
|
172
|
+
const first = merged.get(target);
|
|
173
|
+
|
|
174
|
+
if (first !== undefined) {
|
|
175
|
+
if (first === value) continue;
|
|
176
|
+
|
|
177
|
+
// Two refs on one target whose connections are the same connection.
|
|
178
|
+
// That is the hoist merging two rows for one account — legal under
|
|
179
|
+
// contract 2, where `gmail.main` and `gmail.archive` could hold the
|
|
180
|
+
// same mailbox at different scopes — and there is one connection now,
|
|
181
|
+
// so one token. The first row is the one that survived the hoist, so
|
|
182
|
+
// its credential is the one that belongs to it.
|
|
183
|
+
const owner = held.get(target);
|
|
184
|
+
if (mapped !== undefined && owner?.connection === mapped.connection) continue;
|
|
185
|
+
|
|
186
|
+
throw new ConfigError(
|
|
187
|
+
`Two credentials want to be at "${target}", and this migration cannot choose ` +
|
|
188
|
+
`between them.\n` +
|
|
189
|
+
` ${owner?.profile ?? 'another profile'} and ${plan.profile} both hold one. Both ` +
|
|
190
|
+
`are real credentials, and picking either would point a connection at the wrong ` +
|
|
191
|
+
`account.\n` +
|
|
192
|
+
` Nothing has been written. Give one of them its own credential_ref before ` +
|
|
193
|
+
`migrating.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
merged.set(target, value);
|
|
198
|
+
held.set(target, {
|
|
199
|
+
profile: plan.profile,
|
|
200
|
+
...(mapped === undefined ? {} : { connection: mapped.connection }),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// **A token is only left behind when the profiles disagree about it.**
|
|
206
|
+
//
|
|
207
|
+
// Under contract 2 each profile had its own store, so one ref meant one value
|
|
208
|
+
// per profile and contract 3's single store cannot hold three of them. But a
|
|
209
|
+
// workspace with one profile — or three that were registered from the same
|
|
210
|
+
// token — has nothing to choose between, and dropping it there was gratuitous:
|
|
211
|
+
// every client registered against that endpoint started getting 401s after a
|
|
212
|
+
// routine `update`, for a conflict that did not exist.
|
|
213
|
+
//
|
|
214
|
+
// Where they do disagree it is still left behind rather than picked between.
|
|
215
|
+
// It is minted locally rather than granted by anybody, `ensureProfileToken`
|
|
216
|
+
// writes a fresh one the first time a command asks, and the old stores are not
|
|
217
|
+
// deleted — so the cost is re-registering a client, and no account has to be
|
|
218
|
+
// authorised again.
|
|
219
|
+
for (const [ref, byProfile] of endpoint) {
|
|
220
|
+
const values = new Set(byProfile.values());
|
|
221
|
+
const agreed = values.size === 1 ? [...values][0] : undefined;
|
|
222
|
+
|
|
223
|
+
if (agreed === undefined || merged.has(ref)) {
|
|
224
|
+
tokens.add(ref);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
merged.set(ref, agreed);
|
|
229
|
+
held.set(ref, { profile: [...byProfile.keys()].join(', ') });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { merged, tokens };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* What the merge will do, computed before anything is written.
|
|
237
|
+
*
|
|
238
|
+
* The report an operator confirms is therefore the real one, and a refusal
|
|
239
|
+
* leaves the workspace exactly as it was.
|
|
240
|
+
*/
|
|
241
|
+
export async function planCredentials(
|
|
242
|
+
root: string,
|
|
243
|
+
plans: readonly CredentialPlan[],
|
|
244
|
+
): Promise<CredentialMerge> {
|
|
245
|
+
const { merged, tokens } = await readMerged(root, plans);
|
|
246
|
+
return { refs: [...merged.keys()].sort(), tokens: [...tokens].sort() };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Copy every profile's credentials into the workspace store.
|
|
251
|
+
*
|
|
252
|
+
* Written and read back before the old stores are touched, which is the whole
|
|
253
|
+
* of the safety argument: a half-finished merge that has not deleted anything is
|
|
254
|
+
* recoverable by running it again, and one that deleted first is not.
|
|
255
|
+
*
|
|
256
|
+
* The old stores are left in place regardless. They are a few kilobytes, they
|
|
257
|
+
* are the only copy of anything if this went wrong, and `doctor` names them so
|
|
258
|
+
* an operator can remove them once the endpoint has served a request.
|
|
259
|
+
*
|
|
260
|
+
* Skipped entirely for a workspace in a bucket, for the reason `readMerged`
|
|
261
|
+
* gives: its credentials are in Secret Manager under refs that were never
|
|
262
|
+
* per-profile, so there is no second store to fold in.
|
|
263
|
+
*/
|
|
264
|
+
export async function mergeCredentials(
|
|
265
|
+
root: string,
|
|
266
|
+
plans: readonly CredentialPlan[],
|
|
267
|
+
): Promise<void> {
|
|
268
|
+
if (isRemoteWorkspace(root)) return;
|
|
269
|
+
|
|
270
|
+
const { merged } = await readMerged(root, plans);
|
|
271
|
+
const destination = createFileSecretStore({ path: `${root}/${C3.credentials()}` });
|
|
272
|
+
|
|
273
|
+
for (const [ref, value] of merged) {
|
|
274
|
+
const already = await destination.get(ref);
|
|
275
|
+
if (already !== null) {
|
|
276
|
+
// Already copied, by a run that did not get to the end. Anything else is
|
|
277
|
+
// a store that disagrees with the profiles it was built from, which is
|
|
278
|
+
// not something to overwrite silently.
|
|
279
|
+
if (already === value) continue;
|
|
280
|
+
throw new ConfigError(
|
|
281
|
+
`${C3.credentials()} already holds a different value for "${ref}" than the profile ` +
|
|
282
|
+
`stores do. Nothing has been deleted; resolve it and run this again.`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await destination.set(ref, value);
|
|
287
|
+
if ((await destination.get(ref)) !== value) {
|
|
288
|
+
throw new ConfigError(
|
|
289
|
+
`The credential "${ref}" did not read back after being written to ` +
|
|
290
|
+
`${C3.credentials()}. Nothing has been deleted; fix the store and run this again.`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { ConfigError, LEGACY_DATA_DIR } from '#profile';
|
|
2
|
+
import { applyMoves, assertOneObjectPerDestination, claim, type Move } from './migrate-move.ts';
|
|
3
|
+
import { C3 } from './contract3-layout.ts';
|
|
4
|
+
import type { BlobStore } from '#stores/blobs';
|
|
5
|
+
import { CONNECTIONS_NAMESPACE, decodeSegment, objectKey } from '#stores/state';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The half of the contract-3 migration that moves bytes rather than YAML.
|
|
9
|
+
*
|
|
10
|
+
* Split from `contract3.ts` on the seam the migration already has: that file
|
|
11
|
+
* decides *what* the new shape is, and this one carries the credentials and
|
|
12
|
+
* objects into it. Both are ordered so a crash between any two steps leaves a
|
|
13
|
+
* workspace that still opens — nothing is deleted until it has been read back.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where every object under `data/<profile>/` is going.
|
|
19
|
+
*
|
|
20
|
+
* Driven by `perProfile`, the per-profile map the hoist already built from old
|
|
21
|
+
* key to new — because the rename that matters is *this profile's*. The first
|
|
22
|
+
* version of this keyed a lookup by the hoisted (new) key and queried it with
|
|
23
|
+
* the old one, which made the resolution an unconditional no-op: provider and
|
|
24
|
+
* connection ids contain no dot, so anything the map returned already had the id
|
|
25
|
+
* being looked up. Two profiles holding `gmail.main` for different mailboxes
|
|
26
|
+
* both sent their blobs to `data/gmail/main/`, and the second one's landed in
|
|
27
|
+
* the first one's namespace.
|
|
28
|
+
*
|
|
29
|
+
* Anything that matches no rule is left exactly where it is: this moves what it
|
|
30
|
+
* understands and never deletes what it does not.
|
|
31
|
+
*/
|
|
32
|
+
export async function planMoves(
|
|
33
|
+
files: BlobStore,
|
|
34
|
+
profiles: readonly string[],
|
|
35
|
+
perProfile: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
|
36
|
+
): Promise<Move[]> {
|
|
37
|
+
const moves: Move[] = [];
|
|
38
|
+
// Destinations inside `state.kv` that an earlier profile already claimed, for
|
|
39
|
+
// the two namespaces where a second claim is a duplicate rather than a
|
|
40
|
+
// conflict. See `stateMove`.
|
|
41
|
+
const claimed = new Set<string>();
|
|
42
|
+
|
|
43
|
+
for (const profile of profiles) {
|
|
44
|
+
const mapping = perProfile.get(profile) ?? new Map<string, string>();
|
|
45
|
+
const prefix = `${LEGACY_DATA_DIR}/${profile}/`;
|
|
46
|
+
|
|
47
|
+
for (const blob of await files.list(prefix)) {
|
|
48
|
+
const rest = blob.key.slice(prefix.length);
|
|
49
|
+
const [head, ...tail] = rest.split('/');
|
|
50
|
+
if (head === undefined) continue;
|
|
51
|
+
|
|
52
|
+
// The credential store is merged rather than moved, and the old copy is
|
|
53
|
+
// deliberately left behind.
|
|
54
|
+
if (head === 'credentials.enc' || head === 'credentials.enc.key') continue;
|
|
55
|
+
|
|
56
|
+
// The instance this profile's single-instance surfaces became. Both are
|
|
57
|
+
// one store per profile in contract 2 and one per *connection* in
|
|
58
|
+
// contract 3, so two profiles' vaults are two documents — sending both to
|
|
59
|
+
// `vault('main')` orphaned the second and silently gave it the first's,
|
|
60
|
+
// which is the worst of the collisions because the wrong answer is a
|
|
61
|
+
// credential (ADR-059).
|
|
62
|
+
if (head === 'vault.enc') {
|
|
63
|
+
moves.push({ from: blob.key, to: C3.vault(instanceOf(mapping, 'vault')) });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (head === 'vault.enc.key') {
|
|
67
|
+
moves.push({ from: blob.key, to: `${C3.vault(instanceOf(mapping, 'vault'))}.key` });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (head === 'skills.d') {
|
|
71
|
+
moves.push({
|
|
72
|
+
from: blob.key,
|
|
73
|
+
to: `${C3.skills(instanceOf(mapping, 'skills'))}/${tail.join('/')}`,
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Under ADR-030 a manifest lived in the profile, so an operator using
|
|
78
|
+
// their own connector in two profiles holds two copies of one file. They
|
|
79
|
+
// are the same manifest; refusing byte-identical files and asking for the
|
|
80
|
+
// data directory's layout was not a useful answer.
|
|
81
|
+
if (head === 'providers.d') {
|
|
82
|
+
const move = claim(claimed, {
|
|
83
|
+
from: blob.key,
|
|
84
|
+
to: `${C3.providers()}/${tail.join('/')}`,
|
|
85
|
+
});
|
|
86
|
+
if (move !== null) moves.push(move);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// One object per event under a key already carrying the timestamp, so
|
|
90
|
+
// concatenating three profiles' logs is exactly moving them across.
|
|
91
|
+
if (head === 'audit.log') {
|
|
92
|
+
moves.push({ from: blob.key, to: `${C3.audit()}/${tail.join('/')}` });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (head === 'state.kv') {
|
|
96
|
+
const move = stateMove(blob.key, tail, mapping, claimed);
|
|
97
|
+
if (move !== null) moves.push(move);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Otherwise it is `<provider>/<connection>/...`, the namespace every
|
|
102
|
+
// provider's blobs are scoped into.
|
|
103
|
+
const connection = tail[0];
|
|
104
|
+
if (connection === undefined) continue;
|
|
105
|
+
|
|
106
|
+
// This profile's old key, through this profile's mapping.
|
|
107
|
+
const settled = mapping.get(`${head}.${connection}`);
|
|
108
|
+
const id = settled === undefined ? connection : (settled.split('.')[1] ?? connection);
|
|
109
|
+
moves.push({ from: blob.key, to: `${LEGACY_DATA_DIR}/${head}/${id}/${tail.slice(1).join('/')}` });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
assertOneObjectPerDestination(moves);
|
|
114
|
+
return moves;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Where one object under a profile's `state.kv` is going.
|
|
120
|
+
*
|
|
121
|
+
* `state.kv` used to be moved verbatim, on the grounds that its records "already
|
|
122
|
+
* carry the profile". They do not. `connections.v1` is keyed on
|
|
123
|
+
* `<provider>.<id>` — precisely what the hoist renames — so every profile's
|
|
124
|
+
* owner layer wrote `connections.v1/vault.main`, and three profiles aimed three
|
|
125
|
+
* objects at one key. That is the collision `assertOneObjectPerDestination`
|
|
126
|
+
* called unreachable, and it was reachable from any workspace with two profiles
|
|
127
|
+
* in it.
|
|
128
|
+
*
|
|
129
|
+
* Keys are decoded rather than pattern-matched: on disk both halves are
|
|
130
|
+
* percent-encoded per segment (`connections%2Ev1/vault%2Emain.json`), and
|
|
131
|
+
* respelling that here would be a second copy of an encoding that must have one.
|
|
132
|
+
*
|
|
133
|
+
* `null` means leave it where it is — nothing is deleted by not moving it, and
|
|
134
|
+
* `doctor` names what is left.
|
|
135
|
+
*/
|
|
136
|
+
function stateMove(
|
|
137
|
+
from: string,
|
|
138
|
+
tail: readonly string[],
|
|
139
|
+
mapping: ReadonlyMap<string, string>,
|
|
140
|
+
claimed: Set<string>,
|
|
141
|
+
): Move | null {
|
|
142
|
+
const here = `${C3.state()}/${tail.join('/')}`;
|
|
143
|
+
const leaf = tail[tail.length - 1];
|
|
144
|
+
|
|
145
|
+
// Not an object this module wrote: moved as it is, and still held to the
|
|
146
|
+
// one-object-per-destination rule, because an unrecognised collision is a
|
|
147
|
+
// thing to refuse rather than to resolve by guessing.
|
|
148
|
+
if (tail.length < 2 || leaf === undefined || !leaf.endsWith('.json')) {
|
|
149
|
+
return { from, to: here };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// `decodeSegment` is `decodeURIComponent`, which throws `URIError` on a stray
|
|
153
|
+
// `%`. Aborting somebody's whole migration on `URI error`, naming no file, is
|
|
154
|
+
// not an answer — an undecodable key is one this does not understand, and
|
|
155
|
+
// those are moved as they are.
|
|
156
|
+
let segments: string[];
|
|
157
|
+
let key: string;
|
|
158
|
+
try {
|
|
159
|
+
segments = tail.slice(0, -1).map(decodeSegment);
|
|
160
|
+
key = decodeSegment(leaf.slice(0, -'.json'.length));
|
|
161
|
+
} catch {
|
|
162
|
+
return { from, to: here };
|
|
163
|
+
}
|
|
164
|
+
const namespace = segments.join('/');
|
|
165
|
+
|
|
166
|
+
// Keyed on the provider id, not a connection, so two profiles on the same
|
|
167
|
+
// vendor hold two entries under one key. `open.ts` treats a miss and a corrupt
|
|
168
|
+
// entry alike as "not discovered yet" and `connect` refreshes it: first wins.
|
|
169
|
+
if (namespace === 'discovery') {
|
|
170
|
+
return claim(claimed, { from, to: here });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// `<provider>/<connection>` — a provider's own state, keyed on the connection
|
|
174
|
+
// exactly as its blobs are. Left out of the first pass, so two profiles
|
|
175
|
+
// holding one provider's fixed-name object (`dav`'s home, `bunq`'s session)
|
|
176
|
+
// still aimed at one key.
|
|
177
|
+
if (segments.length === 2) {
|
|
178
|
+
const [provider, connection] = segments as [string, string];
|
|
179
|
+
const settled = mapping.get(`${provider}.${connection}`);
|
|
180
|
+
if (settled === undefined) return { from, to: here };
|
|
181
|
+
|
|
182
|
+
const id = settled.slice(settled.indexOf('.') + 1);
|
|
183
|
+
return claim(claimed, { from, to: `${C3.state()}/${objectKey(`${provider}/${id}`, key)}` });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (namespace !== CONNECTIONS_NAMESPACE) return { from, to: here };
|
|
187
|
+
|
|
188
|
+
// This profile's old key, through this profile's mapping — the same lookup
|
|
189
|
+
// the provider-blob branch does, and wrong in the same way if it is skipped.
|
|
190
|
+
const settled = mapping.get(key);
|
|
191
|
+
|
|
192
|
+
// Orphaned under contract 2 already — no row, and `hoistConnections` reads
|
|
193
|
+
// rows — so hoisting it would manufacture a connection nothing grants, and its
|
|
194
|
+
// key can collide with a rename that is real.
|
|
195
|
+
if (settled === undefined) return null;
|
|
196
|
+
|
|
197
|
+
const to = `${C3.state()}/${objectKey(CONNECTIONS_NAMESPACE, settled)}`;
|
|
198
|
+
|
|
199
|
+
// Two profiles on one account merge into one row, so both hold a record for
|
|
200
|
+
// it. One connection; the second differs only in when it was written.
|
|
201
|
+
if (settled === key) return claim(claimed, { from, to });
|
|
202
|
+
|
|
203
|
+
const dot = settled.indexOf('.');
|
|
204
|
+
const provider = settled.slice(0, dot);
|
|
205
|
+
const id = settled.slice(dot + 1);
|
|
206
|
+
return claim(claimed, { from, to, rewrite: (data) => retarget(data, provider, id) });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A connection record, told what it is now called.
|
|
212
|
+
*
|
|
213
|
+
* Spread rather than assigned field by field, so key order and anything a later
|
|
214
|
+
* version added survive. Bytes that are not a JSON object are returned
|
|
215
|
+
* untouched: refusing to move what would not parse strands it under a profile
|
|
216
|
+
* directory nothing reads.
|
|
217
|
+
*/
|
|
218
|
+
function retarget(data: Uint8Array, provider: string, id: string): Uint8Array {
|
|
219
|
+
let record: unknown;
|
|
220
|
+
try {
|
|
221
|
+
record = JSON.parse(new TextDecoder().decode(data));
|
|
222
|
+
} catch {
|
|
223
|
+
return data;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (record === null || typeof record !== 'object' || Array.isArray(record)) return data;
|
|
227
|
+
|
|
228
|
+
return new TextEncoder().encode(
|
|
229
|
+
JSON.stringify({ ...(record as Record<string, unknown>), provider, id }),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Copy, verify, then delete. In that order, per object.
|
|
235
|
+
*
|
|
236
|
+
* A move that deleted first would lose an object on any failure, and these are
|
|
237
|
+
* the owner's notes, tasks and entities.
|
|
238
|
+
*
|
|
239
|
+
* A destination that already holds *different* bytes is a bug rather than a case
|
|
240
|
+
* to handle, now that the hoist gives every profile's owner layer its own
|
|
241
|
+
* instance: two sets of notes can no longer be aimed at one key. It used to be
|
|
242
|
+
* skipped silently, which is how work's vault came to be orphaned while `moved`
|
|
243
|
+
* reported it as moved. `assertOneObjectPerDestination` refuses that while this
|
|
244
|
+
* is still a plan, and the check here catches what a plan cannot see.
|
|
245
|
+
*
|
|
246
|
+
* **The same bytes at the destination is the interrupted move, and it finishes
|
|
247
|
+
* it.** Copy-then-delete has a window between the two, and this migration now
|
|
248
|
+
* runs against buckets — where the window is a network round trip rather than a
|
|
249
|
+
* syscall, and an interruption is something that happens rather than something
|
|
250
|
+
* to reason about. Refusing there would have meant a workspace that could not be
|
|
251
|
+
* migrated by running the migration again, which is the one recovery this file
|
|
252
|
+
* promises.
|
|
253
|
+
*/
|
|
254
|
+
/** Which instance of a single-instance surface this profile's store became. */
|
|
255
|
+
function instanceOf(mapping: ReadonlyMap<string, string>, provider: string): string {
|
|
256
|
+
for (const [from, to] of mapping) {
|
|
257
|
+
if (from.startsWith(`${provider}.`)) return to.split('.')[1] ?? 'main';
|
|
258
|
+
}
|
|
259
|
+
return 'main';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { LEGACY_DATA_DIR, LEGACY_WORKSPACE_FILE } from '#profile';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where contract 3 put things, frozen.
|
|
5
|
+
*
|
|
6
|
+
* `#profile`'s `layout` describes where things live *now*. A migration lives
|
|
7
|
+
* between two layouts and must not ask it: `contract3.ts` migrates to this one
|
|
8
|
+
* and `contract4.ts` migrates away from it, so both need it spelled somewhere
|
|
9
|
+
* that will not move when the live one does. Asking `layout` made the contract-3
|
|
10
|
+
* migration write a contract-4 tree, skipping the step contract 4 was about to
|
|
11
|
+
* take and leaving nothing for it to find.
|
|
12
|
+
*
|
|
13
|
+
* One module rather than a copy in each, for the reason `layout.ts` exists: the
|
|
14
|
+
* credential path was spelled in three files and they have to agree forever.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The owner layer's ids through contract 3, before the `lanes_` prefix.
|
|
18
|
+
*
|
|
19
|
+
* Spelled here for the reason every other path in this file is: a migration
|
|
20
|
+
* reads the shape it is migrating *from*. `RESERVED_PROVIDER_IDS` is the live
|
|
21
|
+
* list and is `lanes_memory` now, so asking it whether a contract-2 row is
|
|
22
|
+
* owner-layer answers no for every one of them.
|
|
23
|
+
*/
|
|
24
|
+
export const C3_OWNER_PROVIDERS: readonly string[] = [
|
|
25
|
+
'memory',
|
|
26
|
+
'tasks',
|
|
27
|
+
'assets',
|
|
28
|
+
'skills',
|
|
29
|
+
'vault',
|
|
30
|
+
'setup',
|
|
31
|
+
'identity',
|
|
32
|
+
'entities',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export const C3 = {
|
|
36
|
+
/** The registry, before it became `workspaces.yaml`. */
|
|
37
|
+
workspace: LEGACY_WORKSPACE_FILE,
|
|
38
|
+
/** A profile's declaration, before it moved inside its own directory. */
|
|
39
|
+
profile: (profile: string): string => `profiles/${profile}.yaml`,
|
|
40
|
+
credentials: (): string => `${LEGACY_DATA_DIR}/credentials.enc`,
|
|
41
|
+
state: (): string => `${LEGACY_DATA_DIR}/state.kv`,
|
|
42
|
+
audit: (): string => `${LEGACY_DATA_DIR}/audit.log`,
|
|
43
|
+
providers: (): string => `${LEGACY_DATA_DIR}/providers.d`,
|
|
44
|
+
vault: (connection: string): string => `${LEGACY_DATA_DIR}/vault.d/${connection}.enc`,
|
|
45
|
+
skills: (connection: string): string => `${LEGACY_DATA_DIR}/skills.d/${connection}`,
|
|
46
|
+
} as const;
|