dsh-connect-agnes-token-plan 0.5.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.
@@ -0,0 +1,456 @@
1
+ //#region src/host/util.ts
2
+ /**
3
+ * The error `pluginError` actually produces at runtime: an `Error` with a
4
+ * stable `code` the panel branches on, plus optional structured fields the
5
+ * panel and trace read. The fields are attached, not inherited, so this is a
6
+ * structural annotation, not a subclass.
7
+ * @typedef {Error & {
8
+ * code: import("./codes.ts").CodeValue,
9
+ * retryAfterMs?: number,
10
+ * detail?: string,
11
+ * trace?: object[]
12
+ * }} PluginError
13
+ */
14
+ /** Read a finite positive number, else the fallback. */
15
+ function num(value, fallback) {
16
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
17
+ }
18
+ /** Read a non-empty string, else the fallback. */
19
+ function str(value, fallback) {
20
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : fallback;
21
+ }
22
+ /**
23
+ * Redact credential-shaped strings from any text that may reach a log, an
24
+ * error message, or a panel-facing response.
25
+ *
26
+ * AGENTS.md's red line: "凭据不入库" — a credential never reaches a log or a
27
+ * response. The login trace already sanitizes in `Agnes-auth.ts`; this is
28
+ * the counterpart for the LLM route, where an HTTP error object's `message`
29
+ * often embeds the request headers it was built from (axios/fetch errors do),
30
+ * and a Agnes 4xx body may echo the `sk-` key back. Without this gate a
31
+ * registration failure would leak the key through `providerState.error` and
32
+ * `ctx.logger.warn`.
33
+ * @param {string} text - any string that might carry a credential.
34
+ * @returns {string} the text with credential patterns replaced by `[REDACTED]`.
35
+ */
36
+ function redactSecrets(text) {
37
+ return (typeof text === "string" ? text : "").replace(/(["']?[Aa]uthorization["']?\s*[:=]\s*["']?)(?!Bearer\s)[^"',;\s]+/g, "$1[REDACTED]").replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [REDACTED]").replace(/\bsk-[A-Za-z0-9._-]{8,}/g, "sk-[REDACTED]").replace(/(["']?(?:password|access_token|refresh_token|api[_-]?key|token)["']?\s*:\s*["'])[^"']+(?=["'])/gi, "$1[REDACTED]").replace(/\b(password|access_token|refresh_token|api[_-]?key|token)\s*=\s*[^&;\s]+/gi, "$1=[REDACTED]");
38
+ }
39
+ /** Read a plain object, else `{}`. */
40
+ function obj(value) {
41
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
42
+ }
43
+ /**
44
+ * Read a string exactly as it was given, else the fallback.
45
+ *
46
+ * The companion to `str()` for secrets: a password is stored, read back and
47
+ * sent as typed, because trimming it is a change the user cannot see. A
48
+ * password of only whitespace is still "not filled in", which the caller
49
+ * judges with `.trim()`.
50
+ */
51
+ function verbatim(value, fallback) {
52
+ return typeof value === "string" ? value : fallback;
53
+ }
54
+ /** Read a finite number, else `null`. */
55
+ function numOrNull(value) {
56
+ const parsed = Number(value);
57
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
58
+ }
59
+ /**
60
+ * An error carrying a stable code the panel can branch on.
61
+ *
62
+ * The single constructor for every failure this plugin produces. `extra`
63
+ * carries optional structured fields (retryAfterMs, detail) that the panel
64
+ * and the trace need; only defined extras are copied, so an absent field
65
+ * stays absent rather than reading as a zero.
66
+ *
67
+ * The runtime value is a plain `Error` with these fields attached; the
68
+ * {@link PluginError} type records that shape so a `catch (e)` downstream can
69
+ * read `e.code` as more than a hopeful guess.
70
+ * @param {import("./codes.ts").CodeValue} code - a {@link import("./codes.ts").CODE} wire value.
71
+ * @param {string} message - human-readable description.
72
+ * @param {{ retryAfterMs?: number, detail?: string }} [extra] - optional structured fields.
73
+ * @returns {PluginError}
74
+ */
75
+ function pluginError(code, message, extra = {}) {
76
+ const error = new Error(message);
77
+ error.code = code;
78
+ if (extra.retryAfterMs !== void 0) error.retryAfterMs = extra.retryAfterMs;
79
+ if (extra.detail !== void 0) error.detail = extra.detail;
80
+ return error;
81
+ }
82
+
83
+ //#endregion
84
+ //#region src/host/host-config.ts
85
+ /**
86
+ * The plugin's configuration contract and the Host trust fence.
87
+ *
88
+ * Everything the Host half reads from the row's patch config lives here, plus
89
+ * the `isAdmitted` check that keeps a foreign page from planting an account.
90
+ * `test/config.test.mjs` pins `CONFIG_DEFAULTS` and the resolvers against this
91
+ * file and `cordis.patch.yml`, so the code and the documented contract cannot
92
+ * silently drift.
93
+ * @module dsh-connect-agnes-token-plan/host-config
94
+ */
95
+ /**
96
+ * The one slug every addressable surface of this plugin derives from.
97
+ *
98
+ * Besides the name the Loader reports for the row, it is also the `/api` route
99
+ * prefix (`index.ts`), the credential record's scope (`token-store.ts`) and the
100
+ * state directory (`throttle-store.ts`) — so a rename has to carry the user's
101
+ * stored grant and parked throttle with it, not just the text. The two files
102
+ * that cannot import from here repeat it literally: `package.json#name` and the
103
+ * `id`/`name` pair in `cordis.patch.yml`. Neither is pinned against this
104
+ * constant by `test/config.test.mjs` (it pins `CONFIG_DEFAULTS` only), so a
105
+ * rename must check all three by hand.
106
+ */
107
+ const name = "dsh-connect-agnes-token-plan";
108
+ /** Cordis services this plugin needs; without `webServer` it stays inactive. */
109
+ const inject = ["webServer"];
110
+ /**
111
+ * The plugin's configuration contract in one place.
112
+ *
113
+ * Every default the Host half reads lives here, so the code and the
114
+ * `cordis.patch.yml` that documents it cannot silently drift: `test/config.test.mjs`
115
+ * pins both against this object. The `auth` sub-object lists the operator-facing
116
+ * login-flow overrides (their patch.yml entries are commented by default, which is
117
+ * why they default to empty/zero and mean "use the platform default").
118
+ */
119
+ const CONFIG_DEFAULTS = Object.freeze({
120
+ consoleBase: "https://platform-backend.agnes-ai.cn",
121
+ apiBase: "https://api.agnes-ai.cn/v1",
122
+ /**
123
+ * How many days of usage the panel's chart covers, inclusive of today.
124
+ *
125
+ * Days, not hours: `/api/usage/series` takes `start_date` / `end_date` as
126
+ * DATES, so an hour-granular window cannot be asked for. Snapping to whole
127
+ * days also keeps the request URL stable for the day, which is what lets the
128
+ * series cache actually hit.
129
+ */
130
+ usageDays: 30,
131
+ /**
132
+ * Pseudo multipliers for the model roster, keyed by a case-insensitive
133
+ * SUBSTRING of a model id (first matching key wins, in insertion order).
134
+ *
135
+ * Empty by default, and that is not an oversight: the factor used to scale a
136
+ * per-model credit figure, and Agnes publishes no per-model usage at all
137
+ * (its console never reads a `model` field). What is left is the operator's
138
+ * own annotation — a `×N` badge on a roster row — so there is no defensible
139
+ * default to ship. Set it to tag models for your own routing notes; rows
140
+ * without a match get no badge, never a guessed 1.
141
+ */
142
+ trendMultipliers: {},
143
+ cacheSeconds: 60,
144
+ pollSeconds: 30,
145
+ consoleTimeoutMs: 15e3,
146
+ tokenSkewSeconds: 120,
147
+ /**
148
+ * Login-flow overrides, in the shape `agnes-auth.ts` reads them.
149
+ *
150
+ * Agnes signs in with ONE request (`POST /api/user/login`), so the SenseNova
151
+ * OIDC knobs — `iamBase`, `tokenEndpoint`, `jwksEndpoint`, `redirectUri`,
152
+ * `clientId`, `scope`, `encKeyId`, `maxHops` — have no counterpart here and
153
+ * are gone. The three below are the ones that still mean something.
154
+ */
155
+ auth: {
156
+ loginPath: "",
157
+ loginTimeoutMs: 0,
158
+ fallbackExpiresInSeconds: 0
159
+ },
160
+ /** Host names the Host answers as, by default. The operator's list is added. */
161
+ admittedHosts: [
162
+ "localhost",
163
+ "127.0.0.1",
164
+ "[::1]",
165
+ "::1"
166
+ ],
167
+ /**
168
+ * Vision step two: whether the Host syncs the identified vision-capable
169
+ * model ids into THIS row's own settings namespace (`imageModelIds`,
170
+ * `visionModels`) on every catalog poll, for a later LLM connect plugin to
171
+ * read. Off by default - the read-only info layer is the safe shape. The
172
+ * writes go to this plugin's OWN settings row only, never another
173
+ * provider's, so a miscalculated list cannot reach DSH's model routing.
174
+ */
175
+ writeImageModelIds: false,
176
+ /** The last published image-model id list (the reader's primary field). */
177
+ imageModelIds: [],
178
+ /** The last full vision identification (id + source marker per model). */
179
+ visionModels: [],
180
+ /**
181
+ * Step three ("one-stop service"): register the LLM provider DIRECTLY.
182
+ *
183
+ * When true, the plugin calls `ctx.llm.registerAdapter` itself with an
184
+ * OpenAI-compatible pi-ai adapter aimed at `apiBase`, the catalog poll feeds
185
+ * its model list, vision models carry image input automatically, and the
186
+ * panel-saved `AGNES_TOKEN_PLAN_API_KEY` reference authenticates requests. Off by
187
+ * default for the same reason `writeImageModelIds` is: registering a model
188
+ * source is a Host-wide change, not a read-only panel view, so it stays an
189
+ * explicit opt-in and an operator with the hand-written `llm-pi-ai` row is
190
+ * not suddenly offered two providers.
191
+ */
192
+ registerProvider: false,
193
+ /**
194
+ * Draw absorption (ARCHITECTURE §5.4 route B): register the
195
+ * `agnes_draw_image` agent tool. When true AND the Host exposes a tools
196
+ * service, image-generation requests go to `{apiBase}/images/generations`
197
+ * with the panel-saved `AGNES_TOKEN_PLAN_API_KEY`, and the model list comes
198
+ * from `modality.ts` — the declared `output_modalities` field when the
199
+ * gateway sends one, the platform's own `agnes-image-*` family segment when
200
+ * it does not (the Agnes gateway sends no modality metadata at all; see that
201
+ * module's header). Off by
202
+ * default like every execution module: a tool the agent can call is a
203
+ * Host-wide change, and a Host without the tools service must simply never
204
+ * see it rather than fail.
205
+ *
206
+ * 0.4.2: this value is now the DEPLOYMENT DEFAULT only. The panel's draw
207
+ * tool switch (`POST /api/<name>/draw`, stored in `draw-store.ts`)
208
+ * overrides it live with no restart. See `docs/PROVIDER-HOT-RELOAD.md` §7.
209
+ */
210
+ drawEnabled: false,
211
+ /** Preferred draw model id; empty means "first image-gen model of the catalog". */
212
+ drawModelId: "",
213
+ /** Deadline for one image request. Image models are slow; chat deadlines do not apply. */
214
+ drawTimeoutMs: 12e4,
215
+ /**
216
+ * Video absorption: register the `agnes_video_generate` agent tool
217
+ * (`video.ts`). A SEPARATE opt-in from `drawEnabled` on purpose — wanting
218
+ * image generation without video (or the reverse) is an ordinary
219
+ * preference, and one switch would force both on together.
220
+ *
221
+ * The protocol differs from drawing in the way that matters here: video is
222
+ * an ASYNCHRONOUS TASK (create, then poll until terminal), so this is the
223
+ * only module whose deadline is measured in MINUTES. Off by default, and
224
+ * degraded exactly like the draw tool: no tools service or a failing peer
225
+ * leaves the panel and the provider untouched.
226
+ *
227
+ * The panel's video switch (`POST /api/<name>/video`, stored in
228
+ * `video-store.ts`) overrides this live with no restart.
229
+ */
230
+ videoEnabled: false,
231
+ /** Preferred video model id; empty means "first V2.0-family video model of the catalog". */
232
+ videoModelId: "",
233
+ /** The whole-generation poll budget (create + poll). Video tasks run for minutes. */
234
+ videoTimeoutMs: 6e5,
235
+ /** Default video width in pixels (16:9). */
236
+ videoWidth: 1152,
237
+ /** Default video height in pixels (16:9). */
238
+ videoHeight: 768,
239
+ /** Default frame count — must satisfy 8n+1 and be ≤441; 121 @ 24fps ≈ 5 seconds. */
240
+ videoNumFrames: 121,
241
+ /** Default frame rate (1–60). */
242
+ videoFrameRate: 24
243
+ });
244
+ /**
245
+ * Sanitize the operator's pseudo-multiplier map: keep only string keys and
246
+ * finite positive numbers, preserving insertion order (matching is
247
+ * first-key-wins). A non-object or empty input falls back to the shipped
248
+ * defaults; the operator sets `{}` explicitly to disable all multipliers.
249
+ * Exported so `test/config.test.mjs` drives the same sanitizer the resolve
250
+ * path uses, instead of a copy that could drift.
251
+ * @param {unknown} raw - the raw `trendMultipliers` config value.
252
+ * @returns {Record<string, number>} the sanitized map.
253
+ */
254
+ function resolveTrendMultipliers(raw) {
255
+ const source = raw === void 0 || raw === null ? CONFIG_DEFAULTS.trendMultipliers : raw;
256
+ if (source === null || typeof source !== "object" || Array.isArray(source)) return { ...CONFIG_DEFAULTS.trendMultipliers };
257
+ const out = {};
258
+ for (const [key, value] of Object.entries(source)) if (typeof key === "string" && key !== "" && typeof value === "number" && Number.isFinite(value) && value > 0) out[key] = value;
259
+ return out;
260
+ }
261
+ /**
262
+ * Clamp a raw numeric setting to its effective integer.
263
+ *
264
+ * Every numeric field in {@link resolveSettings} follows the same shape: floor
265
+ * the raw value, clamp it at a lower bound, then (optionally) at an upper bound;
266
+ * a non-positive or non-finite raw falls back to `def` (because `num` only
267
+ * accepts a positive finite number). The sequence — `Math.min(max, Math.max(min,
268
+ * Math.floor(raw)))` with `max` defaulting to `Infinity` — is exactly what the
269
+ * inline `Math.max`/`Math.min` chains used to spell out one field at a time, so
270
+ * this is a MOVE of that pattern into one tested place, not a behaviour change.
271
+ * @param {unknown} raw - the raw value read from the row.
272
+ * @param {number} def - the fallback when `raw` is not a positive finite number.
273
+ * @param {number} min - the lower clamp (inclusive) applied after flooring.
274
+ * @param {number} [max] - the upper clamp (inclusive); omit for no upper bound.
275
+ * @returns {number} the clamped integer.
276
+ */
277
+ function clampInt(raw, def, min, max = Infinity) {
278
+ return Math.min(max, Math.max(min, Math.floor(num(raw, def))));
279
+ }
280
+ /**
281
+ * Resolve the row's raw patch config into effective settings.
282
+ *
283
+ * A malformed row must not throw out of here: `apply` runs at mount, and an
284
+ * exception would take the whole plugin down instead of leaving a panel that
285
+ * explains itself. So problems are returned as `configError` and surfaced
286
+ * through the snapshot route.
287
+ * @param {object} config - the row's raw patch config.
288
+ * @returns {{settings: object, configError: string|null}}
289
+ */
290
+ function resolveSettings(config) {
291
+ const source = obj(config);
292
+ const consoleBase = str(source.consoleBase, CONFIG_DEFAULTS.consoleBase).replace(/\/+$/, "");
293
+ const apiBase = str(source.apiBase, CONFIG_DEFAULTS.apiBase).replace(/\/+$/, "");
294
+ try {
295
+ return {
296
+ settings: {
297
+ consoleBase,
298
+ apiBase,
299
+ usageDays: clampInt(source.usageDays, CONFIG_DEFAULTS.usageDays, 1, 365),
300
+ trendMultipliers: resolveTrendMultipliers(source.trendMultipliers),
301
+ cacheSeconds: clampInt(source.cacheSeconds, CONFIG_DEFAULTS.cacheSeconds, 5),
302
+ pollSeconds: clampInt(source.pollSeconds, CONFIG_DEFAULTS.pollSeconds, 5),
303
+ consoleTimeoutMs: clampInt(source.consoleTimeoutMs, CONFIG_DEFAULTS.consoleTimeoutMs, 1e3),
304
+ allowedHosts: resolveAllowedHosts(source),
305
+ tokenSkewSeconds: clampInt(source.tokenSkewSeconds, CONFIG_DEFAULTS.tokenSkewSeconds, 0),
306
+ auth: resolveAuthOverrides(source, consoleBase),
307
+ writeImageModelIds: source.writeImageModelIds === true,
308
+ imageModelIds: Array.isArray(source.imageModelIds) ? source.imageModelIds.filter((id) => typeof id === "string") : CONFIG_DEFAULTS.imageModelIds,
309
+ visionModels: Array.isArray(source.visionModels) ? source.visionModels.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) : CONFIG_DEFAULTS.visionModels,
310
+ registerProvider: source.registerProvider === true,
311
+ drawEnabled: source.drawEnabled === true,
312
+ drawModelId: str(source.drawModelId, ""),
313
+ drawTimeoutMs: clampInt(source.drawTimeoutMs, CONFIG_DEFAULTS.drawTimeoutMs, 5e3),
314
+ videoEnabled: source.videoEnabled === true,
315
+ videoModelId: str(source.videoModelId, ""),
316
+ videoTimeoutMs: clampInt(source.videoTimeoutMs, CONFIG_DEFAULTS.videoTimeoutMs, 3e4),
317
+ videoWidth: clampInt(source.videoWidth, CONFIG_DEFAULTS.videoWidth, 1),
318
+ videoHeight: clampInt(source.videoHeight, CONFIG_DEFAULTS.videoHeight, 1),
319
+ videoNumFrames: clampInt(source.videoNumFrames, CONFIG_DEFAULTS.videoNumFrames, 1),
320
+ videoFrameRate: clampInt(source.videoFrameRate, CONFIG_DEFAULTS.videoFrameRate, 1)
321
+ },
322
+ configError: null
323
+ };
324
+ } catch (error) {
325
+ return {
326
+ settings: {
327
+ consoleBase,
328
+ apiBase,
329
+ usageDays: CONFIG_DEFAULTS.usageDays,
330
+ trendMultipliers: CONFIG_DEFAULTS.trendMultipliers,
331
+ cacheSeconds: CONFIG_DEFAULTS.cacheSeconds,
332
+ pollSeconds: CONFIG_DEFAULTS.pollSeconds,
333
+ consoleTimeoutMs: CONFIG_DEFAULTS.consoleTimeoutMs,
334
+ allowedHosts: new Set(CONFIG_DEFAULTS.admittedHosts),
335
+ tokenSkewSeconds: CONFIG_DEFAULTS.tokenSkewSeconds,
336
+ auth: { consoleOrigin: consoleBase }
337
+ },
338
+ configError: error instanceof Error ? error.message : String(error)
339
+ };
340
+ }
341
+ }
342
+ /**
343
+ * Collect just the auth keys the operator actually set.
344
+ *
345
+ * The keys are read from the TOP LEVEL of the row. That is not obvious, and
346
+ * getting it wrong is not a harmless typo: a nested `auth:` block is accepted
347
+ * by the loader, silently dropped here, and the panel then runs on its shipped
348
+ * defaults — which point at the REAL platform. An end-to-end run meant to talk
349
+ * to a local stub then posts a real login attempt, which is exactly how this
350
+ * plugin locked an account once already. So a nested `auth` key is reported as
351
+ * a configuration error rather than ignored.
352
+ *
353
+ * The emitted set is exactly what `agnes-auth.ts:resolveAuthConfig` reads. The
354
+ * SenseNova OIDC knobs (`iamBase` / `tokenEndpoint` / `jwksEndpoint` /
355
+ * `redirectUri` / `clientId` / `scope` / `encKeyId` / `maxHops`) are gone with
356
+ * the OIDC walk they configured — passing them through now would hand
357
+ * `createAuth` keys it never reads, which is the silent-drop failure this
358
+ * function exists to prevent.
359
+ * @param {object} source - the row's raw patch config.
360
+ * @param {string} consoleBase - the resolved console origin.
361
+ * @returns {object} the override object for `createAuth`.
362
+ * @throws {Error} when the row looks like it nests overrides it does not read.
363
+ */
364
+ function resolveAuthOverrides(source, consoleBase) {
365
+ if (source.auth !== void 0 && source.auth !== null) {
366
+ const keys = Object.keys(obj(source.auth));
367
+ throw new Error(`auth overrides are top-level keys on this row, not a nested \`auth:\` block${keys.length === 0 ? "" : ` (found: ${keys.join(", ")})`}. Use \`loginPath\`, \`loginTimeoutMs\` or \`fallbackExpiresInSeconds\` at the top level; a nested block is ignored and the panel would keep using the real platform.`);
368
+ }
369
+ const overrides = { consoleOrigin: consoleBase };
370
+ const loginPath = str(source.loginPath, "");
371
+ if (loginPath !== "") overrides.loginPath = loginPath;
372
+ const fallbackExpiresInSeconds = Math.floor(num(source.fallbackExpiresInSeconds, 0));
373
+ if (fallbackExpiresInSeconds > 0) overrides.fallbackExpiresInSeconds = fallbackExpiresInSeconds;
374
+ const loginTimeoutMs = Math.floor(num(source.loginTimeoutMs, num(source.requestTimeoutMs, 0)));
375
+ if (loginTimeoutMs > 0) overrides.requestTimeoutMs = loginTimeoutMs;
376
+ return overrides;
377
+ }
378
+ /**
379
+ * Collect the host names this Host will answer as.
380
+ *
381
+ * The operator's list is ADDED to the defaults, never substituted: replacing
382
+ * them would let a typo lock the panel out of itself, and there is no console
383
+ * to fix it from.
384
+ * @param {object} source - the row's raw patch config.
385
+ * @returns {Set<string>} the admitted host names, lowercased.
386
+ */
387
+ function resolveAllowedHosts(source) {
388
+ const admitted = new Set(CONFIG_DEFAULTS.admittedHosts);
389
+ const extra = Array.isArray(source.allowedHosts) ? source.allowedHosts : [];
390
+ for (const entry of extra) {
391
+ const name = str(entry, "").trim().toLowerCase();
392
+ if (name !== "") admitted.add(name);
393
+ }
394
+ return admitted;
395
+ }
396
+ /**
397
+ * The host name a `Host` header names, without its port.
398
+ * @param {string} host - the raw header value.
399
+ * @returns {string} the name; bracketed for IPv6 literals.
400
+ */
401
+ function hostName(host) {
402
+ if (host.startsWith("[") && host.includes("]")) return host.slice(0, host.indexOf("]") + 1);
403
+ const colons = host.split(":");
404
+ if (colons.length > 2) {
405
+ if (/^\d+$/.test(colons[colons.length - 2]) && /^\d+$/.test(colons[colons.length - 1])) return host.slice(0, host.lastIndexOf(":"));
406
+ return host;
407
+ }
408
+ return colons.length > 1 ? host.slice(0, host.lastIndexOf(":")) : host;
409
+ }
410
+ /**
411
+ * Trust fence for a route the browser can reach.
412
+ *
413
+ * Two different attacks have to be turned away here, and they need two
414
+ * different facts:
415
+ *
416
+ * 1. DNS rebinding. The attacker's page rebinds its own name to 127.0.0.1 and
417
+ * POSTs an account. `Origin` and `Host` now AGREE on the attacker's name
418
+ * while the request lands on the Host, so comparing them to each other
419
+ * admits it. The `Host` header is the one thing a browser cannot forge, so
420
+ * it is checked against a whitelist instead of against the `Origin`.
421
+ * 2. Cross-site forgery. A page on another origin asks the browser to post to
422
+ * the loopback Host. Here the whitelist alone is worthless — the Host IS
423
+ * legitimate — and the `Origin` is what gives it away.
424
+ *
425
+ * So: the `Host` must be one this Host answers as, AND any stated `Origin`
426
+ * must agree with it. A request that states no `Origin` is the ordinary
427
+ * same-origin GET and is admitted.
428
+ *
429
+ * The boundary this draws is the BROWSER, not the machine. A process running
430
+ * as the user sets `Host` and `Origin` to whatever it likes, and there is no
431
+ * CSRF token here to tell it apart from the panel — so anything that can open
432
+ * a socket to this port can also plant an account. That is the same trust the
433
+ * Host places in the user's own processes generally, but it is worth saying
434
+ * plainly: an `Origin` check reads like more protection than it is, and a
435
+ * reader who believes otherwise will build something on top of it. Closing
436
+ * that gap needs a token the Host serves in its own page and the POST carries
437
+ * back, not a header a client can choose.
438
+ * @param request - the incoming HTTP request.
439
+ * @param {Set<string>} allowedHosts - the host names this Host answers as.
440
+ * @returns {boolean} whether the request may be served.
441
+ */
442
+ function isAdmitted(request, allowedHosts) {
443
+ const host = str(request.headers?.host, "").toLowerCase();
444
+ if (host === "" || !allowedHosts.has(hostName(host))) return false;
445
+ const origin = request.headers.origin;
446
+ if (typeof origin !== "string" || origin === "") return true;
447
+ if (origin === "null") return false;
448
+ try {
449
+ return new URL(origin).host === host;
450
+ } catch {
451
+ return false;
452
+ }
453
+ }
454
+
455
+ //#endregion
456
+ export { name as a, num as c, pluginError as d, redactSecrets as f, isAdmitted as i, numOrNull as l, verbatim as m, hostName as n, resolveAuthOverrides as o, str as p, inject as r, resolveSettings as s, CONFIG_DEFAULTS as t, obj as u };