@lanes-sh/link 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/README.md +21 -8
  2. package/instructions/skills/lanes-link/SKILL.md +59 -14
  3. package/package.json +1 -1
  4. package/src/auth/index.ts +3 -1
  5. package/src/auth/oauth/metadata.ts +83 -9
  6. package/src/auth/oauth/redirects.ts +70 -0
  7. package/src/auth/oauth/server.ts +49 -69
  8. package/src/auth/oauth/store.ts +19 -5
  9. package/src/cli/argv.ts +50 -0
  10. package/src/cli/brand.ts +178 -0
  11. package/src/cli/callback-page.ts +108 -128
  12. package/src/cli/commands/connect/accounts.ts +5 -0
  13. package/src/cli/commands/connect/assertion.ts +187 -0
  14. package/src/cli/commands/connect/authorise.ts +61 -17
  15. package/src/cli/commands/connect/client.ts +37 -9
  16. package/src/cli/commands/connect/discover.ts +94 -0
  17. package/src/cli/commands/connect/family.ts +72 -0
  18. package/src/cli/commands/connect/index.ts +113 -115
  19. package/src/cli/commands/connect/method.ts +237 -0
  20. package/src/cli/commands/connect/outcome.ts +42 -1
  21. package/src/cli/commands/connect/pasted-token.ts +66 -0
  22. package/src/cli/commands/connect/requirements.ts +60 -8
  23. package/src/cli/commands/connect/setup.ts +16 -5
  24. package/src/cli/commands/connect/target-note.ts +34 -0
  25. package/src/cli/commands/identity.ts +258 -0
  26. package/src/cli/commands/knowledge/index.ts +390 -0
  27. package/src/cli/commands/knowledge/migrate.ts +180 -0
  28. package/src/cli/commands/knowledge/setup.ts +144 -0
  29. package/src/cli/commands/knowledge.ts +10 -0
  30. package/src/cli/commands/mcp/harnesses.ts +16 -2
  31. package/src/cli/commands/mcp/register.ts +9 -1
  32. package/src/cli/commands/mcp/stdio.ts +21 -0
  33. package/src/cli/commands/operate/dashboard.ts +107 -0
  34. package/src/cli/commands/operate/findings.ts +151 -0
  35. package/src/cli/commands/operate/inspect.ts +56 -158
  36. package/src/cli/commands/operate/outputs.ts +38 -11
  37. package/src/cli/commands/operate/serve.ts +3 -0
  38. package/src/cli/commands/operate/token.ts +1 -1
  39. package/src/cli/commands/operate.ts +2 -0
  40. package/src/cli/commands/profile/declare.ts +154 -0
  41. package/src/cli/commands/profile/removal.ts +17 -0
  42. package/src/cli/commands/profile.ts +83 -35
  43. package/src/cli/commands/setup.ts +22 -6
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +48 -144
  46. package/src/cli/config-repair.ts +186 -0
  47. package/src/cli/dashboard-page.ts +284 -0
  48. package/src/cli/dashboard-shell.ts +125 -0
  49. package/src/cli/identity.ts +12 -1
  50. package/src/cli/main.ts +68 -4
  51. package/src/cli/oauth-callback.ts +187 -0
  52. package/src/cli/oauth-exchange.ts +57 -15
  53. package/src/cli/oauth.ts +67 -177
  54. package/src/cli/output.ts +21 -5
  55. package/src/cli/provider-marks.ts +45 -0
  56. package/src/cli/runtime/open.ts +67 -49
  57. package/src/cli/runtime/registry.ts +60 -2
  58. package/src/cli/runtime/select.ts +26 -13
  59. package/src/cli/runtime/vault.ts +61 -0
  60. package/src/cli/runtime.ts +2 -1
  61. package/src/cli/selection.ts +357 -0
  62. package/src/cli/usage.ts +32 -9
  63. package/src/connectivity/auth/README.md +7 -1
  64. package/src/connectivity/auth/basic/index.ts +1 -1
  65. package/src/connectivity/auth/index.ts +14 -0
  66. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  67. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  68. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  69. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  70. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  71. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  72. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  73. package/src/connectivity/auth/resolve.ts +1 -1
  74. package/src/connectivity/auth/token.ts +11 -0
  75. package/src/connectivity/index.ts +2 -0
  76. package/src/connectivity/manifest/auth.ts +99 -2
  77. package/src/connectivity/manifest/identity.ts +12 -0
  78. package/src/connectivity/manifest/index.ts +3 -1
  79. package/src/connectivity/manifest/provider.ts +37 -8
  80. package/src/connectivity/manifest/requirements.ts +109 -6
  81. package/src/deployments/adapters/filesystem.ts +10 -1
  82. package/src/deployments/adapters/github-api.ts +106 -0
  83. package/src/deployments/adapters/github-commit.ts +103 -0
  84. package/src/deployments/adapters/github-repo.ts +356 -0
  85. package/src/deployments/adapters/github-testing.ts +258 -0
  86. package/src/deployments/adapters/github.ts +125 -0
  87. package/src/deployments/deploy.ts +48 -19
  88. package/src/deployments/driver.ts +8 -1
  89. package/src/deployments/gcp/driver.ts +9 -1
  90. package/src/deployments/gcp/survey.ts +3 -0
  91. package/src/deployments/knowledge.ts +119 -0
  92. package/src/deployments/prepare.ts +2 -2
  93. package/src/deployments/servable.ts +81 -0
  94. package/src/deployments/target.ts +3 -2
  95. package/src/deployments/upload.ts +2 -1
  96. package/src/dispatch/dispatch.ts +1 -1
  97. package/src/profile/authorization.ts +13 -4
  98. package/src/profile/identity.ts +60 -0
  99. package/src/profile/index.ts +15 -5
  100. package/src/profile/knowledge.ts +124 -0
  101. package/src/profile/load.ts +17 -5
  102. package/src/profile/primitives.ts +24 -1
  103. package/src/profile/schema.ts +72 -4
  104. package/src/profile/targets.ts +74 -114
  105. package/src/profile/workspace.ts +71 -84
  106. package/src/providers/google/calendar/index.ts +2 -0
  107. package/src/providers/google/contacts/index.ts +2 -0
  108. package/src/providers/google/docs/index.ts +2 -0
  109. package/src/providers/google/drive/index.ts +2 -0
  110. package/src/providers/google/gmail/index.ts +2 -0
  111. package/src/providers/google/gmail-imap/index.ts +125 -0
  112. package/src/providers/google/index.ts +2 -1
  113. package/src/providers/google/shared/oauth.ts +18 -6
  114. package/src/providers/google/shared/service-account.ts +110 -0
  115. package/src/providers/google/shared/setup.ts +5 -2
  116. package/src/providers/google/sheets/index.ts +2 -0
  117. package/src/providers/google/tasks/index.ts +2 -0
  118. package/src/providers/identity/provider.ts +166 -0
  119. package/src/providers/index.ts +3 -0
  120. package/src/providers/owner.ts +10 -2
  121. package/src/providers/scopes.ts +2 -0
  122. package/src/providers/setup/plan.ts +31 -9
  123. package/src/providers/setup/provider.ts +23 -0
  124. package/src/providers/slack/index.ts +81 -33
  125. package/src/providers/slack/oauth.ts +103 -0
  126. package/src/providers/slack/scopes.ts +37 -0
  127. package/src/server/container.ts +18 -1
  128. package/src/server/cors.ts +252 -0
  129. package/src/server/dashboard.ts +208 -0
  130. package/src/server/endpoint.ts +57 -4
  131. package/src/server/generation.ts +1 -0
  132. package/src/server/generations.ts +13 -2
  133. package/src/server/harness.ts +20 -3
  134. package/src/server/index.ts +76 -12
  135. package/src/server/mcp/build.ts +1 -1
  136. package/src/server/mcp/index.ts +1 -0
  137. package/src/server/mcp/instructions.ts +62 -5
  138. package/src/server/mcp/visibility.ts +42 -0
  139. package/src/stores/blobs/route.ts +123 -0
