@catheadowl/ukp 0.2.5 → 0.3.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 +93 -21
- package/SECURITY.md +69 -0
- package/bin/ukp.js +31 -7
- package/docs/remote-deployment.md +175 -19
- package/package.json +11 -6
- package/skills/ukp/SKILL.md +32 -0
- package/src/capabilities/door-health.ts +480 -0
- package/src/capabilities/nav.ts +209 -444
- package/src/capabilities/qmd.ts +19 -0
- package/src/capabilities/read.ts +18 -2
- package/src/capabilities/remote-client.ts +4 -2
- package/src/capabilities/search.ts +23 -7
- package/src/capabilities/session.ts +455 -0
- package/src/capabilities/tls-identity.ts +55 -3
- package/src/cli.ts +38 -64
- package/src/commands/diagnose.ts +80 -2
- package/src/commands/guide-content/client.txt +19 -6
- package/src/commands/guide-content/remote.txt +22 -1
- package/src/commands/guide-content/rg.txt +2 -2
- package/src/commands/init.ts +243 -1
- package/src/commands/inventory.ts +277 -17
- package/src/commands/nav.ts +17 -12
- package/src/commands/read.ts +1 -1
- package/src/commands/rg.ts +2 -2
- package/src/commands/search.ts +19 -3
- package/src/commands/serve-task.ts +334 -44
- package/src/commands/serve.ts +410 -44
- package/src/commands/session.ts +541 -0
- package/src/commands/use.ts +178 -0
- package/src/config/agent-backed.ts +136 -0
- package/src/config/manifest.ts +39 -3
- package/src/manifest.yml +15 -0
- package/src/path-safety.ts +9 -4
- package/src/plumbing/token-file.ts +368 -0
- package/src/registry.ts +78 -1
- package/src/server.ts +291 -75
- package/src/spawn-relay.ts +21 -3
package/src/commands/serve.ts
CHANGED
|
@@ -2,14 +2,30 @@ import {
|
|
|
2
2
|
executeKitCommand,
|
|
3
3
|
parseKitArgs,
|
|
4
4
|
renderKitHelp,
|
|
5
|
+
renderKitUsageError,
|
|
5
6
|
KitUsageError,
|
|
6
7
|
type KitCommandResult,
|
|
7
8
|
type KitParsed,
|
|
8
9
|
type UkpCommandSpec,
|
|
9
10
|
} from "./kit.ts";
|
|
11
|
+
import { readRegistry } from "../registry.ts";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join, dirname } from "node:path";
|
|
14
|
+
import { mkdirSync } from "node:fs";
|
|
10
15
|
import { normalizeTlsSanEntry } from "../capabilities/tls-identity.ts";
|
|
11
16
|
import { DISCOVERY_PATH, startUkpServer, type ServeInfo } from "../server.ts";
|
|
12
|
-
import { printTaskPreflightError, renderServeTaskArtifacts, type ServeTaskTls } from "./serve-task.ts";
|
|
17
|
+
import { printTaskPreflightError, renderServeTaskArtifacts, writeServeTaskArtifacts, type ServeTaskTls } from "./serve-task.ts";
|
|
18
|
+
import { acquireLock, releaseLock, LockBusyError } from "../fslock.ts";
|
|
19
|
+
import {
|
|
20
|
+
appendTokenLine,
|
|
21
|
+
loadTokenFile,
|
|
22
|
+
mintTokenFileKey,
|
|
23
|
+
parseTokenFileText,
|
|
24
|
+
removeTokenLines,
|
|
25
|
+
storeTokenFile,
|
|
26
|
+
TokenFileUsageError,
|
|
27
|
+
type TokenFileEntry,
|
|
28
|
+
} from "../plumbing/token-file.ts";
|
|
13
29
|
|
|
14
30
|
export interface ServeCommandContext {
|
|
15
31
|
currentDirectory: string;
|
|
@@ -17,6 +33,10 @@ export interface ServeCommandContext {
|
|
|
17
33
|
qmdCommand?: readonly string[];
|
|
18
34
|
/** Overrides `process.env.UKP_SERVE_TOKEN` for tests. */
|
|
19
35
|
tokens?: readonly string[];
|
|
36
|
+
/** Overrides the home directory the serve token file and the
|
|
37
|
+
* `--print-task --write` artifacts resolve into (tests point this at a
|
|
38
|
+
* scratch home; production resolves the real user home). */
|
|
39
|
+
homeDir?: string;
|
|
20
40
|
}
|
|
21
41
|
|
|
22
42
|
/** `UKP_SERVE_TOKEN=a,b,c` (RQ-16): comma-separated, trimmed, empty entries
|
|
@@ -26,24 +46,227 @@ function parseServeTokens(raw: string | undefined): string[] {
|
|
|
26
46
|
return raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
27
47
|
}
|
|
28
48
|
|
|
49
|
+
/** W13 / RQ-16c rehearsal point A: the labeled token file lives at
|
|
50
|
+
* `~/.ukp/tokens` (0600, hash-only rows) beside the registry — the app-dot
|
|
51
|
+
* directory plus 0600 is the CLI credential-file standard shape. */
|
|
52
|
+
export function serveTokenFilePath(context: ServeCommandContext): string {
|
|
53
|
+
return join(context.homeDir ?? homedir(), ".ukp", "tokens");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** W13 chain order (rehearsal point D), as one testable resolution: env
|
|
57
|
+
* tokens win outright — while set, the file is not consulted at all (AWS/
|
|
58
|
+
* gh/kubectl chain order; an empty env value parses to no tokens and falls
|
|
59
|
+
* through to the file, the same mainstream treatment of empty-as-unset).
|
|
60
|
+
* Otherwise the file arms the gate ONLY when it exists AND holds at least
|
|
61
|
+
* one parseable key — a missing or keyless file leaves admission exactly
|
|
62
|
+
* where RQ-18 put it (both sources absent ⇒ --allow-anonymous decides), so
|
|
63
|
+
* an anonymous door is not walled off by an empty file. A present but
|
|
64
|
+
* malformed file is a loud startup refusal instead (never half-arm). The
|
|
65
|
+
* source decision is startup-time; once armed, the file's CONTENT is
|
|
66
|
+
* fresh per request (delete every line ⇒ everyone 401s, fail closed). */
|
|
67
|
+
export function resolveServeAuthSources(
|
|
68
|
+
envTokens: readonly string[],
|
|
69
|
+
homeDir: string,
|
|
70
|
+
): { tokens: readonly string[]; tokenFilePath?: string } | { error: string } {
|
|
71
|
+
if (envTokens.length > 0) return { tokens: envTokens };
|
|
72
|
+
const path = join(homeDir, ".ukp", "tokens");
|
|
73
|
+
const loaded = loadTokenFile(path);
|
|
74
|
+
if ("error" in loaded) return { error: loaded.error };
|
|
75
|
+
if ("missing" in loaded) return { tokens: [] };
|
|
76
|
+
const parsed = parseTokenFileText(loaded.text);
|
|
77
|
+
if ("error" in parsed) {
|
|
78
|
+
return {
|
|
79
|
+
error: `token file ${path} line ${parsed.error.line}: ${parsed.error.message} (fix the line, or move the file away and reissue with 'ukp serve token add')`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return parsed.entries.length > 0 ? { tokens: [], tokenFilePath: path } : { tokens: [] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Read the current managed text under the write-discipline lock. A missing
|
|
86
|
+
* file is the empty state; anything else (unreadable, over-open
|
|
87
|
+
* permissions, format errors) is a loud failure — the token file is a
|
|
88
|
+
* credential store, and silent degradation here would half-arm a gate. */
|
|
89
|
+
function loadManagedTokenText(path: string): { text: string; entries: TokenFileEntry[] } {
|
|
90
|
+
const loaded = loadTokenFile(path);
|
|
91
|
+
if ("missing" in loaded) return { text: "", entries: [] };
|
|
92
|
+
if ("error" in loaded) throw new Error(loaded.error);
|
|
93
|
+
const parsed = parseTokenFileText(loaded.text);
|
|
94
|
+
if ("error" in parsed) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`token file ${path} line ${parsed.error.line}: ${parsed.error.message} (fix the line, or move the file away and reissue with 'ukp serve token add')`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return { text: loaded.text, entries: parsed.entries };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `ukp serve token add|list|revoke` (W13 / RQ-16c, rehearsal point B):
|
|
103
|
+
* the issuance face for the labeled token file — minting, hashing, and the
|
|
104
|
+
* 0600 discipline are not hand-editing material, so the command owns the
|
|
105
|
+
* file's managed rewrites (append/remove preserve comment lines) under the
|
|
106
|
+
* same write-discipline lock propose uses. The raw key is printed exactly
|
|
107
|
+
* once, at mint time; the file never holds it. */
|
|
108
|
+
function executeServeTokenCommand(
|
|
109
|
+
args: readonly string[],
|
|
110
|
+
context: ServeCommandContext,
|
|
111
|
+
): KitCommandResult {
|
|
112
|
+
const [verb, ...rest] = args;
|
|
113
|
+
const path = serveTokenFilePath(context);
|
|
114
|
+
const usage = "usage: ukp serve token add <label> <scopes> | ukp serve token list | ukp serve token revoke <label> (scopes: notes:ro,cad:rw - Docker-registry style; full access is '*:rw')";
|
|
115
|
+
if (verb === undefined) throw new KitUsageError(usage);
|
|
116
|
+
|
|
117
|
+
if (verb === "add") {
|
|
118
|
+
const [label, scopesSpec] = rest;
|
|
119
|
+
if (rest.length !== 2 || label === undefined || scopesSpec === undefined) {
|
|
120
|
+
throw new KitUsageError(`serve token add takes <label> <scopes> (${usage})`);
|
|
121
|
+
}
|
|
122
|
+
let minted;
|
|
123
|
+
try {
|
|
124
|
+
minted = mintTokenFileKey(label, scopesSpec);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof TokenFileUsageError) throw new KitUsageError(error.message);
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
const { sameLabel } = withTokenFileLock(path, () => {
|
|
130
|
+
const current = loadManagedTokenText(path);
|
|
131
|
+
const sameLabel = current.entries.filter((entry) => entry.label === label).length;
|
|
132
|
+
storeTokenFile(path, appendTokenLine(current.text, minted.line));
|
|
133
|
+
return { sameLabel };
|
|
134
|
+
});
|
|
135
|
+
const rotation = sameLabel > 0
|
|
136
|
+
? `\nlabel '${label}' already had ${sameLabel} active key(s); both remain valid until revoked (zero-downtime rotation).`
|
|
137
|
+
: "";
|
|
138
|
+
return {
|
|
139
|
+
exitCode: 0,
|
|
140
|
+
stdout: [
|
|
141
|
+
`issued key for '${label}' (scopes: ${scopesSpec})`,
|
|
142
|
+
"",
|
|
143
|
+
` ${minted.token}`,
|
|
144
|
+
"",
|
|
145
|
+
`stored in ${path} (0600, hash-only). The raw key is shown ONCE - hand it to the client now.${rotation}`,
|
|
146
|
+
"The serve gate re-reads the file on every request: revoking needs no restart",
|
|
147
|
+
`('ukp serve token revoke ${label}' or delete the line).`,
|
|
148
|
+
"",
|
|
149
|
+
].join("\n"),
|
|
150
|
+
stderr: "",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (verb === "list") {
|
|
155
|
+
if (rest.length > 0) throw new KitUsageError(`serve token list takes no arguments (${usage})`);
|
|
156
|
+
const loaded = loadTokenFile(path);
|
|
157
|
+
if ("missing" in loaded) {
|
|
158
|
+
return {
|
|
159
|
+
exitCode: 0,
|
|
160
|
+
stdout: `no token file at ${path} - issue a key with 'ukp serve token add <label> <scopes>'\n`,
|
|
161
|
+
stderr: "",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if ("error" in loaded) throw new Error(loaded.error);
|
|
165
|
+
const parsed = parseTokenFileText(loaded.text);
|
|
166
|
+
if ("error" in parsed) {
|
|
167
|
+
throw new Error(`token file ${path} line ${parsed.error.line}: ${parsed.error.message}`);
|
|
168
|
+
}
|
|
169
|
+
if (parsed.entries.length === 0) {
|
|
170
|
+
return {
|
|
171
|
+
exitCode: 0,
|
|
172
|
+
stdout: `token file ${path} holds no keys (only comments) - issue one with 'ukp serve token add <label> <scopes>'\n`,
|
|
173
|
+
stderr: "",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const labelWidth = Math.max(...parsed.entries.map((entry) => entry.label.length), "label".length);
|
|
177
|
+
const idWidth = Math.max(...parsed.entries.map((entry) => entry.keyId.length), "key-id".length);
|
|
178
|
+
const rows = parsed.entries.map((entry) =>
|
|
179
|
+
` ${entry.label.padEnd(labelWidth)} ${entry.keyId.padEnd(idWidth)} ${
|
|
180
|
+
Object.entries(entry.scopes).map(([name, access]) => `${name}:${access}`).join(",")
|
|
181
|
+
}`);
|
|
182
|
+
return {
|
|
183
|
+
exitCode: 0,
|
|
184
|
+
stdout: [
|
|
185
|
+
`token file ${path} - ${parsed.entries.length} key(s) (hashes omitted; revoke deletes the whole label)`,
|
|
186
|
+
` ${"label".padEnd(labelWidth)} ${"key-id".padEnd(idWidth)} scopes`,
|
|
187
|
+
...rows,
|
|
188
|
+
"",
|
|
189
|
+
].join("\n"),
|
|
190
|
+
stderr: "",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (verb === "revoke") {
|
|
195
|
+
const [label] = rest;
|
|
196
|
+
if (rest.length !== 1 || label === undefined) {
|
|
197
|
+
throw new KitUsageError(`serve token revoke takes <label> (${usage})`);
|
|
198
|
+
}
|
|
199
|
+
const removed = withTokenFileLock(path, () => {
|
|
200
|
+
const current = loadManagedTokenText(path);
|
|
201
|
+
const result = removeTokenLines(current.text, label);
|
|
202
|
+
if (result.removed === 0) {
|
|
203
|
+
const labels = [...new Set(current.entries.map((entry) => entry.label))];
|
|
204
|
+
throw new KitUsageError(
|
|
205
|
+
`no key with label '${label}' in ${path}${labels.length > 0 ? ` (labels present: ${labels.join(", ")})` : " (the file holds no keys)"}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
storeTokenFile(path, result.text);
|
|
209
|
+
return result.removed;
|
|
210
|
+
});
|
|
211
|
+
return {
|
|
212
|
+
exitCode: 0,
|
|
213
|
+
stdout: [
|
|
214
|
+
`revoked ${removed} key(s) with label '${label}' from ${path}`,
|
|
215
|
+
"a running gate stops accepting them on the next request (fresh read - no restart needed).",
|
|
216
|
+
"",
|
|
217
|
+
].join("\n"),
|
|
218
|
+
stderr: "",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
throw new KitUsageError(`unknown serve token verb '${verb}' (${usage})`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Cross-process read-modify-write discipline for the token file (the same
|
|
226
|
+
* fslock propose uses): two concurrent `token add` runs must not lose a
|
|
227
|
+
* line to a last-writer-wins rename. Serving itself never takes the lock —
|
|
228
|
+
* fresh readers observe atomic replaces, never partial files. */
|
|
229
|
+
function withTokenFileLock<T>(path: string, work: () => T): T {
|
|
230
|
+
const lockPath = `${path}.lock`;
|
|
231
|
+
// The lock file's directory must exist before the exclusive create —
|
|
232
|
+
// a first-ever `token add` on a machine without ~/.ukp yet.
|
|
233
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
234
|
+
let descriptor: number;
|
|
235
|
+
try {
|
|
236
|
+
descriptor = acquireLock(lockPath);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error instanceof LockBusyError) {
|
|
239
|
+
throw new Error(`another ukp process is editing the token file (${lockPath} is locked): retry shortly`);
|
|
240
|
+
}
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
return work();
|
|
245
|
+
} finally {
|
|
246
|
+
releaseLock(lockPath, descriptor);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
29
250
|
/** Single-source command spec (ADR 0024): long-running host command — the
|
|
30
251
|
* Bun.serve listener keeps the process alive after runCli returns its exit
|
|
31
252
|
* code; SIGINT/SIGTERM stop the listener so the process exits with it.
|
|
32
253
|
* `--endpoint` is optional (W7 / ADR-REM-004): present = the original
|
|
33
|
-
* single-endpoint mode; absent = host door mode serving the
|
|
34
|
-
*
|
|
254
|
+
* single-endpoint mode; absent = host door mode serving the registry over
|
|
255
|
+
* `/e/<name>/` routing — every local binding, or the `--select` whitelist
|
|
256
|
+
* subset (ADR-REM-010). */
|
|
35
257
|
export const SERVE_SPEC: UkpCommandSpec = {
|
|
36
258
|
name: "serve",
|
|
37
|
-
summary: "serve one endpoint
|
|
259
|
+
summary: "serve one endpoint, a selected subset, or the whole registry as a host door - one HTTP server, one port - for remote UKP clients",
|
|
38
260
|
group: "operations",
|
|
39
261
|
description:
|
|
40
|
-
"Expose a registered endpoint over HTTP using the ukp-remote wire, or - without --endpoint - serve
|
|
41
|
-
usage: "[--endpoint <name>] [--host <addr>] [--port <n>] [--tls [--tls-san <ip|dns>]... | --tls-cert <pem> --tls-key <pem>] [--max-idle <seconds>] [--systemd-socket] [--print-task]",
|
|
262
|
+
"Expose a registered endpoint over HTTP using the ukp-remote wire, or - without --endpoint - serve local endpoints as one host door routed by name: all of them, or just the ones --select names.",
|
|
263
|
+
usage: "[--endpoint <name>] [--select <names>] [--host <addr>] [--port <n>] [--tls [--tls-san <ip|dns>]... | --tls-cert <pem> --tls-key <pem>] [--max-idle <seconds>] [--systemd-socket] [--print-task [--write]] | token add <label> <scopes> | token list | token revoke <label>",
|
|
42
264
|
options: [
|
|
43
|
-
{ flags: "--endpoint <name>", help: "the registered endpoint to expose; omit it to serve
|
|
265
|
+
{ flags: "--endpoint <name>", help: "the registered endpoint to expose; omit it to serve a host door (/e/<name>/ routing, one port) instead" },
|
|
266
|
+
{ flags: "--select <names>", help: "host door serving ONLY the named endpoints (comma-separated): one port, /e/<name>/ routing, and a door document that declares just this subset - unselected local endpoints are not served, routed, or declared; mutually exclusive with --endpoint" },
|
|
44
267
|
{ flags: "--host <addr>", help: "listen address (default 127.0.0.1, loopback only without a token)" },
|
|
45
268
|
{ flags: "--port <n>", help: "listen port (default 8570)" },
|
|
46
|
-
{ flags: "--tls", help: "serve HTTPS with a self-signed identity (auto-generated under
|
|
269
|
+
{ flags: "--tls", help: "serve HTTPS with a self-signed identity (auto-generated under ~/.ukp/tls/<name>/, machine-local beside the registry; SAN covers this host's addresses; clients pin it at registration)" },
|
|
47
270
|
{ flags: "--tls-san <ip|dns>", help: "extra SAN entry for the --tls self-signed identity (repeatable): a public/NAT IP or hostname no NIC of this host carries; a persisted certificate missing an entry is re-signed over the same key (pin unchanged, pinned clients re-anchor)", multi: true },
|
|
48
271
|
{ flags: "--tls-cert <pem>", help: "TLS certificate (chain) PEM path - Let's Encrypt, mkcert, or a private CA; pair with --tls-key" },
|
|
49
272
|
{ flags: "--tls-key <pem>", help: "TLS private key PEM path; pair with --tls-cert" },
|
|
@@ -51,6 +274,7 @@ export const SERVE_SPEC: UkpCommandSpec = {
|
|
|
51
274
|
{ flags: "--max-idle <seconds>", help: "exit after <seconds> without requests (self-reap; the orphan backstop for on-demand-woken doors - fractional values accepted for tests)" },
|
|
52
275
|
{ flags: "--systemd-socket", help: "serve on the systemd socket-activation listener (LISTEN_FDS fd 3) instead of binding a port - the port belongs to your .socket unit; Linux-only" },
|
|
53
276
|
{ flags: "--print-task", help: "Windows: print this door's process-manager artifacts (start script, hidden launcher, Task Scheduler command, firewall rule) instead of serving - review and apply them yourself; nothing is installed or started for you, and the token is yours to paste (never printed)" },
|
|
277
|
+
{ flags: "--write", help: "with --print-task: write the two artifact files (%USERPROFILE%\\.ukp\\start-door.cmd with the token interpolated from UKP_SERVE_TOKEN, plus the hidden launcher) instead of printing them - existing files are never overwritten, the token is never displayed, and the task/firewall steps stay on stdout for you to apply" },
|
|
54
278
|
],
|
|
55
279
|
helpSuffix: [
|
|
56
280
|
"",
|
|
@@ -61,21 +285,42 @@ export const SERVE_SPEC: UkpCommandSpec = {
|
|
|
61
285
|
" GET /v1/read?ref=...|uri=... endpoint-relative ref or ukp:// URI",
|
|
62
286
|
" GET /v1/nav?path=&depth= markdown route view (ukp.nav.v1)",
|
|
63
287
|
" GET /v1/rg?query=... lexical search (ukp.rg.v1, ukp_uri handoff)",
|
|
288
|
+
" PUT /v1/propose/<id> proposal upsert (ukp.propose.v1) - the write",
|
|
289
|
+
" face; a scoped key needs this endpoint at rw",
|
|
64
290
|
"",
|
|
65
291
|
"Wire (ukp-remote v1), host door (no --endpoint):",
|
|
66
|
-
` GET ${DISCOVERY_PATH} door document (scope:"host", endpoint roster
|
|
292
|
+
` GET ${DISCOVERY_PATH} door document (scope:"host", endpoint roster -`,
|
|
293
|
+
" the --select subset when --select is given)",
|
|
67
294
|
" GET /e/<name>/.well-known/ukp.json per-endpoint document (same shape",
|
|
68
295
|
" as single-endpoint mode; TOFU pins anchor here)",
|
|
69
296
|
" POST /e/<name>/v1/search capabilities routed by endpoint name",
|
|
70
297
|
" GET /e/<name>/v1/read|nav|rg",
|
|
298
|
+
" PUT /e/<name>/v1/propose/<id> proposal upsert (ukp.propose.v1) - the write",
|
|
299
|
+
" face; a scoped key needs <name> at rw",
|
|
71
300
|
"",
|
|
72
301
|
"Auth (deny by default):",
|
|
73
|
-
" Serving requires UKP_SERVE_TOKEN (comma-separate
|
|
74
|
-
" any listed token authorizes
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
302
|
+
" Serving requires a credential: UKP_SERVE_TOKEN (comma-separate",
|
|
303
|
+
" several: alice,bob; any listed token authorizes - full power) or a",
|
|
304
|
+
" labeled key from the token file. Tokenless serving is an explicit",
|
|
305
|
+
" opt-in: --allow-anonymous, loopback binds only. The discovery",
|
|
306
|
+
" document stays public. A reverse proxy on the same host forwards",
|
|
307
|
+
" from the public side to the loopback bind, so proxy deployments",
|
|
308
|
+
" treat the credential as mandatory - serve cannot see past its own",
|
|
309
|
+
" bind address.",
|
|
310
|
+
"",
|
|
311
|
+
" UKP_SERVE_TOKEN is the token file's full-power shortcut form and",
|
|
312
|
+
" takes precedence when set (AWS/gh/kubectl chain order): the file is",
|
|
313
|
+
" not consulted while the env is present.",
|
|
314
|
+
"",
|
|
315
|
+
" Token file (labeled, scoped, revocable keys):",
|
|
316
|
+
" ukp serve token add <label> <scopes> issue a key (raw shown once)",
|
|
317
|
+
" ukp serve token list labels, key ids, scopes",
|
|
318
|
+
" ukp serve token revoke <label> revoke every key of a label",
|
|
319
|
+
" Scopes are Docker-registry style, required per key: notes:ro,cad:rw",
|
|
320
|
+
" ('*' is the explicit full-power name: '*:rw'). The file lives at",
|
|
321
|
+
" ~/.ukp/tokens (0600, hash-only rows) and is re-read on every request",
|
|
322
|
+
" - deleting a line revokes without a restart. Out-of-scope endpoints",
|
|
323
|
+
" answer exactly like nonexistent ones (no 403 class exists).",
|
|
79
324
|
"",
|
|
80
325
|
" Loopback-without-token covers testing/dogfood AND the ssh door",
|
|
81
326
|
" deployment: bind the door on the remote host's loopback and let ssh",
|
|
@@ -84,9 +329,10 @@ export const SERVE_SPEC: UkpCommandSpec = {
|
|
|
84
329
|
" DOCKER_HOST=ssh:// posture). It is not the way to consume a",
|
|
85
330
|
" same-machine endpoint - register its local path instead.",
|
|
86
331
|
" TLS: pass --tls to serve HTTPS with a self-signed identity",
|
|
87
|
-
" (generated under
|
|
88
|
-
" mode -, SAN covers this host's addresses; remote
|
|
89
|
-
" at registration and refresh by re-registering), or
|
|
332
|
+
" (generated under ~/.ukp/tls/<name>/ beside the registry - the flat",
|
|
333
|
+
" ~/.ukp/tls/ in door mode -, SAN covers this host's addresses; remote",
|
|
334
|
+
" clients TOFU-pin it at registration and refresh by re-registering), or",
|
|
335
|
+
" --tls-cert/--tls-key",
|
|
90
336
|
" for your own certificate (Let's Encrypt - IP certs available since",
|
|
91
337
|
" 2026-01 -, mkcert, a private CA). Plain HTTP remains loopback-only by",
|
|
92
338
|
" admission; public exposure needs TLS or the ssh:// transport.",
|
|
@@ -103,6 +349,12 @@ export const SERVE_SPEC: UkpCommandSpec = {
|
|
|
103
349
|
export interface ParsedServe {
|
|
104
350
|
/** Present = single-endpoint mode; absent = host door mode (W7). */
|
|
105
351
|
endpoint?: string;
|
|
352
|
+
/** `--select` names (ADR-REM-010): present next to an absent endpoint =
|
|
353
|
+
* subset door — the door serves, routes, and declares only these
|
|
354
|
+
* endpoints. Parsed here exactly the way register --select parses its
|
|
355
|
+
* list (comma-separated, trimmed, empty entries dropped); the execute
|
|
356
|
+
* layer's eager check (usage error with the roster) is the admission. */
|
|
357
|
+
select?: string[];
|
|
106
358
|
host: string;
|
|
107
359
|
port: number;
|
|
108
360
|
/** Present = the --max-idle self-reap timer is armed (W9). */
|
|
@@ -112,6 +364,10 @@ export interface ParsedServe {
|
|
|
112
364
|
/** Present = render the Windows process-manager artifacts instead of
|
|
113
365
|
* serving (W12; Windows-only, print-only, never starts a listener). */
|
|
114
366
|
printTask?: boolean;
|
|
367
|
+
/** Present = the print-task artifacts' file pair lands on disk with the
|
|
368
|
+
* env token interpolated (O-020 ruling C); only valid next to
|
|
369
|
+
* --print-task, and the print surface stays secret-free. */
|
|
370
|
+
write?: boolean;
|
|
115
371
|
/** `--tls-san` values, normalized to `IP:x`/`DNS:y` SAN form (repeatable);
|
|
116
372
|
* valid only next to `--tls` — the execute layer enforces the pairing.
|
|
117
373
|
* Absent when the flag is not passed (parse output stays byte-identical
|
|
@@ -120,11 +376,24 @@ export interface ParsedServe {
|
|
|
120
376
|
}
|
|
121
377
|
|
|
122
378
|
function toParsedServe(parsed: KitParsed): ParsedServe {
|
|
123
|
-
const options = parsed.options as { host?: string; port?: string; endpoint?: string; maxIdle?: string; systemdSocket?: boolean; printTask?: boolean; tlsSan?: string[] };
|
|
379
|
+
const options = parsed.options as { host?: string; port?: string; endpoint?: string; select?: string; maxIdle?: string; systemdSocket?: boolean; printTask?: boolean; write?: boolean; tlsSan?: string[] };
|
|
124
380
|
const endpoint = options.endpoint;
|
|
125
381
|
if (endpoint !== undefined && endpoint.length === 0) {
|
|
126
382
|
throw new KitUsageError("--endpoint <name> must not be empty (omit it to serve a host door)");
|
|
127
383
|
}
|
|
384
|
+
// --select parses with register --select's exact list semantics (the same
|
|
385
|
+
// operation's other side): comma-separated, trimmed, empties dropped. A
|
|
386
|
+
// flag that parses to nothing is a usage error, not an empty whitelist.
|
|
387
|
+
let select: string[] | undefined;
|
|
388
|
+
if (options.select !== undefined) {
|
|
389
|
+
select = options.select.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
390
|
+
if (select.length === 0) {
|
|
391
|
+
throw new KitUsageError("--select requires at least one endpoint name");
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (endpoint !== undefined && select !== undefined) {
|
|
395
|
+
throw new KitUsageError("--endpoint and --select cannot be used together (--endpoint is the N=1 form; --select narrows a host door)");
|
|
396
|
+
}
|
|
128
397
|
const host = options.host ?? "127.0.0.1";
|
|
129
398
|
const port = Number(options.port ?? "8570");
|
|
130
399
|
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
@@ -138,6 +407,10 @@ function toParsedServe(parsed: KitParsed): ParsedServe {
|
|
|
138
407
|
if (printTask && systemdSocket) {
|
|
139
408
|
throw new KitUsageError("--print-task is the Windows resident form; --systemd-socket is the Linux one (systemd holds the port - see the deployment handbook)");
|
|
140
409
|
}
|
|
410
|
+
const write = options.write === true;
|
|
411
|
+
if (write && !printTask) {
|
|
412
|
+
throw new KitUsageError("--write modifies --print-task: run ukp serve --print-task --write (without --print-task, serve just serves)");
|
|
413
|
+
}
|
|
141
414
|
let maxIdleSeconds: number | undefined;
|
|
142
415
|
if (options.maxIdle !== undefined) {
|
|
143
416
|
maxIdleSeconds = Number(options.maxIdle);
|
|
@@ -160,11 +433,13 @@ function toParsedServe(parsed: KitParsed): ParsedServe {
|
|
|
160
433
|
}
|
|
161
434
|
return {
|
|
162
435
|
...(endpoint !== undefined ? { endpoint } : {}),
|
|
436
|
+
...(select !== undefined ? { select } : {}),
|
|
163
437
|
host,
|
|
164
438
|
port,
|
|
165
439
|
...(maxIdleSeconds !== undefined ? { maxIdleSeconds } : {}),
|
|
166
440
|
...(systemdSocket ? { systemdSocket } : {}),
|
|
167
441
|
...(printTask ? { printTask } : {}),
|
|
442
|
+
...(write ? { write } : {}),
|
|
168
443
|
...(tlsSan.length > 0 ? { tlsSan } : {}),
|
|
169
444
|
};
|
|
170
445
|
}
|
|
@@ -177,6 +452,19 @@ function isLoopback(host: string): boolean {
|
|
|
177
452
|
return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
|
|
178
453
|
}
|
|
179
454
|
|
|
455
|
+
/** The banner auth line, both sources (W13): the token-file form names the
|
|
456
|
+
* fresh-read revocation idiom so the operator knows deleting a line is the
|
|
457
|
+
* whole lifecycle. */
|
|
458
|
+
function authBannerLine(info: ServeInfo): string {
|
|
459
|
+
if (info.tokenFile !== undefined) {
|
|
460
|
+
return ` auth: bearer token required (token file: ${info.tokenFile.keys} key(s) - re-read per request, revoke with 'ukp serve token revoke <label>')`;
|
|
461
|
+
}
|
|
462
|
+
if (info.authRequired) return " auth: bearer token required";
|
|
463
|
+
return info.mode === "door"
|
|
464
|
+
? " auth: no token (loopback bind; ssh-forwarded clients authenticate by SSH key)"
|
|
465
|
+
: " auth: no token (loopback only)";
|
|
466
|
+
}
|
|
467
|
+
|
|
180
468
|
export function renderServeBanner(info: ServeInfo): string {
|
|
181
469
|
if (info.mode === "door") {
|
|
182
470
|
return [
|
|
@@ -184,8 +472,11 @@ export function renderServeBanner(info: ServeInfo): string {
|
|
|
184
472
|
` listening: ${info.url}`,
|
|
185
473
|
` discovery: ${info.url.startsWith("http") ? `${info.url}${DISCOVERY_PATH}` : info.url} (host door)`,
|
|
186
474
|
` endpoints: ${info.door!.endpoints.length > 0 ? info.door!.endpoints.join(", ") : "(none - register endpoints on this host)"}`,
|
|
475
|
+
...(info.door!.select !== undefined
|
|
476
|
+
? [` select: ${info.door!.select.join(", ")} (subset door - unselected local endpoints are not served, routed, or declared)`]
|
|
477
|
+
: []),
|
|
187
478
|
` write: ${info.door!.write.length > 0 ? info.door!.write.join(", ") + " (propose via PUT /e/<name>/v1/propose/<id>)" : "(no endpoint declares propose)"}`,
|
|
188
|
-
|
|
479
|
+
authBannerLine(info),
|
|
189
480
|
` rg: ${info.rg === "ok" ? "ok" : "missing (rg calls skip with a warning - install ripgrep on this door's PATH)"}`,
|
|
190
481
|
...(info.maxIdleSeconds !== undefined
|
|
191
482
|
? [` idle: exits after ${info.maxIdleSeconds}s without requests (--max-idle)`]
|
|
@@ -200,7 +491,7 @@ export function renderServeBanner(info: ServeInfo): string {
|
|
|
200
491
|
`serving endpoint '${info.endpoint}' (ukp-remote v1)`,
|
|
201
492
|
` listening: ${info.url}`,
|
|
202
493
|
` discovery: ${info.url.startsWith("http") ? `${info.url}${DISCOVERY_PATH}` : info.url}`,
|
|
203
|
-
|
|
494
|
+
authBannerLine(info),
|
|
204
495
|
` rg: ${info.rg === "ok" ? "ok" : "missing (rg calls skip with a warning - install ripgrep on this door's PATH)"}`,
|
|
205
496
|
...(info.maxIdleSeconds !== undefined
|
|
206
497
|
? [` idle: exits after ${info.maxIdleSeconds}s without requests (--max-idle)`]
|
|
@@ -212,27 +503,29 @@ export function renderServeBanner(info: ServeInfo): string {
|
|
|
212
503
|
].join("\n");
|
|
213
504
|
}
|
|
214
505
|
|
|
215
|
-
/** Auth admission (RQ-18, web-standard deny-by-default): a
|
|
216
|
-
* to start serving
|
|
217
|
-
*
|
|
218
|
-
* loopback
|
|
219
|
-
*
|
|
506
|
+
/** Auth admission (RQ-18, web-standard deny-by-default): a credential is
|
|
507
|
+
* required to start serving — an env token or at least one token-file key
|
|
508
|
+
* (W13: both sources feed the same `authArmed` bit); tokenless access is an
|
|
509
|
+
* explicit, loopback-only opt-in. A reverse proxy on the same host
|
|
510
|
+
* forwards from the public side to the loopback bind, so the proxy recipes
|
|
511
|
+
* treat the bearer requirement as mandatory — serve itself cannot see past
|
|
512
|
+
* its own bind address. */
|
|
220
513
|
export function serveAuthDecision(
|
|
221
514
|
host: string,
|
|
222
|
-
|
|
515
|
+
authArmed: boolean,
|
|
223
516
|
allowAnonymous: boolean,
|
|
224
517
|
): { ok: true; authRequired: boolean } | { ok: false; reason: string } {
|
|
225
|
-
if (
|
|
518
|
+
if (authArmed) return { ok: true, authRequired: true };
|
|
226
519
|
if (!isLoopback(host)) {
|
|
227
520
|
return {
|
|
228
521
|
ok: false,
|
|
229
|
-
reason: `refusing to serve on non-loopback ${host} without a token: set UKP_SERVE_TOKEN`,
|
|
522
|
+
reason: `refusing to serve on non-loopback ${host} without a token: set UKP_SERVE_TOKEN, or issue a file key with 'ukp serve token add <label> <scopes>'`,
|
|
230
523
|
};
|
|
231
524
|
}
|
|
232
525
|
if (allowAnonymous) return { ok: true, authRequired: false };
|
|
233
526
|
return {
|
|
234
527
|
ok: false,
|
|
235
|
-
reason: "serve requires authentication: set UKP_SERVE_TOKEN, or pass --allow-anonymous for tokenless loopback testing (a reverse proxy on the same host still requires
|
|
528
|
+
reason: "serve requires authentication: set UKP_SERVE_TOKEN, issue a file key with 'ukp serve token add <label> <scopes>', or pass --allow-anonymous for tokenless loopback testing (a reverse proxy on the same host still requires a token)",
|
|
236
529
|
};
|
|
237
530
|
}
|
|
238
531
|
|
|
@@ -240,8 +533,30 @@ export function executeServeCommand(
|
|
|
240
533
|
args: readonly string[],
|
|
241
534
|
context: ServeCommandContext,
|
|
242
535
|
): KitCommandResult {
|
|
536
|
+
// W13: `ukp serve token …` is the issuance subcommand family — dispatched
|
|
537
|
+
// ahead of the flag parser (SERVE_SPEC declares no positionals), with the
|
|
538
|
+
// same error contract executeKitCommand applies (usage errors exit 2 with
|
|
539
|
+
// the usage line, operational failures exit 1 naming the command). Help
|
|
540
|
+
// requests route to the same rendered help (which teaches the family).
|
|
541
|
+
if (args[0] === "token") {
|
|
542
|
+
if (args.slice(1).some((arg) => arg === "--help" || arg === "-h")) {
|
|
543
|
+
return { exitCode: 0, stdout: renderKitHelp(SERVE_SPEC), stderr: "" };
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
return executeServeTokenCommand(args.slice(1), context);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (error instanceof KitUsageError) {
|
|
549
|
+
return { exitCode: 2, stdout: "", stderr: renderKitUsageError(SERVE_SPEC, error.message) };
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
exitCode: 1,
|
|
553
|
+
stdout: "",
|
|
554
|
+
stderr: `ukp serve: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
}
|
|
243
558
|
return executeKitCommand(SERVE_SPEC, args, (parsed) => {
|
|
244
|
-
const { endpoint, host, port, maxIdleSeconds, systemdSocket, tlsSan = [] } = toParsedServe(parsed);
|
|
559
|
+
const { endpoint, select, host, port, maxIdleSeconds, systemdSocket, write, tlsSan = [] } = toParsedServe(parsed);
|
|
245
560
|
const allowAnonymous = (parsed.options as { allowAnonymous?: boolean }).allowAnonymous === true;
|
|
246
561
|
const printTask = (parsed.options as { printTask?: boolean }).printTask === true;
|
|
247
562
|
const options = parsed.options as { tls?: boolean; tlsCert?: string; tlsKey?: string };
|
|
@@ -258,10 +573,37 @@ export function executeServeCommand(
|
|
|
258
573
|
if (tlsSan.length > 0 && options.tls !== true) {
|
|
259
574
|
throw new KitUsageError("--tls-san is only used with --tls (an explicit --tls-cert certificate carries its own SAN)");
|
|
260
575
|
}
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
576
|
+
// Subset-door admission (ADR-REM-010): a name not among the local
|
|
577
|
+
// bindings is a usage error with the roster — the eager check mirrors
|
|
578
|
+
// register --select's door-import behavior and the ingress admission
|
|
579
|
+
// precedent (a typo silently narrowing the door is a zero-signal
|
|
580
|
+
// misconfiguration). An unreadable registry skips the check: that
|
|
581
|
+
// failure surface is the door's own, shown per request like today.
|
|
582
|
+
if (select !== undefined && endpoint === undefined) {
|
|
583
|
+
let localNames: string[] | undefined;
|
|
584
|
+
try {
|
|
585
|
+
localNames = readRegistry(context.registryPath)
|
|
586
|
+
.filter((binding) => binding.kind !== "remote")
|
|
587
|
+
.map((binding) => binding.name);
|
|
588
|
+
} catch {
|
|
589
|
+
localNames = undefined;
|
|
590
|
+
}
|
|
591
|
+
if (localNames !== undefined) {
|
|
592
|
+
const unknown = select.filter((name) => !localNames!.includes(name));
|
|
593
|
+
if (unknown.length > 0) {
|
|
594
|
+
throw new KitUsageError(
|
|
595
|
+
`--select names not registered on this host: ${unknown.join(", ")} (available: ${localNames.join(", ")})`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// W12 print branch: render the Windows artifacts and stop. No listener,
|
|
601
|
+
// no admission token check. Print mode never reads the env token - the
|
|
602
|
+
// output is secret-free by construction. Write mode (O-020 ruling C:
|
|
603
|
+
// the D-090 secret-free clause scopes to the print surface) reads
|
|
604
|
+
// UKP_SERVE_TOKEN to interpolate it into the files it writes - the
|
|
605
|
+
// token exists in exactly one place, the cmd file's bytes; stdout stays
|
|
606
|
+
// secret-free in both modes.
|
|
265
607
|
if (printTask) {
|
|
266
608
|
const preflight = printTaskPreflightError({
|
|
267
609
|
platform: process.platform,
|
|
@@ -278,27 +620,49 @@ export function executeServeCommand(
|
|
|
278
620
|
: options.tlsCert !== undefined && options.tlsKey !== undefined
|
|
279
621
|
? { mode: "certificates", certPath: options.tlsCert, keyPath: options.tlsKey }
|
|
280
622
|
: undefined;
|
|
623
|
+
const input = {
|
|
624
|
+
host,
|
|
625
|
+
port,
|
|
626
|
+
...(endpoint !== undefined ? { endpoint } : {}),
|
|
627
|
+
...(select !== undefined ? { select } : {}),
|
|
628
|
+
...(tls !== undefined ? { tls } : {}),
|
|
629
|
+
};
|
|
630
|
+
if (!write) {
|
|
631
|
+
return {
|
|
632
|
+
exitCode: 0,
|
|
633
|
+
stdout: renderServeTaskArtifacts(input),
|
|
634
|
+
stderr: "",
|
|
635
|
+
};
|
|
636
|
+
}
|
|
281
637
|
return {
|
|
282
638
|
exitCode: 0,
|
|
283
|
-
stdout:
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
...(endpoint !== undefined ? { endpoint } : {}),
|
|
287
|
-
...(tls !== undefined ? { tls } : {}),
|
|
639
|
+
stdout: writeServeTaskArtifacts(input, {
|
|
640
|
+
token: process.env.UKP_SERVE_TOKEN,
|
|
641
|
+
homeDir: context.homeDir ?? homedir(),
|
|
288
642
|
}),
|
|
289
643
|
stderr: "",
|
|
290
644
|
};
|
|
291
645
|
}
|
|
292
646
|
const tokens = context.tokens ?? parseServeTokens(process.env.UKP_SERVE_TOKEN);
|
|
647
|
+
// W13 chain order (rehearsal point D) — see resolveServeAuthSources:
|
|
648
|
+
// env is the full-power shortcut and wins outright; the file arms the
|
|
649
|
+
// gate only when it holds at least one key; a malformed file refuses
|
|
650
|
+
// startup loudly; both sources absent leaves RQ-18's anonymous opt-in
|
|
651
|
+
// exactly where it was.
|
|
652
|
+
const authSources = resolveServeAuthSources(tokens, context.homeDir ?? homedir());
|
|
653
|
+
if ("error" in authSources) throw new Error(authSources.error);
|
|
654
|
+
const gateTokens = authSources.tokens;
|
|
655
|
+
const tokenFilePath = authSources.tokenFilePath;
|
|
656
|
+
const authArmed = gateTokens.length > 0 || tokenFilePath !== undefined;
|
|
293
657
|
// W10: socket activation must not admit tokenless serving — the bind
|
|
294
658
|
// address belongs to the socket unit (possibly public), so the loopback
|
|
295
659
|
// premise of --allow-anonymous is unverifiable here (RQ-18 review P1).
|
|
296
|
-
if (systemdSocket &&
|
|
660
|
+
if (systemdSocket && !authArmed) {
|
|
297
661
|
throw new Error(
|
|
298
|
-
"refusing to serve on --systemd-socket without a token: the listener's bind address belongs to the socket unit and may be public - tokenless loopback serving cannot be verified here; set UKP_SERVE_TOKEN",
|
|
662
|
+
"refusing to serve on --systemd-socket without a token: the listener's bind address belongs to the socket unit and may be public - tokenless loopback serving cannot be verified here; set UKP_SERVE_TOKEN or issue a file key with 'ukp serve token add'",
|
|
299
663
|
);
|
|
300
664
|
}
|
|
301
|
-
const decision = serveAuthDecision(host,
|
|
665
|
+
const decision = serveAuthDecision(host, authArmed, allowAnonymous);
|
|
302
666
|
if (!decision.ok) {
|
|
303
667
|
const target = endpoint === undefined ? "host door" : `'${endpoint}'`;
|
|
304
668
|
throw new Error(`refusing to serve ${target}: ${decision.reason}`);
|
|
@@ -310,7 +674,9 @@ export function executeServeCommand(
|
|
|
310
674
|
host,
|
|
311
675
|
port,
|
|
312
676
|
...(endpoint !== undefined ? { endpointName: endpoint } : {}),
|
|
313
|
-
...(
|
|
677
|
+
...(select !== undefined ? { select } : {}),
|
|
678
|
+
...(gateTokens.length > 0 ? { tokens: gateTokens } : {}),
|
|
679
|
+
...(tokenFilePath !== undefined ? { tokenFilePath } : {}),
|
|
314
680
|
...(maxIdleSeconds !== undefined ? { maxIdleSeconds } : {}),
|
|
315
681
|
...(systemdSocket !== undefined && systemdSocket ? { systemdSocket } : {}),
|
|
316
682
|
...(options.tls === true
|