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.

§2–§3 (auth + RLS) were rewritten 2026-09-13 to match the live Clerk B2B2C + Split-Glass system (landed 2026-07-03) — the previous Neon-Auth-era stale banner has been resolved into the body below.

A third deployment target sits outside this doc’s topology: apps/bondlayer-worker/, a Python Bondlayer→Matriz importer. Its live host is the matriz-import-runtime Cloudflare Container at apps/bondlayer-worker/edge/ — the D:importer-containers cutover flipped IMPORT_RUNTIME to "container" on skyfall-api 2026-09-02; the Render Docker service it used to run on is retired (decommission still an open owner action, see plans/OWNER-OPS.md) — see docs/dev/CLAUDE-REFERENCE.md §2 and 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.ts → workers/src/index.ts frontend/ (Vite build → static assets)
Routes served /api/*, /mcp/*, /published/* /* (static) + thin proxy at functions/api/[[path]].ts
Framework Hono router React 19 + React Router
State Stateless (each request is independent) + Durable Objects (IntentRoomDO) Browser Zustand stores
Auth Verifies Clerk session JWT from Authorization: Bearer header (@clerk/backend verifyToken) ClerkProvider mints the session token, lib/api/client.ts attaches it as Bearer
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

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.


2. End-to-End Auth + RLS Flow (Clerk B2B2C + Split-Glass, live since 2026-07-03)

Section titled “2. End-to-End Auth + RLS Flow (Clerk B2B2C + Split-Glass, live since 2026-07-03)”

Clerk is the identity provider. The Clerk organization IS the project — projects.id/users.id are Clerk text ids (org_…/user_…); a personal (no-org) session scopes to the user directly. The tenancy hierarchy is Split-Glass: projects (tenant boundary) → targets (deployable site, formerly sites). /api/webhooks/clerk (Svix-verified) syncs Clerk users/orgs/memberships into the local users/projects/project_users projections.

1. Frontend: ClerkProvider mints a session JWT
2. Frontend: fetch("/api/...", { headers: { Authorization: "Bearer <jwt>" } })
(attached by lib/api/client.ts)
3. Pages Function: proxy → Worker (no logic)
4. Worker: authMiddleware verifies the JWT via @clerk/backend verifyToken
→ sets c.var.userId (sub), c.var.orgId/orgRole (active-org claim),
c.var.jwtToken (raw JWT string)
5. Worker: projectAccessMiddleware resolves projectId/targetId + the caller's
effective ProjectRole:
- org-claim fast path: the target/project's owning project IS the
caller's active Clerk org → membership already proven, no DB join
- else: a project_users join (personal projects / a different
active org)
6. Worker: requireCapability(cap) / assertCapability (rbac.ts) gates mutating
requests on the resolved ProjectRole — see "Roles" below
7. Worker: getScopedDB(c) picks the DB connection:
- RLS_ENFORCED=true + the authenticated URL secret set → getTenantDB
(tenant role, JWT forwarded as authToken)
- else → getServiceDB (service/owner role, BYPASSRLS) — app-layer
middleware is the effective isolation until then
8. DB: if the tenant role, pg_session_jwt validates the Clerk JWT against the
Clerk JWKS registered on the Neon project, exposes auth.user_id()/
clerk_org_id() → RLS policies fire; cross-tenant rows are denied

Auth middleware (workers/src/middleware/auth.ts)

Section titled “Auth middleware (workers/src/middleware/auth.ts)”
  • Verifies the Clerk session JWT with @clerk/backend’s verifyToken (CLERK_SECRET_KEY).
  • Extracts sub → c.var.userId; email/name claims → c.var.userEmail/userName.
  • Extracts the active-org claim: v2 session tokens carry o.id/o.rol, v1 carries org_id/org_role — normalized to c.var.orgId/c.var.orgRole. orgId is null on a personal (no active org) session.
  • Stores the raw JWT as c.var.jwtToken, forwarded to Neon by getTenantDB if RLS is enforced.
  • DEV_BYPASS_AUTH=true injects a fixed dev user (user_dev_bypass, local dev only).

Project/Target access middleware (workers/src/middleware/project-access.ts)

Section titled “Project/Target access middleware (workers/src/middleware/project-access.ts)”

projectAccessMiddleware is the application-layer RLS — it runs after authMiddleware and resolves c.var.projectId / c.var.targetId / c.var.projectRole.

  • Extracts targetId/projectId from (in priority order): the route param, the target_id/project_id query string, a regex scan of the path (extractIdFromPath, for use('*', …) mounts that don’t bind named params), the JSON body on a create (POST/PATCH/PUT), or — for node-/style-scoped routes that name only a row id — the row’s own targetId column.
  • Org-claim fast path (D220): if the resolved target/project’s owning project equals the caller’s verified active-org claim (c.var.orgId), access is already proven — no project_users lookup. This matters beyond speed: the local membership projection can lag behind a webhook outage, and a handler-level recheck that only reads the projection would 403 a user the middleware just admitted.
  • Otherwise (personal project, or a different active org) falls back to a project_users join and 403s if no membership row exists.
  • Sets c.var.projectRole to the caller’s effective ProjectRole either way (see Roles below) — orgScopedRole/resolveEffectiveRole, not a raw DB value.
  • Returns 400 if neither a target nor project scope can be resolved at all — this scope param is mandatory, not just recommended: omitting it silently drops the request rather than falling back to some default tenant (the D50 bug class on the save-sink’s batch endpoint).

Roles: hybrid Clerk + app model (packages/schema/src/rbac.ts, EP-11)

Section titled “Roles: hybrid Clerk + app model (packages/schema/src/rbac.ts, EP-11)”

Five capability bundles, not a strict linear hierarchy (a content-editor can publish but not touch structure; an editor can touch structure but not publish):

Role Capabilities
viewer read only
content-editor content:write, publish
editor content:write, structure:write
publisher content:write, structure:write, publish, domains:manage, members:manage
admin everything above + admin (backup-restore, team/roles, keys, billing, delete)

The free Clerk plan only ships two built-in org roles (org:admin, org:member), so the four granular non-admin tiers can’t live in Clerk. resolveEffectiveRole(clerkOrgRole, storedRole) splits the source of truth: Clerk is authoritative for the admin bit (an org:admin claim always resolves to admin, and no app-stored value can grant or revoke it — fail-safe against a stale projection); otherwise the app-owned granular tier in project_users.role wins, capped at publisher only when Clerk actively claims a non-admin member. requireCapability(cap) (router-level middleware) and assertCapability (inline) enforce this — by default only on mutating methods (POST/PATCH/PUT/DELETE), so any member can read.

The separate, parallel member-auth system (Visitor Auth D73/VA-3)

Section titled “The separate, parallel member-auth system (Visitor Auth D73/VA-3)”

Not Clerk, and not part of the hierarchy above. A published target’s own end-users (a site’s readers/customers/portal users) authenticate through a first-party session system: MEMBER_SESSION_SIGNING_KEY signs an HS256 JWT stored in the __matriz_member HttpOnly cookie, verified by workers/src/middleware/visitor-auth.ts (visitorAuthMiddleware) — a non-blocking middleware that sets c.var.visitorId/c.var.memberId when a valid session cookie is present and lets anonymous requests through untouched. The edge gate inside servePublishedAsset calls the same verifier directly (not through this middleware) so a published page’s gated-content check and the API’s own /me-style routes never fork session semantics. See docs/architecture/TERMINOLOGY.md for member vs visitor vs Clerk user, and CLAUDE.md’s Gotchas section for the data-member-visible grammar.


Role Created by BYPASSRLS When used
neondb_owner / dev Neon project ✅ Local dev, migrations
service connection (getServiceDB) Same as owner ✅ Provisioning, publish, import, MCP, webhook sync
authenticator Neon RLS Authorize ❌ Connection role; the forwarded Clerk JWT is validated by pg_session_jwt
authenticated Neon RLS Authorize ❌ Session role after pg_session_jwt validates the Clerk JWT and exposes auth.user_id()/clerk_org_id()

Connection chooser (workers/src/db/client.ts)

Section titled “Connection chooser (workers/src/db/client.ts)”
// Use this in all routes behind authMiddleware + projectAccessMiddleware:
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, webhooks, 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 route’s behavior is identical; it only changes when the flag is flipped. RLS_ENFORCED is already "true" in workers/wrangler.jsonc’s vars — the remaining gate is whether NEON_AUTHENTICATED_DATABASE_URL is set on prod (getTenantDB falls back to the BYPASSRLS owner URL without it, making the flag inert); see the STATUS punchlist for that item’s current state.

db/migrations/20260703_clerk_rls_baseline.sql (2026-07-03) REPLACES the Neon-Auth-era policy set wholesale (the old 20260629_rls_neon_authorize.sql

  • 20260630_rls_legacy_remediation.sql pair, which targeted sites/org_members and are historical). It defines the Clerk claim helpers (clerk_user_id(), clerk_org_id(), is_project_member() = org-claim fast path OR the project_users projection) and the direct-string-compare policies Split-Glass needs (projects.id/users.id are Clerk text ids — no uuid casts, no per-request join for the org-claim path).

Roughly a dozen sidecar migrations layer RLS onto features added after the baseline, each scoped to its own tables: 20260709_target_domains_rls.sql, 20260710_forms_email_rls.sql, 20260713_marketing_rls.sql, 20260713_visitor_auth_rls.sql, 20260720_backups_rls.sql, 20260720_symbols_rls.sql, 20260721_audit_events_rls.sql, 20260727_font_metrics_rls.sql, 20260817_token_sets_rls_grants.sql, 20260822_optimizer_runs_rls.sql, 20260824_collection_access_rls.sql, 20260902_site_auth_providers_rls.sql.

See db/RLS-ACTIVATION.md for the cutover checklist and current activation state.


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 — no password; the Clerk JWT is forwarded as the driver authToken and validated by pg_session_jwt
CLERK_SECRET_KEY ✅ Verifies the Clerk session JWT (authMiddleware, @clerk/backend verifyToken)
CLERK_WEBHOOK_SECRET ✅ Svix signature verification for /api/webhooks/clerk
GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET optional (secrets) GitHub connector (D:github-connector, D:github-connector-full-scope, D:github-connector-pr-writer, docs/architecture/GITHUB-CONNECTOR.md), mounted at /api/github: App JWT signing, installation tokens (narrowed per call site), webhook HMAC (POST /api/github/webhook). The App has full repository write scope, so the private key is a write credential for every installed repo (rotation: the GitHub connector doc). Absent ⇒ the webhook / token-needing routes 503
GITHUB_STATE_SECRET optional (secret) Root of the install- and authorize-state signing keys (D:github-connector-pr-writer, githubStateSecret). Unset ⇒ falls back to GITHUB_WEBHOOK_SECRET, the old behaviour; setting it invalidates in-flight states once (10-15 min). Neither set ⇒ state routes 503
CLOCKWORK_ACCESS_CLIENT_ID, CLOCKWORK_ACCESS_CLIENT_SECRET (secrets), CLOCKWORK_API_BASE (var) optional GitHub connector alert sink (D:github-connector-pr-writer): a Cloudflare Access service token sent as CF-Access-Client-Id/CF-Access-Client-Secret to admin-shell POST /api/cards (base defaults to https://admin-shell.simaomartinez.workers.dev). Absent ⇒ no card; the audit_events alert row is still written (alerting fails open)
GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRET optional (secrets) GitHub App user authorization (D:github-connector-full-scope, routes/github-oauth.ts at /api/github): the server-side code exchange behind app.matriz.design/settings/github/authorize/callback. Absent (either, or the state root GITHUB_STATE_SECRET/GITHUB_WEBHOOK_SECRET) ⇒ those routes 503
AGENTE_MAQUINAS (var, names only) + per machine a Workers VPC binding AGENTE_<MACHINE> and a secret AGENTE_SEGREDO_<MACHINE> optional Server connector, read half (D:server-connector, lib/agente/, routes/servers.ts at /api/projects/:projectId/servers, admin only). The var is JSON {"<machine>":{"projectId","binding","segredo","nome"}} and holds no address or secret value; the secret is a Worker secret (wrangler secret put), never a plaintext var. Neither the var nor the bindings are declared in wrangler.jsonc until the owner switches a machine on (plans/MATRIZ-PLATFORM-PLAN.md §4.6). Absent var ⇒ every machine 404 and the list 503; absent binding or secret ⇒ that machine 503 {disabled:true} with no outbound call
RLS_ENFORCED optional Set to "true" to route getScopedDB calls through the tenant path (already "true" in workers/wrangler.jsonc; inert without NEON_AUTHENTICATED_DATABASE_URL)
DEV_BYPASS_AUTH dev only Set to "true" to skip JWT verification in local dev
R2_BUCKET / ASSETS_BUCKET ✅ R2 binding for asset storage

workers/src/middleware/types.ts is the authoritative Bindings shape. CLAUDE.md keeps a compact name → purpose → “absent ⇒” table; the full catalogue (member auth, sealed-box, analytics, ops door, importer dual-host, CF provisioning) is below.

Worker (wrangler secret put <NAME> --cwd workers — never plaintext vars, a var shadows a same-named secret):

  • NEON_DATABASE_URL (owner/service role), CLERK_SECRET_KEY, CLERK_WEBHOOK_SECRET (Svix), NEON_AUTHENTICATED_DATABASE_URL (RLS tenant path)
  • NEON_API_KEY + NEON_ORG_ID — OPTIONAL secrets for on-demand per-target Neon provisioning (workers/src/lib/neon-provision/, D:neon-on-demand, docs/architecture/NEON-PROVISIONING.md). NEON_API_KEY must be an organization-scoped key (never a personal key); absent ⇒ provisioning refuses and nothing is created. (NEON_API_KEY is shared with the older createNeonBranch control-plane path, which also needs NEON_PROJECT_ID.)
  • CLERK_JWT_KEY — OPTIONAL Clerk instance PEM public key (Dashboard → API keys → JWT public key). When set, authMiddleware passes it to verifyToken as jwtKey, so session-token verification makes no network call (no JWKS fetch on a cold isolate). Absent ⇒ verification uses the JWKS fetched with CLERK_SECRET_KEY, as before. Must be rotated together with the Clerk signing key, or every session token 401s.
  • CLERK_AUTHORIZED_PARTIES — OPTIONAL comma-separated origins (exact match, no wildcards) passed to Clerk verifyToken as authorizedParties, i.e. the session token’s azp must be listed (D:cors-origins-and-auth-hardening, parsed once in lib/clerk-verify-options.ts, used by authMiddleware, the intents WebSocket and /api/assets/serve; not MCP OAuth tokens). Unset ⇒ no azp check, exactly as before. Read real azp values from live session JWTs before setting it; a missing origin 401s that client (include https://app.matriz.design, the Pages preview hosts in use, http://localhost:3001).
  • MEMBER_SESSION_SIGNING_KEY — 256-bit HS256 key signing the first-party member session JWT (__matriz_member cookie, Visitor Auth D73/VA-3). Secret-gated: absent ⇒ session mint/verify disabled (redeem 503s, edge gate treats every request as anonymous). Set on prod skyfall-api.
  • MAIL_CREDENTIALS_KEY — base64 32-byte AES-256-GCM master key for the sealed-box (lib/sealed-box.ts). Seals BYO email provider keys (email-settings.ts) AND per-target Google OAuth client secrets for member sign-in (site_auth_providers, D:member-google-oidc). Secret-gated: absent ⇒ BYO email 503s, the sign-in-providers UI reports unavailable, /members/google/* 503s. Rotating it invalidates every sealed row of both kinds. It also signs the HMAC state for the Google round-trip together with MEMBER_SESSION_SIGNING_KEY (both must be set).
  • CF_ANALYTICS_API_TOKEN — Account Analytics:Read token for the analytics query route (GET /api/targets/:id/analytics); pairs with the CF_ACCOUNT_ID var; either absent ⇒ route 503s. Serving/writes unaffected.
  • OPS_ADMIN_TOKEN — shared-secret bearer for the operator cleanup door (/api/ops/..., routes/ops.ts): the chunked imported-target purge + orphan sweep, which a ~1-minute Clerk session token can’t drive from a script. Secret-gated: absent ⇒ the ops router 503s (the Clerk-authed copies under /api/projects are unaffected). Reaches those two routes and nothing else — don’t mount more on that router.
  • OPS_DATABASES (var, names only) + OPS_DSN_<KEY> (secret per database) + optional OPS_SQL_<KEY> (binding) — the opt-in, admin-only, read-only Postgres ops UI (/api/ops-databases, routes/ops-databases.ts, D:postgres-ops-ui). Each DSN is a read-only monitoring role (pg_monitor, default_transaction_read_only, no table grants), never the app’s own DSN. Absent ⇒ 503/404 with no outbound call. No Neon API key is read.
  • DEV_MCP_TOKEN — shared-secret bearer for the Matriz Developer MCP (/api/dev-mcp, routes/dev-mcp.ts, D:dev-mcp) — internal engineering/debugging tools, NOT a customer-facing surface: screenshot_ui/inspect_console drive Cloudflare Browser Rendering (BROWSER binding) against any *.matriz.design/*.pages.dev URL (never localhost — Browser Rendering runs in Cloudflare’s cloud), and read_dom/read_tokens/etc. mirror the external door’s matriz:site:read tools but take targetId per call instead of being pinned to one site. Secret-gated: absent ⇒ the dev-mcp router 503s. A SEPARATE secret from OPS_ADMIN_TOKEN (that token’s own doc says don’t mount anything else on its router) — no user, no tenant boundary, reaches every project by design; safe only because reachability is a shared secret the team holds, not Clerk/OAuth.
  • DEV_MCP_CLERK_USER_ID — Clerk user id the Dev MCP’s screenshot_ui/inspect_console sign in as (D:dev-mcp-signed-in-capture): each capture mints a 2-minute Clerk sign-in ticket via CLERK_SECRET_KEY, opens /sign-in?__clerk_ticket=…, captures the requested URL signed in, then signs the session out. Gives whoever holds DEV_MCP_TOKEN that user’s editor access through those two tools, so a dedicated capture user that belongs to a demo project only is recommended over a person’s own account (D:dev-mcp-capture-account, docs/intent-system/MCP-CONNECT-RUNBOOK.md §5a); the owner knowingly runs their own account today. Absent ⇒ captures are signed out and editor/CMS/dashboard URLs return auth_required. Works on app.matriz.design (the production Clerk key’s domain), not *.pages.dev.
  • SCAN_SERVICE_SECRET — shared HMAC secret with the ClamAV scan daemon on the Oracle box (D:asset-upload-scanning, infra/scan-service/). Signs every call to /api/internal/scan/* (routes/scan-internal.ts: download-url, object/:token/:key, verdict) and keys the 10-minute object download capability. The same value lives in the daemon’s root-only env file. Absent ⇒ those routes 503 (fail closed: uploads still land pending). Never a plaintext var.
  • binding ASSET_SCAN_QUEUE (Queue matriz-asset-scan, PRODUCER only — wrangler.jsonc queues.producers) — every upload door enqueues {v:1,key,size,sha256}; the consumer is the daemon’s HTTP pull, so there is no Worker consumer. The queue must exist before a deploy carrying the binding. Absent at runtime ⇒ rows stay pending, nothing refused.
  • PLATFORM_OPERATOR_CLERK_IDS — comma-separated Clerk user ids allowed on the shared-account control-plane routes (/api/control-plane: /mcp, POST /branch, DELETE /branch/:id, POST /pages, POST /pages/:project/domains, and A/AAAA/any-name /dns; D:control-plane-role-gate). Entries are trimmed, empty ones ignored, match is exact and case-sensitive against the verified Clerk user id (never a header/body/query), and the caller must ALSO hold the project admin role. Plain config, not a secret, but set it like one: wrangler secret put PLATFORM_OPERATOR_CLERK_IDS --cwd workers or the dashboard, never a plaintext wrangler.jsonc var (a var shadows a same-named secret). Absent/empty ⇒ nobody is an operator (those routes 403).
  • binding LOADER (worker_loaders, Cloudflare Dynamic Workers) + var SERVER_FUNCTIONS_ENABLED (+ MAIL_CREDENTIALS_KEY for sealed secrets) — server functions (D:server-functions). On only when the binding is bound AND the var is exactly "true"; otherwise deploy/seal/invoke 503 before any work. No Cloudflare API token or Workers for Platforms namespace. Owner steps: docs/architecture/SERVER-FUNCTIONS.md.
  • CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_ZONE_ID — live CF provisioning secrets, set via wrangler secret put --cwd workers (never a plaintext var — a var shadows the secret). Gate custom-domain TLS go-live (POST /api/targets/:id/domains/:did/verify, Cloudflare for SaaS) and other provisioning calls; CLOUDFLARE_ZONE_ID additionally needs the platform zone to have a fallback origin configured. Absent (either of the token/zone pair) ⇒ the TLS path is fully inert, zero network calls. Distinct from the same-named CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID GitHub Actions repo secrets, which only gate the deploy-*.yml workflows — two different credentials serving two different purposes despite the shared names.
  • Bondlayer importer runtime vars (D:importer-containers, workers/src/lib/import-runtime.ts is the sole reader): IMPORT_RUNTIME ("render" | "container"; live value is "container" since the 2026-09-02 cutover — absent/unknown still defaults to "render" for safety), IMPORT_CONTAINER_ORIGIN (the live matriz-import-runtime Worker’s base origin; container mode), IMPORT_WORKER_ORIGIN / BONDLAYER_MATERIALIZE_URL / BONDLAYER_TRIMSCAN_URL (Render base origin and its /materialize//trimscan URLs — now inert rollback values, kept only so flipping IMPORT_RUNTIME back to "render" still works; retiring them is a Pass F/decommission owner action, see plans/OWNER-OPS.md), IMPORT_WORKER_TOKEN (shared secret sent as X-Import-Worker-Auth/Authorization: Bearer to whichever host is active — same value on both hosts, one rotation; absent ⇒ the import-worker proxy route 503s). Importer-container-only vars (D:importer-callback-allowlist; set on the matriz-import-runtime container/Render, not on skyfall-api): IMPORT_CALLBACK_ORIGINS (comma/space-separated exact origins the importer may POST its finalize_url callbacks to; unset ⇒ https://api.matriz.design; must include MATRIZ_API_ORIGIN if that is set differently), IMPORT_CALLBACK_ALLOW_LOCALHOST=1 (dev only: permits http://localhost/127.0.0.1 entries).
  • Motion Studio vars (D:motion-render-jobs, D:motion-studio; typed in workers/src/middleware/types.ts): MOTION_RENDER_RUNTIME ("off" | "container", plaintext var in workers/wrangler.jsonc, live value "off": video jobs stay queued; stills are unaffected), MOTION_RENDER_ORIGIN (plaintext var, base origin of the matriz-motion-render edge Worker in apps/motion-render/edge/, read only when the runtime is "container"; unset today), MOTION_RENDER_TOKEN (secret on BOTH Workers, same value: wrangler secret put MOTION_RENDER_TOKEN --cwd workers and --cwd apps/motion-render/edge; the bearer on dispatch and the root of the per-job callback HMAC; never a vars entry), MOTION_DRAFTS ("on" opens POST /api/motion/drafts, plaintext var, live value "off"). Motion also derives its download/confirm signing key from CLERK_SECRET_KEY (no new secret; rotating Clerk invalidates outstanding Motion URLs).
  • vars in workers/wrangler.jsonc: DEV_BYPASS_AUTH (local only), RLS_ENFORCED, PUBLISH_ROOT_DOMAIN (unset/"" ⇒ legacy /published/:siteId path serves byte-for-byte; set ⇒ path route 301s to the target’s canonical host), CF_ACCOUNT_ID. Optional binding PUBLISH_ANALYTICS (Analytics Engine — cookieless page-view writes; absent ⇒ no-op).

.env.example predates the Clerk pivot — trust this list and workers/src/middleware/types.ts over it.

Variable Required Purpose
VITE_CLERK_PUBLISHABLE_KEY ✅ Clerk publishable key — main.tsx throws without it
VITE_WORKERS_ORIGIN optional Override the Worker origin (e.g. http://localhost:8787); empty = same-origin via the Pages proxy

Standard target-scoped route (most routes)

Section titled “Standard target-scoped route (most routes)”
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 { projectAccessMiddleware } from '../middleware/project-access'
import { requireCapability } from '../middleware/rbac'
import { myTable } from '../db/schema'
export const myResourceRouter = new Hono<AuthEnv>()
// Apply auth + project-access guards to ALL routes in this router, then a
// capability gate for mutating methods (see rbac.ts / ARCHITECTURE.md §2 Roles).
myResourceRouter.use('*', authMiddleware)
myResourceRouter.use('*', projectAccessMiddleware)
myResourceRouter.use('*', requireCapability('structure:write'))
myResourceRouter.get('/targets/:targetId/my-resource', async (c) => {
const db = getScopedDB(c) // RLS-scoped when enforced, service otherwise
const targetId = c.get('targetId') // set by projectAccessMiddleware
const rows = await db.select().from(myTable).where(eq(myTable.targetId, targetId))
return c.json({ data: rows })
})

Mount in workers/src/index.ts:

app.route('/api', myResourceRouter)

If your route path includes /targets/:targetId/, or the request carries ?target_id=…/?project_id=… or the tenant column in a JSON body, projectAccessMiddleware’s fallbacks (extractIdFromPath + the body/row resolvers) will find the scope even in a use('*', …) mount. The scope param is mandatory — an unscoped request 400s rather than falling back to a default tenant (the D50 bug class).

Public collector route (POST /api/csp-report, D:csp-report-endpoint)

Section titled “Public collector route (POST /api/csp-report, D:csp-report-endpoint)”

routes/csp-report.ts: unauthenticated, no DB/secrets/migration, always 204. Accepts legacy application/csp-report and Reporting API application/reports+json, caps the body at 16 KB (readBodyCapped), rate limits 30/min/IP (lib/rate-limit.ts over the CONFIG KV, in-isolate fallback without it) and writes one reduced console.log JSON line per report to Workers observability (event: "csp_report"; URLs reduced to scheme+host / origin+path, never queries, referrers or samples). It is mounted in index.ts BEFORE the global corsPolicy() because it carries its own editor-origins-only CORS. Nothing sends reports to it yet (frontend/public/_headers has no report-uri/report-to).

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 project+target atomically — no targetId exists yet,
// projectAccessMiddleware 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 :targetId in the URL)

Section titled “Route that resolves a row by its own ID (not by :targetId in the URL)”

Use userCanAccessTarget after fetching the row:

import { userCanAccessTarget } from '../middleware/project-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 userCanAccessTarget(c.env, c.get('userId'), row.targetId, c.get('orgId')))) {
return c.json({ error: 'Access denied' }, 403)
}

DO Binding Purpose
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. (embed/custom_code nodes: the live editor canvas actually renders them, not an inert placeholder — embeds mount their real iframe on the canvas always, and custom_code is click-to-run in edit mode (a Run button mounts the sandboxed iframe on demand) and live in interactive/preview mode. The iframe markup is produced by the same @matriz/site-render code path used at publish — see CLAUDE.md’s Gotchas section for the full detail and file references.)

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.

PUBLISH_ROOT_DOMAIN is also the free-subdomain allocation root (D:platform-root-split). Every target’s free <slug>.<root> subdomain is allocated on PUBLISH_ROOT_DOMAIN (matriz.page — allocateSubdomainHost in routes/targets.ts), NOT on PLATFORM_ROOT_DOMAIN (matriz.design, where app./api. live) — allocating on the platform’s own zone would put every free tenant site in the editor/API’s cookie space and defeat isolation entirely (the bug this decision fixes). PLATFORM_ROOT_DOMAIN keeps a single, narrower job: isPlatformHost’s skip check (the platform apex + reserved first-labels never resolve as a tenant). Pre-split target_domains rows still on matriz.design were left as-is, not migrated (nothing published was meaningful at the time) — they keep resolving exactly as before, just without the isolation benefit.

What a published host may reach (D:published-host-assets). A resolved host serves published bytes and short-circuits before the /api surface (D72). Three prefixes are exempt, and each is scoped to this host’s own target — none of them can address another one:

Prefix Why it must be reachable Scoped by
/api/m/* public module ingest (forms, member session, member-gated assets) modulePublicMiddleware — path :targetId must equal resolvedTargetId
/api/asset-pub/* every public page’s <img src> is rewritten to this at publish (classifyDocAssets) not routed at all — short-circuits to servePublicAsset(…, { scopeTargetId }), which joins the asset to that target’s project
/published-query/* A-tier live collection lists the route re-derives :targetId and refuses a resolvedTargetId mismatch

Everything else on a published host — including /cdn/* and /api/cdn/*, which the renderer never emits — stays short-circuited to published bytes.

Workers Logs keeps every request’s URL and every console.* line, so a credential in a query string is a credential in the logs. Three rules:

  • Member magic links / invites are …/api/m/:targetId/members/session#token=<raw>. The fragment never leaves the browser. GET /members/session (no query) serves a tiny interstitial (routes/modules/members-session.ts) whose one inline script is pinned by sha256 in its own CSP (script-src 'sha256-…', default-src 'none', form-action 'self'); it strips the fragment from history and POSTs the token as a form body to POST /members/session — the atomic burn + 10-min/7-day expiry are unchanged. A cross-site POST (Sec-Fetch-Site: cross-site) is refused (login CSRF). New tokens carry the f. prefix; one presented in a query string is treated as leaked: burned, nobody signed in. Pre-change unprefixed ?token= links still redeem (and redirect at once, no-store + no-referrer) until LEGACY_QUERY_TOKEN_SUNSET.
  • The intents WebSocket (/api/intents/ws/:projectId) carries no credential. The editor POSTs /api/intents/ws/:projectId/ticket with its normal Bearer header (the Worker checks membership), then sends {"type":"auth","ticket":…} as the socket’s first frame. IntentRoomDO keeps the socket PENDING (no pokes, outside the room cap, own cap of 20 with oldest-eviction) until the single-use, 30-second ticket (only its hash stored) is burned; otherwise it closes 4401 within 10 s. Legacy ?token=<JWT> upgrades are verified at the Worker until LEGACY_WS_QUERY_TOKEN_SUNSET.
  • Logging: the request logger is logger(redactingPrint) and any URL a handler logs goes through redactUrl (lib/log-redact.ts, allow-list of id/paging keys; every other query value becomes REDACTED). log-url-redaction-scan.test.ts fails the build on a console.* call that logs a raw request URL.

Internal links follow the door (D:publish-base-path). A published page’s own links are resolved against resolvePublishBase(env, targetId, url).pathPrefix — the same base the sitemap, feeds, OG image and JSON-LD absolute URLs use. A target with a canonical host bakes bare paths (/about); one without bakes /published/<targetId>/about, because on the legacy door a bare /about resolves against the API origin’s root, which is not the site. Threaded as SiteRenderContext.basePath → serializeNodeTree({ basePath }) → resolveNodeLink, and applied to root-relative hrefs only. Two consequences: a doc published before the target had a host is self-healed by the host door, which strips a leading /published/<targetId> and 301s to the clean URL; and the serve route’s own site-path Location headers (EP-3 redirect rules, the members-only sign-in 302) carry the serving prefix, passed in rather than inferred.

Multi-locale publish + language switcher (D:multi-locale-publish)

Section titled “Multi-locale publish + language switcher (D:multi-locale-publish)”

A target with N supported_languages publishes every page N times — publishLocales(default_language, supported_languages) (@matriz/site-render, default first, de-duplicated) drives a locale loop inside routes/publish.ts’s writePage closure (and, serialization-only, the print/raster branch): for each locale, expandSubtree re-resolves _language conditionals and localized CMS text against THAT locale (ExpandOptions.locale doubles as the active gating locale, per D:language-conditionals), and renderPageDocument renders with lang: <locale>. A single-language target runs this loop once and is byte-identical to before the feature existed.

URL scheme. localizedPagePath (packages/site-render/src/locale-routing.ts) is the ONE function the publish keys, the sitemap entries, the hreflang block and the language switcher’s hrefs all call — they cannot disagree. The default locale keeps its address (/about); every other locale is prefixed with its lowercased tag (/en/about, root → /en). R2 keys mirror the path, so the serve route needs no locale-aware routing; its only change is the not-found fallback, which writes (and looks up) a PER-LOCALE 404.html (<seg>/404.html for a non-default locale) when the miss path’s first segment is shaped like a language tag (looksLikeLocaleSegment).

The switcher is links, not a runtime. element_type: 'language_switcher' (a LEAF_ELEMENTS entry, @matriz/site-render) renders one <a hreflang lang href> per published locale at render time — zero JS, so the published CSP invariant (script-src forever 'none'|hashes) is untouched. A node carrying data-matriz-lang="<lang>" gets the same href-rewrite treatment individually (the authored-toggle path Bondlayer imports use — see below); either way the current locale’s link is marked aria-current="page" + current state-current. Every multi-locale document also carries <link rel="alternate" hreflang="…"> for every locale plus x-default (buildAlternateLinks).

Scope of this iteration, stated plainly: OG cards, RSS/JSON feeds and the B+ archive index are computed once (against the default-locale render) and reused for every locale’s document — duplicating them per locale was judged not worth the extra publish-time CPU/screenshot budget yet. A print/raster PUBLICATION’s page-box SEQUENCE (record count, N-up chunking) is likewise decided once; only its serialization (text, <html lang>) repeats per locale, so a _language rule INSIDE a print box does not yet re-gate per locale. The canvas now mounts the switcher’s generated markup too (language_switcher joined the isEmbeddedNodeType predicate + mountEmbeddedHtml in preview.html/canvas-embed-render.ts, same shape as countdown), but no locale list is threaded through that call path yet, so a canvas preview shows the serializer’s single-current-link fallback rather than every locale.

Importer. Bondlayer’s PT/EN toggle is not a trigger — it’s a plain link (link_type: "page" + link_locale: "<lang>", no link_page), mapped by _bondlayer_language_toggle_lang (apps/bondlayer-worker/src/element_mapper.py) to data-matriz-lang on the node. The wrapper’s own _language conditional (the ON/OFF layout — a tick when current, a link when not) is left untouched; it now gates correctly per rendered locale because of the loop above.

element_type: 'section_nav' is a leaf generated at render time like the language switcher: the serializer pre-pass (planSectionNav) lists the page’s headings with stable ids as crawlable anchors, and a marker-gated ninth module of the one hash-pinned interactions runtime adds the scrollspy — no new script or origin. Contract, ids, layouts, limits: docs/architecture/SECTION-NAV.md.

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.

8. Live time, schedules and symbols (D:recurring-structures, waves 0–1)

Section titled “8. Live time, schedules and symbols (D:recurring-structures, waves 0–1)”

A date-driven condition — “hide until March 1,” “show only during the current edition’s late period” — is a temporal rule. Unlike every other publish-time condition, a temporal rule’s answer keeps changing after publish. The design (D:live-time) keeps enforcement server-side forever and moves only visibility to a client clock:

Publish (buildNow) Serve (published page)
binding-engine.ts resolves the runtime fetches GET /api/m/:targetId/time
rule once at the build clock once, computes skew vs. the visitor's
→ the no-JS default AND the local clock, derives liveNow()
encoded rule both go into the → settleWhen() re-evaluates the SAME
document encoded rule against liveNow(), flips
`hidden`, and re-arms a timer for the
next boundary (max 24h out)
  • Compiling a rule. classifyVisibilityRule (member-visibility.ts) recognizes a fourth kind, temporal, alongside the existing audience/field/owner kinds: date_eq | before | after | between against @now, a relative token, or @current.<symbol>[.period…]. At publish, resolveNodeBindings in binding-engine.ts turns a temporal rule into a { kind:'when-marker', encoded, hiddenDefault } payload, hiddenDefault being the rule evaluated at buildNow (publish.ts threads buildNow down through page-render.ts the same way it already threads the audience build clock) — so a visitor with JavaScript disabled sees exactly what was true when the page was last published, never a broken or empty state.
  • Settling a rule. The published runtime (packages/site-render/src/ interactions-runtime.ts) fetches /time once per load, computes a clock skew against it, and evaluates every encoded rule via prepWhenEls/settleWhen (collection-filter-runtime.ts) — the identical evaluator the publish step used, ported as a hand string (the CSP invariant below forbids importing the real module into the runtime bundle). A single timer re-arms for the nearest rule or period boundary on the page (capped at 24h out) so an open tab flips state exactly when a deadline passes, with no reload. A rule referencing @current.<symbol> triggers one lazy /symbols fetch; a page with no such reference never calls it.
  • Deferred pre-filters. A relative-date or @current.* pre-filter on a repeater or table (as opposed to a visibility rule) is split at publish by partitionPreFilters (collection-query.ts) into a static half (applied at build time as always) and a deferred half, applied client-side by applyWhenFilter. This only happens under a row ceiling, WHEN_DEFER_MAX_ROWS = 500 (workers/src/lib/repeater-expand.ts) — above it the list falls back to a static publish-time snapshot with a dev-time warning, because settling thousands of rows’ worth of date comparisons in every visitor’s browser on every load is not a trade this codebase will make silently. The intended shape for a large recurring list is a symbol-filtered list (edition = @current.edition, one static value resolved at publish) or a per-item detail page, not a deferred filter over everything.
  • What never moves to the client. Form submission gates, the member edit gate, derived-field stamping, symbol resolution for any write, members_only/ draft gating, and row moderation are resolved fresh on every request against Date.now() on the Worker. The browser clock only ever decides what is shown; nothing it computes is trusted for what is allowed.

/time and /symbols (public, unauthenticated)

Section titled “/time and /symbols (public, unauthenticated)”

Both are mounted at /api/m/:targetId/* (the same public module namespace forms ingest and member sessions use — modulePublicMiddleware resolves targetId/ projectId and enforces the custom-host carve-out before either handler runs) in workers/src/routes/modules/time.ts:

  • GET /time → { now: <epoch ms> }.
  • GET /symbols → { [name]: { id, slug, title, start, end, period } | null } — an allow-list echo, never a passthrough of the resolved item’s full data bag (which may hold private author fields). Enforcement always re-resolves the symbol itself, server-side; this route is display-only for the runtime.

Both responses are Cache-Control: no-store (a cached clock or a cached “current period” is a correctness bug, not a performance win) and KV rate-limited at 60/min/IP, matching the forms ingest route — the limiter no-ops when the CONFIG KV binding is absent (tests, unprovisioned envs) rather than failing closed.

Schedules and symbols underneath the routes

Section titled “Schedules and symbols underneath the routes”

A schedule (packages/site-render/src/schedule.ts) is an ordered, non-overlapping list of Period { key, label, start, end, gates, data } on any collection item’s field value; currentPeriod(schedule, now) is the ONE resolver the Worker (form gates, the member edit gate, symbol resolution), the publish-time binding engine, and the CMS editor all import — there is no second implementation. A site symbol (SymbolDef on targets.settings.symbols, resolved by workers/src/lib/symbols.ts’s resolveSymbol) names a dated pointer into a collection — mode:'window' picks the published, current item whose configured start/end fields bracket now (with an optional fallback:'latest'), mode:'pinned' names a fixed item — and, when the definition names a scheduleField, also carries that item’s current period. This symbols concept is unrelated to the pre-existing symbols DB table (D:symbols-v1, per-target user-created reusable components) — same word, two different features that happen to coexist; see the terminology note in docs/architecture/ TERMINOLOGY.md before assuming either name disambiguates the other in code.

Temporal rules and the table element each get their own marker namespace in packages/site-render/src/collection-marker-grammar.ts, alongside the existing MF_AUDIENCE/MF families, and both are declared in the orphan-marker sweep (collection-marker-grammar.test.ts) the same way every marker family is — undeclared markers fail CI, not just a lint:

  • MF_WHEN — rules (data-mf-when, the encoded rule payload on any element), whenState (data-mf-when-state, pending|live, a theming hook mirroring MF_AUDIENCE’s audienceState), filter/limit (data-mf-when-filter/data-mf-when-limit, the deferred pre-filter payload and its row ceiling on a repeater’s list root).
  • MF_TABLE — markers on a table element’s rendered rows (item-row identity for client-side identity-column fill and export paging), its identity-column cells, its sortable headers, and its export buttons. A table’s own root and body reuse the repeater’s existing MF.list/MF.items wrapper markers rather than inventing new ones — an author’s existing filter/sort controls work against a table with zero runtime changes.

workers/drizzle/0040_tired_darkhawk.sql is the one schema change this whole plan needed: four new forms columns (opens_at, closes_at, closed_message, derive jsonb not null default '[]'). Everything else — schedules, site symbols, rollups, tables, upsert scope — lives in existing jsonb columns (content_items. data, targets.settings, field/element config) or an existing indexed column (content_items.quota_key, reused as the upsert-scope identity via its partial unique index). No new table.