package/README.md CHANGED
@@ -48,8 +48,8 @@ Needs [Bun](https://bun.com) 1.3.11+. Nothing else — no account anywhere.
48
48
 
49
49
  ```console
50
50
  $ bun install -g @lanes-sh/link # puts `lanes` on your PATH
51
- $ lanes link profile add personal --default
52
- $ lanes link start
51
+ $ lanes link profile add personal --target local
52
+ $ lanes link start --profile personal --target local
53
53
  ok serving http://127.0.0.1:7337/mcp
54
54
  profiles: personal
55
55
  ```
@@ -57,7 +57,7 @@ ok serving http://127.0.0.1:7337/mcp
57
57
  Then, in another shell:
58
58
 
59
59
  ```console
60
- $ lanes link mcp add # every agent installed; or name one: claude, codex
60
+ $ lanes link mcp add --profile personal --target local # every agent installed; or name one: claude, codex
61
61
  ok registered lanes-link with Claude Code (user scope)
62
62
  ok registered lanes-link with Codex
63
63
  ```
@@ -78,6 +78,17 @@ calendar are the next step. **[Full quickstart →](docs/quickstart.md)**
78
78
  Memory and skills are plain Markdown files, so a text editor and an agent reach the same bytes. All
79
79
  four belong to one profile: what you add under `work` is invisible under `personal`.
80
80
 
81
+ Keep those two in a private GitHub repository instead of on this machine, and get history, diffs,
82
+ and the same notes from anywhere you run this:
83
+
84
+ ```console
85
+ $ lanes link knowledge use github --repo <owner/name> --migrate
86
+ ```
87
+
88
+ It moves what you have already stored, in one commit, and `lanes link knowledge use local
89
+ --migrate` brings it back. Nothing else moves — your credentials and your vault stay where they
90
+ are, and there is no setting that would put them in a repository.
91
+
81
92
  ## Connect an account
82
93
 
83
94
  One command per account. Run it again to add a second mailbox, a second calendar, a second anything.
@@ -99,15 +110,17 @@ One command per account. Run it again to add a second mailbox, a second calendar
99
110
  | Linear | `lanes link connect linear` |
100
111
  | GitHub | `lanes link connect github` |
101
112
  | Slack | `lanes link connect slack` |
113
+ | Gmail (IMAP, app password) | `lanes link connect gmail_imap` |
102
114
  | Gmail (Google MCP) | `lanes link connect gmail_mcp` |
103
115
  | Drive (Google MCP) | `lanes link connect drive_mcp` |
104
116
 
105
117
  Three things worth knowing up front. `lanes link connect icloud` sets up Mail, Calendar, and
106
- Contacts together, because one app-specific password covers all three. Google needs no OAuth client
107
- of your own: `lanes link connect gmail` authorises against the one Lanes operates, so there is no
108
- Cloud console to visit add `--own-client` if you would rather register your own. And GitHub and
109
- Slack take a token you paste rather than a browser sign-in, because neither will register a client
110
- for us; for Slack that means creating a Slack app once, which is the one console visit left here.
118
+ Contacts together, because one app-specific password covers all three. Google and Slack need no
119
+ OAuth client of your own: both authorise against the one Lanes operates, so there is no console to
120
+ visit for Google, add `--own-client` if you would rather register your own, or take a service
121
+ account key or an app password over IMAP where you would rather nothing expired. And GitHub takes a
122
+ token you paste rather than a browser sign-in, because it will not register a client for us; that
123
+ is the one console visit left here.
111
124
 
112
125
  Full guide — what each one gives your agent, what it needs, and adding your own:
113
126
  **[docs/connect.md](docs/connect.md)**.
@@ -24,6 +24,13 @@ is listed first.** Quietly picking one crosses the line the profile exists to
24
24
  draw. There is no "current profile" to switch — the choice is made per call, and
25
25
  `lanes link profile list` shows what exists.
26
26
 
27
+ **Every `lanes link` command names its profile and its target.** Both are required
28
+ flags with no default, no environment variable, and nothing in a config file
29
+ behind them, so a command missing either refuses rather than acting somewhere
30
+ else. When you write one out for the owner, either fill both in or leave them as
31
+ `<name>` for them to complete — never drop them. `lanes link target list
32
+ --profile <name>` shows what a profile declares.
33
+
27
34
  A `connection` names an account within that profile. One profile may hold
28
35
  several of the same kind, and naming a connection belonging to a *different*
29
36
  profile is refused rather than guessed at.
@@ -37,7 +44,7 @@ try more than one wording before deciding it is not there.
37
44
  Writing is a separate grant, and it should be. What you write is served back to
38
45
  every later session, including to a different agent, so **write when you are
39
46
  asked to remember something, not as a habit.** The owner reaches the same
40
- entries with `lanes link memory list` and a text editor.
47
+ entries with `lanes link memory list --profile <name> --target <name>` and a text editor.
41
48
 
42
49
  ## Skills are theirs, not yours
43
50
 
@@ -47,7 +54,7 @@ cannot read a skill's body; that is deliberate, not a gap to work around.
47
54
 
48
55
  So when a task has a skill for it, **say the skill exists and let them invoke
49
56
  it** rather than improvising your own version of their procedure. They manage
50
- these with `lanes link skills list` and `lanes link skills show <name>`.
57
+ these with `lanes link skills list --profile <name> --target <name>` and `lanes link skills show <skill> --profile <name> --target <name>`.
51
58
 
52
59
  ## Vault values are credentials
53
60
 
@@ -55,6 +62,27 @@ Use one to do the thing that needs it. Never quote it back, summarise it, echo
55
62
  it into a file, or paste it into a command whose output you will show. If a
56
63
  value has been printed by accident, say so.
57
64
 
65
+ ## Who you are writing as is declared, not inferred
66
+
67
+ A profile may declare the names, addresses and handles its owner wants used when
68
+ something is written as them. Call `identity_list` for the profile in play
69
+ before signing a message, choosing an address to send from, or attributing work
70
+ to a handle — **do not** read a name off the conversation, off a previous
71
+ message's signature, or off the account label on a connection. That label is the
72
+ identity a provider reports for a mailbox; it is not necessarily what they sign
73
+ with.
74
+
75
+ A profile may declare several of a kind on purpose. The first is the default and
76
+ each carries a note saying when to prefer it, so read the notes rather than
77
+ picking the first unconditionally. If none of them fits what you are doing, ask
78
+ — do not combine two, and do not carry one profile's name into another. That
79
+ crossing is the specific mistake this exists to prevent.
80
+
81
+ If the tool is not there, the profile has declared nothing. Ask rather than
82
+ inventing something; they add one with `lanes link identity add <kind> <value> --profile <profile>
83
+ --target <target>` — both flags, because neither has a fallback.
84
+ Nothing you can call writes here, deliberately.
85
+
58
86
  ## Attachments are named, not carried
59
87
 
60
88
  Where a tool takes `attachments`, each entry names **one** source and the endpoint
@@ -84,7 +112,7 @@ its content.
84
112
 
85
113
  **If the endpoint is not on the same machine as the file**, `path` names the
86
114
  *server's* filesystem rather than theirs, and will not find it. Ask them to run
87
- `lanes link attach <file> --connection <provider>.<account>`, which prints a
115
+ `lanes link attach <file> --profile <name> --target <name> --connection <provider>.<account>`, which prints a
88
116
  handle to use instead.
89
117
 
90
118
  **`draft_only: true`** saves instead of sending, where they should see it before
@@ -104,9 +132,9 @@ granted, and retrying will not reveal it. A call that *is* refused was refused b
104
132
  policy on purpose.
105
133
 
106
134
  Report it plainly and let the owner decide whether to widen the grant —
107
- `lanes link policy list` shows the rules, `lanes link policy allow <capability>`
135
+ `lanes link policy list --profile <name>` shows the rules, `lanes link policy allow <capability> --profile <name> --target <name>`
108
136
  changes them, and that is their call, not yours. **Do not look for another route
109
- to the same data.** Every call is audited either way; `lanes link audit tail`
137
+ to the same data.** Every call is audited either way; `lanes link audit tail --profile <name> --target <name>`
110
138
  shows what was attempted, refusals included.
111
139
 
112
140
  ## Setting something up
@@ -125,9 +153,9 @@ credential — those are `lanes link` in a terminal, deliberately. What you can
125
153
  know exactly what to hand over, and say what it will ask for before they start.
126
154
 
127
155
  If you have a shell, you can run it yourself for anything that does *not* need a
128
- browser: `lanes link setup plan <provider> --json` lists what to store,
129
- `lanes link secrets set <ref>` takes the value on stdin, and
130
- `lanes link connect <provider> --id <name> --non-interactive --json` finishes.
156
+ browser: `lanes link setup plan <provider> --profile <name> --target <name> --json` lists what to store,
157
+ `lanes link secrets set <ref> --profile <name> --target <name>` takes the value on stdin, and
158
+ `lanes link connect <provider> --profile <name> --target <name> --id <id> --non-interactive --json` finishes.
131
159
  Anything with a browser sign-in belongs to whoever owns the account — give them
132
160
  the line.
133
161
 
@@ -139,10 +167,10 @@ straight after connecting will still not show it. Say so rather than retrying.
139
167
 
140
168
  ## Registering it, and re-registering it
141
169
 
142
- `lanes link mcp add` runs each harness's own registration command and installs
170
+ `lanes link mcp add --profile <name> --target <name>` runs each harness's own registration command and installs
143
171
  this skill where that harness keeps them. With no argument it does every harness
144
172
  installed; name one (`claude`, `codex`) to be specific. Run it again after
145
- `lanes link token rotate` — add `--force`, since Claude Code stores the token as
173
+ `lanes link token rotate --profile <name> --target <name>` — add `--force`, since Claude Code stores the token as
146
174
  a value rather than a command.
147
175
 
148
176
  **Never paste the token.** There is a right way and a wrong way, and the
@@ -151,7 +179,7 @@ difference matters:
151
179
  ```bash
152
180
  # RIGHT — the token goes from the CLI to the harness. You never see it.
153
181
  claude mcp add --transport http lanes-link http://127.0.0.1:7337/mcp \
154
- --header "Authorization: Bearer $(lanes link token show --raw)"
182
+ --header "Authorization: Bearer $(lanes link token show --raw --profile <name> --target <name>)"
155
183
 
156
184
  # WRONG — the token is now in your context, and in the transcript, forever.
157
185
  lanes link token show --show # then copying the value into the command
@@ -159,9 +187,9 @@ lanes link token show --show # then copying the value into the command
159
187
 
160
188
  The token reaches every account of every profile the endpoint serves. Use the
161
189
  substitution form. If you have already printed one by accident, say so and offer
162
- `lanes link token rotate`.
190
+ `lanes link token rotate --profile <name> --target <name>`.
163
191
 
164
- Prefer `lanes link mcp add` to writing the command yourself: it checks the
192
+ Prefer `lanes link mcp add --profile <name> --target <name>` to writing the command yourself: it checks the
165
193
  endpoint is reachable, refuses to silently shadow an existing registration, and
166
194
  cannot mistype the token. For a harness it does not know, take the command from
167
195
  `lanes link outputs` rather than writing it blind — that command checks whether
@@ -173,7 +201,7 @@ If you register Codex, tell the user to export the token — Codex stores only t
173
201
  variable name, so nothing works until it is set:
174
202
 
175
203
  ```bash
176
- export LANES_LINK_TOKEN="$(lanes link token show --raw)"
204
+ export LANES_LINK_TOKEN="$(lanes link token show --raw --profile <name> --target <name>)"
177
205
  ```
178
206
 
179
207
  One registration covers every profile. Do not add one per profile; they share a
@@ -185,3 +213,20 @@ URL and a token.
185
213
  user the command rather than backgrounding it silently on their behalf.
186
214
  Registration works while it is down — the harness simply cannot reach it yet,
187
215
  and the first symptom is a failed call much later.
216
+
217
+ ## When a call does not land
218
+
219
+ Different from the above, and more common: calls were working, and then one does
220
+ not go through. A deployed endpoint is one machine its owner runs, and a client
221
+ can report it unreachable while it is up — sometimes without sending anything at
222
+ all, which is why the endpoint's own log can show no trace of the attempt.
223
+
224
+ Treat it as ordinary. **Say the call did not land, and stop there.** It is not a
225
+ fault to diagnose, and it is not authorization that has lapsed — do not tell them
226
+ to sign in again unless the endpoint itself said so.
227
+
228
+ **Do not redo what already succeeded.** A call that returned is done, and the
229
+ next one failing does not undo it. Re-deriving a finished answer, or rewriting a
230
+ memory entry that was already written, is the expensive mistake here and the one
231
+ that actually gets made. Say which parts landed, which did not, and offer to
232
+ retry the rest.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, skills, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
package/src/auth/index.ts CHANGED
@@ -212,9 +212,11 @@ export {
212
212
  challenge,
213
213
  protectedResourceMetadata,
214
214
  MCP_SCOPE,
215
+ type ChallengeError,
215
216
  type ResourceIdentity,
216
217
  } from './oauth/metadata.ts';
217
- export { OAuthServer, matchesRegistered, pkceChallengeFor, type AuthorizeRequest, type OAuthResult } from './oauth/server.ts';
218
+ export { OAuthServer, pkceChallengeFor, type AuthorizeRequest, type OAuthResult } from './oauth/server.ts';
219
+ export { matchesRegistered } from './oauth/redirects.ts';
218
220
  export { OAuthStore, hashToken, randomToken } from './oauth/store.ts';
219
221
  export { OidcVerifier, type OidcVerifierOptions, type VerifiedSubject } from './oidc.ts';
220
222
  export { IssuedTokenAuthenticator, OidcAuthenticator } from './remote.ts';
@@ -22,21 +22,62 @@ export interface ResourceIdentity {
22
22
  }
23
23
 
24
24
  /**
25
- * One scope, and it is not a permission axis.
25
+ * Two scopes, and neither is a permission axis.
26
26
  *
27
27
  * What a caller may do is decided by the profile's policy, per capability, per
28
28
  * call, and recorded in the audit log. A second permission system expressed as
29
29
  * scopes could only either duplicate that or disagree with it, and a client
30
- * cannot be trusted to ask for less than it wants anyway. The scope exists
31
- * because the protocol has a slot for one.
30
+ * cannot be trusted to ask for less than it wants anyway. The scopes exist
31
+ * because the protocol has a slot for them, and because a client reads that
32
+ * slot to decide what this endpoint will do for it.
32
33
  */
33
34
  export const MCP_SCOPE = 'mcp';
34
35
 
36
+ /**
37
+ * OIDC Core §11's name for "issue me a refresh token", and the reason this
38
+ * endpoint stopped sending its owner back to a browser.
39
+ *
40
+ * A refresh token has always been issued here, unconditionally. What was missing
41
+ * was saying so. A client's requested scope defaults to whatever the *resource*
42
+ * document lists, and the reference MCP client appends `offline_access` only
43
+ * when the *authorization server* document advertises it:
44
+ *
45
+ * ```js
46
+ * let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(" ") || …
47
+ * if (effectiveScope && authServerMetadata?.scopes_supported?.includes("offline_access") && …)
48
+ * effectiveScope = `${effectiveScope} offline_access`;
49
+ * ```
50
+ *
51
+ * So both documents matter and they matter differently. Advertising it in
52
+ * neither left a client no grounds to request, persist, or use the refresh
53
+ * token it was being handed — and a client with no grounds reconnects, which
54
+ * means its owner approving in a browser.
55
+ */
56
+ export const OFFLINE_ACCESS_SCOPE = 'offline_access';
57
+
58
+ /** Everything grantable here. A request for anything else is narrowed, not refused. */
59
+ export const SUPPORTED_SCOPES = [MCP_SCOPE, OFFLINE_ACCESS_SCOPE] as const;
60
+
61
+ /**
62
+ * The grantable part of what was asked for.
63
+ *
64
+ * Empty means the request named nothing we recognise, and the caller falls back
65
+ * to `MCP_SCOPE` — refusing with `invalid_scope` would turn an unknown token in
66
+ * a client's default string into a connector that cannot be added at all, and
67
+ * scope is not the thing protecting anything here.
68
+ */
69
+ export function grantableScope(requested: string | null | undefined): string {
70
+ const asked = new Set((requested ?? '').split(/\s+/).filter(Boolean));
71
+ return SUPPORTED_SCOPES.filter((scope) => asked.has(scope)).join(' ');
72
+ }
73
+
35
74
  export function protectedResourceMetadata(identity: ResourceIdentity): Record<string, unknown> {
36
75
  return {
37
76
  resource: identity.resource,
38
77
  authorization_servers: [identity.issuer],
39
- scopes_supported: [MCP_SCOPE],
78
+ // Where a client's *default* requested scope comes from, so this is the
79
+ // list that decides what an untouched connector asks for.
80
+ scopes_supported: [...SUPPORTED_SCOPES],
40
81
  bearer_methods_supported: ['header'],
41
82
  };
42
83
  }
@@ -47,7 +88,9 @@ export function authorizationServerMetadata(origin: string): Record<string, unkn
47
88
  authorization_endpoint: `${origin}/authorize`,
48
89
  token_endpoint: `${origin}/token`,
49
90
  registration_endpoint: `${origin}/register`,
50
- scopes_supported: [MCP_SCOPE],
91
+ // And this is the list that gates whether `offline_access` is appended at
92
+ // all. Both documents have to carry it; neither one alone is enough.
93
+ scopes_supported: [...SUPPORTED_SCOPES],
51
94
  response_types_supported: ['code'],
52
95
  grant_types_supported: ['authorization_code', 'refresh_token'],
53
96
  // Advertised because a spec-compliant client checks for it before starting
@@ -60,6 +103,27 @@ export function authorizationServerMetadata(origin: string): Record<string, unkn
60
103
  };
61
104
  }
62
105
 
106
+ /**
107
+ * Why a credential was refused, in RFC 6750 §3.1's vocabulary.
108
+ *
109
+ * There is one code worth sending and it carries the whole distinction a client
110
+ * needs: `invalid_token` says the credential was *rejected*, where an otherwise
111
+ * identical challenge says only that authorization is required. A client that
112
+ * cannot tell those apart cannot tell "refresh — you hold a refresh token for
113
+ * this" from "start a new authorization", and the safe-looking guess is the
114
+ * second, which means the owner approving in a browser for a credential a
115
+ * silent refresh would have replaced.
116
+ *
117
+ * Deliberately absent when nothing was presented. RFC 6750 §3: a resource
118
+ * server SHOULD NOT include an error code where the request carried no
119
+ * authentication information — and sending one would set a client refreshing a
120
+ * credential it does not have.
121
+ */
122
+ export interface ChallengeError {
123
+ readonly code: 'invalid_token';
124
+ readonly description: string;
125
+ }
126
+
63
127
  /**
64
128
  * The `WWW-Authenticate` value on a 401.
65
129
  *
@@ -67,9 +131,19 @@ export function authorizationServerMetadata(origin: string): Record<string, unkn
67
131
  * has to guess the document's location by probing well-known paths, which costs
68
132
  * round trips and fails entirely on a host that does not serve them. Clients do
69
133
  * not honour this header on a `200`, so the status has to be right too.
134
+ *
135
+ * It stays on the header even when a token was rejected. A client that decides
136
+ * to authorize after all — because the refresh was refused too — must not have
137
+ * to go and find the document a second time.
70
138
  */
71
- export function challenge(metadataUrl: string | null): string {
72
- return metadataUrl
73
- ? `Bearer realm="lanes-link", resource_metadata="${metadataUrl}"`
74
- : 'Bearer realm="lanes-link"';
139
+ export function challenge(metadataUrl: string | null, error?: ChallengeError): string {
140
+ // Every value is a quoted-string, so none may contain a quote. Both of these
141
+ // are constants in this repository and the types keep them that way.
142
+ const parts = [
143
+ 'realm="lanes-link"',
144
+ ...(error ? [`error="${error.code}"`, `error_description="${error.description}"`] : []),
145
+ ...(metadataUrl ? [`resource_metadata="${metadataUrl}"`] : []),
146
+ ];
147
+
148
+ return `Bearer ${parts.join(', ')}`;
75
149
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Which redirect URIs this server will send an authorization code to.
3
+ *
4
+ * Its own file because it is its own subject. `server.ts` is the flow as
5
+ * decisions — a code exchanged, a token rotated, an owner approving — and none
6
+ * of it is about URL shapes. Both halves stayed inside the file-size budget
7
+ * until they did not, and the budget exists to point at exactly this: it was
8
+ * not too long, it was two things.
9
+ *
10
+ * Nothing here consults configuration. What a client registered is checked
11
+ * against what it now presents, and the rules are RFC 8252's rather than ours.
12
+ */
13
+
14
+ /**
15
+ * https, or loopback for a native client.
16
+ *
17
+ * A native client cannot receive an https redirect, so RFC 8252 has it listen
18
+ * on a loopback port instead. Everything else is refused: a redirect to `http://`
19
+ * on a routable host puts an authorization code on the wire in clear text.
20
+ */
21
+ export function isSafeRedirect(uri: string): boolean {
22
+ let parsed: URL;
23
+ try {
24
+ parsed = new URL(uri);
25
+ } catch {
26
+ return false;
27
+ }
28
+
29
+ if (parsed.protocol === 'https:') return true;
30
+ return parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname);
31
+ }
32
+
33
+ function isLoopbackHost(hostname: string): boolean {
34
+ return hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]' || hostname === 'localhost';
35
+ }
36
+
37
+ /**
38
+ * Exact match, except for the port of a loopback URI.
39
+ *
40
+ * RFC 8252 §7.3 requires ignoring the port for the IP-literal form, because a
41
+ * native client binds an ephemeral one it cannot know at registration time.
42
+ * Claude Code declares `http://localhost/callback` and `http://127.0.0.1/callback`
43
+ * and then listens on whatever port it got, so the same allowance has to cover
44
+ * `localhost` or it never connects.
45
+ */
46
+ export function matchesRegistered(candidate: string, registered: readonly string[]): boolean {
47
+ if (registered.includes(candidate)) return true;
48
+
49
+ let parsed: URL;
50
+ try {
51
+ parsed = new URL(candidate);
52
+ } catch {
53
+ return false;
54
+ }
55
+ if (!isLoopbackHost(parsed.hostname)) return false;
56
+
57
+ return registered.some((uri) => {
58
+ try {
59
+ const other = new URL(uri);
60
+ return (
61
+ isLoopbackHost(other.hostname) &&
62
+ other.protocol === parsed.protocol &&
63
+ other.hostname === parsed.hostname &&
64
+ other.pathname === parsed.pathname
65
+ );
66
+ } catch {
67
+ return false;
68
+ }
69
+ });
70
+ }
@@ -1,4 +1,5 @@
1
- import { MCP_SCOPE } from './metadata.ts';
1
+ import { grantableScope, MCP_SCOPE } from './metadata.ts';
2
+ import { isSafeRedirect, matchesRegistered } from './redirects.ts';
2
3
  import {
3
4
  hashToken,
4
5
  randomToken,
@@ -26,6 +27,21 @@ import {
26
27
  const CODE_TTL_MS = 60_000;
27
28
  const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
28
29
 
30
+ /**
31
+ * How long a spent refresh token still answers.
32
+ *
33
+ * A client whose refresh succeeded but whose *response* was lost holds a token
34
+ * the server has already spent, and retrying with it is the only move it has.
35
+ * Without a window that retry is `invalid_grant`, and the reference MCP client
36
+ * rethrows every `OAuthError` but `server_error` rather than recovering — so
37
+ * the connector dies and its owner is sent to a browser, over a network blip.
38
+ *
39
+ * Thirty seconds is the band Auth0's reuse interval (0–60 s) and Okta's grace
40
+ * period occupy. What it costs: a captured refresh token keeps working for up
41
+ * to this long after the real client next rotates it.
42
+ */
43
+ const REFRESH_REUSE_MS = 30_000;
44
+
29
45
  export type OAuthResult =
30
46
  | { readonly kind: 'json'; readonly status: number; readonly body: unknown }
31
47
  | { readonly kind: 'redirect'; readonly location: string }
@@ -61,6 +77,9 @@ export interface OAuthServerOptions {
61
77
  /** Proof of being the owner. The same token the endpoint already accepts. */
62
78
  readonly verifyOwner: (presented: string) => Promise<boolean>;
63
79
  readonly accessTokenTtlMs: number;
80
+ /** Where a replayed refresh token is recorded. Structural, because this layer
81
+ * may not import `#connectivity`; the endpoint's own logger satisfies it. */
82
+ readonly log?: { warn(message: string, detail?: Record<string, unknown>): void };
64
83
  readonly now?: () => number;
65
84
  }
66
85
 
@@ -163,7 +182,11 @@ export class OAuthServer {
163
182
  redirectUri,
164
183
  codeChallenge: challenge,
165
184
  state: params.get('state') ?? undefined,
166
- scope: params.get('scope') || MCP_SCOPE,
185
+ // The grantable part of what was asked for, not the request verbatim.
186
+ // Echoing it back through `#issue` was granting by echo, which was inert
187
+ // while `mcp` was the only scope and stops being inert now that there is
188
+ // a second one that means something.
189
+ scope: grantableScope(params.get('scope')) || MCP_SCOPE,
167
190
  resource: params.get('resource') ?? undefined,
168
191
  },
169
192
  };
@@ -198,7 +221,12 @@ export class OAuthServer {
198
221
  clientId: request.clientId,
199
222
  redirectUri: request.redirectUri,
200
223
  codeChallenge: request.codeChallenge,
201
- scope: request.scope,
224
+ // Narrowed here as well as in `authorize`, and this is the one that
225
+ // matters: the request arrives back through hidden form fields, so a
226
+ // caller can post any scope it likes straight to this endpoint. Nothing
227
+ // round-tripped through the form is trusted — the client and the redirect
228
+ // URI are re-checked above for the same reason.
229
+ scope: grantableScope(request.scope) || MCP_SCOPE,
202
230
  ...(request.resource ? { resource: request.resource } : {}),
203
231
  expiresAt: this.#now() + CODE_TTL_MS,
204
232
  };
@@ -252,15 +280,25 @@ export class OAuthServer {
252
280
  return invalid('invalid_grant', 'That refresh token is unknown or expired.');
253
281
  }
254
282
 
255
- // A spent token presented again is the one signal that it has been copied.
256
- // Rotation alone does not answer it: whoever refreshes first walks away
257
- // with a live pair, and rejecting only the token in hand leaves that pair
258
- // working while the other party usually the real client — is locked out.
259
- // So the whole chain goes. A client retrying a response it never saw and a
260
- // thief replaying are indistinguishable from here, and re-authorising is
261
- // the cheaper of the two mistakes.
283
+ // A spent token presented again used to take its whole family with it, on
284
+ // the reading that a replay is a theft. Against a real connector that was
285
+ // wrong twice over, and ADR-035 has the evidence. Two answers replace it,
286
+ // and the tombstone's age is what tells them apart.
262
287
  if (record.kind === 'consumed') {
263
- await this.#options.store.revokeFamily(record.family);
288
+ // Inside the window it is a retry of a request already answered, and the
289
+ // client is owed the answer rather than a dead connector. Not re-consumed:
290
+ // a client retrying twice is still retrying.
291
+ const spentAt = record.consumedAt;
292
+ if (spentAt !== undefined && this.#now() - spentAt <= REFRESH_REUSE_MS) {
293
+ return this.#issue(record.clientId, record.scope, randomToken('llr'), record.family);
294
+ }
295
+
296
+ // Outside it, refused on its own — and the family survives, which is the
297
+ // half that was taking live sessions down with it.
298
+ this.#options.log?.warn('refresh token replayed', {
299
+ clientId: record.clientId,
300
+ family: record.family,
301
+ });
264
302
  return invalid('invalid_grant', 'That refresh token has already been used.');
