@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.
Files changed (190) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +148 -73
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +85 -4
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +109 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +90 -88
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +16 -6
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +183 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +24 -12
  42. package/src/cli/commands/knowledge/index.ts +49 -89
  43. package/src/cli/commands/knowledge/migrate.ts +79 -13
  44. package/src/cli/commands/knowledge/show.ts +97 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +33 -13
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +53 -5
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/assets.ts +2 -2
  66. package/src/cli/commands/owner/entities.ts +2 -2
  67. package/src/cli/commands/owner/memory.ts +2 -2
  68. package/src/cli/commands/owner/shared.ts +13 -2
  69. package/src/cli/commands/owner/skills.ts +28 -8
  70. package/src/cli/commands/owner/tasks.ts +2 -2
  71. package/src/cli/commands/owner/vault.ts +3 -3
  72. package/src/cli/commands/profile/disposition.ts +236 -0
  73. package/src/cli/commands/profile/removal.ts +154 -64
  74. package/src/cli/commands/profile/remove.ts +83 -7
  75. package/src/cli/commands/profile.ts +79 -16
  76. package/src/cli/commands/relabel.ts +112 -0
  77. package/src/cli/commands/secrets.ts +39 -17
  78. package/src/cli/commands/set-workspace.ts +96 -0
  79. package/src/cli/commands/setup.ts +2 -2
  80. package/src/cli/commands/sync.ts +8 -8
  81. package/src/cli/commands/target.ts +9 -7
  82. package/src/cli/commands/update-migration.ts +54 -0
  83. package/src/cli/commands/update.ts +78 -24
  84. package/src/cli/config-edit.ts +99 -143
  85. package/src/cli/config-migrate.ts +82 -64
  86. package/src/cli/config-repair-sweep.ts +119 -0
  87. package/src/cli/config-repair.ts +131 -125
  88. package/src/cli/config-templates.ts +200 -0
  89. package/src/cli/contract3-credentials.ts +294 -0
  90. package/src/cli/contract3-data.ts +262 -0
  91. package/src/cli/contract3-layout.ts +46 -0
  92. package/src/cli/contract3-shape.ts +212 -0
  93. package/src/cli/contract3.ts +399 -0
  94. package/src/cli/contract4-credentials.ts +207 -0
  95. package/src/cli/contract4-data.ts +399 -0
  96. package/src/cli/contract4-rename.ts +73 -0
  97. package/src/cli/contract4-yaml.ts +223 -0
  98. package/src/cli/contract4.ts +342 -0
  99. package/src/cli/endpoint-url.ts +1 -1
  100. package/src/cli/identity.ts +44 -26
  101. package/src/cli/lanes.ts +25 -1
  102. package/src/cli/main.ts +94 -14
  103. package/src/cli/migrate-move.ts +166 -0
  104. package/src/cli/migrate-plan.ts +12 -6
  105. package/src/cli/output.ts +34 -1
  106. package/src/cli/publish.ts +6 -7
  107. package/src/cli/runtime/open.ts +64 -99
  108. package/src/cli/runtime/registry.ts +6 -7
  109. package/src/cli/runtime/select.ts +2 -11
  110. package/src/cli/runtime/stores.ts +58 -0
  111. package/src/cli/runtime/types.ts +106 -0
  112. package/src/cli/runtime/vault.ts +19 -4
  113. package/src/cli/runtime/workspace.ts +60 -0
  114. package/src/cli/runtime.ts +2 -1
  115. package/src/cli/selection-require.ts +44 -13
  116. package/src/cli/selection.ts +127 -145
  117. package/src/cli/usage.ts +40 -20
  118. package/src/cli/workspace-migrate.ts +152 -22
  119. package/src/connectivity/manifest/provider.ts +34 -13
  120. package/src/connectivity/manifest/requirements.ts +1 -1
  121. package/src/connectivity/transports/imap/parser.ts +70 -9
  122. package/src/deployments/adapters/filesystem.ts +18 -3
  123. package/src/deployments/bind.ts +1 -1
  124. package/src/deployments/deploy.ts +38 -29
  125. package/src/deployments/gcp/bucket.ts +58 -11
  126. package/src/deployments/gcp/provision.ts +7 -7
  127. package/src/deployments/knowledge.ts +9 -4
  128. package/src/deployments/prepare.ts +72 -24
  129. package/src/deployments/record.ts +1 -1
  130. package/src/deployments/report.ts +2 -2
  131. package/src/deployments/serving.ts +15 -74
  132. package/src/deployments/target.ts +34 -13
  133. package/src/deployments/upload.ts +60 -27
  134. package/src/dispatch/deps.ts +88 -0
  135. package/src/dispatch/dispatch.ts +21 -62
  136. package/src/policy/index.ts +47 -15
  137. package/src/profile/connections.ts +195 -0
  138. package/src/profile/deployments.ts +86 -8
  139. package/src/profile/index.ts +35 -6
  140. package/src/profile/knowledge.ts +18 -5
  141. package/src/profile/layout.ts +163 -90
  142. package/src/profile/load.ts +133 -64
  143. package/src/profile/pairing.ts +32 -0
  144. package/src/profile/primitives.ts +35 -1
  145. package/src/profile/registry.ts +6 -6
  146. package/src/profile/schema.ts +181 -21
  147. package/src/profile/targets.ts +21 -9
  148. package/src/profile/testing.ts +104 -2
  149. package/src/profile/workspace.ts +124 -33
  150. package/src/providers/assets/provider.ts +6 -6
  151. package/src/providers/custom/index.ts +1 -1
  152. package/src/providers/custom/load.ts +2 -3
  153. package/src/providers/entities/provider.ts +6 -6
  154. package/src/providers/entities/writes.ts +1 -1
  155. package/src/providers/identity/provider.ts +2 -2
  156. package/src/providers/memory/provider.ts +26 -8
  157. package/src/providers/setup/plan.ts +1 -1
  158. package/src/providers/setup/provider.ts +3 -3
  159. package/src/providers/skills/provider.ts +2 -2
  160. package/src/providers/slack/index.ts +2 -2
  161. package/src/providers/tasks/provider.ts +6 -6
  162. package/src/providers/vault/provider.ts +1 -1
  163. package/src/registry/policy-bridge.ts +33 -11
  164. package/src/registry/reconcile.ts +4 -4
  165. package/src/registry/registry.ts +1 -1
  166. package/src/server/authorization.ts +94 -0
  167. package/src/server/edge.ts +14 -1
  168. package/src/server/endpoint.ts +89 -104
  169. package/src/server/generation.ts +10 -1
  170. package/src/server/harness.ts +71 -13
  171. package/src/server/index.ts +31 -0
  172. package/src/server/mcp/build.ts +20 -1
  173. package/src/server/mcp/client-info.ts +54 -0
  174. package/src/server/mcp/guide.ts +120 -0
  175. package/src/server/mcp/instructions.ts +22 -22
  176. package/src/server/mcp/prompts.ts +7 -3
  177. package/src/server/mcp/resources.ts +16 -8
  178. package/src/server/mcp/routing.ts +3 -3
  179. package/src/server/mcp/tools.ts +25 -6
  180. package/src/server/mcp/visibility.ts +74 -7
  181. package/src/server/oauth.ts +29 -109
  182. package/src/server/read/credential.ts +134 -0
  183. package/src/server/read/deployed.ts +56 -0
  184. package/src/server/read/listener.ts +54 -0
  185. package/src/server/read/open.ts +101 -0
  186. package/src/server/read/routes.ts +247 -0
  187. package/src/server/read/state.ts +171 -0
  188. package/src/stores/blobs/conformance.ts +19 -0
  189. package/src/stores/state/index.ts +76 -10
  190. package/src/stores/state/testing.ts +5 -1
