diff --git a/packages/cms-admin/src/app/api/lens-session/route.ts b/packages/cms-admin/src/app/api/lens-session/route.ts new file mode 100644 index 00000000..7b867ed2 --- /dev/null +++ b/packages/cms-admin/src/app/api/lens-session/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import { SignJWT } from "jose"; + +/** + * F151 — Lens mint-endpoint (fleet `mintEndpoint` standard). + * + * `POST /api/lens-session` + * - Auth: `Authorization: Bearer ` → 401 on missing/wrong. + * This secret ONLY authorizes minting a lens session; it is NOT an admin token. + * - Mints a short-lived (~10 min), read-only `cms-session` JWT for the dedicated + * lens principal (`lens@webhouse.app`, role admin so it can RENDER admin + * surfaces — never cb@webhouse.dk, never a real user). Read-only is enforced by + * the `lens:true` write-guard in proxy.ts, NOT by the role. + * - Returns a Playwright `storageState` the Lens daemon applies verbatim. + * + * Contract: broberg-ai/cardmem/docs/LENS-MINT-ENDPOINT.md + */ + +const TTL_SECONDS = 600; // ~10 minutes — cookie + JWT share this expiry + +function getJwtSecret(): Uint8Array { + // Same secret + fallback as proxy.ts so the minted cookie validates 1:1. + return new TextEncoder().encode( + process.env.CMS_JWT_SECRET ?? "cms-dev-secret-change-me-in-production", + ); +} + +/** + * Cookie domain from the request Host (or an explicit LENS_COOKIE_DOMAIN) — + * NEVER the bound address. On Fly the app binds 0.0.0.0; deriving the domain + * from the socket would store the cookie under "0.0.0.0" and the browser would + * never send it to the real host → a silent false-green capture of the public + * shell. (cardmem mint-endpoint doc, sa 2026-06-05.) + */ +function cookieDomain(request: NextRequest): string { + const explicit = process.env.LENS_COOKIE_DOMAIN; + if (explicit) return explicit; + return (request.headers.get("host") ?? "").split(":")[0]; +} + +/** + * Active org/site for the minted session so site-scoped surfaces render instead + * of an empty workspace. Env-driven (LENS_ACTIVE_ORG/LENS_ACTIVE_SITE); when + * unset, callers can target a site per-surface via `?site=` (proxy resolves + * it). Kept env-only to avoid a registry dependency in the mint path. + */ +function resolveActiveSite(): { org: string; site: string } | null { + const org = process.env.LENS_ACTIVE_ORG; + const site = process.env.LENS_ACTIVE_SITE; + return org && site ? { org, site } : null; +} + +export async function POST(request: NextRequest) { + const secret = process.env.LENS_MINT_SECRET; + const authHeader = request.headers.get("authorization"); + const bearer = authHeader?.startsWith("Bearer ") ? authHeader.slice(7).trim() : null; + if (!secret || !bearer || bearer !== secret) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const now = Math.floor(Date.now() / 1000); + const expires = now + TTL_SECONDS; + const token = await new SignJWT({ + sub: "lens", + email: "lens@webhouse.app", + name: "Lens", + role: "admin", + lens: true, + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(expires) + .sign(getJwtSecret()); + + const domain = cookieDomain(request); + const sessionCookie = { + name: "cms-session", + value: token, + domain, + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax" as const, + expires, + }; + + const cookies: Array = [sessionCookie]; + const active = resolveActiveSite(); + if (active) { + // active-org/site are read client-side too → not httpOnly (matches the app). + cookies.push({ ...sessionCookie, name: "cms-active-org", value: active.org, httpOnly: false }); + cookies.push({ ...sessionCookie, name: "cms-active-site", value: active.site, httpOnly: false }); + } + + return NextResponse.json({ cookies, origins: [] }); +} + +export function GET() { + return NextResponse.json({ error: "Method not allowed" }, { status: 405 }); +} diff --git a/packages/cms-admin/src/lib/require-role.ts b/packages/cms-admin/src/lib/require-role.ts index 0b193361..44ede3fb 100644 --- a/packages/cms-admin/src/lib/require-role.ts +++ b/packages/cms-admin/src/lib/require-role.ts @@ -23,8 +23,11 @@ export async function getSiteRole(): Promise { const session = await getSessionUser(cookieStore); if (!session) return null; - // Dev/API/service tokens carry their role in the JWT — no team lookup needed - if (session.sub === "dev-token" || session.sub === "service-token") return session.role; + // Dev/API/service/lens tokens carry their role in the JWT — no team lookup needed. + // F151: the lens principal (sub "lens") has no team membership; its admin role + // comes from the minted JWT so it can render every surface (read-only is the + // proxy.ts write-guard's job, not the role's). + if (session.sub === "dev-token" || session.sub === "service-token" || session.sub === "lens") return session.role; const members = await getTeamMembers(); const membership = members.find((m) => m.userId === session.sub); diff --git a/packages/cms-admin/src/proxy.ts b/packages/cms-admin/src/proxy.ts index fb8bd9f2..e9ad8f2f 100644 --- a/packages/cms-admin/src/proxy.ts +++ b/packages/cms-admin/src/proxy.ts @@ -41,6 +41,7 @@ const PUBLIC_PREFIXES = [ "/api/admin/invitations/", // Invite accept flow (user not yet logged in) "/api/cms/scheduled/calendar.ics", // Auth via ?token= query param "/api/mcp", // MCP servers have their own auth (Bearer token) + "/api/lens-session", // F151 Lens mint-endpoint — bearer-authed (LENS_MINT_SECRET); it MINTS the session "/api/publish-scheduled", // Called by cron/instrumentation, no user session "/api/beam/receive/", // Live Beam receive — token-authenticated (not session) "/api/mobile/", // F07 webhouse.app mobile — Bearer JWT in header, no cookies (handlers enforce auth themselves) @@ -261,7 +262,13 @@ export async function proxy(request: NextRequest) { } try { - await jwtVerify(token, getJwtSecret()); + const { payload } = await jwtVerify(token, getJwtSecret()); + // F151: the Lens principal is read-only — block every mutating method. + // No-op for all real users (no `lens` claim); the only read-only boundary + // for the minted lens session (its role is admin so surfaces still render). + if (payload.lens === true && ["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) { + return NextResponse.json({ error: "Lens session is read-only" }, { status: 403 }); + } return forwardOk(); } catch (err) { // RSC prefetch with invalid token — don't redirect, just reject silently