@lanes-sh/link 0.2.2 → 0.3.1

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 (143) hide show
  1. package/README.md +22 -8
  2. package/instructions/skills/lanes-link/SKILL.md +42 -14
  3. package/package.json +1 -1
  4. package/src/cli/argv.ts +50 -0
  5. package/src/cli/brand.ts +178 -0
  6. package/src/cli/callback-page.ts +108 -128
  7. package/src/cli/commands/connect/accounts.ts +5 -0
  8. package/src/cli/commands/connect/assertion.ts +187 -0
  9. package/src/cli/commands/connect/authorise.ts +61 -17
  10. package/src/cli/commands/connect/client.ts +37 -9
  11. package/src/cli/commands/connect/discover.ts +94 -0
  12. package/src/cli/commands/connect/family.ts +72 -0
  13. package/src/cli/commands/connect/index.ts +113 -115
  14. package/src/cli/commands/connect/method.ts +237 -0
  15. package/src/cli/commands/connect/outcome.ts +42 -1
  16. package/src/cli/commands/connect/pasted-token.ts +66 -0
  17. package/src/cli/commands/connect/requirements.ts +60 -8
  18. package/src/cli/commands/connect/setup.ts +16 -5
  19. package/src/cli/commands/connect/target-note.ts +34 -0
  20. package/src/cli/commands/identity.ts +258 -0
  21. package/src/cli/commands/knowledge/index.ts +390 -0
  22. package/src/cli/commands/knowledge/migrate.ts +180 -0
  23. package/src/cli/commands/knowledge/setup.ts +144 -0
  24. package/src/cli/commands/knowledge.ts +10 -0
  25. package/src/cli/commands/mcp/harnesses.ts +16 -2
  26. package/src/cli/commands/mcp/register.ts +9 -1
  27. package/src/cli/commands/mcp/stdio.ts +21 -0
  28. package/src/cli/commands/operate/dashboard.ts +107 -0
  29. package/src/cli/commands/operate/findings.ts +151 -0
  30. package/src/cli/commands/operate/inspect.ts +63 -164
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/policy.ts +7 -7
  33. package/src/cli/commands/operate/serve.ts +3 -0
  34. package/src/cli/commands/operate/status.ts +108 -1
  35. package/src/cli/commands/operate/token.ts +1 -1
  36. package/src/cli/commands/operate.ts +2 -0
  37. package/src/cli/commands/profile/declare.ts +154 -0
  38. package/src/cli/commands/profile/removal.ts +17 -0
  39. package/src/cli/commands/profile/remove.ts +5 -5
  40. package/src/cli/commands/profile.ts +83 -35
  41. package/src/cli/commands/secrets.ts +6 -6
  42. package/src/cli/commands/setup.ts +22 -6
  43. package/src/cli/commands/sync.ts +262 -0
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +53 -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/dispatch-owner.ts +93 -0
  50. package/src/cli/identity.ts +12 -1
  51. package/src/cli/main.ts +90 -61
  52. package/src/cli/nearest.ts +45 -0
  53. package/src/cli/oauth-callback.ts +187 -0
  54. package/src/cli/oauth-exchange.ts +57 -15
  55. package/src/cli/oauth.ts +67 -177
  56. package/src/cli/output.ts +21 -5
  57. package/src/cli/provider-marks.ts +45 -0
  58. package/src/cli/runtime/open.ts +74 -51
  59. package/src/cli/runtime/registry.ts +60 -2
  60. package/src/cli/runtime/select.ts +26 -13
  61. package/src/cli/runtime/vault.ts +61 -0
  62. package/src/cli/runtime.ts +2 -1
  63. package/src/cli/selection.ts +365 -0
  64. package/src/cli/usage.ts +42 -11
  65. package/src/connectivity/auth/README.md +7 -1
  66. package/src/connectivity/auth/basic/index.ts +1 -1
  67. package/src/connectivity/auth/index.ts +14 -0
  68. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  69. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  70. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  71. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  72. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  73. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  74. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  75. package/src/connectivity/auth/resolve.ts +1 -1
  76. package/src/connectivity/auth/token.ts +11 -0
  77. package/src/connectivity/index.ts +2 -0
  78. package/src/connectivity/manifest/auth.ts +99 -2
  79. package/src/connectivity/manifest/identity.ts +12 -0
  80. package/src/connectivity/manifest/index.ts +3 -1
  81. package/src/connectivity/manifest/provider.ts +37 -8
  82. package/src/connectivity/manifest/requirements.ts +109 -6
  83. package/src/deployments/adapters/filesystem.ts +10 -1
  84. package/src/deployments/adapters/github-api.ts +106 -0
  85. package/src/deployments/adapters/github-commit.ts +103 -0
  86. package/src/deployments/adapters/github-repo.ts +356 -0
  87. package/src/deployments/adapters/github-testing.ts +258 -0
  88. package/src/deployments/adapters/github.ts +125 -0
  89. package/src/deployments/deploy.ts +94 -114
  90. package/src/deployments/discover.ts +103 -0
  91. package/src/deployments/driver.ts +8 -1
  92. package/src/deployments/gcp/driver.ts +3 -1
  93. package/src/deployments/knowledge.ts +119 -0
  94. package/src/deployments/prepare.ts +12 -6
  95. package/src/deployments/report.ts +117 -0
  96. package/src/deployments/servable.ts +82 -0
  97. package/src/deployments/serving.ts +165 -0
  98. package/src/deployments/sync-apply.ts +276 -0
  99. package/src/deployments/sync.ts +136 -0
  100. package/src/deployments/target.ts +3 -2
  101. package/src/deployments/upload.ts +19 -12
  102. package/src/dispatch/dispatch.ts +1 -1
  103. package/src/profile/deployments.ts +80 -0
  104. package/src/profile/identity.ts +60 -0
  105. package/src/profile/index.ts +23 -5
  106. package/src/profile/knowledge.ts +124 -0
  107. package/src/profile/load.ts +17 -5
  108. package/src/profile/primitives.ts +24 -1
  109. package/src/profile/schema.ts +81 -3
  110. package/src/profile/targets.ts +122 -109
  111. package/src/profile/workspace.ts +139 -79
  112. package/src/providers/google/calendar/index.ts +2 -0
  113. package/src/providers/google/contacts/index.ts +2 -0
  114. package/src/providers/google/docs/index.ts +2 -0
  115. package/src/providers/google/drive/index.ts +2 -0
  116. package/src/providers/google/gmail/index.ts +2 -0
  117. package/src/providers/google/gmail-imap/index.ts +125 -0
  118. package/src/providers/google/index.ts +2 -1
  119. package/src/providers/google/shared/oauth.ts +18 -6
  120. package/src/providers/google/shared/service-account.ts +110 -0
  121. package/src/providers/google/shared/setup.ts +21 -3
  122. package/src/providers/google/sheets/index.ts +2 -0
  123. package/src/providers/google/tasks/index.ts +2 -0
  124. package/src/providers/identity/provider.ts +166 -0
  125. package/src/providers/index.ts +3 -0
  126. package/src/providers/owner.ts +10 -2
  127. package/src/providers/scopes.ts +2 -0
  128. package/src/providers/setup/plan.ts +31 -9
  129. package/src/providers/setup/provider.ts +23 -0
  130. package/src/providers/slack/index.ts +81 -33
  131. package/src/providers/slack/oauth.ts +103 -0
  132. package/src/providers/slack/scopes.ts +37 -0
  133. package/src/server/container.ts +18 -1
  134. package/src/server/cors.ts +252 -0
  135. package/src/server/dashboard.ts +208 -0
  136. package/src/server/endpoint.ts +45 -1
  137. package/src/server/generations.ts +11 -2
  138. package/src/server/harness.ts +7 -0
  139. package/src/server/index.ts +45 -6
  140. package/src/server/mcp/index.ts +1 -0
  141. package/src/server/mcp/instructions.ts +28 -1
  142. package/src/server/mcp/visibility.ts +33 -0
  143. package/src/stores/blobs/route.ts +123 -0
