@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
package/README.md CHANGED
@@ -55,7 +55,7 @@ $ bun install -g @lanes-sh/link # puts `lanes` on your PATH
55
55
  $ lanes auth login # opens a browser once
56
56
  $ lanes link profile add personal --workspace local
57
57
  $ lanes link profile members add --me --profile personal --workspace local
58
- $ lanes link start --profile personal --workspace local
58
+ $ lanes link start --workspace local
59
59
  ok serving http://127.0.0.1:7337/mcp
60
60
  profiles: personal
61
61
  ```
@@ -63,7 +63,7 @@ ok serving http://127.0.0.1:7337/mcp
63
63
  Then, in another shell:
64
64
 
65
65
  ```console
66
- $ lanes link mcp add --profile personal --workspace local # every agent installed; or name one: claude, codex
66
+ $ lanes link mcp add --workspace local # every agent installed; or name one: claude, codex
67
67
  ok registered lanes-link with Claude Code (user scope)
68
68
  ok registered lanes-link with Codex
69
69
  ```
@@ -75,8 +75,14 @@ Mail and calendar are the next step. **[Full quickstart →](https://lanes.sh/do
75
75
  **Why the sign-in.** A profile declares who may consume it, and there is nothing to check that
76
76
  against if the endpoint has no idea who is asking. That is a real dependency for a self-hostable
77
77
  tool and worth stating plainly; what it is not is a dependency per request. The network is needed
78
- to sign in and to refresh, and a machine offline for a day keeps serving. `lanes link token
79
- show` still mints a static token for CI, which has no browser to sign in with.
78
+ to sign in and to refresh, and a machine offline for a day keeps serving.
79
+
80
+ **Why `mcp add` names no profile.** One endpoint serves every profile in the workspace, and each
81
+ call names one in its `profile` argument — so registering is about the endpoint, not a profile.
82
+ Which profiles a client actually reaches is decided when its owner signs in: every profile whose
83
+ `members:` lists them, and no others. A credential is an identity here, not a selection. For a
84
+ runner with no browser, `lanes link token issue --me` mints a static token that reaches exactly
85
+ what its subject is a member of.
80
86
 
81
87
  ## What you keep in it
82
88
 
@@ -38,10 +38,10 @@ then a narrower fragment, before concluding something is not there.
38
38
  Tasks are worth the second look rather than an afterthought: an open one is often
39
39
  the actual answer to "what do we already know about X", and it carries something
40
40
  memory cannot — that the matter is unfinished. Say so when it is, and say which
41
- status: *blocked since June* is a different answer from *open*. `tasks.list`
41
+ status: *blocked since June* is a different answer from *open*. `lanes_tasks_list`
42
42
  hides finished work by default, so ask for `done` when the question is historical.
43
43
 
44
- **Assets are a listing, not a corpus.** `assets.list` tells you a file exists,
44
+ **Assets are a listing, not a corpus.** `lanes_assets_list` tells you a file exists,
45
45
  its type and its size; only a text one reads back. Report that a document is
46
46
  there and let the main thread decide what to do with it — do not try to get at
47
47
  the contents of a binary, and never ask for it as base64.
@@ -67,7 +67,7 @@ to every future session.
67
67
  stops a write is policy on the endpoint:
68
68
 
69
69
  ```console
70
- $ lanes link policy deny memory.write --connection memory.main --profile <name> --workspace <name>
70
+ $ lanes link policy deny lanes_memory.write --connection lanes_memory.lan1 --profile <name> --workspace <name>
71
71
  $ lanes link policy list --profile <name> --workspace <name>
