@lanes-sh/link 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +148 -73
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +85 -4
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +109 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +90 -88
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +16 -6
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +183 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +24 -12
  42. package/src/cli/commands/knowledge/index.ts +49 -89
  43. package/src/cli/commands/knowledge/migrate.ts +79 -13
  44. package/src/cli/commands/knowledge/show.ts +97 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +33 -13
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +53 -5
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/assets.ts +2 -2
  66. package/src/cli/commands/owner/entities.ts +2 -2
  67. package/src/cli/commands/owner/memory.ts +2 -2
  68. package/src/cli/commands/owner/shared.ts +13 -2
  69. package/src/cli/commands/owner/skills.ts +28 -8
  70. package/src/cli/commands/owner/tasks.ts +2 -2
  71. package/src/cli/commands/owner/vault.ts +3 -3
  72. package/src/cli/commands/profile/disposition.ts +236 -0
  73. package/src/cli/commands/profile/removal.ts +154 -64
  74. package/src/cli/commands/profile/remove.ts +83 -7
  75. package/src/cli/commands/profile.ts +79 -16
  76. package/src/cli/commands/relabel.ts +112 -0
  77. package/src/cli/commands/secrets.ts +39 -17
  78. package/src/cli/commands/set-workspace.ts +96 -0
  79. package/src/cli/commands/setup.ts +2 -2
  80. package/src/cli/commands/sync.ts +8 -8
  81. package/src/cli/commands/target.ts +9 -7
  82. package/src/cli/commands/update-migration.ts +54 -0
  83. package/src/cli/commands/update.ts +78 -24
  84. package/src/cli/config-edit.ts +99 -143
  85. package/src/cli/config-migrate.ts +82 -64
  86. package/src/cli/config-repair-sweep.ts +119 -0
  87. package/src/cli/config-repair.ts +131 -125
  88. package/src/cli/config-templates.ts +200 -0
  89. package/src/cli/contract3-credentials.ts +294 -0
  90. package/src/cli/contract3-data.ts +262 -0
  91. package/src/cli/contract3-layout.ts +46 -0
  92. package/src/cli/contract3-shape.ts +212 -0
  93. package/src/cli/contract3.ts +399 -0
  94. package/src/cli/contract4-credentials.ts +207 -0
  95. package/src/cli/contract4-data.ts +399 -0
  96. package/src/cli/contract4-rename.ts +73 -0
  97. package/src/cli/contract4-yaml.ts +223 -0
  98. package/src/cli/contract4.ts +342 -0
  99. package/src/cli/endpoint-url.ts +1 -1
  100. package/src/cli/identity.ts +44 -26
  101. package/src/cli/lanes.ts +25 -1
  102. package/src/cli/main.ts +94 -14
  103. package/src/cli/migrate-move.ts +166 -0
  104. package/src/cli/migrate-plan.ts +12 -6
  105. package/src/cli/output.ts +34 -1
  106. package/src/cli/publish.ts +6 -7
  107. package/src/cli/runtime/open.ts +64 -99
  108. package/src/cli/runtime/registry.ts +6 -7
  109. package/src/cli/runtime/select.ts +2 -11
  110. package/src/cli/runtime/stores.ts +58 -0
  111. package/src/cli/runtime/types.ts +106 -0
  112. package/src/cli/runtime/vault.ts +19 -4
  113. package/src/cli/runtime/workspace.ts +60 -0
  114. package/src/cli/runtime.ts +2 -1
  115. package/src/cli/selection-require.ts +44 -13
  116. package/src/cli/selection.ts +127 -145
  117. package/src/cli/usage.ts +40 -20
  118. package/src/cli/workspace-migrate.ts +152 -22
  119. package/src/connectivity/manifest/provider.ts +34 -13
  120. package/src/connectivity/manifest/requirements.ts +1 -1
  121. package/src/connectivity/transports/imap/parser.ts +70 -9
  122. package/src/deployments/adapters/filesystem.ts +18 -3
  123. package/src/deployments/bind.ts +1 -1
  124. package/src/deployments/deploy.ts +38 -29
  125. package/src/deployments/gcp/bucket.ts +58 -11
  126. package/src/deployments/gcp/provision.ts +7 -7
  127. package/src/deployments/knowledge.ts +9 -4
  128. package/src/deployments/prepare.ts +72 -24
  129. package/src/deployments/record.ts +1 -1
  130. package/src/deployments/report.ts +2 -2
  131. package/src/deployments/serving.ts +15 -74
  132. package/src/deployments/target.ts +34 -13
  133. package/src/deployments/upload.ts +60 -27
  134. package/src/dispatch/deps.ts +88 -0
  135. package/src/dispatch/dispatch.ts +21 -62
  136. package/src/policy/index.ts +47 -15
  137. package/src/profile/connections.ts +195 -0
  138. package/src/profile/deployments.ts +86 -8
  139. package/src/profile/index.ts +35 -6
  140. package/src/profile/knowledge.ts +18 -5
  141. package/src/profile/layout.ts +163 -90
  142. package/src/profile/load.ts +133 -64
  143. package/src/profile/pairing.ts +32 -0
  144. package/src/profile/primitives.ts +35 -1
  145. package/src/profile/registry.ts +6 -6
  146. package/src/profile/schema.ts +181 -21
  147. package/src/profile/targets.ts +21 -9
  148. package/src/profile/testing.ts +104 -2
  149. package/src/profile/workspace.ts +124 -33
  150. package/src/providers/assets/provider.ts +6 -6
  151. package/src/providers/custom/index.ts +1 -1
  152. package/src/providers/custom/load.ts +2 -3
  153. package/src/providers/entities/provider.ts +6 -6
  154. package/src/providers/entities/writes.ts +1 -1
  155. package/src/providers/identity/provider.ts +2 -2
  156. package/src/providers/memory/provider.ts +26 -8
  157. package/src/providers/setup/plan.ts +1 -1
  158. package/src/providers/setup/provider.ts +3 -3
  159. package/src/providers/skills/provider.ts +2 -2
  160. package/src/providers/slack/index.ts +2 -2
  161. package/src/providers/tasks/provider.ts +6 -6
  162. package/src/providers/vault/provider.ts +1 -1
  163. package/src/registry/policy-bridge.ts +33 -11
  164. package/src/registry/reconcile.ts +4 -4
  165. package/src/registry/registry.ts +1 -1
  166. package/src/server/authorization.ts +94 -0
  167. package/src/server/edge.ts +14 -1
  168. package/src/server/endpoint.ts +89 -104
  169. package/src/server/generation.ts +10 -1
  170. package/src/server/harness.ts +71 -13
  171. package/src/server/index.ts +31 -0
  172. package/src/server/mcp/build.ts +20 -1
  173. package/src/server/mcp/client-info.ts +54 -0
  174. package/src/server/mcp/guide.ts +120 -0
  175. package/src/server/mcp/instructions.ts +22 -22
  176. package/src/server/mcp/prompts.ts +7 -3
  177. package/src/server/mcp/resources.ts +16 -8
  178. package/src/server/mcp/routing.ts +3 -3
  179. package/src/server/mcp/tools.ts +25 -6
  180. package/src/server/mcp/visibility.ts +74 -7
  181. package/src/server/oauth.ts +29 -109
  182. package/src/server/read/credential.ts +134 -0
  183. package/src/server/read/deployed.ts +56 -0
  184. package/src/server/read/listener.ts +54 -0
  185. package/src/server/read/open.ts +101 -0
  186. package/src/server/read/routes.ts +247 -0
  187. package/src/server/read/state.ts +171 -0
  188. package/src/stores/blobs/conformance.ts +19 -0
  189. package/src/stores/state/index.ts +76 -10
  190. package/src/stores/state/testing.ts +5 -1
