@sigrun/sigrun 0.0.0-stage → 0.1.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/CHANGELOG.md +214 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +89 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
package/dist/serve.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The serve boot (Task 5): the deploy entry's composition, factored out of
|
|
3
|
+
* `src/index.ts` so the CLI's `run` command can reuse it (boot
|
|
4
|
+
* harness+server, get the bound port, shut down on demand) while
|
|
5
|
+
* `src/index.ts` — and therefore `npm start` and the cluster container
|
|
6
|
+
* command — keeps the exact same deployed behavior by calling
|
|
7
|
+
* {@link serve} once with signal handlers attached. Every behavior the
|
|
8
|
+
* deploy-entry test pins (the bootstrap, the listening line, the
|
|
9
|
+
* SIGTERM/SIGINT shutdown, the fail-loud boot) lives here.
|
|
10
|
+
*
|
|
11
|
+
* `loadConfig(process.env)` → settings bootstrap → `openHarness` →
|
|
12
|
+
* `createServer` → listen.
|
|
13
|
+
*
|
|
14
|
+
* The settings bootstrap (ruling 5 / spec §6.6): on first boot the
|
|
15
|
+
* store writes ONLY what the environment set (the env-sourced `server`
|
|
16
|
+
* fields plus `extensions`/`defaultModel` when env supplied them —
|
|
17
|
+
* defaults are never persisted), and the FINAL config re-resolves env
|
|
18
|
+
* over the (possibly just-written) file (`loadConfig(env, file)`) — env
|
|
19
|
+
* always wins, the file fills only what env leaves unset.
|
|
20
|
+
* `SIGRUN_CONFIG_DIR` stays env-only (it locates the file itself).
|
|
21
|
+
*
|
|
22
|
+
* Fail-loud is the contract (the task brief): any boot error rejects the
|
|
23
|
+
* boot promise; callers print it and exit 1 — a wedged deploy must crash,
|
|
24
|
+
* never serve a broken process.
|
|
25
|
+
*/
|
|
26
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
27
|
+
import { envServerSection, loadConfig } from "./config.js";
|
|
28
|
+
import { openHarness } from "./harness/boot.js";
|
|
29
|
+
import { packagesDirOf, syncPackages } from "./harness/packages.js";
|
|
30
|
+
import { defaultSettingsDir, SettingsStore } from "./harness/settings.js";
|
|
31
|
+
import { createServer } from "./server/index.js";
|
|
32
|
+
/**
|
|
33
|
+
* Boot the harness + server composition and listen on
|
|
34
|
+
* `options.host ?? "0.0.0.0"` + `<port>` (the default is the container's
|
|
35
|
+
* address — loopback-only would be unreachable behind the Service;
|
|
36
|
+
* `loadConfig` refuses port 0, so a real port is always bound).
|
|
37
|
+
* The CLI's `run` additionally launches the TUI against the loopback URL
|
|
38
|
+
* on {@link BootedServer.port}.
|
|
39
|
+
*/
|
|
40
|
+
export async function serve(env, options) {
|
|
41
|
+
// 1. Env-only resolution (the pre-file behavior — fail-loud on a bad
|
|
42
|
+
// env value before anything is written). A file-only `defaultModel`
|
|
43
|
+
// must NOT fail here: the file has not been read yet, and step 3
|
|
44
|
+
// re-resolves with it. The env-only `envConfig` result feeds ONLY the
|
|
45
|
+
// bootstrap's `defaultModel` write, which is used only when env
|
|
46
|
+
// actually supplied the model — so skip the env-only resolution
|
|
47
|
+
// entirely when no env model is set (a file-only model otherwise
|
|
48
|
+
// throws here, before `settings.json` is ever read).
|
|
49
|
+
const envHasModel = env.SIGRUN_DEFAULT_MODEL?.trim() !== undefined ||
|
|
50
|
+
env.SIGRUN_MODEL?.trim() !== undefined;
|
|
51
|
+
const envConfig = envHasModel ? loadConfig(env) : undefined;
|
|
52
|
+
// 2. First-boot bootstrap (ruling 5 / spec §6.6): absent settings file
|
|
53
|
+
// → write ONLY what the environment explicitly set — the env-sourced
|
|
54
|
+
// `server` fields (`envServerSection`) plus `extensions` and
|
|
55
|
+
// `defaultModel` when env supplied them. Defaults are NEVER
|
|
56
|
+
// persisted, so later releases' default changes reach existing
|
|
57
|
+
// installs (the file records intent, not reality); a `server`
|
|
58
|
+
// section with zero entries is omitted entirely. A present file is
|
|
59
|
+
// never touched.
|
|
60
|
+
const envModel = env.SIGRUN_DEFAULT_MODEL?.trim() || env.SIGRUN_MODEL?.trim() || undefined;
|
|
61
|
+
const settingsStore = new SettingsStore({ dir: defaultSettingsDir() });
|
|
62
|
+
settingsStore.bootstrap(envServerSection(env), {
|
|
63
|
+
extensions: env.SIGRUN_EXTENSIONS?.trim() === undefined
|
|
64
|
+
? undefined
|
|
65
|
+
: envConfig.extensions,
|
|
66
|
+
defaultModel: envModel === undefined
|
|
67
|
+
? undefined
|
|
68
|
+
: `${envConfig.models.provider}/${envConfig.models.modelId}`,
|
|
69
|
+
});
|
|
70
|
+
// 3. Final resolution: env over the file over code defaults. The file
|
|
71
|
+
// re-read picks up whatever bootstrap wrote (or what was already
|
|
72
|
+
// there) — env still wins every field it sets.
|
|
73
|
+
const config = loadConfig(env, settingsStore.read());
|
|
74
|
+
// Boot-time package install (the pi-style packages dir): the settings
|
|
75
|
+
// file's top-level `packages` array is the INSTALL intent — install the
|
|
76
|
+
// declared specifiers into `<config-dir>/packages` and prune the
|
|
77
|
+
// undeclared BEFORE any extension loads. Fail-loud: an uninstallable
|
|
78
|
+
// declaration fails the boot here, naming the specifier. Steady state
|
|
79
|
+
// (the dir already matches) costs one manifest read, no subprocess.
|
|
80
|
+
const settingsFile = settingsStore.read();
|
|
81
|
+
const packagesDir = packagesDirOf(defaultSettingsDir());
|
|
82
|
+
const extensionResolutions = await syncPackages(settingsFile.extensions ?? [], packagesDir);
|
|
83
|
+
// The workspace directory must exist before any files endpoint or tool
|
|
84
|
+
// call touches it. The deploy manifest mounts a volume at /data; the
|
|
85
|
+
// workspace is /data/workspace. Without this, the first files request
|
|
86
|
+
// 404s forever (the directory was never created).
|
|
87
|
+
const { mkdir } = await import("node:fs/promises");
|
|
88
|
+
await mkdir(config.workspace.path, { recursive: true });
|
|
89
|
+
const opened = await openHarness(config, { extensionEntries: extensionResolutions });
|
|
90
|
+
// The contributed-bundle registry (build-and-webui Task 6): boot folded
|
|
91
|
+
// the loader's extension contributions into the name-keyed registry;
|
|
92
|
+
// the server's serve-time NAME-selector resolution reads it here.
|
|
93
|
+
const app = await createServer(opened, config, opened.webuiBundles, {
|
|
94
|
+
listenHost: options?.host ?? "0.0.0.0",
|
|
95
|
+
env,
|
|
96
|
+
});
|
|
97
|
+
const host = options?.host ?? "0.0.0.0";
|
|
98
|
+
await app.listen({ port: config.port, host });
|
|
99
|
+
// Machine-greppable: the deploy-entry test waits for this line, and an
|
|
100
|
+
// operator reading pod logs sees the bound port without guessing.
|
|
101
|
+
console.log(`sigrun listening on http://${host}:${config.port}`);
|
|
102
|
+
const shutdown = async () => {
|
|
103
|
+
const failures = [];
|
|
104
|
+
try {
|
|
105
|
+
await app.close();
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
failures.push(error);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await opened.eventBus.shutdownSequence(BACKGROUND_CONTEXT);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
failures.push(error);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
// The interval refresh scheduler (provider plugins Task 5) stops
|
|
118
|
+
// BEFORE the harness closes. The T4 boot refresh promise is raced
|
|
119
|
+
// against a bounded join: a wedged catalog fetch must not hold the
|
|
120
|
+
// ordered shutdown (and the SSE test harness's `app.close`) — the
|
|
121
|
+
// refresh contract already treats abandon as a retained-catalog
|
|
122
|
+
// warn, never a boot failure.
|
|
123
|
+
opened.stopProviderRefresh();
|
|
124
|
+
let joined = false;
|
|
125
|
+
const join = opened.bootRefresh
|
|
126
|
+
.then(() => {
|
|
127
|
+
joined = true;
|
|
128
|
+
})
|
|
129
|
+
.catch(() => { });
|
|
130
|
+
const BOOT_REFRESH_JOIN_MS = 2_000;
|
|
131
|
+
await Promise.race([
|
|
132
|
+
join,
|
|
133
|
+
new Promise((resolve) => {
|
|
134
|
+
const timer = setTimeout(resolve, BOOT_REFRESH_JOIN_MS);
|
|
135
|
+
timer.unref?.();
|
|
136
|
+
}),
|
|
137
|
+
]);
|
|
138
|
+
if (!joined) {
|
|
139
|
+
console.warn(`sigrun: boot refresh still in flight after ${BOOT_REFRESH_JOIN_MS}ms at shutdown — abandoning the join`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
failures.push(error);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
await opened.harness.close(BACKGROUND_CONTEXT);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
failures.push(error);
|
|
150
|
+
}
|
|
151
|
+
if (failures.length > 0) {
|
|
152
|
+
throw new AggregateError(failures, "sigrun shutdown failed");
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
let stopping;
|
|
156
|
+
return {
|
|
157
|
+
port: config.port,
|
|
158
|
+
shutdown,
|
|
159
|
+
shutdownOnce: () => {
|
|
160
|
+
stopping ??= shutdown();
|
|
161
|
+
return stopping;
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The claim mutex (core hardening W1 Task 4, spec §5.8): ONE in-process
|
|
3
|
+
* promise chain shared by the onboarding claim route and invite
|
|
4
|
+
* redemption. Claims and redeems are serialized end-to-end — the
|
|
5
|
+
* directory's `isClaimed()` read happens INSIDE the lock, and the
|
|
6
|
+
* directory write itself is atomic (Task 2's stores) — so of two
|
|
7
|
+
* concurrent correct claims exactly one lands the first admin and the
|
|
8
|
+
* rest observe `already_claimed`.
|
|
9
|
+
*
|
|
10
|
+
* In-process by design: the directory's MutationQueue already serializes
|
|
11
|
+
* single-store writes across processes; this mutex closes the
|
|
12
|
+
* read-decide-write WINDOW (check unclaimed → add) that would otherwise
|
|
13
|
+
* let two same-process claims both pass the check.
|
|
14
|
+
*/
|
|
15
|
+
/** The one module-level chain; every critical section rides its tail. */
|
|
16
|
+
let chain = Promise.resolve();
|
|
17
|
+
/**
|
|
18
|
+
* Run `section` under the claim mutex: it starts only after the previous
|
|
19
|
+
* section settles (either way — a failed claim must not wedge the
|
|
20
|
+
* chain), and the next waiter sees this section's tail. The returned
|
|
21
|
+
* promise carries `section`'s own settlement (success or rejection) to
|
|
22
|
+
* its caller.
|
|
23
|
+
*/
|
|
24
|
+
export function withClaimLock(section) {
|
|
25
|
+
const run = chain.then(section, section);
|
|
26
|
+
chain = run.then(() => undefined, () => undefined);
|
|
27
|
+
return run;
|
|
28
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CIDR allowlist membership for peer addresses (W1 Task 3): the proxy
|
|
3
|
+
* method's trusted-proxies check. `net.BlockList` is the engine;
|
|
4
|
+
* v4-mapped v6 spellings (`::ffff:10.42.3.4`) are folded to the v4
|
|
5
|
+
* address first — a dual-stack socket reports v4 peers mapped, and the
|
|
6
|
+
* allowlist spells v4 ranges.
|
|
7
|
+
*/
|
|
8
|
+
import { BlockList, isIP } from "node:net";
|
|
9
|
+
/**
|
|
10
|
+
* Whether `address` (a socket remote address) falls in any of `cidrs`
|
|
11
|
+
* (CIDR ranges or bare IPs). Malformed entries never match; an
|
|
12
|
+
* undefined/unparseable address never matches.
|
|
13
|
+
*/
|
|
14
|
+
export function ipAllowed(address, cidrs) {
|
|
15
|
+
if (address === undefined)
|
|
16
|
+
return false;
|
|
17
|
+
let candidate = address.trim();
|
|
18
|
+
if (candidate.startsWith("::ffff:") && isIP(candidate.slice(7)) === 4) {
|
|
19
|
+
candidate = candidate.slice(7);
|
|
20
|
+
}
|
|
21
|
+
const family = isIP(candidate);
|
|
22
|
+
if (family === 0)
|
|
23
|
+
return false;
|
|
24
|
+
const list = new BlockList();
|
|
25
|
+
for (const entry of cidrs) {
|
|
26
|
+
const trimmed = entry.trim();
|
|
27
|
+
if (trimmed === "")
|
|
28
|
+
continue;
|
|
29
|
+
const slash = trimmed.indexOf("/");
|
|
30
|
+
try {
|
|
31
|
+
if (slash === -1) {
|
|
32
|
+
const entryFamily = isIP(trimmed);
|
|
33
|
+
if (entryFamily === 0)
|
|
34
|
+
continue;
|
|
35
|
+
list.addAddress(trimmed, entryFamily === 6 ? "ipv6" : "ipv4");
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
const base = trimmed.slice(0, slash);
|
|
39
|
+
const prefix = Number(trimmed.slice(slash + 1));
|
|
40
|
+
const entryFamily = isIP(base);
|
|
41
|
+
if (entryFamily === 0 || !Number.isInteger(prefix))
|
|
42
|
+
continue;
|
|
43
|
+
list.addSubnet(base, prefix, entryFamily === 6 ? "ipv6" : "ipv4");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// A malformed entry matches nothing (never trust everyone).
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return list.check(candidate, family === 6 ? "ipv6" : "ipv4");
|
|
51
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The login rate limiter (W1 Task 3, spec §5.4): per-source-IP failure
|
|
3
|
+
* buckets over {@link Set} keys. Ten consecutive failures within the
|
|
4
|
+
* window lock the source for the lockout period; a successful login
|
|
5
|
+
* clears the bucket. Time is injectable for fake-timer tests.
|
|
6
|
+
*/
|
|
7
|
+
export const LOGIN_LIMIT_MAX_FAILURES = 10;
|
|
8
|
+
export const LOGIN_LIMIT_WINDOW_MS = 10 * 60_000;
|
|
9
|
+
export const LOGIN_LOCKOUT_MS = 10 * 60_000;
|
|
10
|
+
/**
|
|
11
|
+
* The system clock, captured at module load: vitest's `useFakeTimers`
|
|
12
|
+
* replaces the global `Date`, and the login route's limiter must keep
|
|
13
|
+
* wall-clock semantics when a test fakes timers to skip the 1 000 ms
|
|
14
|
+
* failure delay (the limiter windows are minutes — no test waits them
|
|
15
|
+
* out; they simply never advance within one test).
|
|
16
|
+
*/
|
|
17
|
+
const systemNow = Date.now;
|
|
18
|
+
export class LoginLimiter {
|
|
19
|
+
now;
|
|
20
|
+
buckets = new Map();
|
|
21
|
+
constructor(now = systemNow) {
|
|
22
|
+
this.now = now;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Whether a login attempt from `key` (the source IP) may proceed.
|
|
26
|
+
* False while locked; the locked reply never reveals the state of the
|
|
27
|
+
* token (checked BEFORE the credential and the wait, per the brief).
|
|
28
|
+
*/
|
|
29
|
+
allowed(key) {
|
|
30
|
+
const bucket = this.buckets.get(key);
|
|
31
|
+
return bucket === undefined || bucket.lockedUntil <= this.now();
|
|
32
|
+
}
|
|
33
|
+
/** Record a failed attempt; the 10th inside the window locks the key. */
|
|
34
|
+
recordFailure(key) {
|
|
35
|
+
const now = this.now();
|
|
36
|
+
const bucket = this.buckets.get(key) ?? { failures: [], lockedUntil: 0 };
|
|
37
|
+
while (bucket.failures.length > 0 &&
|
|
38
|
+
now - bucket.failures[0] >= LOGIN_LIMIT_WINDOW_MS) {
|
|
39
|
+
bucket.failures.shift();
|
|
40
|
+
}
|
|
41
|
+
bucket.failures.push(now);
|
|
42
|
+
if (bucket.failures.length >= LOGIN_LIMIT_MAX_FAILURES) {
|
|
43
|
+
bucket.lockedUntil = now + LOGIN_LOCKOUT_MS;
|
|
44
|
+
bucket.failures.length = 0;
|
|
45
|
+
}
|
|
46
|
+
this.buckets.set(key, bucket);
|
|
47
|
+
}
|
|
48
|
+
/** A success clears the bucket — the lock punishes guessing, not use. */
|
|
49
|
+
recordSuccess(key) {
|
|
50
|
+
this.buckets.delete(key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loopback test shared by the auth surface (core-hardening W1).
|
|
3
|
+
*
|
|
4
|
+
* Two consumers with one notion of "local": `effectiveMethods` (the
|
|
5
|
+
* defaults ruling — `[]` methods on a loopback bind, `["token"]`
|
|
6
|
+
* otherwise) and Task 3's pipeline Host check (a loopback bind only
|
|
7
|
+
* accepts loopback `Host` values). The set is `127.0.0.0/8`, `::1`,
|
|
8
|
+
* and `localhost` (spec §5.2) — deliberately NOT `::` or link-local:
|
|
9
|
+
* binding those exposes the server beyond this machine.
|
|
10
|
+
*/
|
|
11
|
+
import { BlockList, isIP } from "node:net";
|
|
12
|
+
const v4Loopback = new BlockList();
|
|
13
|
+
v4Loopback.addSubnet("127.0.0.0", 8);
|
|
14
|
+
const v6Loopback = new BlockList();
|
|
15
|
+
// The family hints are required: BlockList's auto-detection does not
|
|
16
|
+
// accept `::1` at add time and does not match it at check time (Node 26
|
|
17
|
+
// behavior, verified — the plan's Review Focus 1 assumes explicit
|
|
18
|
+
// normalization/family handling around BlockList, which is this).
|
|
19
|
+
v6Loopback.addAddress("::1", "ipv6");
|
|
20
|
+
/**
|
|
21
|
+
* Whether `host` is a loopback bind address: any `127.x.x.x`, `::1`
|
|
22
|
+
* (including its expanded, bracketed, and v4-mapped spellings), or the
|
|
23
|
+
* `localhost` name (case-insensitive). Everything else — the container
|
|
24
|
+
* default `0.0.0.0`, `::`, LAN addresses, other hostnames — is not.
|
|
25
|
+
*/
|
|
26
|
+
export function isLoopbackHost(host) {
|
|
27
|
+
let candidate = host.trim().toLowerCase();
|
|
28
|
+
// The URL-hostname form brackets IPv6 literals.
|
|
29
|
+
if (candidate.startsWith("[") && candidate.endsWith("]")) {
|
|
30
|
+
candidate = candidate.slice(1, -1);
|
|
31
|
+
}
|
|
32
|
+
if (candidate === "localhost")
|
|
33
|
+
return true;
|
|
34
|
+
// A v4-mapped v6 address binds the v4 address it maps to (Node
|
|
35
|
+
// accepts `::ffff:127.0.0.1` as a bind address; BlockList does not
|
|
36
|
+
// fold the mapping itself).
|
|
37
|
+
if (candidate.startsWith("::ffff:") &&
|
|
38
|
+
isIP(candidate.slice(7)) === 4) {
|
|
39
|
+
candidate = candidate.slice(7);
|
|
40
|
+
}
|
|
41
|
+
const family = isIP(candidate);
|
|
42
|
+
if (family === 4)
|
|
43
|
+
return v4Loopback.check(candidate);
|
|
44
|
+
if (family === 6)
|
|
45
|
+
return v6Loopback.check(candidate, "ipv6");
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The §5 request pipeline (core hardening W1 Task 3, spec §5.2/§5.3):
|
|
3
|
+
* ONE onRequest hook, registered before every route, that resolves the
|
|
4
|
+
* caller into `request.principal`. The stages, in order:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Host/Origin checks.** On a loopback listen host, the `Host`
|
|
7
|
+
* hostname (port stripped) must be loopback or in
|
|
8
|
+
* `auth.allowedHosts`, else 421 (DNS-rebinding defense); a
|
|
9
|
+
* non-loopback bind checks only when `allowedHosts` is set. A
|
|
10
|
+
* non-GET/HEAD request with an `Origin` must match the effective
|
|
11
|
+
* host (`X-Forwarded-Host` from a trusted proxy peer, else `Host`)
|
|
12
|
+
* or `auth.allowedOrigins`, else 403 `origin_mismatch`.
|
|
13
|
+
* 2. **Public paths.** Decided from the MATCHED ROUTE PATTERN
|
|
14
|
+
* (`request.routeOptions.url`), never the raw URL (Fastify decodes
|
|
15
|
+
* before routing — `/%761/...` routes to `/v1/...`): `/healthz`,
|
|
16
|
+
* `/readyz`, `/metrics` when `config.metricsPublic`,
|
|
17
|
+
* `/v1/onboarding` (+ subpaths), `POST /v1/auth/login`,
|
|
18
|
+
* `POST /v1/auth/logout`, and the browser surface — a GET/HEAD that
|
|
19
|
+
* matched the static wildcard (`/*`) or no route at all (the
|
|
20
|
+
* not-found handler serves the SPA for non-/v1 paths).
|
|
21
|
+
* 3. **Env service token.** A `Bearer` equal (constant-time) to
|
|
22
|
+
* `SIGRUN_API_TOKEN` resolves the service principal and skips the
|
|
23
|
+
* setup gate.
|
|
24
|
+
* 4. **Unclaimed gate.** `!users.isClaimed()` → 403 `setup_required`
|
|
25
|
+
* (onboarding is the only usable surface until the first claim).
|
|
26
|
+
* 4b. **Extension-route public skip** (W5 Task 2, spec §9.2). A request
|
|
27
|
+
* ROUTED to a mounted `/v1/ext/<slug>/<path>` route whose
|
|
28
|
+
* contribution declared `auth: "public"` skips stage 5 — webhooks
|
|
29
|
+
* and OAuth callbacks verify their own caller. Stages 1-4 still
|
|
30
|
+
* applied (an unclaimed server answers a public route 403
|
|
31
|
+
* `setup_required`, spec §5.8); the request carries no principal.
|
|
32
|
+
* 5. **Methods, in `ctx.methods` order.** `proxy`: trusted peer CIDR +
|
|
33
|
+
* (when configured) the shared-secret header (deleted from the
|
|
34
|
+
* request before handlers run) + a non-empty user header; a
|
|
35
|
+
* directory miss auto-provisions (`source:"proxy"`, admin:false)
|
|
36
|
+
* unless `autoProvision:false` → 403 `user_not_provisioned`.
|
|
37
|
+
* `token`: Bearer or the `sigrun_session` cookie → `tokens.verify`;
|
|
38
|
+
* the token's user must exist in the directory, else 401.
|
|
39
|
+
* 6. **Empty list** (the naive workstation path): the trimmed
|
|
40
|
+
* `X-Sigrun-User` header verbatim, `admin: true`, method `"none"`.
|
|
41
|
+
*/
|
|
42
|
+
import { timingSafeEqual } from "node:crypto";
|
|
43
|
+
import { ipAllowed } from "./ip.js";
|
|
44
|
+
import { isLoopbackHost } from "./loopback.js";
|
|
45
|
+
/** One header read: Fastify headers may be string | string[]; first wins. */
|
|
46
|
+
function headerOf(request, name) {
|
|
47
|
+
const value = request.headers[name.toLowerCase()];
|
|
48
|
+
const single = Array.isArray(value) ? value[0] : value;
|
|
49
|
+
return single?.trim() || undefined;
|
|
50
|
+
}
|
|
51
|
+
/** Constant-time string equality (credentials never compare with `===`). */
|
|
52
|
+
function timingSafeStringEqual(a, b) {
|
|
53
|
+
const left = Buffer.from(a, "utf8");
|
|
54
|
+
const right = Buffer.from(b, "utf8");
|
|
55
|
+
if (left.length !== right.length)
|
|
56
|
+
return false;
|
|
57
|
+
return timingSafeEqual(left, right);
|
|
58
|
+
}
|
|
59
|
+
/** The request's Bearer credential, if the Authorization header carries one. */
|
|
60
|
+
function bearerOf(request) {
|
|
61
|
+
const value = headerOf(request, "authorization");
|
|
62
|
+
if (value === undefined)
|
|
63
|
+
return undefined;
|
|
64
|
+
const match = /^Bearer\s+(.+)$/i.exec(value);
|
|
65
|
+
return match?.[1]?.trim() || undefined;
|
|
66
|
+
}
|
|
67
|
+
/** The `sigrun_session` cookie value (naive parse; the cookie is ours). */
|
|
68
|
+
function sessionCookieOf(request) {
|
|
69
|
+
const raw = headerOf(request, "cookie");
|
|
70
|
+
if (raw === undefined)
|
|
71
|
+
return undefined;
|
|
72
|
+
for (const pair of raw.split(";")) {
|
|
73
|
+
const eq = pair.indexOf("=");
|
|
74
|
+
if (eq === -1)
|
|
75
|
+
continue;
|
|
76
|
+
if (pair.slice(0, eq).trim() === "sigrun_session") {
|
|
77
|
+
return pair.slice(eq + 1).trim() || undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
/** The Host hostname with any port stripped (`[::1]:3000` → `[::1]`). */
|
|
83
|
+
function hostNameOf(host) {
|
|
84
|
+
if (host.startsWith("[")) {
|
|
85
|
+
const close = host.indexOf("]");
|
|
86
|
+
return close === -1 ? host : host.slice(0, close + 1);
|
|
87
|
+
}
|
|
88
|
+
const colon = host.indexOf(":");
|
|
89
|
+
return colon === -1 ? host : host.slice(0, colon);
|
|
90
|
+
}
|
|
91
|
+
/** Stage 1a: the Host check. Returns the rejection status, or undefined. */
|
|
92
|
+
function checkHost(request, ctx) {
|
|
93
|
+
const host = headerOf(request, "host");
|
|
94
|
+
if (host === undefined)
|
|
95
|
+
return 421;
|
|
96
|
+
const allowed = ctx.config.auth.allowedHosts ?? [];
|
|
97
|
+
if (allowed.some((entry) => entry.toLowerCase() === host.toLowerCase())) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const name = hostNameOf(host);
|
|
101
|
+
if (allowed.some((entry) => entry.toLowerCase() === name.toLowerCase())) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
if (!isLoopbackHost(ctx.listenHost)) {
|
|
105
|
+
// A non-loopback bind checks only when the allowlist exists.
|
|
106
|
+
return allowed.length === 0 ? undefined : 421;
|
|
107
|
+
}
|
|
108
|
+
return isLoopbackHost(name) ? undefined : 421;
|
|
109
|
+
}
|
|
110
|
+
/** Stage 1b: the Origin check (non-GET/HEAD only). Returns true when rejected. */
|
|
111
|
+
function checkOrigin(request, ctx) {
|
|
112
|
+
if (request.method === "GET" || request.method === "HEAD")
|
|
113
|
+
return false;
|
|
114
|
+
const origin = headerOf(request, "origin");
|
|
115
|
+
if (origin === undefined)
|
|
116
|
+
return false;
|
|
117
|
+
for (const allowed of ctx.config.auth.allowedOrigins ?? []) {
|
|
118
|
+
if (allowed.toLowerCase() === origin.toLowerCase())
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
// The effective host the browser claims to be serving: X-Forwarded-Host
|
|
122
|
+
// from a trusted peer (a forward-auth proxy fronts us), else Host.
|
|
123
|
+
const trusted = ipAllowed(request.socket.remoteAddress, ctx.config.auth.proxy.trustedProxies);
|
|
124
|
+
const forwarded = trusted ? headerOf(request, "x-forwarded-host") : undefined;
|
|
125
|
+
const host = headerOf(request, "host");
|
|
126
|
+
const expectedHosts = new Set();
|
|
127
|
+
for (const candidate of [forwarded, host]) {
|
|
128
|
+
if (candidate === undefined)
|
|
129
|
+
continue;
|
|
130
|
+
expectedHosts.add(candidate.toLowerCase());
|
|
131
|
+
expectedHosts.add(hostNameOf(candidate).toLowerCase());
|
|
132
|
+
}
|
|
133
|
+
let originHost;
|
|
134
|
+
try {
|
|
135
|
+
originHost = new URL(origin).host.toLowerCase();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return true; // an unparseable Origin never matches
|
|
139
|
+
}
|
|
140
|
+
return !expectedHosts.has(originHost);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Stage 2: whether the request answers without a principal.
|
|
144
|
+
*
|
|
145
|
+
* Computed from the MATCHED ROUTE PATTERN (`request.routeOptions.url`,
|
|
146
|
+
* e.g. `/v1/settings`, `/v1/conversations/:id`, `/*`), never the raw
|
|
147
|
+
* URL: Fastify decodes the path BEFORE routing, so `GET /%761/settings`
|
|
148
|
+
* (`%76` = `v`) routes to `/v1/settings` while the raw URL fails a
|
|
149
|
+
* `/v1/` prefix check — the encoded-path auth bypass. A GET is public
|
|
150
|
+
* only when it matched the static plugin's wildcard (`/*`) or matched
|
|
151
|
+
* nothing (the not-found handler serves the SPA for non-/v1 paths).
|
|
152
|
+
*/
|
|
153
|
+
function isPublicPath(request, ctx) {
|
|
154
|
+
const pattern = request.routeOptions.url;
|
|
155
|
+
if (pattern === "/healthz" || pattern === "/readyz")
|
|
156
|
+
return true;
|
|
157
|
+
if (pattern === "/metrics" && ctx.config.metricsPublic)
|
|
158
|
+
return true;
|
|
159
|
+
if (pattern === "/v1/auth/login" && request.method === "POST")
|
|
160
|
+
return true;
|
|
161
|
+
if (pattern === "/v1/auth/logout" && request.method === "POST")
|
|
162
|
+
return true;
|
|
163
|
+
if (pattern === "/v1/onboarding" || pattern?.startsWith("/v1/onboarding/")) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
// The browser surface: GET/HEAD that matched the static wildcard or
|
|
167
|
+
// no route at all (the not-found handler serves the SPA). Anything
|
|
168
|
+
// that matched a real API route — under ANY spelling of its path — is
|
|
169
|
+
// not public.
|
|
170
|
+
if (request.method === "GET" || request.method === "HEAD") {
|
|
171
|
+
if (pattern === "/*" || pattern === undefined)
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Stage 5a (proxy): resolve the forward-auth headers, or reply with the
|
|
178
|
+
* rejection. The secret header is DELETED from the request before any
|
|
179
|
+
* handler (or test hook) can observe it.
|
|
180
|
+
*/
|
|
181
|
+
async function resolveProxy(request, ctx) {
|
|
182
|
+
const { proxy, adminGroups } = ctx.config.auth;
|
|
183
|
+
if (!ipAllowed(request.socket.remoteAddress, proxy.trustedProxies)) {
|
|
184
|
+
return { status: 401 };
|
|
185
|
+
}
|
|
186
|
+
if (proxy.secret !== undefined && proxy.secretHeader !== undefined) {
|
|
187
|
+
const name = proxy.secretHeader.toLowerCase();
|
|
188
|
+
const presented = headerOf(request, name);
|
|
189
|
+
delete request.headers[name];
|
|
190
|
+
if (presented === undefined || !timingSafeStringEqual(presented, proxy.secret)) {
|
|
191
|
+
return { status: 401 };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const user = headerOf(request, proxy.userHeader);
|
|
195
|
+
if (user === undefined)
|
|
196
|
+
return { status: 401 };
|
|
197
|
+
const groups = headerOf(request, proxy.groupsHeader)
|
|
198
|
+
?.split(",")
|
|
199
|
+
.map((group) => group.trim())
|
|
200
|
+
.filter((group) => group !== "") ?? [];
|
|
201
|
+
const { users } = ctx.opened.identity;
|
|
202
|
+
// The invalid-username case (a proxy header that can't be a directory
|
|
203
|
+
// user) is a loud 403, not a 500: the proxy vouched for an identity
|
|
204
|
+
// this server can never represent.
|
|
205
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(user)) {
|
|
206
|
+
return { status: 403, error: "invalid_username" };
|
|
207
|
+
}
|
|
208
|
+
const existing = users.get(user);
|
|
209
|
+
if (existing === undefined && !proxy.autoProvision) {
|
|
210
|
+
return { status: 403, error: "user_not_provisioned" };
|
|
211
|
+
}
|
|
212
|
+
const record = existing ??
|
|
213
|
+
(await users.add({
|
|
214
|
+
user,
|
|
215
|
+
...(headerOf(request, proxy.nameHeader) === undefined
|
|
216
|
+
? {}
|
|
217
|
+
: { name: headerOf(request, proxy.nameHeader) }),
|
|
218
|
+
...(headerOf(request, proxy.emailHeader) === undefined
|
|
219
|
+
? {}
|
|
220
|
+
: { email: headerOf(request, proxy.emailHeader) }),
|
|
221
|
+
admin: false,
|
|
222
|
+
source: "proxy",
|
|
223
|
+
}));
|
|
224
|
+
const groupAdmin = groups.some((group) => adminGroups.includes(group));
|
|
225
|
+
return {
|
|
226
|
+
user: record.user,
|
|
227
|
+
...(record.name === undefined ? {} : { name: record.name }),
|
|
228
|
+
...(record.email === undefined ? {} : { email: record.email }),
|
|
229
|
+
admin: record.admin || groupAdmin,
|
|
230
|
+
service: false,
|
|
231
|
+
method: "proxy",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/** Stage 5b (token): verify a presented credential against the token store. */
|
|
235
|
+
function resolveToken(request, ctx) {
|
|
236
|
+
const presented = bearerOf(request) ?? sessionCookieOf(request);
|
|
237
|
+
if (presented === undefined)
|
|
238
|
+
return { status: 401 };
|
|
239
|
+
const record = ctx.opened.identity.tokens.verify(presented);
|
|
240
|
+
if (record === undefined)
|
|
241
|
+
return { status: 401 };
|
|
242
|
+
const user = ctx.opened.identity.users.get(record.user);
|
|
243
|
+
// A token for a removed user is dead weight — the user must exist.
|
|
244
|
+
if (user === undefined)
|
|
245
|
+
return { status: 401 };
|
|
246
|
+
return {
|
|
247
|
+
user: user.user,
|
|
248
|
+
...(user.name === undefined ? {} : { name: user.name }),
|
|
249
|
+
...(user.email === undefined ? {} : { email: user.email }),
|
|
250
|
+
admin: user.admin,
|
|
251
|
+
service: false,
|
|
252
|
+
method: "token",
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/** The ONE request hook. Stages 1→5 in brief order; a stage replies or falls through. */
|
|
256
|
+
export function registerAuthPipeline(app, ctx) {
|
|
257
|
+
app.addHook("onRequest", async (request, reply) => {
|
|
258
|
+
// Stage 1: Host + Origin.
|
|
259
|
+
const hostStatus = checkHost(request, ctx);
|
|
260
|
+
if (hostStatus !== undefined) {
|
|
261
|
+
return await reply.code(hostStatus).send({ error: "host_not_allowed" });
|
|
262
|
+
}
|
|
263
|
+
if (checkOrigin(request, ctx)) {
|
|
264
|
+
return await reply.code(403).send({ error: "origin_mismatch" });
|
|
265
|
+
}
|
|
266
|
+
// Stage 2: the public surface (resolved but never blocked).
|
|
267
|
+
if (isPublicPath(request, ctx))
|
|
268
|
+
return;
|
|
269
|
+
// Stage 3: the env service token (constant-time; skips stage 4).
|
|
270
|
+
const service = ctx.config.auth.serviceToken;
|
|
271
|
+
const bearer = bearerOf(request);
|
|
272
|
+
if (service !== undefined &&
|
|
273
|
+
bearer !== undefined &&
|
|
274
|
+
timingSafeStringEqual(bearer, service.token)) {
|
|
275
|
+
request.principal = {
|
|
276
|
+
user: service.user,
|
|
277
|
+
admin: true,
|
|
278
|
+
service: true,
|
|
279
|
+
method: "token",
|
|
280
|
+
};
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
// Stage 4: the setup gate (the brief's "never 401 before 403").
|
|
284
|
+
if (!ctx.opened.identity.users.isClaimed()) {
|
|
285
|
+
return await reply.code(403).send({ error: "setup_required" });
|
|
286
|
+
}
|
|
287
|
+
// Stage 4b: the extension-route public skip (W5 Task 2, spec §9.2).
|
|
288
|
+
// The request is already ROUTED — Fastify resolved it to a route
|
|
289
|
+
// before lifecycle hooks run — so `routeOptions.config.extAuth` is
|
|
290
|
+
// the mounted route's declared auth, exactly (and `routeOptions.url`
|
|
291
|
+
// is the MATCHED PATTERN, immune to encoded-path spellings). A
|
|
292
|
+
// `public` route (a webhook, an OAuth callback) skips stage 5; its
|
|
293
|
+
// handler sees no principal and verifies its own caller. Everything
|
|
294
|
+
// else (`user`/`admin`, core routes, unrouted paths) falls through to
|
|
295
|
+
// the normal authentication stages.
|
|
296
|
+
if (request.routeOptions.url?.startsWith("/v1/ext/") &&
|
|
297
|
+
request.routeOptions.config.extAuth === "public") {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// Stage 5/6: the configured methods, in order; the empty list is
|
|
301
|
+
// the naive loopback path.
|
|
302
|
+
if (ctx.methods.length === 0) {
|
|
303
|
+
request.principal = {
|
|
304
|
+
user: headerOf(request, "x-sigrun-user"),
|
|
305
|
+
admin: true,
|
|
306
|
+
service: false,
|
|
307
|
+
method: "none",
|
|
308
|
+
};
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
for (const method of ctx.methods) {
|
|
312
|
+
const resolved = method === "proxy"
|
|
313
|
+
? await resolveProxy(request, ctx)
|
|
314
|
+
: resolveToken(request, ctx);
|
|
315
|
+
if ("user" in resolved && resolved.user !== undefined) {
|
|
316
|
+
request.principal = resolved;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
// A configured-deny (403 user_not_provisioned) is terminal: the
|
|
320
|
+
// proxy VOUCHED for a user the directory refuses — trying weaker
|
|
321
|
+
// methods would widen, not narrow, the failure surface.
|
|
322
|
+
if (!("user" in resolved) && resolved.status === 403) {
|
|
323
|
+
return await reply
|
|
324
|
+
.code(403)
|
|
325
|
+
.send({ error: resolved.error ?? "forbidden" });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return await reply.code(401).send({ error: "unauthenticated" });
|
|
329
|
+
});
|
|
330
|
+
}
|