@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.
Files changed (90) hide show
  1. package/README.md +1 -25
  2. package/instructions/skills/lanes-link/SKILL.md +12 -12
  3. package/package.json +1 -1
  4. package/src/auth/oauth/store.ts +21 -5
  5. package/src/cli/accepts.ts +6 -5
  6. package/src/cli/brand.ts +20 -15
  7. package/src/cli/callback-page.ts +8 -7
  8. package/src/cli/commands/connect/index.ts +2 -1
  9. package/src/cli/commands/connect/settle.ts +12 -4
  10. package/src/cli/commands/connection.ts +2 -1
  11. package/src/cli/commands/identity.ts +4 -4
  12. package/src/cli/commands/knowledge/index.ts +9 -16
  13. package/src/cli/commands/knowledge/migrate.ts +9 -4
  14. package/src/cli/commands/knowledge/show.ts +14 -9
  15. package/src/cli/commands/operate/migrate.ts +5 -2
  16. package/src/cli/commands/operate/pair.ts +1 -1
  17. package/src/cli/commands/operate/serve.ts +1 -1
  18. package/src/cli/commands/owner/assets.ts +2 -2
  19. package/src/cli/commands/owner/entities.ts +2 -2
  20. package/src/cli/commands/owner/memory.ts +2 -2
  21. package/src/cli/commands/owner/tasks.ts +2 -2
  22. package/src/cli/commands/owner/vault.ts +3 -3
  23. package/src/cli/commands/profile/disposition.ts +236 -0
  24. package/src/cli/commands/profile/removal.ts +100 -13
  25. package/src/cli/commands/profile/remove.ts +67 -6
  26. package/src/cli/commands/profile.ts +33 -6
  27. package/src/cli/commands/secrets.ts +4 -4
  28. package/src/cli/commands/update-migration.ts +54 -0
  29. package/src/cli/commands/update.ts +36 -23
  30. package/src/cli/config-edit.ts +29 -8
  31. package/src/cli/config-repair-sweep.ts +183 -0
  32. package/src/cli/config-repair.ts +90 -141
  33. package/src/cli/config-templates.ts +26 -24
  34. package/src/cli/contract3-credentials.ts +294 -0
  35. package/src/cli/contract3-data.ts +143 -209
  36. package/src/cli/contract3-layout.ts +46 -0
  37. package/src/cli/contract3-shape.ts +34 -8
  38. package/src/cli/contract3.ts +141 -24
  39. package/src/cli/contract4-credentials.ts +207 -0
  40. package/src/cli/contract4-data.ts +399 -0
  41. package/src/cli/contract4-rename.ts +73 -0
  42. package/src/cli/contract4-yaml.ts +223 -0
  43. package/src/cli/contract4.ts +349 -0
  44. package/src/cli/identity.ts +44 -26
  45. package/src/cli/main.ts +6 -1
  46. package/src/cli/migrate-move.ts +166 -0
  47. package/src/cli/migrate-plan.ts +3 -3
  48. package/src/cli/publish.ts +1 -5
  49. package/src/cli/runtime/open.ts +5 -5
  50. package/src/cli/runtime/select.ts +2 -11
  51. package/src/cli/runtime/stores.ts +16 -11
  52. package/src/cli/runtime/vault.ts +2 -2
  53. package/src/cli/usage.ts +5 -1
  54. package/src/cli/workspace-migrate.ts +32 -11
  55. package/src/connectivity/manifest/provider.ts +31 -12
  56. package/src/connectivity/transports/imap/parser.ts +70 -9
  57. package/src/deployments/adapters/filesystem.ts +18 -3
  58. package/src/deployments/deploy.ts +5 -5
  59. package/src/deployments/gcp/bucket.ts +42 -6
  60. package/src/deployments/knowledge.ts +9 -4
  61. package/src/deployments/target.ts +28 -7
  62. package/src/deployments/upload.ts +39 -30
  63. package/src/profile/connections.ts +13 -1
  64. package/src/profile/deployments.ts +86 -8
  65. package/src/profile/index.ts +5 -1
  66. package/src/profile/knowledge.ts +18 -5
  67. package/src/profile/layout.ts +147 -71
  68. package/src/profile/load.ts +53 -17
  69. package/src/profile/schema.ts +11 -2
  70. package/src/profile/testing.ts +45 -10
  71. package/src/profile/workspace.ts +66 -30
  72. package/src/providers/assets/provider.ts +6 -6
  73. package/src/providers/entities/provider.ts +6 -6
  74. package/src/providers/entities/writes.ts +1 -1
  75. package/src/providers/identity/provider.ts +1 -1
  76. package/src/providers/memory/provider.ts +6 -6
  77. package/src/providers/setup/provider.ts +3 -3
  78. package/src/providers/skills/provider.ts +2 -2
  79. package/src/providers/tasks/provider.ts +6 -6
  80. package/src/providers/vault/provider.ts +1 -1
  81. package/src/registry/registry.ts +1 -1
  82. package/src/server/endpoint.ts +4 -0
  83. package/src/server/harness.ts +1 -1
  84. package/src/server/mcp/instructions.ts +21 -21
  85. package/src/server/mcp/routing.ts +3 -3
  86. package/src/server/mcp/tools.ts +16 -3
  87. package/src/server/mcp/visibility.ts +56 -4
  88. package/src/stores/blobs/conformance.ts +19 -0
  89. package/src/stores/state/index.ts +76 -10
  90. package/src/stores/state/testing.ts +5 -1