72
72
  ```
73
73
 
@@ -43,6 +43,12 @@ workspace*. A profile lives in exactly one, so `personal` on `local` and
43
43
  `personal` on `cloud` are two profiles that share a name rather than one profile
44
44
  in two places.
45
45
 
46
+ **A profile owns its data.** Two profiles granting the same connection — every
47
+ profile grants `lanes_memory.lan1` — still read and write different notes,
48
+ tasks, files and entities, because the profile is part of where they are kept.
49
+ So there is nothing to be found in one by asking another, and a note taken under
50
+ `work` is not available under `personal`.
51
+
46
52
  **What a command must be told is never inferred from a profile — but the
47
53
  workspace may have a default.** `lanes set-workspace <name>` writes one, every
48
54
  command that uses it echoes the name it resolved, and the commands where being
@@ -74,12 +80,19 @@ disagree with it.
74
80
  When you write a command out for the owner, fill in what that command needs or
75
81
  leave it as `<name>` for them to complete — never drop a required one.
76
82
 
77
- A `connection` names an account the profile grants. One profile may grant
78
- several of the same kind and govern each differently, so `gmail.work` may be
79
- readable where `gmail.personal` is writable. Naming a connection the profile does
80
- not grant is refused rather than guessed at, and a connection it does not grant
81
- is absent from the enum entirely: if you cannot see it there, it was not
82
- withheld by accident.
83
+ A `connection` names an account the profile grants, and it is a **fully
84
+ qualified** `<provider>.<id>` `lanes_memory.lan1`, not `lan1`. The bare id is
85
+ refused, so take the value out of the enum rather than assembling one.
86
+
87
+ Ids are opaque: `con1`, `con2` for accounts and `lan1`, `lan2` for Lanes' own
88
+ surfaces. They carry no meaning and are not worth guessing at — the enum prints
89
+ each one's account and label beside it, and that is what tells `con1` from
90
+ `con2`. One profile may grant several of the same kind and govern each
91
+ differently, so `gmail.con1` may be readable where `gmail.con2` is writable.
92
+
93
+ Naming a connection the profile does not grant is refused rather than guessed
94
+ at, and one it does not grant is absent from the enum entirely: if you cannot
95
+ see it there, it was not withheld by accident.
83
96
 
84
97
  ## Which store a thing goes in
85
98
 
@@ -107,7 +120,7 @@ when Y", that is a skill they should write, not a memory entry describing it.
107
120
 
108
121
  ## Reach for memory before answering from nothing
109
122
 
110
- `lanes_memory.search` before concluding you do not know something about this person or
123
+ `lanes_memory_search` before concluding you do not know something about this person or
111
124
  their work. It is a substring search over their own notes, not a ranked index —
112
125
  try more than one wording before deciding it is not there.
113
126
 
@@ -118,7 +131,7 @@ entries with `lanes link memory list --profile <name> --workspace <name>` and a
118
131
 
119
132
  ## Tasks have a status, so finish them rather than deleting them
120
133
 
121
- `lanes_tasks.list` answers what is outstanding. It shows `in_progress`, `open` and
134
+ `lanes_tasks_list` answers what is outstanding. It shows `in_progress`, `open` and
122
135
  `blocked` and hides the rest, so a listing is what is left to do rather than
123
136
  everything that ever was — name a status to see more.
124
137
 
@@ -133,7 +146,7 @@ Six of them, and the two that are easy to confuse are worth learning:
133
146
  | `done` | finished |
134
147
  | `dropped` | decided against, which is not the same as finished |
135
148
 
136
- **Closing a task is `lanes_tasks.update` with a status, never `lanes_tasks.remove`.** The
149
+ **Closing a task is `lanes_tasks_update` with a status, never `lanes_tasks_remove`.** The
137
150
  record of having done it is the useful half, and it is what stops the same thing
138
151
  being suggested again next week. Remove is for something recorded by mistake.
139
152
 
@@ -333,9 +346,11 @@ changed, so a capability for a freshly connected account is not callable until
333
346
  the client reconnects. Say that, rather than reporting the connection as missing
334
347
  or asking them to connect again.
335
348
 
336
- One exception, and it is the one that matters here: the authenticator is built
337
- once at boot and is not re-read, so a `lanes link token rotate` does need the
338
- endpoint restarted before the new token opens anything.
349
+ One exception used to be here that a `token rotate` needed the endpoint
350
+ restarted. It does not: the authenticator re-reads both the issued rows and
351
+ their values on a seconds-long cache window, so `token issue`, `token rotate`
352
+ and `token revoke` all take effect on a running endpoint. So does a change to a
353
+ profile's `members:`, which is what decides where a token reaches.
339
354
 
340
355
  ## Operating the workspace
341
356
 
@@ -400,17 +415,35 @@ in the workspace and could be given it. Somebody with an unaccepted invitation
400
415
  has no subject yet, so they are listed, marked, and refused; the answer there is
401
416
  for them to accept, not for anyone to invent a subject.
402
417
 
403
- Removing somebody does not end a session they already hold: membership is read
404
- when a token is minted. Rotating the endpoint token closes that
405
- window now, and it names both flags: `lanes link token rotate --profile <name>
406
- --workspace <name>`.
418
+ Removing somebody does not end a browser session they already hold: membership
419
+ is read when that token is minted. A **static** token is different — its
420
+ membership is resolved on every call, so removing somebody takes effect within
421
+ seconds for anything holding one.
422
+
423
+ **A credential is an identity, not a selection.** Whether it arrived through a
424
+ browser sign-in or is a static `llk_` token, it names a person, and what it
425
+ reaches is every profile whose `members:` lists that person. There is nothing to
426
+ scope at registration time and nothing to scope at deploy time; the member lists
427
+ are the whole mechanism. `lanes link token show`, `rotate` and `revoke` refuse
428
+ `--profile` outright rather than accept and ignore it.
407
429
 
408
- **A client is not given a token any more.** `lanes link mcp add` registers the
409
- bare URL, and the client discovers the endpoint, sends its owner to sign in, and
410
- comes back with a token of its own. The static token is for CI, which has no
411
- browser, and `--headless` is what writes it into a registration. If you are
412
- writing a registration command for somebody, do not add an `Authorization`
413
- header: it is no longer how a client connects.
430
+ **A client is not given a token any more.** `lanes link mcp add --workspace
431
+ <name>` registers the bare URL no `--profile`, because one endpoint serves
432
+ every profile and each call names one in its `profile` argument. The client
433
+ discovers the endpoint, sends its owner to sign in, and comes back with a token
434
+ of its own. If you are writing a registration command for somebody, do not add
435
+ an `Authorization` header: it is no longer how a client connects.
436
+
437
+ **For a runner with no browser**, issue one and say who it is for:
438
+
439
+ ```
440
+ lanes link token issue --me --workspace <name>
441
+ lanes link token list --workspace <name>
442
+ ```
443
+
444
+ `--headless` on `mcp add` is what writes such a token into a registration. A
445
+ workspace that has issued none is the ordinary healthy state, and `start` and
446
+ `deploy` no longer mint or demand one.
414
447
 
415
448
  ## Deploying, and what it decides
416
449
 
@@ -424,14 +457,16 @@ resources that cost money, it implements no `--json` to inspect instead, and tha
424
457
  plan is the only place the consequences are visible while they are still
425
458
  avoidable.
426
459
 
427
- Two things it refuses to guess, and both are the owner's to answer:
460
+ One thing it refuses to guess, and it is the owner's to answer:
428
461
 
429
- - **Whose bearer token opens the endpoint.** One token reaches every profile
430
- behind that workspace, so this decides who gets in. With several candidates and
431
- nothing recorded, it refuses and prints the command that names one.
432
462
  - **A first deploy.** A workspace that does not exist yet has nothing to derive
433
463
  a set from, so `--profile` is required there. It may be repeated, and the first
434
- one named is the primary.
464
+ one named is the primary — which now decides only which profile's host and
465
+ port the endpoint binds, not who gets in.
466
+
467
+ It used to refuse a second thing — *whose bearer token opens the endpoint* — and
468
+ that question no longer has a subject. A token names a person and reaches what
469
+ they are a member of, so there is no per-deploy choice of who gets in.
435
470
 
436
471
  **Never pass `--yes`, `--non-interactive`, `--access public` or
437
472
  `--service-account` yourself.** Each settles a question about who can reach their
@@ -461,45 +496,47 @@ existed in two copies that could disagree, and there is one copy now.
461
496
 
462
497
  ## Registering it, and re-registering it
463
498
 
464
- `lanes link mcp add --profile <name> --workspace <name>` runs each harness's own registration command and installs
465
- this skill where that harness keeps them. With no argument it does every harness
466
- installed; name one (`claude`, `codex`) to be specific. Run it again after
467
- `lanes link token rotate --profile <name> --workspace <name>` — add `--force`, since Claude Code stores the token as
468
- a value rather than a command.
499
+ `lanes link mcp add --workspace <name>` runs each harness's own registration
500
+ command and installs this skill where that harness keeps them. With no argument
501
+ it does every harness installed; name one (`claude`, `codex`) to be specific.
469
502
 
470
- **Never paste the token.** There is a right way and a wrong way, and the
471
- difference matters:
503
+ **It names no profile, and adding one changes nothing about who gets in.** One
504
+ endpoint serves every profile in the workspace and each call names one in its
505
+ `profile` argument, so a registration was never per-profile. What a client
506
+ reaches is decided when its owner signs in: every profile whose `members:` lists
507
+ them.
472
508
 
473
- ```bash
474
- # RIGHT — the token goes from the CLI to the harness. You never see it.
475
- claude mcp add --transport http lanes-link http://127.0.0.1:7337/mcp \
476
- --header "Authorization: Bearer $(lanes link token show --raw --profile <name> --workspace <name>)"
509
+ **Do not write an `Authorization` header.** The registration is a bare URL:
477
510
 
