my-durable 0.0.3 → 0.0.5

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.
Files changed (31) hide show
  1. package/README.md +8 -6
  2. package/apps/dpi-backend/src/config.ts +20 -3
  3. package/apps/dpi-backend/src/main.ts +2 -0
  4. package/apps/dpi-backend/src/pairing.ts +42 -11
  5. package/apps/dpi-backend/src/routes.ts +11 -3
  6. package/apps/dpi-playground/README.md +7 -0
  7. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +1 -0
  8. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.js +77 -0
  9. package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
  10. package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +3 -0
  11. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/auth.ts +108 -0
  12. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +18 -3
  13. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/LoginCard.vue +59 -0
  14. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +32 -4
  15. package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +4 -0
  16. package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +24 -0
  17. package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +168 -0
  18. package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +14 -2
  19. package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +79 -6
  20. package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +42 -7
  21. package/apps/dpi-sdk/src/protocol/limits.ts +17 -0
  22. package/apps/dpi-sdk/src/protocol/pairing.ts +201 -1
  23. package/apps/dpi-tui/src/app.ts +25 -1
  24. package/apps/dpi-tui/src/main.ts +95 -51
  25. package/apps/dpi-tui/src/pickers.ts +98 -0
  26. package/apps/dpi-tui/src/welcome.ts +394 -0
  27. package/bin/dpi.mjs +155 -0
  28. package/package.json +2 -2
  29. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +0 -77
  30. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +0 -1
  31. package/bin/md.mjs +0 -88
@@ -7,8 +7,8 @@
7
7
  <meta name="referrer" content="no-referrer" />
8
8
  <link rel="icon" type="image/svg+xml" href="/icons/durable-pi.svg" />
9
9
  <title>Durable Pi | Network</title>
10
- <script type="module" crossorigin src="/assets/index-CFGc7lHT.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-DFdKpwm_.css">
10
+ <script type="module" crossorigin src="/assets/index-Dq0CZYr7.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index--aoZ5Tj0.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="app"></div>
@@ -13,6 +13,7 @@ import DeviceCredentialsDialog from "./components/DeviceCredentialsDialog.vue";
13
13
  import PromptDialog from "./components/PromptDialog.vue";
14
14
  import ModelPicker from "./components/ModelPicker.vue";
15
15
  import HelpDialog from "./components/HelpDialog.vue";
16
+ import LoginCard from "./components/LoginCard.vue";
16
17
  import Toast from "./components/Toast.vue";
