@lanes-sh/link 0.7.2 → 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 (156) 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/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 +88 -87
  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 +4 -2
  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 +182 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +21 -9
  42. package/src/cli/commands/knowledge/index.ts +46 -79
  43. package/src/cli/commands/knowledge/migrate.ts +74 -13
  44. package/src/cli/commands/knowledge/show.ts +92 -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 +29 -12
  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 +52 -4
  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/shared.ts +13 -2
  66. package/src/cli/commands/owner/skills.ts +28 -8
  67. package/src/cli/commands/profile/removal.ts +79 -76
  68. package/src/cli/commands/profile/remove.ts +16 -1
  69. package/src/cli/commands/profile.ts +46 -10
  70. package/src/cli/commands/relabel.ts +112 -0
  71. package/src/cli/commands/secrets.ts +34 -12
  72. package/src/cli/commands/set-workspace.ts +96 -0
  73. package/src/cli/commands/setup.ts +2 -2
  74. package/src/cli/commands/sync.ts +8 -8
  75. package/src/cli/commands/target.ts +9 -7
  76. package/src/cli/commands/update.ts +58 -17
  77. package/src/cli/config-edit.ts +75 -140
  78. package/src/cli/config-migrate.ts +82 -64
  79. package/src/cli/config-repair.ts +89 -32
  80. package/src/cli/config-templates.ts +198 -0
  81. package/src/cli/contract3-data.ts +328 -0
  82. package/src/cli/contract3-shape.ts +186 -0
  83. package/src/cli/contract3.ts +282 -0
  84. package/src/cli/endpoint-url.ts +1 -1
  85. package/src/cli/lanes.ts +25 -1
  86. package/src/cli/main.ts +89 -14
  87. package/src/cli/migrate-plan.ts +12 -6
  88. package/src/cli/output.ts +34 -1
  89. package/src/cli/publish.ts +5 -2
  90. package/src/cli/runtime/open.ts +63 -98
  91. package/src/cli/runtime/registry.ts +6 -7
  92. package/src/cli/runtime/stores.ts +53 -0
  93. package/src/cli/runtime/types.ts +106 -0
  94. package/src/cli/runtime/vault.ts +19 -4
  95. package/src/cli/runtime/workspace.ts +60 -0
  96. package/src/cli/runtime.ts +2 -1
  97. package/src/cli/selection-require.ts +44 -13
  98. package/src/cli/selection.ts +127 -145
  99. package/src/cli/usage.ts +34 -18
  100. package/src/cli/workspace-migrate.ts +125 -16
  101. package/src/connectivity/manifest/provider.ts +3 -1
  102. package/src/connectivity/manifest/requirements.ts +1 -1
  103. package/src/deployments/bind.ts +1 -1
  104. package/src/deployments/deploy.ts +36 -27
  105. package/src/deployments/gcp/bucket.ts +18 -7
  106. package/src/deployments/gcp/provision.ts +7 -7
  107. package/src/deployments/prepare.ts +72 -24
  108. package/src/deployments/record.ts +1 -1
  109. package/src/deployments/report.ts +2 -2
  110. package/src/deployments/serving.ts +15 -74
  111. package/src/deployments/target.ts +15 -15
  112. package/src/deployments/upload.ts +46 -22
  113. package/src/dispatch/deps.ts +88 -0
  114. package/src/dispatch/dispatch.ts +21 -62
  115. package/src/policy/index.ts +47 -15
  116. package/src/profile/connections.ts +183 -0
  117. package/src/profile/deployments.ts +3 -3
  118. package/src/profile/index.ts +30 -5
  119. package/src/profile/layout.ts +86 -89
  120. package/src/profile/load.ts +80 -47
  121. package/src/profile/pairing.ts +32 -0
  122. package/src/profile/primitives.ts +35 -1
  123. package/src/profile/registry.ts +6 -6
  124. package/src/profile/schema.ts +172 -21
  125. package/src/profile/targets.ts +21 -9
  126. package/src/profile/testing.ts +69 -2
  127. package/src/profile/workspace.ts +58 -3
  128. package/src/providers/custom/index.ts +1 -1
  129. package/src/providers/custom/load.ts +2 -3
  130. package/src/providers/identity/provider.ts +1 -1
  131. package/src/providers/memory/provider.ts +20 -2
  132. package/src/providers/setup/plan.ts +1 -1
  133. package/src/providers/slack/index.ts +2 -2
  134. package/src/registry/policy-bridge.ts +33 -11
  135. package/src/registry/reconcile.ts +4 -4
  136. package/src/server/authorization.ts +94 -0
  137. package/src/server/edge.ts +14 -1
  138. package/src/server/endpoint.ts +85 -104
  139. package/src/server/generation.ts +10 -1
  140. package/src/server/harness.ts +71 -13
  141. package/src/server/index.ts +31 -0
  142. package/src/server/mcp/build.ts +20 -1
  143. package/src/server/mcp/client-info.ts +54 -0
  144. package/src/server/mcp/guide.ts +120 -0
  145. package/src/server/mcp/instructions.ts +1 -1
  146. package/src/server/mcp/prompts.ts +7 -3
  147. package/src/server/mcp/resources.ts +16 -8
  148. package/src/server/mcp/tools.ts +9 -3
  149. package/src/server/mcp/visibility.ts +18 -3
  150. package/src/server/oauth.ts +29 -109
  151. package/src/server/read/credential.ts +134 -0
  152. package/src/server/read/deployed.ts +56 -0
  153. package/src/server/read/listener.ts +54 -0
  154. package/src/server/read/open.ts +101 -0
  155. package/src/server/read/routes.ts +247 -0
  156. package/src/server/read/state.ts +171 -0