478
- # WRONG — the token is now in your context, and in the transcript, forever.
479
- lanes link token show --show # then copying the value into the command
511
+ ```bash
512
+ claude mcp add --transport http lanes-link http://127.0.0.1:7337/mcp
480
513
  ```
481
514
 
482
- The token reaches every account of every profile the endpoint serves. Use the
483
- substitution form. If you have already printed one by accident, say so and offer
484
- `lanes link token rotate --profile <name> --workspace <name>`.
515
+ The client reads this endpoint's protected-resource document, sends its owner to
516
+ sign in, and comes back holding a token of its own. That is why a re-registration
517
+ is not needed after a rotate, and why a harness config in a dotfiles repository
518
+ is not a leak.
485
519
 
486
- Prefer `lanes link mcp add --profile <name> --workspace <name>` to writing the command yourself: it checks the
487
- endpoint is reachable, refuses to silently shadow an existing registration, and
488
- cannot mistype the token. For a harness it does not know, take the command from
489
- `lanes link outputs --profile <name> --workspace <name>` rather than writing it blind — that command checks whether
490
- `lanes` resolves on this machine and prints a longer working form if it does
491
- not, where guessing gives you an empty substitution, a `Bearer ` header, and a
492
- 401 that reads as a bad token.
493
-
494
- If you register Codex, tell the user to export the token — Codex stores only the
495
- variable name, so nothing works until it is set:
520
+ **A static token is CI's, and it belongs to a person.** For a runner with no
521
+ browser, issue one and let the shell substitute it — never paste it, because a
522
+ pasted token passes through the agent's context and into the transcript:
496
523
 