@@ -0,0 +1,109 @@
1
+ import { CONNECT_CUSTOM_FLAGS } from './commands/connect/custom/spec.ts';
2
+
3
+ /**
4
+ * The flags each command reads, beyond the universal set and its own selection.
5
+ *
6
+ * Split from `selection.ts` so that file stays inside the size budget, and on
7
+ * the seam it already had: that file answers *what a command must be told*, and
8
+ * this answers *what it will listen to*. Both are read by `assertKnownFlags`,
9
+ * and a flag missing from here is refused rather than ignored — which is the
10
+ * defect the whole arrangement exists to prevent.
11
+ */
12
+
13
+ /**
14
+ * What each command accepts beyond the universal set and its own selection.
15
+ *
16
+ * Only commands with flags of their own appear. Anything absent accepts the
17
+ * universal set plus whatever `SELECTION` says it must be told.
18
+ */
19
+ export const ACCEPTS: Record<string, readonly string[]> = {
20
+ // Imported rather than written out. Thirty-odd entries here would take this
21
+ // file past the size budget for a data literal, and the command's own
22
+ // `spec.ts` already derives most of them from the per-kind field tables — so
23
+ // a flag added there cannot be forgotten here.
24
+ 'connect custom': CONNECT_CUSTOM_FLAGS,
25
+ // `own-client` is the older spelling of one of the routes `auth` names, kept
26
+ // because it is in scripts and a year of documentation (ADR-038).
27
+ connect: [
28
+ 'id',
29
+ 'display-name',
30
+ // Repeatable: `--set host=cloud.example.com`. The only way to give a
31
+ // provider its address without a terminal to ask at.
32
+ 'set',
33
+ 'label',
34
+ 'replace',
35
+ 'non-interactive',
36
+ 'accept-broad-scopes',
37
+ 'own-client',
38
+ 'auth',
39
+ ],
40
+ setup: ['id'],
41
+ 'profile add': ['workspace', 'non-interactive'],
42
+ // `--delete-data` and `--migrate-to` say what becomes of the bytes the
43
+ // profile owns (ADR-066). Neither is optional in effect: `--yes` skips
44
+ // confirmations of things the command was told to do, and this is a question
45
+ // it has not been asked, so a run with no terminal is refused rather than
46
+ // guessing.
47
+ 'profile remove': ['dry-run', 'yes', 'workspace', 'delete-data', 'migrate-to'],
48
+ 'profile members': ['me', 'role'],
49
+ disconnect: ['yes', 'keep-credential'],
50
+ // The one repair `doctor` can apply rather than only name. Narrow on purpose:
51
+ // it undoes a provider rename this project shipped, and every other finding
52
+ // there is something only the operator can decide.
53
+ doctor: ['fix'],
54
+ // A filter, not a second subject: it narrows the answer to one connection so
55
+ // a caller can re-ask about the row it just repaired. Same shape as `attach`.
56
+ auth: ['connection'],
57
+ relabel: [],
58
+ // A rule lands in a grant row, and a row names one connection (ADR-058), so
59
+ // the flag is required rather than optional. It is listed here as well as
60
+ // enforced in the command because `assertKnownFlags` refuses anything absent
61
+ // from this table — the flag existing everywhere except the allowlist is the
62
+ // exact defect this file was written for.
63
+ 'policy allow': ['connection'],
64
+ 'policy deny': ['connection'],
65
+ 'workspace list': ['urls', 'workspace'],
66
+ 'target list': ['urls', 'workspace'],
67
+ 'workspace show': ['workspace'],
68
+ 'target show': ['workspace'],
69
+ 'mcp install-instructions': ['client'],
70
+ pair: ['print', 'rotate', 'yes'],
71
+ 'token show': ['show', 'raw'],
72
+ 'token rotate': ['show', 'raw', 'yes'],
73
+ 'audit tail': ['limit', 'denied-only', 'format'],
74
+ 'audit verify': ['limit', 'format'],
75
+ attach: ['connection'],
76
+ outputs: ['show'],
77
+ start: ['port', 'only'],
78
+ 'mcp stdio': ['only'],
79
+ 'mcp add': ['name', 'scope', 'token-env', 'dry-run', 'force', 'no-skill', 'headless'],
80
+ 'mcp skill': ['print', 'force'],
81
+ 'mcp list': ['name', 'scope'],
82
+ // `--yes` because it installs the app when nothing answers the scheme, and
83
+ // that is the one prompt in this CLI that puts an application on the machine.
84
+ dashboard: ['print', 'yes'],
85
+ desktop: ['print', 'yes'],
86
+ skill: ['print', 'force'],
87
+ deploy: ['dry-run', 'iam', 'access', 'service-account', 'tag', 'yes', 'non-interactive'],
88
+ 'secrets push': ['from', 'to', 'overwrite', 'dry-run'],
89
+ sync: ['dry-run', 'from', 'discover', 'prefer'],
90
+ 'sync targets': ['dry-run', 'from', 'discover', 'prefer'],
91
+ 'sync workspaces': ['dry-run', 'from', 'discover', 'prefer'],
92
+ update: ['check'],
93
+ 'identity add': ['note'],
94
+ memory: ['connection', 'title', 'description', 'file', 'tag'],
95
+ // `--yes` on both, because both have a delete that asks first.
96
+ tasks: ['connection', 'title', 'status', 'due', 'tag', 'yes'],
97
+ assets: ['connection', 'name', 'content-type', 'yes'],
98
+ skills: ['connection', 'title', 'description', 'file'],
99
+ vault: ['connection'],
100
+ // `alias`, `attr` and `related` are repeatable — see `ownerFlags`. `name`
101
+ // overrides the id derived from the positional name, which is how you get
102
+ // `acme-bv` rather than `acme-b-v`.
103
+ entities: ['connection', 'type', 'name', 'alias', 'attr', 'related', 'tag', 'yes'],
104
+ // `no-migrate` is listed beside `migrate` because they are three states
105
+ // rather than two: neither one asks, and a run with no terminal has to be
106
+ // able to say which it meant (ADR-041).
107
+ knowledge: ['repo', 'branch', 'path', 'migrate', 'no-migrate', 'keep', 'allow-public', 'replace', 'yes'],
108
+ };
109
+
package/src/cli/argv.ts CHANGED
@@ -50,7 +50,52 @@ export function parseArgv(argv: readonly string[]): Parsed {
50
50
  }