@@ -1,119 +1,18 @@
1
- import { ConfigError, DATA_DIR, isRemoteWorkspace, layout } from '#profile';
2
- import { createFileSecretStore } from '#secrets';
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';
3
4
  import type { BlobStore } from '#stores/blobs';
5
+ import { CONNECTIONS_NAMESPACE, decodeSegment, objectKey } from '#stores/state';
4
6
 
5
7
  /**
6
8
  * The half of the contract-3 migration that moves bytes rather than YAML.
7
9
  *
8
10
  * Split from `contract3.ts` on the seam the migration already has: that file
9
11
  * decides *what* the new shape is, and this one carries the credentials and
10
- * objects into it. Both halves are ordered so a crash between any two steps
11
- * leaves a workspace that still opens, and the rule that makes that true lives
12
- * here — nothing is deleted until what replaced it has been read back.
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.
13
14
  */
14
15
 
15
- export interface Move {
16
- readonly from: string;
17
- readonly to: string;
18
- }
19
-
20
- /** `gmail.main` — how a connection is addressed in every file after this. */
21
- function keyOf(connection: { provider: string; id: string }): string {
22
- return `${connection.provider}.${connection.id}`;
23
- }
24
-
25
- /**
26
- * Which credential refs the merged store will hold.
27
- *
28
- * Read-only, and it runs before anything is written so the report an operator
29
- * confirms is the real one. A ref present in two profiles' stores with two
30
- * different values is the one thing this cannot resolve, and it is reported
31
- * rather than merged: both are real credentials, and picking either would point
32
- * a connection at the wrong account's token.
33
- *
34
- * **A workspace in a bucket has nothing to merge, and this says so rather than
35
- * finding out.** `workspacePath` refuses a filesystem adapter against a remote
36
- * root, so the only credential store such a workspace can declare is
37
- * `gcp-secret-manager` — whose refs were never scoped by profile, and are
38
- * therefore already what contract 3 wants. Without the guard the path below is
39
- * built by string interpolation into `gs://bucket/data/<profile>/credentials.enc`
40
- * and handed to `Bun.file`, where the failure is swallowed by the `catch` and
41
- * reads exactly like a workspace with no credentials in it.
42
- */
43
- export async function planCredentials(root: string, profiles: readonly string[]): Promise<string[]> {
44
- if (isRemoteWorkspace(root)) return [];
45
-
46
- const refs = new Set<string>();
47
-
48
- for (const profile of profiles) {
49
- const store = createFileSecretStore({ path: `${root}/${DATA_DIR}/${profile}/credentials.enc` });
50
- try {
51
- for (const ref of await store.list()) refs.add(ref);
52
- } catch {
53
- // A store that will not open is reported by `doctor`, not here. This is a
54
- // preview and must not fail on a workspace that is already broken.
55
- }
56
- }
57
-
58
- return [...refs].sort();
59
- }
60
-
61
- /**
62
- * Copy every profile's credentials into the workspace store.
63
- *
64
- * Written and read back before the old stores are touched, which is the whole
65
- * of the safety argument: a half-finished merge that has not deleted anything is
66
- * recoverable by running it again, and one that deleted first is not.
67
- *
68
- * The old stores are left in place regardless. They are a few kilobytes, they
69
- * are the only copy of anything if this went wrong, and `doctor` names them so
70
- * an operator can remove them once the endpoint has served a request.
71
- *
72
- * Skipped entirely for a workspace in a bucket, for the reason `planCredentials`
73
- * gives: its credentials are in Secret Manager under refs that were never
74
- * per-profile, so there is no second store to fold in.
75
- */
76
- export async function mergeCredentials(root: string, profiles: readonly string[]): Promise<void> {
77
- if (isRemoteWorkspace(root)) return;
78
-
79
- const destination = createFileSecretStore({ path: `${root}/${layout.credentials()}` });
80
-
81
- for (const profile of profiles) {
82
- const source = createFileSecretStore({ path: `${root}/${DATA_DIR}/${profile}/credentials.enc` });
83
-
84
- let refs: string[];
85
- try {
86
- refs = await source.list();
87
- } catch {
88
- continue;
89
- }
90
-
91
- for (const ref of refs) {
92
- const value = await source.get(ref);
93
- if (value === null) continue;
94
-
95
- const held = await destination.get(ref);
96
- if (held !== null) {
97
- if (held === value) continue;
98
- throw new ConfigError(
99
- `Two profiles hold different values for the credential "${ref}", and this migration ` +
100
- `cannot choose between them.\n` +
101
- ` Both are real credentials for different accounts, and picking either would point a ` +
102
- `connection at the wrong one.\n` +
103
- ` Rename one connection before migrating, so its credential ref differs.`,
104
- );
105
- }
106
-
107
- await destination.set(ref, value);
108
- if ((await destination.get(ref)) !== value) {
109
- throw new ConfigError(
110
- `The credential "${ref}" did not read back after being written to ` +
111
- `${layout.credentials()}. Nothing has been deleted; fix the store and run this again.`,
112
- );
113
- }
114
- }
115
- }
116
- }
117
16
 
