@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
@@ -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,28 @@ 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
+ * **4 gave a profile its bytes back, and retired `data/`** (ADR-066, ADR-067).
54
+ * Contract 3 moved the owner layer's stores beside the *connection*, so a
55
+ * profile owned nothing and two profiles granting one memory read one note. The
56
+ * profile is the container again — `profiles/<name>/` holds its declaration and
57
+ * everything it owns — and `data/`, which had been the line between what a
58
+ * revision reads and what it writes, stopped drawing that line the moment the
59
+ * declaration moved inside it. No file's *shape* changed: `grants:`,
60
+ * `members:` and `connections.yaml` are untouched, and only the paths moved.
61
+ *
62
+ * A hard cut each time, and only the newest is read here. `./legacy.ts`
63
+ * understands the older shapes and only the migration uses it — a runtime that
64
+ * loaded either would be the two-sources-of-truth problem again, one level up.
41
65
  */
42
- export const SUPPORTED_CONTRACT = 2;
66
+ export const SUPPORTED_CONTRACT = 4;
43
67
 
44
68
  /**
45
69
  * There is no `database:` block any more.
@@ -136,7 +160,7 @@ export const auditTargetSchema = z.object({
136
160
  */
137
161
  export const vaultTargetSchema = z.object({
138
162
  adapter: z.enum(['file', 'blob', 'secret']),
139
- /** File path, or blob key. Defaults to `./data/<profile>.vault.enc` / `vault.enc`. */
163
+ /** File path, or blob key. Defaults to `data/vault.d/<connection>.enc`. */
140
164
  path: z.string().optional(),
141
165
  /** `secret` only: where the sealed document lives. Defaults to `vault/document`. */
142
166
  ref: credentialRef.optional(),
@@ -327,6 +351,69 @@ export const policySchema = z.object({
327
351
  deny: z.array(policyRuleSchema).default([]),
328
352
  });
329
353
 
354
+ /**
355
+ * Providers a profile may grant at most one instance of.
356
+ *
357
+ * Not a limitation of the store — `data/skills.d/<id>/` and
358
+ * `data/vault.d/<id>.enc` hold as many as anyone makes (ADR-059). It is a
359
+ * limitation of the surface, and it comes from the protocol rather than from
360
+ * here.
361
+ *
362
+ * Every other owner-layer tool takes a `connection` argument, so two memory
363
+ * instances are two routes through one tool and the caller says which. These
364
+ * two have nowhere to put that. A skill is surfaced as an MCP **prompt**, and a
365
+ * prompt is selected by name with no arguments to route on (ADR-012) — two
366
+ * `triage` skills in one profile would be one name for two procedures. A vault
367
+ * item becomes its own `vault.get.<id>` capability, and capability ids are flat
368
+ * for the same reason: two instances holding `stripe_key` would be one
369
+ * capability naming two secrets, which is the worst of the three collisions
370
+ * because the wrong answer is a credential.
371
+ *
372
+ * So the refusal is at load, where it names both rows, rather than at call time
373
+ * where one would silently win.
374
+ */
375
+ export const SINGLE_INSTANCE_PROVIDERS: readonly string[] = ['lanes_skills', 'lanes_vault'];
376
+
377
+ /**
378
+ * One connection, and what may be done with it (ADR-058).
379
+ *
380
+ * The rules are the same `allow`/`deny` pair `policySchema` has always carried;
381
+ * what is new is that a row names the account they govern. ADR-003 kept rules
382
+ * connection-blind on the reasoning that "a narrower grant is a narrower
383
+ * profile" — sound while a connection lived in exactly one profile, because the
384
+ * second profile *was* the granularity. ADR-057 removed that mechanism, so the
385
+ * granularity moves into the rule.
386
+ *
387
+ * **There is no profile-wide `allow` beside these rows, deliberately.** A
388
+ * second place a connection could be granted is a second answer to "may this
389
+ * call proceed", and two answers to one question is the failure ADR-052 exists
390
+ * to prevent. A profile that wants the same rules on eight connections writes
391
+ * eight rows; the repetition is the cost of there being one place to look.
392
+ *
393
+ * Default deny is unchanged and now bites one step earlier: a connection with
394
+ * no row is not reachable, and is never advertised.
395
+ */
396
+ export const grantSchema = z.object({
397
+ connection: connectionRef,
398
+ allow: z.array(policyRuleSchema).default([]),
399
+ deny: z.array(policyRuleSchema).default([]),
400
+ });
401
+
402
+ /**
403
+ * Who may consume this profile (ADR-060).
404
+ *
405
+ * `role` decides who may edit this list and nothing else. Both roles reach
406
+ * exactly what the grants above allow, because a role that changed what an
407
+ * agent could call would be a second policy system beside `grants:`, with
408
+ * precedence rules between them, answering a question the first one already
409
+ * answers. Two people needing different scopes is two profiles, which is cheap
410
+ * now that neither of them re-authorises an account.
411
+ */
412
+ export const memberSchema = z.object({
413
+ subject: subjectRef,
414
+ role: z.enum(['owner', 'member']).default('member'),
415
+ });
416
+
330
417
  /**
331
418
  * One authorised account.
332
419
  *
@@ -412,15 +499,36 @@ export const configSchema = z.object({
412
499
  })
413
500
  .default({ requests_per_minute: 120, upstream_calls_per_minute: 60 }),
414
501
 
415
- oauth_apps: z.record(identifier, oauthAppSchema).default({}),
502
+ /**
503
+ * What this profile is for, in the owner's own words.
504
+ *
505
+ * New in contract 3, and it earns its place because a profile is now a thing
506
+ * you hand to somebody (ADR-060). "personal-assistant" is a name; "reads my
507
+ * mail, keeps the calendar, never sends" is what a member needs to know
508
+ * before accepting it. The dashboard and `setup_overview` show it.
509
+ */
510
+ description: z.string().min(1).optional(),
416
511
 
417
512
  /**
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.
513
+ * The connections this profile selects, and what may be done with each.
514
+ *
515
+ * There is no `connections:` block here any more a connection belongs to
516
+ * the workspace (ADR-057), and `connections.yaml` beside this file is where
517
+ * it lives. A grant naming a connection the workspace does not hold is
518
+ * refused by `assertReferentialIntegrity` rather than loading and reaching
519
+ * nothing.
421
520
  */
422
- connections: z.array(connectionSchema).default([]),
423
- policy: policySchema.default({ allow: [], deny: [] }),
521
+ grants: z.array(grantSchema).default([]),
522
+
523
+ /**
524
+ * Who may consume this profile (ADR-060).
525
+ *
526
+ * Empty is not "everyone" — it is nobody, which is default deny applied to
527
+ * the identity axis. `profile add` writes the signed-in subject as `owner`,
528
+ * so the empty state is one a hand-edit produces rather than one anybody is
529
+ * handed.
530
+ */
531
+ members: z.array(memberSchema).default([]),
424
532
 
425
533
  /**
426
534
  * Memory and skills, somewhere other than the target's own storage (ADR-041).
@@ -446,8 +554,35 @@ export const configSchema = z.object({
446
554
  identity: identitySchema.default([]),
447
555
  });
448
556
 
557
+ /**
558
+ * `connections.yaml` — every account authorised in this workspace (ADR-057).
559
+ *
560
+ * Its own file rather than a block in `lanes-link.yaml`, because that file is
561
+ * the registry and is read before anything else can be: resolving which
562
+ * workspace a command means must not require parsing every connection in it.
563
+ * And its own file rather than staying in the profile, because authorising an
564
+ * account and deciding what may be done with it are two acts, and only the
565
+ * second is a property of a profile.
566
+ *
567
+ * `oauth_apps` comes with them. A registered client belongs to the account it
568
+ * authenticates, not to whichever selection happens to name that account.
569
+ *
570
+ * There is still no `providers` block. A provider is enabled by having a
571
+ * connection to it — a second place to say so could only ever disagree with the
572
+ * first, and everything else a provider needs is in its manifest.
573
+ */
574
+ export const connectionsFileSchema = z.object({
575
+ contract: z.number().int().positive(),
576
+ connections: z.array(connectionSchema).default([]),
577
+ oauth_apps: z.record(identifier, oauthAppSchema).default({}),
578
+ });
579
+
580
+ export type ConnectionsFile = z.infer<typeof connectionsFileSchema>;
581
+
449
582
  export type Config = z.infer<typeof configSchema>;
450
583
  export type ConnectionConfig = z.infer<typeof connectionSchema>;
584
+ export type GrantConfig = z.infer<typeof grantSchema>;
585
+ export type MemberConfig = z.infer<typeof memberSchema>;
451
586
  export type PolicyRuleConfig = z.infer<typeof policyRuleSchema>;
452
587
  export type TargetConfig = z.infer<typeof targetSchema>;
453
588
  export type DeployConfig = z.infer<typeof deployTargetSchema>;
@@ -482,8 +617,14 @@ export type { IdentityEntry } from './identity.ts';
482
617
  */
483
618
  export const workspaceTargetSchema = z
484
619
  .object({
485
- /** A pointer: where the workspace declaring this target lives. */
486
- workspace: z.string().min(1).optional(),
620
+ /**
621
+ * A pointer: where the workspace declaring this one lives.
622
+ *
623
+ * Spelled `at:` rather than `workspace:` since contract 3. The block is
624
+ * `workspaces:` now (ADR-061), and `workspaces.cloud.workspace` names the
625
+ * concept twice and reads as a typo.
626
+ */
627
+ at: z.string().min(1).optional(),
487
628
  credentials: credentialsTargetSchema.optional(),
488
629
  audit: auditTargetSchema.optional(),
489
630
  storage: storageTargetSchema.optional(),
@@ -497,6 +638,15 @@ export const workspaceTargetSchema = z
497
638
  * gets in — the one question about a deployment that must not be guessed at.
498
639
  */
499
640
  primary: identifier.optional(),
641
+ /**
642
+ * The Lanes workspace this one serves, when it serves one (ADR-060).
643
+ *
644
+ * A binding, not an identity: it says whose membership list
645
+ * `profile members add` validates a subject against. Absent means the
646
+ * workspace delegates only to the signed-in owner, which is what `local`
647
+ * does.
648
+ */
649
+ lanes_workspace: z.string().min(1).optional(),
500
650
  last_deploy: z.string().optional(),
501
651
  /**
502
652
  * The CLI release that rolled the revision serving this target.
@@ -520,21 +670,21 @@ export const workspaceTargetSchema = z
520
670
  // rather than preferring one: a pointer beside a declaration is two answers
521
671
  // to "where are this target's bytes", and picking either silently is how the
522
672
  // fifteen-connection bucket got reported as seven.
523
- if (declares && entry.workspace !== undefined) {
673
+ if (declares && entry.at !== undefined) {
524
674
  ctx.addIssue({
525
675
  code: 'custom',
526
676
  message:
527
- 'names a "workspace" and also declares adapters — a target is declared by exactly ' +
677
+ 'names an "at" and also declares adapters — a workspace is declared in exactly ' +
528
678
  'one workspace. Keep the adapters here, or keep the pointer and declare them there.',
529
679
  });
530
680
  return;
531
681
  }
532
682
 
533
- if (!declares && entry.workspace === undefined) {
683
+ if (!declares && entry.at === undefined) {
534
684
  ctx.addIssue({
535
685
  code: 'custom',
536
686
  message:
537
- 'declares neither "workspace" nor "credentials" and "storage" — a target either ' +
687
+ 'declares neither "at" nor "credentials" and "storage" — a workspace either ' +
538
688
  'lives here or points at where it does.',
539
689
  });
540
690
  return;
@@ -559,7 +709,17 @@ export const workspaceTargetSchema = z
559
709
  export const workspaceSchema = z.object({
560
710
  contract: z.number().int().positive(),
561
711
  default_profile: identifier.optional(),
562
- targets: z.record(z.string(), workspaceTargetSchema).default({}),
712
+ /**
713
+ * The workspace a command acts in when `--workspace` is absent (ADR-061).
714
+ *
715
+ * The first key in this file that is read rather than parsed and ignored
716
+ * since ADR-037. What makes it not the resolution chain that decision removed
717
+ * is that there is one source, it is printed on every command that uses it,
718
+ * and every command that publishes or destroys refuses it outright. If the
719
+ * echo is ever dropped for tidiness, ADR-061 has been reversed.
720
+ */
721
+ default_workspace: z.string().optional(),
722
+ workspaces: z.record(z.string(), workspaceTargetSchema).default({}),
563
723
  });
564
724
 
565
725
  export type WorkspaceConfig = z.infer<typeof workspaceSchema>;
@@ -568,8 +728,8 @@ export type WorkspaceTarget = z.infer<typeof workspaceTargetSchema>;
568
728
  /** Whether a registry entry points elsewhere rather than declaring the target. */
569
729
  export function isPointer(
570
730
  entry: WorkspaceTarget,
571
- ): entry is WorkspaceTarget & { workspace: string } {
572
- return entry.workspace !== undefined;
731
+ ): entry is WorkspaceTarget & { at: string } {
732
+ return entry.at !== undefined;
573
733
  }
574
734
 
575
735
  /**
@@ -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(' ');
@@ -58,7 +58,7 @@ export function workspaceYaml(
58
58
  return (
59
59
  `contract: ${SUPPORTED_CONTRACT}\n` +
60
60
  (options.defaultProfile ? `default_profile: ${options.defaultProfile}\n` : '') +
61
- `targets:\n${blocks}\n`
61
+ `workspaces:\n${blocks}\n`
62
62
  );
63
63
  }
64
64
 
@@ -73,6 +73,108 @@ export function pointerYaml(
73
73
  return (
74
74
  `contract: ${SUPPORTED_CONTRACT}\n` +
75
75
  (options.defaultProfile ? `default_profile: ${options.defaultProfile}\n` : '') +
76
- `targets:\n${local} ${target}:\n workspace: ${workspace}\n`
76
+ `workspaces:\n${local} ${target}:\n at: ${workspace}\n`
77
77
  );
78
78
  }
79
+
80
+ /**
81
+ * A `connections.yaml` holding the owner layer plus whatever a test names.
82
+ *
83
+ * Every fixture that opens a runtime needs one now: a profile's grants name rows
84
+ * in this file, and `assertGrantsResolve` refuses a grant with nothing behind it
85
+ * (ADR-057). Thirty tests writing the seven owner-layer rows by hand is how a
86
+ * fixture ends up subtly different from what `newConnectionsTemplate` writes.
87
+ */
88
+ export function connectionsYaml(
89
+ extra: readonly { id: string; provider: string; account: string }[] = [],
90
+ ): string {
91
+ const owner = [
92
+ { id: 'lan1', provider: 'lanes_memory', account: 'Memory' },
93
+ { id: 'lan2', provider: 'lanes_tasks', account: 'Tasks' },
94
+ { id: 'lan3', provider: 'lanes_assets', account: 'Assets' },
95
+ { id: 'lan4', provider: 'lanes_skills', account: 'Skills' },
96
+ { id: 'lan5', provider: 'lanes_vault', account: 'Vault' },
97
+ { id: 'lan6', provider: 'lanes_setup', account: 'Setup' },
98
+ { id: 'lan7', provider: 'lanes_entities', account: 'Entities' },
99
+ ];
100
+
101
+ const rows = [...owner, ...extra]
102
+ .map((row) => ` - { id: ${row.id}, provider: ${row.provider}, account: ${row.account} }`)
103
+ .join('\n');
104
+
105
+ return `contract: ${SUPPORTED_CONTRACT}\nconnections:\n${rows}\noauth_apps: {}\n`;
106
+ }
107
+
108
+ /** The owner-layer grant rows a fresh profile carries, plus whatever a test names. */
109
+ export function grantsYaml(
110
+ extra: readonly { connection: string; allow?: readonly string[]; deny?: readonly string[] }[] = [],
111
+ ): string {
112
+ const owner = [
113
+ 'lanes_memory',
114
+ 'lanes_tasks',
115
+ 'lanes_assets',
116
+ 'lanes_skills',
117
+ 'lanes_vault',
118
+ 'lanes_setup',
119
+ 'lanes_entities',
120
+ ].map((provider, index) => ({
121
+ connection: `${provider}.lan${index + 1}`,
122
+ allow: [`${provider}.*`],
123
+ deny: [],
124
+ }));
125
+
126
+ return [...owner, ...extra]
127
+ .map(
128
+ (grant) =>
129
+ ` - { connection: ${grant.connection}, allow: [${(grant.allow ?? []).join(', ')}], ` +
130
+ `deny: [${(grant.deny ?? []).join(', ')}] }`,
131
+ )
132
+ .join('\n');
133
+ }
134
+
135
+ /** A whole profile document at the current contract, for a test that needs one. */
136
+ export function profileYaml(
137
+ profile: string,
138
+ options: {
139
+ port?: number;
140
+ grants?: readonly { connection: string; allow?: readonly string[]; deny?: readonly string[] }[];
141
+ members?: readonly string[];
142
+ } = {},
143
+ ): string {
144
+ const members = (options.members ?? [])
145
+ .map((subject) => ` - { subject: ${subject}, role: owner }`)
146
+ .join('\n');
147
+
148
+ return (
149
+ `contract: ${SUPPORTED_CONTRACT}\n` +
150
+ `instance:\n profile: ${profile}\n port: ${options.port ?? 7337}\n host: 127.0.0.1\n` +
151
+ `auth:\n mode: bearer\n token_ref: profile/token\n` +
152
+ `grants:\n${grantsYaml(options.grants ?? [])}\n` +
153
+ `members:${members ? `\n${members}` : ' []'}\n`
154
+ );
155
+ }
156
+
157
+ /**
158
+ * Write a profile's declaration into a fixture workspace.
159
+ *
160
+ * A profile is a directory now (ADR-067), so writing one means creating that
161
+ * directory — which every fixture that used to write `profiles/<name>.yaml`
162
+ * beside its siblings got for free. Centralised here rather than repeated in
163
+ * twenty test files, for the reason `layout.ts` exists at all: the last time a
164
+ * path was spelled in two places one of them went stale, and the failure was a
165
+ * listing that disagreed with a loader about what existed.
166
+ */
167
+ export async function writeProfileFixture(
168
+ root: string,
169
+ profile: string,
170
+ body: string,
171
+ ): Promise<string> {
172
+ const { mkdir, writeFile } = await import('node:fs/promises');
173
+ const { dirname, join } = await import('node:path');
174
+ const { layout } = await import('./layout.ts');
175
+
176
+ const path = join(root, layout.profileConfig(profile));
177
+ await mkdir(dirname(path), { recursive: true });
178
+ await writeFile(path, body);
179
+ return path;
180
+ }