497
524
  ```bash
498
- export LANES_LINK_TOKEN="$(lanes link token show --raw --profile <name> --workspace <name>)"
525
+ lanes link token issue --me --workspace <name>
526
+ export LANES_LINK_TOKEN="$(lanes link token show --raw --workspace <name>)"
499
527
  ```
500
528
 
529
+ `--headless` on `mcp add` writes such a token into a registration. If you have
530
+ printed one by accident, say so and offer `lanes link token rotate --workspace
531
+ <name>` — no `--profile`, which those commands refuse.
532
+
533
+ Prefer `lanes link mcp add --workspace <name>` to writing the command yourself:
534
+ it checks the endpoint is reachable and refuses to silently shadow an existing
535
+ registration. For a harness it does not know, take the command from `lanes link
536
+ outputs --workspace <name>` rather than writing it blind.
537
+
501
538
  One registration covers every profile. Do not add one per profile; they share a
502
- URL and a token.
539
+ URL.
503
540
 
504
541
  `lanes link mcp list` needs neither flag and reports whether the registration and
505
542
  this document are current, out of date, or absent. That is the cheap first
@@ -508,8 +545,9 @@ copy means the rules you are reading are not the ones that shipped.
508
545
 
509
546
  Claude Desktop cannot be handed a URL, so it spawns the endpoint over stdio
510
547
  instead. That one is named in the client's own config file rather than registered