118
17
  /**
119
18
  * Where every object under `data/<profile>/` is going.
@@ -136,10 +35,14 @@ export async function planMoves(
136
35
  perProfile: ReadonlyMap<string, ReadonlyMap<string, string>>,
137
36
  ): Promise<Move[]> {
138
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>();
139
42
 
140
43
  for (const profile of profiles) {
141
44
  const mapping = perProfile.get(profile) ?? new Map<string, string>();
142
- const prefix = `${DATA_DIR}/${profile}/`;
45
+ const prefix = `${LEGACY_DATA_DIR}/${profile}/`;
143
46
 
144
47
  for (const blob of await files.list(prefix)) {
145
48
  const rest = blob.key.slice(prefix.length);
@@ -147,9 +50,7 @@ export async function planMoves(
147
50
  if (head === undefined) continue;
148
51
 
149
52
  // The credential store is merged rather than moved, and the old copy is
150
- // deliberately left behind. `state.kv` and `audit.log` are per profile and
151
- // become the workspace's, but their contents already carry the profile in
152
- // every record, so they are concatenated by moving the objects across.
53
+ // deliberately left behind.
153
54
  if (head === 'credentials.enc' || head === 'credentials.enc.key') continue;
154
55
 
155
56
  // The instance this profile's single-instance surfaces became. Both are
@@ -159,26 +60,41 @@ export async function planMoves(
159
60
  // which is the worst of the collisions because the wrong answer is a
160
61
  // credential (ADR-059).
161
62
  if (head === 'vault.enc') {
162
- moves.push({ from: blob.key, to: layout.vault(instanceOf(mapping, 'vault')) });
63
+ moves.push({ from: blob.key, to: C3.vault(instanceOf(mapping, 'vault')) });
163
64
  continue;
164
65
  }
165
66
  if (head === 'vault.enc.key') {
166
- moves.push({ from: blob.key, to: `${layout.vault(instanceOf(mapping, 'vault'))}.key` });
67
+ moves.push({ from: blob.key, to: `${C3.vault(instanceOf(mapping, 'vault'))}.key` });
167
68
  continue;
168
69
  }
169
70
  if (head === 'skills.d') {
170
71
  moves.push({
171
72
  from: blob.key,
172
- to: `${layout.skills(instanceOf(mapping, 'skills'))}/${tail.join('/')}`,
73
+ to: `${C3.skills(instanceOf(mapping, 'skills'))}/${tail.join('/')}`,
173
74
  });
174
75
  continue;
175
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.
176
81
  if (head === 'providers.d') {
177
- moves.push({ from: blob.key, to: `${layout.providers()}/${tail.join('/')}` });
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('/')}` });
178
93
  continue;
179
94
  }
180
- if (head === 'state.kv' || head === 'audit.log') {
181
- moves.push({ from: blob.key, to: `${DATA_DIR}/${head}/${tail.join('/')}` });
95
+ if (head === 'state.kv') {
96
+ const move = stateMove(blob.key, tail, mapping, claimed);
97
+ if (move !== null) moves.push(move);
182
98
  continue;
183
99
  }
184
100
 
@@ -190,7 +106,7 @@ export async function planMoves(
190
106
  // This profile's old key, through this profile's mapping.
191
107
  const settled = mapping.get(`${head}.${connection}`);
192
108
  const id = settled === undefined ? connection : (settled.split('.')[1] ?? connection);
193
- moves.push({ from: blob.key, to: `${DATA_DIR}/${head}/${id}/${tail.slice(1).join('/')}` });
109
+ moves.push({ from: blob.key, to: `${LEGACY_DATA_DIR}/${head}/${id}/${tail.slice(1).join('/')}` });
194
110
  }
195
111
  }
196
112
 
@@ -198,31 +114,120 @@ export async function planMoves(
198
114
  return moves;
199
115
  }
200
116
 
117
+
201
118
  /**
202
- * Two objects aimed at one key, caught while this is still a plan.
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.
203
128
  *
204
- * `applyMoves` checks the destination per object as well, but that check cannot
205
- * see a collision between two objects *in this run* once the moves are applied
206
- * concurrently both would look at an absent destination and both would write.
207
- * Hoisting it here also puts it where this file says it belongs: everything that
208
- * can fail happens before the first byte moves, so a refusal leaves the
209
- * workspace exactly as it was.
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.
210
135
  */
