@lanes-sh/link 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +136 -61
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +65 -0
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +108 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/brand.ts +39 -10
  22. package/src/cli/callback-page.ts +37 -104
  23. package/src/cli/commands/auth-dispatch.ts +48 -0
  24. package/src/cli/commands/auth.ts +229 -0
  25. package/src/cli/commands/connect/accounts.ts +4 -4
  26. package/src/cli/commands/connect/authorise.ts +4 -4
  27. package/src/cli/commands/connect/bind-credential.ts +2 -1
  28. package/src/cli/commands/connect/custom/index.ts +1 -1
  29. package/src/cli/commands/connect/custom/write.ts +2 -2
  30. package/src/cli/commands/connect/grant.ts +29 -14
  31. package/src/cli/commands/connect/index.ts +88 -87
  32. package/src/cli/commands/connect/options.ts +83 -0
  33. package/src/cli/commands/connect/registration.ts +50 -0
  34. package/src/cli/commands/connect/requirements.ts +1 -1
  35. package/src/cli/commands/connect/settle.ts +4 -2
  36. package/src/cli/commands/connect/target-note.ts +7 -2
  37. package/src/cli/commands/connect/unknown.ts +1 -1
  38. package/src/cli/commands/connect/variables.ts +3 -2
  39. package/src/cli/commands/connection-list.ts +116 -0
  40. package/src/cli/commands/connection.ts +182 -165
  41. package/src/cli/commands/grant.ts +140 -0
  42. package/src/cli/commands/identity.ts +21 -9
  43. package/src/cli/commands/knowledge/index.ts +46 -79
  44. package/src/cli/commands/knowledge/migrate.ts +74 -13
  45. package/src/cli/commands/knowledge/show.ts +92 -0
  46. package/src/cli/commands/knowledge.ts +2 -1
  47. package/src/cli/commands/mcp/harnesses.ts +30 -8
  48. package/src/cli/commands/mcp/onboarding.ts +86 -0
  49. package/src/cli/commands/mcp/register.ts +16 -2
  50. package/src/cli/commands/mcp.ts +1 -0
  51. package/src/cli/commands/members.ts +288 -0
  52. package/src/cli/commands/operate/attach.ts +3 -3
  53. package/src/cli/commands/operate/audit.ts +11 -7
  54. package/src/cli/commands/operate/auth.ts +28 -11
  55. package/src/cli/commands/operate/findings.ts +2 -1
  56. package/src/cli/commands/operate/inspect.ts +37 -19
  57. package/src/cli/commands/operate/migrate.ts +29 -12
  58. package/src/cli/commands/operate/outputs.ts +3 -3
  59. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  60. package/src/cli/commands/operate/pair.ts +324 -0
  61. package/src/cli/commands/operate/policy.ts +73 -22
  62. package/src/cli/commands/operate/serve.ts +52 -4
  63. package/src/cli/commands/operate/status.ts +18 -10
  64. package/src/cli/commands/operate/tools.ts +2 -2
  65. package/src/cli/commands/operate.ts +2 -0
  66. package/src/cli/commands/owner/shared.ts +13 -2
  67. package/src/cli/commands/owner/skills.ts +28 -8
  68. package/src/cli/commands/profile/removal.ts +79 -76
  69. package/src/cli/commands/profile/remove.ts +16 -1
  70. package/src/cli/commands/profile.ts +46 -10
  71. package/src/cli/commands/relabel.ts +112 -0
  72. package/src/cli/commands/secrets.ts +34 -12
  73. package/src/cli/commands/set-workspace.ts +96 -0
  74. package/src/cli/commands/setup.ts +2 -2
  75. package/src/cli/commands/sync.ts +8 -8
  76. package/src/cli/commands/target.ts +9 -7
  77. package/src/cli/commands/update.ts +58 -17
  78. package/src/cli/config-edit.ts +75 -140
  79. package/src/cli/config-migrate.ts +82 -64
  80. package/src/cli/config-repair.ts +89 -32
  81. package/src/cli/config-templates.ts +198 -0
  82. package/src/cli/contract3-data.ts +328 -0
  83. package/src/cli/contract3-shape.ts +186 -0
  84. package/src/cli/contract3.ts +282 -0
  85. package/src/cli/endpoint-url.ts +1 -1
  86. package/src/cli/lanes.ts +25 -1
  87. package/src/cli/main.ts +89 -14
  88. package/src/cli/migrate-plan.ts +12 -6
  89. package/src/cli/output.ts +34 -1
  90. package/src/cli/publish.ts +5 -2
  91. package/src/cli/runtime/open.ts +63 -98
  92. package/src/cli/runtime/registry.ts +6 -7
  93. package/src/cli/runtime/stores.ts +53 -0
  94. package/src/cli/runtime/types.ts +106 -0
  95. package/src/cli/runtime/vault.ts +19 -4
  96. package/src/cli/runtime/workspace.ts +60 -0
  97. package/src/cli/runtime.ts +2 -1
  98. package/src/cli/selection-require.ts +44 -13
  99. package/src/cli/selection.ts +127 -145
  100. package/src/cli/usage.ts +34 -18
  101. package/src/cli/workspace-migrate.ts +125 -16
  102. package/src/connectivity/manifest/provider.ts +3 -1
  103. package/src/connectivity/manifest/requirements.ts +1 -1
  104. package/src/deployments/bind.ts +1 -1
  105. package/src/deployments/deploy.ts +36 -27
  106. package/src/deployments/gcp/bucket.ts +18 -7
  107. package/src/deployments/gcp/provision.ts +7 -7
  108. package/src/deployments/prepare.ts +72 -24
  109. package/src/deployments/record.ts +1 -1
  110. package/src/deployments/report.ts +2 -2
  111. package/src/deployments/serving.ts +15 -74
  112. package/src/deployments/target.ts +15 -15
  113. package/src/deployments/upload.ts +46 -22
  114. package/src/dispatch/deps.ts +88 -0
  115. package/src/dispatch/dispatch.ts +21 -62
  116. package/src/policy/index.ts +47 -15
  117. package/src/profile/connections.ts +183 -0
  118. package/src/profile/deployments.ts +3 -3
  119. package/src/profile/index.ts +30 -5
  120. package/src/profile/layout.ts +86 -89
  121. package/src/profile/load.ts +80 -47
  122. package/src/profile/pairing.ts +32 -0
  123. package/src/profile/primitives.ts +35 -1
  124. package/src/profile/registry.ts +6 -6
  125. package/src/profile/schema.ts +172 -21
  126. package/src/profile/targets.ts +21 -9
  127. package/src/profile/testing.ts +69 -2
  128. package/src/profile/workspace.ts +58 -3
  129. package/src/providers/custom/index.ts +1 -1
  130. package/src/providers/custom/load.ts +2 -3
  131. package/src/providers/identity/provider.ts +1 -1
  132. package/src/providers/memory/provider.ts +20 -2
  133. package/src/providers/setup/plan.ts +1 -1
  134. package/src/providers/slack/index.ts +2 -2
  135. package/src/registry/policy-bridge.ts +33 -11
  136. package/src/registry/reconcile.ts +4 -4
  137. package/src/server/authorization.ts +94 -0
  138. package/src/server/edge.ts +14 -1
  139. package/src/server/endpoint.ts +85 -104
  140. package/src/server/generation.ts +10 -1
  141. package/src/server/harness.ts +71 -13
  142. package/src/server/index.ts +31 -0
  143. package/src/server/mcp/build.ts +20 -1
  144. package/src/server/mcp/client-info.ts +54 -0
  145. package/src/server/mcp/guide.ts +120 -0
  146. package/src/server/mcp/instructions.ts +1 -1
  147. package/src/server/mcp/prompts.ts +7 -3
  148. package/src/server/mcp/resources.ts +16 -8
  149. package/src/server/mcp/tools.ts +9 -3
  150. package/src/server/mcp/visibility.ts +18 -3
  151. package/src/server/oauth.ts +29 -75
  152. package/src/server/read/credential.ts +134 -0
  153. package/src/server/read/deployed.ts +56 -0
  154. package/src/server/read/listener.ts +54 -0
  155. package/src/server/read/open.ts +101 -0
  156. package/src/server/read/routes.ts +247 -0
  157. package/src/server/read/state.ts +171 -0
