Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<basePath>/.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
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions scripts/emit-oauth-protected-resource.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
5 changes: 4 additions & 1 deletion scripts/serve-export.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
56 changes: 55 additions & 1 deletion selfhosted/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
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<string, string>([
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
Expand Down Expand Up @@ -133,7 +177,10 @@ async function serveStatic(res: ServerResponse, urlPath: string): Promise<void>
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);
}
Expand Down Expand Up @@ -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
Expand All @@ -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", "*");
Expand Down
2 changes: 2 additions & 0 deletions skills/agent-render-linking/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
4 changes: 4 additions & 0 deletions skills/selfhosted-agent-render/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions tests/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -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") {
Expand Down
20 changes: 20 additions & 0 deletions tests/e2e/oauth-protected-resource.spec.ts
Original file line number Diff line number Diff line change
@@ -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: [],
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/e2e/visual.spec.ts-snapshots/code-light-chromium.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/e2e/visual.spec.ts-snapshots/diff-light-chromium.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/e2e/visual.spec.ts-snapshots/json-light-chromium.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/e2e/visual.spec.ts-snapshots/markdown-dark-chromium.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/e2e/visual.spec.ts-snapshots/markdown-light-chromium.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading