@lanes-sh/link 0.7.2 → 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 (156) 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/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +88 -87
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +4 -2
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +182 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +21 -9
  42. package/src/cli/commands/knowledge/index.ts +46 -79
  43. package/src/cli/commands/knowledge/migrate.ts +74 -13
  44. package/src/cli/commands/knowledge/show.ts +92 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +29 -12
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +52 -4
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/shared.ts +13 -2
  66. package/src/cli/commands/owner/skills.ts +28 -8
  67. package/src/cli/commands/profile/removal.ts +79 -76
  68. package/src/cli/commands/profile/remove.ts +16 -1
  69. package/src/cli/commands/profile.ts +46 -10
  70. package/src/cli/commands/relabel.ts +112 -0
  71. package/src/cli/commands/secrets.ts +34 -12
  72. package/src/cli/commands/set-workspace.ts +96 -0
  73. package/src/cli/commands/setup.ts +2 -2
  74. package/src/cli/commands/sync.ts +8 -8
  75. package/src/cli/commands/target.ts +9 -7
  76. package/src/cli/commands/update.ts +58 -17
  77. package/src/cli/config-edit.ts +75 -140
  78. package/src/cli/config-migrate.ts +82 -64
  79. package/src/cli/config-repair.ts +89 -32
  80. package/src/cli/config-templates.ts +198 -0
  81. package/src/cli/contract3-data.ts +328 -0
  82. package/src/cli/contract3-shape.ts +186 -0
  83. package/src/cli/contract3.ts +282 -0
  84. package/src/cli/endpoint-url.ts +1 -1
  85. package/src/cli/lanes.ts +25 -1
  86. package/src/cli/main.ts +89 -14
  87. package/src/cli/migrate-plan.ts +12 -6
  88. package/src/cli/output.ts +34 -1
  89. package/src/cli/publish.ts +5 -2
  90. package/src/cli/runtime/open.ts +63 -98
  91. package/src/cli/runtime/registry.ts +6 -7
  92. package/src/cli/runtime/stores.ts +53 -0
  93. package/src/cli/runtime/types.ts +106 -0
  94. package/src/cli/runtime/vault.ts +19 -4
  95. package/src/cli/runtime/workspace.ts +60 -0
  96. package/src/cli/runtime.ts +2 -1
  97. package/src/cli/selection-require.ts +44 -13
  98. package/src/cli/selection.ts +127 -145
  99. package/src/cli/usage.ts +34 -18
  100. package/src/cli/workspace-migrate.ts +125 -16
  101. package/src/connectivity/manifest/provider.ts +3 -1
  102. package/src/connectivity/manifest/requirements.ts +1 -1
  103. package/src/deployments/bind.ts +1 -1
  104. package/src/deployments/deploy.ts +36 -27
  105. package/src/deployments/gcp/bucket.ts +18 -7
  106. package/src/deployments/gcp/provision.ts +7 -7
  107. package/src/deployments/prepare.ts +72 -24
  108. package/src/deployments/record.ts +1 -1
  109. package/src/deployments/report.ts +2 -2
  110. package/src/deployments/serving.ts +15 -74
  111. package/src/deployments/target.ts +15 -15
  112. package/src/deployments/upload.ts +46 -22
  113. package/src/dispatch/deps.ts +88 -0
  114. package/src/dispatch/dispatch.ts +21 -62
  115. package/src/policy/index.ts +47 -15
  116. package/src/profile/connections.ts +183 -0
  117. package/src/profile/deployments.ts +3 -3
  118. package/src/profile/index.ts +30 -5
  119. package/src/profile/layout.ts +86 -89
  120. package/src/profile/load.ts +80 -47
  121. package/src/profile/pairing.ts +32 -0
  122. package/src/profile/primitives.ts +35 -1
  123. package/src/profile/registry.ts +6 -6
  124. package/src/profile/schema.ts +172 -21
  125. package/src/profile/targets.ts +21 -9
  126. package/src/profile/testing.ts +69 -2
  127. package/src/profile/workspace.ts +58 -3
  128. package/src/providers/custom/index.ts +1 -1
  129. package/src/providers/custom/load.ts +2 -3
  130. package/src/providers/identity/provider.ts +1 -1
  131. package/src/providers/memory/provider.ts +20 -2
  132. package/src/providers/setup/plan.ts +1 -1
  133. package/src/providers/slack/index.ts +2 -2
  134. package/src/registry/policy-bridge.ts +33 -11
  135. package/src/registry/reconcile.ts +4 -4
  136. package/src/server/authorization.ts +94 -0
  137. package/src/server/edge.ts +14 -1
  138. package/src/server/endpoint.ts +85 -104
  139. package/src/server/generation.ts +10 -1
  140. package/src/server/harness.ts +71 -13
  141. package/src/server/index.ts +31 -0
  142. package/src/server/mcp/build.ts +20 -1
  143. package/src/server/mcp/client-info.ts +54 -0
  144. package/src/server/mcp/guide.ts +120 -0
  145. package/src/server/mcp/instructions.ts +1 -1
  146. package/src/server/mcp/prompts.ts +7 -3
  147. package/src/server/mcp/resources.ts +16 -8
  148. package/src/server/mcp/tools.ts +9 -3
  149. package/src/server/mcp/visibility.ts +18 -3
  150. package/src/server/oauth.ts +29 -109
  151. package/src/server/read/credential.ts +134 -0
  152. package/src/server/read/deployed.ts +56 -0
  153. package/src/server/read/listener.ts +54 -0
  154. package/src/server/read/open.ts +101 -0
  155. package/src/server/read/routes.ts +247 -0
  156. package/src/server/read/state.ts +171 -0
