@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
@@ -1,8 +1,10 @@
1
1
  import {
2
+ readConnections,
2
3
  ConfigError,
3
4
  listProfiles,
4
5
  loadWorkspaceProfiles,
5
6
  readRegistry,
7
+ resolveTargetWorkspace,
6
8
  type WorkspaceProfiles,
7
9
  } from '#profile';
8
10
  import { rotatableCredentialRefsFor } from '#registry';
@@ -49,16 +51,25 @@ export async function servingProfiles(input: {
49
51
  return { profiles: [...named], primary: named[0]! };
50
52
  }
51
53
 
52
- const living = await listProfiles(workspaceRoot);
54
+ // **Where the profiles actually are, which is not this machine.** After
55
+ // ADR-052 a deployed target's profiles live in its workspace, so listing the
56
+ // local root answered with whatever happened to be here: an empty workspace
57
+ // holding nothing but a pointer refused a redeploy of a live endpoint
58
+ // outright, and a local profile the bucket does not hold would have been sent
59
+ // to a revision that cannot open it. A declaration resolves back to the local
60
+ // root, which is the right answer there — a first deploy is exactly the case
61
+ // where the profiles are still on this machine and about to be uploaded.
62
+ const where = await resolveTargetWorkspace(workspaceRoot, target).catch(() => workspaceRoot);
63
+ const living = await listProfiles(where);
53
64
 
54
65
  if (living.length === 0) {
55
66
  throw new ConfigError(
56
67
  `No profile lives in "${target}", so there is no set to deploy.\n` +
57
68
  ' A first deploy creates the target, and has to be told which profile\n' +
58
69
  ' it belongs to:\n' +
59
- ` lanes link deploy --target ${target} --profile <name>\n\n` +
70
+ ` lanes link deploy --workspace ${target} --profile <name>\n\n` +
60
71
  ` If "${target}" was deployed before and the pointer to it was lost:\n` +
61
- ` lanes link sync targets --target ${target} --discover`,
72
+ ` lanes link sync targets --workspace ${target} --discover`,
62
73
  );
63
74
  }
64
75
 
@@ -89,7 +100,7 @@ async function choosePrimary(
89
100
  "of them owns the endpoint's token. One token opens the endpoint and\n" +
90
101
  'reaches every profile behind it, so this cannot be picked for you.\n\n' +
91
102
  ` Name it once and it is remembered:\n` +
92
- ` lanes link deploy --target ${target} --profile ${declaring[0]!}` +
103
+ ` lanes link deploy --workspace ${target} --profile ${declaring[0]!}` +
93
104
  declaring
94
105
  .slice(1)
95
106
  .map((name) => ` --profile ${name}`)
@@ -97,73 +108,3 @@ async function choosePrimary(
97
108
  );
98
109
  }
99
110
 
100
- /**
101
- * A credential reference two profiles would both write, in one store.
102
- *
103
- * References are flat — `gmail/main`, not `personal/gmail/main` — and a target
104
- * has one credential store, so two profiles deployed to the same project share
105
- * a namespace. `https://lanes.sh/docs/link/configuration` admits this in an aside about
106
- * removing a profile; deploying both at once is where it stops being an aside.
107
- *
108
- * The failure is silent and it is the bad kind: `personal`'s Gmail refresh
109
- * token is overwritten by `work`'s, both profiles go on listing their own
110
- * account in config, and the first symptom is one of them reading the other's
111
- * mailbox. Nothing downstream can catch it, because by then there is one
112
- * credential and it is valid.
113
- *
114
- * `profile/token` is deliberately not a collision. Every profile defaults to
115
- * that ref and the endpoint has exactly one token by design — sharing it is
116
- * what ADR-009 says happens, rather than an accident.
117
- */
118
- export interface Collision {
119
- readonly ref: string;
120
- readonly profiles: string[];
121
- }
122
-
123
- export async function collidingRefs(
124
- workspaceRoot: string,
125
- profiles: readonly string[],
126
- workspace?: WorkspaceProfiles,
127
- ): Promise<Collision[]> {
128
- const loaded = (workspace ?? (await loadWorkspaceProfiles(workspaceRoot))).loaded.filter(
129
- (entry) => profiles.includes(entry.profile),
130
- );
131
-
132
- const owners = new Map<string, string[]>();
133
-
134
- for (const entry of loaded) {
135
- const registry = await buildRegistryWithWorkspace(workspaceRoot, entry.profile);
136
- const refs = new Set<string>();
137
-
138
- for (const connection of entry.config.connections) {
139
- const manifest = registry.manifest(connection.provider);
140
- for (const ref of rotatableCredentialRefsFor(connection, manifest)) refs.add(ref);
141
- if (connection.credential_ref) refs.add(connection.credential_ref);
142
- }
143
-
144
- for (const ref of refs) {
145
- if (ref === entry.config.auth.token_ref) continue;
146
- owners.set(ref, [...(owners.get(ref) ?? []), entry.profile]);
147
- }
148
- }
149
-
150
- return [...owners.entries()]
151
- .filter(([, names]) => names.length > 1)
152
- .map(([ref, names]) => ({ ref, profiles: names.sort() }))
153
- .sort((a, b) => a.ref.localeCompare(b.ref));
154
- }
155
-
156
- /** The refusal, as a block, so the wording is testable without a deploy. */
157
- export function collisionRefusal(found: readonly Collision[], target: string): string {
158
- const rows = found.map((one) => ` ${one.ref} ${one.profiles.join(', ')}`).join('\n');
159
-
160
- return (
161
- `${found.length} credential reference(s) would be written by more than one\n` +
162
- `profile into the one credential store "${target}" has:\n\n${rows}\n\n` +
163
- ' References are flat, so these are the same secret and the last deploy\n' +
164
- ' wins — after which one profile is reading the other\'s account, and\n' +
165
- ' both still name their own in config.\n\n' +
166
- ' Give the connections different ids, or deploy the profiles to targets\n' +
167
- ' in separate projects.'
168
- );
169
- }
@@ -69,14 +69,14 @@ export async function openSecrets(input: TargetInput): Promise<SecretStore> {
69
69
  return createFileSecretStore({
70
70
  path: workspacePath(
71
71
  root,
72
- declared.credentials.path ?? layout.credentials(config.instance.profile),
72
+ declared.credentials.path ?? layout.credentials(),
73
73
  ),
74
74
  });
75
75
 
76
76
  case 'gcp-secret-manager': {
77
77
  if (!declared.credentials.project) {
78
78
  throw new ConfigError(
79
- `targets.${target}.credentials.project is required for the gcp-secret-manager adapter.`,
79
+ `workspaces.${target}.credentials.project is required for the gcp-secret-manager adapter.`,
80
80
  );
81
81
  }
82
82
  const { GcpSecretManagerStore } = await import('./adapters/gcp-secret-manager.ts');
@@ -97,8 +97,8 @@ export async function openSecrets(input: TargetInput): Promise<SecretStore> {
97
97
  * Its own root, `layout.state`, so it is not addressable from a provider's
98
98
  * blob namespace — the same containment `openAudit` relies on.
99
99
  */
100
- export function openState(storage: StorageFactory, profile: string): RuntimeState {
101
- return createRuntimeState(storage(layout.state(profile)));
100
+ export function openState(storage: StorageFactory): RuntimeState {
101
+ return createRuntimeState(storage(layout.state()));
102
102
  }
103
103
 
104
104
  /**
@@ -115,8 +115,8 @@ export function openState(storage: StorageFactory, profile: string): RuntimeStat
115
115
  * into (`layout.audit`), and that separation is what keeps ADR-007's wall
116
116
  * intact.
117
117
  */
118
- export function openAudit(storage: StorageFactory, profile: string): AuditStore {
119
- return createBlobAuditStore({ storage: storage(layout.audit(profile)) });
118
+ export function openAudit(storage: StorageFactory): AuditStore {
119
+ return createBlobAuditStore({ storage: storage(layout.audit()) });
120
120
  }
121
121
 
122
122
  /**
@@ -137,7 +137,7 @@ export async function openAuditSinks(
137
137
  secrets: SecretStore,
138
138
  log?: (message: string) => void,
139
139
  ): Promise<{ sink: AuditSink; reader: AuditReader }> {
140
- const durable = openAudit(storage, input.config.instance.profile);
140
+ const durable = openAudit(storage);
141
141
  const declared = input.declared.audit?.sinks ?? [];
142
142
  if (declared.length === 0) return { sink: durable, reader: durable };
143
143
 
@@ -201,7 +201,7 @@ export async function openStorage(
201
201
  switch (declared.storage.adapter) {
202
202
  case 'filesystem': {
203
203
  const { createFilesystemBlobStore } = await import('./adapters/filesystem.ts');
204
- const base = declared.storage.path ?? layout.blobs(config.instance.profile);
204
+ const base = declared.storage.path ?? layout.blobs();
205
205
  return (area) =>
206
206
  createFilesystemBlobStore({ root: workspacePath(root, area === undefined ? base : area) });
207
207
  }
@@ -214,12 +214,12 @@ export async function openStorage(
214
214
  // by hand in a console.
215
215
  const { bucket, prefix } = declared.storage;
216
216
  if (!bucket) {
217
- throw new ConfigError(`targets.${target}.storage.bucket is required for the gcs adapter.`);
217
+ throw new ConfigError(`workspaces.${target}.storage.bucket is required for the gcs adapter.`);
218
218
  }
219
219
 
220
220
  const { createGcsBlobStore } = await import('./adapters/gcs.ts');
221
221
  const base = prefix ?? '';
222
- const root = layout.blobs(config.instance.profile);
222
+ const root = layout.blobs();
223
223
 
224
224
  return (area) =>
225
225
  createGcsBlobStore({
@@ -231,25 +231,25 @@ export async function openStorage(
231
231
  case 's3': {
232
232
  const { bucket, endpoint, region, prefix } = declared.storage;
233
233
  if (!bucket) {
234
- throw new ConfigError(`targets.${target}.storage.bucket is required for the s3 adapter.`);
234
+ throw new ConfigError(`workspaces.${target}.storage.bucket is required for the s3 adapter.`);
235
235
  }
236
236
 
237
237
  const accessKeyId = await requireSecret(
238
238
  secrets,
239
239
  declared.storage.access_key_id_ref,
240
- `targets.${target}.storage.access_key_id_ref`,
240
+ `workspaces.${target}.storage.access_key_id_ref`,
241
241
  target,
242
242
  );
243
243
  const secretAccessKey = await requireSecret(
244
244
  secrets,
245
245
  declared.storage.secret_access_key_ref,
246
- `targets.${target}.storage.secret_access_key_ref`,
246
+ `workspaces.${target}.storage.secret_access_key_ref`,
247
247
  target,
248
248
  );
249
249
 
250
250
  const { createS3BlobStore } = await import('./adapters/s3.ts');
251
251
  const base = prefix ?? '';
252
- const root = layout.blobs(config.instance.profile);
252
+ const root = layout.blobs();
253
253
 
254
254
  return (area) =>
255
255
  createS3BlobStore({
@@ -289,7 +289,7 @@ export async function requireSecret(
289
289
  if (!value) {
290
290
  throw new ConfigError(
291
291
  `${field} names "${ref}", which is not in this target's secret store. ` +
292
- `Store it with: lanes link secrets set ${ref} --target ${target}`,
292
+ `Store it with: lanes link secrets set ${ref} --workspace ${target}`,
293
293
  );
294
294
  }
295
295
  return value;
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  openTarget,
3
3
  DATA_DIR,
4
+ CONNECTIONS_FILE,
4
5
  WORKSPACE_FILE,
5
6
  layout,
6
7
  listProfiles,
@@ -55,13 +56,18 @@ export function deployedWorkspace(declared: TargetConfig): string | undefined {
55
56
  * forgets means an endpoint that will not boot, and a credential this includes
56
57
  * by accident means a credential in a bucket. Forgetting is loud.
57
58
  *
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.
59
+ * **Two areas inside `data/` are authored rather than accumulated.** Skills and
60
+ * provider manifests have to go up or a deployed instance loses both — the
61
+ * regression ADR-014 §2 fixed for skills. So this reaches into `data/` for
62
+ * exactly those two, by whole path segment and never by prefix:
63
+ * `data/skills.detour/` is not `skills.d`, and the difference between matching
64
+ * it and not is a credential in a bucket.
65
+ *
66
+ * Neither is filtered by the profile set any more. Both are keyed by connection
67
+ * now rather than by profile (ADR-057, ADR-059), and a connection can be
68
+ * granted by any profile in the workspace — so sending "only this profile's
69
+ * skills" is not a thing that can be computed, and withholding them would
70
+ * deploy an endpoint whose prompts are missing.
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,45 @@ 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
+
89
+ if (isAuthoredArea(key)) return true;
90
+
77
91
  // A set rather than one name, because a deploy now sends every profile that
78
92
  // declares the target rather than the single one it was told. `undefined`
79
93
  // still means the whole workspace, and an *empty* set means nothing — which
80
94
  // is a distinction a bare string could not make.
81
95
  const wanted = profiles === undefined ? undefined : new Set(profiles);
82
96
 
83
- const owner = authoredAreaOwner(key);
84
- if (owner !== null) return wanted === undefined || wanted.has(owner);
85
-
86
97
  if (!key.startsWith('profiles/') || !key.endsWith('.yaml')) return false;
87
98
  const name = key.slice('profiles/'.length, -'.yaml'.length);
88
99
  return wanted === undefined || wanted.has(name);
89
100
  }
90
101
 
91
102
  /**
92
- * The profile whose authored area holds `key`, or null for anything else.
103
+ * Whether `key` is inside one of the two authored areas.
93
104
  *
94
105
  * Composed back out of `layout` rather than compared against literals, so a
95
106
  * 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.
107
+ *
108
+ * The segment counts differ because the layouts do, and both are the "a
109
+ * directory is not a file to send" rule: a manifest is `data/providers.d/<file>`
110
+ * and a skill is `data/skills.d/<connection>/<file>`, so requiring one more
111
+ * segment than the area itself has is what stops the bare directory key
112
+ * matching.
98
113
  */
99
- function authoredAreaOwner(key: string): string | null {
114
+ function isAuthoredArea(key: string): boolean {
100
115
  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;
116
+ if (segments.length < 3 || segments[0] !== DATA_DIR) return false;
105
117
 
106
- const area = `${DATA_DIR}/${owner}/${segments[2]}`;
107
- return area === layout.skills(owner) || area === layout.providers(owner) ? owner : null;
118
+ const area = `${DATA_DIR}/${segments[1]}`;
119
+ if (area === layout.providers()) return segments.length >= 3;
120
+ if (area === layout.skillsRoot()) return segments.length >= 4;
121
+ return false;
108
122
  }
109
123
 
110
124
  /**
@@ -157,7 +171,16 @@ export async function publishWorkspace(input: {
157
171
  readonly config: Config;
158
172
  readonly workspaceRoot: string;
159
173
  readonly target: string;
160
- readonly profile: string;
174
+ /**
175
+ * The profiles this edit touched, not just the one it was run under.
176
+ *
177
+ * `disconnect` removes a connection from the workspace and drops the grant
178
+ * from *every* profile that named it, so publishing one profile left the
179
+ * bucket holding a `connections.yaml` without the connection and a sibling
180
+ * profile still granting it — which `assertGrantsResolve` refuses at load, so
181
+ * `openReconciled` skipped that profile and it silently stopped being served.
182
+ */
183
+ readonly profile: string | readonly string[];
161
184
  }): Promise<string | null> {
162
185
  // Resolution failures are swallowed rather than thrown. The config edit that
163
186
  // called this has already succeeded and is on disk; a target that cannot be
@@ -173,6 +196,7 @@ export async function publishWorkspace(input: {
173
196
  const destination = deployedWorkspace(declared);
174
197
  if (!destination) return null;
175
198
 
176
- await uploadWorkspace(input.workspaceRoot, destination, [input.profile]);
199
+ const touched = typeof input.profile === 'string' ? [input.profile] : input.profile;
200
+ await uploadWorkspace(input.workspaceRoot, destination, touched);
177
201
  return destination;
178
202
  }
@@ -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
  }