@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.
@@ -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 whole registry
34
- * over `/e/<name>/` routing. */
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 - or the whole registry as a host door, one HTTP server for every endpoint - for remote UKP clients",
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 every local endpoint as one host door routed by name.",
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 the whole registry as a host door (/e/<name>/ routing, all endpoints, one port)" },
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 .ukp/tls/, SAN covers this host's addresses; clients pin it at registration)" },
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 several: alice,bob;",
74
- " any listed token authorizes). Tokenless serving is an explicit opt-in:",
75
- " --allow-anonymous, loopback binds only. The discovery document stays",
76
- " public. A reverse proxy on the same host forwards from the public side",
77
- " to the loopback bind, so proxy deployments treat the token as",
78
- " mandatory - serve cannot see past its own bind address.",
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 .ukp/tls/ - under the registry directory in door",
88
- " mode -, SAN covers this host's addresses; remote clients TOFU-pin it",
89
- " at registration and refresh by re-registering), or --tls-cert/--tls-key",
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
- ` auth: ${info.authRequired ? "bearer token required" : "no token (loopback bind; ssh-forwarded clients authenticate by SSH key)"}`,
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
- ` auth: ${info.authRequired ? "bearer token required" : "no token (loopback only)"}`,
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 token is required
216
- * to start serving; tokenless access is an explicit, loopback-only opt-in.
217
- * A reverse proxy on the same host forwards from the public side to the
218
- * loopback bind, so the proxy recipes treat UKP_SERVE_TOKEN as mandatory —
219
- * serve itself cannot see past its own bind address. */
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
- tokens: readonly string[],
515
+ authArmed: boolean,
223
516
  allowAnonymous: boolean,
224
517
  ): { ok: true; authRequired: boolean } | { ok: false; reason: string } {
225
- if (tokens.length > 0) return { ok: true, authRequired: true };
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 UKP_SERVE_TOKEN)",
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
- // W12 print-only branch: render the Windows artifacts and stop. No
262
- // listener, no admission token check (the token is pasted into the
263
- // generated script later), and the env token is deliberately not read
264
- // — the output is secret-free by construction.
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: renderServeTaskArtifacts({
284
- host,
285
- port,
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 && tokens.length === 0) {
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, tokens, allowAnonymous);
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
- ...(tokens.length > 0 ? { tokens } : {}),
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