@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
@@ -14,10 +14,10 @@ import { recordDeployment, type DeploymentRecord } from './record.ts';
14
14
  import { printSteps, runSteps } from './steps.ts';
15
15
  import { driverFor } from './drivers.ts';
16
16
  import { prepareSecrets, readableRefs, rotatableRefs } from './prepare.ts';
17
- import { repairOwnerLayer } from '#cli/config-repair.ts';
18
- import { migrateWorkspace } from '#cli/workspace-migrate.ts';
17
+ import { repairOwnerLayer } from '#cli/config-repair-sweep.ts';
18
+ import { migrateToCurrentContract } from '#cli/workspace-migrate.ts';
19
19
  import { deployedWorkspace, uploadWorkspace } from './upload.ts';
20
- import { collidingRefs, collisionRefusal, servingProfiles } from './serving.ts';
20
+ import { servingProfiles } from './serving.ts';
21
21
  import { healthLine, reachability, registerLine, reportUnauthorised } from './report.ts';
22
22
 
23
23
  /**
@@ -75,9 +75,14 @@ export async function deploy(flags: DeployFlags): Promise<void> {
75
75
  // declare anything: it reads this machine's pointer and stops. So the bucket is
76
76
  // located, migrated, and only then opened.
77
77
  //
78
- // Idempotent, and silent on a workspace already at contract 2 — a listing and
79
- // no writes. `--dry-run` reports what it would do and writes nothing, like
80
- // every other step of this command.
78
+ // Idempotent, and silent on a workspace already current — a listing and no
79
+ // writes. `--dry-run` reports what it would do and writes nothing, like every
80
+ // other step of this command.
81
+ //
82
+ // It goes all the way to `SUPPORTED_CONTRACT`, which it did not use to: this
83
+ // ran the contract 1→2 step alone, so a contract-2 bucket passed straight
84
+ // through and the revision came up reading an empty `data/` while the bytes
85
+ // stayed under `data/<profile>/`. See `migrateToCurrentContract`.
81
86
  if (!(await migrateTargetWorkspace(requireTargetFlag(flags), flags.dryRun !== true))) return;
82
87
 
83
88
  // The one command allowed to name a target that does not exist yet: creating
@@ -112,14 +117,13 @@ export async function deploy(flags: DeployFlags): Promise<void> {
112
117
  print(style.dim(` serving ${serving.join(', ')} — ${primary} owns the token`));
113
118
  }
114
119
 
115
- // Before anything external, beside `check`, and for the same reason: two
116
- // profiles writing one flat credential ref into one store is a failure with
117
- // no later symptom worth having.
118
- const colliding = await collidingRefs(resolution.workspaceRoot, serving);
119
- if (colliding.length > 0) {
120
- heading('Cannot share a credential store');
121
- throw new ConfigError(collisionRefusal(colliding, target));
122
- }
120
+ // The credential-collision preflight is gone. It existed because two profiles
121
+ // each held their own `gmail.main` and both derived the flat ref `gmail/main`
122
+ // into one store — the last deploy winning, with no later symptom worth
123
+ // having. A connection belongs to the workspace now and `<provider>.<id>` is
124
+ // unique by construction (ADR-057), so the state it guarded against cannot be
125
+ // written: `assertConnectionsUnique` refuses it at load, before a deploy runs
126
+ // at all.
123
127
 
124
128
  // `check` before anything external, per the gate order: a config that will be
125
129
  // rejected on boot should be rejected here, not after a five-minute build.
@@ -150,7 +154,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
150
154
  // below are, and read from config and manifests before anything opens a
151
155
  // store — `--dry-run` must reach the printed step list without touching a
152
156
  // credential.
153
- const rotatable = await rotatableRefs(resolution.workspaceRoot, serving);
157
+ const rotatable = await rotatableRefs(resolution.workspaceRoot, serving, declared);
154
158
  const readable = await readableRefs(resolution.workspaceRoot, serving, declared);
155
159
  const provision = await driver.provision({
156
160
  deploy: deployConfig,
@@ -225,7 +229,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
225
229
  await runSteps(driver, provision);
226
230
  }
227
231
 
228
- const credentials = await openSecretStoreFor(config, resolution.workspaceRoot, target);
232
+ const credentials = await openSecretStoreFor(resolution.workspaceRoot, target);
229
233
  const prepared = await prepareSecrets({
230
234
  config,
231
235
  declared,
@@ -240,7 +244,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
240
244
  for (const problem of prepared.blocking) print(fail(problem));
241
245
  throw new ConfigError(
242
246
  'The deployed instance cannot start without these. Store them with ' +
243
- `lanes link secrets set <ref> --profile ${resolution.profile} --target ${target}, or ` +
247
+ `lanes link secrets set <ref> --profile ${resolution.profile} --workspace ${target}, or ` +
244
248
  `copy a local setup with lanes link secrets push --profile ${resolution.profile} ` +
245
249
  `--from local --to ${target}.`,
246
250
  );
@@ -275,6 +279,17 @@ export async function deploy(flags: DeployFlags): Promise<void> {
275
279
  // workspace that this target cannot open, and nothing left to check.
276
280
  await repairOwnerLayer(resolution.workspaceRoot, serving);
277
281
 
282
+ // **The bucket's own contract, before anything is written over it.**
283
+ //
284
+ // The pass at the top of the command resolves the target through this
285
+ // machine's pointer, which a *first* deploy does not have: the entry is
286
+ // still a declaration, `resolveTargetWorkspace` answers with the local root,
287
+ // and it returns early. So this is the only pass that ever sees an existing
288
+ // bucket at contract 2 — and running it after the upload is the same as not
289
+ // running it, because the upload writes contract-3 profiles and
290
+ // `needsContract3` reads the profiles.
291
+ await migrateToCurrentContract(workspace, { apply: true, target });
292
+
278
293
  // Before the rollout, so the revision that comes up finds a config to read.
279
294
  // Uploading after would leave a window where the service is serving and the
280
295
  // workspace it was told to read is not there yet.
@@ -291,15 +306,9 @@ export async function deploy(flags: DeployFlags): Promise<void> {
291
306
  await uploadWorkspace(resolution.workspaceRoot, workspace, serving);
292
307
  }
293
308
 
294
- // **The bucket's own migration, here and nowhere else.**
295
- //
296
- // A second pass, and it is not redundant. The one at the top of the command
297
- // migrated whatever the bucket already held; this catches what the upload
298
- // just put there — profiles from a workspace that is itself at contract 2
299
- // arrive migrated, but a first deploy of a *newly created* bucket writes
300
- // them here for the first time. Idempotent, so the ordinary case is one
301
- // listing and no writes.
302
- await migrateWorkspace(workspace, { apply: true });
309
+ // Again for what the upload put there: a newly created bucket gets its
310
+ // profiles written here for the first time. Idempotent — one listing.
311
+ await migrateToCurrentContract(workspace, { apply: true, target });
303
312
 
304
313
  // Where this deployment lives, in both registries — see `record.ts`. The
305
314
  // declaration has to land before the revision boots, so it goes here rather
@@ -325,7 +334,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
325
334
  if (!url) {
326
335
  print(
327
336
  warn(
328
- `deployed, but the platform reported no URL yet — run: lanes link outputs --profile ${resolution.profile} --target ${target}`,
337
+ `deployed, but the platform reported no URL yet — run: lanes link outputs --profile ${resolution.profile} --workspace ${target}`,
329
338
  ),
330
339
  );
331
340
  return;
@@ -365,7 +374,7 @@ async function migrateTargetWorkspace(target: string, apply: boolean): Promise<b
365
374
  const workspace = await resolveTargetWorkspace(root, target).catch(() => null);
366
375
  if (workspace === null || workspace === root) return true;
367
376
 
368
- const migrated = await migrateWorkspace(workspace, { apply });
377
+ const migrated = await migrateToCurrentContract(workspace, { apply, target });
369
378
  if (migrated.alreadyCurrent) return true;
370
379
 
371
380
  heading(apply ? 'Migrated' : 'Would migrate');
@@ -383,6 +392,6 @@ async function migrateTargetWorkspace(target: string, apply: boolean): Promise<b
383
392
  print(style.dim(' Nothing was written, and nothing else was checked: the rest of this'));
384
393
  print(style.dim(' command opens the target, which is not readable until this has run.'));
385
394
  print('');
386
- print(style.dim(` Run it for real: lanes link deploy --target ${target}`));
395
+ print(style.dim(` Run it for real: lanes link deploy --workspace ${target}`));
387
396
  return false;
388
397
  }
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import { installRoot, layout } from '#profile';
2
+ import { CONNECTIONS_FILE, WORKSPACE_FILE, installRoot, layout } from '#profile';
3
3
  import type { DeployStep } from '../driver.ts';
4
4
  import {
5
5
  removalStep,
@@ -51,6 +51,11 @@ export function bucketGrants(bucket: string, profiles: readonly string[]): Condi
51
51
  `resource.name.startsWith("projects/_/buckets/${bucket}/objects/${path}")`;
52
52
  const objectIs = (path: string): string =>
53
53
  `resource.name == "projects/_/buckets/${bucket}/objects/${path}"`;
54
+ // Parenthesised, because `!` binds tighter than `==` in CEL: `!resource.name
55
+ // == "…"` negates the *name* and compares that, which is not a type error and
56
+ // not what it reads as. Bracketing is the whole difference between excluding
57
+ // one object and excluding nothing.
58
+ const objectIsNot = (path: string): string => `!(${objectIs(path)})`;
54
59
  /**
55
60
  * The bucket itself, which is a different resource from anything in it.
56
61
  *
@@ -77,12 +82,44 @@ export function bucketGrants(bucket: string, profiles: readonly string[]): Condi
77
82
  */
