@lanes-sh/link 0.9.1 → 0.9.3

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 (59) hide show
  1. package/README.md +10 -4
  2. package/instructions/agents/lanes-link-scout.md +3 -3
  3. package/instructions/skills/lanes-link/SKILL.md +95 -57
  4. package/package.json +1 -1
  5. package/src/auth/index.ts +127 -26
  6. package/src/cli/accepts.ts +13 -3
  7. package/src/cli/commands/connect/declare.ts +16 -7
  8. package/src/cli/commands/connect/index.ts +4 -4
  9. package/src/cli/commands/connect/settle.ts +33 -7
  10. package/src/cli/commands/connection-list.ts +25 -1
  11. package/src/cli/commands/mcp/harnesses.ts +36 -22
  12. package/src/cli/commands/mcp/register.ts +63 -16
  13. package/src/cli/commands/mcp/stdio.ts +0 -1
  14. package/src/cli/commands/operate/inspect.ts +21 -10
  15. package/src/cli/commands/operate/outputs.ts +94 -61
  16. package/src/cli/commands/operate/serve.ts +0 -4
  17. package/src/cli/commands/operate/token.ts +305 -35
  18. package/src/cli/commands/operate/tools.ts +28 -5
  19. package/src/cli/commands/operate.ts +7 -1
  20. package/src/cli/commands/profile/removal.ts +10 -9
  21. package/src/cli/config-repair-sweep.ts +18 -4
  22. package/src/cli/config-repair.ts +1 -1
  23. package/src/cli/config-templates.ts +14 -7
  24. package/src/cli/contract3-credentials.ts +8 -8
  25. package/src/cli/contract4.ts +7 -2
  26. package/src/cli/contract5.ts +234 -0
  27. package/src/cli/endpoint-url.ts +17 -3
  28. package/src/cli/main.ts +28 -4
  29. package/src/cli/publish.ts +13 -4
  30. package/src/cli/runtime/open.ts +19 -2
  31. package/src/cli/runtime/select.ts +0 -12
  32. package/src/cli/runtime.ts +0 -1
  33. package/src/cli/selection.ts +40 -10
  34. package/src/cli/usage.ts +12 -7
  35. package/src/cli/workspace-migrate.ts +18 -12
  36. package/src/connectivity/context.ts +17 -0
  37. package/src/connectivity/manifest/provider.ts +9 -1
  38. package/src/deployments/adapters/audit-blob.ts +22 -1
  39. package/src/deployments/prepare.ts +8 -33
  40. package/src/deployments/report.ts +6 -3
  41. package/src/dispatch/context.ts +3 -0
  42. package/src/dispatch/dispatch.ts +5 -0
  43. package/src/profile/connections.ts +32 -0
  44. package/src/profile/index.ts +9 -0
  45. package/src/profile/schema.ts +47 -3
  46. package/src/profile/tokens.ts +137 -0
  47. package/src/profile/workspace.ts +1 -1
  48. package/src/providers/harness.ts +1 -0
  49. package/src/providers/setup/plan.ts +16 -0
  50. package/src/providers/setup/provider.ts +39 -12
  51. package/src/server/container.ts +3 -3
  52. package/src/server/endpoint.ts +17 -29
  53. package/src/server/harness.ts +28 -3
  54. package/src/server/index.ts +8 -8
  55. package/src/server/mcp/visibility.ts +11 -3
  56. package/src/server/read/deployed.ts +4 -0
  57. package/src/server/read/open.ts +4 -0
  58. package/src/server/read/routes.ts +15 -2
  59. package/src/server/read/state.ts +30 -2
@@ -1,70 +1,340 @@
1
- import { announce, ok, print, style, warn } from '../../output.ts';
2
- import { ensureProfileToken, openRuntime, type GlobalFlags } from '../../runtime.ts';
1
+ import { CONNECTIONS_FILE, nextTokenId, readConnections, tokenRef } from '#profile';
2
+ import { readSession } from '#auth/lanes/session.ts';
3
+ import { ConfigError } from '#profile';
4
+ import { ConfigDocument } from '../../config-edit.ts';
5
+ import { openOrCreateConnections } from '../../config-repair-sweep.ts';
6
+ import { heading, ok, print, style, warn } from '../../output.ts';
7
+ import { openWorkspaceRuntime, type GlobalFlags } from '../../runtime.ts';
3
8
 
