Skip to content

Architecture reference

Everything the platform promises, and its price

This page is the honest version of the architecture: what each subsystem guarantees, what it explicitly does not, and which constraint forced the design. Where a limit exists it is named with its number.

Tenancy & roles

One platform, many academies, one identity. An academy is a row plus a hostname, not a deployment — ai.metax.academy and data.metax.academy are the same Worker reading a different tenant row. Adding an academy is an insert and a DNS record; it is never a redeploy.

The role ladder

Roles are integers, not strings, because every authorisation question is then a comparison rather than a set membership test — and because a compact numeric ACL fits inside a sealed cookie.

Capability floors

Capabilities are what code actually checks. A role satisfies a capability when its level meets the floor.

CapabilityFloorLowest role that clears it

Page grants: the escape hatch

Real organisations have a person who must edit exactly one page and nothing else. Modelling that with roles produces role inflation, so page_grants lifts a named person above their role for one page or one section, with an optional expiry. A member holding a section-scoped publish grant can publish that section and nothing else — and the editing overlay shows them a per-section chip so the difference is visible rather than surprising.

The ACL carried in the access cookie is capped at eight academies. Beyond that the Worker falls back to a database read — a deliberate trade of a rare extra query against an unbounded cookie.

The mode engine

There is exactly one operational switch on this platform: the vps_url field of the cfg:core object in KV. Empty string means the platform runs whole on Cloudflare. Every other mode is derived from that field plus observed health — never set by hand.

function derive(cfg) {
  if (!cfg.vps_url) return 'edge';              // full Cloudflare operation
  if (cfg.freeze_until > now()) return 'freeze';  // cutover in progress
  if (!cfg.handover.finished_at) return 'mirror'; // D1 true, MySQL warming
  return cfg.vps_fail_streak >= 3
    ? 'degraded'                                // origin unreachable
    : 'origin';                                 // MySQL is the record
}
Reads and writes by mode. Note that identity is unaffected in every row — that is invariant one.
ModeSystem of recordWritesMoneyLogin
edgeD1D1, immediateAcceptedWorks
mirrorD1D1 + outbox to MySQLAcceptedWorks
freezeNeither, brieflyRefused, not queuedRefused clearlyWorks
originMySQLMySQL, immediateAcceptedWorks
degradedMySQL (unreachable)Queued, marked queuedRefused clearlyWorks

Propagation is about a minute, and the UI must say so

A mode change is not instant. Worker isolates cache the config for fifteen seconds and KV is eventually consistent, so allow roughly one minute worldwide. The console states this rather than spinning a progress bar that lies.

The two invariants

  1. Identity never depends on the origin. Sessions are stateless encrypted cookies — a sealed AES-GCM access cookie valid thirty minutes carrying the compact ACL, plus an opaque thirty-day refresh cookie. Login costs zero KV writes and survives the origin being on fire.
  2. Money is never queued. Progress can be queued. A profile edit can be queued. An order may not. In freeze and degraded the checkout button disables and states the reason, because the gap between "your work is safe but delayed" and "this did not happen" must never be ambiguous.

The repository contract

Both backends implement an identical action list, and each action declares its own metadata. The dispatcher reads that metadata and needs no knowledge of any individual action:

const ACTIONS = {
  'progress.save': { write: true,  mirror: true,  queue: true,  money: false },
  'pay.checkout':  { write: true,  mirror: false, queue: false, money: true  },
  'catalog.series':{ write: false, cacheable: 300 },
  'sys.config.set':{ write: true,  admin: true,  queue: false }
};

Anything that behaves differently between edge mode and origin mode is a bug in this abstraction, not a feature of the mode. That sentence is the whole test plan.

Catalogue & paywall

A series holds ordered items. Each series names how many leading items are free; the default is two, which is enough to judge the writing without giving away the work.

  • The paywall decision is made at the edge, server side. A client-side lock is theatre — the body would already be in the payload.
  • A refused item returns a reason: login_required or payment_required, plus the price in integer minor units. The UI renders different affordances for each; "sign in" and "pay" are not the same request of a person.
  • Entitlements have scope — item, series, all_access — and an optional expiry. All-access is a fixed twelve-month grant with the date shown on the profile, never a silent renewal.

Content editing and the sanitiser

Editable regions carry data-mx-section. Saving runs the HTML through an HTMLRewriter allowlist at save time, not at render time, so the stored bundle is already safe and every read is free of work.

  • Allowed: paragraphs, headings 2–6, bold, italic, code, blockquote, lists, tables, links, images by URL.
  • Refused as inert text: <script>, event attributes, javascript: hrefs, inline SVG and data:image/svg+xml sources.
  • Element ids are namespaced mxc- so authored content can never collide with application ids.
  • External links are forced to noopener noreferrer nofollow.
  • Hard limit 200,000 characters, with the editor warning at 90% and blocking at the limit rather than failing on save.

Publishing regenerates a per-host JSON bundle into R2, served through cdn.metax.academy at no egress cost. The toast says "Live within 60 seconds" — not "published", because the bundle is edge-cached and claiming instant is how you earn a support ticket.

Payments

One rail: Binance Pay, in USDT. Outbound requests are signed HMAC-SHA512. Incoming webhooks are verified RSASSA-PKCS1-v1_5 with SHA-256 against Binance's published certificate before any row is written. Clock skew is corrected from the date header of Binance's own response, so a drifting edge clock cannot invalidate a signature.

Finalisation is lock-free by necessity