511
- by a command, as `lanes link mcp stdio --profile <name> --workspace <name>`; both
512
- flags are required, and nothing may be written to stdout.
548
+ by a command, as `lanes link mcp stdio --workspace <name>`; the workspace is
549
+ required, `--only --profile <name>` narrows what it serves, and nothing may be
550
+ written to stdout.
513
551
 
514
552
  ## When it is not running
515
553
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
package/src/auth/index.ts CHANGED
@@ -32,10 +32,12 @@ export interface Principal {
32
32
  /**
33
33
  * Every profile this caller may reach, or `undefined` for "all of them".
34
34
  *
35
- * `undefined` is the machine token and the stdio pipe: neither is a person,
36
- * both reach the whole workspace, and saying so explicitly is better than
37
- * enumerating a list that would then need keeping in step. A `member` always
38
- * carries a list, because the list *is* the delegation (ADR-060).
35
+ * `undefined` is the stdio pipe and nothing else now (ADR-068). The pipe is
36
+ * its own proof a process that can write to it already has the operator's
37
+ * shell so there is no credential to carry a subject and no member list to
38
+ * match. Every token, static or issued, carries a list: a `member`'s because
39
+ * the list *is* the delegation (ADR-060), and a `machine`'s because a bearer
40
+ * token names the person it was issued to rather than opening everything.
39
41
  */
40
42
  readonly profiles?: readonly string[] | undefined;
41
43
  }
@@ -61,6 +63,25 @@ export function memberPrincipal(
61
63
  return { id: subject, profile, kind: 'member', profiles };
62
64
  }
63
65
 
66
+ /**
67
+ * A static token's holder, and the profiles whose `members:` name them.
68
+ *
69
+ * The same shape as `memberPrincipal` and deliberately so — `kind` is the only
70
+ * difference, and it exists for the audit log rather than for policy. ADR-060
71
+ * described this principal and nothing minted one: the static token resolved to
72
+ * `ownerPrincipal`, reaching every profile in the workspace, which made it the
73
+ * one credential here that never had to say who was holding it. A row in
74
+ * `tokens:` names a subject (ADR-068), so this resolves the same way an OAuth
75
+ * token does and `mayReach` gets no special case.
76
+ */
77
+ export function machinePrincipal(
78
+ subject: string,
79
+ profile: string,
80
+ profiles: readonly string[],
81
+ ): Principal {
82
+ return { id: subject, profile, kind: 'machine', profiles };
83
+ }
84
+
64
85
  /**
65
86
  * The same caller, acting within a different profile.
66
87
  *
@@ -161,10 +182,39 @@ export function tokensMatch(a: string, b: string): boolean {
161
182
  return timingSafeEqual(hash(a), hash(b));
162
183
  }
163
184
 
185
+ /**
186
+ * One issued token, as the authenticator needs it.
187
+ *
188
+ * Structurally what `connections.yaml` holds, declared here rather than
189
+ * imported: `auth` may not reach `#profile` (the architecture test enforces the
190
+ * direction), and the rows arrive as a closure for the same reason
191
+ * `profilesFor` does.
192
+ */
193
+ export interface IssuedToken {
194
+ readonly id: string;
195
+ readonly subject: string;
196
+ readonly ref: SecretRef;
197
+ }
198
+
164
199
  export interface AuthenticatorOptions {
200
+ /**
201
+ * The primary, which is what `principal.profile` starts as.
202
+ *
203
+ * Not what the token reaches — that is `profilesFor(subject)`. It is where
204
+ * the connection was opened, and every dispatch rewrites it with `forProfile`.
205
+ */
165
206
  readonly profile: string;
166
- readonly tokenRef: SecretRef;
207
+ /** The workspace's issued tokens. Re-read on every reload, so a revoke lands. */
208
+ readonly tokens: () => Promise<readonly IssuedToken[]>;
167
209
  readonly credentials: SecretStore;
210
+ /**
211
+ * Which profiles list this subject as a member.
212
+ *
213
+ * The same resolver the OAuth path is handed (`server/endpoint.ts`), passed in
214
+ * rather than reached for, so discovery and enforcement cannot disagree about
215
+ * a subject's reach.
216
+ */
217
+ readonly profilesFor: (subject: string) => Promise<readonly string[]>;
168
218
  /** Injectable for tests. Only the cache window reads it. */
169
219
  readonly now?: () => number;
170
220
  }
