System overview
Platyn runs as five processes and three stateful services. The topology is small on purpose; the interesting part is not the boxes but the rules about which box may do what.
Topology
Section titled “Topology”flowchart TB
U["Browser"]
subgraph edge["Internet-facing"]
W["web — SvelteKit on Bun<br/>:3000<br/>SSR · session cookie · CSRF"]
end
subgraph internal["Internal network — no ingress"]
A["api — Rust / Axum<br/>:8080<br/>business logic · authz · SQL"]
K["worker — Rust<br/>catalog sync · jobs"]
D["docs — Astro Starlight<br/>:4321"]
end
subgraph data["State"]
P[("postgres 17<br/>:5432<br/>app.* RLS · catalog.* global · job queue")]
V[("valkey 8<br/>:6379<br/>cache only, no persistence")]
S[("MinIO / S3<br/>:9000<br/>artwork")]
end
X["Vendor APIs<br/>S&S Activewear · SanMar"]
U -->|HTTPS| W
W -->|"JSON + bearer token"| A
A --> P
A --> V
A --> S
K --> P
K --> S
K -->|"rate-limited"| X
U -.->|"presigned GET/PUT"| S
style W fill:#00A3D9,stroke:#007AA6,color:#14131A
style A fill:#E4006C,stroke:#B00054,color:#ffffff
style K fill:#E4006C,stroke:#B00054,color:#ffffff
style D fill:#FF5B23,stroke:#D9410F,color:#14131A
The processes
Section titled “The processes”| Process | Runtime | Port | Internet-facing | Owns |
|---|---|---|---|---|
web | Bun + SvelteKit | 3000 | yes | HTML, cookies, CSRF, redirects, form actions |
api | Rust + Axum | 8080 | no | Business logic, authorization, every SQL statement |
worker | Rust | — | no | Catalog syncs, queued jobs, scheduled work |
docs | Bun static server | 4321 | no | This site |
migrate | Rust (platyn-cli) | — | no | Schema migrations, as a gated one-shot |
The request path
Section titled “The request path”-
Browser →
web. The request arrives at SvelteKit with anHttpOnly,SameSite=Laxsession cookie. For anything non-idempotent, SvelteKit also checks the double-submit CSRF token against thecsrf_secretit holds for the session. -
web→api. SvelteKit’shooks.server.tsreads the raw token out of the cookie and forwards it asAuthorization: Bearer <token>on the internal hop. It forwards atraceparenttoo, so one browser request has one trace across both processes. -
apiauthenticates. Axum middleware hashes the token —SHA-256(pepper || raw)— and looks upapp.sessionsbytoken_key. Not found, revoked, or past either expiry ⇒401. Otherwise the idle window slides forward. -
apiopens a tenant-scoped transaction. Before any domain query runs, the connection executesset_config('platyn.tenant_id', $1, true)inside the transaction. Every RLS policy onapp.*reads that setting. If it is unset,app.current_tenant_id()returnsNULL, every policy evaluates toNULL, and the query returns zero rows — the failure mode is an empty result, never another shop’s data. -
apiauthorizes. The membership’s role expands to a permission set (deal.write,design.approve, …). The handler checks the permission it needs. The BFF may hide a button based on the same set, but hiding is not enforcement — the check that counts is here. -
api→web→ browser. The API returns JSON, or an RFC 9457 problem document. SvelteKit turns that into HTML, or into a form-action error. The API never renders a page and never sets a cookie.
The four invariants
Section titled “The four invariants”These are the load-bearing rules of the whole system. Everything else is negotiable.
- Bun never opens a Postgres connection. There is no
pgdriver inweb/package.json, and there should never be one. The moment a second process can write toapp.*, the RLS policy set stops being the single description of who can see what. - Bun never makes a consequential authorization decision. It may reflect permissions to shape a UI. It may not be the thing that stops a request.
- Rust never renders HTML and never sets a cookie. It speaks JSON. Session cookies, CSRF, redirects, and flash messages are the BFF’s concern.
- The Rust API is not internet-facing. See above.
If you find yourself needing to break one, that is an ADR, not a patch.
Postgres 17
Section titled “Postgres 17”Two schemas, and the split between them is the tenancy model:
Directorypostgres
Directoryapp tenant data — RLS forced on every table
- tenants
- users global identity, deliberately not tenant-scoped
- roles
- role_permissions
- memberships
- sessions declared RLS exemption: lookup precedes knowing the tenant
- tokens declared RLS exemption: resolved by hash before authentication
- vendor_accounts
- tenant_keys
- companies
- customers
- addresses
- deals
- deal_assignments
- decoration_methods hand-written policy: NULL-tenant system rows readable by all
- line_items
- line_item_sizes
- designs
- design_versions
- design_assets
- imprints
- approvals
- approval_comments
- jobs hand-written policy: NULL-tenant platform jobs
- job_events
- outbox
- audit_log partitioned by month; policy applied to parent and every partition
- external_refs
- rls_exemptions
Directorycatalog global vendor data — no
tenant_idcolumn exists, ever- vendors
- brands
- sizes
- size_aliases
- size_alias_review
- styles
- style_colors
- variants
- variant_prices
- variant_inventory
- sync_runs
- sync_events
- version
Isolation is not left to reviewer diligence. app.rls_audit() returns every table in app.* that
has a tenant_id column but is not fully protected and not explicitly exempted, and CI asserts it
returns zero rows — see Testing.
Two runtime roles, both NOBYPASSRLS and neither a table owner:
platyn_app— serves tenant requests. Read/write onapp.*, read-only oncatalog.*.platyn_catalog— runs the sync. Zero grants onapp.*. A sync bug physically cannot corrupt a customer’s orders.
Migrations run as a gated one-shot (migrate in compose), never from app boot. An API replica
starting up must never be able to alter the schema out from under the replicas already serving.
Valkey
Section titled “Valkey”Cache only, and configured to make that hard to forget:
command: > valkey-server --save "" --appendonly no --maxmemory 256mb --maxmemory-policy allkeys-lruNo RDB snapshot, no AOF, and an LRU eviction policy that will drop your keys under pressure. If losing a key would lose work, the key was never a cache entry and belongs in Postgres. The job queue lives in Postgres for exactly this reason (ADR-06).
Catalog cache keys embed catalog.version, a counter bumped once per successful sync. Invalidation
is therefore key rotation: there is no invalidation code, and so no invalidation bugs.
catalog:v{catalog_version}:style:{style_id}catalog:v{catalog_version}:search:{query_hash}:{cursor}MinIO / S3
Section titled “MinIO / S3”Artwork — customer-supplied logos, vector separations, mockups, approved proofs. Local development
runs MinIO with a versioned platyn-artwork bucket; production is S3. Objects are keyed
{tenant_id}/{deal_id}/{artwork_id}/{revision}.{ext}, and the tenant prefix is checked against the
session’s tenant before any URL is signed.
Uploads and downloads use presigned URLs, so bytes go browser ↔ storage directly. Neither Bun
nor Rust proxies a 200 MB .ai file. Artwork never touches a host mount.
Deployment
Section titled “Deployment”Everything is containers on plain hosts. No Kubernetes
(ADR-07). web and api scale horizontally
behind a load balancer; worker scales with the number of vendors, not the number of customers,
because vendor API rate limits are the binding constraint.
The worker container is given a stop_grace_period of 90 seconds. Long syncs checkpoint per item
and abort at item boundaries, so killing one is cheap rather than dangerous
(ADR-08).