@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,9 +1,18 @@
1
1
  import { RESERVED_PROVIDER_IDS } from '#connectivity';
2
2
  import { credentialRefFor } from '#registry';
3
- import type { ConnectionConfig, Config, Resolution } from '#profile';
3
+ import {
4
+ CONNECTIONS_FILE,
5
+ listProfiles,
6
+ loadProfileConfig,
7
+ readConnections,
8
+ type ConnectionConfig,
9
+ type Resolution,
10
+ layout,
11
+ } from '#profile';
4
12
  import { ConfigDocument } from '../config-edit.ts';
5
- import { announce, emit, ok, print, style, warn } from '../output.ts';
6
- import { openRuntime, type GlobalFlags } from '../runtime.ts';
13
+ import { announce, announceWorkspace, emit, ok, print, style, warn } from '../output.ts';
14
+ import { recordConfigChange } from './../audit-change.ts';
15
+ import { openWorkspaceRuntime, type GlobalFlags } from '../runtime.ts';
7
16
  import { nextAfterEdit, publishProfileEdit } from '../publish.ts';
8
17
  import { confirm, isInteractive } from '../prompt.ts';
9
18
 
@@ -44,18 +53,20 @@ export interface Disconnected {
44
53
  readonly credential: string | null;
45
54
  /** Set when the credential was left because something else still needs it. */
46
55
  readonly credentialSharedWith: readonly string[];
56
+ /**
57
+ * The profiles that lost a grant on this connection.
58
+ *
59
+ * Reported rather than counted, because a connection belongs to the workspace
60
+ * (ADR-057) and the profiles that lose one are not the profile the command was
61
+ * run against. Empty means it was granted by nobody, which is a legitimate
62
+ * state for an account connected and not yet used.
63
+ */
64
+ readonly ungranted: readonly string[];
65
+ /** Connections left in the workspace, not in the profile. */
47
66
  readonly remaining: number;
48
67
  readonly published: string;
49
68
  }
50
69
 
51
- export interface Relabelled {
52
- readonly profile: string;
53
- readonly target: string;
54
- readonly key: string;
55
- readonly from: string;
56
- readonly to: string;
57
- readonly published: string;
58
- }
59
70
 
