Skip to content

Commit 81270c0

Browse files
dahliaclaude
andcommitted
Split mod.tsx into log-store, auth, and routes modules
The ~700-line mod.tsx was doing too many things: log storage, auth helpers, route definitions, and the public createFederationDebugger API. Extract cohesive units into their own modules: - log-store.ts: LogStore class, SerializedLogRecord, createLogSink - auth.ts: FederationDebuggerAuth type, session/HMAC helpers, checkAuth - routes.tsx: createDebugApp (Hono route tree with auth middleware) mod.tsx is now the public entry point that wires the pieces together and re-exports the public types. Addresses: #564 (comment) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7d2cf3a commit 81270c0

4 files changed

Lines changed: 444 additions & 401 deletions

File tree

packages/debugger/src/auth.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* Authentication types and helpers for the debug dashboard.
3+
*
4+
* @module
5+
*/
6+
import { timingSafeEqual } from "node:crypto";
7+
8+
/**
9+
* Authentication configuration for the debug dashboard.
10+
*
11+
* The debug dashboard can be protected using one of three authentication modes:
12+
*
13+
* - `"password"` — Shows a password-only login form.
14+
* - `"usernamePassword"` — Shows a username + password login form.
15+
* - `"request"` — Authenticates based on the incoming request (e.g., IP
16+
* address). No login form is shown; unauthenticated requests receive a
17+
* 403 response.
18+
*
19+
* Each mode supports either a static credential check or a callback function.
20+
*/
21+
export type FederationDebuggerAuth =
22+
| {
23+
readonly type: "password";
24+
authenticate(password: string): boolean | Promise<boolean>;
25+
}
26+
| {
27+
readonly type: "password";
28+
readonly password: string;
29+
}
30+
| {
31+
readonly type: "usernamePassword";
32+
authenticate(
33+
username: string,
34+
password: string,
35+
): boolean | Promise<boolean>;
36+
}
37+
| {
38+
readonly type: "usernamePassword";
39+
readonly username: string;
40+
readonly password: string;
41+
}
42+
| {
43+
readonly type: "request";
44+
authenticate(request: Request): boolean | Promise<boolean>;
45+
};
46+
47+
export const SESSION_COOKIE_NAME = "__fedify_debug_session";
48+
const SESSION_TOKEN = "authenticated";
49+
50+
export async function generateHmacKey(): Promise<CryptoKey> {
51+
return await crypto.subtle.generateKey(
52+
{ name: "HMAC", hash: "SHA-256" },
53+
false,
54+
["sign", "verify"],
55+
);
56+
}
57+
58+
function toHex(buffer: ArrayBuffer): string {
59+
return [...new Uint8Array(buffer)]
60+
.map((b) => b.toString(16).padStart(2, "0"))
61+
.join("");
62+
}
63+
64+
function fromHex(hex: string): ArrayBuffer {
65+
const bytes = new Uint8Array(hex.length / 2);
66+
for (let i = 0; i < hex.length; i += 2) {
67+
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
68+
}
69+
return bytes.buffer as ArrayBuffer;
70+
}
71+
72+
export async function signSession(key: CryptoKey): Promise<string> {
73+
const encoder = new TextEncoder();
74+
const signature = await crypto.subtle.sign(
75+
"HMAC",
76+
key,
77+
encoder.encode(SESSION_TOKEN),
78+
);
79+
return toHex(signature);
80+
}
81+
82+
export async function verifySession(
83+
key: CryptoKey,
84+
signature: string,
85+
): Promise<boolean> {
86+
try {
87+
const encoder = new TextEncoder();
88+
return await crypto.subtle.verify(
89+
"HMAC",
90+
key,
91+
fromHex(signature),
92+
encoder.encode(SESSION_TOKEN),
93+
);
94+
} catch {
95+
return false;
96+
}
97+
}
98+
99+
/**
100+
* Constant-time string comparison to prevent timing attacks on credential
101+
* checks. Uses {@link timingSafeEqual} from `node:crypto` under the hood.
102+
*/
103+
function constantTimeEqual(a: string, b: string): boolean {
104+
const encoder = new TextEncoder();
105+
const bufA = encoder.encode(a);
106+
const bufB = encoder.encode(b);
107+
if (bufA.byteLength !== bufB.byteLength) {
108+
// Still compare to burn the same amount of time regardless, but
109+
// the result is always false when lengths differ.
110+
timingSafeEqual(bufA, new Uint8Array(bufA.byteLength));
111+
return false;
112+
}
113+
return timingSafeEqual(bufA, bufB);
114+
}
115+
116+
export async function checkAuth(
117+
auth: FederationDebuggerAuth,
118+
formData: { username?: string; password: string },
119+
): Promise<boolean> {
120+
if (auth.type === "password") {
121+
if ("authenticate" in auth) {
122+
return await auth.authenticate(formData.password);
123+
}
124+
return constantTimeEqual(formData.password, auth.password);
125+
}
126+
if (auth.type === "usernamePassword") {
127+
if ("authenticate" in auth) {
128+
return await auth.authenticate(
129+
formData.username ?? "",
130+
formData.password,
131+
);
132+
}
133+
// Check both fields in constant time (don't short-circuit)
134+
const usernameMatch = constantTimeEqual(
135+
formData.username ?? "",
136+
auth.username,
137+
);
138+
const passwordMatch = constantTimeEqual(formData.password, auth.password);
139+
return usernameMatch && passwordMatch;
140+
}
141+
return false;
142+
}