4
- /** `lanes link token` — the one bearer token this endpoint accepts. */
9
+ /**
10
+ * `lanes link token` — the static tokens this workspace has issued.
11
+ *
12
+ * **A token names a person, and the workspace holds it** (ADR-068). Both halves
13
+ * changed at contract 5. It used to be one credential at `auth.token_ref`,
14
+ * whose default was the constant `profile/token` for every profile in a store
15
+ * that is one per workspace — so `--profile` was asked for and could not affect
16
+ * the answer, and removing a profile deleted the token its siblings were served
17
+ * by. And it resolved to the primary profile's owner with "every profile" as
18
+ * its reach, which made it the one credential on this endpoint that never said
19
+ * who was holding it.
20
+ *
21
+ * So `issue` takes a subject and `show`/`rotate`/`revoke` take a row id. What
22
+ * the token then reaches is whatever that subject is a member of, resolved on
23
+ * every call — the same rule an OAuth token has followed since ADR-060.
24
+ *
25
+ * These are for a machine with no browser. A person registers a client against
26
+ * the bare URL and signs in (ADR-062); `--headless` is the flag that says
27
+ * otherwise, and it is why this family still exists.
28
+ */
5
29
 
6
- /** The token, or enough of it to recognise. One shape, so the two commands agree. */
30
+ export interface TokenFlags extends GlobalFlags {
31
+ readonly show?: boolean | undefined;
32
+ readonly raw?: boolean | undefined;
33
+ readonly id?: string | undefined;
34
+ readonly subject?: string | undefined;
35
+ readonly me?: boolean | undefined;
36
+ readonly label?: string | undefined;
37
+ readonly json?: boolean | undefined;
38
+ }
39
+
40
+ /** The token, or enough of it to recognise. One shape, so every command agrees. */
7
41
  function show(token: string, reveal: boolean | undefined): string {
8
42
  return reveal ? token : `${token.slice(0, 8)}… ${style.dim('(--show to reveal)')}`;
9
43
  }
10
44
 
