@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.
- package/README.md +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +136 -61
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +65 -0
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +108 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/callback-page.ts +36 -115
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +88 -87
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +4 -2
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +182 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +21 -9
- package/src/cli/commands/knowledge/index.ts +46 -79
- package/src/cli/commands/knowledge/migrate.ts +74 -13
- package/src/cli/commands/knowledge/show.ts +92 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +29 -12
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +52 -4
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/profile/removal.ts +79 -76
- package/src/cli/commands/profile/remove.ts +16 -1
- package/src/cli/commands/profile.ts +46 -10
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +34 -12
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update.ts +58 -17
- package/src/cli/config-edit.ts +75 -140
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair.ts +89 -32
- package/src/cli/config-templates.ts +198 -0
- package/src/cli/contract3-data.ts +328 -0
- package/src/cli/contract3-shape.ts +186 -0
- package/src/cli/contract3.ts +282 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +89 -14
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +5 -2
- package/src/cli/runtime/open.ts +63 -98
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/stores.ts +53 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +34 -18
- package/src/cli/workspace-migrate.ts +125 -16
- package/src/connectivity/manifest/provider.ts +3 -1
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +36 -27
- package/src/deployments/gcp/bucket.ts +18 -7
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +15 -15
- package/src/deployments/upload.ts +46 -22
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +183 -0
- package/src/profile/deployments.ts +3 -3
- package/src/profile/index.ts +30 -5
- package/src/profile/layout.ts +86 -89
- package/src/profile/load.ts +80 -47
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +172 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +69 -2
- package/src/profile/workspace.ts +58 -3
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +20 -2
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/slack/index.ts +2 -2
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +85 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +1 -1
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/tools.ts +9 -3
- package/src/server/mcp/visibility.ts +18 -3
- package/src/server/oauth.ts +29 -109
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- package/src/server/read/state.ts +171 -0
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
// `--target` decommissions one target's stores and leaves the profile file in
|
|
43
|
+
// place (`removal.ts`). It is documented in `usage.ts` and read by
|
|
44
|
+
// `removalPlan`, and was refused here — the flag existed everywhere except in
|
|
45
|
+
// the list that decides whether it may be typed.
|
|
46
|
+
'profile remove': ['dry-run', 'yes', 'workspace'],
|
|
47
|
+
'profile members': ['me', 'role'],
|
|
48
|
+
disconnect: ['yes', 'keep-credential'],
|
|
49
|
+
// The one repair `doctor` can apply rather than only name. Narrow on purpose:
|
|
50
|
+
// it undoes a provider rename this project shipped, and every other finding
|
|
51
|
+
// there is something only the operator can decide.
|
|
52
|
+
doctor: ['fix'],
|
|
53
|
+
// A filter, not a second subject: it narrows the answer to one connection so
|
|
54
|
+
// a caller can re-ask about the row it just repaired. Same shape as `attach`.
|
|
55
|
+
auth: ['connection'],
|
|
56
|
+
relabel: [],
|
|
57
|
+
// A rule lands in a grant row, and a row names one connection (ADR-058), so
|
|
58
|
+
// the flag is required rather than optional. It is listed here as well as
|
|
59
|
+
// enforced in the command because `assertKnownFlags` refuses anything absent
|
|
60
|
+
// from this table — the flag existing everywhere except the allowlist is the
|
|
61
|
+
// exact defect this file was written for.
|
|
62
|
+
'policy allow': ['connection'],
|
|
63
|
+
'policy deny': ['connection'],
|
|
64
|
+
'workspace list': ['urls', 'workspace'],
|
|
65
|
+
'target list': ['urls', 'workspace'],
|
|
66
|
+
'workspace show': ['workspace'],
|
|
67
|
+
'target show': ['workspace'],
|
|
68
|
+
'mcp install-instructions': ['client'],
|
|
69
|
+
pair: ['print', 'rotate', 'yes'],
|
|
70
|
+
'token show': ['show', 'raw'],
|
|
71
|
+
'token rotate': ['show', 'raw', 'yes'],
|
|
72
|
+
'audit tail': ['limit', 'denied-only', 'format'],
|
|
73
|
+
'audit verify': ['limit', 'format'],
|
|
74
|
+
attach: ['connection'],
|
|
75
|
+
outputs: ['show'],
|
|
76
|
+
start: ['port', 'only'],
|
|
77
|
+
'mcp stdio': ['only'],
|
|
78
|
+
'mcp add': ['name', 'scope', 'token-env', 'dry-run', 'force', 'no-skill', 'headless'],
|
|
79
|
+
'mcp skill': ['print', 'force'],
|
|
80
|
+
'mcp list': ['name', 'scope'],
|
|
81
|
+
// `--yes` because it installs the app when nothing answers the scheme, and
|
|
82
|
+
// that is the one prompt in this CLI that puts an application on the machine.
|
|
83
|
+
dashboard: ['print', 'yes'],
|
|
84
|
+
desktop: ['print', 'yes'],
|
|
85
|
+
skill: ['print', 'force'],
|
|
86
|
+
deploy: ['dry-run', 'iam', 'access', 'service-account', 'tag', 'yes', 'non-interactive'],
|
|
87
|
+
'secrets push': ['from', 'to', 'overwrite', 'dry-run'],
|
|
88
|
+
sync: ['dry-run', 'from', 'discover', 'prefer'],
|
|
89
|
+
'sync targets': ['dry-run', 'from', 'discover', 'prefer'],
|
|
90
|
+
'sync workspaces': ['dry-run', 'from', 'discover', 'prefer'],
|
|
91
|
+
update: ['check'],
|
|
92
|
+
'identity add': ['note'],
|
|
93
|
+
memory: ['connection', 'title', 'description', 'file', 'tag'],
|
|
94
|
+
// `--yes` on both, because both have a delete that asks first.
|
|
95
|
+
tasks: ['connection', 'title', 'status', 'due', 'tag', 'yes'],
|
|
96
|
+
assets: ['connection', 'name', 'content-type', 'yes'],
|
|
97
|
+
skills: ['connection', 'title', 'description', 'file'],
|
|
98
|
+
vault: ['connection'],
|
|
99
|
+
// `alias`, `attr` and `related` are repeatable — see `ownerFlags`. `name`
|
|
100
|
+
// overrides the id derived from the positional name, which is how you get
|
|
101
|
+
// `acme-bv` rather than `acme-b-v`.
|
|
102
|
+
entities: ['connection', 'type', 'name', 'alias', 'attr', 'related', 'tag', 'yes'],
|
|
103
|
+
// `no-migrate` is listed beside `migrate` because they are three states
|
|
104
|
+
// rather than two: neither one asks, and a run with no terminal has to be
|
|
105
|
+
// able to say which it meant (ADR-041).
|
|
106
|
+
knowledge: ['repo', 'branch', 'path', 'migrate', 'no-migrate', 'keep', 'allow-public', 'replace', 'yes'],
|
|
107
|
+
};
|
|
108
|
+
|
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
|
-
/**
|
|
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, '
|
|
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
|
+
}
|
package/src/cli/callback-page.ts
CHANGED
|
@@ -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
|
-
*
|
|
73
|
+
* Something went wrong, said to the person it went wrong for.
|
|
93
74
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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, '
|
|
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
|
-
*
|
|
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
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|