@@ -183,10 +233,16 @@ export interface AuthenticatorOptions {
183
233
  */
184
234
  const CACHE_TTL_MS = 5_000;
185
235
 
236
+ /** An issued row, with its value read out of the store. */
237
+ interface LoadedToken {
238
+ readonly subject: string;
239
+ readonly value: string;
240
+ }
241
+
186
242
  export class BearerAuthenticator implements Authenticator {
187
243
  readonly #options: AuthenticatorOptions;
188
244
  readonly #now: () => number;
189
- #cached: string | null = null;
245
+ #cached: readonly LoadedToken[] | null = null;
190
246
  #readAt = 0;
191
247
 
192
248
  constructor(options: AuthenticatorOptions) {
@@ -195,48 +251,79 @@ export class BearerAuthenticator implements Authenticator {
195
251
  }
196
252
 
197
253
  async authenticate(authorizationHeader: string | null | undefined): Promise<AuthOutcome> {
198
- const { profile } = this.#options;
199
-
200
254
  const presented = parseBearer(authorizationHeader);
201
255
  if (presented === null) {
202
256
  return { ok: false, reason: authorizationHeader ? 'malformed' : 'missing' };
203
257
  }
204
258
 
205
259
  const fresh = this.#cached !== null && this.#now() - this.#readAt < CACHE_TTL_MS;
206
- let expected = fresh ? this.#cached : await this.#reload();
260
+ let rows = fresh ? this.#cached! : await this.#reload();
261
+ let matched = find(presented, rows);
207
262
 
208
- // A mismatch against a *cached* value is ambiguous: either the credential is
263
+ // A miss against a *cached* set is ambiguous: either the credential is
209
264
  // wrong, or it is the right one and this process has not seen the rotation
210
- // that produced it. One re-read separates the two, and it is what makes a
211
- // rotated-in token work on its first call rather than after the window.
212
- // Only a cached comparison can be wrong this way, so a fresh read never
213
- // pays for a second one — which is what keeps a wrong token from costing a
214
- // store read per attempt.
215
- if (fresh && (expected === null || !tokensMatch(presented, expected))) {
216
- expected = await this.#reload();
265
+ // or the issue that produced it. One re-read separates the two, and it is
266
+ // what makes a rotated-in token work on its first call rather than after
267
+ // the window. Only a cached comparison can be wrong this way, so a fresh
268
+ // read never pays for a second one — which is what keeps a wrong token from
269
+ // costing a store read per attempt.
270
+ if (fresh && matched === null) {
271
+ rows = await this.#reload();
272
+ matched = find(presented, rows);
217
273
  }
218
274
 
219
- if (expected === null) {
220
- // The profile has no token yet. Fail closed and let `lanes link doctor` explain.
275
+ if (rows.length === 0) {
276
+ // No token has been issued. Fail closed, and distinctly from a wrong one:
277
+ // `lanes link doctor` reads this to say "issue one" rather than "check it".
221
278
  return { ok: false, reason: 'not_configured' };
222
279
  }
223
280
 
224
- return tokensMatch(presented, expected)
225
- ? { ok: true, principal: ownerPrincipal(profile) }
226
- : { ok: false, reason: 'invalid' };
281
+ if (matched === null) return { ok: false, reason: 'invalid' };
282
+
283
+ // **Resolved per request, not cached with the value.** Membership is read
284
+ // when a token is minted for an OAuth client (ADR-060) because there is a
285
+ // mint to read it at; a static token has none, so this is the only place
286
+ // the question can be asked. It is what makes `profile members remove`
287
+ // take effect on the next call rather than on the next rotation.
288
+ //
289
+ // A resolver that throws fails closed. The alternative — falling back to
290
+ // "every profile" — would restore exactly the behaviour ADR-068 removes,
291
+ // and would do it precisely when something is already wrong.
292
+ let profiles: readonly string[];
293
+ try {
294
+ profiles = await this.#options.profilesFor(matched.subject);
295
+ } catch {
296
+ return { ok: false, reason: 'invalid' };
297
+ }
298
+
299
+ return {
300
+ ok: true,
301
+ principal: machinePrincipal(matched.subject, this.#options.profile, profiles),
302
+ };
227
303
  }
228
304
 
229
- async #reload(): Promise<string | null> {
305
+ async #reload(): Promise<readonly LoadedToken[]> {
230
306
  // Both caches, or neither: the store holds its own decrypted copy, so
231
307
  // re-reading without dropping that first re-reads the same stale value.
232
308
  this.#options.credentials.refresh?.();
233
- this.#cached = await this.#options.credentials.get(this.#options.tokenRef);
309
+
310
+ const rows = await this.#options.tokens();
311
+ const loaded: LoadedToken[] = [];
312
+ for (const row of rows) {
313
+ const value = await this.#options.credentials.get(row.ref);
314
+ // A row whose credential is gone is not an error to report here. It is
315
+ // what a half-finished `secrets push` looks like, and the row simply
316
+ // matches nothing — `doctor` is where that is worth a sentence.
317
+ if (value) loaded.push({ subject: row.subject, value });
318
+ }
319
+
320
+ this.#cached = loaded;
234
321
  this.#readAt = this.#now();
235
- return this.#cached;
322
+ return loaded;
236
323
  }
237
324
 
238
325
  /**
239
- * Drop the cached value immediately.
326
+ * Drop the cached set immediately.
240
327
  *
241
328
  * The window above already bounds how long a rotation goes unnoticed, so this
242
329
  * is an optimisation rather than the mechanism — nothing's correctness may
@@ -247,6 +334,20 @@ export class BearerAuthenticator implements Authenticator {
247
334
  }
248
335
  }
249
336
 
337
+ /**
338
+ * The row a presented token matches, or null.
339
+ *
340
+ * Every row is compared even after one matches. Returning early would make the
341
+ * time taken describe *which* row answered, and the whole point of
342
+ * `tokensMatch` is that a comparison here leaks nothing about the value it is
343
+ * comparing against.
344
+ */
345
+ function find(presented: string, rows: readonly LoadedToken[]): LoadedToken | null {
346
+ let found: LoadedToken | null = null;
347
+ for (const row of rows) if (tokensMatch(presented, row.value)) found = row;
348
+ return found;
349
+ }
350
+
250
351
  /**
251
352
  * Mint a profile token: 32 random bytes, base64url, prefixed so it is
252
353
  * recognisable in a config file and greppable in a leak.
@@ -68,8 +68,14 @@ export const ACCEPTS: Record<string, readonly string[]> = {
68
68
  'target show': ['workspace'],
69
69
  'mcp install-instructions': ['client'],
70
70
  pair: ['print', 'rotate', 'yes'],
71
- 'token show': ['show', 'raw'],
72
- 'token rotate': ['show', 'raw', 'yes'],
71
+ // `--id` names which row, and is required once more than one is issued.
72
+ // `--subject`/`--me` say who a new one is for, which is the whole of ADR-068.
73
+ token: ['json'],
74
+ 'token list': ['json'],
75
+ 'token issue': ['show', 'subject', 'me', 'label'],
76
+ 'token show': ['show', 'raw', 'id'],
77
+ 'token rotate': ['show', 'raw', 'yes', 'id'],
78
+ 'token revoke': ['yes', 'id'],
73
79
  'audit tail': ['limit', 'denied-only', 'format'],
74
80
  'audit verify': ['limit', 'format'],
75
81
  attach: ['connection'],
@@ -77,7 +83,11 @@ export const ACCEPTS: Record<string, readonly string[]> = {
77
83
  start: ['port', 'only'],
78
84
  'mcp stdio': ['only'],
79
85
  'mcp add': ['name', 'scope', 'token-env', 'dry-run', 'force', 'no-skill', 'headless'],
80
- 'mcp skill': ['print', 'force'],
86
+ // No `--force`: `mcp skill` prints a path or the document and writes nothing,
87
+ // so there was nothing for it to force. It was accepted and ignored, which is
88
+ // the defect `selection.ts` exists to prevent. `mcp add --force` is the flag
89
+ // that replaces a registration.
90
+ 'mcp skill': ['print'],
81
91
  'mcp list': ['name', 'scope'],
82
92
  // `--yes` because it installs the app when nothing answers the scheme, and
83
93
  // that is the one prompt in this CLI that puts an application on the machine.
@@ -22,6 +22,14 @@ export function declareConnection(input: {
22
22
  readonly connectionId: string;
23
23
  readonly account: string;
24
24
  readonly label: string;
25
+ /**
26
+ * What the row is called with nobody's word for it, from `settleIdentity`.
27
+ *
28
+ * The provider's name and the account composed by `defaultConnectionLabel`,
29
+ * which is what every reader falls back to. A label equal to it is a line
30
+ * saying what the two lines above it already say, so it is not written.
31
+ */
32
+ readonly defaultLabel: string;
25
33
  /** Which route in, where the provider offered a choice. */
26
34
  readonly method: string | undefined;
27
35
  /**
@@ -34,6 +42,7 @@ export function declareConnection(input: {
34
42
  readonly config: Readonly<Record<string, string>>;
35
43
  }): readonly string[] {
36
44
  const { document, connections, providerId, connectionId, account, label, method } = input;
45
+ const derived = input.defaultLabel;
37
46
  const config = input.config;
38
47
 
39
48
  const key = `${providerId}.${connectionId}`;
@@ -45,14 +54,14 @@ export function declareConnection(input: {
45
54
  // where the OAuth provider already looks. Writing it would add a line per
46
55
  // connection that can only ever agree or be a bug.
47
56
  //
48
- // No `label` either, when it is the account. Pressing Enter at the prompt is
49
- // the common answer, and a line repeating the address above it is a line to
50
- // read past forever.
57
+ // No `label` either, when it is the one every reader derives anyway.
58
+ // Pressing Enter at the prompt is the common answer, and a line repeating
59
+ // the provider and the address above it is a line to read past forever.
51
60
  document.addTo(['connections'], {
52
61
  id: connectionId,
53
62
  provider: providerId,
54
63
  account,
55
- ...(label === account ? {} : { label }),
64
+ ...(label === derived ? {} : { label }),
56
65
  ...(Object.keys(config).length > 0 ? { config } : {}),
57
66
  });
58
67
  changes.push(`connections += ${key} (${account})`);
@@ -69,10 +78,10 @@ export function declareConnection(input: {
69
78
  changes.push(`connections.${key}.account = ${account}`);
70
79
  }
71
80
 
72
- // Compared against what the row is *called*, which is the account until
81
+ // Compared against what the row is *called*, which is the derived name until
73
82
  // somebody names it otherwise. Without the fallback, every reconnect of an
74
- // unlabelled connection writes a label that says what the line above it says.
75
- if ((declared?.label ?? declared?.account) !== label) {
83
+ // unlabelled connection writes a label that says what the lines above it say.
84
+ if ((declared?.label ?? derived) !== label) {
76
85
  document.setIn(['connections', index, 'label'], label);
77
86
  changes.push(`connections.${key}.label = ${label}`);
78
87
  }
@@ -237,7 +237,7 @@ export async function runConnect(
237
237
  // adding a new one. Without it, a retried connect appends a second row
238
238
  // rather than repairing the first — which is how `main2` and `main3`
239
239
  // ended up in a config describing two mailboxes.
240
- const { connectionId, account, label } = await settleIdentity({
240
+ const settled = await settleIdentity({
241
241
  manifest,
242
242
  provisionalId,
243
243
  explicitId: named,
@@ -246,6 +246,7 @@ export async function runConnect(
246
246
  runtime: { ...runtime, connectorFor: address.connectorFor },
247
247
  prompter,
248
248
  });
249
+ const { connectionId, account, label } = settled;
249
250
 
250
251
  const connectionKey = `${providerId}.${connectionId}`;
251
252
 
@@ -277,9 +278,8 @@ export async function runConnect(
277
278
  document: connectionsDocument,
278
279
  connections: runtime.workspaceConnections,
279
280
  providerId,
280
- connectionId,
281
- account,
282
- label,
281
+ // The settled identity as one value: the row, the account, the name.
282
+ ...settled,
283
283
  method: method.id,
284
284
  config: address.values,
285
285
  }),