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.§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 thematriz-import-runtimeCloudflare Container atapps/bondlayer-worker/edge/— theD:importer-containerscutover flippedIMPORT_RUNTIMEto"container"onskyfall-api2026-09-02; the Render Docker service it used to run on is retired (decommission still an open owner action, seeplans/OWNER-OPS.md) — seedocs/dev/CLAUDE-REFERENCE.md§2 anddocs/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/* |
/* (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) |
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 PostgresThe 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.
Request lifecycle
Section titled “Request lifecycle”1. Frontend: ClerkProvider mints a session JWT2. 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" below7. 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 then8. 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 deniedAuth middleware (workers/src/middleware/auth.ts)
Section titled “Auth middleware (workers/src/middleware/auth.ts)”- Verifies the Clerk session JWT with
@clerk/backend’sverifyToken(CLERK_SECRET_KEY). - Extracts
sub→c.var.userId;email/nameclaims →c.var.userEmail/userName. - Extracts the active-org claim: v2 session tokens carry
o.id/o.rol, v1 carriesorg_id/org_role— normalized toc.var.orgId/c.var.orgRole.orgIdis null on a personal (no active org) session. - Stores the raw JWT as
c.var.jwtToken, forwarded to Neon bygetTenantDBif RLS is enforced. DEV_BYPASS_AUTH=trueinjects 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/projectIdfrom (in priority order): the route param, thetarget_id/project_idquery string, a regex scan of the path (extractIdFromPath, foruse('*', …)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 owntargetIdcolumn. - 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 — noproject_userslookup. 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_usersjoin and 403s if no membership row exists. - Sets
c.var.projectRoleto the caller’s effectiveProjectRoleeither way (see Roles below) —orgScopedRole/resolveEffectiveRole, not a raw DB value. - Returns
400if 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.
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, 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'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 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.
RLS policies
Section titled “RLS policies”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.sqlpair, which targetedsites/org_membersand are historical). It defines the Clerk claim helpers (clerk_user_id(),clerk_org_id(),is_project_member()= org-claim fast path OR theproject_usersprojection) and the direct-string-compare policies Split-Glass needs (projects.id/users.idare 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.
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 — 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.
Full Worker secret / var catalogue
Section titled “Full Worker secret / var catalogue”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_KEYmust be an organization-scoped key (never a personal key); absent ⇒ provisioning refuses and nothing is created. (NEON_API_KEYis shared with the oldercreateNeonBranchcontrol-plane path, which also needsNEON_PROJECT_ID.)CLERK_JWT_KEY— OPTIONAL Clerk instance PEM public key (Dashboard → API keys → JWT public key). When set,authMiddlewarepasses it toverifyTokenasjwtKey, so session-token verification makes no network call (no JWKS fetch on a cold isolate). Absent ⇒ verification uses the JWKS fetched withCLERK_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 ClerkverifyTokenasauthorizedParties, i.e. the session token’sazpmust be listed (D:cors-origins-and-auth-hardening, parsed once inlib/clerk-verify-options.ts, used byauthMiddleware, the intents WebSocket and/api/assets/serve; not MCP OAuth tokens). Unset ⇒ noazpcheck, exactly as before. Read realazpvalues from live session JWTs before setting it; a missing origin 401s that client (includehttps://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_membercookie, Visitor Auth D73/VA-3). Secret-gated: absent ⇒ session mint/verify disabled (redeem 503s, edge gate treats every request as anonymous). Set on prodskyfall-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 HMACstatefor the Google round-trip together withMEMBER_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 theCF_ACCOUNT_IDvar; 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/projectsare 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) + optionalOPS_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_consoledrive Cloudflare Browser Rendering (BROWSERbinding) against any*.matriz.design/*.pages.devURL (neverlocalhost— Browser Rendering runs in Cloudflare’s cloud), andread_dom/read_tokens/etc. mirror the external door’smatriz:site:readtools but taketargetIdper call instead of being pinned to one site. Secret-gated: absent ⇒ the dev-mcp router 503s. A SEPARATE secret fromOPS_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’sscreenshot_ui/inspect_consolesign in as (D:dev-mcp-signed-in-capture): each capture mints a 2-minute Clerk sign-in ticket viaCLERK_SECRET_KEY, opens/sign-in?__clerk_ticket=…, captures the requested URL signed in, then signs the session out. Gives whoever holdsDEV_MCP_TOKENthat 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 returnauth_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 landpending). Never a plaintext var.- binding
ASSET_SCAN_QUEUE(Queuematriz-asset-scan, PRODUCER only —wrangler.jsoncqueues.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 staypending, 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 projectadminrole. Plain config, not a secret, but set it like one:wrangler secret put PLATFORM_OPERATOR_CLERK_IDS --cwd workersor the dashboard, never a plaintextwrangler.jsoncvar (a var shadows a same-named secret). Absent/empty ⇒ nobody is an operator (those routes 403).- binding
LOADER(worker_loaders, Cloudflare Dynamic Workers) + varSERVER_FUNCTIONS_ENABLED(+MAIL_CREDENTIALS_KEYfor 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 viawrangler 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_IDadditionally 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-namedCLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_IDGitHub Actions repo secrets, which only gate thedeploy-*.ymlworkflows — two different credentials serving two different purposes despite the shared names.- Bondlayer importer runtime vars (
D:importer-containers,workers/src/lib/import-runtime.tsis 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 livematriz-import-runtimeWorker’s base origin; container mode),IMPORT_WORKER_ORIGIN/BONDLAYER_MATERIALIZE_URL/BONDLAYER_TRIMSCAN_URL(Render base origin and its/materialize//trimscanURLs — now inert rollback values, kept only so flippingIMPORT_RUNTIMEback to"render"still works; retiring them is a Pass F/decommission owner action, seeplans/OWNER-OPS.md),IMPORT_WORKER_TOKEN(shared secret sent asX-Import-Worker-Auth/Authorization: Bearerto 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 thematriz-import-runtimecontainer/Render, not onskyfall-api):IMPORT_CALLBACK_ORIGINS(comma/space-separated exact origins the importer may POST itsfinalize_urlcallbacks to; unset ⇒https://api.matriz.design; must includeMATRIZ_API_ORIGINif that is set differently),IMPORT_CALLBACK_ALLOW_LOCALHOST=1(dev only: permitshttp://localhost/127.0.0.1entries). - Motion Studio vars (
D:motion-render-jobs,D:motion-studio; typed inworkers/src/middleware/types.ts):MOTION_RENDER_RUNTIME("off"|"container", plaintext var inworkers/wrangler.jsonc, live value"off": video jobs stayqueued; stills are unaffected),MOTION_RENDER_ORIGIN(plaintext var, base origin of thematriz-motion-renderedge Worker inapps/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 workersand--cwd apps/motion-render/edge; the bearer on dispatch and the root of the per-job callback HMAC; never avarsentry),MOTION_DRAFTS("on"opensPOST /api/motion/drafts, plaintext var, live value"off"). Motion also derives its download/confirm signing key fromCLERK_SECRET_KEY(no new secret; rotating Clerk invalidates outstanding Motion URLs). varsinworkers/wrangler.jsonc:DEV_BYPASS_AUTH(local only),RLS_ENFORCED,PUBLISH_ROOT_DOMAIN(unset/""⇒ legacy/published/:siteIdpath serves byte-for-byte; set ⇒ path route 301s to the target’s canonical host),CF_ACCOUNT_ID. Optional bindingPUBLISH_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.
Frontend (frontend/.env.local)
Section titled “Frontend (frontend/.env.local)”| 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 |
5. How to Add New Routes
Section titled “5. How to Add New Routes”Standard target-scoped route (most routes)
Section titled “Standard target-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 { 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 ause('*', …)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)}6. Durable Objects
Section titled “6. Durable Objects”| DO | Binding | Purpose |
|---|---|---|
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. (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.)
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.
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.
No secret in a URL (D:token-free-urls)
Section titled “No secret in a URL (D:token-free-urls)”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 toPOST /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 thef.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) untilLEGACY_QUERY_TOKEN_SUNSET. - The intents WebSocket (
/api/intents/ws/:projectId) carries no credential. The editor POSTs/api/intents/ws/:projectId/ticketwith its normal Bearer header (the Worker checks membership), then sends{"type":"auth","ticket":…}as the socket’s first frame.IntentRoomDOkeeps 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 closes4401within 10 s. Legacy?token=<JWT>upgrades are verified at the Worker untilLEGACY_WS_QUERY_TOKEN_SUNSET. - Logging: the request logger is
logger(redactingPrint)and any URL a handler logs goes throughredactUrl(lib/log-redact.ts, allow-list of id/paging keys; every other query value becomesREDACTED).log-url-redaction-scan.test.tsfails the build on aconsole.*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.
Section nav (D:section-nav)
Section titled “Section nav (D:section-nav)”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.
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.
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 | betweenagainst@now, a relative token, or@current.<symbol>[.period…]. At publish,resolveNodeBindingsinbinding-engine.tsturns a temporal rule into a{ kind:'when-marker', encoded, hiddenDefault }payload,hiddenDefaultbeing the rule evaluated atbuildNow(publish.tsthreadsbuildNowdown throughpage-render.tsthe 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/timeonce per load, computes a clock skew against it, and evaluates every encoded rule viaprepWhenEls/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/symbolsfetch; 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 bypartitionPreFilters(collection-query.ts) into a static half (applied at build time as always) and a deferred half, applied client-side byapplyWhenFilter. 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 againstDate.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 fulldatabag (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.
Marker namespaces
Section titled “Marker namespaces”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 mirroringMF_AUDIENCE’saudienceState),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 atableelement’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 existingMF.list/MF.itemswrapper markers rather than inventing new ones — an author’s existing filter/sort controls work against a table with zero runtime changes.
Migration
Section titled “Migration”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.