265
303
  }
266
304
 
@@ -324,62 +362,4 @@ function invalid(error: string, description: string): OAuthResult {
324
362
  return { kind: 'json', status: 400, body: { error, error_description: description } };
325
363
  }
326
364
 
327
- /**
328
- * https, or loopback for a native client.
329
- *
330
- * A native client cannot receive an https redirect, so RFC 8252 has it listen
331
- * on a loopback port instead. Everything else is refused: a redirect to `http://`
332
- * on a routable host puts an authorization code on the wire in clear text.
333
- */
334
- function isSafeRedirect(uri: string): boolean {
335
- let parsed: URL;
336
- try {
337
- parsed = new URL(uri);
338
- } catch {
339
- return false;
340
- }
341
-
342
- if (parsed.protocol === 'https:') return true;
343
- return parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname);
344
- }
345
-
346
- function isLoopbackHost(hostname: string): boolean {
347
- return hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]' || hostname === 'localhost';
348
- }
349
-
350
- /**
351
- * Exact match, except for the port of a loopback URI.
352
- *
353
- * RFC 8252 §7.3 requires ignoring the port for the IP-literal form, because a
354
- * native client binds an ephemeral one it cannot know at registration time.
355
- * Claude Code declares `http://localhost/callback` and `http://127.0.0.1/callback`
356
- * and then listens on whatever port it got, so the same allowance has to cover
357
- * `localhost` or it never connects.
358
- */
359
- export function matchesRegistered(candidate: string, registered: readonly string[]): boolean {
360
- if (registered.includes(candidate)) return true;
361
-
362
- let parsed: URL;
363
- try {
364
- parsed = new URL(candidate);
365
- } catch {
366
- return false;
367
- }
368
- if (!isLoopbackHost(parsed.hostname)) return false;
369
-
370
- return registered.some((uri) => {
371
- try {
372
- const other = new URL(uri);
373
- return (
374
- isLoopbackHost(other.hostname) &&
375
- other.protocol === parsed.protocol &&
376
- other.hostname === parsed.hostname &&
377
- other.pathname === parsed.pathname
378
- );
379
- } catch {
380
- return false;
381
- }
382
- });
383
- }
384
-
385
365
  export { hashToken };