60
71
  export interface DisconnectFlags extends GlobalFlags {
61
72
  readonly yes?: boolean | undefined;
@@ -63,9 +74,6 @@ export interface DisconnectFlags extends GlobalFlags {
63
74
  readonly json?: boolean | undefined;
64
75
  }
65
76
 
66
- export interface RelabelFlags extends GlobalFlags {
67
- readonly json?: boolean | undefined;
68
- }
69
77
 
70
78
  /**
71
79
  * Find the one connection a key names.
@@ -75,45 +83,64 @@ export interface RelabelFlags extends GlobalFlags {
75
83
  * is nothing to choose here, and a bare `gmail` with two accounts declared would
76
84
  * be a command guessing which one to throw away.
77
85
  */
78
- function locate(config: Config, key: string, profile: string): Located {
86
+ export function locate(
87
+ connections: readonly ConnectionConfig[],
88
+ key: string,
89
+ workspace: string,
90
+ ): Located {
79
91
  if (!key.includes('.')) {
80
- const matches = config.connections.filter((one) => one.provider === key);
92
+ const matches = connections.filter((one) => one.provider === key);
81
93
  throw new Error(
82
94
  `"${key}" names a provider, not a connection.\n` +
83
95
  (matches.length > 0
84
- ? ` This profile declares ${matches.map((one) => `${one.provider}.${one.id}`).join(', ')}.\n`
96
+ ? ` This workspace holds ${matches.map((one) => `${one.provider}.${one.id}`).join(', ')}.\n`
85
97
  : '') +
86
- ` Run: lanes link status --profile ${profile}`,
98
+ ` Run: lanes link connection list --workspace ${workspace}`,
87
99
  );
88
100
  }
89
101
 
90
- const index = config.connections.findIndex((one) => `${one.provider}.${one.id}` === key);
102
+ const index = connections.findIndex((one) => `${one.provider}.${one.id}` === key);
91
103
  if (index === -1) {
92
104
  throw new Error(
93
- `Profile "${profile}" does not declare "${key}".\n` +
94
- ` Run: lanes link status --profile ${profile}`,
105
+ `Workspace "${workspace}" holds no connection "${key}".\n` +
106
+ ` Run: lanes link connection list --workspace ${workspace}`,
95
107
  );
96
108
  }
97
109
 
98
- return { index, connection: config.connections[index] as ConnectionConfig };
110
+ return { index, connection: connections[index] as ConnectionConfig };
99
111
  }
100
112
 
101
113
  /**
102
- * Refuse for a reserved provider, and say what to do instead.
114
+ * Refuse to remove the last instance of a built-in surface, and say why.
115
+ *
116
+ * A reserved provider is a connection like any other now (ADR-059), so
117
+ * `disconnect memory.acme` is a real thing to do and it works: the instance goes,
118
+ * every profile's grant on it goes, and the bytes stay where `rm -r` can find
119
+ * them.
120
+ *
121
+ * The *last* one is different, and refusing is honesty rather than caution. The
122
+ * owner layer arrives granted and is repaired back into place on the next
123
+ * `start`, `connect` or `deploy` (ADR-050), so removing the only `memory`
124
+ * connection succeeds, prints a success line, and is undone by the next command
125
+ * the operator runs. A command that reports a change the system immediately
126
+ * reverses is worse than one that refuses.
103
127
  *
104
- * `memory`, `skills`, `vault`, `setup` and `identity` are not accounts they are
105
- * what the profile is *for*, they hold no credential, and each is granted by a
106
- * policy line this command does not touch. Removing the connection alone would
107
- * leave the policy granting `memory.*` against nothing: a config that is wrong
108
- * rather than merely untidy, which is the same reason `knowledge use` takes its
109
- * own block back. Hand-editing is the honest path and the file says so.
128
+ * `deny` is the way off, and it always wasthe same line ADR-050 documents.
110
129
  */
111
- function refuseReserved(key: string, provider: string, path: string): void {
130
+ function refuseLastReserved(
131
+ key: string,
132
+ provider: string,
133
+ connections: readonly ConnectionConfig[],
134
+ ): void {
112
135
  if (!RESERVED_PROVIDER_IDS.includes(provider)) return;
136
+ if (connections.filter((one) => one.provider === provider).length > 1) return;
137
+
113
138
  throw new Error(
114
- `"${key}" is part of what this profile is, not an account it holds.\n` +
115
- ` ${provider} keeps no credential, and its policy grant is a separate line this command does not touch.\n` +
116
- ` To remove it, delete both from ${path} by hand.`,
139
+ `"${key}" is the only ${provider} connection in this workspace.\n` +
140
+ ` The owner layer is repaired back on the next start, connect or deploy, so removing it\n` +
141
+ ` would report a change that the next command undoes.\n` +
142
+ ` To take the surface away from a profile, deny it:\n` +
143
+ ` lanes link policy deny "${provider}.*" --connection ${key} --profile <name>`,
117
144
  );
118
145
  }
119
146
 
@@ -128,97 +155,89 @@ function refuseReserved(key: string, provider: string, path: string): void {
128
155
  * `unauthorized` for a `connect` nobody ran.
129
156
  */
130
157
  export function connectionsSharingCredential(
131
- config: Config,
158
+ connections: readonly ConnectionConfig[],
132
159
  ref: string,
133
160
  exceptIndex: number,
134
161
  manifestFor: (provider: string) => Parameters<typeof credentialRefFor>[1],
135
162
  ): string[] {
136
- return config.connections
163
+ return connections
137
164
  .filter((one, i) => i !== exceptIndex && credentialRefFor(one, manifestFor(one.provider)) === ref)
138
165
  .map((one) => `${one.provider}.${one.id}`);
139
166
  }
140
167
 
141
168
  /**
142
- * Take back the allow rules that named the provider, once nothing declares it.
169
+ * Take the grant row back from a profile that named this connection.
143
170
  *
144
- * Not a tidy-up. `assertReferentialIntegrity` refuses an allow rule naming a
145
- * provider with no connection, and `save` validates so disconnecting the last
146
- * connection of a provider *failed*, with a config error about a policy line the
147
- * operator had not touched, and nothing removed. Every single-account provider
148
- * was undisconnectable, which is most of them: the bug reproduced on `slack.*`
149
- * and would have on `bunq.*`, while a profile with two Gmail accounts could
150
- * disconnect one perfectly well.
171
+ * Exact, where this used to be a search. A rule named a *capability*, so
172
+ * disconnecting the last Gmail meant hunting every rule whose capability began
173
+ * `gmail.` and deciding whether it was now dangling and getting that wrong
174
+ * either left a config the loader refuses or silently dropped a rule about an
175
+ * account that was still there. A grant names the connection (ADR-058), so
176
+ * removing it is finding one row.
151
177
  *
152
- * Symmetry is the argument for removing rather than warning. `connect` writes
153
- * the row *and* the rule, and the pair is what `config-repair.ts` calls both
154
- * halves or neither a rule with nothing behind it grants nothing and is what
155
- * that file exists to stop being written.
178
+ * Symmetry is still the argument. `connect` writes the connection and the
179
+ * profiles that asked for it get a grant; a connection that goes takes its
180
+ * grants with it, because a grant naming nothing is what
181
+ * `assertGrantsResolve` refuses at load.
156
182
  *
157
- * `allow: ['*']` is untouched: it names no provider, so nothing about it becomes
158
- * false. Narrower rules go with the wide one — `gmail.send_message` is as
159
- * dangling as `gmail.*` once the last Gmail is gone, and the loader refuses it
160
- * for the same reason.
161
- *
162
- * `deny` is left alone, deliberately. The loader permits a deny naming a
163
- * provider with no connection, because denying something you have not connected
164
- * yet is a reasonable thing to write ahead of time — and removing it here would
165
- * silently re-permit whatever it covered if the account came back.
183
+ * Returns whether anything was removed, so the caller can report which profiles
184
+ * it touched rather than claiming all of them.
166
185
  */
167
- function dropProviderRules(document: ConfigDocument, config: Config, index: number): void {
168
- const going = config.connections[index];
169
- if (!going) return;
170
-
171
- const stillDeclared = config.connections.some(
172
- (one, i) => i !== index && one.provider === going.provider,
173
- );
174
- if (stillDeclared) return;
175
-
176
- const rules = document.getIn(['policy', 'allow']) as { items?: unknown[] } | null;
186
+ function dropGrantRow(document: ConfigDocument, key: string): boolean {
187
+ const rows = document.getIn(['grants']) as { items?: unknown[] } | null;
177
188
  const doomed: number[] = [];
178
189
 
179
- (rules?.items ?? []).forEach((_rule, at) => {
180
- const bare = document.getIn(['policy', 'allow', at]);
181
- const capability =
182
- typeof bare === 'string' ? bare : document.getIn(['policy', 'allow', at, 'capability']);
183
- if (typeof capability !== 'string') return;
184
-
185
- if (capability.split('.')[0] === going.provider) doomed.push(at);
190
+ (rows?.items ?? []).forEach((_row, at) => {
191
+ if (document.getIn(['grants', at, 'connection']) === key) doomed.push(at);
186
192
  });
187
193
 
188
194
  // Descending, so each removal cannot move the index of one still to come.
189
- for (const at of doomed.reverse()) document.removeFrom(['policy', 'allow'], at);
195
+ for (const at of doomed.reverse()) document.removeFrom(['grants'], at);
196
+ return doomed.length > 0;
190
197
  }
191
198
 
192
199
  export async function removeConnection(
193
200
  key: string,
194
201
  flags: DisconnectFlags,
195
202
  ): Promise<{ resolution: Resolution; disconnected: Disconnected } | null> {
196
- const runtime = await openRuntime(flags);
203
+ const runtime = await openWorkspaceRuntime(flags);
197
204
 
198
205
  try {
199
- const { resolution, config, target } = runtime;
200
- const located = locate(config, key, resolution.profile);
201
- const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
206
+ const { resolution, target } = runtime;
207
+ const root = resolution.workspaceRoot;
208
+
209
+ const connectionsFile = await readConnections(root);
210
+ const located = locate(connectionsFile.connections, key, target);
211
+
212
+ refuseLastReserved(key, located.connection.provider, connectionsFile.connections);
202
213
 
203
- refuseReserved(key, located.connection.provider, document.path);
214
+ // Which profiles lose a grant, computed before anything is asked, because it
215
+ // is the fact that decides whether this is a small change or a large one. A
216
+ // connection belongs to the workspace now (ADR-057), so disconnecting it
217
+ // reaches every profile that named it — including ones the operator is not
218
+ // thinking about, which is exactly why the confirmation lists them.
219
+ const affected = await profilesGranting(root, key);
204
220
 
205
- if (!(await confirmed(key, flags))) return null;
221
+ if (!(await confirmed(key, affected, flags))) return null;
206
222
 
207
223
  const manifestFor = (provider: string) => runtime.registry.get(provider)?.manifest;
208
224
  const ref = credentialRefFor(located.connection, manifestFor(located.connection.provider));
209
- const shared = ref ? connectionsSharingCredential(config, ref, located.index, manifestFor) : [];
210
-
211
- // The config edit first. If deleting the credential fails, a connection left
212
- // declared with no credential reports `unauthorized`, which is recoverable by
213
- // running `connect`. The reverse credential gone, declaration kept, edit
214
- // failed is the same state, so ordering costs nothing either way; doing the
215
- // edit first means the file is right even if the store is unreachable.
216
- // Both edits before the one `save`, so the file is never written in the
217
- // state where the row is gone and the rule that named it is not — which is
218
- // the state the loader refuses.
219
- dropProviderRules(document, config, located.index);
220
- document.removeFrom(['connections'], located.index);
221
- await document.save();
225
+ const shared = ref
226
+ ? connectionsSharingCredential(connectionsFile.connections, ref, located.index, manifestFor)
227
+ : [];
228
+
229
+ // Grants first, then the connection. That order is the one the loader can
230
+ // survive halfway through: a profile granting a connection that is gone is
231
+ // refused at load, while a connection nothing grants is merely unused. So if
232
+ // the process dies between the two writes, the workspace still opens.
233
+ for (const profile of affected) {
234
+ const document = await ConfigDocument.openKey(root, layout.profileConfig(profile));
235
+ if (dropGrantRow(document, key)) await document.save();
236
+ }
237
+
238
+ const connectionsDocument = await ConfigDocument.openKey(root, CONNECTIONS_FILE);
239
+ connectionsDocument.removeFrom(['connections'], located.index);
240
+ await connectionsDocument.save();
222
241
 
223
242
  let removed: string | null = null;
224
243
  if (ref && shared.length === 0 && flags.keepCredential !== true) {
@@ -226,6 +245,21 @@ export async function removeConnection(
226
245
  removed = ref;
227
246
  }
228
247
 
248
+ // `profiles` is the half of this that is easy to lose. A disconnect takes
249
+ // the account away from every profile that named it, and a row saying only
250
+ // "gmail.work removed" would leave a reader to work out which agents
251
+ // stopped being able to reach it.
252
+ await recordConfigChange(runtime.config, root, target, {
253
+ capability: 'config.connection.remove',
254
+ scope: target,
255
+ connection: key,
256
+ arguments: {
257
+ account: located.connection.account,
258
+ profiles: affected,
259
+ credentialRemoved: removed !== null,
260
+ },
261
+ });
262
+
229
263
  return {
230
264
  resolution,
231
265
  disconnected: {
@@ -235,8 +269,21 @@ export async function removeConnection(
235
269
  account: located.connection.account,
236
270
  credential: removed,
237
271
  credentialSharedWith: shared,
238
- remaining: config.connections.length - 1,
239
- published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
272
+ remaining: connectionsFile.connections.length - 1,
273
+ ungranted: affected,
274
+ published: nextAfterEdit(
275
+ await publishProfileEdit({
276
+ resolution,
277
+ config: runtime.config,
278
+ target,
279
+ // Every profile that lost a grant, not just the one this ran under.
280
+ // Publishing one left the bucket with a `connections.yaml` missing
281
+ // the connection and a sibling still granting it — which
282
+ // `assertGrantsResolve` refuses at load, so `openReconciled` skipped
283
+ // that profile and it silently stopped being served.
284
+ touched: [...new Set([resolution.profile, ...affected])],
285
+ }),
286
+ ),
240
287
  },
241
288
  };
242
289
  } finally {
@@ -244,10 +291,34 @@ export async function removeConnection(
244
291
  }
245
292
  }
246
293
 
294
+ /**
295
+ * Every profile in the workspace whose grants name this connection.
296
+ *
297
+ * Read from the files rather than from the open runtime, which knows about one
298
+ * profile. Reading them all is the point: the operator named a connection, and
299
+ * the set of profiles that lose something is not visible from the one they
300
+ * happen to have selected.
301
+ */
302
+ async function profilesGranting(root: string, key: string): Promise<string[]> {
303
+ const names = await listProfiles(root);
304
+ const granting: string[] = [];
305
+
306
+ for (const name of names) {
307
+ const { config } = await loadProfileConfig(root, name);
308
+ if (config.grants.some((grant) => grant.connection === key)) granting.push(name);
309
+ }
310
+
311
+ return granting;
312
+ }
313
+
247
314
  /** A plain y/N. Not `profile remove`'s type-the-name, which guards the
248
315
  * destruction of whole stores; this takes back one authorisation that `connect`
249
316
  * can grant again. */
250
- async function confirmed(key: string, flags: DisconnectFlags): Promise<boolean> {
317
+ async function confirmed(
318
+ key: string,
319
+ affected: readonly string[],
320
+ flags: DisconnectFlags,
321
+ ): Promise<boolean> {
251
322
  if (flags.yes === true) return true;
252
323
  if (!isInteractive()) {
253
324
  throw new Error(
@@ -255,8 +326,18 @@ async function confirmed(key: string, flags: DisconnectFlags): Promise<boolean>
255
326
  ` Pass --yes to proceed.`,
256
327
  );
257
328
  }
329
+
330
+ // The profiles are named in the question rather than printed above it. A
331
+ // connection is the workspace's now, so "disconnect gmail.personal" can take
332
+ // the account away from a profile the operator is not looking at, and the one
333
+ // place that is certain to be read is the line they have to answer.
334
+ const reach =
335
+ affected.length === 0
336
+ ? 'no profile grants it'
337
+ : `${affected.length} profile(s) lose it: ${affected.join(', ')}`;
338
+
258
339
  // Defaults to no: this deletes a credential, and a stray return should not.
259
- return confirm(`Disconnect ${key} and delete its credential?`, false);
340
+ return confirm(`Disconnect ${key} and delete its credential? (${reach})`, false);
260
341
  }
261
342
 
262
343
  export async function disconnect(key: string | undefined, flags: DisconnectFlags): Promise<void> {
@@ -270,7 +351,7 @@ export async function disconnect(key: string | undefined, flags: DisconnectFlags
270
351
  const { resolution, disconnected: result } = outcome;
271
352
 
272
353
  return emit(flags.json, result, () => {
273
- announce(resolution);
354
+ announceWorkspace(resolution);
274
355
  print(ok(`disconnected ${style.bold(result.key)}${result.account ? ` (${result.account})` : ''}`));
275
356
 
276
357
  if (result.credential) {
@@ -285,7 +366,10 @@ export async function disconnect(key: string | undefined, flags: DisconnectFlags
285
366
  );
286
367
  }
287
368
 
288
- print(style.dim(` ${result.remaining} connection(s) left in this profile.`));
369
+ if (result.ungranted.length > 0) {
370
+ print(` ungranted ${result.ungranted.join(', ')}`);
371
+ }
372
+ print(style.dim(` ${result.remaining} connection(s) left in this workspace.`));
289
373
  // The state record survives on purpose, and the next reconcile is what marks
290
374
  // it disabled. Saying so stops "it is still in `status`" reading as a failure.
291
375
  print(
@@ -296,69 +380,3 @@ export async function disconnect(key: string | undefined, flags: DisconnectFlags
296
380
  if (result.published) print(style.dim(` ${result.published}`));
297
381
  })
298
382
  }
299
-
300
- /**
301
- * Rename a connection, writing `label` and never `account`.
302
- *
303
- * It wrote `account` until it was noticed that `account` is not a display name:
304
- * `settleIdentity` matches on it to tell a repair from a new account,
305
- * `idFromAccount` derives the id from it, and `gmail.send_message` writes it
306
- * into a `From` header. Renaming through it therefore un-recognised the account
307
- * it renamed — the next `connect` added a second row beside it.
308
- */
309
- export async function renameConnection(
310
- key: string,
311
- label: string,
312
- flags: RelabelFlags,
313
- ): Promise<{ resolution: Resolution; relabelled: Relabelled }> {
314
- const runtime = await openRuntime(flags);
315
-
316
- try {
317
- const { resolution, config, target } = runtime;
318
- const located = locate(config, key, resolution.profile);
319
- const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
320
-
321
- document.setIn(['connections', located.index, 'label'], label);
322
- await document.save();
323
-
324
- return {
325
- resolution,
326
- relabelled: {
327
- profile: resolution.profile,
328
- target,
329
- key,
330
- // What it was called a moment ago, which is the account only until the
331
- // first rename.
332
- from: located.connection.label ?? located.connection.account,
333
- to: label,
334
- published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
335
- },
336
- };
337
- } finally {
338
- await runtime.close();
339
- }
340
- }
341
-
342
- export async function relabel(
343
- key: string | undefined,
344
- label: string | undefined,
345
- flags: RelabelFlags,
346
- ): Promise<void> {
347
- if (!key) throw new Error('Which connection? Run: lanes link status');
348
- if (!label) throw new Error(`What should ${key} be called? Run: lanes link relabel ${key} "New name"`);
349
-
350
- const { resolution, relabelled: result } = await renameConnection(key, label, flags);
351
-
352
- return emit(flags.json, result, () => {
353
- announce(resolution);
354
- print(ok(`${style.bold(result.key)} is now ${style.bold(result.to)}`));
355
- if (result.from) print(` was ${style.dim(result.from)}`);
356
- // The label is a display name in two places, and only one of them changed.
357
- print(
358
- style.dim(
359
- ' The state store keeps the old name until the next reconcile, which updates it.',
360
- ),
361
- );
362
- if (result.published) print(style.dim(` ${result.published}`));
363
- })
364
- }
@@ -0,0 +1,140 @@
1
+ import { ConfigError, connectionRefOf, readConnections, type Resolution } from '#profile';
2
+ import { ConfigDocument } from '../config-edit.ts';
3
+ import { announce, emit, ok, print, style } from '../output.ts';
4
+ import { nextAfterEdit, publishProfileEdit } from '../publish.ts';
5
+ import { resolveProfile, type GlobalFlags } from '../runtime.ts';
6
+
7
+ /**
8
+ * `lanes link grant` and `lanes link revoke` — which of the workspace's accounts
9
+ * a profile may reach.
10
+ *
11
+ * The command that did not need to exist under contract 2, because connecting an
12
+ * account and granting it were the same act: `connect` wrote a row into the
13
+ * profile and a rule beside it, and a second profile wanting the same mailbox
14
+ * connected it again. A connection belongs to the workspace now (ADR-057), so
15
+ * authorising an account and deciding who may use it are two commands — and
16
+ * this is the second one.
17
+ *
18
+ * A grant row carries the provider wildcard, which is what `connect` writes for
19
+ * the profile that made the connection. Narrowing it afterwards is
20
+ * `lanes link policy deny <capability> --connection <ref>`; the two are separate
21
+ * because widening and narrowing are different decisions and reading them in one
22
+ * command's flags would hide which had happened.
23
+ */
24
+
25
+ export interface Granted {
26
+ readonly profile: string;
27
+ readonly target: string;
28
+ readonly connection: string;
29
+ readonly account: string;
30
+ readonly allowed: readonly string[];
31
+ readonly published: string;
32
+ }
33
+
34
+ export async function grantConnectionTo(
35
+ key: string | undefined,
36
+ flags: GlobalFlags & { json?: boolean },
37
+ ): Promise<void> {
38
+ if (!key) {
39
+ throw new ConfigError(
40
+ 'Which connection? Run: lanes link connection list\n' +
41
+ ' e.g. lanes link grant gmail.personal --profile assistant',
42
+ );
43
+ }
44
+
45
+ const { resolution, config, target } = await resolveProfile(flags);
46
+ const root = resolution.workspaceRoot;
47
+
48
+ const held = (await readConnections(root)).connections;
49
+ const connection = held.find((one) => connectionRefOf(one) === key);
50
+
51
+ // Refused rather than written on trust. A grant naming a connection the
52
+ // workspace does not hold is refused at load by `assertGrantsResolve`, so
53
+ // writing one would leave a profile that no longer opens — and the operator
54
+ // would have been told "ok" by the command that broke it.
55
+ if (connection === undefined) {
56
+ throw new ConfigError(
57
+ `This workspace holds no connection "${key}".\n` +
58
+ (held.length > 0
59
+ ? ` It holds: ${held.map(connectionRefOf).join(', ')}\n`
60
+ : ' It holds none yet. Connect one with: lanes link connect <provider>\n') +
61
+ ` Run "lanes link connection list --workspace ${target}" to see them.`,
62
+ );
63
+ }
64
+
65
+ if (config.grants.some((grant) => grant.connection === key)) {
66
+ print(style.dim(`${resolution.profile} already grants ${key}.`));
67
+ return;
68
+ }
69
+
70
+ const rule = `${connection.provider}.*`;
71
+ const document = await ConfigDocument.open(root, resolution.profile);
72
+ document.addTo(['grants'], { connection: key, allow: [rule], deny: [] }, { inline: true });
73
+ await document.save();
74
+
75
+ const granted: Granted = {
76
+ profile: resolution.profile,
77
+ target,
78
+ connection: key,
79
+ account: connection.account,
80
+ allowed: [rule],
81
+ published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
82
+ };
83
+
84
+ return emit(flags.json, granted, () => {
85
+ announce(resolution);
86
+ print(ok(`granted ${style.bold(key)} to ${style.bold(granted.profile)}`));
87
+ print(` account ${style.dim(granted.account)}`);
88
+ print(` allowed ${granted.allowed.join(', ')}`);
89
+ print(
90
+ style.dim(
91
+ ` Narrow it with: lanes link policy deny <capability> --connection ${key} ` +
92
+ `--profile ${granted.profile}`,
93
+ ),
94
+ );
95
+ if (granted.published) print(style.dim(` ${granted.published}`));
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Take a grant back, leaving the account where it is.
101
+ *
102
+ * Not `disconnect`, and the difference is the whole reason both exist: this
103
+ * removes one profile's permission and touches nothing else, while `disconnect`
104
+ * removes the account from the workspace and every profile that named it.
105
+ * Conflating them is how somebody loses a mailbox meaning to narrow an agent.
106
+ */
107
+ export async function revokeConnectionFrom(
108
+ key: string | undefined,
109
+ flags: GlobalFlags & { json?: boolean },
110
+ ): Promise<void> {
111
+ if (!key) {
112
+ throw new ConfigError(
113
+ 'Which connection? Run: lanes link policy list\n' +
114
+ ' e.g. lanes link revoke gmail.personal --profile assistant',
115
+ );
116
+ }
117
+
118
+ const { resolution, config, target } = await resolveProfile(flags);
119
+ const at = config.grants.findIndex((grant) => grant.connection === key);
120
+
121
+ if (at === -1) {
122
+ print(style.dim(`${resolution.profile} does not grant ${key}.`));
123
+ return;
124
+ }
125
+
126
+ const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
127
+ document.removeFrom(['grants'], at);
128
+ await document.save();
129
+
130
+ return emit(flags.json, { profile: resolution.profile, target, connection: key }, () => {
131
+ announce(resolution);
132
+ print(ok(`revoked ${style.bold(key)} from ${style.bold(resolution.profile)}`));
133
+ print(
134
+ style.dim(
135
+ ' The account is untouched, and every other profile granting it still reaches it.\n' +
136
+ ` To remove the account itself: lanes link disconnect ${key}`,
137
+ ),
138
+ );
139
+ });
140
+ }