51
51
  }
52
52
 
53
- return { command, flags };
53
+ return { command, flags: normaliseWorkspace(flags) };
54
+ }
55
+
56
+ /**
57
+ * `--target` is the old spelling of `--workspace`, accepted for one minor.
58
+ *
59
+ * A workspace *is* a target and has been since ADR-052; contract 3 finishes the
60
+ * rename (ADR-061). The alias exists because the word is in a year of notes and
61
+ * in the desktop app's hardcoded argument arrays, and cutting it in the same
62
+ * release would break the app for anyone who updates the CLI first.
63
+ *
64
+ * Normalised here rather than in `selection.ts` so exactly one table knows the
65
+ * new word. A command reading `flags['target']` after this would be reading a
66
+ * key that is never set, which is the kind of silent miss `assertKnownFlags`
67
+ * exists to prevent.
68
+ *
69
+ * Both spellings is a mistake rather than a preference, so it is refused: they
70
+ * could name different workspaces, and picking either would be a guess.
71
+ *
72
+ * **It says so, on stderr.** A deprecation nobody sees is one nobody acts on,
73
+ * and the alias has exactly one minor to live. Stderr rather than stdout because
74
+ * `--json` and `--raw` callers parse the other stream, and a deprecation notice
75
+ * that broke a script would be a worse way to make the point. `notify` is
76
+ * injectable so a test can read what was said without capturing a global.
77
+ */
78
+ export function normaliseWorkspace(
79
+ flags: Flags,
80
+ notify: (line: string) => void = (line) => void process.stderr.write(`${line}\n`),
81
+ ): Flags {
82
+ const legacy = flags['target'];
83
+ if (legacy === undefined) return flags;
84
+
85
+ if (flags['workspace'] !== undefined && flags['workspace'] !== legacy) {
86
+ throw new Error(
87
+ `--target and --workspace name different things ("${String(legacy)}" and ` +
88
+ `"${String(flags['workspace'])}"). --target is the old spelling of --workspace; pass one.`,
89
+ );
90
+ }
91
+
92
+ notify(
93
+ `warn --target is now --workspace, and this spelling goes in the next minor. ` +
94
+ `Read it as: --workspace ${String(legacy)}`,
95
+ );
96
+
97
+ const { target: _legacy, ...rest } = flags;
98
+ return { ...rest, workspace: legacy };
54
99
  }
