@catheadowl/ukp 0.1.1 → 0.2.1
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 +34 -24
- package/bin/ukp.js +82 -0
- package/package.json +4 -5
- package/src/capabilities/nav.ts +4 -2
- package/src/capabilities/read.ts +33 -3
- package/src/capabilities/remote-client.ts +351 -60
- package/src/capabilities/rg.ts +2 -0
- package/src/capabilities/search.ts +25 -5
- package/src/capabilities/tls-identity.ts +172 -30
- package/src/cli.ts +1 -1
- package/src/commands/guide-content/client.txt +12 -0
- package/src/commands/guide-content/remote.txt +64 -0
- package/src/commands/guide-content/service-qmd.txt +1 -1
- package/src/commands/guide-content/service.txt +33 -32
- package/src/commands/guide.ts +11 -4
- package/src/commands/init.ts +9 -12
- package/src/commands/inventory.ts +189 -48
- package/src/commands/nav.ts +1 -1
- package/src/commands/read.ts +48 -9
- package/src/commands/rg.ts +4 -1
- package/src/commands/search.ts +4 -1
- package/src/commands/serve.ts +86 -12
- package/src/config/manifest.ts +6 -0
- package/src/registry.ts +104 -16
- package/src/server.ts +276 -46
- package/bun.lock +0 -35
- package/tsconfig.json +0 -12
package/src/commands/serve.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type KitParsed,
|
|
8
8
|
type UkpCommandSpec,
|
|
9
9
|
} from "./kit.ts";
|
|
10
|
+
import { normalizeTlsSanEntry } from "../capabilities/tls-identity.ts";
|
|
10
11
|
import { DISCOVERY_PATH, startUkpServer, type ServeInfo } from "../server.ts";
|
|
11
12
|
|
|
12
13
|
export interface ServeCommandContext {
|
|
@@ -36,15 +37,18 @@ export const SERVE_SPEC: UkpCommandSpec = {
|
|
|
36
37
|
group: "operations",
|
|
37
38
|
description:
|
|
38
39
|
"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.",
|
|
39
|
-
usage: "[--endpoint <name>] [--host <addr>] [--port <n>] [--tls | --tls-cert <pem> --tls-key <pem>]",
|
|
40
|
+
usage: "[--endpoint <name>] [--host <addr>] [--port <n>] [--tls [--tls-san <ip|dns>]… | --tls-cert <pem> --tls-key <pem>] [--max-idle <seconds>] [--systemd-socket]",
|
|
40
41
|
options: [
|
|
41
42
|
{ 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)" },
|
|
42
43
|
{ flags: "--host <addr>", help: "listen address (default 127.0.0.1, loopback only without a token)" },
|
|
43
44
|
{ flags: "--port <n>", help: "listen port (default 8570)" },
|
|
44
45
|
{ 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)" },
|
|
46
|
+
{ 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 },
|
|
45
47
|
{ flags: "--tls-cert <pem>", help: "TLS certificate (chain) PEM path — Let's Encrypt, mkcert, or a private CA; pair with --tls-key" },
|
|
46
48
|
{ flags: "--tls-key <pem>", help: "TLS private key PEM path; pair with --tls-cert" },
|
|
47
49
|
{ flags: "--allow-anonymous", help: "permit tokenless access on loopback (local testing, or an ssh-forwarded host door where SSH carries encryption and auth; reverse-proxy deployments still require UKP_SERVE_TOKEN)" },
|
|
50
|
+
{ 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)" },
|
|
51
|
+
{ 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" },
|
|
48
52
|
],
|
|
49
53
|
helpSuffix: [
|
|
50
54
|
"",
|
|
@@ -56,14 +60,14 @@ export const SERVE_SPEC: UkpCommandSpec = {
|
|
|
56
60
|
" GET /v1/nav?path=&depth= markdown route view (ukp.nav.v1)",
|
|
57
61
|
" GET /v1/rg?query=… lexical search (ukp.rg.v1, ukp_uri handoff)",
|
|
58
62
|
"",
|
|
59
|
-
"Wire (ukp-remote v1), host door (no --endpoint
|
|
63
|
+
"Wire (ukp-remote v1), host door (no --endpoint):",
|
|
60
64
|
` GET ${DISCOVERY_PATH} door document (scope:"host", endpoint roster)`,
|
|
61
65
|
" GET /e/<name>/.well-known/ukp.json per-endpoint document (same shape",
|
|
62
66
|
" as single-endpoint mode; TOFU pins anchor here)",
|
|
63
67
|
" POST /e/<name>/v1/search capabilities routed by endpoint name",
|
|
64
68
|
" GET /e/<name>/v1/read|nav|rg",
|
|
65
69
|
"",
|
|
66
|
-
"Auth (deny by default
|
|
70
|
+
"Auth (deny by default):",
|
|
67
71
|
" Serving requires UKP_SERVE_TOKEN (comma-separate several: alice,bob;",
|
|
68
72
|
" any listed token authorizes). Tokenless serving is an explicit opt-in:",
|
|
69
73
|
" --allow-anonymous, loopback binds only. The discovery document stays",
|
|
@@ -85,6 +89,12 @@ export const SERVE_SPEC: UkpCommandSpec = {
|
|
|
85
89
|
" 2026-01 —, mkcert, a private CA). Plain HTTP remains loopback-only by",
|
|
86
90
|
" admission; public exposure needs TLS or the ssh:// transport.",
|
|
87
91
|
"",
|
|
92
|
+
" A cloud NAT/EIP host's public IP is on no NIC (--tls alone cannot",
|
|
93
|
+
" cover it): add --tls-san <public-ip> next to --tls. Repeatable; DNS",
|
|
94
|
+
" names accepted. The persisted certificate re-signs over the same key",
|
|
95
|
+
" when a new entry appears — the pin is unchanged and pinned clients",
|
|
96
|
+
" re-anchor transparently; dropping entries never re-signs.",
|
|
97
|
+
"",
|
|
88
98
|
].join("\n"),
|
|
89
99
|
};
|
|
90
100
|
|
|
@@ -93,10 +103,19 @@ export interface ParsedServe {
|
|
|
93
103
|
endpoint?: string;
|
|
94
104
|
host: string;
|
|
95
105
|
port: number;
|
|
106
|
+
/** Present = the --max-idle self-reap timer is armed (W9). */
|
|
107
|
+
maxIdleSeconds?: number;
|
|
108
|
+
/** Present = the systemd-passed listener (W10, Linux-only). */
|
|
109
|
+
systemdSocket?: boolean;
|
|
110
|
+
/** `--tls-san` values, normalized to `IP:x`/`DNS:y` SAN form (repeatable);
|
|
111
|
+
* valid only next to `--tls` — the execute layer enforces the pairing.
|
|
112
|
+
* Absent when the flag is not passed (parse output stays byte-identical
|
|
113
|
+
* to the pre-flag shape). */
|
|
114
|
+
tlsSan?: string[];
|
|
96
115
|
}
|
|
97
116
|
|
|
98
117
|
function toParsedServe(parsed: KitParsed): ParsedServe {
|
|
99
|
-
const options = parsed.options as { host?: string; port?: string; endpoint?: string };
|
|
118
|
+
const options = parsed.options as { host?: string; port?: string; endpoint?: string; maxIdle?: string; systemdSocket?: boolean; tlsSan?: string[] };
|
|
100
119
|
const endpoint = options.endpoint;
|
|
101
120
|
if (endpoint !== undefined && endpoint.length === 0) {
|
|
102
121
|
throw new KitUsageError("--endpoint <name> must not be empty (omit it to serve a host door)");
|
|
@@ -106,7 +125,35 @@ function toParsedServe(parsed: KitParsed): ParsedServe {
|
|
|
106
125
|
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
107
126
|
throw new KitUsageError("--port must be an integer between 1 and 65535");
|
|
108
127
|
}
|
|
109
|
-
|
|
128
|
+
const systemdSocket = options.systemdSocket === true;
|
|
129
|
+
if (systemdSocket && options.port !== undefined) {
|
|
130
|
+
throw new KitUsageError("--systemd-socket and --port are mutually exclusive (the socket unit owns the port)");
|
|
131
|
+
}
|
|
132
|
+
let maxIdleSeconds: number | undefined;
|
|
133
|
+
if (options.maxIdle !== undefined) {
|
|
134
|
+
maxIdleSeconds = Number(options.maxIdle);
|
|
135
|
+
if (!Number.isFinite(maxIdleSeconds) || maxIdleSeconds <= 0 || maxIdleSeconds > 86400) {
|
|
136
|
+
throw new KitUsageError("--max-idle must be a positive number of seconds (at most 86400)");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
let tlsSan: string[] = [];
|
|
140
|
+
if (options.tlsSan !== undefined) {
|
|
141
|
+
try {
|
|
142
|
+
// normalized then deduplicated — case variants of one entry collapse
|
|
143
|
+
// here, so ParsedServe carries the canonical set
|
|
144
|
+
tlsSan = [...new Set(options.tlsSan.map(normalizeTlsSanEntry))];
|
|
145
|
+
} catch (error) {
|
|
146
|
+
throw new KitUsageError(error instanceof Error ? error.message : String(error));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
...(endpoint !== undefined ? { endpoint } : {}),
|
|
151
|
+
host,
|
|
152
|
+
port,
|
|
153
|
+
...(maxIdleSeconds !== undefined ? { maxIdleSeconds } : {}),
|
|
154
|
+
...(systemdSocket ? { systemdSocket } : {}),
|
|
155
|
+
...(tlsSan.length > 0 ? { tlsSan } : {}),
|
|
156
|
+
};
|
|
110
157
|
}
|
|
111
158
|
|
|
112
159
|
export function parseServeArgs(args: readonly string[]): ParsedServe {
|
|
@@ -122,10 +169,13 @@ export function renderServeBanner(info: ServeInfo): string {
|
|
|
122
169
|
return [
|
|
123
170
|
`serving host door (ukp-remote v1)`,
|
|
124
171
|
` listening: ${info.url}`,
|
|
125
|
-
` discovery: ${info.url}${DISCOVERY_PATH} (host door)`,
|
|
172
|
+
` discovery: ${info.url.startsWith("http") ? `${info.url}${DISCOVERY_PATH}` : info.url} (host door)`,
|
|
126
173
|
` endpoints: ${info.door!.endpoints.length > 0 ? info.door!.endpoints.join(", ") : "(none — register endpoints on this host)"}`,
|
|
127
174
|
` write: ${info.door!.write.length > 0 ? info.door!.write.join(", ") + " (propose via PUT /e/<name>/v1/propose/<id>)" : "(no endpoint declares propose)"}`,
|
|
128
175
|
` auth: ${info.authRequired ? "bearer token required" : "no token (loopback bind; ssh-forwarded clients authenticate by SSH key)"}`,
|
|
176
|
+
...(info.maxIdleSeconds !== undefined
|
|
177
|
+
? [` idle: exits after ${info.maxIdleSeconds}s without requests (--max-idle)`]
|
|
178
|
+
: []),
|
|
129
179
|
...(info.tls !== undefined
|
|
130
180
|
? [` tls: ${info.tls.source === "operator" ? "operator certificate" : `self-signed identity (${info.tls.source})`} ${info.tls.pin} (SAN: ${info.tls.san})`]
|
|
131
181
|
: []),
|
|
@@ -135,8 +185,11 @@ export function renderServeBanner(info: ServeInfo): string {
|
|
|
135
185
|
return [
|
|
136
186
|
`serving endpoint '${info.endpoint}' (ukp-remote v1)`,
|
|
137
187
|
` listening: ${info.url}`,
|
|
138
|
-
` discovery: ${info.url}${DISCOVERY_PATH}`,
|
|
188
|
+
` discovery: ${info.url.startsWith("http") ? `${info.url}${DISCOVERY_PATH}` : info.url}`,
|
|
139
189
|
` auth: ${info.authRequired ? "bearer token required" : "no token (loopback only)"}`,
|
|
190
|
+
...(info.maxIdleSeconds !== undefined
|
|
191
|
+
? [` idle: exits after ${info.maxIdleSeconds}s without requests (--max-idle)`]
|
|
192
|
+
: []),
|
|
140
193
|
...(info.tls !== undefined
|
|
141
194
|
? [` tls: ${info.tls.source === "operator" ? "operator certificate" : `self-signed identity (${info.tls.source})`} ${info.tls.pin} (SAN: ${info.tls.san})`]
|
|
142
195
|
: []),
|
|
@@ -173,24 +226,37 @@ export function executeServeCommand(
|
|
|
173
226
|
context: ServeCommandContext,
|
|
174
227
|
): KitCommandResult {
|
|
175
228
|
return executeKitCommand(SERVE_SPEC, args, (parsed) => {
|
|
176
|
-
const { endpoint, host, port } = toParsedServe(parsed);
|
|
229
|
+
const { endpoint, host, port, maxIdleSeconds, systemdSocket, tlsSan = [] } = toParsedServe(parsed);
|
|
177
230
|
const allowAnonymous = (parsed.options as { allowAnonymous?: boolean }).allowAnonymous === true;
|
|
178
231
|
const options = parsed.options as { tls?: boolean; tlsCert?: string; tlsKey?: string };
|
|
179
232
|
// TLS flag family (W5'): --tls and --tls-cert/--tls-key are mutually
|
|
180
|
-
// exclusive; the explicit pair must arrive complete.
|
|
233
|
+
// exclusive; the explicit pair must arrive complete. --tls-san only
|
|
234
|
+
// shapes the self-signed generation — an operator certificate carries
|
|
235
|
+
// its own SAN, so the flag next to one is a mistake, not a no-op.
|
|
181
236
|
if (options.tls === true && (options.tlsCert !== undefined || options.tlsKey !== undefined)) {
|
|
182
237
|
throw new KitUsageError("--tls and --tls-cert/--tls-key are mutually exclusive");
|
|
183
238
|
}
|
|
184
239
|
if (options.tls !== true && (options.tlsCert !== undefined) !== (options.tlsKey !== undefined)) {
|
|
185
240
|
throw new KitUsageError("--tls-cert and --tls-key are used together");
|
|
186
241
|
}
|
|
242
|
+
if (tlsSan.length > 0 && options.tls !== true) {
|
|
243
|
+
throw new KitUsageError("--tls-san is only used with --tls (an explicit --tls-cert certificate carries its own SAN)");
|
|
244
|
+
}
|
|
187
245
|
const tokens = context.tokens ?? parseServeTokens(process.env.UKP_SERVE_TOKEN);
|
|
246
|
+
// W10: socket activation must not admit tokenless serving — the bind
|
|
247
|
+
// address belongs to the socket unit (possibly public), so the loopback
|
|
248
|
+
// premise of --allow-anonymous is unverifiable here (RQ-18 review P1).
|
|
249
|
+
if (systemdSocket && tokens.length === 0) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
"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",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
188
254
|
const decision = serveAuthDecision(host, tokens, allowAnonymous);
|
|
189
255
|
if (!decision.ok) {
|
|
190
256
|
const target = endpoint === undefined ? "host door" : `'${endpoint}'`;
|
|
191
257
|
throw new Error(`refusing to serve ${target}: ${decision.reason}`);
|
|
192
258
|
}
|
|
193
|
-
const { server, info } = startUkpServer({
|
|
259
|
+
const { server, info, stopAll } = startUkpServer({
|
|
194
260
|
currentDirectory: context.currentDirectory,
|
|
195
261
|
registryPath: context.registryPath,
|
|
196
262
|
qmdCommand: context.qmdCommand,
|
|
@@ -198,18 +264,26 @@ export function executeServeCommand(
|
|
|
198
264
|
port,
|
|
199
265
|
...(endpoint !== undefined ? { endpointName: endpoint } : {}),
|
|
200
266
|
...(tokens.length > 0 ? { tokens } : {}),
|
|
267
|
+
...(maxIdleSeconds !== undefined ? { maxIdleSeconds } : {}),
|
|
268
|
+
...(systemdSocket !== undefined && systemdSocket ? { systemdSocket } : {}),
|
|
201
269
|
...(options.tls === true
|
|
202
|
-
? { tls: { mode: "self-signed" as const } }
|
|
270
|
+
? { tls: { mode: "self-signed" as const, ...(tlsSan.length > 0 ? { sanEntries: tlsSan } : {}) } }
|
|
203
271
|
: options.tlsCert !== undefined && options.tlsKey !== undefined
|
|
204
272
|
? { tls: { mode: "certificates" as const, certPath: options.tlsCert, keyPath: options.tlsKey } }
|
|
205
273
|
: {}),
|
|
206
274
|
});
|
|
207
275
|
const stop = () => {
|
|
208
276
|
console.error(`ukp serve: stopped (${info.url})`);
|
|
209
|
-
|
|
277
|
+
stopAll();
|
|
210
278
|
};
|
|
211
279
|
process.once("SIGINT", stop);
|
|
212
280
|
process.once("SIGTERM", stop);
|
|
281
|
+
// SIGHUP = the session died (client killed the ssh that woke this door).
|
|
282
|
+
// Bun IGNORES SIGHUP by default (ali E2E: every woken door survived its
|
|
283
|
+
// session and idled to max-idle) — the explicit handler restores the
|
|
284
|
+
// session-bound lifecycle; --max-idle stays the backstop for abnormal
|
|
285
|
+
// disconnects where no signal ever arrives. Never fires on Windows.
|
|
286
|
+
process.once("SIGHUP", stop);
|
|
213
287
|
// Exit code 0 flows out through runCli; the server listener keeps the
|
|
214
288
|
// process alive until the signal handler stops it.
|
|
215
289
|
return { exitCode: 0, stdout: renderServeBanner(info), stderr: "" };
|
package/src/config/manifest.ts
CHANGED
|
@@ -147,6 +147,12 @@ export function loadManifest(serviceFolder: string): LoadedManifest {
|
|
|
147
147
|
const normalized = normalizeFileNativeFlatKeys(withoutLegacyGet, (message) => {
|
|
148
148
|
throw new ManifestError(message);
|
|
149
149
|
});
|
|
150
|
+
if (isRecord(normalized) && !Object.hasOwn(normalized, "capabilities")) {
|
|
151
|
+
throw new ManifestError(
|
|
152
|
+
"Service Manifest schema is invalid: missing required [capabilities] table; "
|
|
153
|
+
+ "use an empty [capabilities] table for the provider-free read/nav/rg baseline",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
150
156
|
const result = manifestSchema.safeParse(normalized);
|
|
151
157
|
if (!result.success) {
|
|
152
158
|
throw new ManifestError(`Service Manifest schema is invalid: ${result.error.message}`);
|
package/src/registry.ts
CHANGED
|
@@ -29,6 +29,12 @@ const bindingSchema = z.object({
|
|
|
29
29
|
/** Client credential stored in the binding (D-078: plaintext, 0600 file —
|
|
30
30
|
* AWS credentials-file / netrc convention; env override wins at call time). */
|
|
31
31
|
token: z.string().min(1).optional(),
|
|
32
|
+
/** Remote-declared endpoint name snapshotted at registration
|
|
33
|
+
* (ADR-REM-007 / D-086): provenance and expected-assertion target. The
|
|
34
|
+
* binding's `name` is the consumer-chosen handle (defaults to the declared
|
|
35
|
+
* name); service identity stays with instance_uid / TLS pins. The profile
|
|
36
|
+
* check lives in validateBindings, next to `name` itself. */
|
|
37
|
+
declared_name: z.string().min(1).optional(),
|
|
32
38
|
/** TLS trust anchor pinned at registration (W5' / D-079, self-signed
|
|
33
39
|
* servers only — public-CA chains are not pinned): `tls_cert` is the PEM
|
|
34
40
|
* used as the fetch trust anchor, `tls_pin` is the RFC 7469 SPKI pin that
|
|
@@ -53,6 +59,8 @@ export interface RegistryBinding {
|
|
|
53
59
|
kind?: "remote";
|
|
54
60
|
url?: string;
|
|
55
61
|
instance_uid?: string;
|
|
62
|
+
/** Remote-declared name snapshot (ADR-REM-007 / D-086); see bindingSchema. */
|
|
63
|
+
declared_name?: string;
|
|
56
64
|
/** Remote-only stored credential (plaintext; env takes precedence). */
|
|
57
65
|
token?: string;
|
|
58
66
|
/** Remote-only TLS anchor (W5' / D-079); see bindingSchema. */
|
|
@@ -197,6 +205,37 @@ export class RegistryError extends Error {
|
|
|
197
205
|
}
|
|
198
206
|
}
|
|
199
207
|
|
|
208
|
+
/** Registration-only admission (D-089): since W9's on-demand wake an explicit
|
|
209
|
+
* port in an ssh:// url selects nothing — the woken door listens on a
|
|
210
|
+
* client-chosen loopback port — so intake rejects the dead grammar slot
|
|
211
|
+
* instead of documenting it (guide complexity attribution, D-087).
|
|
212
|
+
* Parse-time surfaces (parseRemoteUrl / assertRemoteUrlAllowed) stay
|
|
213
|
+
* permissive on purpose: bindings written by the resident-door era (W4–W8,
|
|
214
|
+
* e.g. `ssh://host:8571`) keep loading and calling; migrating one is an
|
|
215
|
+
* explicit unregister + register without the port (idempotency keys on the
|
|
216
|
+
* url, so an in-place refresh is not offered). */
|
|
217
|
+
export function assertRegistrableRemoteUrl(raw: string): void {
|
|
218
|
+
assertRemoteUrlAllowed(raw);
|
|
219
|
+
// assertRemoteUrlAllowed returning without throwing means parseRemoteUrl is
|
|
220
|
+
// defined for this url — and new URL(raw) cannot throw either. No defensive
|
|
221
|
+
// try/catch: a rejection helper fails closed by construction.
|
|
222
|
+
const parts = parseRemoteUrl(raw)!;
|
|
223
|
+
const parsed = new URL(raw);
|
|
224
|
+
if (parts.scheme === "ssh" && parsed.port !== "") {
|
|
225
|
+
// The remedy is deliberately built portless from parts — `origin` would
|
|
226
|
+
// carry the rejected port right back into the suggestion. URL.hostname
|
|
227
|
+
// drops IPv6 brackets, so they are re-added for that shape.
|
|
228
|
+
const host = parts.host.includes(":") ? `[${parts.host}]` : parts.host;
|
|
229
|
+
const portless = `ssh://${parts.user !== undefined ? `${parts.user}@` : ""}${host}`
|
|
230
|
+
+ `${parts.endpointName !== undefined ? `/${parts.endpointName}` : ""}`;
|
|
231
|
+
throw new RegistryError(
|
|
232
|
+
`ssh:// urls take no port: the woken door listens on a client-chosen loopback port and the url port selects nothing`
|
|
233
|
+
+ ` — register '${portless}' instead`
|
|
234
|
+
+ ` (for a non-standard sshd port, use an ssh config Host alias)`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
200
239
|
export class RegistryBusyError extends RegistryError {
|
|
201
240
|
constructor(path: string) {
|
|
202
241
|
super(
|
|
@@ -221,6 +260,9 @@ function validateBindings(endpoints: readonly RegistryBinding[]): RegistryBindin
|
|
|
221
260
|
if (endpoint.url === undefined) {
|
|
222
261
|
throw new RegistryError(`remote binding '${endpoint.name}' requires a url`);
|
|
223
262
|
}
|
|
263
|
+
if (endpoint.declared_name !== undefined && !ENDPOINT_NAME.test(endpoint.declared_name)) {
|
|
264
|
+
throw new RegistryError(`remote binding '${endpoint.name}' carries an invalid declared name '${endpoint.declared_name}'`);
|
|
265
|
+
}
|
|
224
266
|
if ((endpoint.tls_cert !== undefined) !== (endpoint.tls_pin !== undefined)) {
|
|
225
267
|
throw new RegistryError(`remote binding '${endpoint.name}' must carry tls_cert and tls_pin together`);
|
|
226
268
|
}
|
|
@@ -230,9 +272,10 @@ function validateBindings(endpoints: readonly RegistryBinding[]): RegistryBindin
|
|
|
230
272
|
} else {
|
|
231
273
|
if (
|
|
232
274
|
endpoint.url !== undefined || endpoint.instance_uid !== undefined || endpoint.token !== undefined
|
|
275
|
+
|| endpoint.declared_name !== undefined
|
|
233
276
|
|| endpoint.tls_cert !== undefined || endpoint.tls_pin !== undefined
|
|
234
277
|
) {
|
|
235
|
-
throw new RegistryError(`local binding '${endpoint.name}' must not carry remote fields (url/instance_uid/token/tls)`);
|
|
278
|
+
throw new RegistryError(`local binding '${endpoint.name}' must not carry remote fields (url/instance_uid/token/declared_name/tls)`);
|
|
236
279
|
}
|
|
237
280
|
if (endpoint.path === undefined || !isAbsolute(endpoint.path)) {
|
|
238
281
|
throw new RegistryError(`registry path must be absolute: ${endpoint.path ?? "(missing)"}`);
|
|
@@ -273,14 +316,16 @@ export function readRegistry(registryPath: string): RegistryBinding[] {
|
|
|
273
316
|
}
|
|
274
317
|
|
|
275
318
|
/** Canonical serialization (configuration-contracts canonical form): local rows
|
|
276
|
-
* `{name, path}`, remote rows `{name, kind, url, instance_uid?}`
|
|
277
|
-
*
|
|
319
|
+
* `{name, path}`, remote rows `{name, kind, url, declared_name?, instance_uid?}`
|
|
320
|
+
* (plus credentials/pins when present) — key order is insertion order, so
|
|
321
|
+
* build plain ordered objects before stringify. */
|
|
278
322
|
function toSerializableBinding(binding: RegistryBinding): Record<string, string> {
|
|
279
323
|
if (binding.kind === "remote") {
|
|
280
324
|
return {
|
|
281
325
|
name: binding.name,
|
|
282
326
|
kind: "remote",
|
|
283
327
|
url: binding.url!,
|
|
328
|
+
...(binding.declared_name !== undefined ? { declared_name: binding.declared_name } : {}),
|
|
284
329
|
...(binding.instance_uid !== undefined ? { instance_uid: binding.instance_uid } : {}),
|
|
285
330
|
...(binding.token !== undefined ? { token: binding.token } : {}),
|
|
286
331
|
...(binding.tls_cert !== undefined ? { tls_cert: binding.tls_cert } : {}),
|
|
@@ -316,8 +361,12 @@ export function registerBinding(endpoints: readonly RegistryBinding[], binding:
|
|
|
316
361
|
return validateBindings([...endpoints, canonical]);
|
|
317
362
|
}
|
|
318
363
|
|
|
319
|
-
/** Remote registration (D-077
|
|
320
|
-
*
|
|
364
|
+
/** Remote registration (D-077; naming residence ADR-REM-007 / D-086): the
|
|
365
|
+
* binding `name` is the consumer-chosen handle (defaults to the declared
|
|
366
|
+
* name), `declared_name` is the registration-time provenance snapshot.
|
|
367
|
+
* Idempotency keys on url+declared name → refresh under the EXISTING handle
|
|
368
|
+
* (N-2): one url, one handle; a second handle for the same instance goes
|
|
369
|
+
* through a different url and warns at list time, never here. */
|
|
321
370
|
export function registerRemoteBinding(
|
|
322
371
|
endpoints: readonly RegistryBinding[],
|
|
323
372
|
binding: RegistryBinding,
|
|
@@ -326,26 +375,57 @@ export function registerRemoteBinding(
|
|
|
326
375
|
name: binding.name,
|
|
327
376
|
kind: "remote",
|
|
328
377
|
url: binding.url!,
|
|
378
|
+
...(binding.declared_name !== undefined ? { declared_name: binding.declared_name } : {}),
|
|
329
379
|
...(binding.instance_uid !== undefined ? { instance_uid: binding.instance_uid } : {}),
|
|
330
380
|
...(binding.token !== undefined ? { token: binding.token } : {}),
|
|
331
381
|
...(binding.tls_cert !== undefined ? { tls_cert: binding.tls_cert } : {}),
|
|
332
382
|
...(binding.tls_pin !== undefined ? { tls_pin: binding.tls_pin } : {}),
|
|
333
383
|
};
|
|
384
|
+
// API-seam enforcement of the registration-only ssh port rule (D-089): the
|
|
385
|
+
// registry never gains a NEW ported ssh binding, while legacy ones keep
|
|
386
|
+
// loading (validateBindings uses the permissive parse-time check).
|
|
387
|
+
assertRegistrableRemoteUrl(canonical.url!);
|
|
388
|
+
const sameUrl = endpoints.find((endpoint) => endpoint.kind === "remote" && endpoint.url === canonical.url);
|
|
389
|
+
if (sameUrl) {
|
|
390
|
+
// Legacy bindings (pre-ADR-REM-007) carry no declared_name; their handle
|
|
391
|
+
// WAS the declared name under RQ-14, so the name stands in for it.
|
|
392
|
+
const existingDeclared = sameUrl.declared_name ?? sameUrl.name;
|
|
393
|
+
const incomingDeclared = canonical.declared_name ?? canonical.name;
|
|
394
|
+
if (existingDeclared !== incomingDeclared) {
|
|
395
|
+
throw new RegistryError(
|
|
396
|
+
`remote endpoint at ${canonical.url} now declares '${incomingDeclared}', but binding '${sameUrl.name}' was registered for '${existingDeclared}'; unregister '${sameUrl.name}' and re-register`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (canonical.name !== sameUrl.name && canonical.name !== incomingDeclared) {
|
|
400
|
+
// An explicitly different handle for an instance already tracked under
|
|
401
|
+
// this url: one url, one handle (rename = unregister + register).
|
|
402
|
+
throw new RegistryError(
|
|
403
|
+
`remote endpoint ${canonical.url} is already registered as '${sameUrl.name}'; unregister it first to change the handle`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
// Default gesture (handle = declared name) or matching handle: the
|
|
407
|
+
// idempotent refresh of the TOFU pin and credentials lands under the
|
|
408
|
+
// EXISTING handle (N-2 — service replacement stays an explicit
|
|
409
|
+
// unregister + re-register, ADR-REM-003). Direct-API callers that omit
|
|
410
|
+
// declared_name must not erase the stored snapshot (the CLI always
|
|
411
|
+
// passes it; the skip/refusal wording that mirrors this branch lives in
|
|
412
|
+
// importDoorEndpoints).
|
|
413
|
+
const refreshed: RegistryBinding = {
|
|
414
|
+
...canonical,
|
|
415
|
+
...(canonical.name !== sameUrl.name ? { name: sameUrl.name } : {}),
|
|
416
|
+
...(canonical.declared_name === undefined && sameUrl.declared_name !== undefined
|
|
417
|
+
? { declared_name: sameUrl.declared_name }
|
|
418
|
+
: {}),
|
|
419
|
+
};
|
|
420
|
+
return validateBindings([...endpoints.filter((e) => e.name !== sameUrl.name), refreshed]);
|
|
421
|
+
}
|
|
334
422
|
const sameName = endpoints.find((endpoint) => endpoint.name === canonical.name);
|
|
335
423
|
if (sameName) {
|
|
336
|
-
if (sameName.kind === "remote" && sameName.url === canonical.url) {
|
|
337
|
-
// Same name+url re-registration refreshes the TOFU pin (service
|
|
338
|
-
// replacement is an explicit re-register, ADR-REM-003).
|
|
339
|
-
return validateBindings([...endpoints.filter((e) => e.name !== canonical.name), canonical]);
|
|
340
|
-
}
|
|
341
424
|
throw new RegistryError(
|
|
342
|
-
`endpoint name '${canonical.name}' is already bound to ${sameName.kind === "remote" ? sameName.url : sameName.path}
|
|
425
|
+
`endpoint name '${canonical.name}' is already bound to ${sameName.kind === "remote" ? sameName.url : sameName.path}; `
|
|
426
|
+
+ `register this endpoint under another handle with --name <handle>`,
|
|
343
427
|
);
|
|
344
428
|
}
|
|
345
|
-
const sameUrl = endpoints.find((endpoint) => endpoint.kind === "remote" && endpoint.url === canonical.url);
|
|
346
|
-
if (sameUrl) {
|
|
347
|
-
throw new RegistryError(`remote endpoint url is already bound to '${sameUrl.name}'`);
|
|
348
|
-
}
|
|
349
429
|
return validateBindings([...endpoints, canonical]);
|
|
350
430
|
}
|
|
351
431
|
|
|
@@ -410,7 +490,15 @@ export function registerAt(registryPath: string, name: string, servicePath: stri
|
|
|
410
490
|
|
|
411
491
|
export function registerRemoteAt(
|
|
412
492
|
registryPath: string,
|
|
413
|
-
binding: {
|
|
493
|
+
binding: {
|
|
494
|
+
name: string;
|
|
495
|
+
url: string;
|
|
496
|
+
declared_name?: string;
|
|
497
|
+
instance_uid?: string;
|
|
498
|
+
token?: string;
|
|
499
|
+
tls_cert?: string;
|
|
500
|
+
tls_pin?: string;
|
|
501
|
+
},
|
|
414
502
|
): RegistryBinding[] {
|
|
415
503
|
return mutateRegistry(registryPath, (current) => registerRemoteBinding(current, binding));
|
|
416
504
|
}
|