Skip to content

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/, treat docs/architecture/* as authoritative. For terminology see docs/architecture/TERMINOLOGY.md.
For current feature status see STATUS.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) Clerkworkers/src/middleware/auth.ts uses @clerk/backend verifyToken
workers/src/middleware/site-access.ts (§2) File does not exist — it is project-access.ts; the middleware dir is auth.ts, project-access.ts, target-resolution.ts, rbac.ts, module-public.ts, visitor-auth.ts, types.ts
sites is the tenant boundary projects is (Clerk org = project); sitestargets

Until 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. See docs/architecture/BONDLAYER-IMPORT-PIPELINE.md.


Cloudflare Workers (skyfall-api) Cloudflare Pages (skyfall-b5u.pages.dev)
Role API backend Frontend SPA host
Entry point workers/src/worker.tsworkers/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)
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 → SiteDurableObject

The 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.


1. Frontend: authClient.token() → fresh EdDSA JWT (15-min expiry)
2. Frontend: fetch("/api/...", { headers: { Authorization: "******" } })
3. Pages Function: proxy → Worker
4. 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.siteId
6. 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 level

Auth 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_URL env var).
  • Extracts sub claim → stored as c.var.userId.
  • Stores raw JWT string as c.var.jwtToken for use by getScopedDB.
  • DEV_BYPASS_AUTH=true injects 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 authMiddleware on all site-scoped routes.
  • Extracts siteId from (in priority order):
    1. c.req.param('siteId') — populated when middleware pattern includes :siteId
    2. c.req.query('site_id') — query-string fallback (/api/nodes?site_id=…)
    3. extractSiteIdFromPath(c.req.path) — regex fallback for use('*', …) mounts where Hono does not populate named params in the middleware context
  • Verifies org membership via a JOIN: sites → org_members WHERE userId.
  • Returns 400 if no siteId found, 403 if user is not a member.
  • Sets c.var.siteId for downstream handlers.

Why the path-regex fallback?
sitePagesRouter and cmsWorkflowRouter register use('*', siteAccessMiddleware). In Hono, a use('*', handler) middleware does not receive the path params from the concrete route pattern (e.g. /sites/:siteId/site-pages). The regex scan of c.req.path is the deterministic extraction strategy for this case.


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' and c.var.jwtToken is set → getTenantDB(env, jwt) (connects as authenticator; pg_session_jwt switches session to authenticated)
  • 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.

See db/RLS-ACTIVATION.md for the full cutover checklist. The RLS policies are in db/migrations/20260629_rls_neon_authorize.sql.


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
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)

workers/src/routes/my-resource.ts
import { Hono } from 'hono'
import { eq } from 'drizzle-orm'
import { getScopedDB } from '../db/client' // ✅ always use this
import { 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 router
myResourceRouter.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/, the extractSiteIdFromPath fallback in siteAccessMiddleware will extract the siteId even in use('*', …) 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)
}

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.

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 CSP

The 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.)

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 from resolveCanonicalHost(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.

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.

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.

  • Write (edge, cookieless): servePublishedAsset records a page view (target id, pathname, referrer origin, country) to the optional PUBLISH_ANALYTICS Analytics 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|30 aggregates the dataset via the Analytics Engine SQL API using CF_ACCOUNT_ID (var) + CF_ANALYTICS_API_TOKEN (secret) — 503 when unconfigured, 502 on upstream failure, _sample_interval-weighted sums. Dashboard surface: TrafficCard.