@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
@@ -1,13 +1,22 @@
1
1
  import { rename, writeFile } from 'node:fs/promises';
2
- import { Document, parseDocument, type Node } from 'yaml';
2
+ import { Document, parseDocument, YAMLSeq, type Node } from 'yaml';
3
3
  import {
4
+ CONNECTIONS_FILE,
4
5
  ConfigError,
6
+ WORKSPACE_FILE,
7
+ LEGACY_WORKSPACE_FILE,
8
+ workspaceSchema,
9
+ assertConnectionsUnique,
10
+ connectionsFileSchema,
11
+ findSecrets,
12
+ formatSecretFindings,
5
13
  isRemoteWorkspace,
6
14
  readWorkspaceFile,
7
15
  validateConfig,
8
16
  validateConfigShape,
9
17
  workspaceFiles,
10
18
  writeWorkspaceFile,
19
+ layout,
11
20
  } from '#profile';
12
21
 
13
22
  /**
@@ -24,6 +33,59 @@ import {
24
33
  * refuses to run.
25
34
  */
26
35
 
36
+ /**
37
+ * Validate a document against the schema for the file it is.
38
+ *
39
+ * The key is the discriminator because it is the only thing that is always
40
+ * right: a caller could be asked to say which shape it holds, and a caller that
41
+ * said the wrong one would get the wrong check silently.
42
+ */
43
+ function validateDocument(
44
+ raw: unknown,
45
+ path: string,
46
+ key: string | undefined,
47
+ options: { shapeOnly?: boolean; contract?: number },
48
+ ): void {
49
+ // Either name. The contract-3 migration rewrites the registry under the name
50
+ // it still has, and a document checked against the wrong schema fails with
51
+ // "instance: expected object" — which reads as a corrupt profile rather than
52
+ // as a registry being validated as one.
53
+ if (key === WORKSPACE_FILE || key === LEGACY_WORKSPACE_FILE) {
54
+ const parsed = workspaceSchema.safeParse(raw);
55
+ if (!parsed.success) {
56
+ throw new ConfigError(
57
+ `${path}:\n${parsed.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n')}`,
58
+ );
59
+ }
60
+ return;
61
+ }
62
+
63
+ if (key === CONNECTIONS_FILE) {
64
+ const secrets = findSecrets(raw);
65
+ if (secrets.length > 0) {
66
+ throw new ConfigError(
67
+ `${path}: ${formatSecretFindings(secrets)}`,
68
+ secrets.map((finding) => finding.path),
69
+ );
70
+ }
71
+
72
+ const parsed = connectionsFileSchema.safeParse(raw);
73
+ if (!parsed.success) {
74
+ throw new ConfigError(
75
+ `${path}:\n${parsed.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n')}`,
76
+ );
77
+ }
78
+
79
+ assertConnectionsUnique(parsed.data.connections);
80
+ return;
81
+ }
82
+
83
+ // `contract` is the one a migration is *producing*, and it is only ever
84
+ // passed by one: every other caller writes the newest.
85
+ if (options.shapeOnly === true) validateConfigShape(raw, path, options.contract);
86
+ else validateConfig(raw, path, options.contract);
87
+ }
88
+
27
89
  export class ConfigDocument {
28
90
  readonly #document: Document;
29
91
  readonly #path: string;
@@ -47,7 +109,20 @@ export class ConfigDocument {
47
109
  * `gs://` URL produces something that addresses nothing.
48
110
  */
49
111
  static async open(workspaceRoot: string, profile: string): Promise<ConfigDocument> {
50
- const key = `profiles/${profile}.yaml`;
112
+ return ConfigDocument.openKey(workspaceRoot, layout.profileConfig(profile));
113
+ }
114
+
115
+ /**
116
+ * Open any document the workspace holds, by key.
117
+ *
118
+ * `open` above is this with the profile path spelled out, and stays because
119
+ * it is what almost every caller wants. This exists for `connections.yaml`,
120
+ * which is a workspace document rather than a profile's (ADR-057) and needs
121
+ * the same comment-preserving edit path — a connection row carries the
122
+ * account label an operator wrote, and rewriting the file through the schema
123
+ * would drop every comment beside it.
124
+ */
125
+ static async openKey(workspaceRoot: string, key: string): Promise<ConfigDocument> {
51
126
  const shown = isRemoteWorkspace(workspaceRoot)
52
127
  ? `${workspaceRoot}/${key}`
53
128
  : `${workspaceRoot}/${key}`;
@@ -132,7 +207,20 @@ export class ConfigDocument {
132
207
  // difference decided whether a command explained itself or crashed.
133
208
  const existing = this.#document.getIn(path as (string | number)[]);
134
209
  if (existing === undefined || existing === null) {
135
- this.#document.setIn(path as (string | number)[], [node]);
210
+ // A `YAMLSeq` rather than the plain `[node]` this used to set. The array
211
+ // is not a collection the document API will traverse — the same hazard
212
+ // `setIn` above documents — so the *first* append landed and the second
213
+ // found a value with no `.add` and threw `existing.add is not a
214
+ // function`. `#expand` below reads `.items` and was silently a no-op for
215
+ // the same reason, leaving the sequence in flow style.
216
+ //
217
+ // Latent until contract 3: every path this was called with
218
+ // (`connections`, `policy.allow`) already existed, so the branch ran at
219
+ // most once per document. `grants:` is genuinely absent on a profile
220
+ // being repaired, which is what made the second append reachable.
221
+ const created = new YAMLSeq();
222
+ created.add(node);
223
+ this.#document.setIn(path as (string | number)[], created);
136
224
  } else {
137
225
  (existing as { add(item: unknown): void }).add(node);
138
226
  }
@@ -195,13 +283,18 @@ export class ConfigDocument {
195
283
  * The half it keeps is the half that matters for a write: the schema, and the
196
284
  * scan that stops a credential value being written into config.
197
285
  */
198
- async save(options: { shapeOnly?: boolean } = {}): Promise<void> {
286
+ async save(options: { shapeOnly?: boolean; contract?: number } = {}): Promise<void> {
199
287
  const rendered = this.toString();
200
288
 
201
289
  // Throws on any validation failure, including a credential value that has
202
290
  // crept in — so a CLI edit can never introduce one.
203
- if (options.shapeOnly === true) validateConfigShape(this.#document.toJSON(), this.#path);
204
- else validateConfig(this.#document.toJSON(), this.#path);
291
+ //
292
+ // Which schema, decided by the key rather than by the caller. This class
293
+ // edits two shapes now: a profile, and the workspace's `connections.yaml`
294
+ // (ADR-057). Validating one against the other's schema is not a stricter
295
+ // check, it is the wrong one — a connections file has no `instance:` block,
296
+ // so it would be refused for a field it is not supposed to have.
297
+ validateDocument(this.#document.toJSON(), this.#path, this.#location?.key, options);
205
298
 
206
299
  if (!this.#location) {
207
300
  throw new ConfigError(`${this.#path}: opened from text, so there is nowhere to save it`);
@@ -234,140 +327,3 @@ export class ConfigDocument {
234
327
  * Written with comments, because this is the file an operator will read first
235
328
  * and most of what it needs to say is *why*, not *what*.
236
329
  */
237
- export function newProfileTemplate(profile: string, port: number): string {
238
- return `# Lanes Link profile: ${profile}
239
- #
240
- # This file is the source of truth for what exists. It never contains a
241
- # credential value — only "_ref" pointers into the credential store, which
242
- # lives beside it and is encrypted at rest.
243
- #
244
- # Edit it by hand or through the CLI; both are supported, and CLI edits
245
- # preserve your comments and ordering.
246
- contract: 2
247
-
248
- instance:
249
- profile: ${profile}
250
- port: ${port}
251
- host: 127.0.0.1
252
-
253
- # This file says nothing about where it runs, and that is the point.
254
- #
255
- # A profile lives in exactly one target, and the target is the workspace holding
256
- # this file — which declares its own adapters, once, in lanes-link.yaml beside
257
- # the profiles/ directory (ADR-052). Moving this profile somewhere else is
258
- # copying the file there; there is no block in it to edit.
259
- #
260
- # Every command still names both, because neither is inferred (ADR-037):
261
- #
262
- # lanes link status --profile ${profile} --target <name>
263
- #
264
- # The bearer token for the endpoint this profile serves.
265
- #
266
- # "lanes link start" serves every profile in the workspace from one URL, and this
267
- # token is what opens it — so it admits every profile that "lanes link outputs"
268
- # lists, not only this one. Each call names the profile it means. Run a separate
269
- # workspace if you need a token that cannot reach them all.
270
- auth:
271
- mode: bearer
272
- token_ref: profile/token
273
-
274
- limits:
275
- requests_per_minute: 120 # per profile
276
- upstream_calls_per_minute: 60 # per connection, protects vendor quota
277
-
278
- # App registrations, shared by every connection of that vendor.
279
- oauth_apps: {}
280
-
281
- # One entry per authorised account. "account" is the identity the provider
282
- # reports — an address, a workspace — so this list says whose data is reachable
283
- # without having to look anything up.
284
- #
285
- # The seven below hold no account, and that is why they are here already: they
286
- # reach your own material rather than anybody's API, so there was never anything
287
- # for a connect step to authorise (ADR-050). What each one is:
288
- #
289
- # memory what you want remembered between sessions
290
- # tasks what you have to do, each with a status
291
- # assets files you want kept, by name
292
- # skills procedures you have written, handed to an agent as instructions
293
- # vault passwords and API keys, released one at a time
294
- # setup what is connected here, and what connecting more would take
295
- # entities the people, companies and projects you deal with, and how to
296
- # reach each of them — so an agent looks an address up rather
297
- # than recalling one
298
- #
299
- # Nothing is stored in any of them until you or an agent puts something there,
300
- # and none of them can read an account. To switch one off, deny it below —
301
- # deleting the entry no longer works, because the next connect or deploy puts it
302
- # back.
303
- connections:
304
- - { id: main, provider: memory, account: Memory }
305
- - { id: main, provider: tasks, account: Tasks }
306
- - { id: main, provider: assets, account: Assets }
307
- - { id: main, provider: skills, account: Skills }
308
- - { id: main, provider: vault, account: Vault }
309
- - { id: main, provider: setup, account: Setup }
310
- - { id: main, provider: entities, account: Entities }
311
-
312
- # Only what is listed here is reachable, and an empty policy grants nothing.
313
- #
314
- # Rules name capabilities, never accounts: "gmail.*" covers every Gmail
315
- # connection in this profile. To grant two accounts differently, run a second
316
- # profile — profiles share no database and no credential store. They do now
317
- # share an endpoint and its token, so that separation is enforced per call
318
- # rather than per URL.
319
- #
320
- # allow: ['*'] everything, which is what connect writes
321
- # allow: [notion.*, gmail.*] two providers
322
- # deny: [gmail.send_message] a deny always beats an allow
323
- #
324
- # The rules below grant each of the seven its whole namespace, writes included —
325
- # the same thing "connect memory" wrote when it was a command you had to run.
326
- # Narrowing is one line, and these are the three worth knowing:
327
- #
328
- # deny: [memory.write, memory.forget] memory becomes read-only
329
- # deny: [skills.manage.*] skills can be invoked but not authored
330
- # deny: [vault.put, vault.remove] nothing new can be stored
331
- # deny: [entities.write, entities.link, entities.forget]
332
- # entities becomes read-only
333
- #
334
- # Those lists are exhaustive on purpose: a namespace is read-only only when
335
- # every capability that changes something is named, so "deny: [memory.write]"
336
- # alone leaves "memory.forget" granted.
337
- #
338
- # A vault read is not granted by "vault.*" alone: each stored item is its own
339
- # "vault.get.<id>" capability and only appears after a restart, so a write can
340
- # never hand itself a read (ADR-012).
341
- policy:
342
- allow: [memory.*, tasks.*, assets.*, skills.*, vault.*, setup.*, entities.*]
343
- deny: []
344
- `;
345
- }
346
-
347
- export function newWorkspaceTemplate(): string {
348
- return `# Lanes Link workspace
349
- #
350
- # A workspace holds one or more profiles, and one endpoint serves all of them:
351
- # every call names the profile it means, with --profile. Profiles never share a
352
- # database or a credential store, so what one holds is invisible to another.
353
- #
354
- # This workspace IS a target. "targets:" below says where its bytes go, once,
355
- # for every profile in it — a profile says nothing about where it runs, so
356
- # there is one copy of it and nothing to keep in step (ADR-052).
357
- #
358
- # A target somewhere else is a pointer, and "deploy" writes one:
359
- #
360
- # targets:
361
- # cloud:
362
- # workspace: gs://your-bucket
363
- #
364
- # The workspace at that address declares its own adapters, and is the only
365
- # thing that does. Reading it is a network call, which is why "--target cloud"
366
- # needs that bucket reachable.
367
- contract: 2
368
- targets:
369
- local:
370
- credentials: { adapter: file }
371
- storage: { adapter: filesystem }
372
- `;
373
- }
@@ -91,10 +91,12 @@ export function pendingRenames(document: ConfigDocument): PendingRename[] {
91
91
  * connection that lost its authorisation for no reason anyone can see.
92
92
  */
93
93
  export async function migrateRenamedProviders(
94
- document: ConfigDocument,
94
+ connections: ConfigDocument,
95
+ profiles: readonly ConfigDocument[],
95
96
  credentials: SecretStore,
96
97
  options: { apply: boolean },
97
98
  ): Promise<RenameMigration> {
99
+ const document = connections;
98
100
  const rows = pendingRenames(document);
99
101
  if (rows.length === 0) return { rows, changes: [], blocked: [] };
100
102
 
@@ -116,15 +118,14 @@ export async function migrateRenamedProviders(
116
118
  }
117
119
  }
118
120
 
119
- // The policy rules second, because whether one can move depends on what is
120
- // left declaring the old id once the rows above have.
121
- for (const [from, to] of new Map(accepted.map((row) => [row.from, row.to]))) {
122
- const policy = renamePolicyRules(document, { from, to }, {
123
- stillDeclared: keepsDeclaring(document, from, accepted),
124
- apply: options.apply,
125
- });
126
- changes.push(...policy.changes);
127
- blocked.push(...policy.blocked);
121
+ // The grant rules second, because whether one can move depends on what is
122
+ // left declaring the old id once the rows above have — and because they live
123
+ // in the profiles now rather than beside the connection (ADR-057), so this is
124
+ // one pass per profile over a rename computed once.
125
+ for (const row of accepted) {
126
+ for (const profile of profiles) {
127
+ changes.push(...renameGrantRules(profile, row, { apply: options.apply }).changes);
128
+ }
128
129
  }
129
130
 
130
131
  if (!options.apply || accepted.length === 0) return { rows, changes, blocked };
@@ -137,8 +138,11 @@ export async function migrateRenamedProviders(
137
138
  }
138
139
 
139
140
  // Throws unless the result is a config that loads, which is the assertion
140
- // worth having here — the whole premise was that it did not.
141
+ // worth having here — the whole premise was that it did not. Both files, in
142
+ // the order that survives a crash between them: a grant naming a connection
143
+ // that has not been renamed yet is refused at load, so the profiles go last.
141
144
  await document.save();
145
+ if (options.apply) for (const profile of profiles) await profile.save();
142
146
 
143
147
  for (const row of accepted) await credentials.delete(`${row.from}/${row.id}`);
144
148
 
@@ -146,7 +150,7 @@ export async function migrateRenamedProviders(
146
150
  }
147
151
 
148
152
  /**
149
- * Rewrite the policy rules that named the old id, where that is unambiguous.
153
+ * Rewrite the grant rules that named the old id, where that is unambiguous.
150
154
  *
151
155
  * A rule names a provider and never an account, so `tasks.*` written for Google
152
156
  * Tasks has to follow the rename or the migrated connection is granted nothing
@@ -154,66 +158,80 @@ export async function migrateRenamedProviders(
154
158
  * consulted, so the repair would land a row that serves exactly as little as
155
159
  * the broken one did.
156
160
  *
157
- * But a profile declaring *both* a Google Tasks row and the built-in has one
158
- * rule serving two providers, and moving it would silently revoke the one that
159
- * kept its name. That profile keeps its rule and is told to add the second,
160
- * which is a sentence rather than a guess at which was meant.
161
+ * The ambiguity this used to guard against is gone, and it is worth saying why.
162
+ * A rule lived in one flat block and named a provider, so a profile holding a
163
+ * Google Tasks row *and* the built-in had one `tasks.*` serving both moving it
164
+ * would silently revoke whichever kept its name, so the migration reported
165
+ * rather than guessed. A rule lives inside the row that names one connection
166
+ * now (ADR-058), so `tasks.*` on a `google_tasks` row is not ambiguous: it is
167
+ * invalid, and `assertReferentialIntegrity` refuses it. There is nothing left to
168
+ * decide.
161
169
  *
162
- * Both lists. A `deny` written to switch Google Tasks off means it as firmly as
163
- * an allow means it on, and leaving it behind would re-enable something the
164
- * operator turned off.
170
+ * Both lists, on every row. A `deny` written to switch Google Tasks off means it
171
+ * as firmly as an allow means it on, and leaving it behind would re-enable
172
+ * something the operator turned off.
173
+ *
174
+ * The row's `connection` moves too, and that is new: a grant names the account
175
+ * rather than the provider now (ADR-058), so `tasks.main` becomes
176
+ * `google_tasks.main` or the grant resolves to nothing.
165
177
  */
166
- function renamePolicyRules(
178
+ function renameGrantRules(
167
179
  document: ConfigDocument,
168
- provider: { from: string; to: string },
169
- options: { stillDeclared: boolean; apply: boolean },
170
- ): { changes: string[]; blocked: string[] } {
180
+ moved: { from: string; to: string; id: string; key: string },
181
+ options: { apply: boolean },
182
+ ): { changes: string[] } {
171
183
  const changes: string[] = [];
172
- const blocked: string[] = [];
173
184
 
174
- for (const field of ['allow', 'deny'] as const) {
175
- const rules = document.getIn(['policy', field]) as { items?: unknown[] } | null;
176
-
177
- (rules?.items ?? []).forEach((_item, index) => {
178
- // Either spelling: a bare pattern, or `{ capability, expires_at }`. The
179
- // path to it differs; the decision does not.
180
- const bare = document.getIn(['policy', field, index]);
181
- const path =
182
- typeof bare === 'string'
183
- ? (['policy', field, index] as const)
184
- : (['policy', field, index, 'capability'] as const);
185
-
186
- const capability = typeof bare === 'string' ? bare : document.getIn(path);
187
- if (typeof capability !== 'string') return;
188
-
189
- const [named, ...rest] = capability.split('.');
190
- if (named !== provider.from) return;
191
-
192
- const moved = [provider.to, ...rest].join('.');
193
-
194
- if (options.stillDeclared) {
195
- blocked.push(
196
- `policy.${field} keeps "${capability}" — this profile still declares a ` +
197
- `"${provider.from}" connection, so add "${moved}" rather than moving it`,
198
- );
199
- return;
200
- }
201
-
202
- if (options.apply) document.setIn(path, moved);
203
- changes.push(`policy.${field}: ${capability} → ${moved}`);
204
- });
205
- }
185
+ const provider = { from: moved.from, to: moved.to };
186
+ const grants = document.getIn(['grants']) as { items?: unknown[] } | null;
187
+
188
+ (grants?.items ?? []).forEach((_row, at) => {
189
+ const connection = document.getIn(['grants', at, 'connection']);
190
+ if (typeof connection !== 'string') return;
191
+
192
+ // The *exact* connection that moved, not every grant naming the old
193
+ // provider. A workspace holding a Google Tasks row beside the built-in has
194
+ // two `tasks.` grants and only one of them is being renamed — moving both
195
+ // would point the built-in's grant at a connection that does not exist.
196
+ if (connection !== moved.key) return;
197
+
198
+ // The `connection` field always follows, and this is the one place the
199
+ // ambiguity does not reach. It names the exact row being renamed, so there
200
+ // is nothing to guess — and leaving it behind would point the grant at a
201
+ // connection that no longer exists, which `assertGrantsResolve` refuses at
202
+ // load. The rename would have made the workspace unopenable.
203
+ const renamedConnection = `${provider.to}.${moved.id}`;
204
+ if (options.apply) document.setIn(['grants', at, 'connection'], renamedConnection);
205
+ changes.push(`grants[${at}].connection: ${connection} → ${renamedConnection}`);
206
206
 
207
- return { changes, blocked };
207
+ for (const field of ['allow', 'deny'] as const) {
208
+ const rules = document.getIn(['grants', at, field]) as { items?: unknown[] } | null;
209
+
210
+ (rules?.items ?? []).forEach((_item, index) => {
211
+ // Either spelling: a bare pattern, or `{ capability, expires_at }`. The
212
+ // path to it differs; the decision does not.
213
+ const bare = document.getIn(['grants', at, field, index]);
214
+ const path =
215
+ typeof bare === 'string'
216
+ ? (['grants', at, field, index] as const)
217
+ : (['grants', at, field, index, 'capability'] as const);
218
+
219
+ const capability = typeof bare === 'string' ? bare : document.getIn(path);
220
+ if (typeof capability !== 'string') return;
221
+
222
+ const [head, ...rest] = capability.split('.');
223
+ if (head !== provider.from) return;
224
+
225
+ const renamed = [provider.to, ...rest].join('.');
226
+ if (options.apply) document.setIn(path, renamed);
227
+ changes.push(`grants[${at}].${field}: ${capability} → ${renamed}`);
228
+ });
229
+ }
230
+ });
231
+
232
+ return { changes };
208
233
  }
209
234
 
210
- /**
211
- * Whether a row naming the old provider survives the migration.
212
- *
213
- * Computed against the accepted set rather than by re-reading the document,
214
- * because the same answer has to hold on a report-only run, where nothing has
215
- * been rewritten yet.
216
- */
217
235
  function keepsDeclaring(
218
236
  document: ConfigDocument,
219
237
  provider: string,
@@ -0,0 +1,119 @@
1
+ import { CONNECTIONS_FILE, listProfiles, workspaceFiles, writeWorkspaceFile } from '#profile';
2
+ import { newConnectionsTemplate } from './config-templates.ts';
3
+ import { ConfigDocument } from './config-edit.ts';
4
+ import { ok, print, style, warn } from './output.ts';
5
+ import { DEFAULT_SURFACES, ensureOwnerLayer, repairLines, repaired } from './config-repair.ts';
6
+
7
+ /**
8
+ * Applying the owner-layer repair across a whole workspace.
9
+ *
10
+ * Split from `config-repair.ts` when that file outgrew the budget, on the seam
11
+ * it already had: that file decides what one profile is missing, and this one
12
+ * walks the workspace applying it. The decision is a pure function of two
13
+ * documents and is tested as one; the sweep is all filesystem, ordering and
14
+ * what to say when a profile will not open.
15
+ */
16
+
17
+ /** `memory, tasks, assets, skills, vault, setup and entities`, in repair order. */
18
+ function listSurfaces(): string {
19
+ const names = [...DEFAULT_SURFACES];
20
+ const last = names.pop();
21
+ return names.length === 0 ? String(last) : `${names.join(', ')} and ${last}`;
22
+ }
23
+
24
+ /** The workspace's connections document, written from the template if missing. */
25
+ async function openOrCreateConnections(workspaceRoot: string): Promise<ConfigDocument> {
26
+ try {
27
+ return await ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
28
+ } catch {
29
+ await writeWorkspaceFile(
30
+ workspaceFiles(workspaceRoot),
31
+ CONNECTIONS_FILE,
32
+ newConnectionsTemplate(),
33
+ );
34
+ return ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Apply that repair across a workspace, saving and reporting what changed.
40
+ *
41
+ * Here rather than in `#deployments`, where it was, because `start` needs it as
42
+ * much as `deploy` does — more, in fact: `start` is the one command an existing
43
+ * install runs without being asked to, so it is the path by which a profile
44
+ * written before ADR-050 gets the layer at all. Two copies of a function that
45
+ * widens a policy is not a thing to have.
46
+ *
47
+ * **The caller scopes it**, and for `deploy` that is exactly the set being
48
+ * uploaded: a profile it sends is a profile the endpoint will serve, so
49
+ * repairing a narrower set would leave a served profile without the surfaces.
50
+ * Note what a `--profile` flag does not mean — it is the flag alone, so a
51
+ * profile resolved from the environment leaves it undefined and that reads as
52
+ * the whole workspace.
53
+ *
54
+ * *Which files are profiles* comes from `listProfiles`, never from an allowlist
55
+ * of what is safe to copy: that would happily hand over a committed
56
+ * `personal.example.yaml` or a nested `profiles/archive/old.yaml`, and this
57
+ * opens and validates what it is given — which once turned a template into a
58
+ * `ConfigError` aborting a deploy after provisioning had made cloud resources.
59
+ *
60
+ * A profile that cannot be read is warned about rather than fatal: the repair is
61
+ * a courtesy on the way past, and the caller's real work should still happen.
62
+ * Not silent, though — nothing else widens a policy without being asked.
63
+ *
64
+ * CLI-side by construction, like everything else in this file: a deployed
65
+ * revision holds `objectViewer` on `profiles/` (ADR-023) and could not write
66
+ * this even if the code let it.
67
+ */
68
+ export async function repairOwnerLayer(
69
+ workspaceRoot: string,
70
+ profiles: readonly string[] | undefined,
71
+ options: { report?: (line: string) => void } = {},
72
+ ): Promise<void> {
73
+ // stdout by default, because every caller but one is printing a report a
74
+ // person reads. `update --json` passes `progress` instead: what it produces is
75
+ // a document, and a line of prose in front of it corrupts whatever is parsing.
76
+ // Routed rather than silenced — nothing else here widens a policy without
77
+ // saying so, and this must not be the exception.
78
+ const say = options.report ?? print;
79
+ const wanted = profiles === undefined ? undefined : new Set(profiles);
80
+
81
+ // One connections document for the whole sweep. The owner layer is the
82
+ // workspace's now (ADR-059), so repairing three profiles must not add three
83
+ // `memory.main` rows — opening it once and saving it once is what makes the
84
+ // second profile see what the first one created.
85
+ // Created if absent rather than refused. A contract-3 workspace always has
86
+ // one, but a hand-made or half-migrated one may not — and this is the repair,
87
+ // so the file it needs is a thing to write rather than a reason to stop.
88
+ const connections = await openOrCreateConnections(workspaceRoot);
89
+ let connectionsChanged = false;
90
+
91
+ for (const name of await listProfiles(workspaceRoot)) {
92
+ if (wanted !== undefined && !wanted.has(name)) continue;
93
+
94
+ try {
95
+ const document = await ConfigDocument.open(workspaceRoot, name);
96
+ const repair = ensureOwnerLayer(connections, document);
97
+ if (!repaired(repair)) continue;
98
+
99
+ connectionsChanged = true;
100
+ await document.save();
101
+
102
+ say(ok(`gave ${style.bold(name)} its own owner layer`));
103
+ for (const change of repairLines(repair)) say(` ${style.dim(change)}`);
104
+ // Built from `DEFAULT_SURFACES` rather than typed out. The typed-out
105
+ // version still named six after a seventh had been added, so a person
106
+ // watching a deploy was told entities had arrived on the line above and
107
+ // that the layer was six things on the line below.
108
+ say(` ${style.dim(`${listSurfaces()} — your own material, no account behind any of them`)}`);
109
+ } catch (error) {
110
+ say(
111
+ warn(
112
+ `could not give ${name} its owner layer: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
113
+ ),
114
+ );
115
+ }
116
+ }
117
+
118
+ if (connectionsChanged) await connections.save();
119
+ }