211
- function assertOneObjectPerDestination(moves: readonly Move[]): void {
212
- const seen = new Map<string, string>();
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
+ }
213
151
 
214
- for (const move of moves) {
215
- const first = seen.get(move.to);
216
- if (first !== undefined) {
217
- throw new ConfigError(
218
- `Two objects want to be at ${move.to}, and this migration cannot merge them.\n` +
219
- ` ${first} and ${move.from}. Nothing has been written.\n` +
220
- ' This should be unreachable: the hoist gives every profile its own instance of ' +
221
- 'each owner-layer surface. Please report it with the layout of your data directory.',
222
- );
223
- }
224
- seen.set(move.to, move.from);
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 });
225
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
+ );
226
231
  }
227
232
 
228
233
  /**
@@ -254,75 +259,4 @@ function instanceOf(mapping: ReadonlyMap<string, string>, provider: string): str
254
259
  return 'main';
255
260
  }
256
261
 
257
- /**
258
- * How many objects are in flight at once.
259
- *
260
- * Serial was fine while this only ever ran against a local disk. A deployed
261
- * workspace's audit log is one object per event, so the first real bucket this
262
- * migrated held 1,906 of them — three round trips each, in series, is minutes of
263
- * a deploy spent with nothing on screen. The same 16 the read paths in
264
- * `#providers/memory` and `#providers/tasks` settled on, and for the same
265
- * reason: enough to hide the latency, not enough to look like an incident to the
266
- * other end.
267
- *
268
- * Safe to widen only while each move stays independent, which is what
269
- * `assertOneObjectPerDestination` guarantees.
270
- */
271
- const MOVE_CONCURRENCY = 16;
272
-
273
- export async function applyMoves(files: BlobStore, moves: readonly Move[]): Promise<void> {
274
- const pending = moves.filter((move) => move.from !== move.to);
275
-
276
- for (let start = 0; start < pending.length; start += MOVE_CONCURRENCY) {
277
- await Promise.all(
278
- pending.slice(start, start + MOVE_CONCURRENCY).map((move) => applyMove(files, move)),
279
- );
280
- }
281
- }
282
-
283
- /** One object, moved or finished. Never deletes before the copy reads back. */
284
- async function applyMove(files: BlobStore, move: Move): Promise<void> {
285
- const data = await files.get(move.from);
286
- if (data === null) return;
287
-
288
- if (await files.has(move.to)) {
289
- const held = await files.get(move.to);
290
-
291
- // Raced away between the two calls, so there is nothing there after all and
292
- // the ordinary path below is still the right one.
293
- if (held !== null) {
294
- if (!sameBytes(held, data)) {
295
- throw new ConfigError(
296
- `Two objects want to be at ${move.to}, and this migration cannot merge them.\n` +
297
- ` ${move.from} is the second, and what is already there is not a copy of it.\n` +
298
- ' Nothing has been deleted. This should be unreachable: the hoist gives every ' +
299
- 'profile its own instance of each owner-layer surface. Please report it with the ' +
300
- 'layout of your data directory.',
301
- );
302
- }
303
-
304
- // Already copied, by a run that did not get to the delete.
305
- await files.delete(move.from);
306
- return;
307
- }
308
- }
309
-
310
- await files.put(move.to, data);
311
- if ((await files.get(move.to)) === null) {
312
- throw new ConfigError(
313
- `${move.to} did not read back after being written. Nothing has been deleted; ` +
314
- `fix the store and run this again.`,
315
- );
316
- }
317
-
318
- await files.delete(move.from);
319
- }
320
-
321
- function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
322
- if (left.length !== right.length) return false;
323
- for (let index = 0; index < left.length; index += 1) {
324
- if (left[index] !== right[index]) return false;
325
- }
326
- return true;
327
- }
328
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;
@@ -1,6 +1,7 @@
1
1
  import { RESERVED_PROVIDER_IDS } from '#connectivity';