@@ -0,0 +1,252 @@
1
+ import { isAuthorizationPath } from './oauth.ts';
2
+
3
+ /**
4
+ * Cross-origin access, for a routable deployment and nothing else.
5
+ *
6
+ * A preflight carries no credentials, by construction rather than by omission:
7
+ * the CORS specification strips them, so `OPTIONS` arrives with no
8
+ * `Authorization` header and no cookie. Answering it from behind the bearer gate
9
+ * therefore refuses every browser-origin client *before* it has any opportunity
10
+ * to present one — which is what this endpoint did until this file existed, and
11
+ * it is a category error rather than a policy.
12
+ *
13
+ * **Never on loopback.** `./rebinding.ts` already answers that question, and
14
+ * answers it no: a cross-origin `Origin` on a request to `127.0.0.1` is refused
15
+ * outright, because a page the owner happens to be visiting can otherwise reach
16
+ * everything that answers before authentication — including the `/authorize`
17
+ * form that asks them for their token. A CORS grant and an `Origin` refusal on
18
+ * the same request are two answers to one question. So `serve` builds a policy
19
+ * only for a host that is not loopback, and nothing in this file decides that:
20
+ * it is decided by whether a policy exists at all.
21
+ */
22
+
23
+ /**
24
+ * Two halves, because what is already public and what carries the credential
25
+ * differ in what a grant costs.
26
+ *
27
+ * The public half is the authorization surface: readable without a credential by
28
+ * design, so a wildcard adds no reach a `curl` did not already have. It includes
29
+ * `/authorize`, which needs no grant at all — a top-level navigation, not a
30
+ * fetch — because `isAuthorizationPath` is one predicate that already exists and
31
+ * a second list of five paths would be a second thing to drift.
32
+ *
33
+ * `/health` and `/reload` are in neither half. No browser client needs them, and
34
+ * where the answer is not obvious default deny is the cheaper mistake.
35
+ */
36
+ export type CorsSurface = 'public' | 'credentialed';
37
+
38
+ export interface CorsPolicy {
39
+ /**
40
+ * Origins that may call the credentialed surface. `['*']` is the default.
41
+ *
42
+ * A wildcard, and not because narrowing was too much work — because on a
43
+ * deployment there is nothing left for it to defend. Three things have to be
44
+ * true together, and here they are:
45
+ *
46
+ * 1. **The endpoint is already reachable by anyone.** `access: public` is what
47
+ * a connector needs, so any server on the internet can post to `/mcp` and
48
+ * read the refusal today. CORS never gated *sending*; it gates whether a
49
+ * *page* may read the reply. An attacker with a server needs no page.
50
+ * 2. **The credential is never ambient.** It is an `Authorization` header a
51
+ * page must already possess, never a cookie a browser attaches on its own.
52
+ * So the request a hostile page gains is an unauthenticated one, and what
53
+ * it gains from reading the answer is a `401` it could have had from
54
+ * anywhere.
55
+ * 3. **`Access-Control-Allow-Credentials` is never sent** — and with `*` it
56
+ * cannot be: the specification refuses the combination outright. The one
57
+ * header that would make a wildcard dangerous is unreachable from here.
58
+ *
59
+ * What an allowlist bought, then, was a required setup step per user in
60
+ * exchange for narrowing a surface that was not exposed. Naming origins is
61
+ * still possible and still narrows — an enterprise deployment may want it —
62
+ * but absent means `*`, because a default nobody can skip is a default that
63
+ * is wrong.
64
+ *
65
+ * None of this holds on loopback, which is why loopback has no policy at all.
66
+ * There the endpoint is *not* publicly reachable, and that is precisely what
67
+ * a page reaching `127.0.0.1` would be stealing.
68
+ */
69
+ readonly allowedOrigins: readonly string[];
70
+ }
71
+
72
+ /** The default, and what an absent `auth.allowed_origins` resolves to. */
73
+ export const ANY_ORIGIN = '*';
74
+
75
+ /**
76
+ * What a browser is allowed to send.
77
+ *
78
+ * The list is fixed rather than reflected from `Access-Control-Request-Headers`,
79
+ * because reflecting it grants whatever was asked for and the answer to "which
80
+ * headers does this endpoint read" is knowable here. These are they: the
81
+ * credential, the 2026-07-28 envelope's method and target, the client label the
82
+ * audit log records, and the session and resumption headers the MCP SDK sends.
83
+ */
84
+ const ALLOW_HEADERS = [
85
+ 'authorization',
86
+ 'content-type',
87
+ 'mcp-session-id',
88
+ 'mcp-protocol-version',
89
+ 'mcp-method',
90
+ 'mcp-name',
91
+ 'x-mcp-client',
92
+ 'last-event-id',
93
+ ].join(', ');
94
+
95
+ /**
96
+ * What a browser is allowed to read back.
97
+ *
98
+ * `WWW-Authenticate` is the load-bearing one: it carries `resource_metadata`,
99
+ * which is the whole discovery handshake (ADR-036). Without it a browser client
100
+ * receives the `401` and cannot see the pointer that tells it what to do next,
101
+ * which looks exactly like an endpoint that refuses for no reason.
102
+ *
103
+ * `Retry-After` for the same reason on the other refusal — `edge.ts` sends one
104
+ * with a `429` and a caller that cannot read it can only guess when to retry.
105
+ */
106
+ const EXPOSE_HEADERS = ['WWW-Authenticate', 'Mcp-Session-Id', 'Retry-After'].join(', ');
107
+
108
+ /**
109
+ * Which half a path belongs to, or neither.
110
+ *
111
+ * The authorization surface answers without a credential by design, so a
112
+ * wildcard there hands a page what `curl` already has. Everything else is either
113
+ * named by the caller as credentialed or gets nothing at all.
114
+ */
115
+ function surfaceOf(pathname: string, credentialed: readonly string[]): CorsSurface | undefined {
116
+ if (isAuthorizationPath(pathname)) return 'public';
117
+ return credentialed.includes(pathname) ? 'credentialed' : undefined;
118
+ }
119
+
120
+ /** POST for calls, GET for the SSE stream, DELETE to end a session. */
121
+ const ALLOW_METHODS = 'GET, POST, DELETE, OPTIONS';
122
+
123
+ /** A day. A preflight per call is the cost of getting this wrong. */
124
+ const MAX_AGE = '86400';
125
+
126
+ /**
127
+ * The value for `Access-Control-Allow-Origin`, or `null` to grant nothing.
128
+ *
129
+ * A listed origin is echoed rather than answered with `*`, because a list is a
130
+ * narrowing and `*` would discard it. That is also why the wildcard and the echo
131
+ * cannot share a branch: they differ in whether the answer varies by caller, and
132
+ * `Vary` has to follow.
133
+ */
134
+ function grantFor(request: Request, surface: CorsSurface, policy: CorsPolicy): string | null {
135
+ if (surface === 'public') return ANY_ORIGIN;
136
+ if (policy.allowedOrigins.includes(ANY_ORIGIN)) return ANY_ORIGIN;
137
+
138
+ const origin = request.headers.get('origin');
139
+ return origin && policy.allowedOrigins.includes(origin) ? origin : null;
140
+ }
141
+
142
+ /**
143
+ * The headers to add to a response, or `undefined` for nothing to add.
144
+ *
145
+ * Nothing is added when the request carries no `Origin`, which is every
146
+ * non-browser client: there is no origin to grant, and a header naming one
147
+ * would be an invention. That is also what keeps `curl` output identical to what
148
+ * it was.
149
+ */
150
+ function corsHeaders(
151
+ request: Request,
152
+ surface: CorsSurface,
153
+ policy: CorsPolicy,
154
+ ): Record<string, string> | undefined {
155
+ const origin = request.headers.get('origin');
156
+ if (!origin) return undefined;
157
+
158
+ const allowed = grantFor(request, surface, policy);
159
+ if (!allowed) return undefined;
160
+
161
+ return {
162
+ 'access-control-allow-origin': allowed,
163
+ 'access-control-allow-methods': ALLOW_METHODS,
164
+ 'access-control-allow-headers': ALLOW_HEADERS,
165
+ 'access-control-expose-headers': EXPOSE_HEADERS,
166
+ 'access-control-max-age': MAX_AGE,
167
+ // Only meaningful where the value varies, and harmful to omit there: a
168
+ // shared cache would otherwise hand one origin's grant to another.
169
+ ...(allowed === ANY_ORIGIN ? {} : { vary: 'Origin' }),
170
+ };
171
+ }
172
+
173
+ /**
174
+ * The answer to a preflight: `204`, with the grant or without it.
175
+ *
176
+ * Without it for an origin that is not allowed, rather than a `403` — the
177
+ * browser refuses the call on the absence of the header, which is the mechanism
178
+ * CORS actually uses, and a distinct status would tell a probing page which
179
+ * paths exist. Never a `401`: see the note at the top of this file.
180
+ *
181
+ * `Access-Control-Allow-Credentials` is absent from both, deliberately and
182
+ * everywhere. The credential here is a header a page cannot obtain, never a
183
+ * cookie a browser would attach on its own, so the wildcard above grants
184
+ * nothing without it — and sending it is what would turn that wildcard into a
185
+ * hole.
186
+ */
187
+ function preflightResponse(headers: Record<string, string> | undefined): Response {
188
+ return new Response(null, { status: 204, headers: headers ?? {} });
189
+ }
190
+
191
+ /**
192
+ * The same response, carrying the grant.
193
+ *
194
+ * Rebuilt rather than mutated: a `Response` the MCP SDK returns may carry an
195
+ * immutable header guard, and `body` is a stream that survives being passed
196
+ * through. A `204` or `304` has a null body already, so this is safe for those
197
+ * too.
198
+ */
199
+ function withCors(response: Response, headers: Record<string, string>): Response {
200
+ const merged = new Headers(response.headers);
201
+ for (const [name, value] of Object.entries(headers)) merged.set(name, value);
202
+
203
+ return new Response(response.body, {
204
+ status: response.status,
205
+ statusText: response.statusText,
206
+ headers: merged,
207
+ });
208
+ }
209
+
210
+ /**
211
+ * The same handler, with cross-origin requests answered.
212
+ *
213
+ * A wrapper rather than six branches in the router, for the reason `./oauth.ts`
214
+ * is its own file: `index.ts` gains a delegation instead of the whole of this
215
+ * behaviour, and it stays inside the size budget that would otherwise be the
216
+ * rule relaxed to fit this in.
217
+ *
218
+ * Wrapped at `serve()` rather than inside the router, which is also where the
219
+ * policy is decided: cross-origin access is a property of the address this is
220
+ * bound to, exactly as `allowedHostnames` and the dashboard are, and putting all
221
+ * three in one function is what makes the loopback exclusion legible instead of
222
+ * an invariant spread across two files.
223
+ *
224
+ * It is what makes the ordering safe, too. A preflight answered here never
225
+ * reaches the rebinding guard inside `inner` — and does not need to, because a
226
+ * policy exists only off loopback, where that guard is inert. Both facts are
227
+ * three lines apart in `serve()`, derived from the same `loopback`.
228
+ *
229
+ * The credentialed paths are passed rather than imported because the router owns
230
+ * its own path constants, and reaching back for `MCP_PATH` would make this file
231
+ * and that one import each other.
232
+ */
233
+ export function corsAware(
234
+ inner: (request: Request) => Promise<Response>,
235
+ credentialed: readonly string[],
236
+ policy: CorsPolicy,
237
+ ): (request: Request) => Promise<Response> {
238
+ return async (request) => {
239
+ const surface = surfaceOf(new URL(request.url).pathname, credentialed);
240
+ if (!surface) return await inner(request);
241
+
242
+ const headers = corsHeaders(request, surface, policy);
243
+
244
+ // Answered here, ahead of the auth gate, because a preflight carries no
245
+ // credential and never will — the note at the top of this file says why that
246
+ // is a fact about CORS rather than a choice about this endpoint.
247
+ if (request.method === 'OPTIONS') return preflightResponse(headers);
248
+
249
+ const response = await inner(request);
250
+ return headers ? withCors(response, headers) : response;
251
+ };
252
+ }
@@ -0,0 +1,208 @@
1
+ import type { Authenticator } from '#auth';
2
+ import { dashboardPage, dashboardSignInPage, type DashboardConnection } from '#cli/dashboard-page.ts';
3
+ import type { Generations } from './generations.ts';
4
+
5
+ /**
6
+ * The dashboard route.
7
+ *
8
+ * Local only, and mounted by nobody else: `lanes link start` asks for it and the
9
+ * container entrypoint does not, with `serve()` refusing it a second time if the
10
+ * bind address is not loopback. That is a decision rather than an omission, and
11
+ * ADR-018 is why. Cloud Run IAM admits a caller holding a Google-signed identity
12
+ * token; a browser doing a top-level navigation never sends one, so `access:
13
+ * iam` answers a person with a 403 rather than a sign-in page. The mode a remote
14
+ * client actually uses — `access: public` with an `auth.authorization` block —
15
+ * leaves the front door open and gates `/mcp` on a bearer token instead, which a
16
+ * navigation cannot carry either. Neither arrangement has a browser-shaped door,
17
+ * so this one does not pretend to be reachable through them.
18
+ *
19
+ * What it serves is a reader. Nothing here writes config, authorises anything,
20
+ * or reaches an upstream — see `#cli/dashboard-page.ts` for why that is the
21
+ * honest shape rather than a limitation to lift later.
22
+ */
23
+
24
+ export const DASHBOARD_PATH = '/dashboard';
25
+
26
+ /**
27
+ * Whether a bind serves the dashboard.
28
+ *
29
+ * Two conditions, and both are load-bearing. Asking is the entrypoint's
30
+ * decision — `lanes link start` does, the container does not. Loopback is the
31
+ * one that does not depend on anybody remembering: a routable bind is a
32
+ * deployed one, and the consequence of getting this wrong is a page listing the
33
+ * owner's accounts on a public address. Named rather than inlined so the rule
34
+ * can be stated once and asserted directly.
35
+ */
36
+ export function servesDashboard(asked: boolean | undefined, loopback: boolean): boolean {
37
+ return asked === true && loopback;
38
+ }
39
+
40
+ /** The query parameter `lanes link dashboard` puts the profile token in. */
41
+ const KEY_PARAM = 'k';
42
+
43
+ const COOKIE = 'lanes_link_dashboard';
44
+
45
+ /**
46
+ * How long a browser stays signed in.
47
+ *
48
+ * Bounded rather than tied to the process: an endpoint left running for weeks
49
+ * should not leave a tab that authenticated once able to read the workspace for
50
+ * as long as it lives.
51
+ */
52
+ const SESSION_MS = 12 * 60 * 60 * 1000;
53
+
54
+ /**
55
+ * Sessions this handler has issued.
56
+ *
57
+ * A random id, not the profile token. The token arrives once, in a URL the CLI
58
+ * built, and is exchanged here for something that is worth nothing anywhere else
59
+ * — so the credential that reaches every profile is never sitting in a cookie
60
+ * jar, and revoking it is closing this process rather than clearing a browser.
61
+ *
62
+ * In memory, which is the one place stateless-by-design does not apply: this
63
+ * route exists only on a single local process. The deployed instance that
64
+ * `index.ts` keeps stateless for never serves it.
65
+ */
66
+ export interface DashboardSessions {
67
+ issue(): string;
68
+ valid(id: string | null): boolean;
69
+ }
70
+
71
+ export function dashboardSessions(now: () => number = Date.now): DashboardSessions {
72
+ const issued = new Map<string, number>();
73
+
74
+ return {
75
+ issue() {
76
+ const id = crypto.randomUUID();
77
+ const at = now();
78
+ // Pruned on write rather than on a timer, so an endpoint nobody is
79
+ // looking at holds nothing and runs nothing.
80
+ for (const [key, expires] of issued) if (expires <= at) issued.delete(key);
81
+ issued.set(id, at + SESSION_MS);
82
+ return id;
83
+ },
84
+ valid(id) {
85
+ if (!id) return false;
86
+ const expires = issued.get(id);
87
+ if (expires === undefined) return false;
88
+ if (expires <= now()) {
89
+ issued.delete(id);
90
+ return false;
91
+ }
92
+ return true;
93
+ },
94
+ };
95
+ }
96
+
97
+ export interface DashboardSurface {
98
+ readonly generations: Generations;
99
+ readonly authenticator: Authenticator;
100
+ /** The profile shown when the URL names none. */
101
+ readonly primary: string;
102
+ readonly sessions: DashboardSessions;
103
+ }
104
+
105
+ function cookieValue(header: string | null, name: string): string | null {
106
+ if (!header) return null;
107
+ for (const part of header.split(';')) {
108
+ const [key, ...rest] = part.trim().split('=');
109
+ if (key === name) return rest.join('=');
110
+ }
111
+ return null;
112
+ }
113
+
114
+ export async function handleDashboard(
115
+ request: Request,
116
+ surface: DashboardSurface,
117
+ ): Promise<Response> {
118
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
119
+ return new Response('Method not allowed', { status: 405, headers: { allow: 'GET, HEAD' } });
120
+ }
121
+
122
+ const url = new URL(request.url);
123
+ const key = url.searchParams.get(KEY_PARAM);
124
+
125
+ if (key !== null) {
126
+ // The token is checked by the same authenticator every other route uses,
127
+ // rather than compared here. One credential, one comparison, one place a
128
+ // rotation has to land.
129
+ const outcome = await surface.authenticator.authenticate(`Bearer ${key}`);
130
+ if (!outcome.ok) return dashboardSignInPage(401);
131
+
132
+ // Straight back out to a URL without it. A token that stays in the address
133
+ // bar is one in the history, in the window title, and in whatever the next
134
+ // link sends as a Referer — and this is the only request that needs it.
135
+ url.searchParams.delete(KEY_PARAM);
136
+ return new Response(null, {
137
+ status: 303,
138
+ headers: {
139
+ location: `${url.pathname}${url.search}`,
140
+ // No `Secure`: this is served over http on loopback and nowhere else,
141
+ // and a Secure cookie would simply never be stored. `SameSite=Strict`
142
+ // is what keeps another origin from navigating into an authenticated
143
+ // view, and the rebinding check in front of every route (`index.ts`)
144
+ // is what keeps one from being the origin in the first place.
145
+ 'set-cookie':
146
+ `${COOKIE}=${surface.sessions.issue()}; HttpOnly; SameSite=Strict; ` +
147
+ `Path=${DASHBOARD_PATH}; Max-Age=${Math.floor(SESSION_MS / 1000)}`,
148
+ 'cache-control': 'no-store',
149
+ },
150
+ });
151
+ }
152
+
153
+ if (!surface.sessions.valid(cookieValue(request.headers.get('cookie'), COOKIE))) {
154
+ return dashboardSignInPage(401);
155
+ }
156
+
157
+ // Pinned for the whole render, as a request on `/mcp` is: a reload landing
158
+ // between the connection list and the provider catalogue would otherwise
159
+ // produce a page describing two different configurations.
160
+ const generation = surface.generations.acquire();
161
+ try {
162
+ const asked = url.searchParams.get('profile');
163
+ const name = asked ?? surface.primary;
164
+ const runtime = generation.profiles.get(name);
165
+
166
+ // A name this endpoint does not serve can only have been typed: every
167
+ // profile link on the page comes from the list below. Refusing beats
168
+ // rendering a different profile than the URL says, which is the silent pick
169
+ // `resolveSelection` exists to prevent.
170
+ if (!runtime) return new Response('Not found', { status: 404 });
171
+
172
+ const records = runtime.connections ? await runtime.connections() : [];
173
+ const byKey = new Map(records.map((record) => [`${record.provider}.${record.id}`, record]));
174
+
175
+ // The same join `lanes link status` makes: config says what should exist,
176
+ // the store says what does, and the difference is the whole point of
177
+ // showing it.
178
+ const connections: DashboardConnection[] = runtime.config.connections.map((connection) => ({
179
+ key: `${connection.provider}.${connection.id}`,
180
+ provider: connection.provider,
181
+ account: connection.account,
182
+ state: byKey.get(`${connection.provider}.${connection.id}`)?.status ?? 'not reconciled',
183
+ }));
184
+
185
+ // Never `instance.default_target`, which this line used to fall back to.
186
+ // ADR-037 left that field inert precisely so a missing target surfaces here
187
+ // rather than one command later: the page names a credential store, and a
188
+ // page that guessed which one would report an account as connected against
189
+ // a store the endpoint asking for it does not read.
190
+ //
191
+ // Absent only if a runtime reached this route without being opened against
192
+ // anything, which a served endpoint never does — `endpoint.ts` builds every
193
+ // entry in that map from a `Runtime`, whose target is required. Refusing
194
+ // beats rendering, for the same reason the 404 above does.
195
+ if (!runtime.target) return new Response('No target', { status: 500 });
196
+
197
+ return dashboardPage({
198
+ profile: name,
199
+ profiles: generation.names(),
200
+ target: runtime.target,
201
+ targets: Object.keys(runtime.config.targets),
202
+ connections,
203
+ ownClients: Object.keys(runtime.config.oauth_apps),
204
+ });
205
+ } finally {
206
+ await surface.generations.release(generation);
207
+ }
208
+ }
@@ -47,10 +47,22 @@ export interface EndpointReporter {
47
47
  reconciled(input: { profile: string; plan: string; ofMany: boolean }): void;
48
48
  /** No profile token existed and one was minted. */
49
49
  tokenMinted(minted: { target: string }): void;
50
+ /**
51
+ * A sibling profile that could not be opened against this target.
52
+ *
53
+ * Optional because it is new and every caller predates it, and because
54
+ * silence is a defensible reading for a caller that only wants what it got.
55
+ */
56
+ skipped?(input: { profile: string; reason: string }): void;
50
57
  }
