@lanes-sh/link 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +136 -61
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +65 -0
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +108 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/brand.ts +39 -10
  22. package/src/cli/callback-page.ts +37 -104
  23. package/src/cli/commands/auth-dispatch.ts +48 -0
  24. package/src/cli/commands/auth.ts +229 -0
  25. package/src/cli/commands/connect/accounts.ts +4 -4
  26. package/src/cli/commands/connect/authorise.ts +4 -4
  27. package/src/cli/commands/connect/bind-credential.ts +2 -1
  28. package/src/cli/commands/connect/custom/index.ts +1 -1
  29. package/src/cli/commands/connect/custom/write.ts +2 -2
  30. package/src/cli/commands/connect/grant.ts +29 -14
  31. package/src/cli/commands/connect/index.ts +88 -87
  32. package/src/cli/commands/connect/options.ts +83 -0
  33. package/src/cli/commands/connect/registration.ts +50 -0
  34. package/src/cli/commands/connect/requirements.ts +1 -1
  35. package/src/cli/commands/connect/settle.ts +4 -2
  36. package/src/cli/commands/connect/target-note.ts +7 -2
  37. package/src/cli/commands/connect/unknown.ts +1 -1
  38. package/src/cli/commands/connect/variables.ts +3 -2
  39. package/src/cli/commands/connection-list.ts +116 -0
  40. package/src/cli/commands/connection.ts +182 -165
  41. package/src/cli/commands/grant.ts +140 -0
  42. package/src/cli/commands/identity.ts +21 -9
  43. package/src/cli/commands/knowledge/index.ts +46 -79
  44. package/src/cli/commands/knowledge/migrate.ts +74 -13
  45. package/src/cli/commands/knowledge/show.ts +92 -0
  46. package/src/cli/commands/knowledge.ts +2 -1
  47. package/src/cli/commands/mcp/harnesses.ts +30 -8
  48. package/src/cli/commands/mcp/onboarding.ts +86 -0
  49. package/src/cli/commands/mcp/register.ts +16 -2
  50. package/src/cli/commands/mcp.ts +1 -0
  51. package/src/cli/commands/members.ts +288 -0
  52. package/src/cli/commands/operate/attach.ts +3 -3
  53. package/src/cli/commands/operate/audit.ts +11 -7
  54. package/src/cli/commands/operate/auth.ts +28 -11
  55. package/src/cli/commands/operate/findings.ts +2 -1
  56. package/src/cli/commands/operate/inspect.ts +37 -19
  57. package/src/cli/commands/operate/migrate.ts +29 -12
  58. package/src/cli/commands/operate/outputs.ts +3 -3
  59. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  60. package/src/cli/commands/operate/pair.ts +324 -0
  61. package/src/cli/commands/operate/policy.ts +73 -22
  62. package/src/cli/commands/operate/serve.ts +52 -4
  63. package/src/cli/commands/operate/status.ts +18 -10
  64. package/src/cli/commands/operate/tools.ts +2 -2
  65. package/src/cli/commands/operate.ts +2 -0
  66. package/src/cli/commands/owner/shared.ts +13 -2
  67. package/src/cli/commands/owner/skills.ts +28 -8
  68. package/src/cli/commands/profile/removal.ts +79 -76
  69. package/src/cli/commands/profile/remove.ts +16 -1
  70. package/src/cli/commands/profile.ts +46 -10
  71. package/src/cli/commands/relabel.ts +112 -0
  72. package/src/cli/commands/secrets.ts +34 -12
  73. package/src/cli/commands/set-workspace.ts +96 -0
  74. package/src/cli/commands/setup.ts +2 -2
  75. package/src/cli/commands/sync.ts +8 -8
  76. package/src/cli/commands/target.ts +9 -7
  77. package/src/cli/commands/update.ts +58 -17
  78. package/src/cli/config-edit.ts +75 -140
  79. package/src/cli/config-migrate.ts +82 -64
  80. package/src/cli/config-repair.ts +89 -32
  81. package/src/cli/config-templates.ts +198 -0
  82. package/src/cli/contract3-data.ts +328 -0
  83. package/src/cli/contract3-shape.ts +186 -0
  84. package/src/cli/contract3.ts +282 -0
  85. package/src/cli/endpoint-url.ts +1 -1
  86. package/src/cli/lanes.ts +25 -1
  87. package/src/cli/main.ts +89 -14
  88. package/src/cli/migrate-plan.ts +12 -6
  89. package/src/cli/output.ts +34 -1
  90. package/src/cli/publish.ts +5 -2
  91. package/src/cli/runtime/open.ts +63 -98
  92. package/src/cli/runtime/registry.ts +6 -7
  93. package/src/cli/runtime/stores.ts +53 -0
  94. package/src/cli/runtime/types.ts +106 -0
  95. package/src/cli/runtime/vault.ts +19 -4
  96. package/src/cli/runtime/workspace.ts +60 -0
  97. package/src/cli/runtime.ts +2 -1
  98. package/src/cli/selection-require.ts +44 -13
  99. package/src/cli/selection.ts +127 -145
  100. package/src/cli/usage.ts +34 -18
  101. package/src/cli/workspace-migrate.ts +125 -16
  102. package/src/connectivity/manifest/provider.ts +3 -1
  103. package/src/connectivity/manifest/requirements.ts +1 -1
  104. package/src/deployments/bind.ts +1 -1
  105. package/src/deployments/deploy.ts +36 -27
  106. package/src/deployments/gcp/bucket.ts +18 -7
  107. package/src/deployments/gcp/provision.ts +7 -7
  108. package/src/deployments/prepare.ts +72 -24
  109. package/src/deployments/record.ts +1 -1
  110. package/src/deployments/report.ts +2 -2
  111. package/src/deployments/serving.ts +15 -74
  112. package/src/deployments/target.ts +15 -15
  113. package/src/deployments/upload.ts +46 -22
  114. package/src/dispatch/deps.ts +88 -0
  115. package/src/dispatch/dispatch.ts +21 -62
  116. package/src/policy/index.ts +47 -15
  117. package/src/profile/connections.ts +183 -0
  118. package/src/profile/deployments.ts +3 -3
  119. package/src/profile/index.ts +30 -5
  120. package/src/profile/layout.ts +86 -89
  121. package/src/profile/load.ts +80 -47
  122. package/src/profile/pairing.ts +32 -0
  123. package/src/profile/primitives.ts +35 -1
  124. package/src/profile/registry.ts +6 -6
  125. package/src/profile/schema.ts +172 -21
  126. package/src/profile/targets.ts +21 -9
  127. package/src/profile/testing.ts +69 -2
  128. package/src/profile/workspace.ts +58 -3
  129. package/src/providers/custom/index.ts +1 -1
  130. package/src/providers/custom/load.ts +2 -3
  131. package/src/providers/identity/provider.ts +1 -1
  132. package/src/providers/memory/provider.ts +20 -2
  133. package/src/providers/setup/plan.ts +1 -1
  134. package/src/providers/slack/index.ts +2 -2
  135. package/src/registry/policy-bridge.ts +33 -11
  136. package/src/registry/reconcile.ts +4 -4
  137. package/src/server/authorization.ts +94 -0
  138. package/src/server/edge.ts +14 -1
  139. package/src/server/endpoint.ts +85 -104
  140. package/src/server/generation.ts +10 -1
  141. package/src/server/harness.ts +71 -13
  142. package/src/server/index.ts +31 -0
  143. package/src/server/mcp/build.ts +20 -1
  144. package/src/server/mcp/client-info.ts +54 -0
  145. package/src/server/mcp/guide.ts +120 -0
  146. package/src/server/mcp/instructions.ts +1 -1
  147. package/src/server/mcp/prompts.ts +7 -3
  148. package/src/server/mcp/resources.ts +16 -8
  149. package/src/server/mcp/tools.ts +9 -3
  150. package/src/server/mcp/visibility.ts +18 -3
  151. package/src/server/oauth.ts +29 -75
  152. package/src/server/read/credential.ts +134 -0
  153. package/src/server/read/deployed.ts +56 -0
  154. package/src/server/read/listener.ts +54 -0
  155. package/src/server/read/open.ts +101 -0
  156. package/src/server/read/routes.ts +247 -0
  157. package/src/server/read/state.ts +171 -0