D1 has no row locking. SELECT … FOR UPDATE does not exist, so the design cannot ask for it. Instead the database decides the race:

  1. A conditional UPDATE … WHERE status <> 'paid' attempts the transition.
  2. meta.changes tells the invocation whether it was the winner; losers return early having written nothing.
  3. Downstream rows use a deterministic group key 'ord:' + order_id with UNIQUE(entry_group, account), so even a crash mid-way cannot double-credit on retry.

The webhook is an optimisation, not a dependency: a five-minute cron re-queries Binance for orders stuck pending and finalises them through the identical function.

Affiliate, and why humans stay in the loop

  • Attribution is first touch and permanent. Last-touch rewards whoever bids on your brand name.
  • Clicks are rolled up daily rather than stored per event; a click is not worth a row forever.
  • Every commission carries a riskScore 0–100 with named flags — "buyer and referrer signed in from the same IP address" — because an unexplained number cannot be argued with.
  • Payout is an irreversible rail, so it requires a hold period and mandatory human approval. Approve needs no confirmation; void requires a typed word.

Learner data

Learners accumulate local telemetry in IndexedDB. Syncing it is opt-in, bounded and reversible.

  • Consent is three-way — always, ask, never — defaulting to ask. Choosing never uploads nothing, ever, including no watermark probe.
  • Databases are declared in window.MX_IDB_MANIFEST because Firefox has no indexedDB.databases(). Enumerating a user's databases would be a fingerprinting surface anyway.
  • A server-side watermark means a repeat sync uploads one new fact rather than the whole history.
  • Identical snapshots are rejected by content hash before they cost a write.
  • Hard cap 2,000 facts per snapshot, three snapshots per user per day.
  • Delete-my-data removes both the D1 rows and the R2 objects — a deletion that leaves the blob behind is not a deletion.

Certificates are signed rows rather than files. The HMAC covers only immutable fields, so the certificate design can improve without invalidating a decade of issued credentials. Verification returns one of four verdicts and states the reason; a tampered row shows no field values, because echoing them back helps an attacker tune the forgery.

Edge mode renders a real PDF when the holder's name is entirely Latin-1 representable and a crisp SVG otherwise, since embedding a full Unicode font in a Worker is not affordable. After cutover the origin renders PDF for everyone. The page tells you which you received instead of transliterating someone's name without asking.

Free-tier budget

Edge mode costs nothing, but "nothing" has numbers attached. These are the ceilings the design is shaped by.

The D1 write ceiling is the one that actually binds. Everything else has headroom.
ResourceFree ceilingWhat consumes it

Why 100,000 writes a day is the real ceiling

A learner syncing a snapshot of two thousand new facts writes two thousand rows, so fifty such syncs would exhaust a day. Three defences already exist in the design — the watermark makes a repeat sync nearly free, the content-hash dedupe rejects identical snapshots outright, and the per-snapshot cap is 2,000 facts. Add a per-user daily ingest cap of three snapshots and the worst realistic case is bounded. Today's write count is on the console health strip so the trend is visible well before the wall.

Other constraints worth memorising

  • 50 queries per Worker invocation, and 100 bound parameters per query. Bulk inserts therefore go through INSERT … SELECT … FROM json_each(?4) — one query, one parameter, any number of rows.
  • 500 MB per D1 database.
  • Exactly three cron triggers: */5 * * * *, 0 * * * *, 0 3 * * *. All three are cursor-chunked so no run can exceed its CPU budget.

Turning on the VPS costs roughly $4.50 a month and removes the write ceiling, the CPU ceiling and the Unicode PDF limitation. It is an upgrade you take when one of those three starts to hurt — not a prerequisite for shipping.

Security register

The stateless session cuts both ways. It removes a storage dependency and a rate limit, but a stolen access cookie is valid for up to thirty minutes and cannot be revoked inside that window. Four mitigations, stated plainly:

  • The thirty-minute TTL itself.
  • A sess_ver counter that invalidates every access cookie for a user when bumped.
  • Encryption rather than mere signing, so an inspected cookie leaks nothing.
  • A strict Content-Security-Policy on every project, because one XSS anywhere on *.metax.academy reaches the cookie.

The policy shipped with this project

This is served from _headers at the edge. Verify it on the live host rather than trusting this page — a CSP that only exists in documentation protects nothing.

The new attack surface

The switch introduces the /_sys/* family, which can read every row in the database. It is protected by a purpose-derived HMAC subkey distinct from the outbound one, a five-minute timestamp window, single-use nonces recorded in D1, and constant-time comparison. A Cloudflare WAF rule restricting it to the origin's IP costs nothing and turns a leaked secret from a catastrophe into a nuisance.

Standing items

  • Affiliate fraud: holds plus mandatory human approval on an irreversible rail.
  • Webhook forgery: RSA verification against Binance's published certificate.
  • Double-crediting: unique constraints and deterministic entry groups — not locks.
  • Open redirects: a safeReturn allowlist; anything off-list lands on the root.
  • Origin compromise: .env at mode 600 outside the webroot, 443 firewalled to Cloudflare, 22 to one address, PHP-FPM unprivileged.
  • In mirror mode the outbox holds plaintext records of every mutation including OAuth profile data. The daily job prunes delivered rows after seven days; treat that table as sensitive.
One thing this static build cannot do: enforce any of it. Route admission, session sealing and the paywall are all edge concerns. Everything you see in the browser is a rendering of a decision made server-side — and where the Worker is absent, this build says so on the page rather than pretending.