Skip to content
Open
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
41 changes: 41 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,44 @@ LOG_LEVEL=info
# Default: 30 requests per 60 seconds.
RATE_LIMIT_MAX=30
RATE_LIMIT_WINDOW_MS=60000

# --- Auth (OAuth 2.1 + sessions) ---------------------------------------------
# Auth is implemented behind the REQUIRE_AUTH toggle. While REQUIRE_AUTH=false
# the API boots without the crypto/SMTP vars and the auth endpoints return 503.
# When REQUIRE_AUTH=true, the following are required at boot or the server
# exits with a clear error: JWT_SIGNING_KEY, JWT_PUBLIC_KEY, GOOGLE_CLIENT_ID,
# GOOGLE_CLIENT_SECRET, AUTH_STATE_SECRET, SMTP_HOST, SMTP_USER, SMTP_PASS.
# See docs/superpowers/specs/2026-05-17-auth-design.md.

# Master switch. Set to true to require auth on mutation endpoints (e.g. POST /new).
# Leave false during the grace-period rollout.
REQUIRE_AUTH=false

# Ed25519 JWT keys for the access-token signer. Base64url-encoded DER bytes
# (generate with `openssl genpkey -algorithm ed25519`). Required when REQUIRE_AUTH=true.
JWT_SIGNING_KEY=
JWT_PUBLIC_KEY=

# Google OAuth 2.0 credentials (https://console.cloud.google.com/apis/credentials).
# Required when REQUIRE_AUTH=true.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Defaults to {PUBLIC_URL}/oauth/callback/google when unset.
GOOGLE_REDIRECT_URI=

# HMAC key for OAuth state JWTs (e.g. `openssl rand -base64 32`).
# Required when REQUIRE_AUTH=true.
AUTH_STATE_SECRET=

# Token lifetimes (defaults match the auth spec).
SESSION_MAX_AGE_DAYS=30
REFRESH_TOKEN_MAX_DAYS=90
ACCESS_TOKEN_TTL_SECONDS=3600

# SMTP for sending magic-link emails. Required when REQUIRE_AUTH=true (except
# SMTP_PORT and SMTP_FROM, which have defaults).
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@pagent.link
85 changes: 85 additions & 0 deletions apps/api/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ vi.mock('./db.ts', () => ({
deletePage: vi.fn(() => Promise.resolve()),
deleteExpiredPages: vi.fn(() => Promise.resolve({ total: 0, abandoned: 0 })),
ping: vi.fn().mockResolvedValue(undefined),
// Session lookup powers the cookie auth path; default to "no session" so
// every test stays anonymous unless it opts in by overriding the mock.
getSessionWithUserByTokenHash: vi.fn(() => Promise.resolve(null)),
extendSessionExpiry: vi.fn(() => Promise.resolve()),
deleteSessionByTokenHash: vi.fn(() => Promise.resolve()),
}));

