@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.
- package/README.md +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +136 -61
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +65 -0
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +108 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/brand.ts +39 -10
- package/src/cli/callback-page.ts +37 -104
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +88 -87
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +4 -2
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +182 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +21 -9
- package/src/cli/commands/knowledge/index.ts +46 -79
- package/src/cli/commands/knowledge/migrate.ts +74 -13
- package/src/cli/commands/knowledge/show.ts +92 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +29 -12
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +52 -4
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/profile/removal.ts +79 -76
- package/src/cli/commands/profile/remove.ts +16 -1
- package/src/cli/commands/profile.ts +46 -10
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +34 -12
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update.ts +58 -17
- package/src/cli/config-edit.ts +75 -140
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair.ts +89 -32
- package/src/cli/config-templates.ts +198 -0
- package/src/cli/contract3-data.ts +328 -0
- package/src/cli/contract3-shape.ts +186 -0
- package/src/cli/contract3.ts +282 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +89 -14
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +5 -2
- package/src/cli/runtime/open.ts +63 -98
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/stores.ts +53 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +34 -18
- package/src/cli/workspace-migrate.ts +125 -16
- package/src/connectivity/manifest/provider.ts +3 -1
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +36 -27
- package/src/deployments/gcp/bucket.ts +18 -7
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +15 -15
- package/src/deployments/upload.ts +46 -22
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +183 -0
- package/src/profile/deployments.ts +3 -3
- package/src/profile/index.ts +30 -5
- package/src/profile/layout.ts +86 -89
- package/src/profile/load.ts +80 -47
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +172 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +69 -2
- package/src/profile/workspace.ts +58 -3
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +20 -2
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/slack/index.ts +2 -2
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +85 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +1 -1
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/tools.ts +9 -3
- package/src/server/mcp/visibility.ts +18 -3
- package/src/server/oauth.ts +29 -75
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- 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
|
+
}
|