78
83
  const theBucket = `resource.name == "projects/_/buckets/${bucket}"`;
79
84
 
80
- const manifestPrefixes = profiles.map((profile) =>
81
- objectsUnder(`${layout.providers(profile)}/`),
82
- );
83
- // No profiles leaves the carve-out off rather than guessing at one: the
84
- // revision keeps write on its own data, as it did before this existed.
85
- const manifests = manifestPrefixes.length > 0 ? `(${manifestPrefixes.join(' || ')})` : null;
85
+ // One prefix, not one per profile. Manifests are the workspace's since
86
+ // ADR-057 — a manifest defines a connection, and connections do not live in a
87
+ // profile — so the carve-out no longer varies with the profile set.
88
+ const manifests = `(${objectsUnder(`${layout.providers()}/`)})`;
89
+
90
+ /**
91
+ * What a running revision writes, named rather than carved out of `data/`.
92
+ *
93
+ * It used to be `objectsUnder('data/') && !manifests` — everything under one
94
+ * prefix, minus the exception. There is no `data/` any more (ADR-067), and
95
+ * naming the writable prefixes is the better shape regardless: an allowlist
96
+ * says what a compromised revision can reach, where a denylist says only what
97
+ * it cannot and grows silently every time something new lands under the
98
+ * prefix.
99
+ *
100
+ * `credentials.enc` is in it because `connect --from-endpoint` and a token
101
+ * refresh both write there (ADR-025), and its `.key` sits beside it.
102
+ */
103
+ // **Per profile, not `profiles/` wholesale.** Granting the whole tree and
104
+ // carving the served declarations back out left the revision `create` and
105
+ // `delete` on every *other* profile's `profile.yaml` — the configuration of a
106
+ // profile the same endpoint serves — and on any name it invented, which
107
+ // `listProfiles` would then pick up on the next boot. Before ADR-067 no
108
+ // declaration was writable at all, because `profiles/` sat outside `data/`;
109
+ // this restores that, one prefix per profile.
110
+ //
111
+ // `!==` on a whole object rather than a pattern, because Cloud Storage IAM
112
+ // conditions have no `matches` — see the note on the read binding below.
113
+ const writable = [
114
+ objectsUnder(`${layout.audit()}/`),
115
+ objectsUnder(`${layout.state()}/`),
116
+ objectsUnder(layout.credentials()),
117
+ ...profiles.map(
118
+ (profile) =>
119
+ `(${objectsUnder(`${layout.profileDir(profile)}/`)} && ` +
120
+ `${objectIsNot(layout.profileConfig(profile))})`,
121
+ ),
122
+ ].join(' || ');
86
123
 
