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.
| Capability | Floor | Lowest 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 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
}
| Mode | System of record | Writes | Money | Login |
|---|---|---|---|---|
| edge | D1 | D1, immediate | Accepted | Works |
| mirror | D1 | D1 + outbox to MySQL | Accepted | Works |
| freeze | Neither, briefly | Refused, not queued | Refused clearly | Works |
| origin | MySQL | MySQL, immediate | Accepted | Works |
| degraded | MySQL (unreachable) | Queued, marked queued | Refused clearly | Works |
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
- 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.
-
Money is never queued. Progress can be queued. A profile edit can be queued. An
order may not. In
freezeanddegradedthe 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_requiredorpayment_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 anddata:image/svg+xmlsources. - 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:
- A conditional
UPDATE … WHERE status <> 'paid'attempts the transition. meta.changestells the invocation whether it was the winner; losers return early having written nothing.- Downstream rows use a deterministic group key
'ord:' + order_idwithUNIQUE(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
riskScore0–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_MANIFESTbecause Firefox has noindexedDB.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.
| Resource | Free ceiling | What 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_vercounter 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.academyreaches 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
safeReturnallowlist; anything off-list lands on the root. - Origin compromise:
.envat mode 600 outside the webroot, 443 firewalled to Cloudflare, 22 to one address, PHP-FPM unprivileged. - In
mirrormode 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.