@@ -1,9 +1,17 @@
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
+ } from '#profile';
4
11
  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';
12
+ import { announce, announceWorkspace, emit, ok, print, style, warn } from '../output.ts';
13
+ import { recordConfigChange } from './../audit-change.ts';
14
+ import { openWorkspaceRuntime, type GlobalFlags } from '../runtime.ts';
7
15
  import { nextAfterEdit, publishProfileEdit } from '../publish.ts';
8
16
  import { confirm, isInteractive } from '../prompt.ts';
9
17
 
@@ -44,18 +52,20 @@ export interface Disconnected {
44
52
  readonly credential: string | null;
45
53
  /** Set when the credential was left because something else still needs it. */
46
54
  readonly credentialSharedWith: readonly string[];
55
+ /**
56
+ * The profiles that lost a grant on this connection.
57
+ *
58
+ * Reported rather than counted, because a connection belongs to the workspace
59
+ * (ADR-057) and the profiles that lose one are not the profile the command was
60
+ * run against. Empty means it was granted by nobody, which is a legitimate
61
+ * state for an account connected and not yet used.
62
+ */
63
+ readonly ungranted: readonly string[];
64
+ /** Connections left in the workspace, not in the profile. */
47
65
  readonly remaining: number;
48
66
  readonly published: string;
49
67
  }
