Skip to content
Merged
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
100 changes: 100 additions & 0 deletions packages/cms-admin/src/app/api/lens-session/route.ts
Original file line number Diff line number Diff line change
@@ -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 <LENS_MINT_SECRET>` → 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=<id>` (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<typeof sessionCookie> = [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 });
}
7 changes: 5 additions & 2 deletions packages/cms-admin/src/lib/require-role.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ export async function getSiteRole(): Promise<UserRole | null> {
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);
Expand Down
9 changes: 8 additions & 1 deletion packages/cms-admin/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading