@lanes-sh/link 0.7.1 → 0.8.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 (157) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +136 -61
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +65 -0
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +108 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/brand.ts +39 -10
  22. package/src/cli/callback-page.ts +37 -104
  23. package/src/cli/commands/auth-dispatch.ts +48 -0
  24. package/src/cli/commands/auth.ts +229 -0
  25. package/src/cli/commands/connect/accounts.ts +4 -4
  26. package/src/cli/commands/connect/authorise.ts +4 -4
  27. package/src/cli/commands/connect/bind-credential.ts +2 -1
  28. package/src/cli/commands/connect/custom/index.ts +1 -1
  29. package/src/cli/commands/connect/custom/write.ts +2 -2
  30. package/src/cli/commands/connect/grant.ts +29 -14
  31. package/src/cli/commands/connect/index.ts +88 -87
  32. package/src/cli/commands/connect/options.ts +83 -0
  33. package/src/cli/commands/connect/registration.ts +50 -0
  34. package/src/cli/commands/connect/requirements.ts +1 -1
  35. package/src/cli/commands/connect/settle.ts +4 -2
  36. package/src/cli/commands/connect/target-note.ts +7 -2
  37. package/src/cli/commands/connect/unknown.ts +1 -1
  38. package/src/cli/commands/connect/variables.ts +3 -2
  39. package/src/cli/commands/connection-list.ts +116 -0
  40. package/src/cli/commands/connection.ts +182 -165
  41. package/src/cli/commands/grant.ts +140 -0
  42. package/src/cli/commands/identity.ts +21 -9
  43. package/src/cli/commands/knowledge/index.ts +46 -79
  44. package/src/cli/commands/knowledge/migrate.ts +74 -13
  45. package/src/cli/commands/knowledge/show.ts +92 -0
  46. package/src/cli/commands/knowledge.ts +2 -1
  47. package/src/cli/commands/mcp/harnesses.ts +30 -8
  48. package/src/cli/commands/mcp/onboarding.ts +86 -0
  49. package/src/cli/commands/mcp/register.ts +16 -2
  50. package/src/cli/commands/mcp.ts +1 -0
  51. package/src/cli/commands/members.ts +288 -0
  52. package/src/cli/commands/operate/attach.ts +3 -3
  53. package/src/cli/commands/operate/audit.ts +11 -7
  54. package/src/cli/commands/operate/auth.ts +28 -11
  55. package/src/cli/commands/operate/findings.ts +2 -1
  56. package/src/cli/commands/operate/inspect.ts +37 -19
  57. package/src/cli/commands/operate/migrate.ts +29 -12
  58. package/src/cli/commands/operate/outputs.ts +3 -3
  59. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  60. package/src/cli/commands/operate/pair.ts +324 -0
  61. package/src/cli/commands/operate/policy.ts +73 -22
  62. package/src/cli/commands/operate/serve.ts +52 -4
  63. package/src/cli/commands/operate/status.ts +18 -10
  64. package/src/cli/commands/operate/tools.ts +2 -2
  65. package/src/cli/commands/operate.ts +2 -0
  66. package/src/cli/commands/owner/shared.ts +13 -2
  67. package/src/cli/commands/owner/skills.ts +28 -8
  68. package/src/cli/commands/profile/removal.ts +79 -76
  69. package/src/cli/commands/profile/remove.ts +16 -1
  70. package/src/cli/commands/profile.ts +46 -10
  71. package/src/cli/commands/relabel.ts +112 -0
  72. package/src/cli/commands/secrets.ts +34 -12
  73. package/src/cli/commands/set-workspace.ts +96 -0
  74. package/src/cli/commands/setup.ts +2 -2
  75. package/src/cli/commands/sync.ts +8 -8
  76. package/src/cli/commands/target.ts +9 -7
  77. package/src/cli/commands/update.ts +58 -17
  78. package/src/cli/config-edit.ts +75 -140
  79. package/src/cli/config-migrate.ts +82 -64
  80. package/src/cli/config-repair.ts +89 -32
  81. package/src/cli/config-templates.ts +198 -0
  82. package/src/cli/contract3-data.ts +328 -0
  83. package/src/cli/contract3-shape.ts +186 -0
  84. package/src/cli/contract3.ts +282 -0
  85. package/src/cli/endpoint-url.ts +1 -1
  86. package/src/cli/lanes.ts +25 -1
  87. package/src/cli/main.ts +89 -14
  88. package/src/cli/migrate-plan.ts +12 -6
  89. package/src/cli/output.ts +34 -1
  90. package/src/cli/publish.ts +5 -2
  91. package/src/cli/runtime/open.ts +63 -98
  92. package/src/cli/runtime/registry.ts +6 -7
  93. package/src/cli/runtime/stores.ts +53 -0
  94. package/src/cli/runtime/types.ts +106 -0
  95. package/src/cli/runtime/vault.ts +19 -4
  96. package/src/cli/runtime/workspace.ts +60 -0
  97. package/src/cli/runtime.ts +2 -1
  98. package/src/cli/selection-require.ts +44 -13
  99. package/src/cli/selection.ts +127 -145
  100. package/src/cli/usage.ts +34 -18
  101. package/src/cli/workspace-migrate.ts +125 -16
  102. package/src/connectivity/manifest/provider.ts +3 -1
  103. package/src/connectivity/manifest/requirements.ts +1 -1
  104. package/src/deployments/bind.ts +1 -1
  105. package/src/deployments/deploy.ts +36 -27
  106. package/src/deployments/gcp/bucket.ts +18 -7
  107. package/src/deployments/gcp/provision.ts +7 -7
  108. package/src/deployments/prepare.ts +72 -24
  109. package/src/deployments/record.ts +1 -1
  110. package/src/deployments/report.ts +2 -2
  111. package/src/deployments/serving.ts +15 -74
  112. package/src/deployments/target.ts +15 -15
  113. package/src/deployments/upload.ts +46 -22
  114. package/src/dispatch/deps.ts +88 -0
  115. package/src/dispatch/dispatch.ts +21 -62
  116. package/src/policy/index.ts +47 -15
  117. package/src/profile/connections.ts +183 -0
  118. package/src/profile/deployments.ts +3 -3
  119. package/src/profile/index.ts +30 -5
  120. package/src/profile/layout.ts +86 -89
  121. package/src/profile/load.ts +80 -47
  122. package/src/profile/pairing.ts +32 -0
  123. package/src/profile/primitives.ts +35 -1
  124. package/src/profile/registry.ts +6 -6
  125. package/src/profile/schema.ts +172 -21
  126. package/src/profile/targets.ts +21 -9
  127. package/src/profile/testing.ts +69 -2
  128. package/src/profile/workspace.ts +58 -3
  129. package/src/providers/custom/index.ts +1 -1
  130. package/src/providers/custom/load.ts +2 -3
  131. package/src/providers/identity/provider.ts +1 -1
  132. package/src/providers/memory/provider.ts +20 -2
  133. package/src/providers/setup/plan.ts +1 -1
  134. package/src/providers/slack/index.ts +2 -2
  135. package/src/registry/policy-bridge.ts +33 -11
  136. package/src/registry/reconcile.ts +4 -4
  137. package/src/server/authorization.ts +94 -0
  138. package/src/server/edge.ts +14 -1
  139. package/src/server/endpoint.ts +85 -104
  140. package/src/server/generation.ts +10 -1
  141. package/src/server/harness.ts +71 -13
  142. package/src/server/index.ts +31 -0
  143. package/src/server/mcp/build.ts +20 -1
  144. package/src/server/mcp/client-info.ts +54 -0
  145. package/src/server/mcp/guide.ts +120 -0
  146. package/src/server/mcp/instructions.ts +1 -1
  147. package/src/server/mcp/prompts.ts +7 -3
  148. package/src/server/mcp/resources.ts +16 -8
  149. package/src/server/mcp/tools.ts +9 -3
  150. package/src/server/mcp/visibility.ts +18 -3
  151. package/src/server/oauth.ts +29 -75
  152. package/src/server/read/credential.ts +134 -0
  153. package/src/server/read/deployed.ts +56 -0
  154. package/src/server/read/listener.ts +54 -0
  155. package/src/server/read/open.ts +101 -0
  156. package/src/server/read/routes.ts +247 -0
  157. package/src/server/read/state.ts +171 -0