@@ -0,0 +1,120 @@
1
+ import { RESERVED_PROVIDER_IDS } from '#connectivity';
2
+
3
+ /**
4
+ * The long form of what this endpoint is, served as a resource.
5
+ *
6
+ * `initialize.instructions` has a budget it pays on every request forever, so it
7
+ * carries only the habits that must arrive everywhere. This is the document a
8
+ * client reads once when it wants the whole account, and it is served always,
9
+ * without a tool grant, because it describes the surface rather than exposing
10
+ * any of it.
11
+ *
12
+ * **Why a resource rather than a longer `instructions`.** A client caches
13
+ * `initialize` for the life of its registration, so guidance improved after
14
+ * somebody connected never reaches them. A resource is fetched when it is read,
15
+ * so this can be corrected without anybody reconnecting.
16
+ *
17
+ * **No vendor may be named here.** `src/architecture.test.ts` forbids it
18
+ * anywhere under `server/`, and rightly: what an owner has connected is theirs,
19
+ * and prose naming one provider would be wrong for everybody else.
20
+ */
21
+
22
+ export const GUIDE_URI = 'lanes://instructions';
23
+
24
+ export const GUIDE_TITLE = 'How this endpoint works';
25
+
26
+ /**
27
+ * The document.
28
+ *
29
+ * Fixed prose, deliberately: it describes the model rather than this workspace,
30
+ * and the per-principal facts are in `initialize.instructions`, which is
31
+ * computed. Two documents with different lifetimes, and mixing them would make
32
+ * this one wrong the moment a connection was added.
33
+ */
34
+ export function guideDocument(): string {
35
+ return `# Lanes Link
36
+
37
+ One endpoint in front of everything its owner has chosen to expose. It
38
+ authenticates, applies permissions per call, and records what happened.
39
+
40
+ ## The three words
41
+
42
+ **Connection.** One authorised account or store, named \`<provider>.<id>\`. It
43
+ belongs to the workspace, so the same account can be reached from more than one
44
+ profile without being authorised twice.
45
+
46
+ **Profile.** A selection of connections, with the capabilities allowed on each,
47
+ and the people who may consume it. This is how someone keeps work and personal
48
+ apart, and how they hand you one mailbox read-only while another is writable.
49
+
50
+ **Workspace.** Where connections and profiles live, and which credential store
51
+ opens them. You never name one; the endpoint you are talking to is already in it.
52
+
53
+ ## Routing
54
+
55
+ Every tool takes \`profile\` and \`connection\`. Both are enums, and both are
56
+ already narrowed to what you may reach, so anything offered is something you are
57
+ allowed to use.
58
+
59
+ **When it is ambiguous which profile is meant, ask.** Do not default to whichever
60
+ is listed first. A profile is a boundary somebody drew on purpose, and crossing
61
+ it is the failure this design exists to prevent.
62
+
63
+ A connection belongs to a profile. Naming one from a different profile is
64
+ refused, and the refusal lists what is available where you asked.
65
+
66
+ ## The owner's own material
67
+
68
+ ${RESERVED_PROVIDER_IDS.join(', ')} are not third-party services. They are the
69
+ owner's own stores, and each is a connection like any other, so a profile decides
70
+ which instances it can reach and what it may do with them.
71
+
72
+ **Memory and tasks are different stores.** Search memory before concluding you do
73
+ not know something about this person or their work. A thing to *do* is a task,
74
+ and it has a status; "remember to..." is a task, not a memory. Both are served
75
+ back to every later session, so write when asked rather than by habit.
76
+
77
+ **Skills are the owner's procedures**, surfaced as prompts rather than tools.
78
+ That is deliberate: a procedure is selected by the person, not chosen by the
79
+ model, and you cannot read one's body. If a task has a skill for it, say so and
80
+ let them invoke it rather than improvising your own version.
81
+
82
+ **Vault values are credentials.** Use one to do the thing that needs it. Do not
83
+ quote it back, summarise it, or write it anywhere.
84
+
85
+ **Entities are the people, companies and projects this owner deals with.** Before
86
+ using anyone's address or handle, look them up. A lookup returns every match and
87
+ never chooses: more than one means ask which is meant, not take the first.
88
+
89
+ ## What is set up is answerable
90
+
91
+ Before saying something cannot be reached, or that an account must be added, ask
92
+ the setup surface. It reports what exists and what is missing, and it gives the
93
+ exact command for the missing thing.
94
+
95
+ **Running that command is the owner's to do, and inventing one is not.** Every
96
+ change to what exists here is a command a person runs; nothing on this surface
97
+ adds a connection, edits a profile, or changes who may consume it. That is not an
98
+ omission to work around.
99
+
100
+ ## When a call is refused
101
+
102
+ A refusal is information, not an obstacle. Three kinds, and they mean different
103
+ things:
104
+
105
+ - **The profile is not available.** You are not a member of it. Nothing you can
106
+ do; its owner adds you.
107
+ - **The connection is not part of this profile.** Look at what is, in the enum.
108
+ - **The capability is denied.** The profile allows a narrower set than the tool
109
+ suggests. Say what was refused and let the owner decide whether to widen it.
110
+
111
+ Retrying a refusal unchanged produces the same refusal and one more line in the
112
+ owner's audit log.
113
+
114
+ ## Files are named, not carried
115
+
116
+ Where a tool takes attachments, give a path, an HTTPS URL, or an attachment
117
+ already on another message. Do not base64 a file into an argument: it is recorded
118
+ in the audit log, and the log is something the owner reads.
119
+ `;
120
+ }
@@ -53,7 +53,7 @@ import type { MergedCapability } from './visibility.ts';
53
53
  */