packages/debugger/src/log-store.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Log record storage for the debug dashboard, backed by a {@link KvStore}.
3+
*
4+
* @module
5+
*/
6+
import type { KvKey, KvStore } from "@fedify/fedify/federation";
7+
import type { LogRecord, Sink } from "@logtape/logtape";
8+
9+
/**
10+
* A serialized log record for the debug dashboard.
11+
*/
12+
export interface SerializedLogRecord {
13+
/**
14+
* The logger category.
15+
*/
16+
readonly category: readonly string[];
17+
18+
/**
19+
* The log level.
20+
*/
21+
readonly level: string;
22+
23+
/**
24+
* The rendered log message.
25+
*/
26+
readonly message: string;
27+
28+
/**
29+
* The timestamp in milliseconds since the Unix epoch.
30+
*/
31+
readonly timestamp: number;
32+
33+
/**
34+
* The extra properties of the log record (excluding traceId and spanId).
35+
*/
36+
readonly properties: Record<string, unknown>;
37+
}
38+
39+
/**
40+
* Persistent storage for log records grouped by trace ID, backed by a
41+
* {@link KvStore}. When the same `KvStore` is shared across web and worker
42+
* processes the dashboard can display logs produced by background tasks.
43+
*/
44+
export class LogStore {
45+
readonly #kv: KvStore;
46+
readonly #keyPrefix: KvKey;
47+
/** Chain of pending write promises for flush(). */
48+
#pending: Promise<void> = Promise.resolve();
49+
50+
constructor(kv: KvStore, keyPrefix: KvKey = ["fedify", "debugger", "logs"]) {
51+
this.#kv = kv;
52+
this.#keyPrefix = keyPrefix;
53+
}
54+
55+
/**
56+
* Enqueue a log record for writing. The write happens asynchronously;
57+
* call {@link flush} to wait for all pending writes to complete.
58+
*
59+
* Keys use a timestamp + random suffix so that entries sort
60+
* chronologically and never collide, even across multiple processes
61+
* sharing the same {@link KvStore}.
62+
*/
63+
add(traceId: string, record: SerializedLogRecord): void {
64+
const key: KvKey = [
65+
...this.#keyPrefix,
66+
traceId,
67+
`${Date.now().toString(36).padStart(10, "0")}-${
68+
Math.random().toString(36).slice(2)
69+
}`,
70+
] as unknown as KvKey;
71+
// Errors are swallowed so a single failed write cannot poison the
72+
// chain or cause an unhandled rejection — logging is best-effort.
73+
this.#pending = this.#pending.then(
74+
() => this.#kv.set(key, record),
75+
).catch(() => {});
76+
}
77+
78+
/** Wait for all pending writes to complete. */
79+
flush(): Promise<void> {
80+
return this.#pending;
81+
}
82+
83+
async get(traceId: string): Promise<readonly SerializedLogRecord[]> {
84+
const prefix: KvKey = [...this.#keyPrefix, traceId] as unknown as KvKey;
85+
const logs: SerializedLogRecord[] = [];
86+
for await (const entry of this.#kv.list(prefix)) {
87+
logs.push(entry.value as SerializedLogRecord);
88+
}
89+
return logs;
90+
}
91+
}
92+
93+
/**
94+
* Converts a {@link LogRecord} into a plain serializable object suitable
95+
* for storage in a {@link KvStore}.
96+
*/
97+
export function serializeLogRecord(record: LogRecord): SerializedLogRecord {
98+
// Render message to string
99+
const messageParts: string[] = [];
100+
for (const part of record.message) {
101+
if (typeof part === "string") messageParts.push(part);
102+
else if (part == null) messageParts.push("");
103+
else messageParts.push(String(part));
104+
}
105+
// Exclude traceId and spanId from properties
106+
const { traceId: _t, spanId: _s, ...properties } = record.properties;
107+
return {
108+
category: record.category,
109+
level: record.level,
110+
message: messageParts.join(""),
111+
timestamp: record.timestamp,
112+
properties,
113+
};
114+
}
115+
116+
/**
117+
* Creates a LogTape {@link Sink} that writes log records into the given
118+
* {@link LogStore}, grouped by their `traceId` property. Records without
119+
* a `traceId` are silently discarded.
120+
*/
121+
export function createLogSink(store: LogStore): Sink {
122+
return (record: LogRecord): void => {
123+
const traceId = record.properties.traceId;
124+
if (typeof traceId !== "string" || traceId.length === 0) return;
125+
store.add(traceId, serializeLogRecord(record));
126+
};
127+
}

0 commit comments

Comments
 (0)