87
124
  return [
88
125
  {
@@ -90,16 +127,26 @@ export function bucketGrants(bucket: string, profiles: readonly string[]): Condi
90
127
  // skills are all written by the running endpoint.
91
128
  role: 'roles/storage.objectAdmin',
92
129
  title: 'owns-its-data',
93
- expression: `${objectsUnder('data/')}${manifests ? ` && !${manifests}` : ''}`,
130
+ expression: `(${writable})`,
94
131
  },
95
132
  {
96
133
  // `expression=true` was here, which is every object in the bucket — the
97
134
  // step title and ADR-023 both claim a narrowing this did not do. The
98
- // config the revision reads is the workspace file, the profiles beside it,
99
- // and each profile's own manifests, so name exactly those.
135
+ // config the revision reads is the workspace file, `connections.yaml`
136
+ // beside it, the profiles, and the manifests, so name exactly those.
137
+ //
138
+ // **Named by the constants, because the list went stale once already.**
139
+ // ADR-057 moved connections out of the profile into `connections.yaml` at
140
+ // the workspace root, and `upload.ts` puts it in the bucket because "the
141
+ // endpoint cannot resolve a single grant without it" — but this expression
142
+ // still enumerated the contract-2 set. Every fresh deploy built its image,
143
+ // rolled a revision, and died on `GCS refused to read "connections.yaml"
144
+ // (403)` before it could listen on its port. Nothing caught it: the
145
+ // condition is a string assembled here and asserted nowhere, so the suite
146
+ // saw a passing build and Cloud Run saw a container that never started.
100
147
  role: 'roles/storage.objectViewer',
101
148
  title: 'reads-its-config',
102
- expression: `${theBucket} || ${objectsUnder('profiles/')} || ${objectIs('lanes-link.yaml')}${manifests ? ` || ${manifests}` : ''}`,
149
+ expression: `${theBucket} || ${objectsUnder(`${layout.profilesRoot()}/`)} || ${objectIs(WORKSPACE_FILE)} || ${objectIs(CONNECTIONS_FILE)} || ${manifests}`,
103
150
  },
104
151
  ];
