@catheadowl/ukp 0.2.6 → 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,6 +2,7 @@ import {
2
2
  executeKitCommand,
3
3
  parseKitArgs,
4
4
  renderKitHelp,
5
+ renderKitUsageError,
5
6
  KitUsageError,
6
7
  type KitCommandResult,
7
8
  type KitParsed,
@@ -9,9 +10,22 @@ import {
9
10
  } from "./kit.ts";
10
11
  import { readRegistry } from "../registry.ts";
11
12
  import { homedir } from "node:os";
13
+ import { join, dirname } from "node:path";
14
+ import { mkdirSync } from "node:fs";
12
15
  import { normalizeTlsSanEntry } from "../capabilities/tls-identity.ts";
13
16
  import { DISCOVERY_PATH, startUkpServer, type ServeInfo } from "../server.ts";
14
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";
15
29
 
16
30
  export interface ServeCommandContext {
17
31
  currentDirectory: string;
@@ -19,9 +33,9 @@ export interface ServeCommandContext {
19
33
  qmdCommand?: readonly string[];
20
34
  /** Overrides `process.env.UKP_SERVE_TOKEN` for tests. */
21
35
  tokens?: readonly string[];
22
- /** Overrides the home directory `--print-task --write` writes into
23
- * (tests point this at a scratch home; production resolves the real
24
- * user home). */
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). */
25
39
  homeDir?: string;
26
40
  }
27
41
 
@@ -32,6 +46,207 @@ function parseServeTokens(raw: string | undefined): string[] {
32
46
  return raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
33
47
  }
34
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
+
35
250
  /** Single-source command spec (ADR 0024): long-running host command — the
36
251
  * Bun.serve listener keeps the process alive after runCli returns its exit
37
252
  * code; SIGINT/SIGTERM stop the listener so the process exits with it.
@@ -45,13 +260,13 @@ export const SERVE_SPEC: UkpCommandSpec = {
45
260
  group: "operations",
46
261
  description:
47
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.",
48
- 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]]",
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>",
49
264
  options: [
50
265
  { flags: "--endpoint <name>", help: "the registered endpoint to expose; omit it to serve a host door (/e/<name>/ routing, one port) instead" },
51
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" },
52
267
  { flags: "--host <addr>", help: "listen address (default 127.0.0.1, loopback only without a token)" },
53
268
  { flags: "--port <n>", help: "listen port (default 8570)" },
54
- { 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)" },
55
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 },
56
271
  { flags: "--tls-cert <pem>", help: "TLS certificate (chain) PEM path - Let's Encrypt, mkcert, or a private CA; pair with --tls-key" },
57
272
  { flags: "--tls-key <pem>", help: "TLS private key PEM path; pair with --tls-cert" },
@@ -70,6 +285,8 @@ export const SERVE_SPEC: UkpCommandSpec = {
70
285
  " GET /v1/read?ref=...|uri=... endpoint-relative ref or ukp:// URI",
71
286
  " GET /v1/nav?path=&depth= markdown route view (ukp.nav.v1)",
72
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",
73
290
  "",
74
291
  "Wire (ukp-remote v1), host door (no --endpoint):",
75
292
  ` GET ${DISCOVERY_PATH} door document (scope:"host", endpoint roster -`,
@@ -78,14 +295,32 @@ export const SERVE_SPEC: UkpCommandSpec = {
78
295
  " as single-endpoint mode; TOFU pins anchor here)",
79
296
  " POST /e/<name>/v1/search capabilities routed by endpoint name",
80
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",
81
300
  "",
82
301
  "Auth (deny by default):",
83
- " Serving requires UKP_SERVE_TOKEN (comma-separate several: alice,bob;",
84
- " any listed token authorizes). Tokenless serving is an explicit opt-in:",
85
- " --allow-anonymous, loopback binds only. The discovery document stays",
86
- " public. A reverse proxy on the same host forwards from the public side",
87
- " to the loopback bind, so proxy deployments treat the token as",
88
- " 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).",
89
324
  "",
90
325
  " Loopback-without-token covers testing/dogfood AND the ssh door",
91
326
  " deployment: bind the door on the remote host's loopback and let ssh",
@@ -94,9 +329,10 @@ export const SERVE_SPEC: UkpCommandSpec = {
94
329
  " DOCKER_HOST=ssh:// posture). It is not the way to consume a",
95
330
  " same-machine endpoint - register its local path instead.",
96
331
  " TLS: pass --tls to serve HTTPS with a self-signed identity",
97
- " (generated under .ukp/tls/ - under the registry directory in door",
98
- " mode -, SAN covers this host's addresses; remote clients TOFU-pin it",
99
- " 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",
100
336
  " for your own certificate (Let's Encrypt - IP certs available since",
101
337
  " 2026-01 -, mkcert, a private CA). Plain HTTP remains loopback-only by",
102
338
  " admission; public exposure needs TLS or the ssh:// transport.",
@@ -216,6 +452,19 @@ function isLoopback(host: string): boolean {
216
452
  return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
217
453
  }
218
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
+
219
468
  export function renderServeBanner(info: ServeInfo): string {
220
469
  if (info.mode === "door") {
221
470
  return [
@@ -227,7 +476,7 @@ export function renderServeBanner(info: ServeInfo): string {
227
476
  ? [` select: ${info.door!.select.join(", ")} (subset door - unselected local endpoints are not served, routed, or declared)`]
228
477
  : []),
229
478
  ` write: ${info.door!.write.length > 0 ? info.door!.write.join(", ") + " (propose via PUT /e/<name>/v1/propose/<id>)" : "(no endpoint declares propose)"}`,
230
- ` auth: ${info.authRequired ? "bearer token required" : "no token (loopback bind; ssh-forwarded clients authenticate by SSH key)"}`,
479
+ authBannerLine(info),
231
480
  ` rg: ${info.rg === "ok" ? "ok" : "missing (rg calls skip with a warning - install ripgrep on this door's PATH)"}`,
232
481
  ...(info.maxIdleSeconds !== undefined
233
482
  ? [` idle: exits after ${info.maxIdleSeconds}s without requests (--max-idle)`]
@@ -242,7 +491,7 @@ export function renderServeBanner(info: ServeInfo): string {
242
491
  `serving endpoint '${info.endpoint}' (ukp-remote v1)`,
243
492
  ` listening: ${info.url}`,
244
493
  ` discovery: ${info.url.startsWith("http") ? `${info.url}${DISCOVERY_PATH}` : info.url}`,
245
- ` auth: ${info.authRequired ? "bearer token required" : "no token (loopback only)"}`,
494
+ authBannerLine(info),
246
495
  ` rg: ${info.rg === "ok" ? "ok" : "missing (rg calls skip with a warning - install ripgrep on this door's PATH)"}`,
247
496
  ...(info.maxIdleSeconds !== undefined
248
497
  ? [` idle: exits after ${info.maxIdleSeconds}s without requests (--max-idle)`]
@@ -254,27 +503,29 @@ export function renderServeBanner(info: ServeInfo): string {
254
503
  ].join("\n");
255
504
  }
256
505
 
257
- /** Auth admission (RQ-18, web-standard deny-by-default): a token is required
258
- * to start serving; tokenless access is an explicit, loopback-only opt-in.
259
- * A reverse proxy on the same host forwards from the public side to the
260
- * loopback bind, so the proxy recipes treat UKP_SERVE_TOKEN as mandatory —
261
- * 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. */
262
513
  export function serveAuthDecision(
263
514
  host: string,
264
- tokens: readonly string[],
515
+ authArmed: boolean,
265
516
  allowAnonymous: boolean,
266
517
  ): { ok: true; authRequired: boolean } | { ok: false; reason: string } {
267
- if (tokens.length > 0) return { ok: true, authRequired: true };
518
+ if (authArmed) return { ok: true, authRequired: true };
268
519
  if (!isLoopback(host)) {
269
520
  return {
270
521
  ok: false,
271
- 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>'`,
272
523
  };
273
524
  }
274
525
  if (allowAnonymous) return { ok: true, authRequired: false };
275
526
  return {
276
527
  ok: false,
277
- 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)",
278
529
  };
279
530
  }