@@ -0,0 +1,288 @@
1
+ import { readSession } from '#auth/lanes/session.ts';
2
+ import { recordConfigChange } from '../audit-change.ts';
3
+ import {
4
+ ConfigError,
5
+ readRegistry,
6
+ resolveTargetWorkspace,
7
+ resolveWorkspaceRoot,
8
+ } from '#profile';
9
+ import { ConfigDocument } from '../config-edit.ts';
10
+ import { describeMember, workspaceMembers } from '#auth/lanes/members.ts';
11
+ import { announce, emit, heading, ok, print, style, table, warn } from '../output.ts';
12
+ import { nextAfterEdit, publishProfileEdit } from '../publish.ts';
13
+ import { resolveProfile, type GlobalFlags } from '../runtime.ts';
14
+
15
+ /**
16
+ * `lanes link profile members` — who may consume a profile (ADR-060).
17
+ *
18
+ * CLI-only, like everything that authorises future agent behaviour (ADR-007),
19
+ * and for the sharpest version of that argument: an agent able to edit this
20
+ * could add itself.
21
+ *
22
+ * **This is a selection from the Lanes workspace, not a list beside it.**
23
+ * Membership is managed on the dashboard — invited, accepted, given a role,
24
+ * removed — and a workspace bound with `lanes_workspace:` is asked who it holds.
25
+ * Somebody added there shows up here the moment they accept, and granting them a
26
+ * profile is a local edit naming a subject the server already vouches for.
27
+ *
28
+ * For `local` there is no such list, so the only subject accepted is the
29
+ * signed-in one — a local workspace delegating to a stranger is a typo, not a
30
+ * use case.
31
+ */
32
+
33
+ export interface MemberRow {
34
+ readonly subject: string;
35
+ readonly role: 'owner' | 'member';
36
+ /** Whether this is the identity running the command. */
37
+ readonly you: boolean;
38
+ }
39
+
40
+ export async function membersList(flags: GlobalFlags & { json?: boolean }): Promise<void> {
41
+ const { resolution, config, target } = await resolveProfile(flags);
42
+ const session = await readSession();
43
+
44
+ const rows: MemberRow[] = config.members.map((member) => ({
45
+ subject: member.subject,
46
+ role: member.role,
47
+ you: member.subject === session?.subject,
48
+ }));
49
+
50
+ // The workspace's own people, so this listing answers both halves of the
51
+ // question: who may use this profile, and who *could* be given it. Without
52
+ // the second, adding somebody means going to the dashboard to copy a subject
53
+ // out of it.
54
+ const held = await workspaceMembers(await boundWorkspace(target));
55
+ const granted = new Set(rows.map((row) => row.subject));
56
+ const available = held.members.filter(
57
+ (member) => member.subject === null || !granted.has(member.subject),
58
+ );
59
+
60
+ return emit(
61
+ flags.json,
62
+ { profile: resolution.profile, members: rows, available: available },
63
+ () => {
64
+ announce(resolution);
65
+
66
+ if (rows.length === 0) {
67
+ // Empty is nobody, and saying so matters: the natural reading of a blank
68
+ // list is "no restriction", which is the opposite of what it means.
69
+ print(style.dim('No members. Nobody may consume this profile.'));
70
+ print(style.dim(' Add yourself with: lanes link profile members add --me'));
71
+ } else {
72
+ heading(`May consume ${resolution.profile} (${rows.length})`);
73
+ table(
74
+ rows.map((row) => [
75
+ ` ${style.bold(row.subject)}`,
76
+ row.role,
77
+ row.you ? style.dim('you') : '',
78
+ ]),
79
+ );
80
+ }
81
+
82
+ if (available.length > 0) {
83
+ heading(`In the workspace, not granted (${available.length})`);
84
+ table(
85
+ available.map((member) => [
86
+ ` ${style.bold(describeMember(member))}`,
87
+ member.role,
88
+ member.status === 'pending'
89
+ ? style.dim('invitation not accepted')
90
+ : style.dim(member.subject ?? ''),
91
+ ]),
92
+ );
93
+ }
94
+
95
+ if (held.unavailable !== null && held.unavailable !== 'this workspace is not bound to a Lanes workspace') {
96
+ print('');
97
+ print(style.dim(`Could not list the workspace's members: ${held.unavailable}`));
98
+ }
99
+ },
100
+ );
101
+ }
102
+
103
+ /** The Lanes workspace this one is bound to, if any. */
104
+ async function boundWorkspace(target: string): Promise<string | undefined> {
105
+ // **The workspace that declares the target, which is not this machine once it
106
+ // is deployed.** `lanes_workspace` is a declaration field, and
107
+ // `recordDeployment` writes the declaration into the bucket while leaving
108
+ // this machine a pointer carrying only `at`, `primary` and the deploy stamps.
109
+ // So reading it here found nothing the moment a target was deployed: binding
110
+ // a workspace stopped taking effect, `members add` refused everyone but the
111
+ // person at the keyboard, and the remedy it printed — add `lanes_workspace:`
112
+ // under `workspaces.<target>` — named the pointer entry, which the next
113
+ // deploy overwrites. A declaration resolves back to this root unchanged.
114
+ const local = resolveWorkspaceRoot();
115
+ const registry = await readRegistry(await resolveTargetWorkspace(local, target).catch(() => local));
116
+ return registry[target]?.lanes_workspace;
117
+ }
118
+
119
+ export interface MembersFlags extends GlobalFlags {
120
+ readonly json?: boolean | undefined;
121
+ /** Add the signed-in subject, rather than one typed out. */
122
+ readonly me?: boolean | undefined;
123
+ readonly role?: string | undefined;
124
+ }
125
+
126
+ export async function membersAdd(
127
+ subject: string | undefined,
128
+ flags: MembersFlags,
129
+ ): Promise<void> {
130
+ const { resolution, config, target } = await resolveProfile(flags);
131
+ const session = await readSession();
132
+
133
+ const wanted = flags.me === true ? session?.subject : subject;
134
+
135
+ if (wanted === undefined) {
136
+ throw new ConfigError(
137
+ flags.me === true
138
+ ? 'Not signed in, so there is no "me" to add. Run: lanes auth login'
139
+ : 'Which subject? Run: lanes link profile members add <subject> --profile <name>\n' +
140
+ ' Or add yourself: lanes link profile members add --me --profile <name>',
141
+ );
142
+ }
143
+
144
+ const role = flags.role === 'owner' ? 'owner' : 'member';
145
+
146
+ if (config.members.some((member) => member.subject === wanted)) {
147
+ print(style.dim(`${resolution.profile} already lists ${wanted}.`));
148
+ return;
149
+ }
150
+
151
+ await assertDelegatable(wanted, target, session?.subject);
152
+
153
+ const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
154
+ document.addTo(['members'], { subject: wanted, role }, { inline: true });
155
+ await document.save();
156
+
157
+ await recordConfigChange(
158
+ config,
159
+ resolution.workspaceRoot,
160
+ target,
161
+ {
162
+ capability: 'config.member.add',
163
+ scope: resolution.profile,
164
+ arguments: { subject: wanted, role },
165
+ },
166
+ (note) => print(warn(note)),
167
+ );
168
+
169
+ const published = nextAfterEdit(await publishProfileEdit({ resolution, config, target }));
170
+
171
+ return emit(flags.json, { profile: resolution.profile, subject: wanted, role }, () => {
172
+ announce(resolution);
173
+ print(ok(`${wanted} may now consume ${style.bold(resolution.profile)} as ${role}`));
174
+ print(style.dim(' They reach exactly what this profile grants, and nothing else.'));
175
+ if (published) print(style.dim(` ${published}`));
176
+ });
177
+ }
178
+
179
+ export async function membersRemove(
180
+ subject: string | undefined,
181
+ flags: MembersFlags,
182
+ ): Promise<void> {
183
+ const { resolution, config, target } = await resolveProfile(flags);
184
+ if (!subject) throw new ConfigError('Which subject? Run: lanes link profile members list');
185
+
186
+ const at = config.members.findIndex((member) => member.subject === subject);
187
+ if (at === -1) {
188
+ print(style.dim(`${resolution.profile} does not list ${subject}.`));
189
+ return;
190
+ }
191
+
192
+ const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
193
+ document.removeFrom(['members'], at);
194
+ await document.save();
195
+
196
+ await recordConfigChange(
197
+ config,
198
+ resolution.workspaceRoot,
199
+ target,
200
+ { capability: 'config.member.remove', scope: resolution.profile, arguments: { subject } },
201
+ (note) => print(warn(note)),
202
+ );
203
+
204
+ const published = nextAfterEdit(await publishProfileEdit({ resolution, config, target }));
205
+
206
+ return emit(flags.json, { profile: resolution.profile, subject }, () => {
207
+ announce(resolution);
208
+ print(ok(`${subject} may no longer consume ${style.bold(resolution.profile)}`));
209
+
210
+ // The half that is not obvious, and is the difference between this and a
211
+ // session manager. A token already issued keeps working until it expires:
212
+ // membership is read when one is minted, not on every call (ADR-060).
213
+ print(
214
+ style.dim(
215
+ ' A token they already hold keeps working until it expires.\n' +
216
+ ` To close that window now: lanes link token rotate --workspace ${target}`,
217
+ ),
218
+ );
219
+ if (published) print(style.dim(` ${published}`));
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Whether this workspace may delegate to that subject.
225
+ *
226
+ * The check exists because the alternative is a profile listing a subject
227
+ * nobody can produce a token for — which reads exactly like a working
228
+ * delegation until the person tries to use it.
229
+ *
230
+ * Bound to a Lanes workspace, the answer is the workspace's own member list:
231
+ * somebody added on the dashboard is delegatable here, and nobody else is.
232
+ * Unbound, there is no list to ask, so the only verifiable subject is the one at
233
+ * the keyboard.
234
+ */
235
+ async function assertDelegatable(
236
+ subject: string,
237
+ target: string,
238
+ signedIn: string | undefined,
239
+ ): Promise<void> {
240
+ const bound = await boundWorkspace(target);
241
+
242
+ if (bound === undefined) {
243
+ if (subject === signedIn) return;
244
+
245
+ throw new ConfigError(
246
+ `Workspace "${target}" is not bound to a Lanes workspace, so it can only delegate to you.\n` +
247
+ ' Add yourself with: lanes link profile members add --me\n' +
248
+ ' To delegate to somebody else, bind the workspace:\n' +
249
+ ` lanes_workspace: <id> # in lanes-link.yaml, under workspaces.${target}\n` +
250
+ ' Run "lanes auth workspaces" for the ids you belong to.',
251
+ );
252
+ }
253
+
254
+ const held = await workspaceMembers(bound);
255
+
256
+ // Unreachable rather than empty. Refusing on a network failure would make a
257
+ // local edit depend on our uptime, and the endpoint verifies the subject when
258
+ // it mints a token regardless — so this warns and proceeds.
259
+ if (held.unavailable !== null) {
260
+ print(
261
+ style.dim(
262
+ ` Could not check the workspace's members (${held.unavailable}), so this was not verified.`,
263
+ ),
264
+ );
265
+ return;
266
+ }
267
+
268
+ const match = held.members.find((member) => member.subject === subject);
269
+ if (match !== undefined) return;
270
+
271
+ // A pending invitation is the interesting refusal: the person exists, the
272
+ // operator can see them on the dashboard, and there is still no subject to
273
+ // write down. Saying "not a member" would send them to add somebody who is
274
+ // already there.
275
+ const pending = held.members.filter((member) => member.status === 'pending');
276
+
277
+ throw new ConfigError(
278
+ `${subject} is not a member of the Lanes workspace behind "${target}".\n` +
279
+ (held.members.length > 0
280
+ ? ` It holds: ${held.members.map(describeMember).join(', ')}\n`
281
+ : ' It holds nobody yet.\n') +
282
+ (pending.length > 0
283
+ ? ` ${pending.map(describeMember).join(', ')} ${pending.length === 1 ? 'has' : 'have'} not ` +
284
+ 'accepted the invitation yet, so there is no subject for them to be granted.\n'
285
+ : '') +
286
+ ' Invite them on the dashboard first: https://lanes.sh/dashboard/settings/members',
287
+ );
288
+ }
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { basename } from 'node:path';
3
3
  import { ownerPrincipal } from '#auth';
4
4
  import { announce, print, style } from '../../output.ts';
5
- import { openRuntime, type GlobalFlags } from '../../runtime.ts';
5
+ import { grantedConnections, openRuntime, type GlobalFlags } from '../../runtime.ts';
6
6
 
7
7
  /**
8
8
  * `lanes link attach <file> --connection <provider>.<account>`
@@ -44,11 +44,11 @@ export async function attachFile(
44
44
 
45
45
  // Checked against config rather than trusted, so a typo names the mistake
46
46
  // instead of staging into a namespace nothing will ever read.
47
- const known = runtime.config.connections.some(
47
+ const known = grantedConnections(runtime).some(
48
48
  (connection) => connection.provider === providerId && connection.id === connectionId,
49
49
  );
50
50
  if (!known) {
51
- const have = runtime.config.connections.map((c) => `${c.provider}.${c.id}`);
51
+ const have = grantedConnections(runtime).map((c) => `${c.provider}.${c.id}`);
52
52
  throw new Error(
53
53
  `No connection "${flags.connection}" in this profile. Configured: ${have.join(', ') || 'none'}.`,
54
54
  );
@@ -1,5 +1,5 @@
1
- import { announce, print, style, table } from '../../output.ts';
2
- import { openRuntime, type GlobalFlags } from '../../runtime.ts';
1
+ import { announceWorkspace, print, style, table } from '../../output.ts';
2
+ import { openWorkspaceRuntime, type GlobalFlags } from '../../runtime.ts';
3
3
 
4
4
  /** `lanes link audit tail` — the log the dispatcher writes, rendered for a terminal. */
5
5
 
@@ -15,11 +15,11 @@ export async function auditTail(
15
15
  }
16
16
  const markdown = flags.format === 'md';
17
17
 
18
- const runtime = await openRuntime(flags);
18
+ const runtime = await openWorkspaceRuntime(flags);
19
19
  try {
20
20
  // The banner is chrome around a table; in Markdown it is a stray line in
21
21
  // the middle of a document someone is pasting somewhere.
22
- if (!markdown) announce(runtime.resolution);
22
+ if (!markdown) announceWorkspace(runtime.resolution);
23
23
 
24
24
  const events = await runtime.audit.tail({
25
25
  limit: flags.limit ?? 25,
@@ -35,7 +35,11 @@ export async function auditTail(
35
35
  // The log lives in the database; this is a rendering of it, not a second
36
36
  // copy of it. See ADR-013 — storage format and display format are not the
37
37
  // same decision.
38
- print(`# Audit ${runtime.resolution.profile}\n`);
38
+ // The workspace, not the profile. Contract 3 gave the workspace one
39
+ // chain covering every profile in it, and a heading naming whichever
40
+ // profile happened to open the store described a log that does not
41
+ // exist. Each row carries its own profile.
42
+ print(`# Audit — ${runtime.resolution.target}\n`);
39
43
  print('| Time | Result | Capability | Connection | Duration | Arguments |');
40
44
  print('|---|---|---|---|---|---|');
41
45
  for (const event of events) {
@@ -81,9 +85,9 @@ export async function auditTail(
81
85
  * can see how much was looked at.
82
86
  */
83
87
  export async function auditVerify(flags: GlobalFlags): Promise<void> {
84
- const runtime = await openRuntime(flags);
88
+ const runtime = await openWorkspaceRuntime(flags);
85
89
  try {
86
- announce(runtime.resolution);
90
+ announceWorkspace(runtime.resolution);
87
91
  const report = await runtime.audit.verify();
88
92
 
89
93
  const scope = `${report.events} event${report.events === 1 ? '' : 's'} across ${report.runs} run${report.runs === 1 ? '' : 's'}`;
@@ -1,8 +1,15 @@
1
+ import type { ConnectionConfig } from '#profile';
1
2
  import { credentialResolver, ReauthRequired } from '#connectivity/auth/index.ts';
2
3
  import type { ResolvedCredential } from '#connectivity/auth/credential.ts';
3
4
  import { credentialRefFor } from '#registry';
4
- import { announce, emit, fail, ok, print, warn } from '../../output.ts';
5
- import { openRuntime, type GlobalFlags, type Runtime } from '../../runtime.ts';
5
+ import { announceWorkspace, emit, fail, ok, print, warn } from '../../output.ts';
6
+ import { readConnections } from '#profile';
7
+ import {
8
+ grantedConnections,
9
+ openWorkspaceRuntime,
10
+ type GlobalFlags,
11
+ type Runtime,
12
+ } from '../../runtime.ts';
6
13
 
7
14
  /**
8
15
  * Whether each connection could still authenticate, asked rather than guessed.
@@ -146,13 +153,13 @@ export interface AuthFlags extends GlobalFlags {
146
153
  */
147
154
  export async function probeConnections(
148
155
  runtime: Runtime,
149
- connections: readonly Runtime['config']['connections'][number][],
156
+ connections: readonly ConnectionConfig[],
150
157
  forSelection: (command: string) => string,
151
158
  ): Promise<ConnectionAuth[]> {
152
159
  const resolve = credentialResolver(runtime.registry, runtime.credentials);
153
160
 
154
161
  const probe = async (
155
- connection: Runtime['config']['connections'][number],
162
+ connection: ConnectionConfig,
156
163
  ): Promise<ConnectionAuth> => {
157
164
  const key = `${connection.provider}.${connection.id}`;
158
165
  const manifest = runtime.manifestFor(connection.provider);
@@ -245,20 +252,30 @@ export async function probeConnections(
245
252
  }
246
253
 
247
254
  export async function auth(flags: AuthFlags): Promise<void> {
248
- const runtime = await openRuntime(flags);
255
+ const runtime = await openWorkspaceRuntime(flags);
249
256
 
250
257
  try {
251
258
  const { profile, target } = runtime.resolution;
252
- const forSelection = (command: string) => `${command} --profile ${profile} --target ${target}`;
259
+ const forSelection = (command: string) => `${command} --workspace ${target}`;
260
+
261
+ // Every connection the workspace holds, not the ones one profile grants.
262
+ // Whether an account can still authenticate is a fact about the account
263
+ // (ADR-057), and scoping it to a profile's grants meant an account that had
264
+ // just been connected could not be checked until somebody granted it —
265
+ // which is exactly the moment you want to check.
266
+ //
267
+ // `--profile` narrows to what that profile can reach, because "can the
268
+ // things this profile uses still sign in" is also a real question.
269
+ const all = flags.profile === undefined
270
+ ? (await readConnections(runtime.resolution.workspaceRoot)).connections
271
+ : grantedConnections(runtime);
253
272
 
254
273
  const wanted = flags.connection;
255
- const connections = wanted
256
- ? runtime.config.connections.filter((c) => `${c.provider}.${c.id}` === wanted)
257
- : runtime.config.connections;
274
+ const connections = wanted ? all.filter((c) => `${c.provider}.${c.id}` === wanted) : all;
258
275
 
259
276
  if (wanted && connections.length === 0) {
260
277
  throw new Error(
261
- `No connection "${wanted}" in profile ${profile}. Run: ${forSelection('lanes link status')}`,
278
+ `No connection "${wanted}" in workspace ${target}. Run: ${forSelection('lanes link connection list')}`,
262
279
  );
263
280
  }
264
281
 
@@ -270,7 +287,7 @@ export async function auth(flags: AuthFlags): Promise<void> {
270
287
  // why it cannot read `doctor`. A connection needing a person is the answer
271
288
  // this command was asked for, not a failure to produce one.
272
289
  return emit(flags.json, { profile, target, ok: needsSomeone.length === 0, connections: results }, () => {
273
- announce(runtime.resolution);
290
+ announceWorkspace(runtime.resolution);
274
291
 
275
292
  for (const result of results) {
276
293
  const line = `${result.key} — ${describe(result.verdict)}`;
@@ -1,3 +1,4 @@
1
+ import { grantedConnections } from '../../runtime.ts';
1
2
  import { ownerPrincipal } from '#auth';
2
3
  import type { DiscoveredCapability } from '#connectivity';
3
4
  import { allowedConnections } from '#policy';
@@ -49,7 +50,7 @@ export async function reportCapabilityDrift(
49
50
  const principal = ownerPrincipal(runtime.config.instance.profile).id;
50
51
 
51
52
  for (const entry of runtime.registry.list()) {
52
- const connection = runtime.config.connections.find((c) => c.provider === entry.manifest.id);
53
+ const connection = grantedConnections(runtime).find((c) => c.provider === entry.manifest.id);
53
54
  if (!connection) continue;
54
55
 
55
56
  const connector = runtime.connectorFor(entry.manifest.id, connection.id);
@@ -1,4 +1,5 @@
1
1
  import { formatPlan, planIsNoop, planReconcile } from '#registry';
2
+ import type { ConnectionConfig, SelectedConnection } from '#profile';
2
3
  import { DEFAULT_SURFACES } from '../../config-repair.ts';
3
4
  import { announce, announceProfile, emit, fail, ok, print, warn } from '../../output.ts';
4
5
  import { staleNudge } from '../../release.ts';
@@ -16,6 +17,18 @@ import { migratedContract, migratedRenamedProviders } from './migrate.ts';
16
17
  */
17
18
 
18
19
  /** Static validation only. No external calls, no database, no credentials. */
20
+ /**
21
+ * The accounts this profile actually reaches, as reconcile wants them.
22
+ *
23
+ * A profile grants a subset of the workspace's connections (ADR-057), and
24
+ * reconcile is about the ones it reaches: an account granted to nobody has no
25
+ * state to keep in step, and marking it `disabled` here would fight with the
26
+ * profile next door that does grant it.
27
+ */
28
+ function grantedConnections(runtime: { connections: readonly SelectedConnection[] }): ConnectionConfig[] {
29
+ return runtime.connections.map(({ connection }) => connection);
30
+ }
31
+
19
32
  export async function check(flags: GlobalFlags): Promise<void> {
20
33
  const { selection, config } = await resolveProfileOnly(flags);
21
34
  announceProfile(selection);
@@ -25,8 +38,10 @@ export async function check(flags: GlobalFlags): Promise<void> {
25
38
  // validates a file, and the file is the same whichever target reads it.
26
39
  print(ok(`${selection.profilePath} is valid`));
27
40
  print(
28
- ` ${config.connections.length} connection(s), ` +
29
- `${config.policy.allow.length} allow rule(s), ${config.policy.deny.length} deny rule(s)`,
41
+ ` ${config.grants.length} grant(s), ` +
42
+ `${config.grants.reduce((n, g) => n + g.allow.length, 0)} allow rule(s), ` +
43
+ `${config.grants.reduce((n, g) => n + g.deny.length, 0)} deny rule(s), ` +
44
+ `${config.members.length} member(s)`,
30
45
  );
31
46
  }
32
47
 
@@ -35,7 +50,7 @@ export async function plan(flags: GlobalFlags): Promise<void> {
35
50
  const runtime = await openRuntime(flags);
36
51
  try {
37
52
  announce(runtime.resolution);
38
- const result = await planReconcile(runtime.config, runtime.state, runtime.credentials, runtime.manifestFor);
53
+ const result = await planReconcile(grantedConnections(runtime), runtime.state, runtime.credentials, runtime.manifestFor);
39
54
  print(formatPlan(result));
40
55
  } finally {
41
56
  await runtime.close();
@@ -100,7 +115,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
100
115
 
101
116
  try {
102
117
  const forSelection = (command: string) =>
103
- `${command} --profile ${runtime.resolution.profile} --target ${runtime.resolution.target}`;
118
+ `${command} --profile ${runtime.resolution.profile} --workspace ${runtime.resolution.target}`;
104
119
 
105
120
  checks.push('config is valid');
106
121
  checks.push('state store is reachable');
@@ -134,7 +149,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
134
149
  // `probeConnections` answers it by attempting the renewal, which is the only
135
150
  // thing that actually knows. Same classifier as `lanes link auth`, so the two
136
151
  // cannot drift apart again.
137
- const probed = await probeConnections(runtime, runtime.config.connections, forSelection);
152
+ const probed = await probeConnections(runtime, grantedConnections(runtime), forSelection);
138
153
 
139
154
  for (const result of probed) {
140
155
  switch (result.verdict) {
@@ -209,7 +224,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
209
224
  }
210
225
  }
211
226
 
212
- for (const name of new Set(runtime.config.connections.map((c) => c.provider))) {
227
+ for (const name of new Set(grantedConnections(runtime).map((one) => one.provider))) {
213
228
  if (!runtime.registry.has(name)) {
214
229
  problems.push({
215
230
  kind: 'unknown_provider',
@@ -236,22 +251,25 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
236
251
  // covering it is not reported. That is the same rule the repair follows, and
237
252
  // reading it here from `policy.deny` rather than asking the repair keeps
238
253
  // `doctor` a read.
239
- const denied = (rule: string): boolean =>
240
- runtime.config.policy.deny.some(
241
- (entry) => entry.capability === '*' || entry.capability === rule,
242
- );
254
+ // A grant row *is* the connection and the rule together now (ADR-058), so
255
+ // "has a row but no rule" is a state that stopped existing — which removes
256
+ // the half-repaired case this check was originally written for. What is left
257
+ // is one question per surface: does this profile grant one, and is the grant
258
+ // more than a deny.
259
+ const grantFor = (provider: string) =>
260
+ runtime.config.grants.find((grant) => grant.connection.startsWith(`${provider}.`));
243
261
 
244
262
  const missing = DEFAULT_SURFACES.filter((provider) => {
245
- const rule = `${provider}.*`;
246
- if (denied(rule)) return false;
263
+ const grant = grantFor(provider);
264
+ if (grant === undefined) return true;
247
265
 
248
- const hasRow = runtime.config.connections.some(
249
- (connection) => connection.provider === provider,
250
- );
251
- const granted = runtime.config.policy.allow.some(
266
+ const rule = `${provider}.*`;
267
+ const denied = grant.deny.some(
252
268
  (entry) => entry.capability === '*' || entry.capability === rule,
253
269
  );
254
- return !hasRow || !granted;
270
+ if (denied) return false;
271
+
272
+ return !grant.allow.some((entry) => entry.capability === '*' || entry.capability === rule);
255
273
  });
256
274
 
257
275
  if (missing.length > 0) {
@@ -287,7 +305,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
287
305
  warnings.push({ kind: 'capability_drift', message }),
288
306
  );
289
307
 
290
- const drift = await planReconcile(runtime.config, runtime.state, runtime.credentials, runtime.manifestFor);
308
+ const drift = await planReconcile(grantedConnections(runtime), runtime.state, runtime.credentials, runtime.manifestFor);
291
309
  if (!planIsNoop(drift)) {
292
310
  warnings.push({
293
311
  kind: 'reconcile_drift',
@@ -309,7 +327,7 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
309
327
  const rotation = await unboundRotatableRefs({
310
328
  deploy: runtime.declared.deploy,
311
329
  target: runtime.target,
312
- connections: runtime.config.connections,
330
+ connections: grantedConnections(runtime),
313
331
  manifestFor: runtime.manifestFor,
314
332
  });
315
333
  if (rotation.unbound.length > 0) {
@@ -1,7 +1,10 @@
1
- import { ConfigError, resolveSelection, resolveTargetWorkspace, resolveWorkspaceRoot } from '#profile';
1
+ import {
2
+ CONNECTIONS_FILE,
3
+ listProfiles, ConfigError, resolveSelection, resolveTargetWorkspace, resolveWorkspaceRoot,
4
+ SUPPORTED_CONTRACT } from '#profile';
2
5
  import { ConfigDocument } from '../../config-edit.ts';
3
6
  import { migrateRenamedProviders, pendingRenames, shapeOf } from '../../config-migrate.ts';
4
- import { migrateWorkspace, needsMigration } from '../../workspace-migrate.ts';
7
+ import { migrateToCurrentContract } from '../../workspace-migrate.ts';
5
8
  import { emit, fail, ok, print, style, warn } from '../../output.ts';
6
9
  import { openSecretStoreFor, type GlobalFlags } from '../../runtime.ts';
7
10
 
@@ -52,16 +55,25 @@ export async function migratedRenamedProviders(
52
55
  const root = await resolveTargetWorkspace(localRoot, target).catch(() => localRoot);
53
56
 
54
57
  const selection = await resolveSelection({ profileFlag: flags.profile, root });
55
- const document = await ConfigDocument.open(root, selection.profile);
58
+
59
+ // The rename is a property of the *connection*, so it is found in the
60
+ // workspace's file (ADR-057) — and applied across every profile, because any
61
+ // of them may grant the row being renamed.
62
+ const document = await ConfigDocument.openKey(root, CONNECTIONS_FILE);
56
63
  if (pendingRenames(document).length === 0) return false;
57
64
 
65
+ const profiles: ConfigDocument[] = [];
66
+ for (const name of await listProfiles(root)) {
67
+ profiles.push(await ConfigDocument.open(root, name));
68
+ }
69
+
58
70
  // Shape-only, because the check this document fails runs after the schema.
59
71
  // Throws when it is malformed beyond a rename, which is a better sentence
60
72
  // than the referential one it would otherwise be reported under.
61
- const config = shapeOf(document);
73
+ const config = shapeOf(await ConfigDocument.open(root, selection.profile));
62
74
  const credentials = await openSecretStoreFor(config, root, target);
63
75
 
64
- const migration = await migrateRenamedProviders(document, credentials, {
76
+ const migration = await migrateRenamedProviders(document, profiles, credentials, {
65
77
  apply: flags.fix === true,
66
78
  });
67
79
 
@@ -141,9 +153,14 @@ export async function migratedContract(flags: RenameFlags, refusal: unknown): Pr
141
153
  const where = await resolveTargetWorkspace(root, target).catch(() => null);
142
154
  if (where === null) return false;
143
155
 
144
- if (!(await needsMigration(where))) return false;
156
+ // Running the migration is how "is there anything to do" is answered, rather
157
+ // than a predicate beside it. The predicate was `needsMigration`, which asks
158
+ // only about contract 1 — so a contract-2 bucket, the exact thing this
159
+ // function exists to rescue, returned false here and fell through to the
160
+ // refusal it was supposed to fix. With `--fix` absent this writes nothing.
161
+ const migration = await migrateToCurrentContract(where, { apply: flags.fix === true });
162
+ if (migration.alreadyCurrent) return false;
145
163
 
146
- const migration = await migrateWorkspace(where, { apply: flags.fix === true });
147
164
  const applied = flags.fix === true;
148
165
  const remote = where !== root;
149
166
 
@@ -166,8 +183,8 @@ export async function migratedContract(flags: RenameFlags, refusal: unknown): Pr
166
183
  print();
167
184
  print(
168
185
  applied
169
- ? ok('migrated to contract 2 — a target is declared by the workspace, not by each profile')
170
- : warn('this workspace is contract 1, and nothing here reads that any more'),
186
+ ? ok(`migrated to contract ${SUPPORTED_CONTRACT} — a workspace declares its own adapters, and holds the connections a profile grants`)
187
+ : warn(`this workspace is behind contract ${SUPPORTED_CONTRACT}, and nothing here reads that any more`),
171
188
  );
172
189
  for (const change of migration.changes) print(` ${change}`);
173
190
 
@@ -177,15 +194,15 @@ export async function migratedContract(flags: RenameFlags, refusal: unknown): Pr
177
194
  style.dim(
178
195
  applied
179
196
  ? ' The endpoint serving this bucket is running an older image and cannot read\n' +
180
- ` what was just written. Roll a new one: lanes link deploy --target ${target}`
181
- : ` lanes link deploy --target ${target} migrates it and ships the image that\n` +
197
+ ` what was just written. Roll a new one: lanes link deploy --workspace ${target}`
198
+ : ` lanes link deploy --workspace ${target} migrates it and ships the image that\n` +
182
199
  ' understands it, in one step. Prefer that to --fix here.',
183
200
  ),
184
201
  );
185
202
  return;
186
203
  }
187
204
 
188
- if (!applied) print(style.dim(` Fix it with: lanes link doctor --fix --target ${target}`));
205
+ if (!applied) print(style.dim(` Fix it with: lanes link doctor --fix --workspace ${target}`));
189
206
  },
190
207
  );
191
208