@lanes-sh/link 0.7.2 → 0.9.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 (190) 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 +148 -73
  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 +85 -4
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +109 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +90 -88
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +16 -6
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +183 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +24 -12
  42. package/src/cli/commands/knowledge/index.ts +49 -89
  43. package/src/cli/commands/knowledge/migrate.ts +79 -13
  44. package/src/cli/commands/knowledge/show.ts +97 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +33 -13
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +53 -5
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/assets.ts +2 -2
  66. package/src/cli/commands/owner/entities.ts +2 -2
  67. package/src/cli/commands/owner/memory.ts +2 -2
  68. package/src/cli/commands/owner/shared.ts +13 -2
  69. package/src/cli/commands/owner/skills.ts +28 -8
  70. package/src/cli/commands/owner/tasks.ts +2 -2
  71. package/src/cli/commands/owner/vault.ts +3 -3
  72. package/src/cli/commands/profile/disposition.ts +236 -0
  73. package/src/cli/commands/profile/removal.ts +154 -64
  74. package/src/cli/commands/profile/remove.ts +83 -7
  75. package/src/cli/commands/profile.ts +79 -16
  76. package/src/cli/commands/relabel.ts +112 -0
  77. package/src/cli/commands/secrets.ts +39 -17
  78. package/src/cli/commands/set-workspace.ts +96 -0
  79. package/src/cli/commands/setup.ts +2 -2
  80. package/src/cli/commands/sync.ts +8 -8
  81. package/src/cli/commands/target.ts +9 -7
  82. package/src/cli/commands/update-migration.ts +54 -0
  83. package/src/cli/commands/update.ts +78 -24
  84. package/src/cli/config-edit.ts +99 -143
  85. package/src/cli/config-migrate.ts +82 -64
  86. package/src/cli/config-repair-sweep.ts +119 -0
  87. package/src/cli/config-repair.ts +131 -125
  88. package/src/cli/config-templates.ts +200 -0
  89. package/src/cli/contract3-credentials.ts +294 -0
  90. package/src/cli/contract3-data.ts +262 -0
  91. package/src/cli/contract3-layout.ts +46 -0
  92. package/src/cli/contract3-shape.ts +212 -0
  93. package/src/cli/contract3.ts +399 -0
  94. package/src/cli/contract4-credentials.ts +207 -0
  95. package/src/cli/contract4-data.ts +399 -0
  96. package/src/cli/contract4-rename.ts +73 -0
  97. package/src/cli/contract4-yaml.ts +223 -0
  98. package/src/cli/contract4.ts +342 -0
  99. package/src/cli/endpoint-url.ts +1 -1
  100. package/src/cli/identity.ts +44 -26
  101. package/src/cli/lanes.ts +25 -1
  102. package/src/cli/main.ts +94 -14
  103. package/src/cli/migrate-move.ts +166 -0
  104. package/src/cli/migrate-plan.ts +12 -6
  105. package/src/cli/output.ts +34 -1
  106. package/src/cli/publish.ts +6 -7
  107. package/src/cli/runtime/open.ts +64 -99
  108. package/src/cli/runtime/registry.ts +6 -7
  109. package/src/cli/runtime/select.ts +2 -11
  110. package/src/cli/runtime/stores.ts +58 -0
  111. package/src/cli/runtime/types.ts +106 -0
  112. package/src/cli/runtime/vault.ts +19 -4
  113. package/src/cli/runtime/workspace.ts +60 -0
  114. package/src/cli/runtime.ts +2 -1
  115. package/src/cli/selection-require.ts +44 -13
  116. package/src/cli/selection.ts +127 -145
  117. package/src/cli/usage.ts +40 -20
  118. package/src/cli/workspace-migrate.ts +152 -22
  119. package/src/connectivity/manifest/provider.ts +34 -13
  120. package/src/connectivity/manifest/requirements.ts +1 -1
  121. package/src/connectivity/transports/imap/parser.ts +70 -9
  122. package/src/deployments/adapters/filesystem.ts +18 -3
  123. package/src/deployments/bind.ts +1 -1
  124. package/src/deployments/deploy.ts +38 -29
  125. package/src/deployments/gcp/bucket.ts +58 -11
  126. package/src/deployments/gcp/provision.ts +7 -7
  127. package/src/deployments/knowledge.ts +9 -4
  128. package/src/deployments/prepare.ts +72 -24
  129. package/src/deployments/record.ts +1 -1
  130. package/src/deployments/report.ts +2 -2
  131. package/src/deployments/serving.ts +15 -74
  132. package/src/deployments/target.ts +34 -13
  133. package/src/deployments/upload.ts +60 -27
  134. package/src/dispatch/deps.ts +88 -0
  135. package/src/dispatch/dispatch.ts +21 -62
  136. package/src/policy/index.ts +47 -15
  137. package/src/profile/connections.ts +195 -0
  138. package/src/profile/deployments.ts +86 -8
  139. package/src/profile/index.ts +35 -6
  140. package/src/profile/knowledge.ts +18 -5
  141. package/src/profile/layout.ts +163 -90
  142. package/src/profile/load.ts +133 -64
  143. package/src/profile/pairing.ts +32 -0
  144. package/src/profile/primitives.ts +35 -1
  145. package/src/profile/registry.ts +6 -6
  146. package/src/profile/schema.ts +181 -21
  147. package/src/profile/targets.ts +21 -9
  148. package/src/profile/testing.ts +104 -2
  149. package/src/profile/workspace.ts +124 -33
  150. package/src/providers/assets/provider.ts +6 -6
  151. package/src/providers/custom/index.ts +1 -1
  152. package/src/providers/custom/load.ts +2 -3
  153. package/src/providers/entities/provider.ts +6 -6
  154. package/src/providers/entities/writes.ts +1 -1
  155. package/src/providers/identity/provider.ts +2 -2
  156. package/src/providers/memory/provider.ts +26 -8
  157. package/src/providers/setup/plan.ts +1 -1
  158. package/src/providers/setup/provider.ts +3 -3
  159. package/src/providers/skills/provider.ts +2 -2
  160. package/src/providers/slack/index.ts +2 -2
  161. package/src/providers/tasks/provider.ts +6 -6
  162. package/src/providers/vault/provider.ts +1 -1
  163. package/src/registry/policy-bridge.ts +33 -11
  164. package/src/registry/reconcile.ts +4 -4
  165. package/src/registry/registry.ts +1 -1
  166. package/src/server/authorization.ts +94 -0
  167. package/src/server/edge.ts +14 -1
  168. package/src/server/endpoint.ts +89 -104
  169. package/src/server/generation.ts +10 -1
  170. package/src/server/harness.ts +71 -13
  171. package/src/server/index.ts +31 -0
  172. package/src/server/mcp/build.ts +20 -1
  173. package/src/server/mcp/client-info.ts +54 -0
  174. package/src/server/mcp/guide.ts +120 -0
  175. package/src/server/mcp/instructions.ts +22 -22
  176. package/src/server/mcp/prompts.ts +7 -3
  177. package/src/server/mcp/resources.ts +16 -8
  178. package/src/server/mcp/routing.ts +3 -3
  179. package/src/server/mcp/tools.ts +25 -6
  180. package/src/server/mcp/visibility.ts +74 -7
  181. package/src/server/oauth.ts +29 -109
  182. package/src/server/read/credential.ts +134 -0
  183. package/src/server/read/deployed.ts +56 -0
  184. package/src/server/read/listener.ts +54 -0
  185. package/src/server/read/open.ts +101 -0
  186. package/src/server/read/routes.ts +247 -0
  187. package/src/server/read/state.ts +171 -0
  188. package/src/stores/blobs/conformance.ts +19 -0
  189. package/src/stores/state/index.ts +76 -10
  190. package/src/stores/state/testing.ts +5 -1