280
531
 
@@ -282,6 +533,28 @@ export function executeServeCommand(
282
533
  args: readonly string[],
283
534
  context: ServeCommandContext,
284
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
+ }
285
558
  return executeKitCommand(SERVE_SPEC, args, (parsed) => {
286
559
  const { endpoint, select, host, port, maxIdleSeconds, systemdSocket, write, tlsSan = [] } = toParsedServe(parsed);
287
560
  const allowAnonymous = (parsed.options as { allowAnonymous?: boolean }).allowAnonymous === true;
@@ -371,15 +644,25 @@ export function executeServeCommand(
371
644
  };
372
645
  }
373
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;
374
657
  // W10: socket activation must not admit tokenless serving — the bind
375
658
  // address belongs to the socket unit (possibly public), so the loopback
376
659
  // premise of --allow-anonymous is unverifiable here (RQ-18 review P1).
377
- if (systemdSocket && tokens.length === 0) {
660
+ if (systemdSocket && !authArmed) {
378
661
  throw new Error(
379
- "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'",
380
663
  );
381
664
  }
382
- const decision = serveAuthDecision(host, tokens, allowAnonymous);
665
+ const decision = serveAuthDecision(host, authArmed, allowAnonymous);
383
666
  if (!decision.ok) {
384
667
  const target = endpoint === undefined ? "host door" : `'${endpoint}'`;
385
668
  throw new Error(`refusing to serve ${target}: ${decision.reason}`);
@@ -392,7 +675,8 @@ export function executeServeCommand(
392
675
  port,
393
676
  ...(endpoint !== undefined ? { endpointName: endpoint } : {}),
394
677
  ...(select !== undefined ? { select } : {}),
395
- ...(tokens.length > 0 ? { tokens } : {}),
678
+ ...(gateTokens.length > 0 ? { tokens: gateTokens } : {}),
679
+ ...(tokenFilePath !== undefined ? { tokenFilePath } : {}),
396
680
  ...(maxIdleSeconds !== undefined ? { maxIdleSeconds } : {}),
397
681
  ...(systemdSocket !== undefined && systemdSocket ? { systemdSocket } : {}),
398
682
  ...(options.tls === true