diff --git a/AGENTS.md b/AGENTS.md index 9fe6926..b1c0640 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ Describe and preserve what is already true in the repo today. ### Shell and routing - The app renders as one export-friendly shell. +- The static export includes RFC 9728 OAuth Protected Resource Metadata at `/.well-known/oauth-protected-resource` (build step writes into `out/`; see `scripts/emit-oauth-protected-resource.mjs`). - The empty state explains the product and exposes sample fragment presets. - A built-in link creator can generate fragment-based links locally in the browser. - When a valid fragment is present, the app switches to a viewer-first artifact layout. diff --git a/README.md b/README.md index 7fb06a5..f4369c1 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Built for the OpenClaw ecosystem, `agent-render` focuses on fragment-based shari - Markdown, code, diff, CSV, and JSON all render in the static shell - Fragment transport supports `plain`, `lz`, `deflate`, and `arx`, with automatic shortest-fragment selection across packed/non-packed wire formats - The `arx` substitution dictionary is served at `/arx-dictionary.json` so agents can fetch it for local compression +- OAuth Protected Resource Metadata ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)) is published at `/.well-known/oauth-protected-resource` for agent discovery - The viewer toolbar copies artifact bodies to the clipboard, downloads them as files, and (for markdown) supports browser print-to-PDF - Deployment target: static hosting, including Cloudflare Pages diff --git a/docs/deployment.md b/docs/deployment.md index 047731b..39b223b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -9,6 +9,7 @@ Key details: - Build with `npm ci` and `npm run build` - Upload the generated `out/` directory to your static host - Set `NEXT_PUBLIC_BASE_PATH` only when you need a subpath deployment +- OAuth Protected Resource Metadata is emitted at build time to `out/.well-known/oauth-protected-resource` (and under `out//.well-known/...` when using a subpath). Override the canonical resource URL with `NEXT_PUBLIC_SITE_URL` before `npm run build` if your deployment hostname differs from the default (`https://agent-render.com`) - `.nojekyll` remains harmless for hosts that ignore it ## Local verification @@ -68,6 +69,9 @@ The server starts on port 3000. Create artifacts via `POST /api/artifacts` and v | `HOST` | `0.0.0.0` | Server bind address | | `DB_PATH` | `./data/agent-render.db` | SQLite database file path | | `OUT_DIR` | `out` | Path to the static build output| +| `OAUTH_RESOURCE_IDENTIFIER` | _(request origin)_ | RFC 9728 `resource` URL for `/.well-known/oauth-protected-resource` | +| `OAUTH_AUTHORIZATION_SERVERS` | _(empty)_ | JSON array of OAuth/OIDC issuer URLs, e.g. `["https://auth.example.com"]` | +| `OAUTH_SCOPES_SUPPORTED` | _(empty)_ | JSON array of scope strings, e.g. `["artifacts.write"]` | ### Docker Compose diff --git a/package-lock.json b/package-lock.json index 7b7430f..b24977b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9709,6 +9709,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index 4cddab6..2e2ffa4 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "A static, zero-retention artifact viewer for markdown, code, diffs, CSV, and JSON.", "scripts": { "dev": "next dev", - "build": "next build", + "build": "next build && node scripts/emit-oauth-protected-resource.mjs", "preview": "node scripts/serve-export.mjs", "start": "npm run preview", "lint": "eslint . && npm run check:public-export-docs", diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..73e9925 --- /dev/null +++ b/public/_headers @@ -0,0 +1,7 @@ +# Cloudflare Pages: correct MIME for RFC 9728 metadata (extensionless path) +/.well-known/oauth-protected-resource + Content-Type: application/json; charset=utf-8 + +# Subpath deployment (matches common `NEXT_PUBLIC_BASE_PATH=/agent-render` preview) +/agent-render/.well-known/oauth-protected-resource + Content-Type: application/json; charset=utf-8 diff --git a/scripts/emit-oauth-protected-resource.mjs b/scripts/emit-oauth-protected-resource.mjs new file mode 100644 index 0000000..eef0fe6 --- /dev/null +++ b/scripts/emit-oauth-protected-resource.mjs @@ -0,0 +1,35 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +/** + * Writes RFC 9728 OAuth Protected Resource Metadata next to the static export. + * See https://www.rfc-editor.org/rfc/rfc9728 + */ +const configuredBasePath = (process.env.NEXT_PUBLIC_BASE_PATH || "").trim(); +const basePath = configuredBasePath === "/" ? "" : configuredBasePath.replace(/\/$/, ""); +const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL || "https://agent-render.com").replace(/\/$/, ""); + +const resource = basePath ? `${siteUrl}${basePath}` : siteUrl; + +const metadata = { + resource, + authorization_servers: [], + scopes_supported: [], +}; + +const body = `${JSON.stringify(metadata, null, 2)}\n`; +const outDir = path.resolve("out"); + +async function writeMetadata(relativeWellKnownDir) { + const dir = path.join(outDir, ...relativeWellKnownDir.split("/").filter(Boolean), ".well-known"); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "oauth-protected-resource"), body, "utf8"); +} + +await writeMetadata(""); +if (basePath) { + await writeMetadata(basePath); +} + +console.log(`Wrote OAuth Protected Resource Metadata for resource: ${resource}`); diff --git a/scripts/serve-export.mjs b/scripts/serve-export.mjs index 3403d53..9c7fe1c 100644 --- a/scripts/serve-export.mjs +++ b/scripts/serve-export.mjs @@ -82,7 +82,10 @@ const server = createServer(async (request, response) => { return; } - const contentType = contentTypes.get(path.extname(finalPath)) || "application/octet-stream"; + let contentType = contentTypes.get(path.extname(finalPath)) || "application/octet-stream"; + if (finalPath.endsWith(`${path.sep}.well-known${path.sep}oauth-protected-resource`)) { + contentType = "application/json; charset=utf-8"; + } response.writeHead(200, { "Content-Type": contentType }); createReadStream(finalPath).pipe(response); }); diff --git a/selfhosted/server.ts b/selfhosted/server.ts index 4724506..e3344e4 100644 --- a/selfhosted/server.ts +++ b/selfhosted/server.ts @@ -18,6 +18,50 @@ const outputDirectory = path.resolve(process.env.OUT_DIR || "out"); const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const OAUTH_PR_PATH = "/.well-known/oauth-protected-resource"; + +/** + * RFC 9728 OAuth Protected Resource Metadata document for this deployment. + * Lists the resource identifier and (when configured) trusted authorization servers. + */ +function getOAuthProtectedResourceMetadata(url: URL): Record { + const fromEnv = process.env.OAUTH_RESOURCE_IDENTIFIER?.trim(); + const resource = (fromEnv || `${url.protocol}//${url.host}`).replace(/\/$/, ""); + + let authorization_servers: string[] = []; + let scopes_supported: string[] = []; + + const serversRaw = process.env.OAUTH_AUTHORIZATION_SERVERS?.trim(); + if (serversRaw) { + try { + const parsed = JSON.parse(serversRaw) as unknown; + if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) { + authorization_servers = parsed; + } + } catch { + // ignore invalid env + } + } + + const scopesRaw = process.env.OAUTH_SCOPES_SUPPORTED?.trim(); + if (scopesRaw) { + try { + const parsed = JSON.parse(scopesRaw) as unknown; + if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) { + scopes_supported = parsed; + } + } catch { + // ignore invalid env + } + } + + return { + resource, + authorization_servers, + scopes_supported, + }; +} + const contentTypes = new Map([ [".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], @@ -133,7 +177,10 @@ async function serveStatic(res: ServerResponse, urlPath: string): Promise return; } - const contentType = contentTypes.get(path.extname(filePath)) || "application/octet-stream"; + let contentType = contentTypes.get(path.extname(filePath)) || "application/octet-stream"; + if (filePath.endsWith(`${path.sep}.well-known${path.sep}oauth-protected-resource`)) { + contentType = "application/json; charset=utf-8"; + } res.writeHead(200, { "Content-Type": contentType }); createReadStream(filePath).pipe(res); } @@ -172,6 +219,7 @@ function errorPage(title: string, message: string): string { * Main request handler for the self-hosted agent-render server. * * Routes: + * - `GET /.well-known/oauth-protected-resource` — RFC 9728 OAuth Protected Resource Metadata * - `POST /api/artifacts` — create a new artifact * - `GET /api/artifacts/:id` — retrieve an artifact (refreshes TTL) * - `PUT /api/artifacts/:id` — update an artifact payload @@ -185,6 +233,12 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise const pathname = url.pathname.replace(/\/+$/, "") || "/"; const method = req.method?.toUpperCase() ?? "GET"; + // GET /.well-known/oauth-protected-resource — RFC 9728 discovery for OAuth clients and agents + if (method === "GET" && pathname === OAUTH_PR_PATH) { + jsonResponse(res, 200, getOAuthProtectedResourceMetadata(url)); + return; + } + // CORS headers for API routes if (pathname.startsWith("/api/")) { res.setHeader("Access-Control-Allow-Origin", "*"); diff --git a/skills/agent-render-linking/SKILL.md b/skills/agent-render-linking/SKILL.md index 3a100e5..39fd1e9 100644 --- a/skills/agent-render-linking/SKILL.md +++ b/skills/agent-render-linking/SKILL.md @@ -15,6 +15,8 @@ Agent Render is: - self-hostable for people who want their own deployment - meant to provide a zero-retention browser viewer for agent-shared artifacts +OAuth client discovery for protected APIs on a deployment: `GET /.well-known/oauth-protected-resource` ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)). + Source repository: - `https://github.com/baanish/agent-render` diff --git a/skills/selfhosted-agent-render/SKILL.md b/skills/selfhosted-agent-render/SKILL.md index c8c9771..bd4ea83 100644 --- a/skills/selfhosted-agent-render/SKILL.md +++ b/skills/selfhosted-agent-render/SKILL.md @@ -19,6 +19,10 @@ Use self-hosted UUID mode instead of fragment links when: If the payload fits in a fragment and the link will work on the target surface, prefer fragment-based links using the `agent-render-linking` skill instead. Fragment links are zero-retention, require no server, and work on the public `agent-render.com` deployment. +## OAuth discovery (RFC 9728) + +Agents can read `GET /.well-known/oauth-protected-resource` for OAuth Protected Resource Metadata (`resource`, `authorization_servers`, `scopes_supported`). Configure issuers and scopes with `OAUTH_AUTHORIZATION_SERVERS` and `OAUTH_SCOPES_SUPPORTED` (JSON arrays). Override the resource identifier with `OAUTH_RESOURCE_IDENTIFIER` if it should differ from the request origin. + ## API The self-hosted server exposes a simple REST API. diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index e1d8a09..5383911 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -1,7 +1,24 @@ import { expect, type Page } from "@playwright/test"; +/** + * Navigates using the Playwright `baseURL` and waits until `location.hash` matches + * the intended fragment so decode/render is not racing the next assertion. + */ export async function goToHash(page: Page, hash = "") { await page.goto(`.${hash}`); + const expectedBody = hash.startsWith("#") ? hash.slice(1) : hash; + await page.waitForFunction( + (body) => { + const fragment = window.location.hash; + const normalized = fragment.startsWith("#") ? fragment.slice(1) : fragment; + if (body === "") { + return normalized === ""; + } + return normalized === body; + }, + expectedBody, + { timeout: 30_000 }, + ); } export async function setTheme(page: Page, theme: "light" | "dark") { diff --git a/tests/e2e/oauth-protected-resource.spec.ts b/tests/e2e/oauth-protected-resource.spec.ts new file mode 100644 index 0000000..88e0645 --- /dev/null +++ b/tests/e2e/oauth-protected-resource.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "@playwright/test"; + +const port = Number(process.env.PLAYWRIGHT_PORT || 4401); + +test("serves OAuth Protected Resource Metadata at /.well-known/oauth-protected-resource", async ({ + request, +}) => { + const response = await request.get( + `http://127.0.0.1:${port}/agent-render/.well-known/oauth-protected-resource`, + ); + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toMatch(/application\/json/); + + const body = await response.json(); + expect(body).toMatchObject({ + resource: "https://agent-render.com/agent-render", + authorization_servers: [], + scopes_supported: [], + }); +}); diff --git a/tests/e2e/visual.spec.ts-snapshots/bundle-switcher-light-chromium.png b/tests/e2e/visual.spec.ts-snapshots/bundle-switcher-light-chromium.png index e9785a3..ed292cf 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/bundle-switcher-light-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/bundle-switcher-light-chromium.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/code-light-chromium.png b/tests/e2e/visual.spec.ts-snapshots/code-light-chromium.png index 34bdec8..ab92dd6 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/code-light-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/code-light-chromium.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/csv-compact-light-chromium.png b/tests/e2e/visual.spec.ts-snapshots/csv-compact-light-chromium.png index 7d230ee..577a169 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/csv-compact-light-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/csv-compact-light-chromium.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/diff-light-chromium.png b/tests/e2e/visual.spec.ts-snapshots/diff-light-chromium.png index f20c3c9..e85b81e 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/diff-light-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/diff-light-chromium.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/empty-state-light-chromium.png b/tests/e2e/visual.spec.ts-snapshots/empty-state-light-chromium.png index 6e65396..538ca2f 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/empty-state-light-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/empty-state-light-chromium.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/json-light-chromium.png b/tests/e2e/visual.spec.ts-snapshots/json-light-chromium.png index 409f752..03bcc07 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/json-light-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/json-light-chromium.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/markdown-dark-chromium.png b/tests/e2e/visual.spec.ts-snapshots/markdown-dark-chromium.png index 612cee1..6d81079 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/markdown-dark-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/markdown-dark-chromium.png differ diff --git a/tests/e2e/visual.spec.ts-snapshots/markdown-light-chromium.png b/tests/e2e/visual.spec.ts-snapshots/markdown-light-chromium.png index 5b18525..93992f0 100644 Binary files a/tests/e2e/visual.spec.ts-snapshots/markdown-light-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/markdown-light-chromium.png differ