51
58
 
52
59
  const SILENT: EndpointReporter = { reconciled() {}, tokenMinted() {} };
53
60
 
61
+ /** The first line of an error, which is the part fit to print beside a name. */
62
+ function message(error: unknown): string {
63
+ return error instanceof Error ? (error.message.split('\n')[0] ?? error.message) : String(error);
64
+ }
65
+
54
66
  export interface EndpointOptions {
55
67
  readonly flags: GlobalFlags;
56
68
  readonly port?: number | undefined;
@@ -65,6 +77,16 @@ export interface EndpointOptions {
65
77
  readonly reporter?: EndpointReporter | undefined;
66
78
  /** Operational events. Silent when absent, which is what the tests want. */
67
79
  readonly log?: Logger | undefined;
80
+ /**
81
+ * Serve the dashboard at `/dashboard`.
82
+ *
83
+ * True for `lanes link start`, absent in a container — the same split as
84
+ * `mintToken`, and for a related reason. A deployed instance has no door a
85
+ * browser can come through (ADR-018), so a page there would be either
86
+ * unreachable or unguarded depending on `deploy.access`, and both are worse
87
+ * than not having one.
88
+ */
89
+ readonly dashboard?: boolean | undefined;
68
90
  }
69
91
 
70
92
  export interface RunningEndpoint {
@@ -103,7 +125,22 @@ async function openReconciled(options: {
103
125
  try {
104
126
  for (const name of names) {
105
127
  if (runtimes.has(name)) continue;
106
- runtimes.set(name, await openRuntime({ ...options.flags, profile: name }));
128
+
129
+ // A sibling that does not declare this target is skipped, not fatal.
130
+ //
131
+ // Every profile here is opened against the one target the endpoint was
132
+ // started with, and a workspace may hold a profile that runs somewhere
133
+ // else entirely. Refusing would let one such profile take the endpoint
134
+ // down for all of them, which is the opposite of what `start` is for —
135
+ // and in a container it is a revision that never goes healthy. Skipping
136
+ // is not an assumption: it is reported, and `served` below is derived
137
+ // from what actually opened rather than from what is on disk.
138
+ try {
139
+ runtimes.set(name, await openRuntime({ ...options.flags, profile: name }));
140
+ } catch (error) {
141
+ if (name === primary.resolution.profile) throw error;
142
+ reporter.skipped?.({ profile: name, reason: message(error) });
143
+ }
107
144
  }
108
145
 
109
146
  for (const [name, runtime] of runtimes) {
@@ -147,6 +184,12 @@ function profileRuntimes(runtimes: ReadonlyMap<string, Runtime>): Map<string, Pr
147
184
  // through `skills.manage.write`, or by `lanes link skills add` in another
148
185
  // terminal — is a prompt without a restart (ADR-014).
149
186
  refreshSkills: runtime.refreshSkills,
187
+ // For a surface that reports rather than dispatches. A thunk rather
188
+ // than a snapshot because a reconcile lands between requests, and the
189
+ // dashboard reading a list captured at boot would keep showing an
190
+ // account as unauthorized after the connect that fixed it.
191
+ target: runtime.target,
192
+ connections: () => runtime.state.connections.list(),
150
193
  },
151
194
  ]),
152
195
  );
@@ -293,6 +336,7 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
293
336
  : primary.authenticator,
294
337
  log,
295
338
  ...(gate ? { authorization: gate.surface } : {}),
339
+ ...(options.dashboard ? { dashboard: true } : {}),
296
340
  ...(options.port !== undefined ? { port: options.port } : {}),
297
341
  ...(options.host !== undefined ? { host: options.host } : {}),
298
342
  });
