@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
@@ -1,21 +1,43 @@
1
- import type { PolicyDocument, PolicyRule } from '#policy';
2
- import type { Config } from '#profile';
1
+ import type { PolicyDocument, PolicyRule, ProfilePolicy } from '#policy';
2
+ import type { Config, GrantConfig } from '#profile';
3
3
 
4
4
  /**
5
- * Turn the config's `policy` block into the document the policy engine
5
+ * Turn the config's `grants:` rows into the documents the policy engine
6
6
  * evaluates.
7
7
  *
8
- * The config format keeps `allow` and `deny` as separate lists because that is
9
- * what reads clearly in YAML; the engine wants one list carrying an effect,
10
- * because evaluation must consider both together to make deny win regardless of
11
- * ordering. Converting here keeps that difference from leaking either way.
8
+ * Two format differences are reconciled here, and keeping them in one place is
9
+ * what stops either leaking into the other side.
10
+ *
11
+ * The config keeps `allow` and `deny` as separate lists because that is what
12
+ * reads clearly in YAML; the engine wants one list carrying an effect, because
13
+ * evaluation must consider both together to make deny win regardless of
14
+ * ordering.
15
+ *
16
+ * And the config is a *sequence* of rows while the engine wants a lookup by
17
+ * connection (ADR-058). A profile cannot declare the same connection twice —
18
+ * `assertGrantsResolve` refuses it at load, precisely so that this conversion
19
+ * has no collision to resolve and no precedence to invent.
20
+ *
21
+ * This is the only place `#profile`'s shape meets `#policy`'s. `#policy` may
22
+ * not import `#profile` and does not need to: it takes a map of rule lists and
23
+ * knows nothing about YAML, grants, or where a connection was declared.
12
24
  */