2
2
  import type { ContractRename, LegacyConnection, LegacyProfile } from './contract3.ts';
3
3
  import { keyOf } from './contract3.ts';
4
+ import { C3_OWNER_PROVIDERS } from './contract3-layout.ts';
4
5
 
5
6
  /**
6
7
  * Turning contract 2's shape into contract 3's, without touching a file.
@@ -49,7 +50,7 @@ export function hoistConnections(profiles: ReadonlyMap<string, LegacyProfile>):
49
50
  // merge. That is the one outcome ADR-059 forbids: interleaving two sets of
50
51
  // notes is not reversible and not reviewable, and for the vault the wrong
51
52
  // answer is a credential. Keying on the profile forces a rename instead.
52
- const owner = RESERVED_PROVIDER_IDS.includes(connection.provider);
53
+ const owner = C3_OWNER_PROVIDERS.includes(connection.provider);
53
54
  const identity = owner
54
55
  ? `${connection.provider} @${profile}`
55
56
  : `${connection.provider} ${connection.account}`;
@@ -166,7 +167,20 @@ export function grantsFor(
166
167
  return pattern === '*' || pattern.startsWith(`${provider}.`);
167
168
  };
168
169
 
169
- return (config.connections ?? []).map((connection) => {
170
+ // Keyed on the *settled* connection, because two rows can become one. A
171
+ // profile could hold `gmail.main` and `gmail.archive` for the same mailbox at
172
+ // different scopes — which is legal under contract 2, and the reason tokens
173
+ // are per connection at all — and the hoist keys on provider-plus-account, so
174
+ // both settle on one row. Emitting a grant per original row then wrote the
175
+ // same `connection:` twice and `assertGrantsUnique` refused the profile,
176
+ // failing the migration on a merge it had just performed itself.
177
+ //
178
+ // The rules are unioned rather than picked between. Both rows were in force
179
+ // before, the connection they describe is now one connection, and dropping
180
+ // either would narrow a policy the operator never narrowed.
181
+ const rows = new Map<string, { connection: string; allow: Rule[]; deny: Rule[] }>();
182
+
183
+ for (const connection of config.connections ?? []) {
170
184
  const provider = connection.provider;
171
185
  // A bare `*` becomes the provider wildcard rather than being copied
172
186
  // through. It would still mean the same thing inside a row, which is
@@ -177,10 +191,22 @@ export function grantsFor(
177
191
  return rule.capability === '*' ? { ...rule, capability: `${provider}.*` } : rule;
178
192
  };
179
193
 
180
- return {
181
- connection: mapping.get(keyOf(connection)) ?? keyOf(connection),
182
- allow: allow.filter((rule) => covers(rule, provider)).map(widen),
183
- deny: deny.filter((rule) => covers(rule, provider)).map(widen),
184
- };
185
- });
194
+ const key = mapping.get(keyOf(connection)) ?? keyOf(connection);
195
+ const row = rows.get(key) ?? { connection: key, allow: [], deny: [] };
196
+
197
+ row.allow = union(row.allow, allow.filter((rule) => covers(rule, provider)).map(widen));
198
+ row.deny = union(row.deny, deny.filter((rule) => covers(rule, provider)).map(widen));
199
+ rows.set(key, row);
200
+ }
201
+
202
+ return [...rows.values()];
203
+ }
204
+
205
+ /** Rules from two merged rows, keeping the first spelling of each capability. */
206
+ function union(held: readonly Rule[], adding: readonly Rule[]): Rule[] {
207
+ const merged = [...held];
208
+ for (const rule of adding) {
209
+ if (!merged.some((one) => capabilityOf(one) === capabilityOf(rule))) merged.push(rule);
210
+ }
211
+ return merged;
186
212
  }