@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
@@ -61,22 +61,35 @@ export interface TargetInput {
61
61
  */
62
62
  export type StorageFactory = (area?: string) => BlobStore;
63
63
 
64
- export async function openSecrets(input: TargetInput): Promise<SecretStore> {
65
- const { declared, config, root, target } = input;
64
+ /**
65
+ * The workspace's credential store.
66
+ *
67
+ * Takes the adapters and the root, not a `TargetInput` — it never read the
68
+ * profile, and since contract 3 there is nothing in one for it to read: the
69
+ * store is the workspace's. Narrowing the parameter is what lets a *migration*
70
+ * open it, which it must be able to do while the profiles on disk still
71
+ * declare the contract being migrated away from and therefore will not parse.
72
+ */
73
+ export async function openSecrets(input: {
74
+ readonly declared: TargetConfig;
75
+ readonly root: string;
76
+ readonly target: string;
77
+ }): Promise<SecretStore> {
78
+ const { declared, root, target } = input;
66
79
 
67
80
  switch (declared.credentials.adapter) {
68
81
  case 'file':
69
82
  return createFileSecretStore({
70
83
  path: workspacePath(
71
84
  root,
72
- declared.credentials.path ?? layout.credentials(config.instance.profile),
85
+ declared.credentials.path ?? layout.credentials(),
73
86
  ),
74
87
  });
75
88
 
76
89
  case 'gcp-secret-manager': {
77
90
  if (!declared.credentials.project) {
78
91
  throw new ConfigError(
79
- `targets.${target}.credentials.project is required for the gcp-secret-manager adapter.`,
92
+ `workspaces.${target}.credentials.project is required for the gcp-secret-manager adapter.`,
80
93
  );
81
94
  }
82
95
  const { GcpSecretManagerStore } = await import('./adapters/gcp-secret-manager.ts');
@@ -96,9 +109,17 @@ export async function openSecrets(input: TargetInput): Promise<SecretStore> {
96
109
  *
97
110
  * Its own root, `layout.state`, so it is not addressable from a provider's
98
111
  * blob namespace — the same containment `openAudit` relies on.
112
+ *
113
+ * Two roots, because state divides by what a key is *about*. Connection
114
+ * records, the discovery cache and the endpoint's own OAuth server belong to
115
+ * the workspace: a `connect` run once must read as connected from every
116
+ * profile. Cursors and each provider's own keys belong to the profile, because
117
+ * two agents reading one mailbox at different rates must not consume each
118
+ * other's cursor. `isWorkspaceNamespace` in `#stores/state` is the whole rule,
119
+ * and it is closed — a namespace it does not name is the profile's.
99
120
  */
100
121
  export function openState(storage: StorageFactory, profile: string): RuntimeState {
101
- return createRuntimeState(storage(layout.state(profile)));
122
+ return createRuntimeState(storage(layout.state()), storage(layout.profileState(profile)));
102
123
  }
103
124
 
104
125
  /**
@@ -115,8 +136,8 @@ export function openState(storage: StorageFactory, profile: string): RuntimeStat
115
136
  * into (`layout.audit`), and that separation is what keeps ADR-007's wall
116
137
  * intact.
117
138
  */
118
- export function openAudit(storage: StorageFactory, profile: string): AuditStore {
119
- return createBlobAuditStore({ storage: storage(layout.audit(profile)) });
139
+ export function openAudit(storage: StorageFactory): AuditStore {
140
+ return createBlobAuditStore({ storage: storage(layout.audit()) });
120
141
  }
121
142
 
122
143
  /**
@@ -137,7 +158,7 @@ export async function openAuditSinks(
137
158
  secrets: SecretStore,
138
159
  log?: (message: string) => void,
139
160
  ): Promise<{ sink: AuditSink; reader: AuditReader }> {
140
- const durable = openAudit(storage, input.config.instance.profile);
161
+ const durable = openAudit(storage);
141
162
  const declared = input.declared.audit?.sinks ?? [];
142
163
  if (declared.length === 0) return { sink: durable, reader: durable };
143
164
 
@@ -214,7 +235,7 @@ export async function openStorage(
214
235
  // by hand in a console.
215
236
  const { bucket, prefix } = declared.storage;
216
237
  if (!bucket) {
217
- throw new ConfigError(`targets.${target}.storage.bucket is required for the gcs adapter.`);
238
+ throw new ConfigError(`workspaces.${target}.storage.bucket is required for the gcs adapter.`);
218
239
  }
219
240
 
220
241
  const { createGcsBlobStore } = await import('./adapters/gcs.ts');
@@ -231,19 +252,19 @@ export async function openStorage(
231
252
  case 's3': {
232
253
  const { bucket, endpoint, region, prefix } = declared.storage;
233
254
  if (!bucket) {
234
- throw new ConfigError(`targets.${target}.storage.bucket is required for the s3 adapter.`);
255
+ throw new ConfigError(`workspaces.${target}.storage.bucket is required for the s3 adapter.`);
235
256
  }
236
257
 
237
258
  const accessKeyId = await requireSecret(
238
259
  secrets,
239
260
  declared.storage.access_key_id_ref,
240
- `targets.${target}.storage.access_key_id_ref`,
261
+ `workspaces.${target}.storage.access_key_id_ref`,
241
262
  target,
242
263
  );
243
264
  const secretAccessKey = await requireSecret(
244
265
  secrets,
245
266
  declared.storage.secret_access_key_ref,
246
- `targets.${target}.storage.secret_access_key_ref`,
267
+ `workspaces.${target}.storage.secret_access_key_ref`,
247
268
  target,
248
269
  );
249
270
 
@@ -289,7 +310,7 @@ export async function requireSecret(
289
310
  if (!value) {
290
311
  throw new ConfigError(
291
312
  `${field} names "${ref}", which is not in this target's secret store. ` +
292
- `Store it with: lanes link secrets set ${ref} --target ${target}`,
313
+ `Store it with: lanes link secrets set ${ref} --workspace ${target}`,
293
314
  );
294
315
  }
295
316
  return value;
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  openTarget,
3
- DATA_DIR,
3
+ CONNECTIONS_FILE,
4
4
  WORKSPACE_FILE,
5
5
  layout,
6
6
  listProfiles,
@@ -55,13 +55,19 @@ export function deployedWorkspace(declared: TargetConfig): string | undefined {
55
55
  * forgets means an endpoint that will not boot, and a credential this includes
56
56
  * by accident means a credential in a bucket. Forgetting is loud.
57
57
  *
58
- * **Two areas inside `data/` are authored rather than accumulated**, since
59
- * ADR-030 moved skills and provider manifests into the profile that owns them.
60
- * They have to go up or a deployed instance loses both — the regression ADR-014
61
- * §2 fixed for skills, reintroduced by where they now live. So this reaches
62
- * into `data/` for exactly those two, by whole path segment and never by
63
- * prefix: `data/personal/skills.detour/` is not `skills.d`, and the difference
64
- * between matching it and not is a credential in a bucket.
58
+ * **Two areas inside `data/` are authored rather than accumulated.** Skills and
59
+ * provider manifests have to go up or a deployed instance loses both — the
60
+ * regression ADR-014 §2 fixed for skills. So the allowlist names those two
61
+ * areas, by whole path segment and never by prefix:
62
+ * `profiles/work/skills.detour/` is not `skills.d`, and the difference between
63
+ * matching it and not is a credential in a bucket.
64
+ *
65
+ * The two are filtered differently, and that follows from where they sit.
66
+ * A manifest is the workspace's (ADR-057) and defines a connection any profile
67
+ * may grant, so it goes up whole. Skills are back inside a profile (ADR-066),
68
+ * so they are filtered by the profile set exactly as the declarations are —
69
+ * sending a profile's procedures on a deploy that does not carry that profile
70
+ * would put one profile's material in front of another's endpoint.
65
71
  */
66
72
  export function isWorkspaceConfig(key: string, profiles?: readonly string[]): boolean {
67
73
  // **Never the workspace file.** It was sent, and it is the one file that must
@@ -74,37 +80,54 @@ export function isWorkspaceConfig(key: string, profiles?: readonly string[]): bo
74
80
  // the upload is done.
75
81
  if (key === WORKSPACE_FILE) return false;
76
82
 
83
+ // **The connections file always goes up.** It is configuration, the endpoint
84
+ // cannot resolve a single grant without it, and unlike the registry above
85
+ // there is nothing machine-specific in it — a connection is a connection
86
+ // wherever the workspace is read from (ADR-057).
87
+ if (key === CONNECTIONS_FILE) return true;
88
+
77
89
  // A set rather than one name, because a deploy now sends every profile that
78
90
  // declares the target rather than the single one it was told. `undefined`
79
91
  // still means the whole workspace, and an *empty* set means nothing — which
80
92
  // is a distinction a bare string could not make.
81
93
  const wanted = profiles === undefined ? undefined : new Set(profiles);
94
+ const carried = (profile: string): boolean => wanted === undefined || wanted.has(profile);
95
+
96
+ if (isManifest(key)) return true;
97
+
98
+ const segments = key.split('/');
99
+ if (segments[0] !== layout.profilesRoot()) return false;
82
100
 
83
- const owner = authoredAreaOwner(key);
84
- if (owner !== null) return wanted === undefined || wanted.has(owner);
101
+ const profile = segments[1];
102
+ if (profile === undefined || !carried(profile)) return false;
85
103
 
86
- if (!key.startsWith('profiles/') || !key.endsWith('.yaml')) return false;
87
- const name = key.slice('profiles/'.length, -'.yaml'.length);
88
- return wanted === undefined || wanted.has(name);
104
+ // The declaration itself.
105
+ if (key === layout.profileConfig(profile)) return true;
106
+
107
+ // `profiles/<profile>/skills.d/<connection>/…`, and at least one segment
108
+ // past the connection — a directory is not a file to send. Both skill
109
+ // layouts satisfy it: `<name>.md` is five segments and `<name>/SKILL.md` is
110
+ // six.
111
+ if (`${segments[0]}/${segments[1]}/${segments[2]}` === layout.skillsRoot(profile)) {
112
+ return segments.length >= 5;
113
+ }
114
+
115
+ // Everything else under a profile is state, a sealed vault, or a provider's
116
+ // own blobs. None of it is configuration and the vault is a credential.
117
+ return false;
89
118
  }
90
119
 
91
120
  /**
92
- * The profile whose authored area holds `key`, or null for anything else.
121
+ * Whether `key` is one of the workspace's own provider manifests.
93
122
  *
94
- * Composed back out of `layout` rather than compared against literals, so a
123
+ * Composed back out of `layout` rather than compared against a literal, so a
95
124
  * renamed directory moves both this and the store that reads it, or neither.
96
- * Requires a fourth segment: `data/personal/skills.d` names the directory, and
97
- * a directory is not a file to send.
125
+ * Two segments at least, which is the "a directory is not a file to send" rule
126
+ * `providers.d` alone is the area, `providers.d/<file>` is a manifest.
98
127
  */
99
- function authoredAreaOwner(key: string): string | null {
128
+ function isManifest(key: string): boolean {
100
129
  const segments = key.split('/');
101
- if (segments.length < 4 || segments[0] !== DATA_DIR) return null;
102
-
103
- const owner = segments[1]!;
104
- if (owner.length === 0) return null;
105
-
106
- const area = `${DATA_DIR}/${owner}/${segments[2]}`;
107
- return area === layout.skills(owner) || area === layout.providers(owner) ? owner : null;
130
+ return segments.length >= 2 && segments[0] === layout.providers();
108
131
  }
109
132
 
110
133
  /**
@@ -157,7 +180,16 @@ export async function publishWorkspace(input: {
157
180
  readonly config: Config;
158
181
  readonly workspaceRoot: string;
159
182
  readonly target: string;
160
- readonly profile: string;
183
+ /**
184
+ * The profiles this edit touched, not just the one it was run under.
185
+ *
186
+ * `disconnect` removes a connection from the workspace and drops the grant
187
+ * from *every* profile that named it, so publishing one profile left the
188
+ * bucket holding a `connections.yaml` without the connection and a sibling
189
+ * profile still granting it — which `assertGrantsResolve` refuses at load, so
190
+ * `openReconciled` skipped that profile and it silently stopped being served.
191
+ */
192
+ readonly profile: string | readonly string[];
161
193
  }): Promise<string | null> {
162
194
  // Resolution failures are swallowed rather than thrown. The config edit that
163
195
  // called this has already succeeded and is on disk; a target that cannot be
@@ -173,6 +205,7 @@ export async function publishWorkspace(input: {
173
205
  const destination = deployedWorkspace(declared);
174
206
  if (!destination) return null;
175
207
 
176
- await uploadWorkspace(input.workspaceRoot, destination, [input.profile]);
208
+ const touched = typeof input.profile === 'string' ? [input.profile] : input.profile;
209
+ await uploadWorkspace(input.workspaceRoot, destination, touched);
177
210
  return destination;
178
211
  }
@@ -0,0 +1,88 @@
1
+ import type { Principal } from '#auth';
2
+ import type { AuditSink } from '#audit';
3
+ import type { PolicyDocument, ProfilePolicy, RateLimiter } from '#policy';
4
+ import type { Config, ConnectionConfig } from '#profile';
5
+ import type { ProviderRegistry } from '#registry';
6
+ import type { SecretStore } from '#secrets';
7
+ import type { BlobStore } from '#stores/blobs';
8
+ import type { RuntimeState } from '#stores/state';
9
+ import type { AnyConnector } from '#connectivity';
10
+ import type { Logger } from '#connectivity';
11
+
12
+ /**
13
+ * What the dispatcher is given, and what it is asked.
14
+ *
15
+ * Split from the dispatcher itself so that file stays inside the size budget.
16
+ * The seam is real rather than arithmetic: everything here is what a *caller*
17
+ * assembles, and the class next door is the fixed order in which a call is
18
+ * authorised and run. Reading one has never required reading the other.
19
+ */
20
+
21
+ /**
22
+ * The dispatch path.
23
+ *
24
+ * Every invocation goes through here in one fixed order — authenticate,
25
+ * resolve, authorize, rate-limit, dispatch, audit — with no way around it.
26
+ * The ordering is not stylistic: policy is evaluated before a provider is
27
+ * reached, so a provider never sees a request it was not authorised to serve,
28
+ * and authorization is never something provider code could get wrong.
29
+ *
30
+ * Exactly one audit event is written per invocation, on every path including
31
+ * denials. That is enforced structurally by `finally` rather than by
32
+ * remembering to call it at each return.
33
+ */
34
+
35
+ export interface DispatchDeps {
36
+ readonly config: Config;
37
+ /**
38
+ * The accounts this workspace holds, and the clients it registered (ADR-057).
39
+ *
40
+ * Separate from `config` because neither is the profile's to declare. The
41
+ * *gate* stays policy: a connection that exists and is not granted resolves
42
+ * and is then denied `denied_default`, which is the same answer a caller gets
43
+ * for one that does not exist — two shapes of "no" that read alike, so probing
44
+ * is not an oracle for what the workspace holds.
45
+ */
46
+ readonly connections: readonly ConnectionConfig[];
47
+ readonly oauthApps: readonly string[];
48
+ readonly registry: ProviderRegistry;
49
+ /**
50
+ * Resolve the connector for a provider. Supplied by the caller so core does
51
+ * not import connector implementations, keeping the dependency direction
52
+ * intact: infrastructure -> sdk -> core -> connectors.
53
+ */
54
+ readonly connectorFor: (providerId: string, connectionId: string) => AnyConnector | undefined;
55
+ /** Attach whatever the manifest's auth kind requires to an outbound request. */
56
+ readonly authorizeRequest?: (
57
+ providerId: string,
58
+ connectionId: string,
59
+ request: Request,
60
+ ) => Promise<Request>;
61
+ readonly policy: ProfilePolicy;
62
+ /** Optional instance floor. Empty in M1; composition is tighten-only. */
63
+ readonly floor?: PolicyDocument;
64
+ readonly state: RuntimeState;
65
+ /**
66
+ * Where events go. Separate from `state` because the log is no longer in
67
+ * it — and because dispatch only ever writes, so taking the sink rather than
68
+ * the whole store means this path structurally cannot read the log back.
69
+ */
70
+ readonly audit: AuditSink;
71
+ readonly credentials: SecretStore;
72
+ readonly storage: BlobStore;
73
+ readonly limiter: RateLimiter;
74
+ readonly log: Logger;
75
+ readonly now?: () => number;
76
+ }
77
+
78
+ export interface DispatchRequest {
79
+ readonly principal: Principal;
80
+ /** Fully qualified, e.g. `example.echo`. */
81
+ readonly capabilityId: string;
82
+ /** Fully qualified, e.g. `example.a`. */
83
+ readonly connectionKey: string;
84
+ readonly arguments: Readonly<Record<string, unknown>>;
85
+ /** Self-reported by the client. Observability only — never authorization. */
86
+ readonly clientLabel?: string | undefined;
87
+ readonly signal?: AbortSignal;
88
+ }
@@ -1,9 +1,9 @@
1
1
  import type { AuditDraft, AuditLogger, AuditSink, AuthorizationResult } from '#audit';
2
2
  import { keepKeys, redactAllValues } from '#audit';
3
- import type { Principal } from '#auth';
3
+ import { mayReach, type Principal } from '#auth';
4
4
  import type { SecretStore } from '#secrets';
5
5
  import type { RuntimeState } from '#stores/state';
6
- import type { PolicyDocument } from '#policy';
6
+ import type { PolicyDocument, ProfilePolicy } from '#policy';
7
7
  import { RateLimiter, evaluate } from '#policy';
8
8
  import type { BlobStore } from '#stores/blobs';
9
9
  import type {
@@ -14,69 +14,14 @@ import type {
14
14
  Logger,
15
15
  } from '#connectivity';
16
16
  import { isToolResult, strategyContextFrom, strategyFor } from '#connectivity';
17
- import type { Config } from '#profile';
17
+ import type { Config, ConnectionConfig } from '#profile';
18
18
  import { buildProviderContext, createProviderLogger } from './context.ts';
19
19
  import { fetchStaged, stageAttachment } from './staging.ts';
20
20
  import type { FetchStagedRequest, StagedAttachment, StageRequest } from './staging.ts';
21
21
  import type { ProviderRegistry } from '#registry';
22
22
 
23
- /**
24
- * The dispatch path.
25
- *
26
- * Every invocation goes through here in one fixed order — authenticate,
27
- * resolve, authorize, rate-limit, dispatch, audit — with no way around it.
28
- * The ordering is not stylistic: policy is evaluated before a provider is
29
- * reached, so a provider never sees a request it was not authorised to serve,
30
- * and authorization is never something provider code could get wrong.
31
- *
32
- * Exactly one audit event is written per invocation, on every path including
33
- * denials. That is enforced structurally by `finally` rather than by
34
- * remembering to call it at each return.
35
- */
36
-
37
- export interface DispatchDeps {
38
- readonly config: Config;
39
- readonly registry: ProviderRegistry;
40
- /**
41
- * Resolve the connector for a provider. Supplied by the caller so core does
42
- * not import connector implementations, keeping the dependency direction
43
- * intact: infrastructure -> sdk -> core -> connectors.
44
- */
45
- readonly connectorFor: (providerId: string, connectionId: string) => AnyConnector | undefined;
46
- /** Attach whatever the manifest's auth kind requires to an outbound request. */
47
- readonly authorizeRequest?: (
48
- providerId: string,
49
- connectionId: string,
50
- request: Request,
51
- ) => Promise<Request>;
52
- readonly policy: PolicyDocument;
53
- /** Optional instance floor. Empty in M1; composition is tighten-only. */
54
- readonly floor?: PolicyDocument;
55
- readonly state: RuntimeState;
56
- /**
57
- * Where events go. Separate from `state` because the log is no longer in
58
- * it — and because dispatch only ever writes, so taking the sink rather than
59
- * the whole store means this path structurally cannot read the log back.
60
- */
61
- readonly audit: AuditSink;
62
- readonly credentials: SecretStore;
63
- readonly storage: BlobStore;
64
- readonly limiter: RateLimiter;
65
- readonly log: Logger;
66
- readonly now?: () => number;
67
- }
68
-
69
- export interface DispatchRequest {
70
- readonly principal: Principal;
71
- /** Fully qualified, e.g. `example.echo`. */
72
- readonly capabilityId: string;
73
- /** Fully qualified, e.g. `example.a`. */
74
- readonly connectionKey: string;
75
- readonly arguments: Readonly<Record<string, unknown>>;
76
- /** Self-reported by the client. Observability only — never authorization. */
77
- readonly clientLabel?: string | undefined;
78
- readonly signal?: AbortSignal;
79
- }
23
+ export type { DispatchDeps, DispatchRequest } from './deps.ts';
24
+ import type { DispatchDeps, DispatchRequest } from './deps.ts';
80
25
 
81
26
  /**
82
27
  * `result` is a union because a capability is not necessarily a tool — a
@@ -198,7 +143,21 @@ export class Dispatcher {
198
143
 
199
144
  auditArguments = (redact ?? redactAllValues)(request.arguments);
200
145
 
201
- const declared = config.connections.find(
146
+ // **Who, before what.** A caller reaches a profile only if that profile's
147
+ // `members:` names them (ADR-060), and this is checked before the
148
+ // connection is even resolved: a refusal that first said "no such
149
+ // connection" would answer a question the caller was not entitled to ask.
150
+ // The profile is the principal's: the MCP layer routes on the `profile`
151
+ // argument and builds the principal for it, so this is the same value the
152
+ // caller named and the one the audit event records.
153
+ if (!mayReach(request.principal, request.principal.profile)) {
154
+ return (outcome = deny(
155
+ 'denied_default',
156
+ `Profile ${request.principal.profile} is not available`,
157
+ ));
158
+ }
159
+
160
+ const declared = this.#deps.connections.find(
202
161
  (connection) => `${connection.provider}.${connection.id}` === request.connectionKey,
203
162
  );
204
163
  if (!declared || declared.provider !== providerId) {
@@ -311,7 +270,7 @@ export class Dispatcher {
311
270
  // Which vendors this profile registered a client of its own for. A
312
271
  // provider that could be brokered but is not reads its client from the
313
272
  // store, so it has to be able to.
314
- ownClients: Object.keys(this.#deps.config.oauth_apps),
273
+ ownClients: this.#deps.oauthApps,
315
274
  ...(entry.manifest.connector.kind === 'local' ? {} : { authorize }),
316
275
  });
317
276
 
@@ -14,6 +14,11 @@
14
14
  * withheld. The floor is empty in M1 — the invariant is implemented
15
15
  * anyway, because it is what makes delegated access safe to add later,
16
16
  * and retrofitting it once rules exist in the wild is not possible.
17
+ *
18
+ * Since contract 3 a profile's rules are grouped by the connection they govern
19
+ * (ADR-058). Both invariants are unchanged and one is now sharper: a connection
20
+ * with no rules at all is not merely unmatched, it is absent, so default deny
21
+ * bites before a capability is even considered.
17
22
  */
18
23
 
19
24
  export type { AuthorizationResult } from '#audit';
@@ -43,6 +48,26 @@ export interface PolicyDocument {
43
48
 
44
49
  export const EMPTY_POLICY: PolicyDocument = { rules: [] };
45
50
 
51
+ /**
52
+ * A profile's rules, grouped by the connection each governs (ADR-058).
53
+ *
54
+ * A map rather than one list carrying a connection per rule, because the
55
+ * lookup is the decision: a call names exactly one connection, and the rules
56
+ * for any other are not evidence about it. Flattening them into one list would
57
+ * mean every evaluation re-filtered, and a filter that was ever wrong would let
58
+ * one account's `allow` answer for another.
59
+ *
60
+ * A connection absent from the map is not granted. That is default deny on the
61
+ * connection axis, and it is why `grants:` with an empty `allow` is a different
62
+ * thing from no row at all — the first says "reachable, nothing permitted", the
63
+ * second says "not reachable".
64
+ */
65
+ export interface ProfilePolicy {
66
+ readonly byConnection: ReadonlyMap<string, PolicyDocument>;
67
+ }
68
+
69
+ export const EMPTY_PROFILE_POLICY: ProfilePolicy = { byConnection: new Map() };
70
+
46
71
  export interface PolicyRequest {
47
72
  /**
48
73
  * The authenticated principal. One per profile in M1 (the owner). Carried
@@ -131,14 +156,21 @@ export function evaluateDocument(
131
156
  */
132
157
  export function evaluate(
133
158
  request: PolicyRequest,
134
- profile: PolicyDocument,
159
+ profile: ProfilePolicy,
135
160
  floor?: PolicyDocument,
136
161
  ): PolicyDecision {
137
162
  if (floor) {
138
163
  const floorDecision = evaluateDocument(floor, request);
139
164
  if (!floorDecision.allowed) return floorDecision;
140
165
  }
141
- return evaluateDocument(profile, request);
166
+
167
+ // The floor is still evaluated against the whole request, because a floor is
168
+ // an instance-wide narrowing and knows nothing about which connections a
169
+ // profile happens to hold. Only the profile's half is per connection.
170
+ const granted = profile.byConnection.get(request.connection);
171
+ if (granted === undefined) return { allowed: false, reason: 'denied_default' };
172
+
173
+ return evaluateDocument(granted, request);
142
174
  }
143
175
 
144
176
  /**
@@ -153,7 +185,7 @@ export function allowedConnections(
153
185
  capability: string,
154
186
  connections: readonly string[],
155
187
  principal: string,
156
- profile: PolicyDocument,
188
+ profile: ProfilePolicy,
157
189
  floor?: PolicyDocument,
158
190
  at?: Date,
159
191
  ): string[] {
@@ -164,16 +196,16 @@ export function allowedConnections(
164
196
  const provider = capability.slice(0, capability.indexOf('.'));
165
197
  const ofProvider = connections.filter((connection) => connection.startsWith(`${provider}.`));
166
198
 
167
- // All or nothing beyond that, since rules do not discriminate between
168
- // accounts. The list shape is kept because it is what the `connection` enum
169
- // wants, and because a future principal-scoped rule would restore the
170
- // filtering without touching any caller.
171
- const first = ofProvider[0];
172
- if (first === undefined) return [];
173
-
174
- const request: PolicyRequest = at
175
- ? { principal, capability, connection: first, at }
176
- : { principal, capability, connection: first };
177
-
178
- return evaluate(request, profile, floor).allowed ? ofProvider : [];
199
+ // One evaluation per account, where this used to be all-or-nothing. That
200
+ // difference is the whole of ADR-058 at the discovery boundary: a profile
201
+ // holding two mailboxes with different rules now advertises the `connection`
202
+ // enum each capability actually permits, instead of offering both wherever
203
+ // either was allowed.
204
+ return ofProvider.filter((connection) => {
205
+ const request: PolicyRequest = at
206
+ ? { principal, capability, connection, at }
207
+ : { principal, capability, connection };
208
+
209
+ return evaluate(request, profile, floor).allowed;
210
+ });
179
211
  }