@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
@@ -151,14 +151,14 @@ export async function tokenInvocation(
151
151
  target: string,
152
152
  ): Promise<{ command: string; onPath: boolean }> {
153
153
  // Both, always, and from the *resolved* selection rather than the flags. A
154
- // token is per-target, so `outputs --target cloud` printing a bare
154
+ // token is per-target, so `outputs --workspace cloud` printing a bare
155
155
  // `token show --raw` hands over the local one beside a deployed URL — a
156
156
  // credential that looks like an answer and fails as a wrong password. Naming
157
157
  // the profile as well makes the line pasteable into any shell rather than
158
158
  // only into one where the same default happens to resolve.
159
- const selection = ` --profile ${profile} --target ${target}`;
159
+ const selection = ` --profile ${profile} --workspace ${target}`;
160
160
  const short = `lanes link token show --raw${selection}`;
161
- const argv = ['link', 'token', 'show', '--raw', '--profile', profile, '--target', target];
161
+ const argv = ['link', 'token', 'show', '--raw', '--profile', profile, '--workspace', target];
162
162
 
163
163
  const resolved = Bun.which('lanes');
164
164
  if (resolved) {
@@ -0,0 +1,141 @@
1
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { ConfigError, PAIR_CERT_REF, PAIR_KEY_REF } from '#profile';
5
+ import type { SecretStore } from '#secrets';
6
+ import { print, style, warn } from '../../output.ts';
7
+ import { confirm, isInteractive } from '../../prompt.ts';
8
+ import type { PairDeps, PairFlags } from './pair.ts';
9
+
10
+ /**
11
+ * Making a loopback address one this machine's browsers will trust.
12
+ *
13
+ * Split from `./pair.ts` because it is the half of pairing that only loopback
14
+ * has. A deployed endpoint terminates TLS with a certificate a browser already
15
+ * trusts, so none of this runs for one — and keeping it beside the command made
16
+ * a file most of which did not apply to half its callers.
17
+ *
18
+ * It is also the largest side effect any command in this CLI has: a persistent
19
+ * change to the machine's trust store, made by a CLI. That is why everything
20
+ * here asks first, and why a run with nobody at the terminal is refused rather
21
+ * than assumed.
22
+ */
23
+
24
+ /** The names the certificate has to cover. All three are this machine. */
25
+ const HOSTS = ['127.0.0.1', 'localhost', '::1'];
26
+
27
+ /**
28
+ * A certificate a browser on this machine will accept.
29
+ *
30
+ * mkcert and nothing else, deliberately. It is the one tool that installs into
31
+ * the system trust store *and* Firefox's separate NSS store, across macOS,
32
+ * Linux and Windows — and a self-signed certificate this command generated
33
+ * itself would fail in the browser with an error the page cannot read, which is
34
+ * the worst of both: the work is done and the feature does not work.
35
+ */
36
+ export async function ensureCertificate(
37
+ credentials: SecretStore,
38
+ flags: PairFlags,
39
+ deps: PairDeps,
40
+ ): Promise<'reused' | 'installed'> {
41
+ const held =
42
+ (await credentials.get(PAIR_CERT_REF)) !== null &&
43
+ (await credentials.get(PAIR_KEY_REF)) !== null;
44
+
45
+ if (held && flags.rotate !== true) return 'reused';
46
+
47
+ const which = deps.which ?? ((binary: string) => Bun.which(binary));
48
+ const run = deps.run ?? runCommand;
49
+
50
+ let mkcert = which('mkcert');
51
+ if (!mkcert) mkcert = await installMkcert(flags, deps, which, run);
52
+
53
+ // Installs the local CA if it is not already there, and says nothing if it
54
+ // is. This is the step that touches the trust store, and it has already been
55
+ // consented to by the time it runs.
56
+ const installed = await run([mkcert, '-install']);
57
+ if (installed !== null) {
58
+ throw new ConfigError(`mkcert -install failed.\n${installed || ' It said nothing.'}`);
59
+ }
60
+
61
+ const scratch = await mkdtemp(join(tmpdir(), 'lanes-pair-'));
62
+ try {
63
+ const certPath = join(scratch, 'cert.pem');
64
+ const keyPath = join(scratch, 'key.pem');
65
+
66
+ const failed = await run([mkcert, '-cert-file', certPath, '-key-file', keyPath, ...HOSTS]);
67
+ if (failed !== null) {
68
+ throw new ConfigError(`mkcert failed to issue a certificate.\n${failed || ' It said nothing.'}`);
69
+ }
70
+
71
+ // Into the credential store rather than left on disk. The private key is a
72
+ // secret by any reading, and the store is the one place in a workspace that
73
+ // is encrypted at rest.
74
+ await credentials.set(PAIR_CERT_REF, await readFile(certPath, 'utf8'));
75
+ await credentials.set(PAIR_KEY_REF, await readFile(keyPath, 'utf8'));
76
+ } finally {
77
+ await rm(scratch, { recursive: true, force: true });
78
+ }
79
+
80
+ return 'installed';
81
+ }
82
+
83
+ async function installMkcert(
84
+ flags: PairFlags,
85
+ deps: PairDeps,
86
+ which: (binary: string) => string | null,
87
+ run: (command: readonly string[]) => Promise<string | null>,
88
+ ): Promise<string> {
89
+ const line = 'brew install mkcert';
90
+ const brew = which('brew');
91
+
92
+ if (!brew) {
93
+ // The one path where this command cannot finish the job, so it hands over
94
+ // the whole of it rather than half.
95
+ throw new ConfigError(
96
+ 'Pairing needs mkcert, which issues a certificate this machine\'s browsers trust.\n' +
97
+ ` With Homebrew: ${line}\n` +
98
+ ' Otherwise: https://github.com/FiloSottile/mkcert#installation\n' +
99
+ ' Then run "lanes link pair" again.',
100
+ );
101
+ }
102
+
103
+ print(warn('pairing needs mkcert, and it is not installed'));
104
+ print(
105
+ style.dim(
106
+ ' It issues a certificate for 127.0.0.1 that this machine trusts, which is what\n' +
107
+ ' lets a page on lanes.sh read your endpoint at all. Safari will not fetch\n' +
108
+ ' http://127.0.0.1 from an https page, and offers no way to allow it.\n' +
109
+ '\n' +
110
+ ' This installs a local certificate authority into your system trust store.\n' +
111
+ ` ${line}`,
112
+ ),
113
+ );
114
+
115
+ if (flags.yes !== true) {
116
+ if (!(deps.interactive ?? isInteractive())) {
117
+ throw new ConfigError(
118
+ 'Nothing here can answer a prompt, and installing a certificate authority because\n' +
119
+ ' nobody was there to say no is the wrong way to resolve that.\n' +
120
+ ` Run "${line}" yourself, or pass --yes.`,
121
+ );
122
+ }
123
+ if (!(await (deps.confirm ?? ((question: string) => confirm(question)))('Install it now?'))) {
124
+ throw new ConfigError(`Not paired. When you want it: ${line}`);
125
+ }
126
+ }
127
+
128
+ const failed = await run([brew, 'install', 'mkcert']);
129
+ if (failed !== null) throw new ConfigError(`${line} failed.\n${failed || ' It said nothing.'}`);
130
+
131
+ const found = which('mkcert');
132
+ if (!found) throw new ConfigError(`${line} reported success but mkcert is still not on PATH.`);
133
+ return found;
134
+ }
135
+
136
+ /** Runs it. Null on success; whatever it said on failure. */
137
+ async function runCommand(command: readonly string[]): Promise<string | null> {
138
+ const spawned = Bun.spawn([...command], { stdout: 'pipe', stderr: 'pipe' });
139
+ const [code, stderr] = await Promise.all([spawned.exited, new Response(spawned.stderr).text()]);
140
+ return code === 0 ? null : stderr.trim();
141
+ }
@@ -0,0 +1,324 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import {
3
+ ConfigError,
4
+ PAIR_CERT_REF,
5
+ PAIR_KEY_REF,
6
+ PAIR_TOKEN_REF,
7
+ loadWorkspaceProfiles,
8
+ openTarget,
9
+ type LoadedProfile,
10
+ resolveWorkspaceRoot,
11
+ } from '#profile';
12
+ import type { ResolvedTarget } from '#profile';
13
+ import { deployedUrl } from '../../endpoint-url.ts';
14
+ import { ensureCertificate } from './pair-certificate.ts';
15
+ import { recordConfigChange } from '../../audit-change.ts';
16
+ import { ok, print, style } from '../../output.ts';
17
+ import type { SecretStore } from '#secrets';
18
+ import { openSecretStoreFor, type GlobalFlags } from '../../runtime.ts';
19
+
20
+ /**
21
+ * `lanes link pair` — let the Lanes dashboard read this machine (ADR-063).
22
+ *
23
+ * Three things, and each is the operator's to decline. It installs a locally
24
+ * trusted certificate, mints a credential that reads the whole workspace, and
25
+ * hands the browser a link carrying it. None of that happens without being
26
+ * asked for, and none of it is implied by `start`.
27
+ *
28
+ * **The certificate is the largest side effect any command in this CLI has.**
29
+ * It is a persistent change to the machine's trust store, made by a CLI, and
30
+ * ADR-053's precedent applies unchanged: offer it, name what it is, and stop if
31
+ * declined. A run with nobody at the terminal is refused rather than assumed.
32
+ *
33
+ * **The token travels in a fragment.** `#pair=` is never sent to a server, so a
34
+ * credential for a surface whose entire point is that Lanes cannot see it does
35
+ * not end up in a Lanes access log, a proxy, or a referrer header.
36
+ *
37
+ * **A deployed workspace pairs too, and skips all of that** (ADR-064). The
38
+ * certificate was the whole of the old refusal — installing one for an address
39
+ * this machine does not answer on is meaningless — and it is the one piece a
40
+ * deployed endpoint does not need, because the platform terminates TLS with a
41
+ * certificate a browser already trusts. What remains is a token and an address.
42
+ *
43
+ * **It names a workspace, not a profile**, because that is what it pairs. The
44
+ * surface it opens lists every connection and every profile the workspace holds,
45
+ * and the credential it mints reads all of them — so asking which profile was
46
+ * asking a question with no answer, and implying a per-profile pairing that does
47
+ * not exist. `--profile` is still accepted, and picks the port when profiles
48
+ * disagree about one.
49
+ */
50
+
51
+ /**
52
+ * Where the three pieces live in the credential store.
53
+ *
54
+ * Declared in `#profile` rather than here, because three components read these
55
+ * names now and only one of them is the CLI — the server opens the read surface
56
+ * with them and a deploy binds the token so the revision may read it. Importing
57
+ * a *command* module for a string constant pulled the CLI's output and prompt
58
+ * handling into the container's runtime graph. Re-exported because a year of
59
+ * callers spell them from here.
60
+ */
61
+ export { PAIR_CERT_REF, PAIR_KEY_REF, PAIR_TOKEN_REF };
62
+
63
+ /** Where the dashboard lives, overridable so `lanes dev` can pair against it. */
64
+ const DASHBOARD_URL = process.env['LANES_WEB_URL'] ?? 'https://lanes.sh';
65
+
66
+ export interface PairFlags extends GlobalFlags {
67
+ /** Print the link for an existing pairing and change nothing. */
68
+ readonly print?: boolean | undefined;
69
+ /** Mint a fresh token, invalidating whatever a browser already holds. */
70
+ readonly rotate?: boolean | undefined;
71
+ /** Do not ask before installing mkcert. */
72
+ readonly yes?: boolean | undefined;
73
+ }
74
+
75
+ export interface PairDeps {
76
+ readonly which?: (binary: string) => string | null;
77
+ readonly run?: (command: readonly string[]) => Promise<string | null>;
78
+ readonly confirm?: (question: string) => Promise<boolean>;
79
+ readonly interactive?: boolean;
80
+ }
81
+
82
+ export async function pair(flags: PairFlags, deps: PairDeps = {}): Promise<void> {
83
+ const target = flags.target!;
84
+ const root = resolveWorkspaceRoot();
85
+ const resolved = await openTarget(root, target);
86
+ const { loaded: profiles } = await loadWorkspaceProfiles(resolved.workspaceRoot);
87
+
88
+ if (profiles.length === 0) {
89
+ throw new ConfigError(
90
+ `Workspace "${target}" holds no profiles, so there is no endpoint to pair.\n` +
91
+ ` Create one with: lanes link profile add <name> --workspace ${target}`,
92
+ );
93
+ }
94
+
95
+ // One endpoint serves every profile in a workspace, so they normally agree on
96
+ // a port and the choice is not a choice. Where they do not, the ambiguity is
97
+ // real and `--profile` is how it is settled — refused rather than guessed,
98
+ // because pairing the wrong port produces a dashboard that says "not
99
+ // connected" with everything working.
100
+ const named = flags.profile
101
+ ? profiles.find((one: LoadedProfile) => one.profile === flags.profile)
102
+ : undefined;
103
+
104
+ if (flags.profile && !named) {
105
+ throw new ConfigError(
106
+ `Workspace "${target}" has no profile "${flags.profile}".\n` +
107
+ ` It holds: ${profiles.map((one: LoadedProfile) => one.profile).join(', ')}`,
108
+ );
109
+ }
110
+
111
+ const ports = new Set(profiles.map((one: LoadedProfile) => one.config.instance.port));
112
+ if (!named && ports.size > 1) {
113
+ throw new ConfigError(
114
+ `The profiles in "${target}" do not agree on a port, so it is not clear which endpoint to pair.\n` +
115
+ profiles
116
+ .map((one: LoadedProfile) => ` ${one.profile} ${one.config.instance.port}`)
117
+ .join('\n') +
118
+ '\n Name one: lanes link pair --profile <name>',
119
+ );
120
+ }
121
+
122
+ const chosen = named ?? profiles[0]!;
123
+ const host = chosen.config.instance.host;
124
+ const credentials = await openSecretStoreFor(chosen.config, root, target);
125
+
126
+ // A workspace that declares a deployment is paired over the address the
127
+ // platform gave it, not over loopback — which is what `declared.deploy`
128
+ // answers and what `instance.host` does not: a deployed revision takes its
129
+ // host from the container's environment, so a profile bound to `127.0.0.1`
130
+ // in config is still serving `0.0.0.0` on Cloud Run.
131
+ if (resolved.declared.deploy) {
132
+ await pairDeployed({ flags, target, chosen, root, credentials, deploy: resolved.declared.deploy });
133
+ return;
134
+ }
135
+
136
+ if (!isLoopback(host)) {
137
+ // Not deployed, and not on this machine either. There is no certificate
138
+ // this command could install for an address this machine does not answer
139
+ // on, and no platform URL to hand the browser instead.
140
+ throw new ConfigError(
141
+ `"${target}" is bound to ${host}, which is neither loopback nor a deployment.\n` +
142
+ ' Pairing reaches an endpoint on *this* machine, or one `lanes link deploy` put\n' +
143
+ ' somewhere with an address of its own.',
144
+ );
145
+ }
146
+
147
+ const readPort = chosen.config.instance.port + 1;
148
+
149
+ // `127.0.0.1` rather than `instance.host`, even though `localhost` and `::1`
150
+ // are equally loopback and equally covered by the certificate. It is one
151
+ // address for one machine, and the browser has to agree with `open.ts` about
152
+ // which spelling it is: two pairings of the same endpoint under two names
153
+ // would be two entries in the switcher, both working, neither wrong.
154
+ const address = `https://127.0.0.1:${readPort}`;
155
+
156
+ if (flags.print === true) {
157
+ const existing = await credentials.get(PAIR_TOKEN_REF);
158
+ if (existing === null) throw new ConfigError('Not paired yet. Run: lanes link pair');
159
+ print(pairingLink(existing, address));
160
+ return;
161
+ }
162
+
163
+ const certificate = await ensureCertificate(credentials, flags, deps);
164
+
165
+ const rotating = flags.rotate === true;
166
+ const existing = rotating ? null : await credentials.get(PAIR_TOKEN_REF);
167
+ const token = existing ?? `llp_${randomBytes(32).toString('base64url')}`;
168
+ if (existing === null) await credentials.set(PAIR_TOKEN_REF, token);
169
+
170
+ // The token itself never goes in, obviously. What is worth recording is that
171
+ // a credential reading the whole workspace now exists, and when — and for a
172
+ // rotation, that whatever a browser was holding stopped working at that
173
+ // moment.
174
+ if (existing === null) {
175
+ await recordConfigChange(chosen.config, root, target, {
176
+ capability: rotating ? 'config.pair.rotate' : 'config.pair.mint',
177
+ scope: target,
178
+ arguments: { readPort, certificate },
179
+ });
180
+ }
181
+
182
+ print(ok(certificate === 'reused' ? 'certificate already installed' : 'certificate installed'));
183
+ if (rotating) {
184
+ print(style.dim(' The previous pairing link no longer works. Re-open the new one.'));
185
+ }
186
+ print('');
187
+ print(ok(`the dashboard may now read ${style.bold(address)}`));
188
+ print('');
189
+ print(pairingLink(token, address));
190
+ print('');
191
+ print(
192
+ style.dim(
193
+ ' Open that in a browser on this machine. The token is in the URL fragment,\n' +
194
+ ' so it never reaches a Lanes server.\n' +
195
+ ' It reads every connection, profile and audit entry in this workspace, and\n' +
196
+ ' can change nothing. Take it back with: lanes link pair --rotate\n' +
197
+ '\n' +
198
+ ` The endpoint has to be running: lanes link start --workspace ${target}`,
199
+ ),
200
+ );
201
+ }
202
+
203
+ /**
204
+ * Pairing a workspace that lives somewhere with an address of its own (ADR-064).
205
+ *
206
+ * The half of `pair` that is *not* shared with loopback is the certificate, and
207
+ * that was always the whole of the old refusal: installing one for an address
208
+ * this machine does not answer on is meaningless, and it is still meaningless.
209
+ * What was never the thing being refused is the credential and the address —
210
+ * the endpoint terminates TLS with a certificate a browser already trusts, so
211
+ * the two pieces that remain are a token and a URL.
212
+ *
213
+ * So there is no `mkcert` here, nothing is installed, and nothing is asked. The
214
+ * command writes one secret and prints a link.
215
+ */
216
+ async function pairDeployed(input: {
217
+ flags: PairFlags;
218
+ target: string;
219
+ chosen: LoadedProfile;
220
+ root: string;
221
+ credentials: SecretStore;
222
+ deploy: NonNullable<ResolvedTarget['declared']['deploy']>;
223
+ }): Promise<void> {
224
+ const { flags, target, chosen, root, credentials } = input;
225
+
226
+ // `deployedUrl` asks the platform where the service ended up and degrades to
227
+ // null for every reason that is not this command's business — no driver, not
228
+ // deployed yet, no credentials for the project. A link with no address in it
229
+ // reads nothing, so this refuses rather than printing half of one.
230
+ const mcpUrl = await deployedUrl(input.deploy);
231
+ if (mcpUrl === null) {
232
+ throw new ConfigError(
233
+ `Could not find the address of "${target}".\n` +
234
+ ' The service may not be deployed yet, or the platform CLI may not be signed in.\n' +
235
+ ` Check it with: lanes link outputs --workspace ${target}`,
236
+ );
237
+ }
238
+
239
+ // The read surface answers on the endpoint's own origin, beside `/mcp` rather
240
+ // than on a port of its own — Cloud Run routes exactly one.
241
+ const endpoint = mcpUrl.replace(/\/mcp$/, '');
242
+
243
+ if (flags.print === true) {
244
+ const existing = await credentials.get(PAIR_TOKEN_REF);
245
+ if (existing === null || existing === '') {
246
+ throw new ConfigError(`Not paired yet. Run: lanes link pair --workspace ${target}`);
247
+ }
248
+ print(pairingLink(existing, endpoint));
249
+ return;
250
+ }
251
+
252
+ const rotating = flags.rotate === true;
253
+ const held = rotating ? null : await credentials.get(PAIR_TOKEN_REF);
254
+
255
+ // An empty string, not just a missing ref: `lanes link deploy` creates this
256
+ // secret with no version so the revision's IAM binding has something to
257
+ // attach to, and a secret that exists with no version reads back as null
258
+ // here and as `unpaired` there. Either shape means nobody has paired yet.
259
+ const existing = held === '' ? null : held;
260
+ const token = existing ?? `llp_${randomBytes(32).toString('base64url')}`;
261
+ if (existing === null) await credentials.set(PAIR_TOKEN_REF, token);
262
+
263
+ if (existing === null) {
264
+ await recordConfigChange(chosen.config, root, target, {
265
+ capability: rotating ? 'config.pair.rotate' : 'config.pair.mint',
266
+ scope: target,
267
+ arguments: { endpoint },
268
+ });
269
+ }
270
+
271
+ print(ok('no certificate needed — this endpoint already has one a browser trusts'));
272
+ if (rotating) {
273
+ print(style.dim(' The previous pairing link no longer works. Re-open the new one.'));
274
+ }
275
+ print('');
276
+ print(ok(`the dashboard may now read ${style.bold(endpoint)}`));
277
+ print('');
278
+ print(pairingLink(token, endpoint));
279
+ print('');
280
+ print(
281
+ style.dim(
282
+ ' Open that in any browser, on any machine. The token is in the URL fragment,\n' +
283
+ ' so it never reaches a Lanes server.\n' +
284
+ ' It reads every connection, profile and audit entry in this workspace, and\n' +
285
+ ' can change nothing. Take it back with:\n' +
286
+ ` lanes link pair --workspace ${target} --rotate\n` +
287
+ '\n' +
288
+ ' A rotation takes up to five seconds to be refused, because the endpoint\n' +
289
+ ' caches what it read rather than calling Secret Manager per request.',
290
+ ),
291
+ );
292
+ }
293
+
294
+ /**
295
+ * The link the browser opens.
296
+ *
297
+ * The token rides in the fragment, which is never sent to a server — so a
298
+ * credential for a surface whose entire point is that Lanes cannot see this
299
+ * data does not land in a Lanes access log, a proxy, or a referrer header. The
300
+ * address rides beside it for the same reason and one more: it is the only
301
+ * thing telling the page which of several paired endpoints this link is for,
302
+ * and a query parameter would put a workspace's public address in that log.
303
+ *
304
+ * **A loopback link carries its address too**, and the parameter is required so
305
+ * that it cannot quietly stop. It used to be omitted here on the reasoning that
306
+ * loopback is derivable — and it is not: the read listener sits one port above
307
+ * whatever `instance.port` says, so an endpoint on any port but the default
308
+ * printed a link the dashboard then read at `7338`, reported unreachable, and
309
+ * gave no way to correct. The page still treats a link with no `at=` as
310
+ * loopback on the default port, because every link minted before this is that
311
+ * shape.
312
+ *
313
+ * Exported for `pair.test.ts` and for nothing else. The whole of the defect
314
+ * above was a shape nothing asserted on, in a command whose output no test
315
+ * reads, so the fix is not worth much without something that fails when the
316
+ * address goes missing again.
317
+ */
318
+ export function pairingLink(token: string, endpoint: string): string {
319
+ return `${DASHBOARD_URL}/dashboard/link#pair=${token}&at=${encodeURIComponent(endpoint)}`;
320
+ }
321
+
322
+ function isLoopback(host: string): boolean {
323
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1';
324
+ }
@@ -1,6 +1,7 @@
1
1
  import { loadConfigFile } from '#profile';
2
+ import { recordConfigChange } from '../../audit-change.ts';
2
3
  import { ConfigDocument } from '../../config-edit.ts';
3
- import { announce, announceProfile, heading, ok, print, style, table } from '../../output.ts';
4
+ import { announce, announceProfile, heading, ok, print, style, table, warn } from '../../output.ts';
4
5
  import { resolveProfile, resolveProfileOnly, type GlobalFlags } from '../../runtime.ts';
5
6
  import { nextAfterEdit, publishProfileEdit } from '../../publish.ts';
6
7
 
@@ -15,56 +16,106 @@ export async function policyList(flags: GlobalFlags): Promise<void> {
15
16
  const { selection, config } = await resolveProfileOnly(flags);
16
17
  announceProfile(selection);
17
18
 
18
- if (config.policy.allow.length === 0 && config.policy.deny.length === 0) {
19
- print(style.dim('No rules. Default deny is in effect: nothing is reachable.'));
19
+ if (config.grants.length === 0) {
20
+ print(style.dim('No grants. Default deny is in effect: nothing is reachable.'));
20
21
  return;
21
22
  }
22
23
 
23
24
  const expiry = (rule: { capability: string; expires_at?: string | undefined }) =>
24
25
  rule.expires_at ? style.dim(`until ${rule.expires_at}`) : '';
25
26
 
26
- if (config.policy.allow.length > 0) {
27
- heading('Allow');
28
- table(config.policy.allow.map((rule) => [` ${style.green('+')}`, rule.capability, expiry(rule)]));
29
- }
27
+ // Grouped by connection rather than by effect, which reverses how this used to
28
+ // read. A rule governs one account now (ADR-058), so "what may be done with
29
+ // the work mailbox" is the question the listing should answer in one place —
30
+ // and the old shape, two lists of capabilities with the account nowhere in
31
+ // them, could not answer it at all.
32
+ for (const grant of config.grants) {
33
+ heading(grant.connection);
34
+
35
+ if (grant.allow.length === 0 && grant.deny.length === 0) {
36
+ print(style.dim(' Granted nothing. The connection is reachable and no capability is.'));
37
+ continue;
38
+ }
30
39
 
31
- if (config.policy.deny.length > 0) {
32
- heading('Deny');
33
- print(style.dim(' A deny beats any allow, whatever the order in the file.'));
34
- table(config.policy.deny.map((rule) => [` ${style.red('-')}`, rule.capability, expiry(rule)]));
40
+ table([
41
+ ...grant.allow.map((rule) => [` ${style.green('+')}`, rule.capability, expiry(rule)]),
42
+ ...grant.deny.map((rule) => [` ${style.red('-')}`, rule.capability, expiry(rule)]),
43
+ ]);
35
44
  }
36
45
 
37
46
  print('');
47
+ print(style.dim('A deny beats any allow on the same connection, whatever the order in the file.'));
38
48
  print(
39
- style.dim('Rules cover every account of a provider. For different grants, use a second profile.'),
49
+ style.dim('A connection with no row above is not reachable at all, and is never advertised.'),
40
50
  );
41
51
  }
42
52
 
53
+ export interface PolicyFlags extends GlobalFlags {
54
+ readonly connection?: string | undefined;
55
+ }
56
+
43
57
  export async function policyRule(
44
58
  effect: 'allow' | 'deny',
45
59
  capability: string,
46
- flags: GlobalFlags,
60
+ flags: PolicyFlags,
47
61
  ): Promise<void> {
48
62
  const { resolution, config, target } = await resolveProfile(flags);
49
- const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
50
63
 
51
- if (config.policy[effect].some((rule) => rule.capability === capability)) {
52
- print(style.dim(`${effect} ${capability} is already declared.`));
64
+ // Required, and refused rather than guessed. A rule has to land in a row, and
65
+ // with two mailboxes granted there is no answer to "which one" that is not a
66
+ // guess about which account the operator meant to widen.
67
+ const key = flags.connection;
68
+ if (key === undefined) {
69
+ throw new Error(
70
+ `--connection is required. A rule governs one connection (ADR-058).\n` +
71
+ (config.grants.length > 0
72
+ ? ` This profile grants: ${config.grants.map((grant) => grant.connection).join(', ')}`
73
+ : ` This profile grants nothing yet. Run: lanes link status --profile ${resolution.profile}`),
74
+ );
75
+ }
76
+
77
+ const index = config.grants.findIndex((grant) => grant.connection === key);
78
+ if (index === -1) {
79
+ throw new Error(
80
+ `Profile "${resolution.profile}" does not grant "${key}".\n` +
81
+ ` Grant it first, then narrow it:\n` +
82
+ ` lanes link grant ${key} --profile ${resolution.profile}`,
83
+ );
84
+ }
85
+
86
+ const grant = config.grants[index]!;
87
+ if (grant[effect].some((rule) => rule.capability === capability)) {
88
+ print(style.dim(`${effect} ${capability} on ${key} is already declared.`));
53
89
  return;
54
90
  }
55
91
 
92
+ const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
93
+
56
94
  // A bare string, because that is what the file should read like. The object
57
95
  // form exists for `expires_at` and is not worth writing by default.
58
- document.addTo(['policy', effect], capability, { inline: true });
59
- // Validation runs before the write, so a rule naming a provider with no
60
- // connection fails here rather than silently granting nothing at runtime.
96
+ document.addTo(['grants', index, effect], capability, { inline: true });
97
+ // Validation runs before the write, so a rule naming a provider other than the
98
+ // row's own fails here rather than matching nothing at runtime.
61
99
  await document.save();
62
100
 
101
+ await recordConfigChange(
102
+ config,
103
+ resolution.workspaceRoot,
104
+ target,
105
+ {
106
+ capability: effect === 'allow' ? 'config.policy.allow' : 'config.policy.deny',
107
+ scope: resolution.profile,
108
+ connection: key,
109
+ arguments: { capability },
110
+ },
111
+ (note) => print(warn(note)),
112
+ );
113
+
63
114
  announce(resolution);
64
- print(ok(`${effect} ${style.bold(capability)}`));
115
+ print(ok(`${effect} ${style.bold(capability)} on ${style.bold(key)}`));
65
116
 
66
- if (effect === 'deny' && config.policy.allow.some((rule) => rule.capability === '*')) {
67
- print(style.dim(' This narrows the catch-all allow; a deny always wins.'));
117
+ if (effect === 'deny' && grant.allow.some((rule) => rule.capability === '*')) {
118
+ print(style.dim(" This narrows that row's catch-all allow; a deny always wins."));
68
119
  }
69
120
 
70
121
  // A deny the endpoint has not heard about is still granting what it names, so