@@ -58,7 +58,7 @@ export function workspaceYaml(
58
58
  return (
59
59
  `contract: ${SUPPORTED_CONTRACT}\n` +
60
60
  (options.defaultProfile ? `default_profile: ${options.defaultProfile}\n` : '') +
61
- `targets:\n${blocks}\n`
61
+ `workspaces:\n${blocks}\n`
62
62
  );
63
63
  }
64
64
 
@@ -73,6 +73,73 @@ export function pointerYaml(
73
73
  return (
74
74
  `contract: ${SUPPORTED_CONTRACT}\n` +
75
75
  (options.defaultProfile ? `default_profile: ${options.defaultProfile}\n` : '') +
76
- `targets:\n${local} ${target}:\n workspace: ${workspace}\n`
76
+ `workspaces:\n${local} ${target}:\n at: ${workspace}\n`
77
+ );
78
+ }
79
+
80
+ /**
81
+ * A `connections.yaml` holding the owner layer plus whatever a test names.
82
+ *
83
+ * Every fixture that opens a runtime needs one now: a profile's grants name rows
84
+ * in this file, and `assertGrantsResolve` refuses a grant with nothing behind it
85
+ * (ADR-057). Thirty tests writing the seven owner-layer rows by hand is how a
86
+ * fixture ends up subtly different from what `newConnectionsTemplate` writes.
87
+ */
88
+ export function connectionsYaml(
89
+ extra: readonly { id: string; provider: string; account: string }[] = [],
90
+ ): string {
91
+ const owner = [
92
+ { id: 'main', provider: 'memory', account: 'Memory' },
93
+ { id: 'main', provider: 'tasks', account: 'Tasks' },
94
+ { id: 'main', provider: 'assets', account: 'Assets' },
95
+ { id: 'main', provider: 'skills', account: 'Skills' },
96
+ { id: 'main', provider: 'vault', account: 'Vault' },
97
+ { id: 'main', provider: 'setup', account: 'Setup' },
98
+ { id: 'main', provider: 'entities', account: 'Entities' },
99
+ ];
100
+
101
+ const rows = [...owner, ...extra]
102
+ .map((row) => ` - { id: ${row.id}, provider: ${row.provider}, account: ${row.account} }`)
103
+ .join('\n');
104
+
105
+ return `contract: ${SUPPORTED_CONTRACT}\nconnections:\n${rows}\noauth_apps: {}\n`;
106
+ }
107
+
108
+ /** The owner-layer grant rows a fresh profile carries, plus whatever a test names. */
109
+ export function grantsYaml(
110
+ extra: readonly { connection: string; allow?: readonly string[]; deny?: readonly string[] }[] = [],
111
+ ): string {
112
+ const owner = ['memory', 'tasks', 'assets', 'skills', 'vault', 'setup', 'entities'].map(
113
+ (provider) => ({ connection: `${provider}.main`, allow: [`${provider}.*`], deny: [] }),
114
+ );
115
+
116
+ return [...owner, ...extra]
117
+ .map(
118
+ (grant) =>
119
+ ` - { connection: ${grant.connection}, allow: [${(grant.allow ?? []).join(', ')}], ` +
120
+ `deny: [${(grant.deny ?? []).join(', ')}] }`,
121
+ )
122
+ .join('\n');
123
+ }
124
+
125
+ /** A whole profile document at the current contract, for a test that needs one. */
126
+ export function profileYaml(
127
+ profile: string,
128
+ options: {
129
+ port?: number;
130
+ grants?: readonly { connection: string; allow?: readonly string[]; deny?: readonly string[] }[];
131
+ members?: readonly string[];
132
+ } = {},
133
+ ): string {
134
+ const members = (options.members ?? [])
135
+ .map((subject) => ` - { subject: ${subject}, role: owner }`)
136
+ .join('\n');
137
+
138
+ return (
139
+ `contract: ${SUPPORTED_CONTRACT}\n` +
140
+ `instance:\n profile: ${profile}\n port: ${options.port ?? 7337}\n host: 127.0.0.1\n` +
141
+ `auth:\n mode: bearer\n token_ref: profile/token\n` +
142
+ `grants:\n${grantsYaml(options.grants ?? [])}\n` +
143
+ `members:${members ? `\n${members}` : ' []'}\n`
77
144
  );
78
145
  }
