diff --git a/CHANGES.md b/CHANGES.md index 48bd44f6..25e2be18 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -139,6 +139,12 @@ To be released. code, the inbox URL, and the response body, making it easier to programmatically handle delivery errors. [[#548], [#559]] + - Added `traceId` and `spanId` to LogTape context in federation middleware + so that log records emitted during request handling and queue processing + include the OpenTelemetry trace and span IDs in their properties. This + enables the `@fedify/debugger` dashboard to display per-trace logs. + [[#561], [#564]] + [#280]: https://github.com/fedify-dev/fedify/issues/280 [#366]: https://github.com/fedify-dev/fedify/issues/366 [#376]: https://github.com/fedify-dev/fedify/issues/376 @@ -163,6 +169,8 @@ To be released. [#548]: https://github.com/fedify-dev/fedify/issues/548 [#559]: https://github.com/fedify-dev/fedify/pull/559 [#560]: https://github.com/fedify-dev/fedify/issues/560 +[#561]: https://github.com/fedify-dev/fedify/issues/561 +[#564]: https://github.com/fedify-dev/fedify/pull/564 ### @fedify/cli @@ -231,6 +239,38 @@ To be released. [#529]: https://github.com/fedify-dev/fedify/pull/529 [#531]: https://github.com/fedify-dev/fedify/pull/531 +### @fedify/debugger + + - Created the *@fedify/debugger* package, an embedded real-time ActivityPub + debug dashboard for Fedify. It wraps an existing `Federation` object as + a proxy, intercepting requests to a configurable path prefix (default + `/__debug__`) and serving an SSR-based web UI. [[#561], [#564]] + + - Added `createFederationDebugger()` function that returns a + `Federation` proxy with a built-in debug dashboard. When called + without an `exporter` option, it automatically sets up OpenTelemetry + tracing (creating `MemoryKvStore`, `FedifySpanExporter`, + `BasicTracerProvider`) and registers it as the global tracer + provider—no manual OTel configuration needed. + - Traces list page showing trace IDs, activity types, activity counts, + and timestamps, with auto-polling for real-time updates. + - Trace detail page showing activity direction, type, actor, signature + verification details, inbox URL, and expandable activity JSON. + - JSON API endpoint at `/__debug__/api/traces` for programmatic access. + - Added per-trace log collection using LogTape. The returned federation + object now includes a `sink` property (a LogTape `Sink` function) + that captures log records grouped by trace ID. In the simplified + overload (without `exporter`), LogTape is auto-configured. + - Trace detail page now shows a “Logs” section with log level, timestamp, + logger category, and message for each log record in the trace. + - JSON API endpoint at `/__debug__/api/logs/:traceId` for retrieving + log records for a specific trace. + - Added optional `auth` configuration for protecting the debug dashboard + with authentication. Supports three modes: password-only, + username + password, and request-based (e.g., IP filtering). + Each mode supports both static credentials and callback functions. + Uses cookie-based sessions with HMAC-signed tokens. + ### @fedify/relay - Created ActivityPub relay integration as the *@fedify/relay* package. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82c6266c..9d643c4a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -335,6 +335,8 @@ The repository is organized as a monorepo with the following packages: - *packages/amqp/*: AMQP/RabbitMQ driver (@fedify/amqp) for Fedify. - *packages/cfworkers/*: Cloudflare Workers integration (@fedify/cfworkers) for Fedify. + - *packages/debugger/*: Embedded ActivityPub debug dashboard (@fedify/debugger) + for Fedify. - *packages/denokv/*: Deno KV integration (@fedify/denokv) for Fedify. - *packages/elysia/*: Elysia integration (@fedify/elysia) for Fedify. - *packages/express/*: Express integration (@fedify/express) for Fedify. diff --git a/deno.json b/deno.json index 273d96dd..bff4e5b3 100644 --- a/deno.json +++ b/deno.json @@ -3,6 +3,7 @@ "./packages/amqp", "./packages/cfworkers", "./packages/cli", + "./packages/debugger", "./packages/denokv", "./packages/express", "./packages/fastify", @@ -35,8 +36,10 @@ "@logtape/logtape": "jsr:@logtape/logtape@^2.0.0", "@nestjs/common": "npm:@nestjs/common@^11.0.1", "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", - "@opentelemetry/core": "npm:@opentelemetry/core@^2.0.0", - "@opentelemetry/sdk-trace-base": "npm:@opentelemetry/sdk-trace-base@^2.0.0", + "@opentelemetry/context-async-hooks": "npm:@opentelemetry/context-async-hooks@^2.5.0", + "@opentelemetry/core": "npm:@opentelemetry/core@^2.5.0", + "@opentelemetry/sdk-trace-base": "npm:@opentelemetry/sdk-trace-base@^2.5.0", + "@opentelemetry/semantic-conventions": "npm:@opentelemetry/semantic-conventions@^1.39.0", "@std/assert": "jsr:@std/assert@^1.0.13", "@std/async": "jsr:@std/async@^1.0.13", "@std/encoding": "jsr:@std/encoding@^1.0.10", @@ -49,6 +52,7 @@ "byte-encodings": "npm:byte-encodings@^1.0.11", "es-toolkit": "npm:es-toolkit@^1.43.0", "h3": "npm:h3@^1.15.0", + "hono": "jsr:@hono/hono@^4.8.3", "ioredis": "npm:ioredis@^5.8.2", "json-preserve-indent": "npm:json-preserve-indent@^1.1.3", "postgres": "npm:postgres@^3.4.7", diff --git a/deno.lock b/deno.lock index 9d2895d6..84cd2ac4 100644 --- a/deno.lock +++ b/deno.lock @@ -73,10 +73,15 @@ "npm:@mjackson/node-fetch-server@0.7": "0.7.0", "npm:@multiformats/base-x@^4.0.1": "4.0.1", "npm:@nestjs/common@^11.0.1": "11.1.11_reflect-metadata@0.2.2_rxjs@7.8.2", + "npm:@opentelemetry/api@1.9.0": "1.9.0", "npm:@opentelemetry/api@^1.9.0": "1.9.0", - "npm:@opentelemetry/core@2": "2.3.0_@opentelemetry+api@1.9.0", - "npm:@opentelemetry/sdk-trace-base@2": "2.3.0_@opentelemetry+api@1.9.0", - "npm:@opentelemetry/semantic-conventions@^1.27.0": "1.38.0", + "npm:@opentelemetry/context-async-hooks@1.30.1": "1.30.1_@opentelemetry+api@1.9.0", + "npm:@opentelemetry/context-async-hooks@^2.5.0": "2.5.0_@opentelemetry+api@1.9.0", + "npm:@opentelemetry/core@1.30.1": "1.30.1_@opentelemetry+api@1.9.0", + "npm:@opentelemetry/core@^2.5.0": "2.5.0_@opentelemetry+api@1.9.0", + "npm:@opentelemetry/sdk-trace-base@1.30.1": "1.30.1_@opentelemetry+api@1.9.0", + "npm:@opentelemetry/sdk-trace-base@^2.5.0": "2.5.0_@opentelemetry+api@1.9.0", + "npm:@opentelemetry/semantic-conventions@^1.39.0": "1.39.0", "npm:@optique/core@0.9": "0.9.0", "npm:@optique/run@0.9": "0.9.0", "npm:@poppanator/http-constants@^1.1.1": "1.1.1", @@ -213,7 +218,7 @@ "jsr:@std/path@^1.1.2", "jsr:@std/semver", "jsr:@std/uuid", - "npm:@opentelemetry/api", + "npm:@opentelemetry/api@^1.9.0", "npm:@preact/signals@^2.2.1", "npm:esbuild-wasm", "npm:esbuild@0.25.7", @@ -2014,32 +2019,71 @@ "@opentelemetry/api@1.9.0": { "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==" }, - "@opentelemetry/core@2.3.0_@opentelemetry+api@1.9.0": { - "integrity": "sha512-PcmxJQzs31cfD0R2dE91YGFcLxOSN4Bxz7gez5UwSUjCai8BwH/GI5HchfVshHkWdTkUs0qcaPJgVHKXUp7I3A==", + "@opentelemetry/context-async-hooks@1.30.1_@opentelemetry+api@1.9.0": { + "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", + "dependencies": [ + "@opentelemetry/api" + ] + }, + "@opentelemetry/context-async-hooks@2.5.0_@opentelemetry+api@1.9.0": { + "integrity": "sha512-uOXpVX0ZjO7heSVjhheW2XEPrhQAWr2BScDPoZ9UDycl5iuHG+Usyc3AIfG6kZeC1GyLpMInpQ6X5+9n69yOFw==", + "dependencies": [ + "@opentelemetry/api" + ] + }, + "@opentelemetry/core@1.30.1_@opentelemetry+api@1.9.0": { + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/semantic-conventions@1.28.0" + ] + }, + "@opentelemetry/core@2.5.0_@opentelemetry+api@1.9.0": { + "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "dependencies": [ "@opentelemetry/api", - "@opentelemetry/semantic-conventions" + "@opentelemetry/semantic-conventions@1.39.0" ] }, - "@opentelemetry/resources@2.3.0_@opentelemetry+api@1.9.0": { - "integrity": "sha512-shlr2l5g+87J8wqYlsLyaUsgKVRO7RtX70Ckd5CtDOWtImZgaUDmf4Z2ozuSKQLM2wPDR0TE/3bPVBNJtRm/cQ==", + "@opentelemetry/resources@1.30.1_@opentelemetry+api@1.9.0": { + "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", "dependencies": [ "@opentelemetry/api", - "@opentelemetry/core", - "@opentelemetry/semantic-conventions" + "@opentelemetry/core@1.30.1_@opentelemetry+api@1.9.0", + "@opentelemetry/semantic-conventions@1.28.0" ] }, - "@opentelemetry/sdk-trace-base@2.3.0_@opentelemetry+api@1.9.0": { - "integrity": "sha512-B0TQ2e9h0ETjpI+eGmCz8Ojb+lnYms0SE3jFwEKrN/PK4aSVHU28AAmnOoBmfub+I3jfgPwvDJgomBA5a7QehQ==", + "@opentelemetry/resources@2.5.0_@opentelemetry+api@1.9.0": { + "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "dependencies": [ "@opentelemetry/api", - "@opentelemetry/core", - "@opentelemetry/resources", - "@opentelemetry/semantic-conventions" + "@opentelemetry/core@2.5.0_@opentelemetry+api@1.9.0", + "@opentelemetry/semantic-conventions@1.39.0" ] }, - "@opentelemetry/semantic-conventions@1.38.0": { - "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==" + "@opentelemetry/sdk-trace-base@1.30.1_@opentelemetry+api@1.9.0": { + "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core@1.30.1_@opentelemetry+api@1.9.0", + "@opentelemetry/resources@1.30.1_@opentelemetry+api@1.9.0", + "@opentelemetry/semantic-conventions@1.28.0" + ] + }, + "@opentelemetry/sdk-trace-base@2.5.0_@opentelemetry+api@1.9.0": { + "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core@2.5.0_@opentelemetry+api@1.9.0", + "@opentelemetry/resources@2.5.0_@opentelemetry+api@1.9.0", + "@opentelemetry/semantic-conventions@1.39.0" + ] + }, + "@opentelemetry/semantic-conventions@1.28.0": { + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==" + }, + "@opentelemetry/semantic-conventions@1.39.0": { + "integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==" }, "@optique/core@0.9.0": { "integrity": "sha512-PN5SwVRK9BPmFUKzcdNYDCV4Q18HGfR4H/1MG1yQYmA1RDNVKTuCV146dTIqI2H8F4lD/WMTiNsTl2wbGr9u1Q==" @@ -4194,8 +4238,7 @@ "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", "dependencies": [ "tsscmp" - ], - "deprecated": true + ] }, "keyv@4.5.4": { "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", @@ -5843,6 +5886,7 @@ "workspace": { "dependencies": [ "jsr:@david/dax@~0.43.2", + "jsr:@hono/hono@^4.8.3", "jsr:@logtape/file@2", "jsr:@logtape/logtape@2", "jsr:@std/assert@^1.0.13", @@ -5857,8 +5901,10 @@ "npm:@js-temporal/polyfill@~0.5.1", "npm:@nestjs/common@^11.0.1", "npm:@opentelemetry/api@^1.9.0", - "npm:@opentelemetry/core@2", - "npm:@opentelemetry/sdk-trace-base@2", + "npm:@opentelemetry/context-async-hooks@^2.5.0", + "npm:@opentelemetry/core@^2.5.0", + "npm:@opentelemetry/sdk-trace-base@^2.5.0", + "npm:@opentelemetry/semantic-conventions@^1.39.0", "npm:@types/node@^22.16.0", "npm:amqplib@~0.10.9", "npm:byte-encodings@^1.0.11", @@ -5974,9 +6020,6 @@ "jsr:@std/assert@0.226", "jsr:@std/url@~0.225.1", "npm:@multiformats/base-x@^4.0.1", - "npm:@opentelemetry/core@2", - "npm:@opentelemetry/sdk-trace-base@2", - "npm:@opentelemetry/semantic-conventions@^1.27.0", "npm:asn1js@^3.0.7", "npm:fast-check@^3.22.0", "npm:fetch-mock@^12.5.2", @@ -5991,7 +6034,6 @@ "packageJson": { "dependencies": [ "npm:@js-temporal/polyfill@~0.5.1", - "npm:@opentelemetry/semantic-conventions@^1.27.0", "npm:@types/node@^24.2.1", "npm:json-canon@^1.0.1", "npm:jsonld@9", @@ -6055,7 +6097,6 @@ }, "packages/vocab": { "dependencies": [ - "npm:@opentelemetry/api@^1.9.0", "npm:fast-check@^3.22.0", "npm:fetch-mock@^12.5.2", "npm:jsonld@9" diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index e0a626df..dffe60f0 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -77,6 +77,7 @@ const MANUAL = { { text: "Integration", link: "/manual/integration.md" }, { text: "Relay", link: "/manual/relay.md" }, { text: "Testing", link: "/manual/test.md" }, + { text: "Debugging", link: "/manual/debug.md" }, { text: "Linting", link: "/manual/lint.md" }, { text: "Logging", link: "/manual/log.md" }, { text: "OpenTelemetry", link: "/manual/opentelemetry.md" }, @@ -92,6 +93,7 @@ const REFERENCES = { { text: "@fedify/fedify", link: "https://jsr.io/@fedify/fedify/doc" }, { text: "@fedify/amqp", link: "https://jsr.io/@fedify/amqp/doc" }, { text: "@fedify/cfworkers", link: "https://jsr.io/@fedify/cfworkers/doc" }, + { text: "@fedify/debugger", link: "https://jsr.io/@fedify/debugger/doc" }, { text: "@fedify/denokv", link: "https://jsr.io/@fedify/denokv/doc" }, { text: "@fedify/express", link: "https://jsr.io/@fedify/express/doc" }, { text: "@fedify/fastify", link: "https://jsr.io/@fedify/fastify/doc" }, diff --git a/docs/manual/debug.md b/docs/manual/debug.md new file mode 100644 index 00000000..7c219c66 --- /dev/null +++ b/docs/manual/debug.md @@ -0,0 +1,452 @@ +--- +description: >- + The @fedify/debugger package provides an embedded real-time debug dashboard + for inspecting ActivityPub traces and activities in your federated server app. +--- + +Debugging +========= + +*This API is available since Fedify 2.0.0.* + +When developing a federated server app, it can be difficult to understand what +activities are being sent and received, and whether signatures are being +verified correctly. The `@fedify/debugger` package provides an embedded +real-time debug dashboard that you can add to your app to inspect ActivityPub +traces and activities without leaving your browser. + + +Installation +------------ + +::: code-group + +~~~~ bash [Deno] +deno add jsr:@fedify/debugger +~~~~ + +~~~~ bash [npm] +npm install @fedify/debugger +~~~~ + +~~~~ bash [pnpm] +pnpm add @fedify/debugger +~~~~ + +~~~~ bash [Yarn] +yarn add @fedify/debugger +~~~~ + +~~~~ bash [Bun] +bun add @fedify/debugger +~~~~ + +::: + + +Setup +----- + +The debugger works as a proxy that wraps your existing `Federation` object. +It intercepts HTTP requests matching a configurable path prefix and serves +the debug dashboard, while delegating everything else to the inner federation. + +The simplest way to set it up is to call `createFederationDebugger()` with +your federation object: + +~~~~ typescript twoslash +// @noErrors: 2345 +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { createFederationDebugger } from "@fedify/debugger"; + +const innerFederation = createFederation({ + kv: new MemoryKvStore(), + // ... other federation options +}); + +const federation = createFederationDebugger(innerFederation); +~~~~ + +When called without an `exporter` option, `createFederationDebugger()` +automatically: + + - Creates a `MemoryKvStore` and `FedifySpanExporter` for trace data storage + - Creates a `BasicTracerProvider` with a `SimpleSpanProcessor` + - Registers it as the global [OpenTelemetry] tracer provider + - Registers an `AsyncLocalStorageContextManager` as the global OpenTelemetry + context manager (required for parent–child span propagation) + - Registers a `W3CTraceContextPropagator` as the global OpenTelemetry + propagator (required for trace context to propagate across message queue + boundaries) + - Configures [LogTape] to collect logs per trace (using `getConfig()` to + merge with any existing configuration) + +This means `createFederation()` will automatically use the tracer provider +without needing an explicit `tracerProvider` option, and logs emitted by +Fedify will be captured and displayed alongside traces in the dashboard. + +The `federation` object returned by `createFederationDebugger()` is a drop-in +replacement for the original. You can use it everywhere you would normally use +the inner federation object (e.g., passing it to framework integrations). + +> [!WARNING] +> The debug dashboard is intended for development use only. It is strongly +> recommended to enable [authentication](#auth) if the dashboard is accessible +> over a network, as it exposes internal trace data. + +[OpenTelemetry]: ./opentelemetry.md +[LogTape]: https://logtape.org/ + + +Configuration +------------- + +The `createFederationDebugger()` function accepts the following options: + +### `path` + +The path prefix for the debug dashboard. Defaults to `"/__debug__"`. +All dashboard routes are served under this prefix. + +For example, if you set `path` to `"/_debug"`, the dashboard will be available +at `/_debug/` and traces at `/_debug/traces/:traceId`. + +~~~~ typescript twoslash +// @noErrors: 2345 +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { createFederationDebugger } from "@fedify/debugger"; + +const innerFederation = createFederation({ + kv: new MemoryKvStore(), +}); +// ---cut-before--- +const federation = createFederationDebugger(innerFederation, { + path: "/_debug", +}); +~~~~ + +### `auth` + +*Optional.* Authentication configuration for the debug dashboard. When +omitted, the dashboard is accessible without authentication. + +The `auth` option accepts a discriminated union with three modes: + +#### Password-only authentication + +Shows a login form with a single password field. You can provide a static +password string or an `authenticate()` callback: + +~~~~ typescript twoslash +// @noErrors: 2345 +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { createFederationDebugger } from "@fedify/debugger"; + +const innerFederation = createFederation({ + kv: new MemoryKvStore(), +}); +// ---cut-before--- +// Static password: +const federation = createFederationDebugger(innerFederation, { + auth: { + type: "password", + password: Deno.env.get("DEBUG_PASSWORD")!, + }, +}); +~~~~ + +~~~~ typescript twoslash +// @noErrors: 2345 +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { createFederationDebugger } from "@fedify/debugger"; + +const innerFederation = createFederation({ + kv: new MemoryKvStore(), +}); +// ---cut-before--- +// Callback: +const federation = createFederationDebugger(innerFederation, { + auth: { + type: "password", + authenticate: (password) => password === "my-secret", + }, +}); +~~~~ + +#### Username + password authentication + +Shows a login form with both username and password fields: + +~~~~ typescript twoslash +// @noErrors: 2345 +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { createFederationDebugger } from "@fedify/debugger"; + +const innerFederation = createFederation({ + kv: new MemoryKvStore(), +}); +// ---cut-before--- +// Static credentials: +const federation = createFederationDebugger(innerFederation, { + auth: { + type: "usernamePassword", + username: "admin", + password: Deno.env.get("DEBUG_PASSWORD")!, + }, +}); +~~~~ + +~~~~ typescript twoslash +// @noErrors: 2345 +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { createFederationDebugger } from "@fedify/debugger"; + +const innerFederation = createFederation({ + kv: new MemoryKvStore(), +}); +// ---cut-before--- +// Callback: +const federation = createFederationDebugger(innerFederation, { + auth: { + type: "usernamePassword", + authenticate: (username, password) => + username === "admin" && password === "secret", + }, +}); +~~~~ + +#### Request-based authentication + +Authenticates based on the incoming `Request` object, without showing a login +form. Useful for IP-based access control. Rejected requests receive a 403 +Forbidden response. + +~~~~ typescript twoslash +// @noErrors: 2345 +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { createFederationDebugger } from "@fedify/debugger"; + +const innerFederation = createFederation({ + kv: new MemoryKvStore(), +}); +// ---cut-before--- +const federation = createFederationDebugger(innerFederation, { + auth: { + type: "request", + authenticate: (request) => { + // Only allow requests from localhost + const url = new URL(request.url); + return url.hostname === "127.0.0.1" || url.hostname === "::1"; + }, + }, +}); +~~~~ + +> [!NOTE] +> Authentication only applies to the debug dashboard routes. All other +> requests (e.g., ActivityPub endpoints) are passed through to the inner +> federation without any authentication check. + +### `exporter` + +*Optional.* A `FedifySpanExporter` instance that the dashboard queries for +trace data. + +When omitted (the recommended approach), the debugger automatically creates +an exporter and sets up OpenTelemetry tracing for you. + +When provided, you are responsible for setting up the `BasicTracerProvider` +and passing it to `createFederation()`. See the +[Advanced setup](#advanced-setup) section below. + +### `kv` + +*Required when `exporter` is provided.* A `KvStore` instance used to persist +log records collected by the debug dashboard's LogTape sink. + +When using the simplified overload (without `exporter`), the debugger +automatically creates a `MemoryKvStore` for log storage. + +When using the advanced overload (with `exporter`), you must pass the same +`KvStore` instance so that log records written by worker processes are visible +in the web dashboard. + + +Dashboard pages +--------------- + +Once set up, the debug dashboard is accessible at the configured path prefix +(default: `/__debug__/`). + +### Traces list + +The root page (`/__debug__/`) shows a list of all captured traces. For each +trace, it displays: + + - **Trace ID** (first 8 characters, linked to the detail page) + - **Activity types** present in the trace (e.g., Create, Follow, Like) + - **Activity count** + - **Timestamp** + +![Traces list page of the debug dashboard](debug/traces-list.png) + +The page automatically polls the JSON API every 3 seconds and refreshes when +new traces are detected. + +### Trace detail + +The trace detail page (`/__debug__/traces/:traceId`) shows all activities +belonging to a specific trace. For each activity, it displays: + + - **Direction** (inbound or outbound) + - **Activity type** (e.g., Create, Accept, Follow) + - **Span ID** and optional parent span ID + - **Activity ID** (if present) + - **Actor ID** + - **Timestamp** + - **Inbox URL** (for outbound activities) + - **Signature verification** details (for inbound activities): + - Whether HTTP Signatures were verified + - The key ID used for verification + - Whether Linked Data Signatures were verified + - **Activity JSON** (expandable, pretty-printed) + +Below the activities section, a **Logs** section shows all [LogTape] log +records captured during the trace. Each log entry displays: + + - **Timestamp** (time portion only) + - **Log level** (color-coded: debug, info, warning, error, fatal) + - **Logger category** (e.g., `fedify.federation.http`) + - **Message** with expandable properties + +![Trace detail page of the debug dashboard](debug/trace-detail.png) + +### JSON API + +A JSON API endpoint is available at `/__debug__/api/traces` which returns +the list of recent traces in JSON format. This is used by the auto-polling +mechanism on the traces list page, but you can also query it directly for +programmatic access. + +A separate endpoint at `/__debug__/api/logs/:traceId` returns the log records +for a specific trace in JSON format. + + +Using with framework integrations +--------------------------------- + +The debugger works with any framework integration that accepts a `Federation` +object. Simply wrap the federation before passing it to your integration: + +### Hono + +~~~~ typescript twoslash +// @noErrors: 2345 +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { createFederationDebugger } from "@fedify/debugger"; +import { federation as honoFederation } from "@fedify/hono"; +import { Hono } from "hono"; + +const innerFederation = createFederation({ + kv: new MemoryKvStore(), +}); +const federation = createFederationDebugger(innerFederation); + +const app = new Hono(); +app.use(honoFederation(federation, (_) => undefined)); +~~~~ + +### Express + +~~~~ typescript twoslash +// @noErrors: 2345 +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { createFederationDebugger } from "@fedify/debugger"; +import { integrateFederation } from "@fedify/express"; +import express from "express"; + +const innerFederation = createFederation({ + kv: new MemoryKvStore(), +}); +const federation = createFederationDebugger(innerFederation); + +const app = express(); +app.use(integrateFederation(federation, (req) => undefined)); +~~~~ + + +Advanced setup +-------------- + +If you need full control over the OpenTelemetry setup (for example, to use +a custom `KvStore` or to add additional span processors), you can pass an +explicit `exporter` option: + +~~~~ typescript twoslash +// @noErrors: 2345 +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { FedifySpanExporter } from "@fedify/fedify/otel"; +import { createFederationDebugger } from "@fedify/debugger"; +import { context, propagation } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { W3CTraceContextPropagator } from "@opentelemetry/core"; +import { + BasicTracerProvider, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; + +// Register context manager and propagator (required for trace +// propagation across async boundaries and message queues): +context.setGlobalContextManager(new AsyncLocalStorageContextManager()); +propagation.setGlobalPropagator(new W3CTraceContextPropagator()); + +// Create a KV store and a span exporter that captures trace data: +const kv = new MemoryKvStore(); +const exporter = new FedifySpanExporter(kv); +const tracerProvider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(exporter)], +}); + +const innerFederation = createFederation({ + kv, + tracerProvider, + // ... other federation options +}); + +// Wrap the federation with the debugger: +const federation = createFederationDebugger(innerFederation, { + exporter, + kv, +}); +~~~~ + +In this mode, the returned `federation` object has a `sink` property that is +a [LogTape] `Sink` function. You should include it in your LogTape +configuration to enable per-trace log collection: + +~~~~ typescript twoslash +// @noErrors: 2345 +import { configure } from "@logtape/logtape"; +declare const federation: { sink: (record: any) => void }; +// ---cut-before--- +await configure({ + sinks: { + debugger: federation.sink, + // ... other sinks + }, + loggers: [ + { category: "fedify", sinks: ["debugger"] }, + // ... other loggers + ], +}); +~~~~ + +In this mode, you are responsible for: + + - Registering an `AsyncLocalStorageContextManager` as the global context + manager + - Registering a `W3CTraceContextPropagator` as the global propagator + - Creating and configuring the `BasicTracerProvider` + - Passing `tracerProvider` to `createFederation()` + - Passing the same `exporter` and `kv` to `createFederationDebugger()` + - Configuring LogTape with `federation.sink` to collect logs per trace diff --git a/docs/manual/debug/trace-detail.png b/docs/manual/debug/trace-detail.png new file mode 100644 index 00000000..9b33f0c3 Binary files /dev/null and b/docs/manual/debug/trace-detail.png differ diff --git a/docs/manual/debug/traces-list.png b/docs/manual/debug/traces-list.png new file mode 100644 index 00000000..71d8ca05 Binary files /dev/null and b/docs/manual/debug/traces-list.png differ diff --git a/docs/package.json b/docs/package.json index 11e2f850..2f41235f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -5,6 +5,7 @@ "@deno/kv": "^0.8.4", "@fedify/amqp": "workspace:^", "@fedify/cfworkers": "workspace:^", + "@fedify/debugger": "workspace:^", "@fedify/express": "workspace:^", "@fedify/fastify": "workspace:^", "@fedify/fedify": "workspace:^", @@ -27,8 +28,9 @@ "@logtape/file": "catalog:", "@logtape/logtape": "catalog:", "@nestjs/common": "catalog:", - "@opentelemetry/exporter-trace-otlp-proto": "^0.208.0", - "@opentelemetry/sdk-node": "^0.208.0", + "@opentelemetry/api": "catalog:", + "@opentelemetry/exporter-trace-otlp-proto": "catalog:", + "@opentelemetry/sdk-node": "catalog:", "@opentelemetry/sdk-trace-base": "catalog:", "@sentry/node": "^8.47.0", "@shikijs/vitepress-twoslash": "^1.24.4", diff --git a/packages/debugger/deno.json b/packages/debugger/deno.json new file mode 100644 index 00000000..130692b5 --- /dev/null +++ b/packages/debugger/deno.json @@ -0,0 +1,16 @@ +{ + "name": "@fedify/debugger", + "version": "2.0.0", + "license": "MIT", + "exports": { + ".": "./src/mod.tsx" + }, + "exclude": ["dist/", "node_modules/"], + "publish": { + "exclude": ["**/*.test.ts", "tsdown.config.ts"] + }, + "tasks": { + "check": "deno fmt --check && deno lint && deno check src/**/*.ts src/**/*.tsx", + "test": "deno test --allow-env" + } +} diff --git a/packages/debugger/package.json b/packages/debugger/package.json new file mode 100644 index 00000000..2a0520c6 --- /dev/null +++ b/packages/debugger/package.json @@ -0,0 +1,55 @@ +{ + "name": "@fedify/debugger", + "version": "2.0.0", + "description": "Embedded ActivityPub debug dashboard for Fedify", + "type": "module", + "main": "./dist/mod.cjs", + "module": "./dist/mod.js", + "types": "./dist/mod.d.ts", + "exports": { + ".": { + "types": { + "import": "./dist/mod.d.ts", + "require": "./dist/mod.d.cts", + "default": "./dist/mod.d.ts" + }, + "import": "./dist/mod.js", + "require": "./dist/mod.cjs", + "default": "./dist/mod.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/", + "package.json" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/fedify-dev/fedify.git", + "directory": "packages/debugger" + }, + "peerDependencies": { + "@fedify/fedify": "workspace:^" + }, + "dependencies": { + "@js-temporal/polyfill": "catalog:", + "@logtape/logtape": "catalog:", + "@opentelemetry/api": "catalog:", + "@opentelemetry/context-async-hooks": "catalog:", + "@opentelemetry/core": "catalog:", + "@opentelemetry/sdk-trace-base": "catalog:", + "hono": "catalog:" + }, + "devDependencies": { + "tsdown": "catalog:", + "typescript": "catalog:" + }, + "scripts": { + "build:self": "tsdown", + "build": "pnpm --filter @fedify/debugger... run build:self", + "prepack": "pnpm build", + "prepublish": "pnpm build", + "test": "node --experimental-transform-types --test", + "test:bun": "bun test" + } +} diff --git a/packages/debugger/src/auth.ts b/packages/debugger/src/auth.ts new file mode 100644 index 00000000..1104c2e1 --- /dev/null +++ b/packages/debugger/src/auth.ts @@ -0,0 +1,142 @@ +/** + * Authentication types and helpers for the debug dashboard. + * + * @module + */ +import { timingSafeEqual } from "node:crypto"; + +/** + * Authentication configuration for the debug dashboard. + * + * The debug dashboard can be protected using one of three authentication modes: + * + * - `"password"` — Shows a password-only login form. + * - `"usernamePassword"` — Shows a username + password login form. + * - `"request"` — Authenticates based on the incoming request (e.g., IP + * address). No login form is shown; unauthenticated requests receive a + * 403 response. + * + * Each mode supports either a static credential check or a callback function. + */ +export type FederationDebuggerAuth = + | { + readonly type: "password"; + authenticate(password: string): boolean | Promise; + } + | { + readonly type: "password"; + readonly password: string; + } + | { + readonly type: "usernamePassword"; + authenticate( + username: string, + password: string, + ): boolean | Promise; + } + | { + readonly type: "usernamePassword"; + readonly username: string; + readonly password: string; + } + | { + readonly type: "request"; + authenticate(request: Request): boolean | Promise; + }; + +export const SESSION_COOKIE_NAME = "__fedify_debug_session"; +const SESSION_TOKEN = "authenticated"; + +export async function generateHmacKey(): Promise { + return await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); +} + +function toHex(buffer: ArrayBuffer): string { + return [...new Uint8Array(buffer)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function fromHex(hex: string): ArrayBuffer { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes.buffer as ArrayBuffer; +} + +export async function signSession(key: CryptoKey): Promise { + const encoder = new TextEncoder(); + const signature = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(SESSION_TOKEN), + ); + return toHex(signature); +} + +export async function verifySession( + key: CryptoKey, + signature: string, +): Promise { + try { + const encoder = new TextEncoder(); + return await crypto.subtle.verify( + "HMAC", + key, + fromHex(signature), + encoder.encode(SESSION_TOKEN), + ); + } catch { + return false; + } +} + +/** + * Constant-time string comparison to prevent timing attacks on credential + * checks. Uses {@link timingSafeEqual} from `node:crypto` under the hood. + */ +function constantTimeEqual(a: string, b: string): boolean { + const encoder = new TextEncoder(); + const bufA = encoder.encode(a); + const bufB = encoder.encode(b); + if (bufA.byteLength !== bufB.byteLength) { + // Still compare to burn the same amount of time regardless, but + // the result is always false when lengths differ. + timingSafeEqual(bufA, new Uint8Array(bufA.byteLength)); + return false; + } + return timingSafeEqual(bufA, bufB); +} + +export async function checkAuth( + auth: FederationDebuggerAuth, + formData: { username?: string; password: string }, +): Promise { + if (auth.type === "password") { + if ("authenticate" in auth) { + return await auth.authenticate(formData.password); + } + return constantTimeEqual(formData.password, auth.password); + } + if (auth.type === "usernamePassword") { + if ("authenticate" in auth) { + return await auth.authenticate( + formData.username ?? "", + formData.password, + ); + } + // Check both fields in constant time (don't short-circuit) + const usernameMatch = constantTimeEqual( + formData.username ?? "", + auth.username, + ); + const passwordMatch = constantTimeEqual(formData.password, auth.password); + return usernameMatch && passwordMatch; + } + return false; +} diff --git a/packages/debugger/src/log-store.ts b/packages/debugger/src/log-store.ts new file mode 100644 index 00000000..62ae60d9 --- /dev/null +++ b/packages/debugger/src/log-store.ts @@ -0,0 +1,127 @@ +/** + * Log record storage for the debug dashboard, backed by a {@link KvStore}. + * + * @module + */ +import type { KvKey, KvStore } from "@fedify/fedify/federation"; +import type { LogRecord, Sink } from "@logtape/logtape"; + +/** + * A serialized log record for the debug dashboard. + */ +export interface SerializedLogRecord { + /** + * The logger category. + */ + readonly category: readonly string[]; + + /** + * The log level. + */ + readonly level: string; + + /** + * The rendered log message. + */ + readonly message: string; + + /** + * The timestamp in milliseconds since the Unix epoch. + */ + readonly timestamp: number; + + /** + * The extra properties of the log record (excluding traceId and spanId). + */ + readonly properties: Record; +} + +/** + * Persistent storage for log records grouped by trace ID, backed by a + * {@link KvStore}. When the same `KvStore` is shared across web and worker + * processes the dashboard can display logs produced by background tasks. + */ +export class LogStore { + readonly #kv: KvStore; + readonly #keyPrefix: KvKey; + /** Chain of pending write promises for flush(). */ + #pending: Promise = Promise.resolve(); + + constructor(kv: KvStore, keyPrefix: KvKey = ["fedify", "debugger", "logs"]) { + this.#kv = kv; + this.#keyPrefix = keyPrefix; + } + + /** + * Enqueue a log record for writing. The write happens asynchronously; + * call {@link flush} to wait for all pending writes to complete. + * + * Keys use a timestamp + random suffix so that entries sort + * chronologically and never collide, even across multiple processes + * sharing the same {@link KvStore}. + */ + add(traceId: string, record: SerializedLogRecord): void { + const key: KvKey = [ + ...this.#keyPrefix, + traceId, + `${Date.now().toString(36).padStart(10, "0")}-${ + Math.random().toString(36).slice(2) + }`, + ] as unknown as KvKey; + // Errors are swallowed so a single failed write cannot poison the + // chain or cause an unhandled rejection — logging is best-effort. + this.#pending = this.#pending.then( + () => this.#kv.set(key, record), + ).catch(() => {}); + } + + /** Wait for all pending writes to complete. */ + flush(): Promise { + return this.#pending; + } + + async get(traceId: string): Promise { + const prefix: KvKey = [...this.#keyPrefix, traceId] as unknown as KvKey; + const logs: SerializedLogRecord[] = []; + for await (const entry of this.#kv.list(prefix)) { + logs.push(entry.value as SerializedLogRecord); + } + return logs; + } +} + +/** + * Converts a {@link LogRecord} into a plain serializable object suitable + * for storage in a {@link KvStore}. + */ +export function serializeLogRecord(record: LogRecord): SerializedLogRecord { + // Render message to string + const messageParts: string[] = []; + for (const part of record.message) { + if (typeof part === "string") messageParts.push(part); + else if (part == null) messageParts.push(""); + else messageParts.push(String(part)); + } + // Exclude traceId and spanId from properties + const { traceId: _t, spanId: _s, ...properties } = record.properties; + return { + category: record.category, + level: record.level, + message: messageParts.join(""), + timestamp: record.timestamp, + properties, + }; +} + +/** + * Creates a LogTape {@link Sink} that writes log records into the given + * {@link LogStore}, grouped by their `traceId` property. Records without + * a `traceId` are silently discarded. + */ +export function createLogSink(store: LogStore): Sink { + return (record: LogRecord): void => { + const traceId = record.properties.traceId; + if (typeof traceId !== "string" || traceId.length === 0) return; + store.add(traceId, serializeLogRecord(record)); + }; +} diff --git a/packages/debugger/src/mod.test.ts b/packages/debugger/src/mod.test.ts new file mode 100644 index 00000000..5e595ef1 --- /dev/null +++ b/packages/debugger/src/mod.test.ts @@ -0,0 +1,1253 @@ +import { notStrictEqual, ok, strictEqual, throws } from "node:assert/strict"; +import { test } from "node:test"; +import { createFederationDebugger, resetAutoSetup } from "@fedify/debugger"; +import type { + FederationDebuggerAuth, + SerializedLogRecord, +} from "@fedify/debugger"; +import type { + Federation, + FederationFetchOptions, + FederationStartQueueOptions, + KvStore, +} from "@fedify/fedify/federation"; +import { MemoryKvStore } from "@fedify/fedify/federation"; +import type { + FedifySpanExporter, + TraceActivityRecord, + TraceSummary, +} from "@fedify/fedify/otel"; +import { trace } from "@opentelemetry/api"; + +function createMockExporter( + traces: TraceSummary[] = [], + activities: TraceActivityRecord[] = [], +): { exporter: FedifySpanExporter; kv: KvStore } { + const kv = new MemoryKvStore(); + const exporter = { + export(_spans: unknown, resultCallback: (result: unknown) => void) { + resultCallback({ code: 0 }); + }, + forceFlush() { + return Promise.resolve(); + }, + shutdown() { + return Promise.resolve(); + }, + getRecentTraces() { + return Promise.resolve(traces); + }, + getActivitiesByTraceId(_traceId: string) { + return Promise.resolve(activities); + }, + } as unknown as FedifySpanExporter; + return { exporter, kv }; +} + +function createMockFederation(): { + federation: Federation; + calls: Record; +} { + const calls: Record = {}; + + function track(name: string) { + if (!calls[name]) calls[name] = []; + // deno-lint-ignore no-explicit-any + return (...args: any[]) => { + calls[name].push(args); + if (name === "fetch") { + const request = args[0] as Request; + const options = args[1] as FederationFetchOptions; + if (options.onNotFound) { + return options.onNotFound(request); + } + return new Response("Federation response", { status: 200 }); + } + if (name === "startQueue") return Promise.resolve(); + if (name === "processQueuedTask") return Promise.resolve(); + if (name === "createContext") return { data: "mock-context" }; + return { setCounter: () => ({}) }; + }; + } + + const federation = { + setNodeInfoDispatcher: track("setNodeInfoDispatcher"), + setWebFingerLinksDispatcher: track("setWebFingerLinksDispatcher"), + setActorDispatcher: track("setActorDispatcher"), + setObjectDispatcher: track("setObjectDispatcher"), + setInboxDispatcher: track("setInboxDispatcher"), + setOutboxDispatcher: track("setOutboxDispatcher"), + setFollowingDispatcher: track("setFollowingDispatcher"), + setFollowersDispatcher: track("setFollowersDispatcher"), + setLikedDispatcher: track("setLikedDispatcher"), + setFeaturedDispatcher: track("setFeaturedDispatcher"), + setFeaturedTagsDispatcher: track("setFeaturedTagsDispatcher"), + setInboxListeners: track("setInboxListeners"), + setCollectionDispatcher: track("setCollectionDispatcher"), + setOrderedCollectionDispatcher: track("setOrderedCollectionDispatcher"), + setOutboxPermanentFailureHandler: track( + "setOutboxPermanentFailureHandler", + ), + startQueue: track("startQueue"), + processQueuedTask: track("processQueuedTask"), + createContext: track("createContext"), + fetch: track("fetch"), + } as unknown as Federation; + + return { federation, calls }; +} + +test("createFederationDebugger returns a Federation object", () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); + notStrictEqual(dbg, null); + notStrictEqual(dbg, undefined); + strictEqual(typeof dbg.fetch, "function"); + strictEqual(typeof dbg.startQueue, "function"); + strictEqual(typeof dbg.processQueuedTask, "function"); + strictEqual(typeof dbg.createContext, "function"); +}); + +test("createFederationDebugger delegates startQueue", async () => { + const { federation, calls } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const options: FederationStartQueueOptions = { signal: undefined }; + await dbg.startQueue(undefined, options); + strictEqual(calls["startQueue"]?.length, 1); + strictEqual(calls["startQueue"]![0]![0], undefined); + strictEqual(calls["startQueue"]![0]![1], options); +}); + +test("createFederationDebugger delegates processQueuedTask", async () => { + const { federation, calls } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const message = { type: "test" }; + await dbg.processQueuedTask( + undefined, + message as unknown as import("@fedify/fedify/federation").Message, + ); + strictEqual(calls["processQueuedTask"]?.length, 1); + strictEqual(calls["processQueuedTask"]![0]![1], message); +}); + +test("createFederationDebugger delegates createContext", () => { + const { federation, calls } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const url = new URL("https://example.com"); + dbg.createContext(url, undefined); + strictEqual(calls["createContext"]?.length, 1); + strictEqual(calls["createContext"]![0]![0], url); +}); + +test("createFederationDebugger delegates setActorDispatcher", () => { + const { federation, calls } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const dispatcher = () => null; + dbg.setActorDispatcher("/users/{identifier}", dispatcher); + strictEqual(calls["setActorDispatcher"]?.length, 1); + strictEqual(calls["setActorDispatcher"]![0]![0], "/users/{identifier}"); + strictEqual(calls["setActorDispatcher"]![0]![1], dispatcher); +}); + +test("fetch delegates non-debug requests to inner federation", async () => { + const { federation, calls } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request("https://example.com/users/alice"); + const response = await dbg.fetch(request, { contextData: undefined }); + strictEqual(calls["fetch"]?.length, 1); + strictEqual(response.status, 200); + strictEqual(await response.text(), "Federation response"); +}); + +test("fetch intercepts debug path prefix requests", async () => { + const { federation, calls } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request("https://example.com/__debug__/"); + const response = await dbg.fetch(request, { contextData: undefined }); + // The debug request should NOT be forwarded to inner federation + strictEqual(calls["fetch"]?.length ?? 0, 0); + strictEqual(response.status, 200); +}); + +test("fetch intercepts custom debug path prefix", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { + exporter, + kv, + path: "/__my_debug__", + }); + const request = new Request("https://example.com/__my_debug__/"); + const response = await dbg.fetch(request, { contextData: undefined }); + strictEqual(response.status, 200); +}); + +// ---------- Path validation tests ---------- + +test("path validation: empty string throws TypeError", () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + throws( + () => createFederationDebugger(federation, { exporter, kv, path: "" }), + TypeError, + ); +}); + +test("path validation: path without leading slash throws TypeError", () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + throws( + () => createFederationDebugger(federation, { exporter, kv, path: "debug" }), + TypeError, + ); +}); + +test("path validation: path with control characters throws TypeError", () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + throws( + () => + createFederationDebugger(federation, { + exporter, + kv, + path: "/debug\x00path", + }), + TypeError, + ); +}); + +test("path validation: path with semicolon throws TypeError", () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + throws( + () => + createFederationDebugger(federation, { + exporter, + kv, + path: "/debug;bad", + }), + TypeError, + ); +}); + +test("path validation: path with comma throws TypeError", () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + throws( + () => + createFederationDebugger(federation, { + exporter, + kv, + path: "/debug,bad", + }), + TypeError, + ); +}); + +test("path validation: trailing slash is stripped", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { + exporter, + kv, + path: "/__debug__/", + }); + // The trailing slash should be normalized away, so /__debug__/ still works + const request = new Request("https://example.com/__debug__/"); + const response = await dbg.fetch(request, { contextData: undefined }); + strictEqual(response.status, 200); +}); + +test("path validation: valid path is accepted", () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + // Should not throw + const dbg = createFederationDebugger(federation, { + exporter, + kv, + path: "/my-debug_panel", + }); + notStrictEqual(dbg, null); +}); + +test("JSON API returns traces", async () => { + const traces: TraceSummary[] = [ + { + traceId: "abcdef1234567890abcdef1234567890", + timestamp: "2026-01-01T00:00:00Z", + activityCount: 3, + activityTypes: ["Create", "Follow"], + }, + ]; + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(traces); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request("https://example.com/__debug__/api/traces"); + const response = await dbg.fetch(request, { contextData: undefined }); + strictEqual(response.status, 200); + strictEqual( + response.headers.get("content-type"), + "application/json", + ); + const body = await response.json() as TraceSummary[]; + strictEqual(body.length, 1); + strictEqual(body[0].traceId, "abcdef1234567890abcdef1234567890"); + strictEqual(body[0].activityCount, 3); +}); + +test("fetch passes through onNotFound for non-debug requests", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); + let notFoundCalled = false; + const request = new Request("https://example.com/unknown"); + const response = await dbg.fetch(request, { + contextData: undefined, + onNotFound: () => { + notFoundCalled = true; + return new Response("Custom Not Found", { status: 404 }); + }, + }); + strictEqual(notFoundCalled, true); + strictEqual(response.status, 404); + strictEqual(await response.text(), "Custom Not Found"); +}); + +test("traces list page returns HTML with trace IDs", async () => { + const traces: TraceSummary[] = [ + { + traceId: "abcdef1234567890abcdef1234567890", + timestamp: "2026-01-01T00:00:00Z", + activityCount: 2, + activityTypes: ["Create", "Follow"], + }, + { + traceId: "1234567890abcdef1234567890abcdef", + timestamp: "2026-01-02T00:00:00Z", + activityCount: 1, + activityTypes: ["Like"], + }, + ]; + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(traces); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request("https://example.com/__debug__/"); + const response = await dbg.fetch(request, { contextData: undefined }); + strictEqual(response.status, 200); + const ct = response.headers.get("content-type") ?? ""; + ok(ct.includes("text/html"), `Expected text/html, got ${ct}`); + const html = await response.text(); + ok(html.includes("Fedify Debug Dashboard")); + // Check that truncated trace IDs appear + ok(html.includes("abcdef12")); + ok(html.includes("12345678")); + // Check activity types are shown + ok(html.includes("Create")); + ok(html.includes("Follow")); + ok(html.includes("Like")); + // Check trace count + ok(html.includes("2")); +}); + +test("traces list page shows empty message when no traces", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request("https://example.com/__debug__/"); + const response = await dbg.fetch(request, { contextData: undefined }); + strictEqual(response.status, 200); + const html = await response.text(); + ok(html.includes("No traces captured yet.")); + ok(html.includes("0")); +}); + +test("traces list page escapes pathPrefix in inline script", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(); + const malicious = '/__debug__">'; + const dbg = createFederationDebugger(federation, { + exporter, + kv, + path: malicious, + }); + const request = new Request("https://example.com" + malicious + "/"); + const response = await dbg.fetch(request, { contextData: undefined }); + strictEqual(response.status, 200); + const ct = response.headers.get("content-type") ?? ""; + const body = await response.text(); + if (ct.includes("text/html")) { + // If the dashboard is rendered, verify that the malicious pathPrefix + // is properly escaped so it cannot break out of the