Matriz Architecture
High-level overview of runtime topology, auth/RLS flow, DB roles, and how to add new API routes. When this document disagrees with the detailed architecture docs under
docs/architecture/, treatdocs/architecture/*as authoritative. For terminology seedocs/architecture/TERMINOLOGY.md.
For current feature status seeSTATUS.md.⚠️ STALE — the pivot this banner says is pending has LANDED (2026-07-03). Flagged 2026-07-26; the file has not yet been rewritten, so read it with these corrections in hand:
This doc says Reality “pivot … formalized but NOT yet executed (D51)” Clerk B2B2C + Split-Glass is live since 2026-07-03 Auth = Neon Auth JWT (§2) Clerk — workers/src/middleware/auth.tsuses@clerk/backendverifyTokenworkers/src/middleware/site-access.ts(§2)File does not exist — it is project-access.ts; the middleware dir isauth.ts,project-access.ts,target-resolution.ts,rbac.ts,module-public.ts,visitor-auth.ts,types.tssitesis the tenant boundaryprojectsis (Clerk org = project);sites→targetsUntil it is rewritten,
CLAUDE.md+ the code are authoritative for auth/tenancy. The Workers-vs-Pages topology (§1), DB roles (§3), route recipes (§5) and publishing (§7) are still broadly accurate. Forward docs:archive/2026-08-22-doc-reset/docs/reference/MASTER_PIVOT_PLAN.md,docs/intent-system/.Missing from §1’s topology: a third deployment target —
apps/bondlayer-worker/, a Python Bondlayer→Matriz importer that ships as a Render Docker service. Seedocs/architecture/BONDLAYER-IMPORT-PIPELINE.md.
1. Cloudflare Workers vs Cloudflare Pages
Section titled “1. Cloudflare Workers vs Cloudflare Pages”Cloudflare Workers (skyfall-api) |
Cloudflare Pages (skyfall-b5u.pages.dev) |
|
|---|---|---|
| Role | API backend | Frontend SPA host |
| Entry point | workers/src/worker.ts → workers/src/index.ts |
frontend/ (Vite build → static assets) |
| Routes served | /api/*, /mcp/*, /published/*, WebSocket (/api/yjs/:siteId) |
/* (static) + thin proxy at functions/api/[[path]].ts |
| Framework | Hono router | React 19 + React Router |
| State | Stateless (each request is independent) + Durable Objects for Yjs | Browser Zustand stores |
| Auth | Verifies Neon Auth JWT from Authorization: Bearer header |
Obtains JWT via authClient.token(), attaches as header |
| DB access | Drizzle ORM over Neon HTTP driver | None — only through the Worker API |
| Config | workers/wrangler.jsonc (vars + secrets) |
frontend/.env.local (VITE_ prefix) |
How they interact
Section titled “How they interact”Browser │ (SPA assets) ▼Cloudflare Pages ──── functions/api/[[path]].ts ────► Cloudflare Workers │ (thin proxy, no logic) │ │ Hono routes │ │ │ Drizzle ORM │ │ │ Neon Postgres │ └── Yjs WebSocket → /api/yjs/:siteId → SiteDurableObjectThe Pages Function (functions/api/[[path]].ts) forwards every /api/* request
verbatim to the Worker. It has no business logic. In local dev, set
VITE_WORKERS_ORIGIN=http://localhost:8787 to talk directly to the Worker.
2. End-to-End Auth + RLS Flow
Section titled “2. End-to-End Auth + RLS Flow”Request lifecycle
Section titled “Request lifecycle”1. Frontend: authClient.token() → fresh EdDSA JWT (15-min expiry)2. Frontend: fetch("/api/...", { headers: { Authorization: "******" } })3. Pages Function: proxy → Worker4. Worker: authMiddleware verifies JWT via JWKS (jose, createRemoteJWKSet) → sets c.var.userId (JWT sub), c.var.jwtToken (raw JWT string)5. Worker: siteAccessMiddleware checks org membership for the site → sets c.var.siteId6. Worker: getScopedDB(c) picks the correct DB connection: - RLS_ENFORCED=true + JWT present → getTenantDB (authenticated role) - else → getServiceDB (service/owner role, BYPASSRLS)7. DB: if authenticated role, pg_session_jwt validates JWT, sets role + auth.user_id() → RLS policies fire; cross-tenant rows are denied at DB levelAuth middleware (workers/src/middleware/auth.ts)
Section titled “Auth middleware (workers/src/middleware/auth.ts)”- Validates the Neon Auth JWT using the JWKS endpoint (
JWKS_URLenv var). - Extracts
subclaim → stored asc.var.userId. - Stores raw JWT string as
c.var.jwtTokenfor use bygetScopedDB. DEV_BYPASS_AUTH=trueinjects a nil-UUID dev user (local dev only).
Site access middleware (workers/src/middleware/site-access.ts)
Section titled “Site access middleware (workers/src/middleware/site-access.ts)”- Runs after
authMiddlewareon all site-scoped routes. - Extracts
siteIdfrom (in priority order):c.req.param('siteId')— populated when middleware pattern includes:siteIdc.req.query('site_id')— query-string fallback (/api/nodes?site_id=…)extractSiteIdFromPath(c.req.path)— regex fallback foruse('*', …)mounts where Hono does not populate named params in the middleware context
- Verifies org membership via a JOIN:
sites → org_members WHERE userId. - Returns
400if no siteId found,403if user is not a member. - Sets
c.var.siteIdfor downstream handlers.
Why the path-regex fallback?
sitePagesRouterandcmsWorkflowRouterregisteruse('*', siteAccessMiddleware). In Hono, ause('*', handler)middleware does not receive the path params from the concrete route pattern (e.g./sites/:siteId/site-pages). The regex scan ofc.req.pathis the deterministic extraction strategy for this case.
3. DB Roles and Intended Usage
Section titled “3. DB Roles and Intended Usage”| Role | Created by | BYPASSRLS | When used |
|---|---|---|---|
neondb_owner / dev |
Neon project | ✅ | Local dev, migrations |
service connection (getServiceDB) |
Same as owner | ✅ | Provisioning, publish, import, DO Yjs flush, MCP |
authenticator |
Neon RLS Authorize | ❌ | Connection role; JWT is the password |
authenticated |
Neon RLS Authorize | ❌ | Session role after pg_session_jwt validates JWT |
Connection chooser (workers/src/db/client.ts)
Section titled “Connection chooser (workers/src/db/client.ts)”// Use this in all routes behind authMiddleware + siteAccessMiddleware:const db = getScopedDB(c) // tenant path when RLS_ENFORCED=true, else service
// Use this ONLY for privileged/cross-tenant operations:const db = getServiceDB(env) // BYPASSRLS — provisioning, publish, etc.getScopedDB(c):
- If
RLS_ENFORCED==='true'andc.var.jwtTokenis set →getTenantDB(env, jwt)(connects asauthenticator;pg_session_jwtswitches session toauthenticated) - Otherwise →
getServiceDB(env)(BYPASSRLS service role)
This means migrating a route from getDB to getScopedDB is always safe while
RLS_ENFORCED is false (the default). The route’s behavior is identical; it only
changes when the flag is flipped.
RLS activation
Section titled “RLS activation”See db/RLS-ACTIVATION.md for the full cutover checklist.
The RLS policies are in db/migrations/20260629_rls_neon_authorize.sql.
4. Environment Variables
Section titled “4. Environment Variables”Worker (workers/wrangler.jsonc + wrangler secret)
Section titled “Worker (workers/wrangler.jsonc + wrangler secret)”| Variable | Required | Purpose |
|---|---|---|
NEON_DATABASE_URL |
✅ | Owner/service-role Postgres connection URL |
NEON_AUTHENTICATED_DATABASE_URL |
✅ (for RLS) | Authenticator-role URL — format: postgresql://authenticator@<host>/neondb?sslmode=require (no password; the JWT is the credential passed via pg_session_jwt) |
JWT_ISSUER |
✅ | Neon Auth issuer (used to validate JWT iss claim) |
JWKS_URL |
✅ | Neon Auth JWKS endpoint |
RLS_ENFORCED |
optional | Set to "true" to route getScopedDB calls through the tenant path |
DEV_BYPASS_AUTH |
dev only | Set to "true" to skip JWT verification in local dev |
R2_BUCKET / ASSETS_BUCKET |
✅ | R2 binding for asset storage |
SITE_DO |
✅ | Durable Object namespace binding for SiteDurableObject |
Frontend (frontend/.env.local)
Section titled “Frontend (frontend/.env.local)”| Variable | Required | Purpose |
|---|---|---|
VITE_NEON_AUTH_URL |
✅ | Neon Auth base URL (BetterAuthVanillaAdapter) |
VITE_NEON_DATA_API_URL |
✅ | Neon Data API URL |
VITE_WORKERS_ORIGIN |
optional | Override the Worker origin (e.g. http://localhost:8787) |
5. How to Add New Routes
Section titled “5. How to Add New Routes”Standard site-scoped route (most routes)
Section titled “Standard site-scoped route (most routes)”import { Hono } from 'hono'import { eq } from 'drizzle-orm'import { getScopedDB } from '../db/client' // ✅ always use thisimport { authMiddleware, type AuthEnv } from '../middleware/auth'import { siteAccessMiddleware } from '../middleware/site-access'import { myTable } from '../db/schema'
export const myResourceRouter = new Hono<AuthEnv>()
// Apply auth + site-access guards to ALL routes in this routermyResourceRouter.use('*', authMiddleware)myResourceRouter.use('*', siteAccessMiddleware)
myResourceRouter.get('/sites/:siteId/my-resource', async (c) => { const db = getScopedDB(c) // RLS-scoped when enforced, service otherwise const siteId = c.get('siteId') // set by siteAccessMiddleware
const rows = await db.select().from(myTable).where(eq(myTable.siteId, siteId)) return c.json({ data: rows })})Mount in workers/src/index.ts:
app.route('/api', myResourceRouter)If your route path includes
/sites/:siteId/, theextractSiteIdFromPathfallback insiteAccessMiddlewarewill extract the siteId even inuse('*', …)context. No extra wiring needed.
Privileged / cross-tenant route (provisioning, publish)
Section titled “Privileged / cross-tenant route (provisioning, publish)”import { getServiceDB } from '../db/client' // BYPASSRLS — use only when necessary
// Annotate WHY service role is needed:// Provisioning creates the org+site atomically — no siteId exists yet,// siteAccessMiddleware cannot run, and the operation is user-owned-only.const db = getServiceDB(c.env)Route that resolves a row by its own ID (not by :siteId in the URL)
Section titled “Route that resolves a row by its own ID (not by :siteId in the URL)”Use userCanAccessSite after fetching the row:
import { userCanAccessSite } from '../middleware/site-access'
const [row] = await db.select().from(myTable).where(eq(myTable.id, id)).limit(1)if (!row) return c.json({ error: 'Not found' }, 404)if (!(await userCanAccessSite(c.env, c.get('userId'), row.siteId))) { return c.json({ error: 'Access denied' }, 403)}6. Durable Objects
Section titled “6. Durable Objects”| DO | Binding | Purpose |
|---|---|---|
SiteDurableObject |
SITE_DO |
Yjs CRDT for real-time collaborative text editing. One DO per siteId. Receives WebSocket upgrades at /api/yjs/:siteId. Flushes Y.Text projections to dom_nodes.slot_values via the service DB connection. |
McpControlAgent |
MCP_CONTROL_AGENT |
MCP server for Copilot coding agent integration. |
McpDataAgent |
MCP_DATA_AGENT |
MCP server for data-layer queries. |
7. Static Site Publishing
Section titled “7. Static Site Publishing”Editor save → dom_nodes table ↓POST /api/publish/:siteId ↓publishRouter (service DB — reads all nodes, no RLS needed) ↓@matriz/site-render (packages/site-render/) — shared render core ↓Cloudflare R2 (published/<siteId>/index.html, … + per-object customMetadata: cspScriptHash, cspFrameSrc, memberGate) ↓GET /published/:siteId/* → servePublishedAsset serves R2 bytes + emits the CSPThe render core in packages/site-render/ is used identically by:
- Editor preview (iframe in CanvasPanel)
- Publish pipeline (static HTML generation)
This guarantees preview ↔ published parity. (Exception: embed and custom_code
nodes render as inert placeholders in the live canvas — their real iframe emission
executes only at publish and in ui-lab.)
Serving origin (PUBLISH_ROOT_DOMAIN)
Section titled “Serving origin (PUBLISH_ROOT_DOMAIN)”Publishing serves from two routes that share servePublishedAsset: the legacy path
route GET /published/:siteId/* and host-based serving (custom hostnames via the
target-resolution middleware). The PUBLISH_ROOT_DOMAIN var controls the split:
- Unset /
""(default): the path route serves bytes byte-for-byte (legacy behavior). - Set: the path route 301s to
https://<canonical-host>/<path><query>(Cache-Control: public, max-age=300, cookie-free). The canonical host comes fromresolveCanonicalHost(env, targetId)(workers/src/lib/canonical-host.ts): active custom primary domain → platform subdomain →null(falls through to serving). KV-cached. The/api/m/member namespace is carved out and unaffected.
CSP metadata flow (frame-src)
Section titled “CSP metadata flow (frame-src)”Publish computes each page’s CSP inputs and stores them on the R2 object’s
customMetadata: cspScriptHash (sha256 of the hash-pinned interactions runtime,
if the page uses one) and cspFrameSrc (space-joined embed-provider origins,
stored only when non-empty). At serve time publishedCsp(scriptHash?, frameSrc?)
emits the header — script-src is forever 'none' or hashes (CI-gated:
published-csp-invariant.test.ts), and frame-src appears only on pages with
embeds (per-token https://host validation, malformed tokens dropped). Only the
embed registry (packages/site-render/src/embed-registry.ts, 8 allowlisted
providers) can produce an iframe with a network src; the origin union it grants
is pinned by third-party-ledger.ts — that file’s diff is the review surface for
any new third-party origin. Publish rollback copies customMetadata wholesale.
custom_code containment
Section titled “custom_code containment”A custom_code node publishes as a sandboxed srcdoc iframe with hardcoded
sandbox="allow-scripts allow-forms". The srcdoc iframe runs at an opaque
origin: no cookies, no storage, no same-origin fetch against the published site,
and no postMessage bridge from the parent page (sizing is explicit — height/aspect
slots — not script-driven). allow-same-origin is unrepresentable in the emitter
and CI-gated (sandbox-gate.test.ts). It contributes nothing to frame-src.
Publish analytics (write / query split)
Section titled “Publish analytics (write / query split)”- Write (edge, cookieless):
servePublishedAssetrecords a page view (target id, pathname, referrer origin, country) to the optionalPUBLISH_ANALYTICSAnalytics Engine binding — 200 HTML responses only (skips assets/301/302/404), wrapped in try/catch so it can never affect serving. - Query (API, auth’d):
GET /api/targets/:targetId/analytics?days=7|30aggregates the dataset via the Analytics Engine SQL API usingCF_ACCOUNT_ID(var) +CF_ANALYTICS_API_TOKEN(secret) — 503 when unconfigured, 502 on upstream failure,_sample_interval-weighted sums. Dashboard surface:TrafficCard.