@@ -5,7 +5,16 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { parse as parseYaml } from 'yaml';
7
7
  import { ConfigError } from './load.ts';
8
- import { workspaceSchema, type Config, type WorkspaceConfig } from './schema.ts';
8
+ import {
9
+ SUPPORTED_CONTRACT,
10
+ connectionsFileSchema,
11
+ workspaceSchema,
12
+ type Config,
13
+ type ConnectionsFile,
14
+ type WorkspaceConfig,
15
+ } from './schema.ts';
16
+ import { assertConnectionsUnique, assertNoRenamedProviders } from './connections.ts';
17
+ import { findSecrets, formatSecretFindings } from './secret-detection.ts';
9
18
 
10
19
  /**
11
20
  * Workspace and profile resolution.
@@ -152,6 +161,52 @@ export async function loadProfileConfig(
152
161
  return parseConfig(text, shown);
153
162
  }
154
163
 
164
+ /** `connections.yaml` sits beside `lanes-link.yaml`, at the workspace root. */
165
+ export const CONNECTIONS_FILE = 'connections.yaml';
166
+
167
+ /**
168
+ * Every account authorised in this workspace (ADR-057).
169
+ *
170
+ * Absent is not an error. A workspace that has never connected anything has no
171
+ * file, and returning an empty set rather than throwing is what lets `profile
172
+ * add`, `status` and `doctor` answer on a fresh install — the same reasoning
173
+ * `readWorkspace` uses for returning null.
174
+ *
175
+ * Read through the workspace store rather than the filesystem, so a deployed
176
+ * revision whose root is a bucket URL loads it (ADR-049). That was the exact
177
+ * bug manifests hit: a filesystem read against a `gs://` root silently found
178
+ * nothing and every custom provider vanished.
179
+ */
180
+ export async function readConnections(workspaceRoot: string): Promise<ConnectionsFile> {
181
+ const path = join(workspaceRoot, CONNECTIONS_FILE);
182
+ const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), CONNECTIONS_FILE);
183
+ if (text === null) return { contract: SUPPORTED_CONTRACT, connections: [], oauth_apps: {} };
184
+
185
+ const raw = parseYaml(text);
186
+
187
+ const secrets = findSecrets(raw);
188
+ if (secrets.length > 0) {
189
+ throw new ConfigError(
190
+ `${path}: ${formatSecretFindings(secrets)}`,
191
+ secrets.map((finding) => finding.path),
192
+ );
193
+ }
194
+
195
+ const parsed = connectionsFileSchema.safeParse(raw);
196
+ if (!parsed.success) {
197
+ throw new ConfigError(
198
+ `${path}:\n${parsed.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n')}`,
199
+ );
200
+ }
201
+
202
+ assertConnectionsUnique(parsed.data.connections);
203
+ assertNoRenamedProviders(
204
+ parsed.data.connections,
205
+ 'lanes link doctor --fix --profile <name> --workspace <name>',
206
+ );
207
+ return parsed.data;
208
+ }
209
+
155
210
  export async function readWorkspace(workspaceRoot: string): Promise<WorkspaceConfig | null> {
156
211
  const path = join(workspaceRoot, WORKSPACE_FILE);
157
212
  const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), WORKSPACE_FILE);