105
152
  }
@@ -1,4 +1,4 @@
1
- import { VAULT_DOCUMENT_REF, type SecretRef } from '#secrets';
1
+ import type { SecretRef } from '#secrets';
2
2
  import type { DeployStep, ProvisionInput } from '../driver.ts';
3
3
  import { encodeRef } from '../adapters/gcp-secret-manager.ts';
4
4
  import { bucketSteps } from './bucket.ts';
@@ -312,12 +312,12 @@ export async function provisionSteps(
312
312
  // mind: nothing here was wrong, it was incomplete, and being incomplete
313
313
  // looked exactly like being finished. Reading mail 403'd an hour after every
314
314
  // deploy.
315
- const rotatable = [
316
- ...(input.declared.vault?.adapter === 'secret'
317
- ? [input.declared.vault.ref ?? VAULT_DOCUMENT_REF]
318
- : []),
319
- ...(input.rotatable ?? []),
320
- ];
315
+ // Both kinds arrive already derived: the vault document is named per vault
316
+ // connection (ADR-059) and only `prepare.ts` has the profile configs to work
317
+ // it out. Naming `vault/document` here — the contract-2 constant — created
318
+ // and granted one secret while `openVault` asked for another, and the
319
+ // revision died on `PERMISSION_DENIED` before it could listen. See `vaultRef`.
320
+ const rotatable = [...(input.rotatable ?? [])];
321
321
 
322
322
  // Read, named one secret at a time, for the same reason the write side is:
323
323
  // a resource-level grant needs no condition to be scoped.
@@ -1,7 +1,7 @@
1
1
  import type { SecretStore } from '#secrets';
2
2
  import type { BlobStore } from '#stores/blobs';
3
3
  import type { BlobRoute } from '#stores/blobs/route.ts';
4
- import { KNOWLEDGE_LAYOUT, knowledgeRoot, type KnowledgeArea, type KnowledgeConfig } from '#profile';
4
+ import { KNOWLEDGE_LAYOUT, KNOWLEDGE_PREFIX, knowledgeRoot, type KnowledgeArea, type KnowledgeConfig } from '#profile';
5
5
  import { requireSecret, type TargetInput } from './target.ts';
6
6
  import type { FetchLike } from './adapters/github-api.ts';
7
7
  // Type-only, so a target with no `knowledge` block never loads the adapter.
@@ -134,10 +134,15 @@ export async function knowledgeStores(
134
134
  * how they would come to disagree. `skills` is absent because it is not a
135
135
  * prefix of that root — it is a store of its own, handed over whole.
136
136
  */
137
- export function knowledgeRoutes(stores: KnowledgeStores): BlobRoute[] {
137
+ export function knowledgeRoutes(
138
+ stores: Pick<KnowledgeStores, 'memory' | 'entities'>,
139
+ ): BlobRoute[] {
138
140
  return [
139
- { prefix: `${KNOWLEDGE_LAYOUT.memory}/`, store: stores.memory },
140
- { prefix: `${KNOWLEDGE_LAYOUT.entities}/`, store: stores.entities },
141
+ // The *provider's* prefix, which is what core scopes a store into. The
142
+ // repository directory it lands in is `KNOWLEDGE_LAYOUT`, and the two are
143
+ // different strings since contract 4 prefixed the owner layer.
144
+ { prefix: `${KNOWLEDGE_PREFIX.memory}/`, store: stores.memory },
145
+ { prefix: `${KNOWLEDGE_PREFIX.entities}/`, store: stores.entities },
141
146
  ];
142
147
  }
143
148
 
@@ -1,5 +1,7 @@
1
1
  import { credentialRefFor, ownClientRefsFor, rotatableCredentialRefsFor } from '#registry';
2
- import { listProfiles, loadProfileConfig, type Config, type TargetConfig } from '#profile';
2
+ import {
3
+ PAIR_TOKEN_REF,
4
+ readConnections, listProfiles, loadProfileConfig, vaultRef, type Config, type TargetConfig } from '#profile';
3
5
  import { VAULT_DOCUMENT_REF, VAULT_KEY_REF, generateVaultKey, type SecretStore } from '#secrets';
4
6
  import { ok, print, style, warn } from '#cli/output.ts';
5
7
  import { buildRegistryWithWorkspace, ensureProfileToken } from '#cli/runtime.ts';
@@ -66,6 +68,7 @@ export interface PrepareResult {
66
68
  export async function rotatableRefs(
67
69
  root: string,
68
70
  profiles: readonly string[] | undefined,
71
+ declared: TargetConfig | undefined,
69
72
  ): Promise<string[]> {
70
73
  const refs = new Set<string>();
71
74
  const wanted = profiles === undefined ? undefined : new Set(profiles);
@@ -80,16 +83,27 @@ export async function rotatableRefs(
80
83
  continue;
81
84
  }
82
85
 
83
- // Inside the loop because manifests are the profile's own (ADR-030): a
84
- // connection in `work` naming a provider only `work` declares resolves to
85
- // nothing against `personal`'s registry, and a missed ref here is a 403 an
86
- // hour after the revision reports healthy.
87
- const registry = await buildRegistryWithWorkspace(root, name);
86
+ // `vault.put` is a capability an agent may hold under policy (ADR-022), so
87
+ // the revision rewrites this one and it is per vault connection, which is
88
+ // what makes this a per-profile pass rather than a target-level constant.
89
+ // The loop had been reduced to `void config` when the derivation moved out;
90
+ // this is it moving back, next to the config it needs.
91
+ if (declared?.vault?.adapter === 'secret') refs.add(vaultRef(declared, config));
92
+ }
88
93
 
89
- for (const connection of config.connections) {
90
- const manifest = registry.manifest(connection.provider);
91
- for (const ref of rotatableCredentialRefsFor(connection, manifest)) refs.add(ref);
92
- }
94
+ // Once, outside the profile loop. Connections and the manifests that describe
95
+ // them belong to the workspace (ADR-057), so the per-profile registry ADR-030
96
+ // required is gone — and with it the failure it guarded against, where a
97
+ // connection in `work` resolved to nothing against `personal`'s registry and
98
+ // the missed ref became a 403 an hour after the revision reported healthy.
99
+ //
100
+ // Every connection, not just granted ones: a credential a revision cannot read
101
+ // is a broken account, and whether some profile currently grants it is a
102
+ // question that changes without redeploying.
103
+ const registry = await buildRegistryWithWorkspace(root);
104
+ for (const connection of (await readConnections(root)).connections) {
105
+ const manifest = registry.manifest(connection.provider);
106
+ for (const ref of rotatableCredentialRefsFor(connection, manifest)) refs.add(ref);
93
107
  }
94
108
 
95
109
  // Sorted, and a set: two Gmail connections share one dynamically registered
@@ -132,9 +146,41 @@ export async function readableRefs(
132
146
  ): Promise<string[]> {
133
147
  const refs = new Set<string>();
134
148
 
149
+ // Unconditionally, for every deploy, whether or not this workspace has ever
150
+ // been paired — and that is the point rather than a rounding up.
151
+ //
152
+ // Secret Manager answers a *missing binding* with 403, not 404, so that an
153
+ // identity cannot enumerate secrets by their error codes. The adapter returns
154
+ // null for the 404 and throws for the 403, so an unbound ref is a thrown
155
+ // error on the read path — and that is the failure `server/read/open.ts`
156
+ // records: a deployed revision asked for refs no binding covered and never
157
+ // went healthy.
158
+ //
159
+ // Bound, the deployed-but-never-paired case becomes the 404 instead — a
160
+ // secret that exists with no version — which reads back as null and renders
161
+ // as `401 {error:'unpaired'}`. The grant is what turns a crash into a
162
+ // refusal.
163
+ //
164
+ // Deciding it by asking *whether the workspace is paired* is the thing that
165
+ // cannot happen: that means opening a credential store inside `readableRefs`,
166
+ // which `--dry-run` reaches and must not do.
167
+ //
168
+ // Deliberately not in `rotatableRefs`. The revision never writes this one —
169
+ // minting is `lanes link pair`, from the operator's machine — and
170
+ // `secretVersionAdder` here would let a compromised revision issue itself a
171
+ // credential that reads the whole workspace.
172
+ //
173
+ // The cert and key refs are absent for a different reason: Cloud Run
174
+ // terminates TLS with a certificate a browser already trusts, so a deployed
175
+ // revision never calls `serveRead` and never reads either.
176
+ refs.add(PAIR_TOKEN_REF);
177
+
178
+ // Only the key is the target's. The *document* is named per vault connection
179
+ // (ADR-059), so it is added inside the profile loop below — naming it here
180
+ // meant `vault/document`, which is not what `openVault` opens, and the
181
+ // revision 403'd on a ref nothing had created. See `vaultRef`.
135
182
  if (declared?.vault?.adapter === 'secret') {
136
183
  refs.add(VAULT_KEY_REF);
137
- refs.add(declared.vault.ref ?? VAULT_DOCUMENT_REF);
138
184
  } else if (declared?.vault?.adapter === 'blob') {
139
185
  // Ciphertext lives in the bucket, but the key that opens it is still here.
140
186
  refs.add(VAULT_KEY_REF);
@@ -153,22 +199,24 @@ export async function readableRefs(
153
199
  }
154
200
 
155
201
  refs.add(config.auth.token_ref);
202
+ if (declared?.vault?.adapter === 'secret') refs.add(vaultRef(declared, config));
156
203
  // The OIDC audience check reads this on every verify (`server/endpoint.ts`).
157
204
  if (config.auth.authorization?.mode === 'oidc') {
158
205
  refs.add(config.auth.authorization.client_id_ref);
159
206
  }
160
207
 
161
- // Per profile, for the reason `rotatableRefs` gives: a manifest is this
162
- // profile's, so the registry that resolves its connections must be too.
163
- const registry = await buildRegistryWithWorkspace(root, name);
208
+ }
164
209
 
165
- for (const connection of config.connections) {
166
- const manifest = registry.manifest(connection.provider);
167
- const ref = credentialRefFor(connection, manifest);
168
- if (ref) refs.add(ref);
169
- for (const rotatable of rotatableCredentialRefsFor(connection, manifest)) refs.add(rotatable);
170
- for (const client of ownClientRefsFor(manifest, config.oauth_apps)) refs.add(client);
171
- }
210
+ // Once, for the reason `rotatableRefs` gives.
211
+ const connectionsFile = await readConnections(root);
212
+ const registry = await buildRegistryWithWorkspace(root);
213
+
214
+ for (const connection of connectionsFile.connections) {
215
+ const manifest = registry.manifest(connection.provider);
216
+ const ref = credentialRefFor(connection, manifest);
217
+ if (ref) refs.add(ref);
218
+ for (const rotatable of rotatableCredentialRefsFor(connection, manifest)) refs.add(rotatable);
219
+ for (const client of ownClientRefsFor(manifest, connectionsFile.oauth_apps)) refs.add(client);
172
220
  }
173
221
 
174
222
  return [...refs].sort();
@@ -190,13 +238,13 @@ export async function prepareSecrets(input: PrepareInput): Promise<PrepareResult
190
238
  // The command is spelled out rather than described. This is the one manual
191
239
  // step a deploy genuinely cannot take for you, so it should cost a paste
192
240
  // rather than a trip to the docs to work out how the id is spelled.
193
- const registry = await buildRegistryWithWorkspace(root, config.instance.profile);
194
- for (const connection of config.connections) {
241
+ const registry = await buildRegistryWithWorkspace(root);
242
+ for (const connection of (await readConnections(root)).connections) {
195
243
  const ref = credentialRefFor(connection, registry.manifest(connection.provider));
196
244
  if (!ref || (await credentials.has(ref))) continue;
197
245
  warnings.push(
198
246
  `${connection.provider}.${connection.id} is not authorised yet — no credential at "${ref}"\n` +
199
- ` lanes link connect ${connection.provider} --profile ${input.config.instance.profile} --target ${target} --id ${connection.id}`,
247
+ ` lanes link connect ${connection.provider} --profile ${input.config.instance.profile} --workspace ${target} --id ${connection.id}`,
200
248
  );
201
249
  }
202
250
 
@@ -56,7 +56,7 @@ export async function recordDeployment(record: DeploymentRecord): Promise<void>
56
56
  // there pointed the bucket at itself, and the revision that came up refused to
57
57
  // open its own target.
58
58
  await recordTarget(resolveWorkspaceRoot(), record.target, {
59
- workspace: record.workspace,
59
+ at: record.workspace,
60
60
  ...stamp,
61
61
  });
62
62
  }
@@ -37,7 +37,7 @@ import { heading, ok, print, style, warn } from '#cli/output.ts';
37
37
  export function registerLine(profile: string, target: string): string {
38
38
  return style.dim(
39
39
  ` Connect your accounts first, then register with:\n` +
40
- ` lanes link outputs --profile ${profile} --target ${target}\n` +
40
+ ` lanes link outputs --profile ${profile} --workspace ${target}\n` +
41
41
  ' A client keeps the tool list it fetched when it connected, so one registered\n' +
42
42
  ' before the accounts holds a surface without them until it is re-added.',
43
43
  );
@@ -66,7 +66,7 @@ export function reportUnauthorised(warnings: readonly string[], profile: string,
66
66
  print(
67
67
  style.dim(
68
68
  ' A browser consent per account is the one step this cannot take for you:\n' +
69
- ` lanes link connect <provider> --profile ${profile} --target ${target}\n` +
69
+ ` lanes link connect <provider> --profile ${profile} --workspace ${target}\n` +
70
70
  ' Each is served as soon as it is authorised. There is no second deploy —\n' +
71
71
  ' deploying is how code gets here, and authorising an account changes none.',
72
72
  ),
@@ -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
- }