54
54
  const OPENING = `This endpoint is one place to reach what its owner has chosen to expose. It
55
55
  authenticates, applies permissions, and records what happened, so you do not
56
- have to.`;
56
+ have to. Read \`lanes://instructions\` for the whole account of how it works.`;
57
57
 
58
58
  const ROUTING = `**Routing.** Every tool takes \`profile\` and \`connection\`. A profile is how
59
59
  someone separates work from personal — when it is ambiguous which one is meant,
@@ -1,3 +1,5 @@
1
+ import { forProfile } from '#auth';
2
+ import { clientLabelFrom } from './client-info.ts';
1
3
  import type { McpServer } from '@modelcontextprotocol/server';
2
4
  import { z } from 'zod';
3
5
  import { isPromptResult } from '#connectivity';
@@ -48,17 +50,19 @@ export function registerPrompt(
48
50
  description: describeWithConnections(capability.description, entry.reachable),
49
51
  argsSchema: z.object(shape),
50
52
  },
51
- async (args: Record<string, unknown>) => {
53
+ async (args: Record<string, unknown>, extra?: unknown) => {
52
54
  const { profile, connection, ...rest } = args;
53
55
  const scope = resolveScope(entry, profile, connection);
54
56
  if ('error' in scope) throw new Error(scope.error);
55
57
 
58
+ const label = clientLabelFrom(extra) ?? options.clientLabel;
59
+
56
60
  const outcome = await options.profiles.get(scope.profile)!.dispatcher.invoke({
57
- principal: options.principal,
61
+ principal: forProfile(options.principal, scope.profile),
58
62
  capabilityId: id,
59
63
  connectionKey: scope.connectionKey,
60
64
  arguments: rest,
61
- clientLabel: options.clientLabel,
65
+ ...(label ? { clientLabel: label } : {}),
62
66
  });
63
67
 
64
68
  // A prompt has no `isError` to carry a refusal in, so a denial is a
@@ -1,3 +1,5 @@
1
+ import { forProfile } from '#auth';
2
+ import { clientLabelFrom } from './client-info.ts';
1
3
  import { ResourceTemplate, type McpServer } from '@modelcontextprotocol/server';
2
4
  import { isResourceListResult, isResourceResult } from '#connectivity';
3
5
  import type { DispatchOutcome } from '#dispatch';
@@ -28,14 +30,20 @@ export function registerResource(
28
30
  const scope = { profile, connectionId };
29
31
  const scoped = scopeResourceUri(capability.uriTemplate, scope);
30
32
 
31
- const dispatch = (args: Record<string, unknown>): Promise<DispatchOutcome> =>
32
- runtime.dispatcher.invoke({
33
- principal: options.principal,
33
+ const dispatch = (
34
+ args: Record<string, unknown>,
35
+ extra?: unknown,
36
+ ): Promise<DispatchOutcome> => {
37
+ const label = clientLabelFrom(extra) ?? options.clientLabel;
38
+
39
+ return runtime.dispatcher.invoke({
40
+ principal: forProfile(options.principal, profile),
34
41
  capabilityId: id,
35
42
  connectionKey,
36
43
  arguments: args,
37
- clientLabel: options.clientLabel,
44
+ ...(label ? { clientLabel: label } : {}),
38
45
  });
46
+ };
39
47
 
40
48
  const metadata = {
41
49
  description: `${capability.description} (${profile}: ${connectionKey})`,
@@ -44,8 +52,8 @@ export function registerResource(
44
52
 
45
53
  // `uri` is the whole argument — the provider recovers its own template
46
54
  // variables from it and never sees the routing segments core prepended.
47
- const read = async (uri: URL) => {
48
- const outcome = await dispatch({ uri: uri.href });
55
+ const read = async (uri: URL, _variables?: unknown, extra?: unknown) => {
56
+ const outcome = await dispatch({ uri: uri.href }, extra);
49
57
  if (!outcome.ok) throw new Error(outcome.message);
50
58
  if (!isResourceResult(outcome.result)) {
51
59
  throw new Error(`${id} did not return resource contents`);
@@ -83,8 +91,8 @@ export function registerResource(
83
91
  // rather than an oversight. A provider omits `list` when its resource
84
92
  // space is unbounded.
85
93
  list: capability.list
86
- ? async () => {
87
- const outcome = await dispatch({});
94
+ ? async (extra?: unknown) => {
95
+ const outcome = await dispatch({}, extra);
88
96
  if (!outcome.ok) throw new Error(outcome.message);
89
97
  if (!isResourceListResult(outcome.result)) return { resources: [] };
90
98
 
@@ -1,6 +1,8 @@
1
+ import { forProfile } from '#auth';
1
2
  import { fromJsonSchema, type McpServer } from '@modelcontextprotocol/server';
2
3
  import { z } from 'zod';
3
4
  import { isToolResult } from '#connectivity';
5
+ import { clientLabelFrom } from './client-info.ts';
4
6
  import { toolNameFor } from './naming.ts';
5
7
  import { resourceLinkRouter } from './routing.ts';
6
8
  import { sanitizeSchema } from './schema.ts';
@@ -111,9 +113,13 @@ export function registerLocalTool(
111
113
  function makeHandler(capabilityId: string, entry: MergedCapability, options: BuildServerOptions) {
112
114
  // `unknown` because the JSON-Schema overload types it that way; the schema
113
115
  // has already validated the shape by the time this runs.
114
- return async (args: unknown) => {
116
+ return async (args: unknown, extra?: unknown) => {
115
117
  const { profile, connection, ...rest } = (args ?? {}) as Record<string, unknown>;
116
118
 
119
+ // The request first, then whatever the transport knew before the call —
120
+ // which on a stateless POST is nothing, and is why the request is asked.
121
+ const label = clientLabelFrom(extra) ?? options.clientLabel;
122
+
117
123
  const name = String(profile);
118
124
  const runtime = options.profiles.get(name);
119
125
  const reachable = entry.reachable.get(name);
@@ -147,11 +153,11 @@ function makeHandler(capabilityId: string, entry: MergedCapability, options: Bui
147
153
  }
148
154
 
149
155
  const outcome = await runtime.dispatcher.invoke({
150
- principal: options.principal,
156
+ principal: forProfile(options.principal, name),
151
157
  capabilityId,
152
158
  connectionKey: String(connection),
153
159
  arguments: rest,
154
- clientLabel: options.clientLabel,
160
+ ...(label ? { clientLabel: label } : {}),
155
161
  });
156
162
 
157
163
  if (!outcome.ok) {
@@ -3,8 +3,9 @@ import type { Principal } from '#auth';
3
3
  import type { Config } from '#profile';
4
4
  import type { ProviderRegistry } from '#registry';
5
5
  import type { Dispatcher } from '#dispatch';
6
- import type { PolicyDocument } from '#policy';
6
+ import type { PolicyDocument, ProfilePolicy } from '#policy';
7
7
  import { allowedConnections } from '#policy';
8
+ import { mayReach } from '#auth';
8
9
 
9
10
  /**
10
11
  * What this principal can see, and therefore what gets registered at all.
@@ -25,7 +26,7 @@ export interface ProfileRuntime {
25
26
  readonly config: Config;
26
27
  readonly registry: ProviderRegistry;
27
28
  readonly dispatcher: Dispatcher;
28
- readonly policy: PolicyDocument;
29
+ readonly policy: ProfilePolicy;
29
30
  readonly floor?: PolicyDocument | undefined;
30
31
  /**
31
32
  * Re-read the skills into `registry`, if they have changed on the store.
@@ -72,8 +73,16 @@ export function oneProfile(
72
73
  return new Map([[name, runtime]]);
73
74
  }
74
75
 
76
+ /**
77
+ * The connections this profile can reach at all, before policy narrows further.
78
+ *
79
+ * The grant rows *are* the answer (ADR-058). A profile reaches what it grants
80
+ * and nothing else, so this needs no view of the workspace's connections — which
81
+ * is the useful half of decoupling them: what a profile can see is written in
82
+ * the profile, and cannot widen when somebody connects a new account.
83
+ */
75
84
  function connectionsOf(runtime: ProfileRuntime): string[] {
76
- return runtime.config.connections.map((connection) => `${connection.provider}.${connection.id}`);
85
+ return runtime.config.grants.map((grant) => grant.connection);
77
86
  }
78
87
 
79
88
  /**
@@ -94,6 +103,12 @@ export function mergeCapabilities(options: BuildServerOptions): Map<string, Merg
94
103
  const merged = new Map<string, MergedCapability>();
95
104
 
96
105
  for (const [name, runtime] of options.profiles) {
106
+ // The same list the dispatcher enforces with. A member does not merely fail
107
+ // to call a profile they are not on — it is absent from the `profile` enum,
108
+ // so they never learn it exists (ADR-060). Discovery and enforcement share
109
+ // one answer here for the same reason they share `allowedConnections`.
110
+ if (!mayReach(options.principal, name)) continue;
111
+
97
112
  const connections = connectionsOf(runtime);
98
113
 
99
114
  for (const { id, capability, discovered } of runtime.registry.capabilities()) {
@@ -1,11 +1,10 @@
1
1
  import {
2
2
  authorizationServerMetadata,
3
3
  protectedResourceMetadata,
4
- type AuthorizeRequest,
5
4
  type OAuthResult,
6
5
  type OAuthServer,
7
6
  } from '#auth';
8
- import { approvalPage } from '#cli/callback-page.ts';
7
+ import { noticePage } from '#cli/callback-page.ts';
9
8
 
10
9
  /**
11
10
  * The HTTP surface of the authorization flow.
@@ -24,6 +23,7 @@ export const PROTECTED_RESOURCE_PATH = '/.well-known/oauth-protected-resource';
24
23
  export const AUTHORIZATION_SERVER_PATH = '/.well-known/oauth-authorization-server';
25
24
  const REGISTER_PATH = '/register';
26
25
  const AUTHORIZE_PATH = '/authorize';
26
+ const CALLBACK_PATH = '/authorize/callback';
27
27
  const TOKEN_PATH = '/token';
28
28
 
29
29
  export interface AuthorizationSurface {
@@ -50,6 +50,7 @@ export function isAuthorizationPath(pathname: string): boolean {
50
50
  pathname === AUTHORIZATION_SERVER_PATH ||
51
51
  pathname === REGISTER_PATH ||
52
52
  pathname === AUTHORIZE_PATH ||
53
+ pathname === CALLBACK_PATH ||
53
54
  pathname === TOKEN_PATH
54
55
  );
55
56
  }
@@ -125,35 +126,37 @@ export async function handleAuthorization(
125
126
  if (!server) return new Response('Not found', { status: 404 });
126
127
 
127
128
  if (path === REGISTER_PATH && request.method === 'POST') {
128
- return render(await server.register(await safeJson(request)), request, surface.target);
129
+ return render(await server.register(await safeJson(request)), request);
129
130
  }
130
131
 
131
- if (path === AUTHORIZE_PATH) {
132
- if (request.method === 'GET') {
133
- return render(await server.authorize(url.searchParams), request, surface.target);
134
- }
135
- if (request.method === 'POST') {
136
- const form = new URLSearchParams(await request.text());
137
- return render(
138
- await server.approve(requestFromForm(form), form.get('token') ?? ''),
139
- request,
140
- surface.target,
141
- );
142
- }
132
+ // Who this endpoint is, from the point of view of *this* request. Derived
133
+ // from `Host` rather than config for the reason `publicOrigin` gives: a
134
+ // deployed instance's hostname is assigned at deploy time, and an assertion
135
+ // whose audience does not match exactly is refused.
136
+ const endpoint = {
137
+ resource: `${origin}${surface.mcpPath}`,
138
+ callbackUrl: `${origin}${CALLBACK_PATH}`,
139
+ };
140
+
141
+ if (path === AUTHORIZE_PATH && request.method === 'GET') {
142
+ return render(await server.authorize(url.searchParams, endpoint), request);
143
+ }
144
+
145
+ // The browser returning from lanes.sh. A GET, because it arrives as a
146
+ // top-level navigation from a 302 — which is also why nothing here reads
147
+ // `Origin`: a navigation carries none. See `rebinding.ts`.
148
+ if (path === CALLBACK_PATH && request.method === 'GET') {
149
+ return render(await server.callback(url.searchParams, endpoint), request);
143
150
  }
144
151
 
145
152
  if (path === TOKEN_PATH && request.method === 'POST') {
146
- return render(
147
- await server.token(new URLSearchParams(await request.text())),
148
- request,
149
- surface.target,
150
- );
153
+ return render(await server.token(new URLSearchParams(await request.text())), request);
151
154
  }
152
155
 
153
156
  return new Response('Method not allowed', { status: 405 });
154
157
  }
155
158
 
156
- function render(result: OAuthResult, request: Request, target: string): Response {
159
+ function render(result: OAuthResult, request: Request): Response {
157
160
  switch (result.kind) {
158
161
  case 'json':
159
162
  return json(result.body, result.status);
@@ -161,95 +164,12 @@ function render(result: OAuthResult, request: Request, target: string): Response
161
164
  case 'redirect':
162
165
  return new Response(null, { status: 302, headers: { location: result.location } });
163
166
 
164
- case 'consent':
165
- return approvalPage({
166
- // The name if it gave one, the identifier if not. Either way the
167
- // redirect host goes on the screen beside it — a client may call itself
168
- // anything, but it cannot change where the code is sent.
169
- client: result.clientName ?? result.request.clientId,
170
- redirectHost: hostOf(result.request.redirectUri),
171
- // The page's policy has to admit the redirect the page's own approval
172
- // ends in, or the browser blocks it. See `formActionFor`.
173
- ...formActionFor(result.request.redirectUri),
174
- action: `${publicOrigin(request)}${AUTHORIZE_PATH}`,
175
- fields: formFromRequest(result.request),
176
- retry: result.retry,
177
- target,
178
- });
179
-
180
167
  case 'error':
181
- return new Response(result.message, { status: result.status });
182
- }
183
- }
184
-
185
- /**
186
- * The authorization request, carried through the approval form.
187
- *
188
- * Round-tripped through hidden fields rather than held in a server-side session:
189
- * the deployed endpoint replaces instances between requests, so a session begun
190
- * on one and submitted to another would be gone. Nothing here is a secret — the
191
- * client sent all of it in the query string — and none of it is trusted on the
192
- * way back, because `approve` re-checks the client and the redirect URI against
193
- * what is registered before it mints anything.
194
- */
195
- function formFromRequest(request: AuthorizeRequest): Record<string, string> {
196
- return {
197
- client_id: request.clientId,
198
- redirect_uri: request.redirectUri,
199
- code_challenge: request.codeChallenge,
200
- scope: request.scope,
201
- ...(request.state !== undefined ? { state: request.state } : {}),
202
- ...(request.resource !== undefined ? { resource: request.resource } : {}),
203
- };
204
- }
205
-
206
- function requestFromForm(form: URLSearchParams): AuthorizeRequest {
207
- return {
208
- clientId: form.get('client_id') ?? '',
209
- redirectUri: form.get('redirect_uri') ?? '',
210
- codeChallenge: form.get('code_challenge') ?? '',
211
- scope: form.get('scope') ?? '',
212
- state: form.get('state') ?? undefined,
213
- resource: form.get('resource') ?? undefined,
214
- };
215
- }
216
-
217
- function hostOf(uri: string): string {
218
- try {
219
- return new URL(uri).host;
220
- } catch {
221
- return uri;
222
- }
223
- }
224
-
225
- /**
226
- * The redirect target as a CSP source, for the consent page's `form-action`.
227
- *
228
- * Chrome and Safari check that directive against the redirect a form submission
229
- * produces, so the page has to name where its own approval is about to send the
230
- * browser — `'self'` alone mints the code and then blocks its delivery.
231
- *
232
- * Taken from the request being approved rather than from what the client
233
- * registered, because the two legitimately differ: a native client registers
234
- * `http://localhost/callback` and binds whatever port it got (RFC 8252), and
235
- * the origin the browser navigates to is the one carrying that port. It is
236
- * already checked against the registration — by `authorize` before this page is
237
- * rendered, and again by `approve` before anything is minted — and it cannot
238
- * move the token, because the form's `action` is built here rather than read
239
- * from the request.
240
- *
241
- * An origin and nothing else, because `isSafeRedirect` registers nothing else:
242
- * https, or http on loopback. A private-use scheme — `vscode:`, the other shape
243
- * RFC 8252 allows — would need a scheme-source here, and is refused two steps
244
- * earlier, so a branch for it would be a branch nothing can reach.
245
- */
246
- function formActionFor(uri: string): { formAction?: string } {
247
- try {
248
- const { protocol, origin } = new URL(uri);
249
- if (protocol !== 'http:' && protocol !== 'https:') return {};
250
- return { formAction: origin };
251
- } catch {
252
- return {};
168
+ // A page rather than a bare string, because the audience changed. These
169
+ // used to be read by a client following a redirect; now the interesting
170
+ // ones — "no profile lists you" — are read by a person in a browser who
171
+ // has just signed in and needs to know what to do next.
172
+ return noticePage(result.message, result.status);
253
173
  }
254
174
  }
