@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
@@ -0,0 +1,54 @@
1
+ import { readRoutes, type ReadDeps } from './routes.ts';
2
+
3
+ /**
4
+ * The read surface on loopback: its own port, over TLS (ADR-063).
5
+ *
6
+ * The routes themselves live in `./routes.ts`, shared with the deployed bind,
7
+ * so the four properties they enforce cannot come to differ between the two.
8
+ * What is decided *here* is the fifth, and it is the one that is genuinely
9
+ * about this bind rather than about the routes:
10
+ *
11
+ * **TLS.** Not for confidentiality on a loopback socket, but because Safari
12
+ * will not let an HTTPS page fetch `http://127.0.0.1` and offers no header,
13
+ * flag or opt-in that changes it. Without this the surface does not exist for a
14
+ * Safari user. It is also the whole reason for a second port: the MCP listener
15
+ * must keep answering `http://127.0.0.1:7337` for every registration that
16
+ * already exists.
17
+ *
18
+ * A deployed workspace needs none of this — Cloud Run terminates TLS with a
19
+ * certificate a browser already trusts, and routes exactly one port — so it
20
+ * takes the routes through the endpoint's own router instead. See
21
+ * `./deployed.ts`.
22
+ */
23
+
24
+ export { READ_ORIGINS, type AuditTail, type ReadDeps } from './routes.ts';
25
+
26
+ export interface ReadListenerOptions extends ReadDeps {
27
+ readonly host: string;
28
+ readonly port: number;
29
+ readonly tls: { readonly cert: string; readonly key: string };
30
+ }
31
+
32
+ export interface RunningReadListener {
33
+ readonly url: string;
34
+ stop(): Promise<void>;
35
+ }
36
+
37
+ export function serveRead(options: ReadListenerOptions): RunningReadListener {
38
+ const server = Bun.serve({
39
+ hostname: options.host,
40
+ port: options.port,
41
+ tls: { cert: options.tls.cert, key: options.tls.key },
42
+ // Everything, because this owns a whole port. The router on the deployed
43
+ // side passes only what `isReadPath` matched — handing an unmatched path to
44
+ // `readRoutes` there would swallow `/mcp`.
45
+ fetch: (request) => readRoutes(request, options),
46
+ });
47
+
48
+ return {
49
+ // The port the kernel assigned, not the one that was asked for. They differ
50
+ // whenever `port: 0` is passed, and a URL naming 0 is one nothing can reach.
51
+ url: `https://${options.host}:${server.port}`,
52
+ stop: () => server.stop(true),
53
+ };
54
+ }
@@ -0,0 +1,101 @@
1
+ import { X509Certificate } from 'node:crypto';
2
+ import { PAIR_CERT_REF, PAIR_KEY_REF, PAIR_TOKEN_REF, readConnections } from '#profile';
3
+ import type { Runtime } from '#cli/runtime.ts';
4
+ import type { Logger } from '#connectivity';
5
+ import type { RunningServer } from '../index.ts';
6
+ import type { ProfileRuntime } from '../mcp/visibility.ts';
7
+ import { directPairingCredential } from './credential.ts';
8
+ import { serveRead, type RunningReadListener } from './listener.ts';
9
+
10
+ /**
11
+ * The dashboard's read surface on loopback, if this workspace has been paired.
12
+ *
13
+ * Absent by default and absent for every workspace that has not run
14
+ * `lanes link pair`, which is the whole shape of ADR-063: a browser origin
15
+ * reaching loopback is a grant somebody makes deliberately, not a property of
16
+ * running an endpoint. All three pieces must be present — the token and both
17
+ * halves of the certificate — because a partial pairing would bind a port
18
+ * serving something no browser will connect to.
19
+ *
20
+ * Bound one above the MCP port, and a failure to bind is reported rather than
21
+ * fatal: the endpoint is what the operator ran this for, and refusing to serve
22
+ * it because a second port is occupied would be the wrong trade.
23
+ */
24
+ export async function openReadListener(
25
+ primary: Runtime,
26
+ server: RunningServer,
27
+ profiles: () => ReadonlyMap<string, ProfileRuntime>,
28
+ log: Logger,
29
+ version: string,
30
+ ): Promise<RunningReadListener | null> {
31
+ // Loopback only, and checked before a single credential is read.
32
+ //
33
+ // A second TLS listener one port above the endpoint is a loopback-only
34
+ // object: Cloud Run routes exactly one port, so there is nowhere for it to
35
+ // bind. Reading the three refs regardless meant a deployed revision asked
36
+ // Secret Manager for secrets no IAM binding covered — and Secret Manager
37
+ // answers a missing binding with 403 rather than 404, so the rejection
38
+ // escaped this function's try block, which wraps only `serveRead`, and the
39
+ // revision never went healthy.
40
+ //
41
+ // A deployed workspace now serves the same routes through the endpoint's own
42
+ // router (`./deployed.ts`), which reads no credential at boot at all — so
43
+ // that failure cannot recur there by construction, and `readableRefs` binds
44
+ // the token so the read itself stops being a rejection.
45
+ const bound = new URL(server.url);
46
+ if (!['127.0.0.1', 'localhost', '::1', '[::1]'].includes(bound.hostname)) return null;
47
+
48
+ const [token, cert, key] = await Promise.all([
49
+ primary.credentials.get(PAIR_TOKEN_REF),
50
+ primary.credentials.get(PAIR_CERT_REF),
51
+ primary.credentials.get(PAIR_KEY_REF),
52
+ ]);
53
+
54
+ if (token === null || cert === null || key === null) return null;
55
+
56
+ try {
57
+ return serveRead({
58
+ host: bound.hostname,
59
+ port: Number(bound.port) + 1,
60
+ workspace: primary.target,
61
+ profiles,
62
+ audit: primary.audit,
63
+ connections: async () =>
64
+ (await readConnections(primary.resolution.workspaceRoot)).connections,
65
+ // Read on every presentation, so `pair --rotate` takes effect on a
66
+ // running endpoint. Affordable here because the store is a local file;
67
+ // the deployed bind caches for exactly this reason. `refresh()` drops the
68
+ // store's decrypted copy first, because the rotation was written by a
69
+ // different process.
70
+ credential: directPairingCredential({
71
+ read: () => primary.credentials.get(PAIR_TOKEN_REF),
72
+ refresh: () => primary.credentials.refresh?.(),
73
+ onError: (reason) => log.warn('could not read the pairing credential', { reason }),
74
+ }),
75
+ endpoint: { kind: 'local', version, certificateExpiresAt: expiryOf(cert) },
76
+ tls: { cert, key },
77
+ });
78
+ } catch (error) {
79
+ log.warn('could not serve the dashboard read surface', {
80
+ port: Number(bound.port) + 1,
81
+ reason: error instanceof Error ? error.message : String(error),
82
+ });
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * When the pairing certificate stops working, as an ISO instant.
89
+ *
90
+ * `null` rather than a throw for a certificate that cannot be parsed: the
91
+ * surface it protects is already serving by the time anyone reads this, and
92
+ * refusing to answer `/state` because an expiry could not be formatted would
93
+ * take down the working thing to report on the broken one.
94
+ */
95
+ function expiryOf(certificate: string): string | null {
96
+ try {
97
+ return new X509Certificate(certificate).validToDate.toISOString();
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
@@ -0,0 +1,247 @@
1
+ import type { Logger } from '#connectivity';
2
+ import type { ProfileRuntime } from '../mcp/visibility.ts';
3
+ import type { PairingCredential } from './credential.ts';
4
+ import { readState, type ConnectionRow, type ReadEndpoint } from './state.ts';
5
+
6
+ /**
7
+ * The two routes a browser origin may read, and everything that guards them.
8
+ *
9
+ * One implementation, two binds. On loopback `./listener.ts` gives them a port
10
+ * of their own over TLS; on a deployed workspace `./deployed.ts` hands them to
11
+ * the endpoint's own router, because Cloud Run routes exactly one port. The
12
+ * split is deliberate and the sharing is the point: four of ADR-063's five
13
+ * properties are decided in this file, so the two surfaces cannot drift into
14
+ * two answers about what a pairing token may reach.
15
+ *
16
+ * Four properties, and dropping any one makes the others decorative:
17
+ *
18
+ * - **One origin, named, never `*`.** Echoed with `Vary: Origin`. A deployment
19
+ * may wildcard `/mcp` because it is already publicly reachable and a `curl`
20
+ * has that reach already — but this returns every connection, every profile
21
+ * and the whole audit log, and `cors.ts`'s wildcard was buying the absence of
22
+ * a required setup step that does not exist here. So it is named.
23
+ * - **A credential that cannot call a tool.** The pairing token, minted by
24
+ * `lanes link pair`, under its own ref, rotatable on its own. It is not the
25
+ * MCP bearer and not an OAuth token, and it never passes through the
26
+ * endpoint's authenticator — one shared check would make each able to do the
27
+ * other's job.
28
+ * - **Never ambient.** An `Authorization` header the page must already hold.
29
+ * No cookie, no session, so `credentials: 'include'` buys an attacker
30
+ * nothing.
31
+ * - **Reads only, ever.** No mutation is reachable from here at all. Editing a
32
+ * profile from a browser would put control-plane mutation behind a CORS
33
+ * grant, and ADR-007 does not move for a convenience.
34
+ *
35
+ * The fifth — TLS — belongs to the bind rather than to the routes, and
36
+ * `./listener.ts` carries it.
37
+ */
38
+
39
+ /** Where the dashboard lives, and where it lives while somebody is building it. */
40
+ export const READ_ORIGINS: readonly string[] = ['https://lanes.sh', 'http://localhost:3000'];
41
+
42
+ export const STATE_PATH = '/state';
43
+ export const AUDIT_PATH = '/audit';
44
+
45
+ /**
46
+ * Whether the router should hand this path over.
47
+ *
48
+ * A predicate rather than an exported array, so the router owns no literal of
49
+ * its own and cannot come to disagree with the handler about which paths these
50
+ * are. `isAuthorizationPath` is the same shape for the same reason.
51
+ */
52
+ export function isReadPath(pathname: string): boolean {
53
+ return pathname === STATE_PATH || pathname === AUDIT_PATH;
54
+ }
55
+
56
+ /** The most entries `/audit` will return, however many are asked for. */
57
+ const AUDIT_CEILING = 500;
58
+ const AUDIT_DEFAULT = 100;
59
+
60
+ /**
61
+ * The half of the audit log this reads, declared rather than imported.
62
+ *
63
+ * `server` may not depend on `#audit`, and widening that table for one `tail`
64
+ * would be the wrong direction to resolve it: these routes do not know how the
65
+ * log is chained, stored, or verified, and nothing here should be able to find
66
+ * out. What they need is the last N entries, so that is what they ask for.
67
+ */
68
+ export interface AuditTail {
69
+ tail(options?: { limit?: number }): Promise<
70
+ readonly {
71
+ readonly id: string;
72
+ readonly timestamp: Date;
73
+ readonly profile: string;
74
+ readonly principal: string;
75
+ readonly clientLabel?: string | undefined;
76
+ readonly provider: string;
77
+ readonly connection?: string | undefined;
78
+ readonly capability: string;
79
+ readonly arguments: Readonly<Record<string, unknown>>;
80
+ readonly authorization: string;
81
+ readonly status: string;
82
+ readonly durationMs: number;
83
+ readonly error?: { readonly kind: string; readonly message: string } | undefined;
84
+ }[]
85
+ >;
86
+ }
87
+
88
+ export interface ReadDeps {
89
+ readonly workspace: string;
90
+ /** The current generation's profiles, read through a thunk so a reload lands. */
91
+ readonly profiles: () => ReadonlyMap<string, ProfileRuntime>;
92
+ readonly audit: AuditTail;
93
+ /**
94
+ * The workspace's connection rows, read per request.
95
+ *
96
+ * A thunk rather than a value because a connection added while the endpoint
97
+ * runs should appear without a restart — the same reason `profiles` is one.
98
+ * Read from `connections.yaml` rather than derived from the grants, because
99
+ * a label and an account live on the connection and a grant carries neither.
100
+ */
101
+ readonly connections: () => Promise<readonly ConnectionRow[]>;
102
+ readonly credential: PairingCredential;
103
+ /** What this endpoint says about itself. Fixed for the life of the bind. */
104
+ readonly endpoint: ReadEndpoint;
105
+ readonly allowedOrigins?: readonly string[] | undefined;
106
+ readonly log?: Logger | undefined;
107
+ }
108
+
109
+ /**
110
+ * Answer a read request, whatever it turns out to be.
111
+ *
112
+ * This answers **every** request handed to it, including its own `404` for an
113
+ * unknown path — which is what lets `serveRead` pass a whole port through it.
114
+ * The router must therefore hand over only what `isReadPath` matched: given an
115
+ * unmatched path this would swallow `/mcp`.
116
+ */
117
+ export async function readRoutes(request: Request, deps: ReadDeps): Promise<Response> {
118
+ const origins = deps.allowedOrigins ?? READ_ORIGINS;
119
+ const origin = request.headers.get('origin');
120
+ const allowed = origin !== null && origins.includes(origin);
121
+
122
+ // Answered before the credential is checked, because a preflight carries no
123
+ // credential — that is what it is for. It carries no data either, so
124
+ // answering one reveals only that something is listening, which the TCP
125
+ // connection already revealed.
126
+ if (request.method === 'OPTIONS') {
127
+ return new Response(null, { status: allowed ? 204 : 403, headers: cors(origin, allowed) });
128
+ }
129
+
130
+ // Not `405`. A surface that answered "method not allowed" would be confirming
131
+ // to any page that a Lanes read surface is here; a page that is not the
132
+ // dashboard learns nothing it did not send.
133
+ if (request.method !== 'GET') {
134
+ return json({ error: 'not_found' }, 404, cors(origin, allowed));
135
+ }
136
+
137
+ if (origin !== null && !allowed) {
138
+ return json({ error: 'origin_not_allowed' }, 403, cors(origin, false));
139
+ }
140
+
141
+ // The header is parsed before the store is asked anything. A request carrying
142
+ // no bearer cannot be the dashboard, and on a deployed workspace a store read
143
+ // is a network call — so answering it from the request alone is the
144
+ // difference between a stranger costing nothing and a stranger costing a
145
+ // Secret Manager round trip. `BearerAuthenticator` orders itself the same way
146
+ // for the same reason.
147
+ const presented = bearer(request);
148
+
149
+ if (presented === null || !(await deps.credential.verify(presented))) {
150
+ return json(
151
+ {
152
+ error: 'unpaired',
153
+ // The page shows this verbatim. Every failure looks the same from a
154
+ // browser — an expired certificate, a rotated token, a listener that is
155
+ // not running — so the answer is always the command that fixes all of
156
+ // them rather than a diagnosis the page cannot make.
157
+ run: 'lanes link pair',
158
+ },
159
+ 401,
160
+ cors(origin, allowed),
161
+ );
162
+ }
163
+
164
+ const url = new URL(request.url);
165
+
166
+ if (url.pathname === STATE_PATH) {
167
+ const rows = await deps.connections().catch(() => []);
168
+ return json(
169
+ readState(deps.workspace, deps.profiles(), rows, deps.endpoint),
170
+ 200,
171
+ cors(origin, allowed),
172
+ );
173
+ }
174
+
175
+ if (url.pathname === AUDIT_PATH) {
176
+ const limit = Math.min(
177
+ Number(url.searchParams.get('limit') ?? AUDIT_DEFAULT) || AUDIT_DEFAULT,
178
+ AUDIT_CEILING,
179
+ );
180
+ const profile = url.searchParams.get('profile');
181
+
182
+ const events = await deps.audit.tail({ limit });
183
+ const shown = profile ? events.filter((event) => event.profile === profile) : events;
184
+
185
+ return json(
186
+ {
187
+ events: shown.map((event) => ({
188
+ id: event.id,
189
+ timestamp: event.timestamp.toISOString(),
190
+ profile: event.profile,
191
+ principal: event.principal,
192
+ clientLabel: event.clientLabel ?? null,
193
+ provider: event.provider,
194
+ connection: event.connection ?? null,
195
+ capability: event.capability,
196
+ // Already redacted where it was written. This does not redact again,
197
+ // and must not start to: a second rule here would be a second answer
198
+ // to what is sensitive, and the log's own would stop being the truth.
199
+ arguments: event.arguments,
200
+ authorization: event.authorization,
201
+ // Authorised and then failed is a state the four fields above cannot
202
+ // express, and it is the one worth seeing: a call the policy allowed
203
+ // and the provider refused reads as a successful call without these.
204
+ status: event.status,
205
+ durationMs: event.durationMs,
206
+ error: event.error ?? null,
207
+ })),
208
+ },
209
+ 200,
210
+ cors(origin, allowed),
211
+ );
212
+ }
213
+
214
+ return json({ error: 'not_found' }, 404, cors(origin, allowed));
215
+ }
216
+
217
+ function bearer(request: Request): string | null {
218
+ const header = request.headers.get('authorization') ?? '';
219
+ if (!header.toLowerCase().startsWith('bearer ')) return null;
220
+
221
+ const presented = header.slice(7).trim();
222
+ return presented === '' ? null : presented;
223
+ }
224
+
225
+ function cors(origin: string | null, allowed: boolean): Record<string, string> {
226
+ // `Vary: Origin` unconditionally, including on a refusal. Without it a cache
227
+ // between here and the page can serve one origin's answer to another, which
228
+ // is the whole grant leaking through an intermediary.
229
+ const headers: Record<string, string> = { vary: 'Origin' };
230
+ if (!allowed || origin === null) return headers;
231
+
232
+ headers['access-control-allow-origin'] = origin;
233
+ headers['access-control-allow-headers'] = 'authorization';
234
+ headers['access-control-allow-methods'] = 'GET, OPTIONS';
235
+ headers['access-control-max-age'] = '600';
236
+ // Deliberately absent: `access-control-allow-credentials`. The token is sent
237
+ // explicitly by the page, so allowing cookies would add an ambient credential
238
+ // to a surface whose safety rests on there not being one.
239
+ return headers;
240
+ }
241
+
242
+ function json(body: unknown, status: number, headers: Record<string, string>): Response {
243
+ return new Response(JSON.stringify(body), {
244
+ status,
245
+ headers: { ...headers, 'content-type': 'application/json', 'cache-control': 'no-store' },
246
+ });
247
+ }
@@ -0,0 +1,171 @@
1
+ import { allowedConnections } from '#policy';
2
+ import type { ProfileRuntime } from '../mcp/visibility.ts';
3
+
4
+ /**
5
+ * What the dashboard is shown, assembled.
6
+ *
7
+ * Read-only by construction rather than by convention: this produces plain data
8
+ * and holds nothing that can mutate. Every field is something the CLI already
9
+ * prints — `status`, `connection list` and `profile show` in one document — so
10
+ * nothing here is readable that a person at this machine could not already read.
11
+ *
12
+ * **No credential is in it.** Not a connection's tokens, not a vault value, not
13
+ * the contents of a secret ref. What is here is the shape of the workspace:
14
+ * which accounts exist, which profiles select them, and what each allows.
15
+ */
16
+
17
+ export interface ReadConnection {
18
+ readonly ref: string;
19
+ readonly provider: string;
20
+ readonly id: string;
21
+ /**
22
+ * The operator's own word for it, and what a reader should be shown.
23
+ *
24
+ * `gmail.ada_lovelace` is an address, not a name. A dashboard listing refs is
25
+ * asking somebody to read identifiers when they gave the thing a label
26
+ * precisely so they would not have to.
27
+ */
28
+ readonly label: string | null;
29
+ /** The identity the provider reported at connect time. */
30
+ readonly account: string | null;
31
+ /** Which profiles grant this connection at all. */
32
+ readonly profiles: readonly string[];
33
+ }
34
+
35
+ /** The connection rows as `connections.yaml` holds them. */
36
+ export interface ConnectionRow {
37
+ readonly provider: string;
38
+ readonly id: string;
39
+ readonly account?: string | undefined;
40
+ readonly label?: string | undefined;
41
+ }
42
+
43
+ export interface ReadGrant {
44
+ readonly connection: string;
45
+ /** The capability ids reachable on it, after the floor and the profile's rules. */
46
+ readonly reachable: readonly string[];
47
+ }
48
+
49
+ export interface ReadProfile {
50
+ readonly name: string;
51
+ readonly description: string | null;
52
+ readonly grants: readonly ReadGrant[];
53
+ /** Subjects only. An email is a fact about a person that is not held here. */
54
+ readonly members: readonly { subject: string; role: string }[];
55
+ }
56
+
57
+ /**
58
+ * What the endpoint says about itself.
59
+ *
60
+ * Added because the dashboard now reads more than one kind of endpoint and had
61
+ * no way to tell which it was looking at — the page derived "local" from the
62
+ * fact that the only address it could reach was a loopback one, which stopped
63
+ * being true the moment a deployed workspace became readable.
64
+ *
65
+ * `certificateExpiresAt` is loopback's alone. ADR-063 promised that an expiry
66
+ * would be reported and nothing reported it; an expired certificate fails in
67
+ * the browser with an error the page cannot read, so the one place it can
68
+ * usefully appear is inside a response sent while the certificate still works.
69
+ * A deployed endpoint has none of its own — the platform terminates TLS — and
70
+ * says `null` rather than inventing one.
71
+ */
72
+ export interface ReadEndpoint {
73
+ readonly kind: 'local' | 'deployed';
74
+ /** The version serving this request, so a page can say what it is reading. */
75
+ readonly version: string;
76
+ readonly certificateExpiresAt: string | null;
77
+ }
78
+
79
+ export interface ReadState {
80
+ readonly workspace: string;
81
+ readonly endpoint: ReadEndpoint;
82
+ readonly connections: readonly ReadConnection[];
83
+ readonly profiles: readonly ReadProfile[];
84
+ }
85
+
86
+ /**
87
+ * The workspace as the dashboard sees it.
88
+ *
89
+ * `reachable` comes from `allowedConnections`, the same call discovery and
90
+ * enforcement make. That is the point rather than a convenience: a dashboard
91
+ * that decided visibility its own way would eventually disagree with the
92
+ * endpoint about what a profile can do, and the version a person reads would be
93
+ * the wrong one.
94
+ */
95
+ export function readState(
96
+ workspace: string,
97
+ profiles: ReadonlyMap<string, ProfileRuntime>,
98
+ rows: readonly ConnectionRow[],
99
+ endpoint: ReadEndpoint,
100
+ ): ReadState {
101
+ // The workspace's own list is the source of truth for *which connections
102
+ // exist*. Deriving it from the grants instead made an account that no profile
103
+ // grants invisible — and that is precisely the state `connect` leaves one in
104
+ // when it is run without `--profile`, so a freshly authorised account did not
105
+ // appear at all. Grants say who can reach a connection, not whether it is
106
+ // there.
107
+ const grantedBy = new Map<string, string[]>();
108
+ const described: ReadProfile[] = [];
109
+
110
+ for (const [name, runtime] of profiles) {
111
+ const grants: ReadGrant[] = [];
112
+
113
+ for (const grant of runtime.config.grants) {
114
+ const ref = grant.connection;
115
+ grantedBy.set(ref, [...(grantedBy.get(ref) ?? []), name]);
116
+
117
+ const reachable = runtime.registry
118
+ .capabilities()
119
+ .filter(
120
+ ({ id: capability }) =>
121
+ allowedConnections(capability, [ref], name, runtime.policy, runtime.floor).length > 0,
122
+ )
123
+ .map(({ id: capability }) => capability);
124
+
125
+ grants.push({ connection: ref, reachable });
126
+ }
127
+
128
+ described.push({
129
+ name,
130
+ description: runtime.config.description ?? null,
131
+ grants,
132
+ members: runtime.config.members.map((member) => ({
133
+ subject: member.subject,
134
+ role: member.role,
135
+ })),
136
+ });
137
+ }
138
+
139
+ const connections: ReadConnection[] = rows.map((row) => {
140
+ const ref = `${row.provider}.${row.id}`;
141
+ return {
142
+ ref,
143
+ provider: row.provider,
144
+ id: row.id,
145
+ label: row.label ?? null,
146
+ account: row.account ?? null,
147
+ profiles: grantedBy.get(ref) ?? [],
148
+ };
149
+ });
150
+
151
+ // A grant naming a connection the workspace no longer holds. `assertGrantsResolve`
152
+ // refuses this at load, so it is unreachable through the CLI — but the read
153
+ // surface should describe what is there rather than assume, and a row that
154
+ // appeared only in a grant would otherwise vanish from the listing while
155
+ // still governing a profile.
156
+ const known = new Set(connections.map((one) => one.ref));
157
+ for (const ref of grantedBy.keys()) {
158
+ if (known.has(ref)) continue;
159
+ const [provider = ref, id = ''] = ref.split('.');
160
+ connections.push({
161
+ ref,
162
+ provider,
163
+ id,
164
+ label: null,
165
+ account: null,
166
+ profiles: grantedBy.get(ref) ?? [],
167
+ });
168
+ }
169
+
170
+ return { workspace, endpoint, connections, profiles: described };
171
+ }