@@ -1,21 +1,16 @@
1
- import { MCP_PATH, serve } from './index.ts';
1
+ import { MCP_PATH, serve, type RunningServer } from './index.ts';
2
2
  import { serveOverStdio } from './stdio.ts';
3
3
  import { Generations, type OpenedWorkspace } from './generations.ts';
4
4
  import type { AuthorizationSurface } from './oauth.ts';
5
5
  import type { ProfileRuntime } from './mcp/index.ts';
6
- import {
7
- AuthenticatorChain,
8
- IssuedTokenAuthenticator,
9
- OAuthServer,
10
- OAuthStore,
11
- OidcAuthenticator,
12
- OidcVerifier,
13
- tokensMatch,
14
- type Authenticator,
15
- } from '#auth';
6
+ import { AuthenticatorChain } from '#auth';
7
+ import { openAuthorization } from './authorization.ts';
8
+ import { openReadListener } from './read/open.ts';
9
+ import { deployedReadDeps } from './read/deployed.ts';
10
+ import { version } from '#cli/version.ts';
16
11
  import type { Logger } from '#connectivity';
17
12
  import { silentLogger } from './logging.ts';
18
- import { listProfiles } from '#profile';
13
+ import { listProfiles, readConnections } from '#profile';
19
14
  import {
20
15
  applyReconcile,
21
16
  formatPlan,
@@ -82,6 +77,8 @@ export interface EndpointOptions {
82
77
  export interface RunningEndpoint {
83
78
  readonly url: string;
84
79
  readonly profiles: readonly string[];
80
+ /** The dashboard read surface, when this workspace is paired (ADR-063). */
81
+ readonly readUrl?: string | undefined;
85
82
  stop(): Promise<void>;
86
83
  }
87
84
 
@@ -133,21 +130,33 @@ async function openReconciled(options: {
133
130
  }
134
131
  }
135
132
 
136
- for (const [name, runtime] of runtimes) {
137
- const result = await planReconcile(
138
- runtime.config,
139
- runtime.state,
140
- runtime.credentials,
141
- runtime.manifestFor,
142
- );
143
- if (!planIsNoop(result)) {
144
- reporter.reconciled({
145
- profile: name,
146
- plan: formatPlan(result),
147
- ofMany: runtimes.size > 1,
148
- });
149
- await applyReconcile(runtime.config, runtime.state, result);
150
- }
133
+ // Once for the workspace, over every connection the workspace holds.
134
+ //
135
+ // Runtime state is one store per workspace since contract 3, and reconcile
136
+ // disables everything in it that the connection list does not declare — so
137
+ // running it per profile over that profile's *grants* had each pass disable
138
+ // the connections only the other profiles granted. Two profiles was enough:
139
+ // the second pass disabled the first's accounts, every later call was
140
+ // refused `denied_connection_unauthorized`, and restarting flipped which
141
+ // profile survived.
142
+ //
143
+ // The primary's runtime is used for the stores because they are the same
144
+ // stores for every profile here. `workspaceConnections` is the whole list,
145
+ // which is what "undeclared" has to be measured against.
146
+ const declared = primary.workspaceConnections;
147
+ const result = await planReconcile(
148
+ declared,
149
+ primary.state,
150
+ primary.credentials,
151
+ primary.manifestFor,
152
+ );
153
+ if (!planIsNoop(result)) {
154
+ reporter.reconciled({
155
+ profile: primary.resolution.profile,
156
+ plan: formatPlan(result),
157
+ ofMany: false,
158
+ });
159
+ await applyReconcile(declared, primary.state, result);
151
160
  }
152
161
 
153
162
  return { primary, runtimes };
@@ -183,80 +192,6 @@ function closeAll(runtimes: ReadonlyMap<string, Runtime>): Promise<unknown> {
183
192
  return Promise.all([...runtimes.values()].map((runtime) => runtime.close()));
184
193
  }
185
194
 
186
- /**
187
- * The remote-client gate, if this profile declares one.
188
- *
189
- * Endpoint-scoped rather than per profile, like the bearer token and for the
190
- * same reason (ADR-009): one URL serves every profile in the workspace, so
191
- * there is one place a client authorises and one set of tokens.
192
- *
193
- * Returns null when `auth.authorization` is absent, and everything downstream
194
- * treats null as "exactly as before" — no metadata published, no pointer on the
195
- * `401`, one authenticator instead of a chain.
196
- */
197
- async function openAuthorization(
198
- primary: Runtime,
199
- log: Logger,
200
- ): Promise<{ surface: AuthorizationSurface; authenticator: Authenticator } | null> {
201
- const declared = primary.config.auth.authorization;
202
- if (!declared) return null;
203
-
204
- const profile = primary.resolution.profile;
205
-
206
- if (declared.mode === 'oidc') {
207
- const audience = await primary.credentials.get(declared.client_id_ref);
208
- if (!audience) {
209
- // Refuse rather than verify without an audience. A verifier that cannot
210
- // check who a token was issued for accepts every token the issuer minted
211
- // for anything, which is the failure this mode exists to prevent.
212
- throw new Error(
213
- `auth.authorization.client_id_ref names "${declared.client_id_ref}", which is not in ` +
214
- `this target's credential store. Store it with: lanes link secrets set ${declared.client_id_ref}`,
215
- );
216
- }
217
-
218
- const verifier = new OidcVerifier({
219
- issuer: declared.issuer,
220
- audience,
221
- allowedSubjects: declared.allowed_subjects,
222
- ...(declared.introspection_endpoint
223
- ? { introspectionEndpoint: declared.introspection_endpoint }
224
- : {}),
225
- });
226
-
227
- return {
228
- // The issuer is somebody else's origin, so it is a constant here rather
229
- // than derived from the request.
230
- surface: { issuer: () => declared.issuer, mcpPath: MCP_PATH, target: primary.target },
231
- authenticator: new OidcAuthenticator(verifier, profile),
232
- };
233
- }
234
-
235
- const store = new OAuthStore(primary.state.kv);
236
- const expected = primary.config.auth.token_ref;
237
-
238
- const server = new OAuthServer({
239
- store,
240
- accessTokenTtlMs: declared.access_token_ttl_minutes * 60_000,
241
- // So a replayed refresh token leaves a line. It is refused rather than
242
- // acted on (ADR-035), and a refusal nobody can see is how a connector
243
- // losing its authorization came to need log forensics to explain.
244
- log,
245
- // Approval is proof of holding the endpoint token, compared the same way
246
- // the request path compares it. There is one person behind this endpoint
247
- // and they already have exactly one credential; a second one invented for
248
- // the consent screen would be a password to lose.
249
- verifyOwner: async (presented) => {
250
- const token = await primary.credentials.get(expected);
251
- return token !== null && tokensMatch(presented, token);
252
- },
253
- });
254
-
255
- return {
256
- surface: { server, issuer: (origin) => origin, mcpPath: MCP_PATH, target: primary.target },
257
- authenticator: new IssuedTokenAuthenticator(store, profile),
258
- };
259
- }
260
195
 
261
196
  export async function startEndpoint(options: EndpointOptions): Promise<RunningEndpoint> {
262
197
  const reporter = options.reporter ?? SILENT;
@@ -278,13 +213,26 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
278
213
  if (!token) {
279
214
  throw new Error(
280
215
  `No profile token at "${primary.config.auth.token_ref}" in this target's credential store. ` +
281
- 'A deployed instance never mints its own — run `lanes link token rotate --target <target>` ' +
216
+ 'A deployed instance never mints its own — run `lanes link token rotate --workspace <name>` ' +
282
217
  'from your machine, or `lanes link secrets push --from local --to cloud`, then redeploy.',
283
218
  );
284
219
  }
285
220
  }
286
221
 
287
- const gate = await openAuthorization(primary, log);
222
+ // Read through a holder rather than closed over `runtimes`, because a
223
+ // reload replaces that map and the gate is deliberately built once
224
+ // (ADR-029). Without the indirection, a member added after start would
225
+ // stay invisible until the endpoint was restarted — which is precisely the
226
+ // thing `profile members add` tells the operator has taken effect.
227
+ let serving: ReadonlyMap<string, Runtime> = runtimes;
228
+
229
+ const gate = await openAuthorization(primary, log, async (subject) =>
230
+ [...serving]
231
+ .filter(([, runtime]) =>
232
+ runtime.config.members.some((member) => member.subject === subject),
233
+ )
234
+ .map(([name]) => name),
235
+ );
288
236
 
289
237
  // The authenticator and the authorization gate are built once, from the
290
238
  // runtime this endpoint booted with, and are deliberately not part of what
@@ -295,6 +243,7 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
295
243
  { profiles: profileRuntimes(runtimes), close: () => closeAll(runtimes).then(() => {}) },
296
244
  async (): Promise<OpenedWorkspace> => {
297
245
  const reopened = await openReconciled(options);
246
+ serving = reopened.runtimes;
298
247
  return {
299
248
  profiles: profileRuntimes(reopened.runtimes),
300
249
  close: () => closeAll(reopened.runtimes).then(() => {}),
@@ -312,6 +261,12 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
312
261
  { primary: primary.resolution.profile, log, ...(gate ? { remoteClients: true } : {}) },
313
262
  );
314
263
 
264
+ // Read once. `version()` walks up to the install root and parses
265
+ // `package.json`; doing it per request would put a synchronous file read on
266
+ // the read surface's hot path to answer a value that cannot change while
267
+ // this process lives.
268
+ const runningVersion = version();
269
+
315
270
  const server = serve({
316
271
  generations,
317
272
  primary: primary.resolution.profile,
@@ -322,20 +277,46 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
322
277
  ...(gate ? { authorization: gate.surface } : {}),
323
278
  ...(options.port !== undefined ? { port: options.port } : {}),
324
279
  ...(options.host !== undefined ? { host: options.host } : {}),
280
+ // Offered unconditionally and discarded by `serve()` on a loopback bind,
281
+ // which is where every other property of the bind address is decided. It
282
+ // opens nothing and reads no credential, so building it for a bind that
283
+ // will not use it costs a closure (ADR-064).
284
+ read: deployedReadDeps({
285
+ primary,
286
+ profiles: () => generations.current.profiles,
287
+ log,
288
+ version: runningVersion,
289
+ }),
325
290
  });
326
291
 
327
292
  // After `serve()`, so the record means the socket is bound. Recording it
328
293
  // from the constructor claimed an endpoint that a failed bind never served.
329
294
  generations.announce();
330
295
 
296
+ // Only when `lanes link pair` has provisioned all three. Absent, this is
297
+ // simply not served — the read surface is opt-in and its absence is the
298
+ // default (ADR-063), so an endpoint that was never paired binds one port
299
+ // exactly as it always did.
300
+ const read = await openReadListener(
301
+ primary,
302
+ server,
303
+ () => generations.current.profiles,
304
+ log,
305
+ runningVersion,
306
+ );
307
+
331
308
  return {
332
309
  url: server.url,
333
310
  profiles: [...runtimes.keys()],
311
+ ...(read ? { readUrl: read.url } : {}),
334
312
  // `server.stop()` closes the request handler, which closes whichever
335
313
  // generation is current — and a generation owns the runtimes it opened.
336
314
  // Closing `runtimes` here too would reach past a reload and close a set
337
315
  // nothing is serving from any more.
338
- stop: () => server.stop(),
316
+ stop: async () => {
317
+ await read?.stop();
318
+ await server.stop();
319
+ },
339
320
  };
340
321
  } catch (error) {
341
322
  await closeAll(runtimes);
@@ -189,7 +189,16 @@ export class Generation {
189
189
  * a handler outliving its generation is the stale-config bug.
190
190
  */
191
191
  handlerFor(principal: Principal, clientLabel: string | undefined): McpHttpHandler {
192
- const key = `${principal.id}\u0000${clientLabel ?? ''}`;
192
+ // The delegation list is part of the key, not just the identity.
193
+ //
194
+ // `Principal` gained `profiles` this release, and `mergeCapabilities` and
195
+ // `forProfile` both read it off the *captured* principal — so two tokens for
196
+ // one subject with different scopes hashed to one entry and whichever
197
+ // authorized first decided what the other could reach. Removing somebody
198
+ // from a profile and re-authorizing then served them the profile they had
199
+ // just lost, or refused one they still had, depending on order.
200
+ const reach = principal.profiles === undefined ? '*' : [...principal.profiles].sort().join(',');
201
+ const key = `${principal.id}\u0000${reach}\u0000${clientLabel ?? ''}`;
193
202
  const existing = this.#handlers.get(key);
194
203
  if (existing) return existing;
195
204
 
@@ -5,10 +5,11 @@ import {
5
5
  IssuedTokenAuthenticator,
6
6
  OAuthServer,
7
7
  OAuthStore,
8
- tokensMatch,
8
+ type Federation,
9
9
  } from '#auth';
10
10
  import { oneProfile, type ProfileRuntime } from './mcp/index.ts';
11
- import { parseConfig, type Config } from '#profile';
11
+ import {
12
+ type ConnectionConfig, parseConfig, type Config } from '#profile';
12
13
  import { ProviderRegistry, toPolicyDocument } from '#registry';
13
14
  import { Dispatcher } from '#dispatch';
14
15
  import { createMemoryCredentials, createMemoryState } from '#stores/state/testing.ts';
@@ -33,26 +34,58 @@ import { serveOverStdio } from './stdio.ts';
33
34
  * the right calls, and a mocked transport cannot demonstrate that.
34
35
  */
35
36
 
37
+ /**
38
+ * The connections a harness config implies, derived from its grants.
39
+ *
40
+ * A test config declares grants and no `connections.yaml` — there is no
41
+ * workspace on disk to read one from. Deriving the rows from the grant refs
42
+ * keeps the harness honest about the only thing dispatch uses them for, which is
43
+ * resolving `<provider>.<id>` to a provider and an id. Anything richer (an
44
+ * account label, a credential ref) belongs to a real workspace and a test that
45
+ * needs one builds it.
46
+ */
47
+ function harnessConnections(config: Config): ConnectionConfig[] {
48
+ return config.grants.map((grant) => {
49
+ const [provider = '', id = ''] = grant.connection.split('.');
50
+ return { provider, id, account: grant.connection };
51
+ });
52
+ }
53
+
36
54
  export const TEST_TOKEN = 'llk_test_token_value';
37
55
 
56
+ /** A signed-in person no profile lists. See the federation stub below. */
57
+ export const STRANGER = 'NOBODY_LISTS_THIS_PERSON';
58
+
59
+ /**
60
+ * A profile for the harness, from the `allow`/`deny` a test hands over.
61
+ *
62
+ * The two `example` accounts are the point: every test here is about a rule
63
+ * covering both, or one of them, so the harness gives each its own grant row
64
+ * carrying the same rules (ADR-058). That is what the flat block used to mean,
65
+ * which keeps every existing test asserting what it was written to assert.
66
+ */
38
67
  export function configFor(profile: string, port: number, policy: string): Config {
68
+ const rules = policy
69
+ .split('\n')
70
+ .filter((line) => line.trim().length > 0)
71
+ .map((line) => ` ${line.trim()}`)
72
+ .join('\n');
73
+
74
+ const grant = (id: string): string =>
75
+ ` - connection: example.${id}\n${rules.replace(/^ {4}(allow|deny):/gm, ' $1:')}`;
76
+
39
77
  return parseConfig(`
40
- contract: 2
78
+ contract: 3
41
79
  instance:
42
80
  profile: ${profile}
43
81
  port: ${port}
44
82
  limits:
45
83
  requests_per_minute: 1000
46
84
  upstream_calls_per_minute: 1000
47
- connections:
48
- - id: a
49
- provider: example
50
- account: Scratch A
51
- - id: b
52
- provider: example
53
- account: Scratch B
54
- policy:
55
- ${policy}
85
+ grants:
86
+ ${grant('a')}
87
+ ${grant('b')}
88
+ members: []
56
89
  `).config;
57
90
  }
58
91
 
@@ -110,6 +143,15 @@ export interface HarnessOptions {
110
143
  refreshSkills?: (registry: ProviderRegistry) => Promise<void>;
111
144
  /** Serve the `self` authorization flow alongside the bearer token. */
112
145
  authorization?: boolean;
146
+ /**
147
+ * The identity half of that flow, stubbed.
148
+ *
149
+ * The real one talks to lanes.sh and verifies a signature; a test that
150
+ * exercised it would be testing `AssertionVerifier`, which has its own file
151
+ * and its own key pair. What a harness test is about is what the endpoint
152
+ * does *with* an answer, so the answer is injected.
153
+ */
154
+ federation?: Partial<Federation>;
113
155
  /**
114
156
  * What a reload re-reads, standing in for `openReconciled` over a workspace
115
157
  * this harness does not have. Throwing is how the "a failed reload keeps
@@ -162,6 +204,8 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
162
204
 
163
205
  const dispatcher = new Dispatcher({
164
206
  config,
207
+ connections: harnessConnections(config),
208
+ oauthApps: [],
165
209
  registry,
166
210
  connectorFor: (providerId): AnyConnector | undefined => {
167
211
  const entry = registry.get(providerId);
@@ -199,6 +243,8 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
199
243
  policy: extraPolicy,
200
244
  dispatcher: new Dispatcher({
201
245
  config: extraConfig,
246
+ connections: harnessConnections(extraConfig),
247
+ oauthApps: [],
202
248
  registry,
203
249
  connectorFor: (providerId): AnyConnector | undefined => {
204
250
  const entry = registry.get(providerId);
@@ -241,7 +287,19 @@ export function startHarness(options: HarnessOptions): Harness {
241
287
  accessTokenTtlMs: 3_600_000,
242
288
  log,
243
289
  ...(options.now ? { now: options.now } : {}),
244
- verifyOwner: (presented) => Promise.resolve(tokensMatch(presented, token)),
290
+ federation: {
291
+ consentUrl: 'https://lanes.example/link/authorize',
292
+ // Anything non-empty verifies, as the subject it spells. Enough to
293
+ // drive the flow, and obviously not a verifier.
294
+ verify: async (assertion) =>
295
+ assertion ? { subject: `lanes:${assertion}`, email: null } : null,
296
+ // One reserved spelling answers "no profile names them", because
297
+ // that refusal is a real branch — a person signs in successfully
298
+ // and still reaches nothing — and there has to be a way to drive it.
299
+ profilesFor: async (subject) =>
300
+ subject === `lanes:${STRANGER}` ? [] : [options.profile],
301
+ ...options.federation,
302
+ },
245
303
  }),
246
304
  issuer: (origin: string) => origin,
247
305
  mcpPath: '/mcp',
@@ -4,6 +4,7 @@ import { capabilityIdForToolName } from '#server/mcp';
4
4
  import { ATTACHMENTS_PATH, handleAttachments } from './attachments.ts';
5
5
  import { allowedHostnamesFor, rebindingRefusal } from './rebinding.ts';
6
6
  import { ANY_ORIGIN, corsAware, type CorsPolicy } from './cors.ts';
7
+ import { isReadPath, readRoutes, type ReadDeps } from './read/routes.ts';
7
8
  import type { Generation } from './generation.ts';
8
9
  import type { Generations } from './generations.ts';
9
10
  import {
@@ -67,6 +68,16 @@ export interface ServerOptions {
67
68
  * — a page the owner happens to be visiting — before this would be reached.
68
69
  */
69
70
  readonly meterUnauthenticated?: boolean | undefined;
71
+ /**
72
+ * The dashboard's read surface, when this bind may serve it (ADR-064).
73
+ *
74
+ * Another property of the bind address, decided in the same lines of
75
+ * `serve()` as `cors` and the meter. Absent on loopback, where the TLS
76
+ * listener in `./read/open.ts` serves it on its own port instead — a
77
+ * cross-origin grant on `127.0.0.1` is what `./rebinding.ts` refuses
78
+ * outright, and ADR-039's rule is not being relaxed to fit this in.
79
+ */
80
+ readonly read?: ReadDeps | undefined;
70
81
  }
71
82
 
72
83
  export const MCP_PATH = '/mcp';
@@ -155,6 +166,8 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
155
166
  healthPath: HEALTH_PATH,
156
167
  isAuthorizationPath,
157
168
  authorizationEnabled: options.authorization !== undefined,
169
+ isReadPath,
170
+ readEnabled: options.read !== undefined,
158
171
  });
159
172
  if (refusal) {
160
173
  options.log.warn('rejected request', { reason: 'unauthenticated_rate' });
@@ -188,6 +201,17 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
188
201
  });
189
202
  }
190
203
 
204
+ // Above the 404 gate because these are deliberately not in the three-path
205
+ // set, and never through `options.authenticator`: the pairing token is a
206
+ // different credential for a different surface, and one shared check
207
+ // would make each able to do the other's job (ADR-063). Below the meter,
208
+ // because verifying one costs a credential-store read. Only what
209
+ // `isReadPath` matched is handed over — `readRoutes` answers everything
210
+ // it is given, so a wider hand-off would swallow `/mcp`.
211
+ if (options.read && isReadPath(url.pathname)) {
212
+ return await readRoutes(request, options.read);
213
+ }
214
+
191
215
  if (
192
216
  url.pathname !== MCP_PATH &&
193
217
  url.pathname !== ATTACHMENTS_PATH &&
@@ -344,8 +368,15 @@ export function serve(options: ServeOptions): RunningServer {
344
368
  const cors: CorsPolicy | undefined = loopback
345
369
  ? undefined
346
370
  : { allowedOrigins: primary.config.auth.allowed_origins ?? [ANY_ORIGIN] };
371
+ // The fourth property of this bind address, decided with the other three.
372
+ // Never on loopback: `./read/open.ts` serves it there over TLS on its own
373
+ // port, and a deployment-only grant that leaked onto `127.0.0.1` is exactly
374
+ // what ADR-039 refuses. Discarded rather than overridable, as `cors` is.
375
+ const read = loopback ? undefined : options.read;
376
+
347
377
  const handler = createRequestHandler({
348
378
  ...options,
379
+ ...(read ? { read } : { read: undefined }),
349
380
  // Off on loopback for the same reason `cors` is undefined there, and decided
350
381
  // here so every property of the bind address is decided together. An
351
382
  // explicit `true` wins, which is how a test drives the deployed behaviour.
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { isPrompt, isResource, isTool } from '#connectivity';
3
3
  import { SERVER_ICONS } from './icon.ts';
4
+ import { GUIDE_TITLE, GUIDE_URI, guideDocument } from './guide.ts';
4
5
  import { serverInstructions } from './instructions.ts';
5
6
  import { SERVER_NAME } from './naming.ts';
6
7
  import { registerPrompt } from './prompts.ts';
@@ -77,12 +78,30 @@ export function buildMcpServer(options: BuildServerOptions): McpServer {
77
78
  // server — to be told nothing is there.
78
79
  capabilities: {
79
80
  tools: { listChanged: false },
80
- ...(offers(merged, isResource) ? { resources: { listChanged: false } } : {}),
81
+ // Unconditional now: `lanes://instructions` is registered below
82
+ // whatever policy said, so this endpoint always has at least one
83
+ // resource and gating the capability on the merged set would advertise
84
+ // nothing while serving something.
85
+ resources: { listChanged: false },
81
86
  ...(offers(merged, isPrompt) ? { prompts: { listChanged: false } } : {}),
82
87
  },
83
88
  },
84
89
  );
85
90
 
91
+ // Always, and ahead of everything policy decided. This describes the surface
92
+ // rather than exposing any of it, so there is nothing here to grant — and a
93
+ // client whose owner has connected nothing at all still gets an account of
94
+ // what the thing is. It also means `resources` is advertised unconditionally,
95
+ // which `offers` below no longer decides on its own.
96
+ server.registerResource(
97
+ 'instructions',
98
+ GUIDE_URI,
99
+ { title: GUIDE_TITLE, description: 'What this endpoint is and how to behave against it', mimeType: 'text/markdown' },
100
+ async (uri: URL) => ({
101
+ contents: [{ uri: uri.href, mimeType: 'text/markdown', text: guideDocument() }],
102
+ }),
103
+ );
104
+
86
105
  for (const [id, entry] of merged) {
87
106
  // Discovered first: an upstream MCP server or an OpenAPI document supplies
88
107
  // the schema, and there is no local capability object to inspect.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Which agent is calling, read off the request rather than off a header.
3
+ *
4
+ * The audit log's `clientLabel` field has always said it holds "the MCP
5
+ * `clientInfo` name". It did not. Over HTTP it read an `x-mcp-client` header,
6
+ * which is not part of MCP and which no client sends; over a pipe nothing set
7
+ * it at all. So the one field that exists to say *who made this call* was empty
8
+ * on every event this endpoint has ever written.
9
+ *
10
+ * The protocol does carry it. A client announces itself at `initialize` and the
11
+ * SDK repeats that announcement in every later request, in `_meta` under
12
+ * `io.modelcontextprotocol/clientInfo`, which the server surfaces on the
13
+ * request envelope. Reading it there rather than from the handshake is what
14
+ * makes it work at all here: streamable HTTP is stateless on this endpoint, a
15
+ * fresh `McpServer` is built and discarded per request (`build.ts`), and a
16
+ * handshake captured on one instance is gone before the next arrives.
17
+ *
18
+ * **A client that announces itself only at `initialize` and never repeats it is
19
+ * still anonymous**, and that is the honest outcome rather than a gap worth
20
+ * papering over. The SDK does not backfill the envelope from the session, so
21
+ * inferring one would mean this endpoint keeping its own session table to hold
22
+ * a field it is not allowed to trust anyway.
23
+ *
24
+ * **Self-reported, and labelled as such wherever it surfaces.** A client may
25
+ * call itself anything. It is recorded so a reader can see which agent made a
26
+ * call and is never consulted to decide what that agent may do — the same rule
27
+ * the field carried when it was a header, and the reason widening it is safe.
28
+ */
29
+
30
+ /** Where the SDK puts the client's own `Implementation` on each request. */
31
+ const CLIENT_INFO_META_KEY = 'io.modelcontextprotocol/clientInfo';
32
+
33
+ /**
34
+ * The name the client gave for itself, or undefined.
35
+ *
36
+ * Deliberately tolerant. This is untrusted input on a path whose failure mode
37
+ * would otherwise be an exception inside a tool call that was going to succeed,
38
+ * and the worst honest outcome is the empty field that already exists.
39
+ */
40
+ export function clientLabelFrom(extra: unknown): string | undefined {
41
+ if (typeof extra !== 'object' || extra === null) return undefined;
42
+
43
+ const request = (extra as { mcpReq?: unknown }).mcpReq;
44
+ if (typeof request !== 'object' || request === null) return undefined;
45
+
46
+ const envelope = (request as { envelope?: unknown }).envelope;
47
+ if (typeof envelope !== 'object' || envelope === null) return undefined;
48
+
49
+ const info = (envelope as Record<string, unknown>)[CLIENT_INFO_META_KEY];
50
+ if (typeof info !== 'object' || info === null) return undefined;
51
+
52
+ const name = (info as { name?: unknown }).name;
53
+ return typeof name === 'string' && name.length > 0 ? name : undefined;
54
+ }