50
68
 
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
69
 
60
70
  export interface DisconnectFlags extends GlobalFlags {
61
71
  readonly yes?: boolean | undefined;
@@ -63,9 +73,6 @@ export interface DisconnectFlags extends GlobalFlags {
63
73
  readonly json?: boolean | undefined;
64
74
  }
65
75
 
66
- export interface RelabelFlags extends GlobalFlags {
67
- readonly json?: boolean | undefined;
68
- }
69
76
 
70
77
  /**
71
78
  * Find the one connection a key names.
@@ -75,45 +82,64 @@ export interface RelabelFlags extends GlobalFlags {
75
82
  * is nothing to choose here, and a bare `gmail` with two accounts declared would
76
83
  * be a command guessing which one to throw away.
77
84
  */
78
- function locate(config: Config, key: string, profile: string): Located {
85
+ export function locate(
86
+ connections: readonly ConnectionConfig[],
87
+ key: string,
88
+ workspace: string,
89
+ ): Located {
79
90
  if (!key.includes('.')) {
80
- const matches = config.connections.filter((one) => one.provider === key);
91
+ const matches = connections.filter((one) => one.provider === key);
81
92
  throw new Error(
82
93
  `"${key}" names a provider, not a connection.\n` +
83
94
  (matches.length > 0
84
- ? ` This profile declares ${matches.map((one) => `${one.provider}.${one.id}`).join(', ')}.\n`
95
+ ? ` This workspace holds ${matches.map((one) => `${one.provider}.${one.id}`).join(', ')}.\n`
85
96
  : '') +
86
- ` Run: lanes link status --profile ${profile}`,
97
+ ` Run: lanes link connection list --workspace ${workspace}`,
87
98
  );
88
99
  }
89
100
 
90
- const index = config.connections.findIndex((one) => `${one.provider}.${one.id}` === key);
101
+ const index = connections.findIndex((one) => `${one.provider}.${one.id}` === key);
91
102
  if (index === -1) {
92
103
  throw new Error(
93
- `Profile "${profile}" does not declare "${key}".\n` +
94
- ` Run: lanes link status --profile ${profile}`,
104
+ `Workspace "${workspace}" holds no connection "${key}".\n` +
105
+ ` Run: lanes link connection list --workspace ${workspace}`,
95
106
  );
96
107
  }
97
108
 
98
- return { index, connection: config.connections[index] as ConnectionConfig };
109
+ return { index, connection: connections[index] as ConnectionConfig };
99
110
  }
100
111
 
101
112
  /**
102
- * Refuse for a reserved provider, and say what to do instead.
113
+ * Refuse to remove the last instance of a built-in surface, and say why.
114
+ *
115
+ * A reserved provider is a connection like any other now (ADR-059), so
116
+ * `disconnect memory.acme` is a real thing to do and it works: the instance goes,
117
+ * every profile's grant on it goes, and the bytes stay where `rm -r` can find
118
+ * them.
119
+ *
120
+ * The *last* one is different, and refusing is honesty rather than caution. The
121
+ * owner layer arrives granted and is repaired back into place on the next
122
+ * `start`, `connect` or `deploy` (ADR-050), so removing the only `memory`
123
+ * connection succeeds, prints a success line, and is undone by the next command
124
+ * the operator runs. A command that reports a change the system immediately
125
+ * reverses is worse than one that refuses.
103
126
  *
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.
127
+ * `deny` is the way off, and it always wasthe same line ADR-050 documents.
110
128
  */
111
- function refuseReserved(key: string, provider: string, path: string): void {
129
+ function refuseLastReserved(
130
+ key: string,
131
+ provider: string,
132
+ connections: readonly ConnectionConfig[],
133
+ ): void {
112
134
  if (!RESERVED_PROVIDER_IDS.includes(provider)) return;
135
+ if (connections.filter((one) => one.provider === provider).length > 1) return;
136
+
113
137
  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.`,
138
+ `"${key}" is the only ${provider} connection in this workspace.\n` +
139
+ ` The owner layer is repaired back on the next start, connect or deploy, so removing it\n` +
140
+ ` would report a change that the next command undoes.\n` +
141
+ ` To take the surface away from a profile, deny it:\n` +
142
+ ` lanes link policy deny "${provider}.*" --connection ${key} --profile <name>`,
117
143
  );
118
144
  }
119
145
 
@@ -128,97 +154,89 @@ function refuseReserved(key: string, provider: string, path: string): void {
128
154
  * `unauthorized` for a `connect` nobody ran.
129
155
  */
130
156
  export function connectionsSharingCredential(
131
- config: Config,
157
+ connections: readonly ConnectionConfig[],
132
158
  ref: string,
133
159
  exceptIndex: number,
134
160
  manifestFor: (provider: string) => Parameters<typeof credentialRefFor>[1],
135
161
  ): string[] {
136
- return config.connections
162
+ return connections
137
163
  .filter((one, i) => i !== exceptIndex && credentialRefFor(one, manifestFor(one.provider)) === ref)
138
164
  .map((one) => `${one.provider}.${one.id}`);
139
165
  }
140
166
 
141
167
  /**
142
- * Take back the allow rules that named the provider, once nothing declares it.
168
+ * Take the grant row back from a profile that named this connection.
143
169
  *
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.
170
+ * Exact, where this used to be a search. A rule named a *capability*, so
171
+ * disconnecting the last Gmail meant hunting every rule whose capability began
172
+ * `gmail.` and deciding whether it was now dangling and getting that wrong
173
+ * either left a config the loader refuses or silently dropped a rule about an
174
+ * account that was still there. A grant names the connection (ADR-058), so
175
+ * removing it is finding one row.
151
176
  *
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.
177
+ * Symmetry is still the argument. `connect` writes the connection and the
178
+ * profiles that asked for it get a grant; a connection that goes takes its
179
+ * grants with it, because a grant naming nothing is what
180
+ * `assertGrantsResolve` refuses at load.
156
181
  *
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.
182
+ * Returns whether anything was removed, so the caller can report which profiles
183
+ * it touched rather than claiming all of them.
166
184
  */
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;
185
+ function dropGrantRow(document: ConfigDocument, key: string): boolean {
186
+ const rows = document.getIn(['grants']) as { items?: unknown[] } | null;
177
187
  const doomed: number[] = [];
178
188
 
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);
189
+ (rows?.items ?? []).forEach((_row, at) => {
190
+ if (document.getIn(['grants', at, 'connection']) === key) doomed.push(at);
186
191
  });
187
192
 
188
193
  // 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);
194
+ for (const at of doomed.reverse()) document.removeFrom(['grants'], at);
195
+ return doomed.length > 0;
190
196
  }
191
197
 
192
198
  export async function removeConnection(
193
199
  key: string,
194
200
  flags: DisconnectFlags,
195
201
  ): Promise<{ resolution: Resolution; disconnected: Disconnected } | null> {
196
- const runtime = await openRuntime(flags);
202
+ const runtime = await openWorkspaceRuntime(flags);
197
203
 
198
204
  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);
205
+ const { resolution, target } = runtime;
206
+ const root = resolution.workspaceRoot;
207
+
208
+ const connectionsFile = await readConnections(root);
209
+ const located = locate(connectionsFile.connections, key, target);
210
+
211
+ refuseLastReserved(key, located.connection.provider, connectionsFile.connections);
202
212
 
203
- refuseReserved(key, located.connection.provider, document.path);
213
+ // Which profiles lose a grant, computed before anything is asked, because it
214
+ // is the fact that decides whether this is a small change or a large one. A
215
+ // connection belongs to the workspace now (ADR-057), so disconnecting it
216
+ // reaches every profile that named it — including ones the operator is not
217
+ // thinking about, which is exactly why the confirmation lists them.
218
+ const affected = await profilesGranting(root, key);
204
219
 
205
- if (!(await confirmed(key, flags))) return null;
220
+ if (!(await confirmed(key, affected, flags))) return null;
206
221
 
207
222
  const manifestFor = (provider: string) => runtime.registry.get(provider)?.manifest;
208
223
  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();
224
+ const shared = ref
225
+ ? connectionsSharingCredential(connectionsFile.connections, ref, located.index, manifestFor)
226
+ : [];
227
+
228
+ // Grants first, then the connection. That order is the one the loader can
229
+ // survive halfway through: a profile granting a connection that is gone is
230
+ // refused at load, while a connection nothing grants is merely unused. So if
231
+ // the process dies between the two writes, the workspace still opens.
232
+ for (const profile of affected) {
233
+ const document = await ConfigDocument.openKey(root, `profiles/${profile}.yaml`);
234
+ if (dropGrantRow(document, key)) await document.save();
235
+ }
236
+
237
+ const connectionsDocument = await ConfigDocument.openKey(root, CONNECTIONS_FILE);
238
+ connectionsDocument.removeFrom(['connections'], located.index);
239
+ await connectionsDocument.save();
222
240
 
223
241
  let removed: string | null = null;
224
242
  if (ref && shared.length === 0 && flags.keepCredential !== true) {
@@ -226,6 +244,21 @@ export async function removeConnection(
226
244
  removed = ref;
227
245
  }
228
246
 
247
+ // `profiles` is the half of this that is easy to lose. A disconnect takes
248
+ // the account away from every profile that named it, and a row saying only
249
+ // "gmail.work removed" would leave a reader to work out which agents
250
+ // stopped being able to reach it.
251
+ await recordConfigChange(runtime.config, root, target, {
252
+ capability: 'config.connection.remove',
253
+ scope: target,
254
+ connection: key,
255
+ arguments: {
256
+ account: located.connection.account,
257
+ profiles: affected,
258
+ credentialRemoved: removed !== null,
259
+ },
260
+ });
261
+
229
262
  return {
230
263
  resolution,
231
264
  disconnected: {
@@ -235,8 +268,21 @@ export async function removeConnection(
235
268
  account: located.connection.account,
236
269
  credential: removed,
237
270
  credentialSharedWith: shared,
238
- remaining: config.connections.length - 1,
239
- published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
271
+ remaining: connectionsFile.connections.length - 1,
272
+ ungranted: affected,
273
+ published: nextAfterEdit(
274
+ await publishProfileEdit({
275
+ resolution,
276
+ config: runtime.config,
277
+ target,
278
+ // Every profile that lost a grant, not just the one this ran under.
279
+ // Publishing one left the bucket with a `connections.yaml` missing
280
+ // the connection and a sibling still granting it — which
281
+ // `assertGrantsResolve` refuses at load, so `openReconciled` skipped
282
+ // that profile and it silently stopped being served.
283
+ touched: [...new Set([resolution.profile, ...affected])],
284
+ }),
285
+ ),
240
286
  },
241
287
  };
242
288
  } finally {
@@ -244,10 +290,34 @@ export async function removeConnection(
244
290
  }
245
291
  }
246
292
 
293
+ /**
294
+ * Every profile in the workspace whose grants name this connection.
295
+ *
296
+ * Read from the files rather than from the open runtime, which knows about one
297
+ * profile. Reading them all is the point: the operator named a connection, and
298
+ * the set of profiles that lose something is not visible from the one they
299
+ * happen to have selected.
300
+ */
301
+ async function profilesGranting(root: string, key: string): Promise<string[]> {
302
+ const names = await listProfiles(root);
303
+ const granting: string[] = [];
304
+
305
+ for (const name of names) {
306
+ const { config } = await loadProfileConfig(root, name);
307
+ if (config.grants.some((grant) => grant.connection === key)) granting.push(name);
308
+ }
309
+
310
+ return granting;
311
+ }
312
+
247
313
  /** A plain y/N. Not `profile remove`'s type-the-name, which guards the
248
314
  * destruction of whole stores; this takes back one authorisation that `connect`
249
315
  * can grant again. */
250
- async function confirmed(key: string, flags: DisconnectFlags): Promise<boolean> {
316
+ async function confirmed(
317
+ key: string,
318
+ affected: readonly string[],
319
+ flags: DisconnectFlags,
320
+ ): Promise<boolean> {
251
321
  if (flags.yes === true) return true;
252
322
  if (!isInteractive()) {
253
323
  throw new Error(
@@ -255,8 +325,18 @@ async function confirmed(key: string, flags: DisconnectFlags): Promise<boolean>
255
325
  ` Pass --yes to proceed.`,
256
326
  );
257
327
  }
328
+
329
+ // The profiles are named in the question rather than printed above it. A
330
+ // connection is the workspace's now, so "disconnect gmail.personal" can take
331
+ // the account away from a profile the operator is not looking at, and the one
332
+ // place that is certain to be read is the line they have to answer.
333
+ const reach =
334
+ affected.length === 0
335
+ ? 'no profile grants it'
336
+ : `${affected.length} profile(s) lose it: ${affected.join(', ')}`;
337
+
258
338
  // Defaults to no: this deletes a credential, and a stray return should not.
259
- return confirm(`Disconnect ${key} and delete its credential?`, false);
339
+ return confirm(`Disconnect ${key} and delete its credential? (${reach})`, false);
260
340
  }
261
341
 
262
342
  export async function disconnect(key: string | undefined, flags: DisconnectFlags): Promise<void> {
@@ -270,7 +350,7 @@ export async function disconnect(key: string | undefined, flags: DisconnectFlags
270
350
  const { resolution, disconnected: result } = outcome;
271
351
 
272
352
  return emit(flags.json, result, () => {
273
- announce(resolution);
353
+ announceWorkspace(resolution);
274
354
  print(ok(`disconnected ${style.bold(result.key)}${result.account ? ` (${result.account})` : ''}`));
275
355
 
276
356
  if (result.credential) {
@@ -285,7 +365,10 @@ export async function disconnect(key: string | undefined, flags: DisconnectFlags
285
365
  );
286
366
  }
287
367
 
288
- print(style.dim(` ${result.remaining} connection(s) left in this profile.`));
368
+ if (result.ungranted.length > 0) {
369
+ print(` ungranted ${result.ungranted.join(', ')}`);
370
+ }
371
+ print(style.dim(` ${result.remaining} connection(s) left in this workspace.`));
289
372
  // The state record survives on purpose, and the next reconcile is what marks
290
373
  // it disabled. Saying so stops "it is still in `status`" reading as a failure.
291
374
  print(
@@ -296,69 +379,3 @@ export async function disconnect(key: string | undefined, flags: DisconnectFlags
296
379
  if (result.published) print(style.dim(` ${result.published}`));
297
380
  })
298
381
  }
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
+ }