11
- export async function tokenShow(
12
- flags: GlobalFlags & { show?: boolean | undefined; raw?: boolean | undefined },
13
- ): Promise<void> {
14
- const runtime = await openRuntime(flags);
15
- try {
16
- const { token, created } = await ensureProfileToken(
17
- runtime.credentials,
18
- runtime.config.auth.token_ref,
45
+ /**
46
+ * Which subject a row is being issued to.
47
+ *
48
+ * `--me` reads the signed-in session rather than asking, because typing your own
49
+ * subject out of `lanes auth status` is a transcription step with a silent
50
+ * failure mode: a mistyped subject is a valid-looking row that matches no
51
+ * member, and the token it holds then reaches nothing for a reason nothing
52
+ * prints. Neither flag is a guess — one of the two is required.
53
+ */
54
+ async function subjectFor(flags: TokenFlags): Promise<string> {
55
+ if (flags.subject !== undefined && flags.me === true) {
56
+ throw new ConfigError(
57
+ '--subject and --me both name who the token is for, so pass one.\n' +
58
+ ' --me reads the subject you are signed in as.',
59
+ );
60
+ }
61
+
62
+ if (flags.subject !== undefined) return flags.subject;
63
+
64
+ if (flags.me !== true) {
65
+ throw new ConfigError(
66
+ 'A token is issued to somebody, so say who.\n' +
67
+ ' --me the subject you are signed in as\n' +
68
+ ' --subject lanes:<id> somebody else, as "lanes members list" reports them\n' +
69
+ ' What it reaches is whatever that subject is a member of, and nothing else.',
70
+ );
71
+ }
72
+
73
+ const session = await readSession().catch(() => null);
74
+ if (!session?.subject) {
75
+ throw new ConfigError(
76
+ '--me needs a signed-in session, and there is none.\n' +
77
+ ' Run: lanes auth login',
19
78
  );
79
+ }
80
+ return session.subject;
81
+ }
82
+
83
+ /**
84
+ * Refuse a `--profile` that can no longer mean anything.
85
+ *
86
+ * The alternative is accepting and ignoring it, which `selection.ts` names as
87
+ * the defect it exists to prevent: an operator who passes `--profile work` here
88
+ * believes they scoped the token to one profile, and it is the member list that
89
+ * decides. Saying so is the whole fix.
90
+ */
91
+ function assertNoProfile(flags: TokenFlags, command: string): void {
92
+ if (flags.profile === undefined) return;
93
+ throw new ConfigError(
94
+ `--profile does not scope "lanes link token ${command}" (ADR-068).\n` +
95
+ ' A token names the person it was issued to, and reaches every profile whose\n' +
96
+ ' members list them. To narrow what one reaches, edit the member lists:\n' +
97
+ ' lanes link profile members remove --subject <id> --profile <name> --workspace <name>',
98
+ );
99
+ }
100
+
101
+ export async function tokenIssue(flags: TokenFlags): Promise<void> {
102
+ assertNoProfile(flags, 'issue');
103
+ const subject = await subjectFor(flags);
104
+
105
+ const runtime = await openWorkspaceRuntime(flags);
106
+ try {
107
+ const root = runtime.resolution.workspaceRoot;
108
+ const existing = (await readConnections(root)).tokens;
109
+ const id = nextTokenId(existing.map((row) => row.id));
110
+ const ref = tokenRef(id);
111
+
112
+ const { generateProfileToken } = await import('#auth');
113
+ const token = generateProfileToken();
114
+
115
+ // The credential first, then the row. The other order leaves a row naming a
116
+ // ref with nothing behind it, which reads to the authenticator as a token
117
+ // that matches nothing — a working-looking registry whose token is refused.
118
+ await runtime.credentials.set(ref, token);
119
+
120
+ // Created from the template if absent, which is what a workspace whose
121
+ // profiles predate `connections.yaml` looks like — and `token issue` is a
122
+ // plausible first write into a fresh one.
123
+ const document = await openOrCreateConnections(root);
124
+ document.addTo(['tokens'], {
125
+ id,
126
+ subject,
127
+ ref,
128
+ ...(flags.label === undefined ? {} : { label: flags.label }),
129
+ issued_at: new Date().toISOString(),
130
+ });
131
+ await document.save();
132
+
133
+ print(ok(`issued ${style.bold(id)} to ${subject}`));
134
+ print(` ${show(token, flags.show)}`);
135
+ print();
136
+
137
+ const reaches = await profilesFor(root, subject);
138
+ if (reaches.length === 0) {
139
+ print(warn('no profile in this workspace lists that subject, so this token reaches nothing'));
140
+ print(
141
+ style.dim(
142
+ ' Add them: lanes link profile members add --subject ' +
143
+ `${subject} --profile <name> --workspace ${runtime.target}`,
144
+ ),
145
+ );
146
+ } else {
147
+ print(style.dim(` Reaches: ${reaches.join(', ')} — every profile listing that subject.`));
148
+ }
149
+ } finally {
150
+ await runtime.close();
151
+ }
152
+ }
153
+
154
+ export async function tokenList(flags: TokenFlags): Promise<void> {
155
+ const runtime = await openWorkspaceRuntime(flags);
156
+ try {
157
+ const root = runtime.resolution.workspaceRoot;
158
+ const rows = (await readConnections(root)).tokens;
159
+
160
+ if (flags.json) {
161
+ const payload = await Promise.all(
162
+ rows.map(async (row) => ({
163
+ id: row.id,
164
+ subject: row.subject,
165
+ ...(row.label === undefined ? {} : { label: row.label }),
166
+ ...(row.issued_at === undefined ? {} : { issued_at: row.issued_at }),
167
+ reaches: await profilesFor(root, row.subject),
168
+ present: (await runtime.credentials.get(row.ref)) !== null,
169
+ })),
170
+ );
171
+ print(JSON.stringify({ target: runtime.target, tokens: payload }, null, 2));
172
+ return;
173
+ }
174
+
175
+ if (rows.length === 0) {
176
+ print('No token has been issued in this workspace.');
177
+ print(
178
+ style.dim(
179
+ ' A person does not need one: a client registers against the bare URL and signs in.\n' +
180
+ ` For a machine with no browser: lanes link token issue --me --workspace ${runtime.target}`,
181
+ ),
182
+ );
183
+ return;
184
+ }
185
+
186
+ heading(`Tokens (${rows.length})`);
187
+ for (const row of rows) {
188
+ const reaches = await profilesFor(root, row.subject);
189
+ // A row whose credential is gone matches nothing, and reads exactly like a
190
+ // wrong token from the client's side. Naming it here is the cheap half of
191
+ // what `doctor` says at length.
192
+ const missing = (await runtime.credentials.get(row.ref)) === null;
193
+ print(
194
+ ` ${row.id} ${row.subject}` +
195
+ (row.label ? ` ${style.dim(`(${row.label})`)}` : '') +
196
+ ` ${reaches.length > 0 ? reaches.join(', ') : style.dim('reaches nothing')}` +
197
+ (missing ? ` ${style.dim('— value missing from the store')}` : ''),
198
+ );
199
+ }
200
+ print(style.dim(' What each reaches is every profile whose members list its subject.'));
201
+ } finally {
202
+ await runtime.close();
203
+ }
204
+ }
205
+
206
+ export async function tokenShow(flags: TokenFlags): Promise<void> {
207
+ assertNoProfile(flags, 'show');
208
+ const runtime = await openWorkspaceRuntime(flags);
209
+ try {
210
+ const root = runtime.resolution.workspaceRoot;
211
+ const row = pick((await readConnections(root)).tokens, flags, runtime.target);
212
+ const token = await runtime.credentials.get(row.ref);
213
+
214
+ if (token === null) {
215
+ throw new ConfigError(
216
+ `Token "${row.id}" has a row but no value at "${row.ref}" in this workspace's store.\n` +
217
+ ` Re-mint it: lanes link token rotate --id ${row.id} --workspace ${runtime.target}`,
218
+ );
219
+ }
20
220
 
21
221
  // `--raw` prints the token and nothing else, for command substitution:
22
222
  //
23
- // claude mcp add … --header "Authorization: Bearer $(lanes link token show --raw)"
223
+ // export LANES_LINK_TOKEN="$(lanes link token show --raw --workspace local)"
24
224
  //
25
- // That is the recommended way to register an instance, and the reason is
26
- // not convenience. A token pasted from `--show` passes through the agent's
27
- // context and into its transcript; substituted by the shell it goes from
28
- // this process to the harness and is never seen by the model at all.
225
+ // A token pasted from `--show` passes through the agent's context and into
226
+ // its transcript; substituted by the shell it goes from this process to the
227
+ // consumer and is never seen by the model at all.
29
228
  if (flags.raw) {
30
229
  process.stdout.write(`${token}\n`);
31
230
  return;
32
231
  }
33
232
 
34
- announce(runtime.resolution);
35
- if (created) print(warn('no token existed; a new one was minted'));
233
+ print(`${row.id} ${row.subject}`);
36
234
  print(show(token, flags.show));
37
235
  } finally {
38
236
  await runtime.close();
39
237
  }
40
238
  }
41
239
 
42
- export async function tokenRotate(
43
- flags: GlobalFlags & { show?: boolean | undefined },
44
- ): Promise<void> {
45
- const runtime = await openRuntime(flags);
240
+ export async function tokenRotate(flags: TokenFlags): Promise<void> {
241
+ assertNoProfile(flags, 'rotate');
242
+ const runtime = await openWorkspaceRuntime(flags);
46
243
  try {
47
- announce(runtime.resolution);
244
+ const root = runtime.resolution.workspaceRoot;
245
+ const row = pick((await readConnections(root)).tokens, flags, runtime.target);
48
246
 
49
247
  const { generateProfileToken } = await import('#auth');
50
248
  const token = generateProfileToken();
51
- await runtime.credentials.set(runtime.config.auth.token_ref, token);
249
+ await runtime.credentials.set(row.ref, token);
52
250
 
53
- print(ok('token rotated'));
54
- // Gated the way `tokenShow` gates it, and for the reason given above: a
55
- // token printed here goes into the transcript of whatever ran the command.
251
+ print(ok(`rotated ${style.bold(row.id)}`));
252
+ // Gated the way `tokenShow` gates it, and for the same reason: a token
253
+ // printed here goes into the transcript of whatever ran the command.
56
254
  // Rotating is what an operator does *because* a token leaked, so printing
57
255
  // the replacement unasked is the one moment it costs the most.
58
256
  print(` ${show(token, flags.show)}`);
59
257
  print();
60
- // Rotating invalidates every agent using this endpoint, which is the cost
61
- // of one token per endpoint rather than one per agent. Say so plainly —
62
- // and say how, because `claude mcp add` stores the substituted value rather
63
- // than the command, so nothing re-reads this on its own.
64
- print(warn('every agent configured with the old token must be re-registered'));
65
- print(style.dim(' A harness stores the token it was given, not the command that produced it.'));
66
- print(style.dim(' Run: lanes link outputs with this profile and target for the command to re-run.'));
258
+ // Narrower than it used to be, and worth saying so. A rotate used to
259
+ // invalidate every agent on the endpoint, because there was one token and
260
+ // every registration carried it. Registrations do not carry one now
261
+ // (ADR-062), so this affects only what was given *this* row's value.
262
+ print(warn(`anything holding ${row.id} must be given the new value`));
263
+ print(style.dim(' Clients that signed in through a browser are unaffected they hold their own tokens.'));
67
264
  } finally {
68
265
  await runtime.close();
69
266
  }
70
267
  }
268
+
269
+ export async function tokenRevoke(flags: TokenFlags): Promise<void> {
270
+ assertNoProfile(flags, 'revoke');
271
+ const runtime = await openWorkspaceRuntime(flags);
272
+ try {
273
+ const root = runtime.resolution.workspaceRoot;
274
+ const rows = (await readConnections(root)).tokens;
275
+ const row = pick(rows, flags, runtime.target);
276
+
277
+ // The row first, then the credential. This is the reverse of `issue` and
278
+ // for the same reason read the other way: what must never survive a partial
279
+ // failure is a *usable* token, so the thing that makes it usable goes last.
280
+ const document = await ConfigDocument.openKey(root, CONNECTIONS_FILE);
281
+ document.removeFrom(['tokens'], rows.indexOf(row));
282
+ await document.save();
283
+
284
+ await runtime.credentials.delete(row.ref);
285
+
286
+ print(ok(`revoked ${style.bold(row.id)}`));
287
+ print(style.dim(' It is refused within the authenticator\'s cache window, which is seconds.'));
288
+ } finally {
289
+ await runtime.close();
290
+ }
291
+ }
292
+
293
+ /**
294
+ * The row a command acts on.
295
+ *
296
+ * With one row and no `--id`, that row: naming it would be ceremony, and the
297
+ * common workspace has exactly one. With several, it refuses and lists them —
298
+ * the `deploy` rule, for the same reason. Nothing is chosen from among
299
+ * candidates.
300
+ */
301
+ function pick(
302
+ rows: readonly { id: string; subject: string; ref: string }[],
303
+ flags: TokenFlags,
304
+ target: string,
305
+ ): { id: string; subject: string; ref: string } {
306
+ if (rows.length === 0) {
307
+ throw new ConfigError(
308
+ 'No token has been issued in this workspace.\n' +
309
+ ` Issue one: lanes link token issue --me --workspace ${target}`,
310
+ );
311
+ }
312
+
313
+ if (flags.id !== undefined) {
314
+ const found = rows.find((row) => row.id === flags.id);
315
+ if (!found) {
316
+ throw new ConfigError(
317
+ `No token "${flags.id}" in this workspace. Have: ${rows.map((row) => row.id).join(', ')}.`,
318
+ );
319
+ }
320
+ return found;
321
+ }
322
+
323
+ if (rows.length > 1) {
324
+ throw new ConfigError(
325
+ `This workspace has ${rows.length} tokens, so this command needs --id.\n` +
326
+ rows.map((row) => ` ${row.id} ${row.subject}`).join('\n'),
327
+ );
328
+ }
329
+
330
+ return rows[0]!;
331
+ }
332
+
333
+ /** Every profile in this workspace whose `members:` names a subject. */
334
+ async function profilesFor(root: string, subject: string): Promise<string[]> {
335
+ const { loadWorkspaceProfiles } = await import('#profile');
336
+ const { loaded } = await loadWorkspaceProfiles(root);
337
+ return loaded
338
+ .filter((entry) => entry.config.members.some((member) => member.subject === subject))
339
+ .map((entry) => entry.profile);
340
+ }
@@ -1,7 +1,8 @@
1
1
  import { capabilityIdForToolName } from '#server/mcp';
2
+ import { anyIssuedToken } from '#profile';
2
3
  import { deployedUrl, endpointHealth, localUrl } from '../../endpoint-url.ts';
3
4
  import { announce, emit, heading, print, style, warn } from '../../output.ts';
4
- import { ensureProfileToken, openRuntime, type GlobalFlags } from '../../runtime.ts';
5
+ import { openRuntime, type GlobalFlags } from '../../runtime.ts';
5
6
 
6
7
  export interface ToolsFlags extends GlobalFlags {
7
8
  readonly json?: boolean | undefined;
@@ -27,7 +28,12 @@ export async function tools(flags: ToolsFlags): Promise<void> {
27
28
  const runtime = await openRuntime(flags);
28
29
 
29
30
  try {
30
- const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
31
+ // Any token the workspace holds, or none (ADR-068). `tools` reports a
32
+ // profile's resolved surface, so it still names one — what it no longer
33
+ // does is mint a credential in order to read.
34
+ const token = (
35
+ await anyIssuedToken(runtime.resolution.workspaceRoot, runtime.credentials)
36
+ )?.value;
31
37
  const declared = runtime.declared.deploy;
32
38
  const deployed = await deployedUrl(declared);
33
39
  // Not `endpointUrl`, which asks the platform a second time for an answer
@@ -40,9 +46,26 @@ export async function tools(flags: ToolsFlags): Promise<void> {
40
46
  // token that works. Reporting that as the deployed endpoint's surface is
41
47
  // the failure `endpoint-url.ts` calls "silent in the worst way".
42
48
  const live = await endpointHealth(url, token);
43
- const mine = live?.profile === runtime.resolution.profile;
44
-
45
- const surface = await askEndpoint(url, token);
49
+ const mine =
50
+ token === undefined ? live !== null : live?.profile === runtime.resolution.profile;
51
+
52
+ // **Nothing to ask with** is a reportable state rather than a failure
53
+ // (ADR-068). Asking the endpoint for its tool list means being a client of
54
+ // it, and a client authenticates; a workspace that has issued no static
55
+ // token has nothing this command can present, and minting one would bind a
56
+ // credential to a subject nobody chose. What is still true without it is
57
+ // everything on the left of the comparison — the capabilities this
58
+ // *config* resolves to — which is most of what the command is for.
59
+ const surface =
60
+ token === undefined
61
+ ? {
62
+ reachable: false,
63
+ reason:
64
+ 'no static token is issued in this workspace, so the endpoint cannot be queried',
65
+ names: [],
66
+ bytes: 0,
67
+ }
68
+ : await askEndpoint(url, token);
46
69
  const providers = [...new Set(runtime.registry.capabilities().map(({ id }) => id))];
47
70
 
48
71
  await emit(
@@ -30,5 +30,11 @@ export { pair, PAIR_CERT_REF, PAIR_KEY_REF, PAIR_TOKEN_REF, type PairFlags } fro
30
30
  export { desktop, settingsUrl, type DesktopFlags } from './operate/desktop.ts';
31
31
  export { auditTail, auditVerify, markdownCell } from './operate/audit.ts';
32
32
  export { attachFile } from './operate/attach.ts';
33
- export { tokenRotate, tokenShow } from './operate/token.ts';
33
+ export {
34
+ tokenIssue,
35
+ tokenList,
36
+ tokenRevoke,
37
+ tokenRotate,
38
+ tokenShow,
39
+ } from './operate/token.ts';
34
40
  export { configShow, policyList, policyRule } from './operate/policy.ts';
@@ -51,17 +51,19 @@ export function declaredRefs(
51
51
  * What the profiles that are staying declare.
52
52
  *
53
53
  * Nothing in here is deleted, however plainly the profile being removed also
54
- * declares it. The credential store is one file per *workspace* since
55
- * contract 3, and every profile takes the template default
56
- * `token_ref: profile/token` — so removing one profile deleted the endpoint
57
- * token the others are served by, and the deployed revision then refused every
58
- * request with "No profile token in this target's credential store". The vault
59
- * ref is read off the target and is identical for every profile there, which
60
- * made the sibling's sealed items unrecoverable in the same command.
54
+ * declares it. The vault ref is read off the target and is identical for every
55
+ * profile there, which once made a sibling's sealed items unrecoverable in
56
+ * this command.
57
+ *
58
+ * **The endpoint token was the sharpest case here and is no longer a case.**
59
+ * Every profile took the default `token_ref: profile/token` out of one
60
+ * per-workspace store, so removing one deleted the token its siblings were
61
+ * served by. What fixed it is not a better survivor check: the token was never
62
+ * a profile's to declare (ADR-068), so removing one cannot reach it now.
61
63
  */
62
64
  survivors: readonly Config[] = [],
63
65
  ): string[] {
64
- const refs = new Set<string>([config.auth.token_ref]);
66
+ const refs = new Set<string>();
65
67
 
66
68
  // Read off the *target*, not the profile. `vaultTargetSchema` sits inside
67
69
  // `targetSchema`, so two targets may seal the same items in different places;
@@ -100,7 +102,6 @@ export function declaredRefs(
100
102
  // declare" has to have one answer.
101
103
  const kept = new Set(
102
104
  survivors.flatMap((other) => [
103
- other.auth.token_ref,
104
105
  ...(declared.vault?.adapter === 'secret' ? [vaultRef(declared, other)] : []),
105
106
  ...(other.auth.authorization?.mode === 'oidc'
106
107
  ? [other.auth.authorization.client_id_ref]
@@ -42,14 +42,28 @@ import { DEFAULT_SURFACES, ensureOwnerLayer, repairLines, repaired } from './con
42
42
  * for the workspaces 0.9.0 already migrated. One spelling, for the reason the
43
43
  * template and `ensureOwnerLayer` share one: two would have to agree forever.
44
44
  */
45
- export async function ensureRegistryContract(workspaceRoot: string): Promise<boolean> {
45
+ export async function ensureRegistryContract(
46
+ workspaceRoot: string,
47
+ /**
48
+ * The contract to stamp, which is the one the caller is *producing*.
49
+ *
50
+ * Defaulted to the newest for the repair sweep, and passed explicitly by each
51
+ * migration step. A step that stamped the newest would put the registry ahead
52
+ * of the profiles it just wrote — and `isUnmigrated` reads exactly this field,
53
+ * so the registry would report the workspace as migrated with a later step
54
+ * still to run. That is the same defect the note on
55
+ * `the contract it stamps on the registry` describes, in the direction that
56
+ * fails silently rather than loudly.
57
+ */
58
+ contract: number = SUPPORTED_CONTRACT,
59
+ ): Promise<boolean> {
46
60
  const files = workspaceFiles(workspaceRoot);
47
61
  if (!(await files.has(WORKSPACE_FILE))) return false;
48
62
 
49
63
  const document = await ConfigDocument.openKey(workspaceRoot, WORKSPACE_FILE);
50
- if (document.getIn(['contract']) === SUPPORTED_CONTRACT) return false;
64
+ if (document.getIn(['contract']) === contract) return false;
51
65
 
52
- document.setIn(['contract'], SUPPORTED_CONTRACT);
66
+ document.setIn(['contract'], contract);
53
67
  await document.save();
54
68
  return true;
55
69
  }
@@ -79,7 +93,7 @@ function listSurfaces(): string {
79
93
  }
80
94
 
81
95
  /** The workspace's connections document, written from the template if missing. */
82
- async function openOrCreateConnections(workspaceRoot: string): Promise<ConfigDocument> {
96
+ export async function openOrCreateConnections(workspaceRoot: string): Promise<ConfigDocument> {
83
97
  try {
84
98
  return await ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
85
99
  } catch {
@@ -18,7 +18,7 @@ import { nextConnectionId } from './identity.ts';
18
18
  */
19
19
 
20
20
  /** Lanes' own provider ids, and the label each row carries. */
21
- const RESERVED_SURFACES = {
21
+ export const RESERVED_SURFACES = {
22
22
  lanes_memory: 'Memory',
23
23
  lanes_tasks: 'Tasks',
24
24
  lanes_assets: 'Assets',
@@ -27,7 +27,7 @@ export function newProfileTemplate(profile: string, port: number, subject?: stri
27
27
  #
28
28
  # Edit it by hand or through the CLI; both are supported, and CLI edits preserve
29
29
  # your comments and ordering.
30
- contract: 4
30
+ contract: 5
31
31
 
32
32
  instance:
33
33
  profile: ${profile}
@@ -47,12 +47,13 @@ instance:
47
47
  #
48
48
  # lanes link status --profile ${profile} --workspace <name>
49
49
  #
50
- # The bearer token below is for CI. People sign in instead: a client that asks
51
- # for authorization is sent to the Lanes login, and comes back as somebody
52
- # (ADR-062). "lanes link token show" is for a runner with no browser.
50
+ # No token here. A profile declares no endpoint credential (ADR-068) the
51
+ # workspace does, in "tokens:" in connections.yaml, one row per person it was
52
+ # issued to. People sign in rather than holding one: a client that asks for
53
+ # authorization is sent to the Lanes login and comes back as somebody
54
+ # (ADR-062). "lanes link token issue" is for a runner with no browser.
53
55
  auth:
54
56
  mode: bearer
55
- token_ref: profile/token
56
57
  authorization:
57
58
  mode: self
58
59
 
@@ -146,7 +147,7 @@ export function newWorkspaceTemplate(): string {
146
147
  # uses it prints which one it got. Commands that publish or destroy — deploy,
147
148
  # sync, secrets push, profile remove, disconnect, token rotate — refuse it and
148
149
  # make you type the name (ADR-061).
149
- contract: 4
150
+ contract: 5
150
151
  default_workspace: local
151
152
  workspaces:
152
153
  local:
@@ -182,7 +183,7 @@ export function newConnectionsTemplate(): string {
182
183
  # still keeps its own bytes — what you write through one profile is absent in
183
184
  # another (ADR-066). A second instance is for holding two of something in one
184
185
  # profile: "lanes link connect lanes_memory --id lan9".
185
- contract: 4
186
+ contract: 5
186
187
 
187
188
  connections:
188
189
  - { id: lan1, provider: lanes_memory, account: Memory }
@@ -195,6 +196,12 @@ connections:
195
196
 
196
197
  # App registrations, shared by every connection of that vendor.
197
198
  oauth_apps: {}
199
+
200
+ # Static endpoint tokens, one row per person one was issued to (ADR-068). Empty
201
+ # is the ordinary state: a client registers against the bare URL and signs in,
202
+ # so nothing needs one until something headless does. A row reaches whatever its
203
+ # subject is a member of — "lanes link token issue --me --workspace <name>".
204
+ tokens: []
198
205
  `;
199
206
  }
200
207
 
@@ -145,10 +145,10 @@ async function readMerged(
145
145
  // values for one key, and there is no merge that means anything.
146
146
  //
147
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.
148
+ // than granted by anybody and the old stores are not deleted, so the cost
149
+ // is re-registering a client and no account has to be authorised again.
150
+ // Contract 5 ends this problem rather than solving it: the token is the
151
+ // workspace's, so there are no longer several to merge (ADR-068).
152
152
  let value: string | null;
153
153
  try {
154
154
  value = await store.get(ref);
@@ -212,10 +212,10 @@ async function readMerged(
212
212
  // routine `update`, for a conflict that did not exist.
213
213
  //
214
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.
215
+ // It is minted locally rather than granted by anybody and the old stores are
216
+ // not deleted, so the cost is re-registering a client and no account has to be
217
+ // authorised again. Contract 5 removes the possibility of the disagreement:
218
+ // one token registry per workspace (ADR-068).
219
219
  for (const [ref, byProfile] of endpoint) {
220
220
  const values = new Set(byProfile.values());
221
221
  const agreed = values.size === 1 ? [...values][0] : undefined;
@@ -159,7 +159,9 @@ export async function migrateToContract4(
159
159
  // unchanged. Here rather than inside `renameRegistry` because that function
160
160
  // returns early on a workspace already holding `workspaces.yaml`, which is
161
161
  // exactly the workspace whose stamp is stale.
162
- await ensureRegistryContract(workspaceRoot);
162
+ // Four, not the newest: contract 5 runs after this, and a registry claiming it
163
+ // makes `isUnmigrated` report a finished migration with a step still to go.
164
+ await ensureRegistryContract(workspaceRoot, 4);
163
165
  await applyMoves(files, plan.moves);
164
166
 
165
167
  // Credentials before the rows: a ref is derived from the id, so the rows must
@@ -185,7 +187,10 @@ export async function migrateToContract4(
185
187
  for (const profile of profiles) {
186
188
  const document = await ConfigDocument.openKey(workspaceRoot, layout.profileConfig(profile));
187
189
  document.setIn(['contract'], 4);
188
- await document.save();
190
+ // The contract it is *producing*, which is not the newest one any more:
191
+ // contract 5 exists, so a bare `save()` here validates this document
192
+ // against a schema it is one step short of and refuses its own output.
193
+ await document.save({ contract: 4 });
189
194
  }
190
195
 
191
196
  return {