13
- export function toPolicyDocument(config: Config): PolicyDocument {
25
+ export function toPolicyDocument(config: Config): ProfilePolicy {
26
+ const byConnection = new Map<string, PolicyDocument>();
27
+
28
+ for (const grant of config.grants) {
29
+ byConnection.set(grant.connection, { rules: rulesFor(grant) });
30
+ }
31
+
32
+ return { byConnection };
33
+ }
34
+
35
+ function rulesFor(grant: GrantConfig): PolicyRule[] {
14
36
  const rules: PolicyRule[] = [];
15
37
 
16
38
  for (const [effect, entries] of [
17
- ['allow', config.policy.allow],
18
- ['deny', config.policy.deny],
39
+ ['allow', grant.allow],
40
+ ['deny', grant.deny],
19
41
  ] as const) {
20
42
  for (const entry of entries) {
21
43
  rules.push({
@@ -28,5 +50,5 @@ export function toPolicyDocument(config: Config): PolicyDocument {
28
50
  }
29
51
  }
30
52
 
31
- return { rules };
53
+ return rules;
32
54
  }
@@ -144,7 +144,7 @@ export function planIsNoop(plan: ReconcilePlan): boolean {
144
144
  * half-configured Gmail account must not stop the other providers from serving.
145
145
  */
146
146
  export async function planReconcile(
147
- config: Config,
147
+ connections: readonly ConnectionConfig[],
148
148
  state: RuntimeState,
149
149
  credentials: SecretStore,
150
150
  /**
@@ -164,7 +164,7 @@ export async function planReconcile(
164
164
  const unauthorized: string[] = [];
165
165
  const declared = new Set<string>();
166
166
 
167
- for (const connection of config.connections) {
167
+ for (const connection of connections) {
168
168
  const key = `${connection.provider}.${connection.id}`;
169
169
  declared.add(key);
170
170
 
@@ -229,11 +229,11 @@ export async function planReconcile(
229
229
  }
230
230
 
231
231
  export async function applyReconcile(
232
- config: Config,
232
+ connections: readonly ConnectionConfig[],
233
233
  state: RuntimeState,
234
234
  plan: ReconcilePlan,
235
235
  ): Promise<void> {
236
- const byKey = new Map(config.connections.map((c) => [`${c.provider}.${c.id}`, c]));
236
+ const byKey = new Map(connections.map((one) => [`${one.provider}.${one.id}`, one]));
237
237
 
238
238
  for (const action of plan.actions) {
239
239
  if (action.kind === 'unchanged') continue;
@@ -78,7 +78,7 @@ export class ProviderRegistry {
78
78
 
79
79
  if (!this.#allowReserved && RESERVED_PROVIDER_IDS.includes(manifest.id)) {
80
80
  throw new Error(
81
- `Provider id "${manifest.id}" is reserved for the owner layer (memory, skills, vault) and cannot be claimed.`,
81
+ `Provider id "${manifest.id}" is reserved for the owner layer (the lanes_ surfaces) and cannot be claimed.`,
82
82
  );
83
83
  }
84
84
 
@@ -0,0 +1,94 @@
1
+ import {
2
+ IssuedTokenAuthenticator,
3
+ OAuthServer,
4
+ OAuthStore,
5
+ OidcAuthenticator,
6
+ OidcVerifier,
7
+ lanesFederation,
8
+ type Authenticator,
9
+ } from '#auth';
10
+ import type { Logger } from '#connectivity';
11
+ import type { Runtime } from '#cli/runtime.ts';
12
+ import { MCP_PATH } from './index.ts';
13
+ import type { AuthorizationSurface } from './oauth.ts';
14
+
15
+ /**
16
+ * Deciding how a remote client proves who it is.
17
+ *
18
+ * Its own file, away from the lifecycle in `endpoint.ts`, because the two
19
+ * answer different questions and only one of them is interesting. Starting an
20
+ * endpoint is bind, serve, reload, stop. This is which of three models the
21
+ * profile declared — none, an issuer of its own, or somebody else's — and each
22
+ * arm carries an argument that has to be read to be changed safely.
23
+ */
24
+
25
+ /**
26
+ * The remote-client gate, if this profile declares one.
27
+ *
28
+ * Endpoint-scoped rather than per profile, like the bearer token and for the
29
+ * same reason (ADR-009): one URL serves every profile in the workspace, so
30
+ * there is one place a client authorises and one set of tokens.
31
+ *
32
+ * Returns null when `auth.authorization` is absent, and everything downstream
33
+ * treats null as "exactly as before" — no metadata published, no pointer on the
34
+ * `401`, one authenticator instead of a chain.
35
+ */
36
+ export async function openAuthorization(
37
+ primary: Runtime,
38
+ log: Logger,
39
+ members: (subject: string) => Promise<readonly string[]>,
40
+ ): Promise<{ surface: AuthorizationSurface; authenticator: Authenticator } | null> {
41
+ const declared = primary.config.auth.authorization;
42
+ if (!declared) return null;
43
+
44
+ const profile = primary.resolution.profile;
45
+
46
+ if (declared.mode === 'oidc') {
47
+ const audience = await primary.credentials.get(declared.client_id_ref);
48
+ if (!audience) {
49
+ // Refuse rather than verify without an audience. A verifier that cannot
50
+ // check who a token was issued for accepts every token the issuer minted
51
+ // for anything, which is the failure this mode exists to prevent.
52
+ throw new Error(
53
+ `auth.authorization.client_id_ref names "${declared.client_id_ref}", which is not in ` +
54
+ `this target's credential store. Store it with: lanes link secrets set ${declared.client_id_ref}`,
55
+ );
56
+ }
57
+
58
+ const verifier = new OidcVerifier({
59
+ issuer: declared.issuer,
60
+ audience,
61
+ allowedSubjects: declared.allowed_subjects,
62
+ ...(declared.introspection_endpoint
63
+ ? { introspectionEndpoint: declared.introspection_endpoint }
64
+ : {}),
65
+ });
66
+
67
+ return {
68
+ // The issuer is somebody else's origin, so it is a constant here rather
69
+ // than derived from the request.
70
+ surface: { issuer: () => declared.issuer, mcpPath: MCP_PATH, target: primary.target },
71
+ authenticator: new OidcAuthenticator(verifier, profile),
72
+ };
73
+ }
74
+
75
+ const store = new OAuthStore(primary.state.kv);
76
+
77
+ const server = new OAuthServer({
78
+ store,
79
+ accessTokenTtlMs: declared.access_token_ttl_minutes * 60_000,
80
+ // So a replayed refresh token leaves a line. It is refused rather than
81
+ // acted on (ADR-035), and a refusal nobody can see is how a connector
82
+ // losing its authorization came to need log forensics to explain.
83
+ log,
84
+ // Identity comes from lanes.sh, not from a credential pasted into a form on
85
+ // this machine (ADR-062). What this endpoint decides is the half lanes.sh
86
+ // cannot know: which of *its* profiles name that person.
87
+ federation: lanesFederation({ profilesFor: members }),
88
+ });
89
+
90
+ return {
91
+ surface: { server, issuer: (origin) => origin, mcpPath: MCP_PATH, target: primary.target },
92
+ authenticator: new IssuedTokenAuthenticator(store, profile),
93
+ };
94
+ }
@@ -33,11 +33,20 @@ export const FAILED_AUTH_PER_MINUTE = 30;
33
33
  * - `/authorize` compares against the endpoint token, which is another read of
34
34
  * the credential store.
35
35
  * - `/token` reads and writes bucket objects.
36
+ * - `/state` and `/audit` verify the pairing token, which on a deployed
37
+ * workspace is a Secret Manager call and has no cache behind it at all.
36
38
  *
37
39
  * `/health` presented with *no* credential is deliberately free: it reads
38
40
  * nothing, and it is what a platform probe and `lanes link outputs` send.
39
41
  * Metering it would put a ceiling on the one request that costs nothing to
40
42
  * answer.
43
+ *
44
+ * The read surface is **not** given that exemption, and the asymmetry is the
45
+ * point: nothing legitimate calls `/state` without a credential — no probe, no
46
+ * CLI command — so there is no free request to protect. Metering it
47
+ * unconditionally also means the ceiling does not depend on `readRoutes`
48
+ * continuing to parse the header before it asks the store anything. That
49
+ * short-circuit is an optimisation; this is the guarantee.
41
50
  */
42
51
  export const UNAUTHENTICATED_PER_MINUTE = 30;
43
52
 
@@ -141,6 +150,9 @@ export function unauthenticatedRefusal(input: {
141
150
  readonly healthPath: string;
142
151
  readonly isAuthorizationPath: (pathname: string) => boolean;
143
152
  readonly authorizationEnabled: boolean;
153
+ /** Passed rather than imported, for the reason `isAuthorizationPath` is. */
154
+ readonly isReadPath: (pathname: string) => boolean;
155
+ readonly readEnabled: boolean;
144
156
  }): Response | undefined {
145
157
  // A `/health` carrying no credential reads nothing and is deliberately free —
146
158
  // it is what a platform probe and `lanes link outputs` send, and a ceiling on
@@ -149,7 +161,8 @@ export function unauthenticatedRefusal(input: {
149
161
  const costly =
150
162
  input.pathname === input.healthPath
151
163
  ? input.request.headers.get('authorization') !== null
152
- : input.authorizationEnabled && input.isAuthorizationPath(input.pathname);
164
+ : (input.readEnabled && input.isReadPath(input.pathname)) ||
165
+ (input.authorizationEnabled && input.isAuthorizationPath(input.pathname));
153
166
 
154
167
  if (!costly) return undefined;
155
168
 
@@ -1,21 +1,16 @@
1
- import { MCP_PATH, serve } from './index.ts';
1
+ import { MCP_PATH, serve, type RunningServer } from './index.ts';
2
2
  import { serveOverStdio } from './stdio.ts';
3
3
  import { Generations, type OpenedWorkspace } from './generations.ts';
4
4
  import type { AuthorizationSurface } from './oauth.ts';
5
5
  import type { ProfileRuntime } from './mcp/index.ts';
6
- import {
7
- AuthenticatorChain,
8
- IssuedTokenAuthenticator,
9
- OAuthServer,
10
- OAuthStore,
11
- OidcAuthenticator,
12
- OidcVerifier,
13
- tokensMatch,
14
- type Authenticator,
15
- } from '#auth';
6
+ import { AuthenticatorChain } from '#auth';
7
+ import { openAuthorization } from './authorization.ts';
8
+ import { openReadListener } from './read/open.ts';
9
+ import { deployedReadDeps } from './read/deployed.ts';
10
+ import { version } from '#cli/version.ts';
16
11
  import type { Logger } from '#connectivity';
17
12
  import { silentLogger } from './logging.ts';
18
- import { listProfiles } from '#profile';
13
+ import { listProfiles, readConnections } from '#profile';
19
14
  import {
20
15
  applyReconcile,
21
16
  formatPlan,
@@ -82,6 +77,8 @@ export interface EndpointOptions {
82
77
  export interface RunningEndpoint {
83
78
  readonly url: string;
84
79
  readonly profiles: readonly string[];
80
+ /** The dashboard read surface, when this workspace is paired (ADR-063). */
81
+ readonly readUrl?: string | undefined;
85
82
  stop(): Promise<void>;
86
83
  }
87
84
 
@@ -133,21 +130,33 @@ async function openReconciled(options: {
133
130
  }
134
131
  }
135
132
 
136
- for (const [name, runtime] of runtimes) {
137
- const result = await planReconcile(
138
- runtime.config,
139
- runtime.state,
140
- runtime.credentials,
141
- runtime.manifestFor,
142
- );
143
- if (!planIsNoop(result)) {
144
- reporter.reconciled({
145
- profile: name,
146
- plan: formatPlan(result),
147
- ofMany: runtimes.size > 1,
148
- });
149
- await applyReconcile(runtime.config, runtime.state, result);
150
- }
133
+ // Once for the workspace, over every connection the workspace holds.
134
+ //
135
+ // Runtime state is one store per workspace since contract 3, and reconcile
136
+ // disables everything in it that the connection list does not declare — so
137
+ // running it per profile over that profile's *grants* had each pass disable
138
+ // the connections only the other profiles granted. Two profiles was enough:
139
+ // the second pass disabled the first's accounts, every later call was
140
+ // refused `denied_connection_unauthorized`, and restarting flipped which
141
+ // profile survived.
142
+ //
143
+ // The primary's runtime is used for the stores because they are the same
144
+ // stores for every profile here. `workspaceConnections` is the whole list,
145
+ // which is what "undeclared" has to be measured against.
146
+ const declared = primary.workspaceConnections;
147
+ const result = await planReconcile(
148
+ declared,
149
+ primary.state,
150
+ primary.credentials,
151
+ primary.manifestFor,
152
+ );
153
+ if (!planIsNoop(result)) {
154
+ reporter.reconciled({
155
+ profile: primary.resolution.profile,
156
+ plan: formatPlan(result),
157
+ ofMany: false,
158
+ });
159
+ await applyReconcile(declared, primary.state, result);
151
160
  }
152
161
 
153
162
  return { primary, runtimes };
@@ -167,6 +176,10 @@ function profileRuntimes(runtimes: ReadonlyMap<string, Runtime>): Map<string, Pr
167
176
  name,
168
177
  {
169
178
  config: runtime.config,
179
+ // For the `connection` argument's description: an id says nothing about
180
+ // which account it is, and the caller choosing between two of a vendor
181
+ // has only that description to go on.
182
+ connections: runtime.connections,
170
183
  registry: runtime.registry,
171
184
  dispatcher: runtime.dispatcher,
172
185
  policy: toPolicyDocument(runtime.config),
@@ -183,80 +196,6 @@ function closeAll(runtimes: ReadonlyMap<string, Runtime>): Promise<unknown> {
183
196
  return Promise.all([...runtimes.values()].map((runtime) => runtime.close()));
184
197
  }
185
198
 
186
- /**
187
- * The remote-client gate, if this profile declares one.
188
- *
189
- * Endpoint-scoped rather than per profile, like the bearer token and for the
190
- * same reason (ADR-009): one URL serves every profile in the workspace, so
191
- * there is one place a client authorises and one set of tokens.
192
- *
193
- * Returns null when `auth.authorization` is absent, and everything downstream
194
- * treats null as "exactly as before" — no metadata published, no pointer on the
195
- * `401`, one authenticator instead of a chain.
196
- */
197
- async function openAuthorization(
198
- primary: Runtime,
199
- log: Logger,
200
- ): Promise<{ surface: AuthorizationSurface; authenticator: Authenticator } | null> {
201
- const declared = primary.config.auth.authorization;
202
- if (!declared) return null;
203
-
204
- const profile = primary.resolution.profile;
205
-
206
- if (declared.mode === 'oidc') {
207
- const audience = await primary.credentials.get(declared.client_id_ref);
208
- if (!audience) {
209
- // Refuse rather than verify without an audience. A verifier that cannot
210
- // check who a token was issued for accepts every token the issuer minted
211
- // for anything, which is the failure this mode exists to prevent.
212
- throw new Error(
213
- `auth.authorization.client_id_ref names "${declared.client_id_ref}", which is not in ` +
214
- `this target's credential store. Store it with: lanes link secrets set ${declared.client_id_ref}`,
215
- );
216
- }
217
-
218
- const verifier = new OidcVerifier({
219
- issuer: declared.issuer,
220
- audience,
221
- allowedSubjects: declared.allowed_subjects,
222
- ...(declared.introspection_endpoint
223
- ? { introspectionEndpoint: declared.introspection_endpoint }
224
- : {}),
225
- });
226
-
227
- return {
228
- // The issuer is somebody else's origin, so it is a constant here rather
229
- // than derived from the request.
230
- surface: { issuer: () => declared.issuer, mcpPath: MCP_PATH, target: primary.target },
231
- authenticator: new OidcAuthenticator(verifier, profile),
232
- };
233
- }
234
-
235
- const store = new OAuthStore(primary.state.kv);
236
- const expected = primary.config.auth.token_ref;
237
-
238
- const server = new OAuthServer({
239
- store,
240
- accessTokenTtlMs: declared.access_token_ttl_minutes * 60_000,
241
- // So a replayed refresh token leaves a line. It is refused rather than
242
- // acted on (ADR-035), and a refusal nobody can see is how a connector
243
- // losing its authorization came to need log forensics to explain.
244
- log,
245
- // Approval is proof of holding the endpoint token, compared the same way
246
- // the request path compares it. There is one person behind this endpoint
247
- // and they already have exactly one credential; a second one invented for
248
- // the consent screen would be a password to lose.
249
- verifyOwner: async (presented) => {
250
- const token = await primary.credentials.get(expected);
251
- return token !== null && tokensMatch(presented, token);
252
- },
253
- });
254
-
255
- return {
256
- surface: { server, issuer: (origin) => origin, mcpPath: MCP_PATH, target: primary.target },
257
- authenticator: new IssuedTokenAuthenticator(store, profile),
258
- };
259
- }
260
199
 
261
200
  export async function startEndpoint(options: EndpointOptions): Promise<RunningEndpoint> {
262
201
  const reporter = options.reporter ?? SILENT;
@@ -278,13 +217,26 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
278
217
  if (!token) {
279
218
  throw new Error(
280
219
  `No profile token at "${primary.config.auth.token_ref}" in this target's credential store. ` +
281
- 'A deployed instance never mints its own — run `lanes link token rotate --target <target>` ' +
220
+ 'A deployed instance never mints its own — run `lanes link token rotate --workspace <name>` ' +
282
221
  'from your machine, or `lanes link secrets push --from local --to cloud`, then redeploy.',
283
222
  );
284
223
  }
285
224
  }
286
225
 
287
- const gate = await openAuthorization(primary, log);
226
+ // Read through a holder rather than closed over `runtimes`, because a
227
+ // reload replaces that map and the gate is deliberately built once
228
+ // (ADR-029). Without the indirection, a member added after start would
229
+ // stay invisible until the endpoint was restarted — which is precisely the
230
+ // thing `profile members add` tells the operator has taken effect.
231
+ let serving: ReadonlyMap<string, Runtime> = runtimes;
232
+
233
+ const gate = await openAuthorization(primary, log, async (subject) =>
234
+ [...serving]
235
+ .filter(([, runtime]) =>
236
+ runtime.config.members.some((member) => member.subject === subject),
237
+ )
238
+ .map(([name]) => name),
239
+ );
288
240
 
289
241
  // The authenticator and the authorization gate are built once, from the
290
242
  // runtime this endpoint booted with, and are deliberately not part of what
@@ -295,6 +247,7 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
295
247
  { profiles: profileRuntimes(runtimes), close: () => closeAll(runtimes).then(() => {}) },
296
248
  async (): Promise<OpenedWorkspace> => {
297
249
  const reopened = await openReconciled(options);
250
+ serving = reopened.runtimes;
298
251
  return {
299
252
  profiles: profileRuntimes(reopened.runtimes),
300
253
  close: () => closeAll(reopened.runtimes).then(() => {}),
@@ -312,6 +265,12 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
312
265
  { primary: primary.resolution.profile, log, ...(gate ? { remoteClients: true } : {}) },
313
266
  );
314
267
 
268
+ // Read once. `version()` walks up to the install root and parses
269
+ // `package.json`; doing it per request would put a synchronous file read on
270
+ // the read surface's hot path to answer a value that cannot change while
271
+ // this process lives.
272
+ const runningVersion = version();
273
+
315
274
  const server = serve({
316
275
  generations,
317
276
  primary: primary.resolution.profile,
@@ -322,20 +281,46 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
322
281
  ...(gate ? { authorization: gate.surface } : {}),
323
282
  ...(options.port !== undefined ? { port: options.port } : {}),
324
283
  ...(options.host !== undefined ? { host: options.host } : {}),
284
+ // Offered unconditionally and discarded by `serve()` on a loopback bind,
285
+ // which is where every other property of the bind address is decided. It
286
+ // opens nothing and reads no credential, so building it for a bind that
287
+ // will not use it costs a closure (ADR-064).
288
+ read: deployedReadDeps({
289
+ primary,
290
+ profiles: () => generations.current.profiles,
291
+ log,
292
+ version: runningVersion,
293
+ }),
325
294
  });
326
295
 
327
296
  // After `serve()`, so the record means the socket is bound. Recording it
328
297
  // from the constructor claimed an endpoint that a failed bind never served.
329
298
  generations.announce();
330
299
 
300
+ // Only when `lanes link pair` has provisioned all three. Absent, this is
301
+ // simply not served — the read surface is opt-in and its absence is the
302
+ // default (ADR-063), so an endpoint that was never paired binds one port
303
+ // exactly as it always did.
304
+ const read = await openReadListener(
305
+ primary,
306
+ server,
307
+ () => generations.current.profiles,
308
+ log,
309
+ runningVersion,
310
+ );
311
+
331
312
  return {
332
313
  url: server.url,
333
314
  profiles: [...runtimes.keys()],
315
+ ...(read ? { readUrl: read.url } : {}),
334
316
  // `server.stop()` closes the request handler, which closes whichever
335
317
  // generation is current — and a generation owns the runtimes it opened.
336
318
  // Closing `runtimes` here too would reach past a reload and close a set
337
319
  // nothing is serving from any more.
338
- stop: () => server.stop(),
320
+ stop: async () => {
321
+ await read?.stop();
322
+ await server.stop();
323
+ },
339
324
  };
340
325
  } catch (error) {
341
326
  await closeAll(runtimes);
@@ -189,7 +189,16 @@ export class Generation {
189
189
  * a handler outliving its generation is the stale-config bug.
190
190
  */
191
191
  handlerFor(principal: Principal, clientLabel: string | undefined): McpHttpHandler {
192
- const key = `${principal.id}\u0000${clientLabel ?? ''}`;
192
+ // The delegation list is part of the key, not just the identity.
193
+ //
194
+ // `Principal` gained `profiles` this release, and `mergeCapabilities` and
195
+ // `forProfile` both read it off the *captured* principal — so two tokens for
196
+ // one subject with different scopes hashed to one entry and whichever
197
+ // authorized first decided what the other could reach. Removing somebody
198
+ // from a profile and re-authorizing then served them the profile they had
199
+ // just lost, or refused one they still had, depending on order.
200
+ const reach = principal.profiles === undefined ? '*' : [...principal.profiles].sort().join(',');
201
+ const key = `${principal.id}\u0000${reach}\u0000${clientLabel ?? ''}`;
193
202
  const existing = this.#handlers.get(key);
194
203
  if (existing) return existing;
195
204
 
@@ -5,10 +5,11 @@ import {
5
5
  IssuedTokenAuthenticator,
6
6
  OAuthServer,
7
7
  OAuthStore,
8
- tokensMatch,
8
+ type Federation,
9
9
  } from '#auth';
10
10
  import { oneProfile, type ProfileRuntime } from './mcp/index.ts';
11
- import { parseConfig, type Config } from '#profile';
11
+ import {
12
+ type ConnectionConfig, parseConfig, type Config } from '#profile';
12
13
  import { ProviderRegistry, toPolicyDocument } from '#registry';
13
14
  import { Dispatcher } from '#dispatch';
14
15
  import { createMemoryCredentials, createMemoryState } from '#stores/state/testing.ts';
@@ -33,26 +34,58 @@ import { serveOverStdio } from './stdio.ts';
33
34
  * the right calls, and a mocked transport cannot demonstrate that.
34
35
  */
35
36
 
37
+ /**
38
+ * The connections a harness config implies, derived from its grants.
39
+ *
40
+ * A test config declares grants and no `connections.yaml` — there is no
41
+ * workspace on disk to read one from. Deriving the rows from the grant refs
42
+ * keeps the harness honest about the only thing dispatch uses them for, which is
43
+ * resolving `<provider>.<id>` to a provider and an id. Anything richer (an
44
+ * account label, a credential ref) belongs to a real workspace and a test that
45
+ * needs one builds it.
46
+ */
47
+ function harnessConnections(config: Config): ConnectionConfig[] {
48
+ return config.grants.map((grant) => {
49
+ const [provider = '', id = ''] = grant.connection.split('.');
50
+ return { provider, id, account: grant.connection };
51
+ });
52
+ }
53
+
36
54
  export const TEST_TOKEN = 'llk_test_token_value';
37
55
 
56
+ /** A signed-in person no profile lists. See the federation stub below. */
57
+ export const STRANGER = 'NOBODY_LISTS_THIS_PERSON';
58
+
59
+ /**
60
+ * A profile for the harness, from the `allow`/`deny` a test hands over.
61
+ *
62
+ * The two `example` accounts are the point: every test here is about a rule
63
+ * covering both, or one of them, so the harness gives each its own grant row
64
+ * carrying the same rules (ADR-058). That is what the flat block used to mean,
65
+ * which keeps every existing test asserting what it was written to assert.
66
+ */
38
67
  export function configFor(profile: string, port: number, policy: string): Config {
68
+ const rules = policy
69
+ .split('\n')
70
+ .filter((line) => line.trim().length > 0)
71
+ .map((line) => ` ${line.trim()}`)
72
+ .join('\n');
73
+
74
+ const grant = (id: string): string =>
75
+ ` - connection: example.${id}\n${rules.replace(/^ {4}(allow|deny):/gm, ' $1:')}`;
76
+
39
77
  return parseConfig(`
40
- contract: 2
78
+ contract: 4
41
79
  instance:
42
80
  profile: ${profile}
43
81
  port: ${port}
44
82
  limits:
45
83
  requests_per_minute: 1000
46
84
  upstream_calls_per_minute: 1000
47
- connections:
48
- - id: a
49
- provider: example
50
- account: Scratch A
51
- - id: b
52
- provider: example
53
- account: Scratch B
54
- policy:
55
- ${policy}
85
+ grants:
86
+ ${grant('a')}
87
+ ${grant('b')}
88
+ members: []
56
89
  `).config;
57
90
  }
58
91
 
@@ -110,6 +143,15 @@ export interface HarnessOptions {
110
143
  refreshSkills?: (registry: ProviderRegistry) => Promise<void>;
111
144
  /** Serve the `self` authorization flow alongside the bearer token. */
112
145
  authorization?: boolean;
146
+ /**
147
+ * The identity half of that flow, stubbed.
148
+ *
149
+ * The real one talks to lanes.sh and verifies a signature; a test that
150
+ * exercised it would be testing `AssertionVerifier`, which has its own file
151
+ * and its own key pair. What a harness test is about is what the endpoint
152
+ * does *with* an answer, so the answer is injected.
153
+ */
154
+ federation?: Partial<Federation>;
113
155
  /**
114
156
  * What a reload re-reads, standing in for `openReconciled` over a workspace
115
157
  * this harness does not have. Throwing is how the "a failed reload keeps
@@ -162,6 +204,8 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
162
204
 
163
205
  const dispatcher = new Dispatcher({
164
206
  config,
207
+ connections: harnessConnections(config),
208
+ oauthApps: [],
165
209
  registry,
166
210
  connectorFor: (providerId): AnyConnector | undefined => {
167
211
  const entry = registry.get(providerId);
@@ -199,6 +243,8 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
199
243
  policy: extraPolicy,
200
244
  dispatcher: new Dispatcher({
201
245
  config: extraConfig,
246
+ connections: harnessConnections(extraConfig),
247
+ oauthApps: [],
202
248
  registry,
203
249
  connectorFor: (providerId): AnyConnector | undefined => {
204
250
  const entry = registry.get(providerId);
@@ -241,7 +287,19 @@ export function startHarness(options: HarnessOptions): Harness {
241
287
  accessTokenTtlMs: 3_600_000,
242
288
  log,
243
289
  ...(options.now ? { now: options.now } : {}),
244
- verifyOwner: (presented) => Promise.resolve(tokensMatch(presented, token)),
290
+ federation: {
291
+ consentUrl: 'https://lanes.example/link/authorize',
292
+ // Anything non-empty verifies, as the subject it spells. Enough to
293
+ // drive the flow, and obviously not a verifier.
294
+ verify: async (assertion) =>
295
+ assertion ? { subject: `lanes:${assertion}`, email: null } : null,
296
+ // One reserved spelling answers "no profile names them", because
297
+ // that refusal is a real branch — a person signs in successfully
298
+ // and still reaches nothing — and there has to be a way to drive it.
299
+ profilesFor: async (subject) =>
300
+ subject === `lanes:${STRANGER}` ? [] : [options.profile],
301
+ ...options.federation,
302
+ },
245
303
  }),
246
304
  issuer: (origin: string) => origin,
247
305
  mcpPath: '/mcp',