@@ -53,6 +53,9 @@ export interface AuthorizationCode {
53
53
  * it arrived. A tombstone keeps the family id and nothing else useful, and it
54
54
  * opens no more than a deleted row does — every check that admits a credential
55
55
  * tests for `access` by name.
56
+ *
57
+ * What is *done* about a detected replay changed in ADR-035: the presented
58
+ * token is refused and the replay logged, rather than the family revoked.
56
59
  */
57
60
  export type TokenKind = 'access' | 'refresh' | 'consumed';
58
61
 
@@ -71,6 +74,15 @@ export interface IssuedToken {
71
74
  * the theft and the retry look identical from here.
72
75
  */
73
76
  readonly family: string;
77
+ /**
78
+ * When this token was spent, on a `consumed` tombstone and nowhere else.
79
+ *
80
+ * What makes the reuse interval possible: without it a spent token carries no
81
+ * hint whether it was spent a second ago or a month ago, and those are a retry
82
+ * and a replay. A tombstone written before this existed has no `consumedAt`
83
+ * and is read as the older one, which is the safe direction.
84
+ */
85
+ readonly consumedAt?: number;
74
86
  }
75
87
 
76
88
  export function hashToken(value: string): string {
@@ -182,16 +194,18 @@ export class OAuthStore {
182
194
  const key = hashToken(token);
183
195
  const record = await this.#read<IssuedToken>(TOKENS, key);
184
196
  if (!record) return;
185
- await this.#state.set(TOKENS, key, JSON.stringify({ ...record, kind: 'consumed' }));
197
+ const spent: IssuedToken = { ...record, kind: 'consumed', consumedAt: this.#now() };
198
+ await this.#state.set(TOKENS, key, JSON.stringify(spent));
186
199
  }
187
200
 
188
201
  /**
189
202
  * Drop every token in a refresh family.
190
203
  *
191
- * Called when a rotated-away refresh token is presented again, which is
192
- * either a client retrying or a thief replaying. Both are answered the same
193
- * way, because from here they are indistinguishable and the safe reading is
194
- * the expensive one.
204
+ * A replay no longer calls this, and a replay was the only thing that did
205
+ * see `OAuthServer.#refresh` and ADR-035. Kept because it is the shape a
206
+ * deliberate revocation takes: one authorization's whole chain, dropped on
207
+ * purpose. Nothing in `src/` reaches it today, so read a call site as new
208
+ * policy rather than as the old one returning.
195
209
  */
196
210
  async revokeFamily(family: string): Promise<void> {
197
211
  for (const key of await this.#state.keys(TOKENS)) {