17
18
  import {
18
19
  boot,
@@ -32,6 +33,7 @@ import {
32
33
  toggleTree,
33
34
  toggleInspector,
34
35
  } from "./client/store";
36
+ import { auth } from "./client/auth";
35
37
 
36
38
  const onlineCount = () => network.devices.value.filter((d) => d.status === "online").length;
37
39
  const pairWizard = ref<{ close(): Promise<void> }>();
@@ -175,6 +177,7 @@ onBeforeUnmount(() => {
175
177
  </section>
176
178
  </div>
177
179
  <HelpDialog v-else-if="ui.dialog?.kind === 'help'" />
180
+ <LoginCard v-if="auth.needed" />
178
181
  <Toast />
179
182
  </div>
180
183
  </template>
@@ -0,0 +1,108 @@
1
+ // Admin session for the browser. The ONLY browser credential is the hub's
2
+ // `dpi_admin` cookie (HttpOnly — script can never read it); no token is kept in
3
+ // localStorage, sessionStorage or the URL.
4
+ //
5
+ // `installAdminLoginFetch` wraps window.fetch once at startup: any 401 from an
6
+ // admin call raises the login card (LoginCard.vue), waits for a successful
7
+ // login, then retries the failed call exactly once and the app continues where
8
+ // it was. The login call itself is never intercepted.
9
+ import { reactive } from "vue";
10
+
11
+ export const auth = reactive({
12
+ /** True while a 401 is waiting for the operator to sign in. */
13
+ needed: false,
14
+ busy: false,
15
+ error: "",
16
+ });
17
+
18
+ type Waiter = (ok: boolean) => void;
19
+ let waiters = new Set<Waiter>();
20
+
21
+ const release = (ok: boolean) => {
22
+ const pending = [...waiters];
23
+ waiters = new Set();
24
+ for (const w of pending) w(ok);
25
+ };
26
+
27
+ const waitForLogin = () => new Promise<boolean>((done) => waiters.add(done));
28
+
29
+ /** POST /api/session with the typed token; on success every waiting call retries. */
30
+ export async function login(token: string): Promise<boolean> {
31
+ if (auth.busy || !token) return false;
32
+ auth.busy = true;
33
+ auth.error = "";
34
+ try {
35
+ const r = await fetch("/api/session", {
36
+ method: "POST",
37
+ headers: { "content-type": "application/json" },
38
+ body: JSON.stringify({ token }),
39
+ });
40
+ // Read the body in full: an unconsumed fetch body is cancelled once its Response is
41
+ // garbage-collected, and harnesses that record response bodies must still read it.
42
+ const body = await r.text().catch(() => "");
43
+ if (r.ok) {
44
+ auth.needed = false;
45
+ release(true);
46
+ return true;
47
+ }
48
+ auth.error =
49
+ r.status === 401
50
+ ? "That token was not accepted."
51
+ : r.status === 429
52
+ ? "Too many attempts. Wait a minute and try again."
53
+ : `Login failed (${r.status}).`;
54
+ void body;
55
+ return false;
56
+ } catch {
57
+ auth.error = "Cannot reach the hub.";
58
+ return false;
59
+ } finally {
60
+ auth.busy = false;
61
+ }
62
+ }
63
+
64
+ /** Dismiss the card: waiting calls get their original 401 back. */
65
+ export function cancelLogin(): void {
66
+ auth.needed = false;
67
+ auth.error = "";
68
+ release(false);
69
+ }
70
+
71
+ const isSessionCall = (url: string) => {
72
+ try {
73
+ return new URL(url, location.href).pathname === "/api/session";
74
+ } catch {
75
+ return url === "/api/session";
76
+ }
77
+ };
78
+
79
+ /**
80
+ * Wrap `fetch`: a 401 on anything except the login call itself raises the login
81
+ * card, and after a successful login the SAME request is retried once. A stream
82
+ * body cannot be replayed, so those are left alone.
83
+ */
84
+ export function withAdminLogin(native: typeof fetch): typeof fetch {
85
+ return (async (input: RequestInfo | URL, init?: RequestInit) => {
86
+ const call = () => native(input, init);
87
+ const response = await call();
88
+ if (response.status !== 401) return response;
89
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
90
+ if (isSessionCall(url) || init?.body instanceof ReadableStream) return response;
91
+ auth.needed = true;
92
+ // Drain the refused body now: an unconsumed fetch body is cancelled when its Response
93
+ // is garbage-collected, and harnesses that record response bodies (tests/pairing-ui.mjs)
94
+ // must still be able to read it afterwards. A cancelled login hands the caller an
95
+ // equivalent Response so its own r.json() keeps working.
96
+ const text = await response.text().catch(() => "");
97
+ if (!(await waitForLogin()))
98
+ return new Response(text, { status: response.status, headers: response.headers });
99
+ return call();
100
+ }) as typeof fetch;
101
+ }
102
+
103
+ /** Patch window.fetch with the 401 retry; install once, before the first call. */
104
+ export function installAdminLoginFetch(): void {
105
+ if (typeof window === "undefined" || (window as { __adminFetch?: boolean }).__adminFetch) return;
106
+ (window as { __adminFetch?: boolean }).__adminFetch = true;
107
+ window.fetch = withAdminLogin(window.fetch.bind(window));
108
+ }
@@ -1,18 +1,33 @@
1
1
  /** Pair links stay in RAM and may only refer to the browser's own hub. */
2
+ import { decodePairOffer, normalizePairCode, parsePairOfferUrl } from "../../../../../dpi-sdk/src/protocol/pairing";
3
+
2
4
  export type PairHandoff = { code: string; origin: string } | { error: string };
3
5
 
4
6
  export function readPairHandoff(url: URL): PairHandoff | undefined {
5
7
  if (!/^\/pair\/?$/.test(url.pathname)) return undefined;
8
+ // v2 FIRST: the whole handoff rides the #offer= fragment, decoded client-side —
9
+ // the code never travels in a query, so it never reaches any server or CDN log.
10
+ if (url.hash.startsWith("#offer=")) {
11
+ const parsed = parsePairOfferUrl(url.href);
12
+ if (parsed) return { code: parsed.offer.code, origin: url.origin };
13
+ const offer = decodePairOffer(url.hash.slice("#offer=".length));
14
+ if (offer && offer.hub !== url.origin)
15
+ return {
16
+ error: "This pairing link does not belong to this hub. Open the original hub link instead.",
17
+ };
18
+ return { error: "Invalid pairing link. Use a complete link from the hub wizard." };
19
+ }
6
20
  const params = url.searchParams;
7
21
  if (!params.has("code") && !params.has("origin") && !params.has("v")) return undefined;
8
22
  const code = params.get("code");
23
+ // The SDK owns the code shape: canonicalise a grouped/lower-case value, refuse anything else.
24
+ const canonical = code === null ? undefined : normalizePairCode(code);
9
25
  if (
10
26
  url.username ||
11
27
  url.password ||
12
28
  url.hash ||
13
29
  params.getAll("code").length !== 1 ||
14
- !code ||
15
- !/^[A-Z2-7]{8}$/.test(code) ||
30
+ !canonical ||
16
31
  params.getAll("v").length !== 1 ||
17
32
  params.get("v") !== "1"
18
33
  ) {
@@ -37,7 +52,7 @@ export function readPairHandoff(url: URL): PairHandoff | undefined {
37
52
  };
38
53
  }
39
54
  }
40
- return { code, origin: url.origin };
55
+ return { code: canonical, origin: url.origin };
41
56
  }
42
57
 
43
58
  /**
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ // The admin gate: one centered card with one password-type input. Shown when any
3
+ // admin call answered 401 (client/auth.ts); a successful login retries the call
4
+ // that was interrupted. The token itself lives only in the HttpOnly cookie — it
5
+ // is never stored or echoed anywhere the page can read.
6
+ import { nextTick, onMounted, ref } from "vue";
7
+ import { auth, login } from "../client/auth";
8
+
9
+ const token = ref("");
10
+ const input = ref<HTMLInputElement>();
11
+
12
+ async function submit() {
13
+ if (auth.busy || !token.value.trim()) return;
14
+ const ok = await login(token.value.trim());
15
+ token.value = "";
16
+ if (ok) input.value?.blur();
17
+ }
18
+
19
+ onMounted(() => void nextTick(() => input.value?.focus()));
20
+ </script>
21
+
22
+ <template>
23
+ <div class="login-veil" data-testid="login-card">
24
+ <form
25
+ class="modal login-card"
26
+ role="dialog"
27
+ aria-modal="true"
28
+ aria-labelledby="login-title"
29
+ @submit.prevent="submit"
30
+ >
31
+ <header>
32
+ <h2 id="login-title">Admin token</h2>
33
+ </header>
34
+ <p class="hint">
35
+ This hub is locked. Sign in with the token from its <code>admin.token</code> file.
36
+ </p>
37
+ <label for="admin-token">Admin token</label>
38
+ <input
39
+ id="admin-token"
40
+ ref="input"
41
+ v-model="token"
42
+ type="password"
43
+ name="admin-token"
44
+ autocomplete="off"
45
+ spellcheck="false"
46
+ placeholder="adm_…"
47
+ aria-label="Admin token"
48
+ :disabled="auth.busy"
49
+ @keydown.esc="auth.error = ''"
50
+ />
51
+ <p v-if="auth.error" class="message-error" role="alert">{{ auth.error }}</p>
52
+ <footer>
53
+ <button class="primary" type="submit" :disabled="auth.busy || !token.trim()">
54
+ {{ auth.busy ? "Checking…" : "Sign in" }}
55
+ </button>
56
+ </footer>
57
+ </form>
58
+ </div>
59
+ </template>
@@ -22,6 +22,7 @@ import { createPairingSession, pairingAdmin, pollCode, type PairingSession } fro
22
22
  import { qrSvg } from "../client/qrcode";
23
23
  import type { PairCodeIssue, PairCodeState } from "@sdk/api";
24
24
  import { DpiError } from "@sdk/errors";
25
+ import { isPairCode } from "@sdk/protocol/pairing";
25
26
  import { settledPairPath, type PairHandoff } from "../client/pair-handoff";
26
27
  import { CANCEL_TIMEOUT_MS, closeWizard, type CancelResult } from "../client/pair-close";
27
28
  import { notify, network } from "../client/store";
@@ -41,11 +42,16 @@ const closing = ref(false);
41
42
  const confirmed = ref(false);
42
43
  const replaceKey = ref(false);
43
44
  const copied = ref(false);
45
+ const linkCopied = ref(false);
44
46
  const watcher = ref<(() => void) | undefined>();
45
47
 
46
48
  const grouped = computed(() => {
47
49
  const code = issue.value?.code ?? "";
48
- return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
50
+ if (!isPairCode(code)) return code; // never invent separators around an unsupported value
51
+ // A minted code is a UUID v4: its hyphens are structure, so it is shown as it is.
52
+ if (code.includes("-")) return code;
53
+ // Legacy base32 (12 | 8 chars) reads better in blocks of four: `ABCD-2345-EF67`.
54
+ return (code.match(/.{4}/g) ?? []).join("-");
49
55
  });
50
56
  const qrData = computed(() => {
51
57
  if (!issue.value) return "";
@@ -205,6 +211,18 @@ async function copyCode(): Promise<void> {
205
211
  }
206
212
  }
207
213
 
214
+ /** Copies the v2 offer link (code never in a query); falls back to the v1 pair URL. */
215
+ async function copyLink(): Promise<void> {
216
+ if (!issue.value) return;
217
+ try {
218
+ await navigator.clipboard.writeText(issue.value.offerUrl ?? issue.value.pairUrl);
219
+ linkCopied.value = true;
220
+ setTimeout(() => (linkCopied.value = false), 1500);
221
+ } catch {
222
+ error.value = "Clipboard unavailable; copy the link manually.";
223
+ }
224
+ }
225
+
208
226
  async function close(): Promise<void> {
209
227
  if (closing.value) return;
210
228
  closing.value = true;
@@ -275,9 +293,14 @@ onBeforeUnmount(() => {
275
293
  <div class="pair-grid">
276
294
  <div class="pair-code-box">
277
295
  <div class="pair-code" aria-label="Pairing code">{{ grouped }}</div>
278
- <button type="button" class="small" @click="copyCode">
279
- <Copy :size="14" /> {{ copied ? "Copied" : "Copy code" }}
280
- </button>
296
+ <div class="pair-copy-actions">
297
+ <button type="button" class="small" @click="copyCode">
298
+ <Copy :size="14" /> {{ copied ? "Copied" : "Copy code" }}
299
+ </button>
300
+ <button type="button" class="small" @click="copyLink" title="Self-contained link: hub, code and expiry all in the fragment — it never reaches server logs">
301
+ <Copy :size="14" /> {{ linkCopied ? "Copied link" : "Copy link" }}
302
+ </button>
303
+ </div>
281
304
  <p class="hint">
282
305
  Run the device pairing command and scan, or enter this code on the device. Expires in
283
306
  5 minutes.
@@ -395,6 +418,11 @@ onBeforeUnmount(() => {
395
418
  font-family: var(--mono, monospace);
396
419
  margin-bottom: 0.35rem;
397
420
  }
421
+ .pair-wizard .pair-copy-actions {
422
+ display: flex;
423
+ flex-wrap: wrap;
424
+ gap: 0.4rem;
425
+ }
398
426
  .pair-wizard .pair-qr svg {
399
427
  border: 1px solid var(--border, #333);
400
428
  border-radius: 6px;
@@ -1,11 +1,15 @@
1
1
  import { createApp } from "vue";
2
2
  import App from "./App.vue";
3
3
  import { ui } from "./client/store";
4
+ import { installAdminLoginFetch } from "./client/auth";
4
5
  import { barePairHome, readPairHandoff } from "./client/pair-handoff";
5
6
  import "./style.css";
6
7
  import "./network.css";
7
8
  import "./jobs.css";
8
9
  import "./extensions.css";
10
+ // Before anything can call out: a 401 from an admin route raises the login card
11
+ // and the failed call is retried after a successful login (client/auth.ts).
12
+ installAdminLoginFetch();
9
13
  const url = new URL(window.location.href);
10
14
  if (/^\/pair\/?$/.test(url.pathname)) {
11
15
  const handoff = readPairHandoff(url);
@@ -1753,6 +1753,30 @@ svg {
1753
1753
  flex-direction: column;
1754
1754
  overflow: hidden;
1755
1755
  }
1756
+ /* The admin gate: one centered card over a dark veil while a 401 waits for the
1757
+ operator's token. Same panel/border/radius language as the dialogs. */
1758
+ .login-veil {
1759
+ position: fixed;
1760
+ inset: 0;
1761
+ z-index: 100;
1762
+ background: #0d0808e0;
1763
+ backdrop-filter: blur(7px);
1764
+ display: flex;
1765
+ align-items: center;
1766
+ justify-content: center;
1767
+ padding: 24px;
1768
+ }
1769
+ .login-card {
1770
+ width: 400px;
1771
+ }
1772
+ .login-card .hint {
1773
+ color: var(--fg);
1774
+ font-size: 15px;
1775
+ line-height: 1.55;
1776
+ }
1777
+ .login-card footer {
1778
+ justify-content: flex-end;
1779
+ }
1756
1780
  .model-list {
1757
1781
  overflow: auto;
1758
1782
  margin-top: 12px;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Hub admin authentication (the operator's own credential — devices use pairing
3
+ * credentials and never see any of this).
4
+ *
5
+ * Token bootstrap (mirrors the device-side DPI_TOKEN bootstrap):
6
+ * - `DPI_ADMIN_TOKEN` env wins outright; anything shorter than 32 characters is a
7
+ * configuration mistake and REFUSES to boot — it is never silently replaced.
8
+ * - Otherwise the token is minted at first boot (`adm_` + 32 random bytes,
9
+ * base64url) into `<DPI_HOME>/admin.token` (or `DPI_ADMIN_TOKEN_FILE`),
10
+ * owner-only 0600, and logged once.
11
+ *
12
+ * Presentation:
13
+ * - Browsers: `POST /api/session {token}` sets a stateless cookie `dpi_admin` =
14
+ * base64url(payload) + "." + base64url(HMAC-SHA256(payload, adminToken)) with
15
+ * payload `{"exp":<ms>}`. HttpOnly, SameSite=Strict, Path=/, 4 h, `Secure` when
16
+ * the request arrived via the TLS proxy. Forging it requires the token itself;
17
+ * it survives a hub restart because it carries no server-side state.
18
+ * - Non-browser clients (TUI, tests, curl): the raw token as
19
+ * `Authorization: Bearer <adm_…>` or `X-Admin-Token`.
20
+ *
21
+ * Every comparison hashes both sides with SHA-256 first and uses timingSafeEqual
22
+ * (the device-side bearerAuth pattern), so the cookie HMAC and the raw token are
23
+ * both compared in constant time.
24
+ */
25
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
26
+ import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
27
+ import { dirname, join } from "node:path";
28
+ import type { RequestHandler } from "express";
29
+ import { homedir } from "node:os";
30
+
31
+ export const ADMIN_COOKIE = "dpi_admin";
32
+ /** How long a login cookie is worth. */
33
+ export const SESSION_TTL_MS = 4 * 60 * 60 * 1000;
34
+ /** A set-but-short env token is a configuration mistake, never replaced silently. */
35
+ export const MIN_TOKEN_CHARS = 32;
36
+ /** Login attempts per client address: 5 per minute, burst 5. */
37
+ export const LOGIN_RATE = { capacity: 5, perMinute: 5 } as const;
38
+
39
+ const sha256 = (value: string) => createHash("sha256").update(value, "utf8").digest();
40
+
41
+ /** Constant-time string equality (hash first so lengths cannot leak). */
42
+ export function tokenEquals(presented: string, expected: string): boolean {
43
+ const a = sha256(presented);
44
+ const b = sha256(expected);
45
+ return timingSafeEqual(a, b);
46
+ }
47
+
48
+ export interface AdminTokenInfo {
49
+ token: string;
50
+ /** Where the token file lives (even when the env override is in force). */
51
+ path: string;
52
+ /** True when this boot minted the token file. */
53
+ created: boolean;
54
+ }
55
+
56
+ /**
57
+ * Resolve the admin token at boot: env override (>= 32 chars or throw), the
58
+ * existing token file, or a freshly minted one written 0600.
59
+ */
60
+ export function bootstrapAdminToken(
61
+ home = process.env.DPI_HOME ?? join(homedir(), ".dpi"),
62
+ ): AdminTokenInfo {
63
+ const path = process.env.DPI_ADMIN_TOKEN_FILE ?? join(home, "admin.token");
64
+ const env = process.env.DPI_ADMIN_TOKEN;
65
+ if (env !== undefined && env !== "") {
66
+ if (env.length < MIN_TOKEN_CHARS)
67
+ throw Error(
68
+ `DPI_ADMIN_TOKEN is ${env.length} characters; an admin token must be at least ${MIN_TOKEN_CHARS}. ` +
69
+ "A short token is a configuration mistake — fix it or unset the variable; the hub will not replace it.",
70
+ );
71
+ return { token: env, path, created: false };
72
+ }
73
+ let existing = "";
74
+ try {
75
+ existing = readFileSync(path, "utf8").trim();
76
+ } catch {
77
+ /* first run (or an unreadable file): mint below */
78
+ }
79
+ if (existing) {
80
+ // The token file must stay 0600 — tighten quietly if a copy restored looser bits
81
+ // (the same quiet re-tightening Audit applies to the registry file).
82
+ try {
83
+ if ((statSync(path).mode & 0o777) !== 0o600) chmodSync(path, 0o600);
84
+ } catch {
85
+ /* best effort */
86
+ }
87
+ return { token: existing, path, created: false };
88
+ }
89
+ const token = `adm_${randomBytes(32).toString("base64url")}`;
90
+ // DPI_ADMIN_TOKEN_FILE may name a directory that does not exist yet — create it
91
+ // owner-only rather than refusing the operator's explicit choice of location.
92
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
93
+ writeFileSync(path, `${token}\n`, { mode: 0o600 });
94
+ chmodSync(path, 0o600); // a pre-existing (e.g. empty) file may carry wider bits
95
+ console.log(`first run: created admin token in ${path}`);
96
+ return { token, path, created: true };
97
+ }
98
+
99
+ /** The cookie value for a session expiring at `exp` (epoch ms). */
100
+ export function signSession(adminToken: string, exp: number): string {
101
+ const payload = Buffer.from(JSON.stringify({ exp }), "utf8").toString("base64url");
102
+ const mac = createHmac("sha256", adminToken).update(payload).digest("base64url");
103
+ return `${payload}.${mac}`;
104
+ }
105
+
106
+ /** True when `cookie` is a well-formed, correctly signed, unexpired session. */
107
+ export function verifySession(
108
+ adminToken: string,
109
+ cookie: string | undefined,
110
+ now = Date.now(),
111
+ ): boolean {
112
+ if (!cookie) return false;
113
+ const dot = cookie.indexOf(".");
114
+ if (dot <= 0) return false;
115
+ const payload = cookie.slice(0, dot);
116
+ const given = cookie.slice(dot + 1);
117
+ const expected = createHmac("sha256", adminToken).update(payload).digest("base64url");
118
+ const a = Buffer.from(given);
119
+ const b = Buffer.from(expected);
120
+ if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
121
+ try {
122
+ const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
123
+ exp?: unknown;
124
+ };
125
+ return typeof parsed.exp === "number" && Number.isFinite(parsed.exp) && parsed.exp > now;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /** The `dpi_admin` value from a raw Cookie header, or undefined. */
132
+ export function adminCookie(header: string | string[] | undefined): string | undefined {
133
+ const cookies = Array.isArray(header) ? header.join("; ") : header;
134
+ if (!cookies) return undefined;
135
+ for (const part of cookies.split(";")) {
136
+ const eq = part.indexOf("=");
137
+ if (eq <= 0) continue;
138
+ if (part.slice(0, eq).trim() === ADMIN_COOKIE) return part.slice(eq + 1).trim();
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ /** The presented raw token: `Authorization: Bearer <t>` or `X-Admin-Token: <t>`. */
144
+ export function bearerToken(req: { headers: Record<string, unknown> }): string {
145
+ const auth = req.headers.authorization;
146
+ const header = typeof auth === "string" ? auth : "";
147
+ if (header.startsWith("Bearer ")) return header.slice(7).trim();
148
+ const alt = req.headers["x-admin-token"];
149
+ return typeof alt === "string" ? alt.trim() : "";
150
+ }
151
+
152
+ /**
153
+ * Require the admin credential (cookie or bearer) on a route. Answers
154
+ * `{error:{code:"unauthorized"}}` with 401 otherwise.
155
+ */
156
+ export function requireAdmin(adminToken: string): RequestHandler {
157
+ return (req, res, next) => {
158
+ const bearer = bearerToken(req);
159
+ if (
160
+ (bearer && tokenEquals(bearer, adminToken)) ||
161
+ verifySession(adminToken, adminCookie(req.headers.cookie))
162
+ )
163
+ return void next();
164
+ res
165
+ .status(401)
166
+ .json({ error: { code: "unauthorized", message: "Admin authentication required" } });
167
+ };
168
+ }
@@ -38,6 +38,8 @@ export const AUDIT_ACTIONS = [
38
38
  "token.rotate_regenerated",
39
39
  "tunnel.connect",
40
40
  "tunnel.disconnect",
41
+ "admin.token_created",
42
+ "admin.login",
41
43
  "auth.denied",
42
44
  "rate_limited",
43
45
  "tunnel.listener_error",
@@ -90,6 +92,8 @@ export function scrubSecrets(value: string): string {
90
92
  let out = value;
91
93
  out = out.replace(/dpt_[A-Za-z0-9_-]{1,64}/g, "dpt_<redacted>");
92
94
  out = out.replace(/dpi_[A-Za-z0-9_-]{1,64}/g, "dpi_<redacted>");
95
+ // The hub's own admin token (adm_ + 32 random bytes, base64url).
96
+ out = out.replace(/adm_[A-Za-z0-9_-]{32,}/g, "adm_<redacted>");
93
97
  out = out.replace(
94
98
  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g,
95
99
  "<private-key-redacted>",
@@ -97,10 +101,18 @@ export function scrubSecrets(value: string): string {
97
101
  out = out.replace(/(authorization\s*[:=]\s*)(\S+)/gi, "$1<redacted>");
98
102
  out = out.replace(/(bearer\s+)([A-Za-z0-9._-]{8,})/gi, "$1<redacted>");
99
103
  out = out.replace(/([?&]code=)[A-Za-z0-9_-]+/g, "$1<redacted>");
104
+ // v2 offer links: the secret IS the #offer= fragment payload — redact it wherever a URL shows up.
105
+ out = out.replace(/(#offer=)[A-Za-z0-9_-]+/g, "$1<redacted>");
100
106
  // Credential material without its prefix (slices, echo): 43-char base64url runs.
101
107
  out = out.replace(/(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g, "<redacted>");
102
- // Pairing codes as standalone values ("code":"AB2C7DEF") but not ordinary words.
103
- out = out.replace(/(["'?=:,\s])([A-Z2-7]{8})(?=["'?\s:,}]|$)/g, "$1<code-redacted>");
108
+ // Pairing codes as standalone values ("code":"AB2C7DEF" / "ABCD2345EF67") but not ordinary words.
109
+ out = out.replace(/(["'?=:,\s])([A-Z2-7]{12}|[A-Z2-7]{8})(?=["'?\s:,}]|$)/g, "$1<code-redacted>");
110
+ // A MINTED code is a canonical UUID v4. Only a value LABELLED a code is a secret here: an audit
111
+ // row's own codeId/enrollmentId are correlation ids and must stay readable.
112
+ out = out.replace(
113
+ /(\bcode["']?\s*[:=]\s*["']?)([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})(?=["'?\s:,}&]|$)/gi,
114
+ "$1<code-redacted>",
115
+ );
104
116
  return out;
105
117
  }
106
118