@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.
- package/README.md +83 -16
- package/SECURITY.md +69 -0
- package/bin/ukp.js +31 -7
- package/docs/remote-deployment.md +86 -8
- package/package.json +11 -6
- package/skills/ukp/SKILL.md +32 -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 +54 -3
- package/src/cli.ts +32 -64
- package/src/commands/guide-content/client.txt +19 -6
- package/src/commands/guide-content/remote.txt +21 -0
- package/src/commands/guide-content/rg.txt +2 -2
- package/src/commands/init.ts +224 -9
- 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 +5 -5
- package/src/commands/serve.ts +313 -29
- 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 +228 -45
- package/src/spawn-relay.ts +21 -3
package/src/commands/serve.ts
CHANGED
|
@@ -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
|
|
23
|
-
* (tests point this at a
|
|
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
|
|
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
|
|
84
|
-
" any listed token authorizes
|
|
85
|
-
"
|
|
86
|
-
"
|
|
87
|
-
"
|
|
88
|
-
"
|
|
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
|
|
98
|
-
" mode -, SAN covers this host's addresses; remote
|
|
99
|
-
" 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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
258
|
-
* to start serving
|
|
259
|
-
*
|
|
260
|
-
* loopback
|
|
261
|
-
*
|
|
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
|
-
|
|
515
|
+
authArmed: boolean,
|
|
265
516
|
allowAnonymous: boolean,
|
|
266
517
|
): { ok: true; authRequired: boolean } | { ok: false; reason: string } {
|
|
267
|
-
if (
|
|
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
|
|
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 &&
|
|
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,
|
|
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
|
-
...(
|
|
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
|