255
175
 
@@ -0,0 +1,134 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+
3
+ /**
4
+ * Whether a presented credential is this workspace's pairing token.
5
+ *
6
+ * A verifier rather than the token itself, because the two binds pay very
7
+ * different prices for the answer and only the verifier can know that. On
8
+ * loopback the credential store holds a decrypted copy and a read is a map
9
+ * lookup; on a deployed workspace `GcpSecretManagerStore` has no cache at all
10
+ * and every `get()` is a network round trip. A shared `token: () => Promise`
11
+ * thunk hid that difference behind one signature, and the comment that used to
12
+ * sit above it — "this is a map lookup in the ordinary case" — was true of one
13
+ * store and false of the other.
14
+ */
15
+ export interface PairingCredential {
16
+ /** Never throws. A store that failed is a refusal, not a `500`. */
17
+ verify(presented: string): Promise<boolean>;
18
+ }
19
+
20
+ /**
21
+ * Constant-time, after a length check.
22
+ *
23
+ * The length is compared first and separately because `timingSafeEqual` throws
24
+ * on a mismatch rather than returning false. The length of a token is not the
25
+ * secret; its contents are.
26
+ */
27
+ function matches(presented: string, expected: string): boolean {
28
+ const a = Buffer.from(presented);
29
+ const b = Buffer.from(expected);
30
+ return a.length === b.length && timingSafeEqual(a, b);
31
+ }
32
+
33
+ export interface CredentialSource {
34
+ /** `null` for a secret that exists with no version — the never-paired case. */
35
+ readonly read: () => Promise<string | null>;
36
+ /** Drops a cached decrypted copy, because the rotation was written elsewhere. */
37
+ readonly refresh?: (() => void) | undefined;
38
+ /** Why a read failed, for an operator. Never the credential itself. */
39
+ readonly onError?: ((reason: string) => void) | undefined;
40
+ }
41
+
42
+ function reasonOf(error: unknown): string {
43
+ return error instanceof Error ? error.message : String(error);
44
+ }
45
+
46
+ /**
47
+ * Loopback: read on every presentation, so a rotation lands immediately.
48
+ *
49
+ * `pair --rotate` says "the previous pairing link no longer works", and captured
50
+ * at boot that was false — the live listener went on accepting the old token
51
+ * until the endpoint restarted. Reading per request is what makes the command
52
+ * tell the truth, and it is affordable here because the store is a local file.
53
+ */
54
+ export function directPairingCredential(source: CredentialSource): PairingCredential {
55
+ return {
56
+ async verify(presented) {
57
+ try {
58
+ source.refresh?.();
59
+ const expected = await source.read();
60
+ return expected !== null && expected !== '' && matches(presented, expected);
61
+ } catch (error) {
62
+ source.onError?.(reasonOf(error));
63
+ return false;
64
+ }
65
+ },
66
+ };
67
+ }
68
+
69
+ /** How long a deployed endpoint may keep an answer. Matches the bearer's own window. */
70
+ const CACHE_TTL_MS = 5_000;
71
+
72
+ /**
73
+ * Deployed: one cached read, and a mismatch buys exactly one more.
74
+ *
75
+ * The same trade `BearerAuthenticator` already takes for the MCP bearer, over a
76
+ * strictly weaker credential and with the same five seconds. Without it a
77
+ * dashboard polling `/state` is one Secret Manager call per poll for as long as
78
+ * the page is open, and a stranger sending a wrong token is one call per
79
+ * request — which is the ADR-054 hazard in its purest form, a costly read
80
+ * performed on behalf of a caller who has presented nothing valid.
81
+ *
82
+ * What the re-read on mismatch preserves is the property that mattered: a
83
+ * token rotated *in* works on its first presentation, because a mismatch
84
+ * against a cached value is ambiguous and exactly one re-read separates
85
+ * "rotated" from "wrong". What it costs is that a token rotated *away* keeps
86
+ * reading for up to the window rather than stopping at once — bounded, where
87
+ * the failure `open.ts` records was unbounded until a restart.
88
+ */
89
+ export function cachedPairingCredential(
90
+ source: CredentialSource & { readonly ttlMs?: number; readonly now?: () => number },
91
+ ): PairingCredential {
92
+ const ttl = source.ttlMs ?? CACHE_TTL_MS;
93
+ const now = source.now ?? (() => Date.now());
94
+
95
+ let cached: string | null = null;
96
+ // Both start "infinitely stale", so the first call reads and the first
97
+ // mismatch is always given its one re-read.
98
+ let readAt = -Infinity;
99
+ let missAt = -Infinity;
100
+
101
+ const reread = async (): Promise<void> => {
102
+ source.refresh?.();
103
+ cached = await source.read();
104
+ readAt = now();
105
+ };
106
+
107
+ const hit = (presented: string): boolean =>
108
+ cached !== null && cached !== '' && matches(presented, cached);
109
+
110
+ return {
111
+ async verify(presented) {
112
+ try {
113
+ if (now() - readAt >= ttl) await reread();
114
+ if (hit(presented)) return true;
115
+
116
+ // A miss against a cached value is ambiguous: the token may be wrong,
117
+ // or it may be the one a rotation has just written. One re-read tells
118
+ // them apart — and **one per window**, tracked separately from the
119
+ // ordinary refresh above. Keying it on `readAt` instead meant the
120
+ // re-read refreshed the very clock that decided whether to re-read, so
121
+ // every wrong guess bought its own Secret Manager call and the ceiling
122
+ // this cache exists to impose was not there at all.
123
+ if (now() - missAt < ttl) return false;
124
+ missAt = now();
125
+ await reread();
126
+
127
+ return hit(presented);
128
+ } catch (error) {
129
+ source.onError?.(reasonOf(error));
130
+ return false;
131
+ }
132
+ },
133
+ };
134
+ }