55
100
 
56
101
  /**
@@ -91,11 +136,20 @@ export function all(argv: readonly string[], name: string): string[] {
91
136
  return values.flatMap((value) => value.split(',').map((part) => part.trim())).filter(Boolean);
92
137
  }
93
138
 
94
- /** The flags every command accepts. */
139
+ /**
140
+ * The flags every command accepts.
141
+ *
142
+ * Read from `workspace`, which is what `parseArgv` normalises both spellings
143
+ * into. The field it lands on is still called `target` and that is deliberate
144
+ * rather than missed: `#profile` resolves a *target* — an adapter set, a
145
+ * credential store, a bucket — and renaming the internal identifier is a
146
+ * separate change from renaming the word an operator types (ADR-061). The
147
+ * boundary is here, in one function, rather than scattered.
148
+ */
95
149
  export function globalFlags(flags: Flags): GlobalFlags {
96
150
  return {
97
151
  profile: text(flags, 'profile'),
98
- target: text(flags, 'target'),
152
+ target: text(flags, 'workspace'),
99
153
  quiet: flags['quiet'] === true,
100
154
  };
101
155
  }
@@ -0,0 +1,140 @@
1
+ import { openAudit, openSecrets, openStorage } from '#deployments/target.ts';
2
+ import { openTarget, type Config } from '#profile';
3
+ import { readSession } from '#auth/lanes/session.ts';
4
+
5
+ /**
6
+ * Config changes, in the same log as the calls they permit.
7
+ *
8
+ * The audit log recorded capability invocations and nothing else, which meant
9
+ * it could answer "what did this agent try to do" and could not answer "who
10
+ * changed what it was allowed to do". Those are halves of one investigation. A
11
+ * member added by hand at 14:02 and the first call that member made at 14:03
12
+ * are one story, and keeping them in two places means reconstructing the join
13
+ * by timestamp across two formats — so they go in one chain, in order, and
14
+ * `audit verify` covers both.
15
+ *
16
+ * **The event shape does not change.** A config change is written as an
17
+ * invocation of a capability that only the CLI can invoke, which is true rather
18
+ * than a convenient fiction: `config.member.add` is a thing that was done, by
19
+ * someone, to something, with arguments worth keeping. Adding a second record
20
+ * type would fork every reader, the chain encoding, and `tail`'s filters, to
21
+ * express something the existing five fields already carry.
22
+ *
23
+ * **`provider: 'config'` is not a provider**, and `config.test.ts` asserts no
24
+ * manifest may claim that id — the guarantee is a test rather than a comment,
25
+ * because the day someone writes `providers.d/config.yaml` the two kinds of row
26
+ * become indistinguishable in a log whose value is that they are not.
27
+ *
28
+ * **None of this is reachable over MCP.** These capabilities are not in the
29
+ * registry, no profile can grant them, and `control-plane.test.ts` is the
30
+ * standing check that config never becomes a surface an agent can call. Writing
31
+ * a row about a change is not the same as offering to make one.
32
+ *
33
+ * **Failing to log never fails the command.** The change is already on disk by
34
+ * the time this is called; throwing here would report failure for something
35
+ * that succeeded, and leave the operator to guess which half happened. It warns
36
+ * and returns.
37
+ */
38
+
39
+ /**
40
+ * The vocabulary, in one place so it cannot drift command by command.
41
+ *
42
+ * `<area>.<verb>`, with the area named for the thing that changed rather than
43
+ * for the command that changed it — `lanes link connect` and a hand-edited
44
+ * `connections.yaml` are the same event, and a log that called them different
45
+ * things would be reporting the route rather than the change.
46
+ */
47
+ export const CONFIG_CAPABILITIES = [
48
+ 'config.connection.create',
49
+ 'config.connection.remove',
50
+ 'config.connection.relabel',
51
+ 'config.profile.add',
52
+ 'config.profile.remove',
53
+ 'config.member.add',
54
+ 'config.member.remove',
55
+ 'config.policy.allow',
56
+ 'config.policy.deny',
57
+ 'config.pair.mint',
58
+ 'config.pair.rotate',
59
+ ] as const;
60
+
61
+ export type ConfigCapability = (typeof CONFIG_CAPABILITIES)[number];
62
+
63
+ /** The pseudo-provider every config row carries. Reserved by `config.test.ts`. */
64
+ export const CONFIG_PROVIDER = 'config';
65
+
66
+ export interface ConfigChange {
67
+ readonly capability: ConfigCapability;
68
+ /**
69
+ * The scope of the change: the profile it altered, or the workspace where it
70
+ * altered something the whole workspace shares.
71
+ *
72
+ * A connection belongs to the workspace now (ADR-057), so `connect` has no
73
+ * profile to name and naming one would be a guess. The workspace's own name
74
+ * goes here instead — informative, and never mistakable for a profile,
75
+ * because a reader who filters by a profile they have gets rows about it and
76
+ * a reader who does not gets the workspace's.
77
+ */
78
+ readonly scope: string;
79
+ /** `<provider>.<id>`, where the change was about one connection. */
80
+ readonly connection?: string | undefined;
81
+ /**
82
+ * What changed, and it is the operator's own config rather than anybody's
83
+ * content — so unlike a provider's arguments there is nothing here to redact.
84
+ * Names, ids, subjects and capability patterns are the whole point of the
85
+ * record. Do not put a credential in it.
86
+ */
87
+ readonly arguments?: Readonly<Record<string, unknown>> | undefined;
88
+ }
89
+
90
+ export async function recordConfigChange(
91
+ config: Config,
92
+ root: string,
93
+ target: string,
94
+ change: ConfigChange,
95
+ warn?: (message: string) => void,
96
+ ): Promise<void> {
97
+ try {
98
+ const resolved = await openTarget(root, target);
99
+ const input = { declared: resolved.declared, config, root: resolved.workspaceRoot, target };
100
+ const audit = openAudit(await openStorage(input, await openSecrets(input)));
101
+
102
+ try {
103
+ await audit.append({
104
+ profile: change.scope,
105
+ principal: await principal(),
106
+ provider: CONFIG_PROVIDER,
107
+ ...(change.connection ? { connection: change.connection } : {}),
108
+ capability: change.capability,
109
+ arguments: change.arguments ?? {},
110
+ // Every one of these is written after the change is on disk, so there
111
+ // is no denied case to record and no duration worth measuring: the
112
+ // number would be how long a file write took, which answers nothing.
113
+ authorization: 'allowed',
114
+ status: 'ok',
115
+ durationMs: 0,
116
+ });
117
+ } finally {
118
+ await audit.close();
119
+ }
120
+ } catch (error) {
121
+ warn?.(`the change was made but not recorded in the audit log: ${message(error)}`);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Who made the change.
127
+ *
128
+ * The signed-in Lanes subject, which since ADR-060 is a real person rather than
129
+ * whoever held a token. `cli:unsigned` is the honest answer where there is no
130
+ * session — some commands do not require one, and attributing their changes to
131
+ * the last person who signed in on this machine would be worse than saying so.
132
+ */
133
+ async function principal(): Promise<string> {
134
+ const session = await readSession().catch(() => null);
135
+ return session?.subject ?? 'cli:unsigned';
136
+ }
137
+
138
+ function message(error: unknown): string {
139
+ return error instanceof Error ? error.message : String(error);
140
+ }
@@ -69,100 +69,44 @@ const ICON =
69
69
  'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
70
70
  '<path d="M20 6 9 17l-5-5"/></svg>';
71
71
 
72
- export interface ApprovalPage {
73
- /** What is asking. A client's self-reported name, or a stand-in. */
74
- readonly client: string;
75
- /** Where the code would be sent. The part of the request that cannot be faked. */
76
- readonly redirectHost: string;
77
- /**
78
- * The same destination as a CSP source, so the browser will follow the
79
- * redirect this form's approval ends in rather than blocking it.
80
- */
81
- readonly formAction?: string;
82
- /** Hidden fields carrying the authorization request through the POST. */
83
- readonly fields: Readonly<Record<string, string>>;
84
- readonly action: string;
85
- /** A previous attempt presented the wrong token. */
86
- readonly retry: boolean;
87
- /** The target this endpoint runs as, so the hint below names the right store. */
88
- readonly target: string;
89
- }
90
-
91
72
  /**
92
- * The one screen a remote client's authorization stops at.
73
+ * Something went wrong, said to the person it went wrong for.
93
74
  *
94
- * Same card as the page above, because it is the same product and the reader
95
- * arrived here from a connector rather than from a terminal. What it asks for is
96
- * the endpoint tokenthe string `lanes link outputs` prints because that is
97
- * already the proof of being the owner and inventing a second one would mean
98
- * inventing a password to go with it.
75
+ * This replaces the consent form (ADR-062), and the two are worth contrasting.
76
+ * The form asked a browser on loopback for the endpoint token the one
77
+ * credential that opened everything which made it the most valuable thing a
78
+ * hostile local page could reach. Identity now comes from lanes.sh, so the only
79
+ * page this endpoint renders is one that *tells* rather than asks.
99
80
  *
100
- * The hint names the target rather than leaving it out. Credentials are
101
- * per-target, and the reader runs that command in a shell resolving a target of
102
- * its own`local` by default, which is the one store a deployed endpoint's
103
- * token is never in. Omitting it sends them to fetch the wrong secret, and
104
- * `outputs` mints a fresh one when that store is empty rather than saying so.
105
- *
106
- * `autocomplete="off"` and `type="password"` are not theatre: this form is
107
- * submitted in whatever browser the phone opened, and a token remembered by a
108
- * shared browser is a token in the hands of whoever borrows the phone.
81
+ * Rendered as a page rather than returned as text because of who reads it. The
82
+ * important message here is "you signed in, and no profile on this endpoint
83
+ * lists you"which arrives at a browser, at the end of a sign-in, addressed
84
+ * to somebody who needs to know what to ask their operator for.
109
85
  */
110
- export function approvalPage(page: ApprovalPage): Response {
111
- const hidden = Object.entries(page.fields)
112
- .map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
113
- .join('\n');
114
-
115
- // The name is self-reported and the host is not, so the host is what the
116
- // reader is asked to recognise. Registration is open by design, which means
117
- // anything can call itself anything — but an impostor still has to nominate
118
- // somewhere for the code to go, and that is on the screen.
119
- const body = `
120
- ${page.retry ? '<p class="label err-text">That token was not accepted.</p>\n' : ''}<h1>Authorise ${escapeHtml(page.client)}?</h1>
121
- <p class="detail">It will be sent back to <strong>${escapeHtml(page.redirectHost)}</strong>, and will be able to reach every profile this endpoint serves, within the policy each one declares.</p>
122
- <form method="post" action="${escapeHtml(page.action)}">
123
- ${hidden}
124
- <input class="field" type="password" name="token" placeholder="Endpoint token" autocomplete="off" autofocus required>
125
- <button class="go" type="submit">Approve</button>
126
- </form>
127
- <p class="small"><code>lanes link outputs --show --target ${escapeHtml(page.target)}</code></p>`;
86
+ export function noticePage(message: string, status: number): Response {
87
+ // Deliberately whole-text-escaped and then split on blank lines: these
88
+ // messages carry a subject and sometimes a command, and neither is markup.
89
+ const body =
90
+ `<h1>Not authorised</h1>` +
91
+ message
92
+ .split('\n\n')
93
+ .map((paragraph) => `<p class="detail">${escapeHtml(paragraph)}</p>`)
94
+ .join('\n');
128
95
 
129
- return shell(body, 'Authorise', page.retry ? 401 : 200, '', {
130
- script: SUBMIT_SPINNER,
131
- ...(page.formAction ? { formAction: [page.formAction] } : {}),
132
- });
96
+ return shell(body, 'Not authorised', status, ' err');
133
97
  }
134
98
 
135
99
  /**
136
- * What runs while the approval is in flight.
137
- *
138
- * Approving is a round trip to an authorization server, and until it returns the
139
- * page looks exactly as it did before the click — so the honest reading is that
140
- * nothing happened, and the second click is the one that produces a duplicated
141
- * request. The button disables itself, which is the part that matters; the
142
- * spinner is what says why.
100
+ * One document, both pages.
143
101
  *
144
- * The cost is real and worth naming: this is the one page that asks for the
145
- * endpoint token, and an inline listener means its policy admits inline script.
146
- * It is the minimum that does the job one listener, no interpolation, nothing
147
- * read from the page and the alternative, a static file, is an asset pipeline
148
- * this repository does not have.
102
+ * No script hook any more, and no `form-action` parameter. Both existed for the
103
+ * consent form: it ran an inline listener to disable its own button, and it had
104
+ * to name in its policy the client origin its approval redirected to. Identity
105
+ * moved to lanes.sh (ADR-062) and the form went with it, so the policy narrows
106
+ * back to what it was before — which is the rare direction for a CSP to move
107
+ * and worth saying out loud.
149
108
  */
150
- const SUBMIT_SPINNER = `
151
- document.querySelector('form').addEventListener('submit', function (event) {
152
- var button = event.currentTarget.querySelector('.go');
153
- button.classList.add('busy');
154
- button.disabled = true;
155
- });
156
- `.trim();
157
-
158
- function shell(
159
- inner: string,
160
- title: string,
161
- status: number,
162
- cardClass = '',
163
- policy: { readonly script?: string; readonly formAction?: readonly string[] } = {},
164
- ): Response {
165
- const script = policy.script ?? '';
109
+ function shell(inner: string, title: string, status: number, cardClass = ''): Response {
166
110
  return new Response(
167
111
  `<!doctype html>
168
112
  <html lang="en">
@@ -184,48 +128,25 @@ ${inner}
184
128
  </div>
185
129
  ${FOOTER}
186
130
  </div>
187
- ${script ? `<script>\n${script}\n</script>` : ''}
188
131
  </body>
189
132
  </html>`,
190
133
  {
191
134
  status,
192
- headers: {
193
- ...PAGE_HEADERS,
194
- 'content-security-policy': pageCsp({
195
- ...(script ? { script: true } : {}),
196
- ...(policy.formAction ? { formAction: policy.formAction } : {}),
197
- }),
198
- },
135
+ headers: { ...PAGE_HEADERS, 'content-security-policy': pageCsp({}) },
199
136
  },
200
137
  );
201
138
  }
202
139
 
140
+ /**
141
+ * What is left of the form styles.
142
+ *
143
+ * `.small` alone: the consent form's field, button and spinner went with the
144
+ * form (ADR-062). Kept as its own constant rather than folded into `STYLE`
145
+ * because the completion page still uses it for the line under the card.
146
+ */
203
147
  const FORM_STYLE = `
204
- .field { width: 100%; margin: 20px 0 12px; padding: 11px 13px; font: inherit; font-size: 15px;
205
- color: inherit; background: var(--background); border: 1px solid var(--border);
206
- border-radius: 6px; }
207
- .field:focus { outline: none; border-color: var(--accent-gold); }
208
- .go { width: 100%; padding: 11px 13px; font: inherit; font-size: 15px; font-weight: 500;
209
- color: var(--foreground); background: transparent; border: 1px solid var(--border);
210
- border-radius: 6px; cursor: pointer; }
211
- .go:hover { background: var(--muted); }
212
148
  .small { margin-top: 16px; font-size: 12px; color: var(--muted-foreground); opacity: 0.8; }
213
149
  .small code { font-size: 12px; }
214
- .err-text { color: var(--destructive); }
215
-
216
- /* The button, mid-flight. The label goes transparent rather than away, so the
217
- button keeps the width it had and the card does not reflow under the cursor. */
218
- .go.busy { color: transparent; position: relative; pointer-events: none; }
219
- .go.busy::after { content: ''; position: absolute; inset: 0; margin: auto;
220
- width: 15px; height: 15px; border-radius: 50%;
221
- border: 2px solid var(--border); border-top-color: var(--foreground);
222
- animation: spin 0.6s linear infinite; }
223
- @keyframes spin { to { transform: rotate(360deg); } }
224
- /* Monochrome deliberately: gold says a thing turned out well, and a request in
225
- flight has not turned out yet. */
226
- @media (prefers-reduced-motion: reduce) {
227
- .go.busy::after { animation-duration: 2.4s; }
228
- }
229
150
  `.trim();
230
151
 
231
152
  export function completionPage(page: CallbackPage): Response {
@@ -0,0 +1,48 @@
1
+ import { parseArgv } from '../argv.ts';
2
+ import { authLogin, authLogout, authStatus, authWorkspaces } from './auth.ts';
3
+
4
+ /**
5
+ * `lanes auth <command>` — the grammar, and nothing else.
6
+ *
7
+ * Its own file for the reason `main.ts` is one: a dispatcher that also does the
8
+ * work is a file that grows a case at a time until nobody can see the grammar.
9
+ * Four commands, one line each.
10
+ */
11
+
12
+ const USAGE = `lanes auth — who this machine is signed in as
13
+
14
+ lanes auth login sign in, in a browser
15
+ lanes auth logout forget this machine's session
16
+ lanes auth status who you are, and how long the token lasts
17
+ lanes auth workspaces the Lanes workspaces you are a member of
18
+
19
+ --json the same answer, as a document
20
+ --api-url <url> a self-hosted API, instead of the default
21
+ `;
22
+
23
+ export async function runAuth(argv: readonly string[]): Promise<void> {
24
+ const { command, flags } = parseArgv(argv);
25
+ const [first] = command;
26
+
27
+ const options = {
28
+ json: flags['json'] === true,
29
+ ...(typeof flags['api-url'] === 'string' ? { apiUrl: flags['api-url'] } : {}),
30
+ };
31
+
32
+ switch (first) {
33
+ case 'login':
34
+ return authLogin(options);
35
+ case 'logout':
36
+ return authLogout(options);
37
+ case 'status':
38
+ case undefined:
39
+ return authStatus(options);
40
+ case 'workspaces':
41
+ return authWorkspaces(options);
42
+ case 'help':
43
+ console.log(USAGE);
44
+ return;
45
+ default:
46
+ throw new Error(`Unknown: lanes auth ${first}\n\n${USAGE}`);
47
+ }
48
+ }