import * as db from './db.ts';
Expand Down Expand Up @@ -722,6 +727,86 @@ describe('OpenAPI surface', () => {
// Error message field
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// owner_id propagation on POST /new
// ---------------------------------------------------------------------------
// Verifies that an authenticated POST /new (via session cookie) propagates
// the user's UUID onto db.insertPage(page.ownerId), and that an anonymous
// POST /new (grace period) leaves ownerId = null. The Bearer path mirrors
// the cookie path through resolveAuth() — covered by the middleware tests
// in apps/api/auth/middleware.test.ts.

describe('POST /new owner_id propagation', () => {
// Matches what the auth session uses internally: SHA-256(raw token) hex.
// We don't bother computing it here — the mock matches any token by always
// returning the same row.
const FAKE_USER_ID = '11111111-2222-3333-4444-555555555555';

function authedRequest(method: string, path: string, body?: unknown): Request {
return new Request(`${BASE}${path}`, {
method,
headers: {
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
// Raw cookie token — the auth session module hashes it before lookup;
// the mocked getSessionWithUserByTokenHash returns a hit regardless.
Cookie: 'pagent_session=raw-cookie-token-value',
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}

it('passes ownerId = user.id to db.insertPage when authenticated via cookie', async () => {
(db.getSessionWithUserByTokenHash as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
session_id: 'session-uuid',
user_id: FAKE_USER_ID,
email: 'alice@example.com',
handle: 'alice',
expires_at: new Date(Date.now() + 86_400_000),
});
const res = await app.fetch(authedRequest('POST', '/new', { spec: { anything: 1 } }));
expect(res.status).toBe(201);
expect(db.insertPage).toHaveBeenCalledOnce();
const [calledPage] = vi.mocked(db.insertPage).mock.calls[0];
expect(calledPage.ownerId).toBe(FAKE_USER_ID);
});

it('passes ownerId = null to db.insertPage on anonymous POST /new (grace period)', async () => {
// Default mock returns null — no session → c.var.user is null → ownerId null.
const res = await app.fetch(req('POST', '/new', { spec: { anything: 1 } }));
expect(res.status).toBe(201);
expect(db.insertPage).toHaveBeenCalledOnce();
const [calledPage] = vi.mocked(db.insertPage).mock.calls[0];
expect(calledPage.ownerId).toBeNull();
});

it('passes ownerId on format=html pages too', async () => {
(db.getSessionWithUserByTokenHash as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
session_id: 'session-uuid',
user_id: FAKE_USER_ID,
email: 'alice@example.com',
handle: 'alice',
expires_at: new Date(Date.now() + 86_400_000),
});
const res = await app.fetch(
authedRequest('POST', '/new', { format: 'html', spec: '<p>hi</p>' }),
);
expect(res.status).toBe(201);
expect(db.insertPage).toHaveBeenCalledOnce();
const [calledPage] = vi.mocked(db.insertPage).mock.calls[0];
expect(calledPage.format).toBe('html');
expect(calledPage.ownerId).toBe(FAKE_USER_ID);
});

it('anonymous format=html POST /new also lands with ownerId = null', async () => {
const res = await app.fetch(req('POST', '/new', { format: 'html', spec: '<p>hi</p>' }));
expect(res.status).toBe(201);
expect(db.insertPage).toHaveBeenCalledOnce();
const [calledPage] = vi.mocked(db.insertPage).mock.calls[0];
expect(calledPage.format).toBe('html');
expect(calledPage.ownerId).toBeNull();
});
});

describe('error message field', () => {
it('500 body includes non-empty message field', async () => {
(db.getActivePage as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('boom'));
Expand Down
51 changes: 48 additions & 3 deletions apps/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { logger } from './logger.ts';
import { metrics, statusClassFor } from './metrics.ts';
import type { RequestIdVariables } from './request-id.ts';
import { requestId, getLog, getRequestId } from './request-id.ts';
import { authRoutes } from './auth/routes.ts';
import type { AuthVariables } from './auth/middleware.ts';
import { resolveAuth, requireAuth } from './auth/middleware.ts';

// --- OpenAPI spec (loaded once at boot, served from memory) ------------------

Expand Down Expand Up @@ -68,7 +71,7 @@ const newPageLimiter = rateLimiter({

// --- App ---------------------------------------------------------------------

export const app = new Hono<{ Variables: RequestIdVariables }>();
export const app = new Hono<{ Variables: RequestIdVariables & AuthVariables }>();
app.use('*', requestId());
app.use(
'*',
Expand Down Expand Up @@ -201,6 +204,39 @@ app.get(
}),
);

// --- Auth resolution ---------------------------------------------------------
// Populate c.var.user from the session cookie or Bearer JWT on EVERY route.
// Never short-circuits — `requireAuth()` is the gatekeeper for protected
// endpoints. Mounted before any route handler so subsequent middlewares
// (e.g. requireAuth on POST /new) can read c.var.user.

app.use('*', resolveAuth());

// --- Auth / OAuth discovery --------------------------------------------------
// Mounts the three .well-known endpoints (AS metadata, protected resource
// metadata, JWKS). Mounted at root so the literal RFC-defined paths land
// where MCP clients expect them. No auth required.

app.route('/', authRoutes);

/**
* No-op or 401-gating middleware, chosen at module load based on the
* REQUIRE_AUTH env var. Centralizing the branch here means route declarations
* stay clean and the grace-period behavior (REQUIRE_AUTH=false → no rejection)
* is the boring path.
*
* When REQUIRE_AUTH=false: passes through. POST /new still gets c.var.user
* populated by resolveAuth, but anonymous requests succeed (matches the spec's
* phased rollout — see §8.2 "Grace period").
* When REQUIRE_AUTH=true: returns 401 for anonymous requests on protected
* routes (§8.3).
*/
const requireAuthIfEnabled: ReturnType<typeof requireAuth> = env.REQUIRE_AUTH
? requireAuth()
: async (_c, next) => {
await next();
};

// --- Route handlers ----------------------------------------------------------

const newPageHandler = async (c: Context) => {
Expand Down Expand Up @@ -236,11 +272,17 @@ const newPageHandler = async (c: Context) => {
}
}

// Authenticated user id flows from resolveAuth() (cookie or Bearer JWT) onto
// c.var.user. Null during the grace period; the row goes in with
// owner_id = NULL. When REQUIRE_AUTH=true, requireAuthIfEnabled has already
// rejected anonymous requests with 401 — so this read is non-null in that path.
const ownerId = c.var.user?.id ?? null;

if (format === 'html') {
try {
const created = await store.createHtmlPage(
spec as string,
{ publicUrl: PUBLIC_URL, pageTtlMs: PAGE_TTL_MS },
{ publicUrl: PUBLIC_URL, pageTtlMs: PAGE_TTL_MS, ownerId },
getLog(c),
);
return c.json(created, 201);
Expand All @@ -262,6 +304,7 @@ const newPageHandler = async (c: Context) => {
const created = await store.createPage(spec, format, {
publicUrl: PUBLIC_URL,
pageTtlMs: PAGE_TTL_MS,
ownerId,
});
return c.json(created, 201);
};
Expand Down Expand Up @@ -351,7 +394,9 @@ const getResultHandler = async (c: Context) => {

// --- Routes ------------------------------------------------------------------

app.post('/new', newPageLimiter, newPageHandler);
// POST /new — gated by requireAuth when REQUIRE_AUTH=true; otherwise the
// requireAuthIfEnabled middleware is a no-op pass-through.
app.post('/new', requireAuthIfEnabled, newPageLimiter, newPageHandler);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce page:create scope on POST /new

When REQUIRE_AUTH=true, this route only checks that a user is authenticated, not that the bearer token has page:create. A token minted with narrower scope (for example page:read) can still create pages, which violates the documented scope grants and turns scope reduction into a no-op for REST mutations.

Useful? React with 👍 / 👎.

app.get('/:id', getPageHandler);
app.post('/:id/result', submitResultHandler);
app.get('/:id/result', getResultHandler);
Loading
Loading