@@ -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
+ }
@@ -177,6 +177,25 @@ export function describeBlobStoreContract(
177
177
  });
178
178
  });
179
179
 
180
+ test('allows a name that merely begins with dots', async () => {
181
+ await use(async (store) => {
182
+ // Not a traversal — an ordinary name that happens to start with two
183
+ // dots, which a provider will hand over verbatim because it is a
184
+ // caller's to choose. One adapter refused these and the rest stored
185
+ // them, so the same workspace behaved differently depending on which
186
+ // target it was opened against. That is the divergence this whole
187
+ // suite exists to catch, and it caught nothing until there was a case
188
+ // for it.
189
+ // Distinct names, because a filesystem cannot hold `x` as a file and
190
+ // `x/y` beneath it at once — a real difference between an object store
191
+ // and a directory tree, and not the one under test here.
192
+ for (const key of ['..config', '...rc', '..nested/inner.txt']) {
193
+ await store.put(key, bytes(key));
194
+ expect(text(await store.get(key))).toBe(key);
195
+ }
196
+ });
197
+ });
198
+
180
199
  test('allows a traversal that stays inside, normalised', async () => {
181
200
  await use(async (store) => {
182
201
  // Weaker than `scopeBlobStore`'s rule on purpose: the namespace
@@ -26,6 +26,11 @@
26
26
  import type { BlobStore } from '../blobs/index.ts';
27
27
  import { keyFromEntry, namespacePrefix, objectKey } from './keys.ts';
28
28
 
29
+ // Re-exported because the contract-3 migration has to address objects this
30
+ // module wrote, and deriving `connections%2Ev1/gmail%2Emain.json` by hand at the
31
+ // call site is the second spelling of an encoding that must not have two.
32
+ export { decodeSegment, encodeSegment, objectKey } from './keys.ts';
33
+
29
34
  export type ConnectionStatus =
30
35
  | 'active'
31
36
  /** Declared, but its credential is missing or rejected. Does not block startup. */
@@ -94,17 +99,78 @@ export interface RuntimeState {
94
99
  * contain. The same rule keeps `audit.log` and `state.kv` out of reach in
95
100
  * `#profile`'s layout.
96
101
  */
97
- const CONNECTIONS = 'connections.v1';
102
+ export const CONNECTIONS_NAMESPACE = 'connections.v1';
98
103
  const CURSORS = 'cursors.v1';
99
104
 
105
+ /**
106
+ * The discovery cache, and what the endpoint's own OAuth server remembers.
107
+ *
108
+ * Both carry a `.v1` for the reason `connections.v1` and `cursors.v1` do, and
109
+ * here it is doing more than versioning. A provider's own keys are namespaced
110
+ * `<provider>/<connection>` in this same store, a provider id is
111
+ * `[a-z][a-z0-9_]*`, and only `custom` is refused by grammar — so while these
112
+ * were spelled `discovery` and `oauth/clients`, a manifest with id `discovery`
113
+ * or `oauth` landed inside them. That was a collision when everything shared
114
+ * one store; with the workspace/profile split below it would be a silent
115
+ * cross-profile leak, because these two route to the workspace. A dotted name
116
+ * is one no provider can reach.
117
+ */
118
+ export const DISCOVERY_NAMESPACE = 'discovery.v1';
119
+ export const OAUTH_NAMESPACE = 'oauth.v1';
120
+
121
+ /**
122
+ * Whether a namespace belongs to the workspace rather than to one profile.
123
+ *
124
+ * Connection records and the discovery cache are facts about an account or a
125
+ * provider — a `connect` run once must read as connected from every profile —
126
+ * and the OAuth server is the endpoint's, not a profile's. Everything else is
127
+ * this profile's use of an account: its cursors, and each provider's own keys.
128
+ *
129
+ * Every name here carries a dot, so the list cannot be entered by a provider.
130
+ */
131
+ export function isWorkspaceNamespace(namespace: string): boolean {
132
+ return (
133
+ namespace === CONNECTIONS_NAMESPACE ||
134
+ namespace === DISCOVERY_NAMESPACE ||
135
+ namespace === OAUTH_NAMESPACE ||
136
+ namespace.startsWith(`${OAUTH_NAMESPACE}/`)
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Runtime state over two stores: the workspace's, and one profile's.
142
+ *
143
+ * `kv` routes on the namespace rather than being two stores the caller picks
144
+ * between, because the callers are providers and they do not know which of
145
+ * their keys are shared — nor should they. `isWorkspaceNamespace` is the whole
146
+ * rule, and it is closed: a namespace it does not name is the profile's.
147
+ *
148
+ * Both are required, with no default that falls back to one. A default would
149
+ * mean forgetting the second argument shares every profile's cursors and
150
+ * provider keys silently — which is the failure this split exists to remove.
151
+ * Passing the same store twice is still how a test that is not exercising the
152
+ * split says so, and it says it out loud.
153
+ */
100
154
  export function createRuntimeState(
101
155
  blobs: BlobStore,
156
+ profileBlobs: BlobStore,
102
157
  now: () => Date = () => new Date(),
103
158
  ): RuntimeState {
104
- const kv = createKeyValue(blobs);
159
+ const workspaceKv = createKeyValue(blobs);
160
+ const profileKv = createKeyValue(profileBlobs);
161
+ const forNamespace = (namespace: string): KeyValueStore =>
162
+ isWorkspaceNamespace(namespace) ? workspaceKv : profileKv;
163
+
164
+ const kv: KeyValueStore = {
165
+ get: (namespace, key) => forNamespace(namespace).get(namespace, key),
166
+ set: (namespace, key, value) => forNamespace(namespace).set(namespace, key, value),
167
+ delete: (namespace, key) => forNamespace(namespace).delete(namespace, key),
168
+ keys: (namespace) => forNamespace(namespace).keys(namespace),
169
+ clearNamespace: (namespace) => forNamespace(namespace).clearNamespace(namespace),
170
+ };
105
171
 
106
172
  const readConnection = async (provider: string, id: string): Promise<ConnectionRecord | null> =>
107
- decodeConnection(await kv.get(CONNECTIONS, connectionKey(provider, id)));
173
+ decodeConnection(await workspaceKv.get(CONNECTIONS_NAMESPACE, connectionKey(provider, id)));
108
174
 
109
175
  const connections: ConnectionRepository = {
110
176
  async upsert(record) {
@@ -116,7 +182,7 @@ export function createRuntimeState(
116
182
  createdAt: existing?.createdAt ?? now(),
117
183
  updatedAt: now(),
118
184
  };
119
- await kv.set(CONNECTIONS, connectionKey(record.provider, record.id), encodeConnection(stored));
185
+ await workspaceKv.set(CONNECTIONS_NAMESPACE, connectionKey(record.provider, record.id), encodeConnection(stored));
120
186
  return stored;
121
187
  },
122
188
 
@@ -124,8 +190,8 @@ export function createRuntimeState(
124
190
 
125
191
  async list() {
126
192
  const records: ConnectionRecord[] = [];
127
- for (const key of await kv.keys(CONNECTIONS)) {
128
- const record = decodeConnection(await kv.get(CONNECTIONS, key));
193
+ for (const key of await workspaceKv.keys(CONNECTIONS_NAMESPACE)) {
194
+ const record = decodeConnection(await workspaceKv.get(CONNECTIONS_NAMESPACE, key));
129
195
  if (record) records.push(record);
130
196
  }
131
197
  return records.sort(
@@ -136,8 +202,8 @@ export function createRuntimeState(
136
202
  async setStatus(provider, id, status) {
137
203
  const existing = await readConnection(provider, id);
138
204
  if (!existing) return;
139
- await kv.set(
140
- CONNECTIONS,
205
+ await workspaceKv.set(
206
+ CONNECTIONS_NAMESPACE,
141
207
  connectionKey(provider, id),
142
208
  encodeConnection({ ...existing, status, updatedAt: now() }),
143
209
  );
@@ -145,8 +211,8 @@ export function createRuntimeState(
145
211
  };
146
212
 
147
213
  const cursors: CursorRepository = {
148
- get: (namespace) => kv.get(CURSORS, namespace),
149
- set: (namespace, cursor) => kv.set(CURSORS, namespace, cursor),
214
+ get: (namespace) => profileKv.get(CURSORS, namespace),
215
+ set: (namespace, cursor) => profileKv.set(CURSORS, namespace, cursor),
150
216
  };
151
217
 
152
218
  return { connections, kv, cursors };
@@ -15,7 +15,11 @@ import { createMemoryBlobStore } from '../blobs/testing.ts';
15
15
  import { createRuntimeState, type RuntimeState } from './index.ts';
16
16
 
17
17
  export function createMemoryState(now: () => Date = () => new Date()): RuntimeState {
18
- return createRuntimeState(createMemoryBlobStore(), now);
18
+ // The same store twice: this harness is for callers that are not exercising
19
+ // the workspace/profile split, and saying so explicitly is the point of
20
+ // `createRuntimeState` having no default.
21
+ const blobs = createMemoryBlobStore();
22
+ return createRuntimeState(blobs, blobs, now);
19
23
  }
20
24
 
21
25
  /** An in-memory credential store, for the same reason. */