@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,10 +1,10 @@
1
1
  import { parse as parseYaml } from 'yaml';
2
2
  import { z } from 'zod';
3
3
  import {
4
+ SINGLE_INSTANCE_PROVIDERS,
4
5
  SUPPORTED_CONTRACT,
5
6
  configSchema,
6
7
  type Config,
7
- type PolicyRuleConfig,
8
8
  } from './schema.ts';
9
9
  import { findSecrets, formatSecretFindings } from './secret-detection.ts';
10
10
 
@@ -21,7 +21,7 @@ export class ConfigError extends Error {
21
21
  export interface LoadedConfig {
22
22
  readonly config: Config;
23
23
  readonly source: string;
24
- /** `provider.id` for every declared connection, in declaration order. */
24
+ /** `provider.id` for every connection this profile grants, in declaration order. */
25
25
  readonly connectionKeys: readonly string[];
26
26
  }
27
27
 
@@ -208,7 +208,7 @@ export const RENAMED_PROVIDERS: Readonly<Record<string, ProviderRename>> = {
208
208
  * and whoever is reading this refusal just typed which one.
209
209
  */
210
210
  function repairCommand(config: Config): string {
211
- return `lanes link doctor --fix --profile ${config.instance.profile} --target <target>`;
211
+ return `lanes link doctor --fix --profile ${config.instance.profile} --workspace <name>`;
212
212
  }
213
213
 
214
214
  /** The rename a row is owed, or `null` when it is owed none. */
@@ -221,7 +221,7 @@ export function renamedProviderFor(connection: {
221
221
  return moved;
222
222
  }
223
223
 
224
- function renamedProvider(
224
+ export function describeRename(
225
225
  connection: { provider: string; account: string },
226
226
  repair: string,
227
227
  ): string | null {
@@ -241,30 +241,74 @@ function renamedProvider(
241
241
  function assertReferentialIntegrity(config: Config, source: string): void {
242
242
  const problems: string[] = [];
243
243
 
244
- // There is nothing to check about targets here any more. A profile declares
245
- // none (ADR-052): the workspace holding this file declares the one target it
246
- // lives in, and `workspaceSchema` is what validates that. A profile is now
247
- // portable between targets precisely because it says nothing about them.
248
-
249
- // Connection ids are unique per provider, so `gmail.main` and
250
- // `icloud_mail.main` can coexist.
251
- const connectionKeys = new Set<string>();
252
- const providerNames = new Set(config.connections.map((connection) => connection.provider));
253
-
254
- config.connections.forEach((connection, index) => {
255
- const key = `${connection.provider}.${connection.id}`;
256
- if (connectionKeys.has(key)) {
257
- problems.push(`connections[${index}]: duplicate connection "${key}"`);
244
+ // Nothing about targets is checked here any more. A profile declares none
245
+ // (ADR-052): the workspace holding this file declares the one it lives in.
246
+ //
247
+ // Nothing about *connections* is either, and that is newer. A connection
248
+ // belongs to the workspace (ADR-057), so whether a grant names a real one is
249
+ // a question this file cannot answer on its own — `assertGrantsResolve` in
250
+ // `./connections.ts` answers it once `connections.yaml` has been read. What
251
+ // is checkable from one profile alone is checked here, and only that.
252
+
253
+ // A grant names one connection, so every capability in it belongs to that
254
+ // connection's provider. Worth refusing rather than warning: `allowedConnections`
255
+ // filters candidates to the capability's own provider before policy is
256
+ // consulted, so `allow: [calendar.*]` on a row granting `gmail.personal`
257
+ // matches nothing, ever, while reading exactly like a grant that works.
258
+ const grantKeys = new Set<string>();
259
+ config.grants.forEach((grant, index) => {
260
+ if (grantKeys.has(grant.connection)) {
261
+ problems.push(`grants[${index}]: duplicate grant for "${grant.connection}"`);
258
262
  }
259
- connectionKeys.add(key);
263
+ grantKeys.add(grant.connection);
264
+
265
+ const provider = grant.connection.split('.')[0] ?? '';
266
+
267
+ // The renamed-provider check is not here any more. It compares a row's
268
+ // *account* against the label the built-in keeps — "is this really Google
269
+ // Tasks?" — and an account lives in `connections.yaml` now (ADR-057). A
270
+ // profile grant carries only the key, so asking here would either need the
271
+ // other file or answer from nothing; `assertNoRenamedProviders` asks where
272
+ // the answer is.
273
+
274
+ for (const [field, rules] of [
275
+ ['allow', grant.allow],
276
+ ['deny', grant.deny],
277
+ ] as const) {
278
+ rules.forEach((rule, ruleIndex) => {
279
+ if (rule.capability === '*') return;
280
+ const named = rule.capability.split('.')[0] ?? '';
281
+ if (named === provider) return;
260
282
 
261
- const renamed = renamedProvider(connection, repairCommand(config));
262
- if (renamed) problems.push(`connections[${index}]: ${renamed}`);
283
+ problems.push(
284
+ `grants[${index}].${field}[${ruleIndex}]: "${rule.capability}" names provider ` +
285
+ `"${named}", but this row grants "${grant.connection}". A rule here governs ` +
286
+ `that connection and nothing else, so it can only name "${provider}.*".`,
287
+ );
288
+ });
289
+ }
263
290
  });
264
291
 
265
- // Same reason as a duplicate connection: two entries with the same kind and
266
- // value cannot both be meant, and the one that loses is invisible. It matters
267
- // more here than it looks, because the two would usually differ only in their
292
+ // At most one skills grant and one vault grant, for the reason
293
+ // `SINGLE_INSTANCE_PROVIDERS` gives: both surface as flat names with no
294
+ // argument to route on, so a second instance is a collision rather than a
295
+ // choice.
296
+ for (const provider of SINGLE_INSTANCE_PROVIDERS) {
297
+ const granted = config.grants
298
+ .map((grant) => grant.connection)
299
+ .filter((ref) => ref.startsWith(`${provider}.`));
300
+
301
+ if (granted.length > 1) {
302
+ problems.push(
303
+ `grants: ${granted.join(' and ')} — a profile may grant one "${provider}" connection. ` +
304
+ `It is surfaced by name with nothing to route on, so two would be one name for two things.`,
305
+ );
306
+ }
307
+ }
308
+
309
+ // Same reason as a duplicate grant: two entries with the same kind and value
310
+ // cannot both be meant, and the one that loses is invisible. It matters more
311
+ // here than it looks, because the two would usually differ only in their
268
312
  // `note` — so the discarded one is precisely the guidance someone wrote down
269
313
  // to stop an agent picking wrong.
270
314
  const identityKeys = new Set<string>();
@@ -276,28 +320,17 @@ function assertReferentialIntegrity(config: Config, source: string): void {
276
320
  identityKeys.add(key);
277
321
  });
278
322
 
279
- const checkRules = (rules: readonly PolicyRuleConfig[], field: 'allow' | 'deny'): void => {
280
- rules.forEach((rule, index) => {
281
- const where = `policy.${field}[${index}]`;
282
- if (rule.capability === '*') return;
283
-
284
- // A rule naming a provider with no connection is almost always a typo,
285
- // and one that fails open-looking: it reads as a grant while matching
286
- // nothing. Worth saying, but not fatal for a `deny` — denying something
287
- // you have not connected yet is a perfectly reasonable thing to write
288
- // ahead of time.
289
- const providerOfCapability = rule.capability.split('.')[0] ?? '';
290
- if (field === 'allow' && !providerNames.has(providerOfCapability)) {
291
- problems.push(
292
- `${where}: "${rule.capability}" names provider "${providerOfCapability}", which has no connection` +
293
- (providerNames.size > 0 ? ` (have: ${[...providerNames].join(', ')})` : ''),
294
- );
295
- }
296
- });
297
- };
298
-
299
- checkRules(config.policy.allow, 'allow');
300
- checkRules(config.policy.deny, 'deny');
323
+ // A subject listed twice is one row deciding the role and the other doing
324
+ // nothing, and which one wins is iteration order. Since the two would differ
325
+ // only in `role`, the silent loser is exactly the line someone added to
326
+ // promote or demote somebody (ADR-060).
327
+ const subjects = new Set<string>();
328
+ config.members.forEach((member, index) => {
329
+ if (subjects.has(member.subject)) {
330
+ problems.push(`members[${index}]: duplicate subject "${member.subject}"`);
331
+ }
332
+ subjects.add(member.subject);
333
+ });
301
334
 
302
335
  if (problems.length > 0) {
303
336
  throw new ConfigError(`${source}:\n${problems.map((p) => ` ${p}`).join('\n')}`, problems);
@@ -316,7 +349,7 @@ export function parseConfig(text: string, source = '<config>'): LoadedConfig {
316
349
  return {
317
350
  config,
318
351
  source,
319
- connectionKeys: config.connections.map((c) => `${c.provider}.${c.id}`),
352
+ connectionKeys: config.grants.map((grant) => grant.connection),
320
353
  };
321
354
  }
322
355
 
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Where the dashboard's pairing credentials live in a workspace's store.
3
+ *
4
+ * Here rather than beside the command that writes them, because three
5
+ * components now read these names and only one of them is the CLI: the server
6
+ * opens the read surface with them (`#server/read`), and a deploy binds the
7
+ * token so the revision may read it (`#deployments/prepare.ts`). Importing a
8
+ * *command* module for a string constant dragged `cli/output.ts`,
9
+ * `cli/prompt.ts` and the terminal handling behind them into the container's
10
+ * runtime graph, which is a large amount of the CLI to load in order to learn
11
+ * three names.
12
+ *
13
+ * Underscores, not dots. A secret reference is `[a-z0-9_-]` separated by `/`
14
+ * (see `isValidSecretRef`), and that is not arbitrary: these names become
15
+ * Secret Manager entries on a deployed workspace, and Google allows no dots
16
+ * there either. `workspace/pair.cert` was refused at the moment somebody first
17
+ * ran the command.
18
+ */
19
+
20
+ /** The credential the dashboard presents. Reads everything; can change nothing. */
21
+ export const PAIR_TOKEN_REF = 'workspace/pair_token';
22
+
23
+ /**
24
+ * The certificate the loopback read listener terminates TLS with, and its key.
25
+ *
26
+ * Loopback only, and deliberately not bound on a deployed workspace: Cloud Run
27
+ * terminates TLS with a certificate a browser already trusts, so a deployed
28
+ * revision never calls `serveRead` and never reads either of these. Binding two
29
+ * secrets nothing reads would say the boundary is wider than it is.
30
+ */
31
+ export const PAIR_CERT_REF = 'workspace/pair_cert';
32
+ export const PAIR_KEY_REF = 'workspace/pair_key';
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
 
3
3
  /**
4
- * The four string shapes the config contract is built out of.
4
+ * The string shapes the config contract is built out of.
5
5
  *
6
6
  * Their own file because more than one schema module needs them, and the
7
7
  * alternative was either a circular import or a second copy of a regex that
@@ -66,3 +66,37 @@ export const browserOrigin = z.string().refine(
66
66
  },
67
67
  'must be "*" or an origin with no trailing slash or path, e.g. "https://chat.example"',
68
68
  );
69
+
70
+ /**
71
+ * `<provider>.<connection>` — how a grant names what it governs.
72
+ *
73
+ * One string rather than two fields, because it is one string everywhere else
74
+ * it appears: the `connection` argument an agent passes, the audit event, the
75
+ * `setup_overview` listing, and the refusal a mismatched pairing produces.
76
+ * Splitting it in the config alone would mean every reader joins it back
77
+ * together.
78
+ */
79
+ export const connectionRef = z
80
+ .string()
81
+ .regex(
82
+ /^[a-z][a-z0-9_]*\.[a-z0-9][a-z0-9_]*$/,
83
+ 'must be "<provider>.<connection>", e.g. "gmail.personal"',
84
+ );
85
+
86
+ /**
87
+ * Who a profile is delegated to: `lanes:<subject>` (ADR-060).
88
+ *
89
+ * The prefix is load-bearing twice over, and the second reason is the one that
90
+ * would otherwise be found the hard way. A Lanes subject is a 28-character
91
+ * mixed-case alphanumeric string — which is exactly what `secret-detection.ts`
92
+ * refuses as a high-entropy blob, so a bare one could not be written into a
93
+ * profile at all. The colon takes the value out of `OPAQUE_TOKEN`'s character
94
+ * class, so saying which identity provider vouched for the subject and being
95
+ * storable are the same decision rather than an exemption list.
96
+ *
97
+ * A pasted credential still cannot be smuggled in here: it does not match this
98
+ * pattern either, which is what makes the fix a narrowing rather than a hole.
99
+ */
100
+ export const subjectRef = z
101
+ .string()
102
+ .regex(/^lanes:[A-Za-z0-9]{6,64}$/, 'must be "lanes:<subject>", as written by lanes auth login');
@@ -27,7 +27,7 @@ import {
27
27
  * A machine reaches a target it does not hold through a **pointer** — a registry
28
28
  * entry carrying `workspace:` and nothing else. Following one is a read of that
29
29
  * workspace's own file, which is why every function here is async and why
30
- * `--target cloud` needs the bucket reachable. That is the trade ADR-052 takes
30
+ * `--workspace cloud` needs the bucket reachable. That is the trade ADR-052 takes
31
31
  * deliberately: a cloud target that cannot be read says so, where the shape it
32
32
  * replaces answered instantly from a copy that had been wrong for eight hours.
33
33
  */
@@ -51,7 +51,7 @@ export interface ResolvedTarget {
51
51
  /** Every target the workspace at `root` knows about. Empty when it has no file. */
52
52
  export async function readRegistry(root: string): Promise<Record<string, WorkspaceTarget>> {
53
53
  const workspace = await readWorkspace(root);
54
- return workspace?.targets ?? {};
54
+ return workspace?.workspaces ?? {};
55
55
  }
56
56
 
57
57
  /**
@@ -66,7 +66,7 @@ export async function resolveTargetWorkspace(root: string, target: string): Prom
66
66
  const registry = await readRegistry(root);
67
67
  const entry = registry[target];
68
68
  if (!entry) throw notInRegistry(target, registry, root);
69
- return isPointer(entry) ? entry.workspace.replace(/\/$/, '') : root;
69
+ return isPointer(entry) ? entry.at.replace(/\/$/, '') : root;
70
70
  }
71
71
 
72
72
  /**
@@ -90,7 +90,7 @@ export async function openTarget(root: string, target: string): Promise<Resolved
90
90
  return { target, workspaceRoot: root, declared, entry, remote: false };
91
91
  }
92
92
 
93
- const workspaceRoot = entry.workspace.replace(/\/$/, '');
93
+ const workspaceRoot = entry.at.replace(/\/$/, '');
94
94
  const remoteRegistry = await readRegistry(workspaceRoot);
95
95
  const remoteEntry = remoteRegistry[target];
96
96
 
@@ -146,7 +146,7 @@ function pointerMissesTarget(
146
146
  return new ConfigError(
147
147
  `${root} says target "${target}" lives at ${workspaceRoot}, but that workspace does not ` +
148
148
  `declare it (it declares: ${there}).\n` +
149
- ` Adopt what is really there: lanes link sync targets --target ${target} --from ${workspaceRoot}`,
149
+ ` Adopt what is really there: lanes link sync targets --workspace ${target} --from ${workspaceRoot}`,
150
150
  );
151
151
  }
152
152
 
@@ -176,7 +176,7 @@ function remoteAtContractOne(target: string, workspaceRoot: string): ConfigError
176
176
  return new ConfigError(
177
177
  `${workspaceRoot} is a contract 1 workspace, so it does not declare "${target}" yet.\n` +
178
178
  ' Its profiles still carry their own targets: block, which this version does not read.\n\n' +
179
- ` lanes link deploy --target ${target}\n` +
179
+ ` lanes link deploy --workspace ${target}\n` +
180
180
  ' migrates it and rolls the image that can read it, in that order — which is what\n' +
181
181
  ' keeps the endpoint in front of it serving throughout (ADR-052).',
182
182
  );
@@ -1,5 +1,12 @@
1
1
  import { z } from 'zod';
2
- import { browserOrigin, capabilityPattern, credentialRef, identifier } from './primitives.ts';
2
+ import {
3
+ browserOrigin,
4
+ capabilityPattern,
5
+ connectionRef,
6
+ credentialRef,
7
+ identifier,
8
+ subjectRef,
9
+ } from './primitives.ts';
3
10
  import { authorizationSchema } from './authorization.ts';
4
11
  import { identitySchema } from './identity.ts';
5
12
  import { knowledgeTargetSchema } from './knowledge.ts';
@@ -35,11 +42,19 @@ import { knowledgeTargetSchema } from './knowledge.ts';
35
42
  * workspace *is* a target: it declares its own adapters once, holds the profiles
36
43
  * that live in it, and a profile is one copy in one place.
37
44
  *
38
- * A hard cut, and contract 1 is not read here. `./legacy.ts` understands it, and
39
- * only the migration uses that a runtime that loaded either shape would be the
40
- * two-sources-of-truth problem again, one level up.
45
+ * **3 moved `connections:` out of the profile and into the workspace**
46
+ * (ADR-057), and replaced the profile's flat `policy:` with `grants:`, one row
47
+ * per connection (ADR-058). A profile stopped being an inventory of accounts
48
+ * and became a selection over them, which is what makes "read this mailbox,
49
+ * write that calendar" a thing that can be written down. It also gained
50
+ * `members:`, because a profile worth sharing needs to say who may consume it
51
+ * (ADR-060).
52
+ *
53
+ * A hard cut each time, and only the newest is read here. `./legacy.ts`
54
+ * understands the older shapes and only the migration uses it — a runtime that
55
+ * loaded either would be the two-sources-of-truth problem again, one level up.
41
56
  */
42
- export const SUPPORTED_CONTRACT = 2;
57
+ export const SUPPORTED_CONTRACT = 3;
43
58
 
44
59
  /**
45
60
  * There is no `database:` block any more.
@@ -136,7 +151,7 @@ export const auditTargetSchema = z.object({
136
151
  */
137
152
  export const vaultTargetSchema = z.object({
138
153
  adapter: z.enum(['file', 'blob', 'secret']),
139
- /** File path, or blob key. Defaults to `./data/<profile>.vault.enc` / `vault.enc`. */
154
+ /** File path, or blob key. Defaults to `data/vault.d/<connection>.enc`. */
140
155
  path: z.string().optional(),
141
156
  /** `secret` only: where the sealed document lives. Defaults to `vault/document`. */
142
157
  ref: credentialRef.optional(),
@@ -327,6 +342,69 @@ export const policySchema = z.object({
327
342
  deny: z.array(policyRuleSchema).default([]),
328
343
  });
329
344
 
345
+ /**
346
+ * Providers a profile may grant at most one instance of.
347
+ *
348
+ * Not a limitation of the store — `data/skills.d/<id>/` and
349
+ * `data/vault.d/<id>.enc` hold as many as anyone makes (ADR-059). It is a
350
+ * limitation of the surface, and it comes from the protocol rather than from
351
+ * here.
352
+ *
353
+ * Every other owner-layer tool takes a `connection` argument, so two memory
354
+ * instances are two routes through one tool and the caller says which. These
355
+ * two have nowhere to put that. A skill is surfaced as an MCP **prompt**, and a
356
+ * prompt is selected by name with no arguments to route on (ADR-012) — two
357
+ * `triage` skills in one profile would be one name for two procedures. A vault
358
+ * item becomes its own `vault.get.<id>` capability, and capability ids are flat
359
+ * for the same reason: two instances holding `stripe_key` would be one
360
+ * capability naming two secrets, which is the worst of the three collisions
361
+ * because the wrong answer is a credential.
362
+ *
363
+ * So the refusal is at load, where it names both rows, rather than at call time
364
+ * where one would silently win.
365
+ */
366
+ export const SINGLE_INSTANCE_PROVIDERS: readonly string[] = ['skills', 'vault'];
367
+
368
+ /**
369
+ * One connection, and what may be done with it (ADR-058).
370
+ *
371
+ * The rules are the same `allow`/`deny` pair `policySchema` has always carried;
372
+ * what is new is that a row names the account they govern. ADR-003 kept rules
373
+ * connection-blind on the reasoning that "a narrower grant is a narrower
374
+ * profile" — sound while a connection lived in exactly one profile, because the
375
+ * second profile *was* the granularity. ADR-057 removed that mechanism, so the
376
+ * granularity moves into the rule.
377
+ *
378
+ * **There is no profile-wide `allow` beside these rows, deliberately.** A
379
+ * second place a connection could be granted is a second answer to "may this
380
+ * call proceed", and two answers to one question is the failure ADR-052 exists
381
+ * to prevent. A profile that wants the same rules on eight connections writes
382
+ * eight rows; the repetition is the cost of there being one place to look.
383
+ *
384
+ * Default deny is unchanged and now bites one step earlier: a connection with
385
+ * no row is not reachable, and is never advertised.
386
+ */
387
+ export const grantSchema = z.object({
388
+ connection: connectionRef,
389
+ allow: z.array(policyRuleSchema).default([]),
390
+ deny: z.array(policyRuleSchema).default([]),
391
+ });
392
+
393
+ /**
394
+ * Who may consume this profile (ADR-060).
395
+ *
396
+ * `role` decides who may edit this list and nothing else. Both roles reach
397
+ * exactly what the grants above allow, because a role that changed what an
398
+ * agent could call would be a second policy system beside `grants:`, with
399
+ * precedence rules between them, answering a question the first one already
400
+ * answers. Two people needing different scopes is two profiles, which is cheap
401
+ * now that neither of them re-authorises an account.
402
+ */
403
+ export const memberSchema = z.object({
404
+ subject: subjectRef,
405
+ role: z.enum(['owner', 'member']).default('member'),
406
+ });
407
+
330
408
  /**
331
409
  * One authorised account.
332
410
  *
@@ -412,15 +490,36 @@ export const configSchema = z.object({
412
490
  })
413
491
  .default({ requests_per_minute: 120, upstream_calls_per_minute: 60 }),
414
492
 
415
- oauth_apps: z.record(identifier, oauthAppSchema).default({}),
493
+ /**
494
+ * What this profile is for, in the owner's own words.
495
+ *
496
+ * New in contract 3, and it earns its place because a profile is now a thing
497
+ * you hand to somebody (ADR-060). "personal-assistant" is a name; "reads my
498
+ * mail, keeps the calendar, never sends" is what a member needs to know
499
+ * before accepting it. The dashboard and `setup_overview` show it.
500
+ */
501
+ description: z.string().min(1).optional(),
416
502
 
417
503
  /**
418
- * There is no `providers` block. A provider is enabled by having a connection
419
- * to it — a second place to say so could only ever disagree with the first,
420
- * and everything else a provider needs is in its manifest.
504
+ * The connections this profile selects, and what may be done with each.
505
+ *
506
+ * There is no `connections:` block here any more a connection belongs to
507
+ * the workspace (ADR-057), and `connections.yaml` beside this file is where
508
+ * it lives. A grant naming a connection the workspace does not hold is
509
+ * refused by `assertReferentialIntegrity` rather than loading and reaching
510
+ * nothing.
421
511
  */
422
- connections: z.array(connectionSchema).default([]),
423
- policy: policySchema.default({ allow: [], deny: [] }),
512
+ grants: z.array(grantSchema).default([]),
513
+
514
+ /**
515
+ * Who may consume this profile (ADR-060).
516
+ *
517
+ * Empty is not "everyone" — it is nobody, which is default deny applied to
518
+ * the identity axis. `profile add` writes the signed-in subject as `owner`,
519
+ * so the empty state is one a hand-edit produces rather than one anybody is
520
+ * handed.
521
+ */
522
+ members: z.array(memberSchema).default([]),
424
523
 
425
524
  /**
426
525
  * Memory and skills, somewhere other than the target's own storage (ADR-041).
@@ -446,8 +545,35 @@ export const configSchema = z.object({
446
545
  identity: identitySchema.default([]),
447
546
  });
448
547
 
548
+ /**
549
+ * `connections.yaml` — every account authorised in this workspace (ADR-057).
550
+ *
551
+ * Its own file rather than a block in `lanes-link.yaml`, because that file is
552
+ * the registry and is read before anything else can be: resolving which
553
+ * workspace a command means must not require parsing every connection in it.
554
+ * And its own file rather than staying in the profile, because authorising an
555
+ * account and deciding what may be done with it are two acts, and only the
556
+ * second is a property of a profile.
557
+ *
558
+ * `oauth_apps` comes with them. A registered client belongs to the account it
559
+ * authenticates, not to whichever selection happens to name that account.
560
+ *
561
+ * There is still no `providers` block. A provider is enabled by having a
562
+ * connection to it — a second place to say so could only ever disagree with the
563
+ * first, and everything else a provider needs is in its manifest.
564
+ */
565
+ export const connectionsFileSchema = z.object({
566
+ contract: z.number().int().positive(),
567
+ connections: z.array(connectionSchema).default([]),
568
+ oauth_apps: z.record(identifier, oauthAppSchema).default({}),
569
+ });
570
+
571
+ export type ConnectionsFile = z.infer<typeof connectionsFileSchema>;
572
+
449
573
  export type Config = z.infer<typeof configSchema>;
450
574
  export type ConnectionConfig = z.infer<typeof connectionSchema>;
575
+ export type GrantConfig = z.infer<typeof grantSchema>;
576
+ export type MemberConfig = z.infer<typeof memberSchema>;
451
577
  export type PolicyRuleConfig = z.infer<typeof policyRuleSchema>;
452
578
  export type TargetConfig = z.infer<typeof targetSchema>;
453
579
  export type DeployConfig = z.infer<typeof deployTargetSchema>;
@@ -482,8 +608,14 @@ export type { IdentityEntry } from './identity.ts';
482
608
  */
483
609
  export const workspaceTargetSchema = z
484
610
  .object({
485
- /** A pointer: where the workspace declaring this target lives. */
486
- workspace: z.string().min(1).optional(),
611
+ /**
612
+ * A pointer: where the workspace declaring this one lives.
613
+ *
614
+ * Spelled `at:` rather than `workspace:` since contract 3. The block is
615
+ * `workspaces:` now (ADR-061), and `workspaces.cloud.workspace` names the
616
+ * concept twice and reads as a typo.
617
+ */
618
+ at: z.string().min(1).optional(),
487
619
  credentials: credentialsTargetSchema.optional(),
488
620
  audit: auditTargetSchema.optional(),
489
621
  storage: storageTargetSchema.optional(),
@@ -497,6 +629,15 @@ export const workspaceTargetSchema = z
497
629
  * gets in — the one question about a deployment that must not be guessed at.
498
630
  */
499
631
  primary: identifier.optional(),
632
+ /**
633
+ * The Lanes workspace this one serves, when it serves one (ADR-060).
634
+ *
635
+ * A binding, not an identity: it says whose membership list
636
+ * `profile members add` validates a subject against. Absent means the
637
+ * workspace delegates only to the signed-in owner, which is what `local`
638
+ * does.
639
+ */
640
+ lanes_workspace: z.string().min(1).optional(),
500
641
  last_deploy: z.string().optional(),
501
642
  /**
502
643
  * The CLI release that rolled the revision serving this target.
@@ -520,21 +661,21 @@ export const workspaceTargetSchema = z
520
661
  // rather than preferring one: a pointer beside a declaration is two answers
521
662
  // to "where are this target's bytes", and picking either silently is how the
522
663
  // fifteen-connection bucket got reported as seven.
523
- if (declares && entry.workspace !== undefined) {
664
+ if (declares && entry.at !== undefined) {
524
665
  ctx.addIssue({
525
666
  code: 'custom',
526
667
  message:
527
- 'names a "workspace" and also declares adapters — a target is declared by exactly ' +
668
+ 'names an "at" and also declares adapters — a workspace is declared in exactly ' +
528
669
  'one workspace. Keep the adapters here, or keep the pointer and declare them there.',
529
670
  });
530
671
  return;
531
672
  }
532
673
 
533
- if (!declares && entry.workspace === undefined) {
674
+ if (!declares && entry.at === undefined) {
534
675
  ctx.addIssue({
535
676
  code: 'custom',
536
677
  message:
537
- 'declares neither "workspace" nor "credentials" and "storage" — a target either ' +
678
+ 'declares neither "at" nor "credentials" and "storage" — a workspace either ' +
538
679
  'lives here or points at where it does.',
539
680
  });
540
681
  return;
@@ -559,7 +700,17 @@ export const workspaceTargetSchema = z
559
700
  export const workspaceSchema = z.object({
560
701
  contract: z.number().int().positive(),
561
702
  default_profile: identifier.optional(),
562
- targets: z.record(z.string(), workspaceTargetSchema).default({}),
703
+ /**
704
+ * The workspace a command acts in when `--workspace` is absent (ADR-061).
705
+ *
706
+ * The first key in this file that is read rather than parsed and ignored
707
+ * since ADR-037. What makes it not the resolution chain that decision removed
708
+ * is that there is one source, it is printed on every command that uses it,
709
+ * and every command that publishes or destroys refuses it outright. If the
710
+ * echo is ever dropped for tidiness, ADR-061 has been reversed.
711
+ */
712
+ default_workspace: z.string().optional(),
713
+ workspaces: z.record(z.string(), workspaceTargetSchema).default({}),
563
714
  });
564
715
 
565
716
  export type WorkspaceConfig = z.infer<typeof workspaceSchema>;
@@ -568,8 +719,8 @@ export type WorkspaceTarget = z.infer<typeof workspaceTargetSchema>;
568
719
  /** Whether a registry entry points elsewhere rather than declaring the target. */
569
720
  export function isPointer(
570
721
  entry: WorkspaceTarget,
571
- ): entry is WorkspaceTarget & { workspace: string } {
572
- return entry.workspace !== undefined;
722
+ ): entry is WorkspaceTarget & { at: string } {
723
+ return entry.at !== undefined;
573
724
  }
574
725
 
575
726
  /**
@@ -16,7 +16,7 @@ import { isPointer, type WorkspaceTarget } from './schema.ts';
16
16
  * The chain this replaces resolved `--target`, then the variable, then the key,
17
17
  * and printed which of the three it landed on. What that bought was one flag
18
18
  * saved per command. What it cost was that an *ignored* flag still produced a
19
- * working command — `profile add --target cloud` dropped the flag on the floor
19
+ * working command — `profile add --workspace cloud` dropped the flag on the floor
20
20
  * and the next command carried on from a different source, so the mistake
21
21
  * surfaced one command later with nothing connecting it to its cause. A
22
22
  * resolver with nowhere to fall back to cannot fail that way.
@@ -69,27 +69,39 @@ export function noTargetNamed(
69
69
  registry: Registry,
70
70
  root?: string,
71
71
  env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
72
+ options: { refusedDefault?: boolean } = {},
72
73
  ): ConfigError {
73
74
  const names = Object.keys(registry).sort();
74
75
  const where = root ?? 'this workspace';
75
76
 
76
77
  if (names.length === 0) {
77
78
  return new ConfigError(
78
- `--target is required, and ${where} declares none.\n` +
79
- ' Create one with: lanes link profile add <name> --target local',
79
+ `--workspace is required, and ${where} declares none.\n` +
80
+ ' Create one with: lanes link profile add <name> --workspace local',
80
81
  );
81
82
  }
82
83
 
83
84
  const stale = env[LEGACY_TARGET_ENV];
84
85
 
86
+ // The refusal a *destructive* command gives is a different sentence, because
87
+ // the operator has a default set and is entitled to know why it was not used
88
+ // (ADR-061). Saying only "--workspace is required" to somebody who configured
89
+ // one reads as a bug in the tool.
90
+ const because =
91
+ options.refusedDefault === true
92
+ ? "--workspace is required. This command publishes or destroys, so the\ndefault is not used for it.\n\n"
93
+ : "--workspace is required. This command opens a workspace's stores, and\nnothing else selects one.\n\n";
94
+
85
95
  return new ConfigError(
86
- '--target is required. This command opens a target\'s stores, and nothing\n' +
87
- 'else selects one.\n\n' +
88
- ` Targets in ${where}\n${rows(registry, names)}\n` +
89
- `\n e.g. lanes link status --target ${names[0]}` +
96
+ because +
97
+ ` Workspaces in ${where}\n${rows(registry, names)}\n` +
98
+ `\n e.g. lanes link status --workspace ${names[0]}` +
99
+ (options.refusedDefault === true
100
+ ? '\n\n A default is set and is used by every command that only reads.'
101
+ : '') +
90
102
  (stale
91
103
  ? `\n\n ${LEGACY_TARGET_ENV}=${stale} is set in this shell and is no longer read.\n` +
92
- ' Unset it, or pass --target.'
104
+ ' Unset it, or pass --workspace.'
93
105
  : ''),
94
106
  );
95
107
  }
@@ -126,7 +138,7 @@ function rows(registry: Registry, names: readonly string[]): string {
126
138
  return names
127
139
  .map((name) => {
128
140
  const entry = registry[name]!;
129
- if (isPointer(entry)) return ` ${name} ${entry.workspace}`;
141
+ if (isPointer(entry)) return ` ${name} ${entry.at}`;
130
142
  const adapters = [entry.credentials?.adapter, entry.storage?.adapter]
131
143
  .filter(Boolean)
132
144
  .join(' ');