@@ -1,5 +1,5 @@
1
1
  import type { Logger } from '#connectivity';
2
- import { clearUpstreamTokens } from '#connectivity/auth/index.ts';
2
+ import { clearMintedTokens, clearUpstreamTokens } from '#connectivity/auth/index.ts';
3
3
  import type { ProfileRuntime } from '#server/mcp';
4
4
  import { Generation } from './generation.ts';
5
5
 
@@ -184,12 +184,21 @@ export class Generations {
184
184
  // cloud target is a network write, so this is reachable rather than
185
185
  // theoretical.
186
186
  try {
187
- // Module-global and keyed per connection, so it survives a reload that
187
+ // Module-global and keyed per connection, so they survive a reload that
188
188
  // replaced everything else. Re-connecting `<provider>.<id>` to a different
189
189
  // account would otherwise serve the previous account's access token until
190
190
  // it expired — up to an hour after the config said otherwise. Unchanged
191
191
  // connections pay one refresh.
192
+ //
193
+ // Both caches, because a connection authenticates one way at a time and
194
+ // re-connecting is how it changes: the route is settled by what `connect`
195
+ // last stored, so a reload that cleared only the authorization-code cache
196
+ // would keep serving a token minted from a key for a connection that no
197
+ // longer authenticates with one — and, worse, keep acting as the
198
+ // previously impersonated user, since the minted cache is keyed by
199
+ // connection with no subject in it.
192
200
  clearUpstreamTokens();
201
+ clearMintedTokens();
193
202
 
194
203
  // After the swap: a request arriving during the retire already gets the
195
204
  // new generation, and this only waits on requests that started before it.
@@ -121,6 +121,8 @@ export interface HarnessOptions {
121
121
  * serving the old generation" case is reached; absent means nothing new.
122
122
  */
123
123
  reopen?: () => Promise<ReadonlyMap<string, ProfileRuntime>>;
124
+ /** Serve `/dashboard`, as `lanes link start` does and a container never does. */
125
+ dashboard?: boolean;
124
126
  }
125
127
 
126
128
  /**
@@ -181,6 +183,10 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
181
183
  dispatcher,
182
184
  policy,
183
185
  ...(options.refreshSkills ? { refreshSkills: () => options.refreshSkills!(registry) } : {}),
186
+ // As `profileRuntimes` supplies them for real. A harness that claims to
187
+ // be the real wiring and omits a field leaves that field untested.
188
+ target: 'local',
189
+ connections: () => state.connections.list(),
184
190
  }),
185
191
  );
186
192
 
@@ -259,6 +265,7 @@ export function startHarness(options: HarnessOptions): Harness {
259
265
  primary: options.profile,
260
266
  authenticator: gate ? new AuthenticatorChain([bearer, gate.authenticator]) : bearer,
261
267
  ...(gate ? { authorization: gate.surface } : {}),
268
+ ...(options.dashboard ? { dashboard: true } : {}),
262
269
  log,
263
270
  });
264
271