@@ -219,7 +274,7 @@ export function noProfileNamed(
219
274
  if (available.length === 0) {
220
275
  return new ConfigError(
221
276
  `--profile is required, and ${workspaceRoot} holds no profiles yet.\n` +
222
- ' Create one with: lanes link profile add <name> --target local',
277
+ ' Create one with: lanes link profile add <name> --workspace local',
223
278
  );
224
279
  }
225
280
 
@@ -230,7 +285,7 @@ export function noProfileNamed(
230
285
  'nothing else selects one.\n\n' +
231
286
  ` Profiles in ${workspaceRoot}\n` +
232
287
  available.map((name) => ` ${name}`).join('\n') +
233
- `\n\n e.g. lanes link status --profile ${available[0]} --target <target>` +
288
+ `\n\n e.g. lanes link status --profile ${available[0]} --workspace <name>` +
234
289
  (stale
235
290
  ? `\n\n LANES_LINK_PROFILE=${stale} is set in this shell and is no longer read.\n` +
236
291
  ' Unset it, or pass --profile.'
@@ -12,5 +12,5 @@
12
12
  * connectivity type; `load.ts` reads and validates them.
13
13
  */
14
14
 
15
- export { loadProfileProviders, parseManifest, type LoadedManifest } from './load.ts';
15
+ export { loadWorkspaceProviders, parseManifest, type LoadedManifest } from './load.ts';
16
16
  export { manifestTemplate } from './template.ts';
@@ -38,14 +38,13 @@ export interface LoadedManifest {
38
38
  readonly path: string;
39
39
  }
40
40
 
41
- export async function loadProfileProviders(
41
+ export async function loadWorkspaceProviders(
42
42
  workspaceRoot: string,
43
- profile: string,
44
43
  /** Injected for tests. A bucket is the case this exists to cover. */
45
44
  store?: BlobStore,
46
45
  ): Promise<LoadedManifest[]> {
47
46
  const files = store ?? workspaceFiles(workspaceRoot);
48
- const directory = layout.providers(profile);
47
+ const directory = layout.providers();
49
48
 
50
49
  let keys: string[];
51
50
  try {
@@ -128,7 +128,7 @@ function render(
128
128
  // Both flags, spelled out. They are required (ADR-037), so a command
129
129
  // missing either is one the owner pastes and watches refuse — and this is
130
130
  // handed to an agent, which relays it verbatim.
131
- const where = `--profile ${profile}${target ? ` --target ${target}` : ''}`;
131
+ const where = `--profile ${profile}${target ? ` --workspace ${target}` : ''}`;
132
132
  return (
133
133
  `Profile "${profile}" declares no identity.\n\n` +
134
134
  'Nothing here says what name or address to use, so do not invent one — ask. ' +
@@ -272,8 +272,26 @@ export const memoryProvider: ProviderDefinition = defineLocalProvider({
272
272
  tag: z.string().optional().describe('Restrict to entries carrying this tag'),
273
273
  limit: z.number().int().min(1).max(50).optional().describe(`Maximum results (default ${DEFAULT_LIMIT})`),
274
274
  }),
275
- // Nothing kept: a memory query is as revealing as a Gmail search query,
276
- // and frequently more so — it is the owner's own material being asked for.
275
+ // Kept, and this reverses the rule the rest of this file follows.
276
+ //
277
+ // The old comment here read "a memory query is as revealing as a Gmail
278
+ // search query, and frequently more so", which is true and is an argument
279
+ // about the wrong thing. A search term is not the owner's material: it is
280
+ // what an *agent* went looking for in that material, which is precisely
281
+ // the question this log exists to answer. Withholding it leaves a record
282
+ // saying memory was searched, twice, and matched nothing, with no way to
283
+ // tell a calendar lookup from a rummage through someone's medical notes.
284
+ //
285
+ // The body stays withheld either way — `entry` and `get` keep only `uri`
286
+ // and `id`, so what was *found* is still not in here. This records the
287
+ // question, not the answer.
288
+ //
289
+ // The reason for the old rule was `audit/fanout.ts`: a workspace may ship
290
+ // copies to stdout or an OTLP collector, and those leave the machine. That
291
+ // is a real exposure and it is the operator's to weigh, which is what a
292
+ // declared sink is. It is not a reason to withhold from the durable log on
293
+ // the operator's own disk.
294
+ redact: keepKeys('query', 'tag', 'limit'),
277
295
  async handler({ query, tag, limit }, context) {
278
296
  const needle = query.toLowerCase();
279
297
  const entries = await allEntries(context.storage);
@@ -137,7 +137,7 @@ export function planFor(
137
137
  // missing either is one that refuses, or worse, writes a credential into a
138
138
  // store the endpoint that asked for it does not read.
139
139
  const command =
140
- `lanes link connect ${manifest.id} --profile ${context.profile} --target ${context.target}` +
140
+ `lanes link connect ${manifest.id} --profile ${context.profile} --workspace ${context.target}` +
141
141
  (needsId ? ' --id <name>' : connectionId ? ` --id ${connectionId}` : '');
142
142
 
143
143
  return {
@@ -105,12 +105,12 @@ export const slack = defineProvider({
105
105
  'Open "OAuth & Permissions" and add the scopes you need under USER TOKEN SCOPES — not Bot Token Scopes; the MCP server reads the user token. The full set this provider asks for in the browser is listed in https://lanes.sh/docs/link/slack.',
106
106
  'Choose "Install to Workspace" and approve. A Slack admin may have to approve it for you.',
107
107
  'Copy the "User OAuth Token". It starts with xoxp- — not the bot token, which starts with xoxb- and will not work here.',
108
- 'The token does not expire unless you enable token rotation on the app. If you rotate or reinstall, run: lanes link connect slack --profile personal --target local --auth pasted_token --replace.',
108
+ 'The token does not expire unless you enable token rotation on the app. If you rotate or reinstall, run: lanes link connect slack --profile personal --workspace local --auth pasted_token --replace.',
109
109
  ],
110
110
  troubleshooting:
111
111
  'Slack refused the token. The usual causes are a bot token (xoxb-) pasted where the user token (xoxp-) belongs, ' +
112
112
  'a scope missing from USER TOKEN SCOPES, or an app that was reinstalled since — reinstalling mints a new token. ' +
113
- 'Copy the User OAuth Token from https://api.slack.com/apps and re-run: lanes link connect slack --profile personal --target local --auth pasted_token --replace.',
113
+ 'Copy the User OAuth Token from https://api.slack.com/apps and re-run: lanes link connect slack --profile personal --workspace local --auth pasted_token --replace.',
114
114
  prompts: [
115
115
  {
116
116
  key: 'token',
@@ -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;
@@ -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