@@ -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
+ }
@@ -0,0 +1,399 @@
1
+ import { layout } from '#profile';
2
+ import type { BlobStore } from '#stores/blobs';
3
+ import {
4
+ decodeSegment,
5
+ DISCOVERY_NAMESPACE,
6
+ encodeSegment,
7
+ isWorkspaceNamespace,
8
+ OAUTH_NAMESPACE,
9
+ } from '#stores/state';
10
+ import { C3 } from './contract3-layout.ts';
11
+ import { planRenames, renamedProvider } from './contract4-rename.ts';
12
+ import { claim, type Move } from './migrate-move.ts';
13
+
14
+ /**
15
+ * The half of the contract-4 migration that moves bytes rather than YAML.
16
+ *
17
+ * Contract 3 hoisted everything a profile owned up to the workspace; this
18
+ * carries it back down (ADR-066), and flattens what stays at the workspace out
19
+ * of `data/` while it is here (ADR-067). Same seam as `contract3-data.ts`, and
20
+ * the same ordering rule: everything that can refuse happens while this is
21
+ * still a plan.
22
+ *
23
+ * **The interesting decision is what to do with a store two profiles grant.**
24
+ * Contract 3's default gives every profile a grant on the same `.main`
25
+ * instances, so the common case in a freshly created workspace is that both
26
+ * profiles point at one memory. There is no answer that is right for everyone:
27
+ * moving it into one profile takes it away from the other, and merging is not a
28
+ * thing bytes can do. So it is **copied into each, and the original is left**.
29
+ * The operator is told, and deletes what they do not want — which is the only
30
+ * step in this migration that is theirs rather than ours.
31
+ */
32
+
33
+ /** Which profiles grant a connection, by `<provider>.<id>`. */
34
+ export type Granting = ReadonlyMap<string, readonly string[]>;
35
+
36
+ /** Old `<provider>.<id>` to new, for every connection this workspace holds. */
37
+ export type Renames = ReadonlyMap<string, string>;
38
+
39
+ /**
40
+ * The new id for every connection, allocated in file order.
41
+ *
42
+ * File order rather than anything derived, so a rerun produces the same map and
43
+ * a preview matches the apply. A row that already carries an allocated id keeps
44
+ * it — this migration is not a renumbering, and running it twice must not walk
45
+ * `con1` to `con2`.
46
+ *
47
+ * The reserved ids are the owner layer's, and they are what the `lan` prefix is
48
+ * for: a reader can tell a surface built into Lanes from somebody's account
49
+ * without resolving anything.
50
+ */
51
+
52
+ export interface DataPlan {
53
+ readonly moves: readonly Move[];
54
+ /**
55
+ * What was copied rather than moved, and into which profiles.
56
+ *
57
+ * Reported so the operator can delete the original. Never deleted here: a
58
+ * store two profiles were sharing is one whose owner has to decide, and this
59
+ * migration guessing would be the silent wrong answer ADR-059 warns about for
60
+ * the vault in particular.
61
+ */
62
+ readonly shared: readonly { readonly key: string; readonly profiles: readonly string[] }[];
63
+ /** Granted by nobody. Left exactly where it is, and named. */
64
+ readonly orphaned: readonly string[];
65
+ /**
66
+ * Per-profile credential stores contract 3 merged but did not delete.
67
+ *
68
+ * Named separately: they are a decryptable document and its key, and "no
69
+ * profile grants it" is the wrong sentence about one.
70
+ */
71
+ readonly leftover: readonly string[];
72
+ }
73
+
74
+ /**
75
+ * Where every object under `data/` is going.
76
+ *
77
+ * Anything matching no rule is left where it is — this moves what it
78
+ * understands and never deletes what it does not.
79
+ */
80
+ export async function planMoves(
81
+ files: BlobStore,
82
+ granting: Granting,
83
+ profiles: readonly string[],
84
+ renames: Renames = new Map(),
85
+ ): Promise<DataPlan> {
86
+ /** The id a connection ends up with — itself, where nothing renamed it. */
87
+ const renamed = (provider: string, id: string): string => {
88
+ const to = renames.get(`${provider}.${id}`);
89
+ return to === undefined ? id : to.slice(to.indexOf('.') + 1);
90
+ };
91
+
92
+ const moves: Move[] = [];
93
+ const shared: { key: string; profiles: readonly string[] }[] = [];
94
+ const orphaned: string[] = [];
95
+ const leftover: string[] = [];
96
+ const claimed = new Set<string>();
97
+
98
+ for (const blob of await files.list('data/')) {
99
+ const parts = blob.key.split('/').slice(1);
100
+ const head = parts[0];
101
+ const tail = parts.slice(1);
102
+ if (head === undefined) continue;
103
+
104
+ // The four the workspace keeps, out of `data/` and up to the root.
105
+ if (head === 'credentials.enc' || head === 'credentials.enc.key') {
106
+ moves.push({ from: blob.key, to: head });
107
+ continue;
108
+ }
109
+ if (head === 'providers.d') {
110
+ moves.push({ from: blob.key, to: `${layout.providers()}/${tail.join('/')}` });
111
+ continue;
112
+ }
113
+ if (head === 'audit.log') {
114
+ moves.push({ from: blob.key, to: `${layout.audit()}/${tail.join('/')}` });
115
+ continue;
116
+ }
117
+ if (head === 'state.kv') {
118
+ for (const move of stateMoves(blob.key, tail, granting, claimed, renames, orphaned)) {
119
+ moves.push(move);
120
+ }
121
+ continue;
122
+ }
123
+
124
+ // `vault.d/<id>.enc` and `skills.d/<id>/...` — the two the profile takes
125
+ // back, keyed by the connection that already owns them.
126
+ if (head === 'vault.d' || head === 'skills.d') {
127
+ const connection = tail[0];
128
+ if (connection === undefined) continue;
129
+
130
+ const provider = head === 'vault.d' ? 'vault' : 'skills';
131
+ // A vault document is `<id>.enc`, so the id has the suffix stripped; a
132
+ // skills directory is the id itself.
133
+ const id = provider === 'vault' ? connection.replace(/\.enc(\.key)?$/, '') : connection;
134
+ const owners = granting.get(`${provider}.${id}`) ?? [];
135
+
136
+ const to = renamed(provider, id);
137
+ const into = (profile: string): string =>
138
+ provider === 'vault'
139
+ ? `${layout.vaultRoot(profile)}/${connection.replace(id, to)}`
140
+ : `${layout.skills(profile, to)}/${tail.slice(1).join('/')}`;
141
+
142
+ record(blob.key, owners, into, moves, shared, orphaned);
143
+ continue;
144
+ }
145
+
146
+ // **A contract-2 leftover is a credential, not an ungranted store.**
147
+ // Contract 3 merges `data/<profile>/credentials.enc` and never deletes it,
148
+ // so a workspace that came through it still holds one per profile — and
149
+ // "no profile grants it" is true and is the wrong sentence about a
150
+ // decryptable document: the operator reads it as tidy-up and leaves it.
151
+ if (tail[0] === 'credentials.enc' || tail[0] === 'credentials.enc.key') {
152
+ leftover.push(blob.key);
153
+ continue;
154
+ }
155
+
156
+ // Otherwise `<provider>/<connection>/…`, the namespace every provider's
157
+ // blobs are scoped into — memory, tasks, assets, entities and every vendor.
158
+ const connection = tail[0];
159
+ if (connection === undefined) continue;
160
+
161
+ const ref = `${head}.${connection}`;
162
+ const owners = granting.get(ref) ?? [];
163
+
164
+ // Both segments from the one rename: one without the other reads nothing.
165
+ const settled = renames.get(ref) ?? ref;
166
+ const dot = settled.indexOf('.');
167
+ const into = `${settled.slice(0, dot)}/${settled.slice(dot + 1)}`;
168
+
169
+ record(
170
+ blob.key,
171
+ owners,
172
+ (profile) => `${layout.blobs(profile)}/${into}/${tail.slice(1).join('/')}`,
173
+ moves,
174
+ shared,
175
+ orphaned,
176
+ );
177
+ }
178
+
179
+ // The declarations themselves, into the directory each now owns.
180
+ for (const profile of profiles) {
181
+ const from = C3.profile(profile);
182
+ if (await files.has(from)) moves.push({ from, to: layout.profileConfig(profile) });
183
+ }
184
+
185
+ return { moves, shared, orphaned, leftover };
186
+ }
187
+
188
+ /**
189
+ * One object, routed by how many profiles grant the connection it belongs to.
190
+ *
191
+ * Exactly one is a move. Several is a copy into each, with the original left
192
+ * behind — see the header. None is left alone and named: a store nothing grants
193
+ * is not this migration's to place, and deleting it would be the one
194
+ * irreversible thing in here.
195
+ */
196
+ function record(
197
+ key: string,
198
+ owners: readonly string[],
199
+ into: (profile: string) => string,
200
+ moves: Move[],
201
+ shared: { key: string; profiles: readonly string[] }[],
202
+ orphaned: string[],
203
+ ): void {
204
+ if (owners.length === 0) {
205
+ orphaned.push(key);
206
+ return;
207
+ }
208
+
209
+ if (owners.length === 1) {
210
+ moves.push({ from: key, to: into(owners[0]!) });
211
+ return;
212
+ }
213
+
214
+ // `keep` rather than a move: `applyMoves` deletes the source once the copy
215
+ // reads back, and there is a second destination still to write.
216
+ for (const profile of owners) moves.push({ from: key, to: into(profile), keep: true });
217
+ shared.push({ key, profiles: owners });
218
+ }
219
+
220
+ /**
221
+ * Where one object under `data/state.kv` goes — and it may go to two places.
222
+ *
223
+ * The split is by namespace rather than by key: connection records, the
224
+ * discovery cache and the endpoint's own OAuth server are the workspace's, and
225
+ * everything else is one profile's use of an account. `isWorkspaceNamespace` in
226
+ * `#stores/state` is that rule and this reads it rather than restating it.
227
+ *
228
+ * A cursor belongs to whichever profiles grant its connection, so it takes the
229
+ * same fan-out as a blob does. The alternative — one cursor shared — is exactly
230
+ * the bug ADR-066 is fixing, two agents consuming each other's position.
231
+ */
232
+ /**
233
+ * The namespaces contract 3 spelled without a dot, and contract 4 does.
234
+ *
235
+ * `discovery` and `oauth/…` were reachable by a provider — only `custom` is
236
+ * refused by grammar, so a manifest with id `oauth` landed inside them — and a
237
+ * dotted name is one no provider can reach. Renaming them is only half the job:
238
+ * the objects already written under the old spelling have to come across, or
239
+ * every client that had signed in is signed out and every discovered spec is
240
+ * fetched again. The rehearsal that found this left 75 OAuth records behind.
241
+ */
242
+ const RENAMED_NAMESPACES: readonly [string, string][] = [
243
+ ['discovery', DISCOVERY_NAMESPACE],
244
+ ['oauth', OAUTH_NAMESPACE],
245
+ ];
246
+
247
+ /** `<provider>/<connection>` as a state namespace is `<provider>.<connection>` as a ref. */
248
+ function refOf(namespace: string): string {
249
+ const slash = namespace.indexOf('/');
250
+ return slash === -1 ? namespace : `${namespace.slice(0, slash)}.${namespace.slice(slash + 1)}`;
251
+ }
252
+
253
+ function stateMoves(
254
+ from: string,
255
+ tail: readonly string[],
256
+ granting: Granting,
257
+ claimed: Set<string>,
258
+ renames: Renames = new Map(),
259
+ orphaned: string[] = [],
260
+ ): Move[] {
261
+ const leaf = tail[tail.length - 1];
262
+
263
+ // **Guarded, the way `contract3-data.ts` guards it and for its reason.**
264
+ // `decodeSegment` throws `URIError` on a stray `%`, and contract 3's catch
265
+ // moves such a key *verbatim* — so the class of key that survives contract 3
266
+ // by design is exactly the class this read unguarded, and one of them threw
267
+ // `URI malformed` naming no file out of every command including the preview.
268
+ // A key this cannot parse is carried across unchanged rather than dropped.
269
+ if (leaf === undefined || tail.length < 2 || !leaf.endsWith('.json')) {
270
+ const move = claim(claimed, { from, to: `${layout.state()}/${tail.join('/')}` });
271
+ return move === null ? [] : [move];
272
+ }
273
+
274
+ let namespace: string;
275
+ let key: string;
276
+ try {
277
+ namespace = tail.slice(0, -1).map(decodeSegment).join('/');
278
+ key = decodeSegment(leaf.replace(/\.json$/, ''));
279
+ } catch {
280
+ const move = claim(claimed, { from, to: `${layout.state()}/${tail.join('/')}` });
281
+ return move === null ? [] : [move];
282
+ }
283
+
284
+ // The undotted spellings, carried across before anything else looks at them.
285
+ for (const [was, now] of RENAMED_NAMESPACES) {
286
+ if (namespace !== was && !namespace.startsWith(`${was}/`)) continue;
287
+
288
+ // Only the first segment. `oauth/tokens` is two segments and each is
289
+ // encoded on its own, so encoding `oauth.v1/tokens` whole would escape the
290
+ // separator too and write one segment named `oauth%2Ev1%2Ftokens`.
291
+ const move = claim(claimed, {
292
+ from,
293
+ to: `${layout.state()}/${[encodeSegment(now), ...tail.slice(1)].join('/')}`,
294
+ });
295
+ return move === null ? [] : [move];
296
+ }
297
+
298
+ if (isWorkspaceNamespace(namespace)) {
299
+ // A connection record is keyed on the ref *and* carries it in the body:
300
+ // `ConnectionRepository.list` reads `provider` and `id` out of the record,
301
+ // not out of the key. A renamed connection whose bytes moved verbatim would
302
+ // sit at `connections.v1/gmail.con1` still calling itself `gmail.main`, and
303
+ // the next reconcile would write a second record beside it.
304
+ const settled = namespace === 'connections.v1' ? renames.get(key) : undefined;
305
+ const to = `${layout.state()}/${
306
+ settled === undefined ? tail.join('/') : `${tail[0]!}/${encodeSegment(settled)}.json`
307
+ }`;
308
+
309
+ // Two profiles both hold a record for a connection they share, and the
310
+ // second differs only in when it was written.
311
+ const move = claim(claimed, {
312
+ from,
313
+ to,
314
+ ...(settled === undefined
315
+ ? {}
316
+ : { rewrite: (data: Uint8Array) => retarget(data, settled) }),
317
+ });
318
+ return move === null ? [] : [move];
319
+ }
320
+
321
+ // A cursor's namespace is `cursors.v1` and its *key* is the connection; a
322
+ // provider's own store is namespaced `<provider>/<connection>` instead — and
323
+ // that slash is not the dot a grant is keyed on. Looking the one up with the
324
+ // other matched nothing and dropped every provider's state in silence, which
325
+ // is the same shape of bug `contract3-data.ts` records having shipped once.
326
+ const cursor = namespace === 'cursors.v1';
327
+ const ref = cursor ? key : refOf(namespace);
328
+ const owners = granting.get(ref) ?? [];
329
+
330
+ if (owners.length === 0) {
331
+ orphaned.push(from);
332
+ return [];
333
+ }
334
+
335
+ const settled = renames.get(ref) ?? ref;
336
+ const dot = settled.indexOf('.');
337
+
338
+ // The rename reaches whichever half carries the ref, and only that half.
339
+ const moved = cursor
340
+ ? `${tail[0]!}/${encodeSegment(settled)}.json`
341
+ : [
342
+ encodeSegment(settled.slice(0, dot)),
343
+ encodeSegment(settled.slice(dot + 1)),
344
+ ...tail.slice(2),
345
+ ].join('/');
346
+
347
+ return owners.map((profile) => ({
348
+ from,
349
+ to: `${layout.profileState(profile)}/${moved}`,
350
+ keep: owners.length > 1,
351
+ }));
352
+ }
353
+
354
+ /**
355
+ * A connection record, told what it is now called.
356
+ *
357
+ * Spread rather than assigned field by field, so key order and anything a later
358
+ * version added survive. Bytes that are not a JSON object come back untouched:
359
+ * refusing to move what will not parse strands it where nothing reads it.
360
+ */
361
+ function retarget(data: Uint8Array, ref: string): Uint8Array {
362
+ try {
363
+ const held = JSON.parse(new TextDecoder().decode(data)) as Record<string, unknown>;
364
+ if (held === null || typeof held !== 'object' || Array.isArray(held)) return data;
365
+
366
+ const dot = ref.indexOf('.');
367
+ return new TextEncoder().encode(
368
+ JSON.stringify({ ...held, provider: ref.slice(0, dot), id: ref.slice(dot + 1) }),
369
+ );
370
+ } catch {
371
+ return data;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Which profiles grant each connection, read off the profiles themselves.
377
+ *
378
+ * Derived rather than assumed, because the fan-out above is the whole decision:
379
+ * a connection nobody grants must not be placed anywhere, and one two profiles
380
+ * grant must not be placed in only the first. `connections.yaml` cannot answer
381
+ * this — it says what exists, not who selected it (ADR-057).
382
+ */
383
+ export function grantingProfiles(
384
+ configs: ReadonlyMap<string, { grants?: { connection?: unknown }[] }>,
385
+ ): Granting {
386
+ const granting = new Map<string, string[]>();
387
+
388
+ for (const [profile, config] of configs) {
389
+ for (const grant of config.grants ?? []) {
390
+ const ref = grant.connection;
391
+ if (typeof ref !== 'string') continue;
392
+ const owners = granting.get(ref) ?? [];
393
+ if (!owners.includes(profile)) owners.push(profile);
394
+ granting.set(ref, owners);
395
+ }
396
+ }
397
+
398
+ return granting;
399
+ }
@@ -0,0 +1,73 @@
1
+ import { nextConnectionId } from './identity.ts';
2
+ import { C3_OWNER_PROVIDERS } from './contract3-layout.ts';
3
+ import type { Renames } from './contract4-data.ts';
4
+
5
+ /**
6
+ * What every connection is called after contract 4.
7
+ *
8
+ * Its own module because it is one decision read by four rewrites — the rows,
9
+ * the grants, the blob paths and the state keys — and because a map keyed one
10
+ * way and queried another is the bug `contract3-data.ts` records shipping once.
11
+ * One place to build it, one place for everything else to read it from.
12
+ */
13
+
14
+ /**
15
+ * The id and provider every connection ends up with.
16
+ *
17
+ * Two things happen here, and the merge is the interesting one.
18
+ *
19
+ * **The owner layer merges to one row per surface.** Contract 3 made the
20
+ * *connection* the isolation boundary, so three profiles each needed their own
21
+ * `memory` — a workspace with three profiles came out of that migration holding
22
+ * `memory.main`, `memory.work` and `memory.demo`, and the file read as
23
+ * duplicated because it was. ADR-066 makes the *profile* the boundary, so one
24
+ * row serves every profile and each still keeps its own bytes at
25
+ * `profiles/<name>/lanes_memory/lan1/`. Merging the rows merges no notes, which
26
+ * is exactly why contract 3 could not do it and this can.
27
+ *
28
+ * **Both sequences are numbered cleanly**, `con1..conN` over the accounts in
29
+ * file order and `lan1..lan8` over the merged owner rows. A rerun produces the
30
+ * same map because the order is the file's, and a row already carrying an
31
+ * allocated id keeps it — this is not a renumbering, and running it twice must
32
+ * not walk `con1` to `con2`.
33
+ */
34
+ export function planRenames(
35
+ connections: readonly { id: string; provider: string }[],
36
+ ): Renames {
37
+ const renames = new Map<string, string>();
38
+ const taken = connections
39
+ .map((row) => row.id)
40
+ .filter((id) => /^(lan|con)[0-9]+$/.test(id));
41
+
42
+ // One destination per owner-layer *provider*, so every profile's instance of
43
+ // it lands on the same row.
44
+ const merged = new Map<string, string>();
45
+
46
+ for (const row of connections) {
47
+ const owner = C3_OWNER_PROVIDERS.includes(row.provider);
48
+ const provider = renamedProvider(row.provider);
49
+ const from = `${row.provider}.${row.id}`;
50
+
51
+ if (owner) {
52
+ const already = merged.get(provider);
53
+ if (already !== undefined) {
54
+ renames.set(from, already);
55
+ continue;
56
+ }
57
+ }
58
+
59
+ const id = /^(lan|con)[0-9]+$/.test(row.id) ? row.id : nextConnectionId(taken, owner);
60
+ if (!taken.includes(id)) taken.push(id);
61
+
62
+ const to = `${provider}.${id}`;
63
+ renames.set(from, to);
64
+ if (owner) merged.set(provider, to);
65
+ }
66
+
67
+ return renames;
68
+ }
69
+
70
+ /** `memory` becomes `lanes_memory`; everything else keeps its id. */
71
+ export function renamedProvider(provider: string): string {
72
+ return C3_OWNER_PROVIDERS.includes(provider) ? `lanes_${provider}` : provider;
73
+ }