Merge MCP auth pages into the sync frontend on a single domain (sync.parc.land).
Architecture: multi-page app with per-page SSR (React + styled-components) and
per-page client hydration. No SPA, no client-side router. The server is the router.
- ~750 lines of inline HTML template strings in
mcp/auth.ts - Separate
mcp/mcp.tsHTTP entry point atmcp.sync.parc.land - Duplicated CSS, vanilla JS WebAuthn flows
- The current client-side SPA (
frontend/index.tsx+ React Router)
- Single domain:
sync.parc.landhandles everything - Type-safe JSX templating (replaces template strings)
- Shared design system via styled-components
- Progressive enhancement: forms work without JS, hydration adds interactivity
- Each page is independent: own component tree, own client bundle, own hydration
- Navigation is
<a href>. It's just the web.
Each page follows the same shape:
// In main.ts route handler
import { renderToString } from "react-dom/server";
import { ServerStyleSheet } from "styled-components";
import { ManagePage } from "./frontend/pages/manage/ManagePage.tsx";
const sheet = new ServerStyleSheet();
const html = renderToString(sheet.collectStyles(<ManagePage vault={vault} />));
const css = sheet.getStyleTags();
return new Response(shell({ html, css, props: { vault }, entry: "/frontend/pages/manage/client.tsx" }));
// frontend/pages/manage/client.tsx
import { hydrateRoot } from "react-dom/client";
import { ManagePage } from "./ManagePage.tsx";
const props = JSON.parse(document.getElementById("__PROPS__")!.textContent!);
hydrateRoot(document.getElementById("root")!, <ManagePage {...props} />);
// shell() emits:
<html>
<head>${css}</head> <!-- styled-components extracted CSS -->
<body>
<div id="root">${html}</div>
<script id="__PROPS__" type="application/json">${serializedProps}</script>
<script type="module" src="${entry}"></script>
</body>
</html>
frontend/
shell.ts — HTML wrapper (head, props injection, script tag)
theme.ts — styled-components theme tokens, shared
components/ — shared across pages (Card, Button, StatusText, Toast...)
pages/
landing/
LandingPage.tsx — isomorphic component
client.tsx — hydrateRoot entry
dashboard/
DashboardPage.tsx — isomorphic component
client.tsx — hydrateRoot entry
docs/
DocPage.tsx — isomorphic component
client.tsx — hydrateRoot entry
authorize/
AuthorizePage.tsx — OAuth sign-in/register + consent
client.tsx — hydrateRoot entry
manage/
ManagePage.tsx — Vault, passkeys, recovery management
client.tsx — hydrateRoot entry
recover/
RecoverPage.tsx — Recovery token → new passkey
client.tsx — hydrateRoot entry
The MCP HTTP handler becomes a function imported by main.ts, not a separate entry point.
// main.ts
import { handleMcpRequest } from "./mcp/mcp.ts";
// In the route handler, before room routes:
if (url.pathname.startsWith("/mcp") ||
url.pathname.startsWith("/oauth/") ||
url.pathname.startsWith("/webauthn/") ||
url.pathname.startsWith("/manage") ||
url.pathname.startsWith("/recover") ||
url.pathname.startsWith("/vault") ||
url.pathname.startsWith("/.well-known/oauth")) {
return handleMcpRequest(req);
}
mcp/mcp.ts changes from export default async function to
export async function handleMcpRequest(req: Request): Promise<Response>.
- MCP server URL:
mcp.sync.parc.land→sync.parc.land(orsync.parc.land/mcp) - OAuth discovery URLs: issuer changes to
https://sync.parc.land - Claude.ai connector config needs updating
- Existing tokens should survive (same DB, validation doesn't check issuer)
- All API endpoints (paths unchanged)
- SQLite database (shared, same as today)
- WebAuthn RP ID (
parc.land— domain-level, unaffected) - Token format and validation
| Path | Method | Handler | Page/Response |
|---|---|---|---|
/ | GET | main.ts | SSR LandingPage |
/?room=X | GET | main.ts | SSR DashboardPage |
/?doc=X | GET | main.ts | SSR DocPage |
/manage | GET | main.ts → mcp | SSR ManagePage |
/recover | GET | main.ts → mcp | SSR RecoverPage |
/oauth/authorize | GET | main.ts → mcp | SSR AuthorizePage |
/mcp | POST | main.ts → mcp | MCP JSON-RPC |
/oauth/* | POST | main.ts → mcp | OAuth API |
/webauthn/* | POST | main.ts → mcp | WebAuthn API |
/manage/api/* | * | main.ts → mcp | Management API |
/recover/* | POST | main.ts → mcp | Recovery API |
/vault | * | main.ts → mcp | Vault API |
/.well-known/* | GET | main.ts → mcp | OAuth discovery |
/rooms/* | * | main.ts | Room API |
/frontend/* | GET | main.ts | Module proxy (esm.town) |
/reference/* | GET | main.ts | Reference docs |
Adopt the sync dashboard palette as the single source of truth. The MCP pages' slightly different dark theme (#0a0a0f vs #0d1117, #4a4aff vs #58a6ff) converges to the dashboard variables.
// frontend/theme.ts
export const theme = {
bg: '#0d1117',
fg: '#c9d1d9',
dim: '#484f58',
border: '#21262d',
accent: '#58a6ff',
green: '#3fb950',
yellow: '#d29922',
red: '#f85149',
surface: '#161b22',
surface2: '#1c2129',
purple: '#bc8cff',
orange: '#f0883e',
// Landing/docs (light default, dark media query)
landing: { ... },
};
frontend/components/
Card.tsx — Surface container
Button.tsx — Primary, secondary variants
StatusText.tsx — Status/error messages
Toast.tsx — Toast notifications
TokenBadge.tsx — Token type badges (room/agent/view)
PasskeyChip.tsx — Passkey credential display
VaultTable.tsx — Token vault table with actions
import {
startRegistration,
startAuthentication,
} from "https://esm.sh/@simplewebauthn/browser@13";
This is client-only — used in hydration scripts, not in SSR.
// frontend/hooks/useWebAuthn.ts
export function useWebAuthn(origin: string) {
const [status, setStatus] = useState("");
const [error, setError] = useState("");
// signIn(): Promise<string | null> — returns sessionId
// register(username): Promise<{ sessionId } | null>
return { signIn, register, status, error, setError };
}
WebAuthn requires JS (browser API). Forms that don't need WebAuthn can work
without JS via <form method="POST" action="...">. The server handles the POST,
re-renders the page with updated state.
Pages that require WebAuthn (all three MCP pages):
- Server renders the initial state (sign-in form, token input, etc.)
- Client hydration adds the WebAuthn interaction
- Without JS: page renders but WebAuthn buttons are inert (acceptable — passkeys require JS anyway)
- Create
frontend/shell.ts— SSR HTML wrapper - Create
frontend/theme.ts— shared design tokens - Create
frontend/components/shared components - Verify
styled-componentsServerStyleSheet works in Val.town Deno runtime
- Convert LandingPage to SSR + hydration (replaces SPA)
- Convert DashboardPage to SSR + hydration
- Convert DocPage to SSR + hydration
- Remove React Router dependency
- Update main.ts to SSR each page
- Convert
mcp/mcp.tsfrom default export to named export - Import handleMcpRequest in main.ts, add route delegation
- Test all MCP endpoints work on sync.parc.land
- Update OAuth discovery metadata (issuer URL)
- Create RecoverPage.tsx (simplest — proof of pattern)
- Create ManagePage.tsx (medium complexity)
- Create AuthorizePage.tsx (most complex — OAuth flow critical path)
- Create client.tsx hydration entries for each
- Create useWebAuthn hook
- Add authorize params validation endpoint (
GET /oauth/authorize/params)
- Delete inline HTML templates from mcp/auth.ts (~600 lines)
- Delete CSS constant
- Delete escapeHtml function
- Remove mcp/mcp.ts as HTTP entry point (becomes plain module)
- Update MCP client configs (Claude.ai connector)
- Test OAuth flow end-to-end
| Risk | Severity | Mitigation |
|---|---|---|
| styled-components ServerStyleSheet fails in Deno | High | Test early in Phase 1. Fallback: inline <style> tags |
| WebAuthn ESM import differs from UMD behavior | High | Test isolated. Fallback: keep UMD script tag in shell |
| MCP clients break when domain changes | High | Keep mcp.sync.parc.land redirect → sync.parc.land |
| SSR cold start adds latency | Medium | Templates are small, renderToString is fast. Monitor |
| OAuth authorize page fails mid-flow | High | Test with Claude.ai connector. Keep old handlers on branch |
| Hydration mismatch (server/client render differ) | Low | Keep components deterministic, no browser-only logic in render |
- Shell renders valid HTML with extracted CSS
- Props serialize/deserialize correctly via
__PROPS__script tag - Hydration attaches without mismatch warnings
- Pages render correctly without JS (progressive enhancement)
- Module proxy serves client.tsx entries correctly
- MCP JSON-RPC works at sync.parc.land/mcp
- OAuth discovery returns correct issuer
- WebAuthn RP ID unchanged (parc.land)
- Existing tokens validate correctly
- mcp.sync.parc.land redirects (or 404s cleanly)
- RecoverPage: token → verify → register passkey → success
- ManagePage: sign in → vault table, passkeys, recovery tokens
- AuthorizePage: sign in/register → consent → redirect with code
- LandingPage: renders, Mermaid diagrams work
- DashboardPage: polling, tab panels, surfaces
- DocPage: markdown rendering
- Mobile responsive
- No console errors
- styled-components CSS in SSR output (no FOUC)
- Toast notifications work after hydration