Skip to content

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.

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
ProcessRuntimePortInternet-facingOwns
webBun + SvelteKit3000yesHTML, cookies, CSRF, redirects, form actions
apiRust + Axum8080noBusiness logic, authorization, every SQL statement
workerRustnoCatalog syncs, queued jobs, scheduled work
docsBun static server4321noThis site
migrateRust (platyn-cli)noSchema migrations, as a gated one-shot
  1. Browser → web. The request arrives at SvelteKit with an HttpOnly, SameSite=Lax session cookie. For anything non-idempotent, SvelteKit also checks the double-submit CSRF token against the csrf_secret it holds for the session.

  2. webapi. SvelteKit’s hooks.server.ts reads the raw token out of the cookie and forwards it as Authorization: Bearer <token> on the internal hop. It forwards a traceparent too, so one browser request has one trace across both processes.

  3. api authenticates. Axum middleware hashes the token — SHA-256(pepper || raw) — and looks up app.sessions by token_key. Not found, revoked, or past either expiry ⇒ 401. Otherwise the idle window slides forward.

  4. api opens a tenant-scoped transaction. Before any domain query runs, the connection executes set_config('platyn.tenant_id', $1, true) inside the transaction. Every RLS policy on app.* reads that setting. If it is unset, app.current_tenant_id() returns NULL, every policy evaluates to NULL, and the query returns zero rows — the failure mode is an empty result, never another shop’s data.

  5. api authorizes. 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.

  6. apiweb → 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.

These are the load-bearing rules of the whole system. Everything else is negotiable.

  1. Bun never opens a Postgres connection. There is no pg driver in web/package.json, and there should never be one. The moment a second process can write to app.*, the RLS policy set stops being the single description of who can see what.
  2. 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.
  3. Rust never renders HTML and never sets a cookie. It speaks JSON. Session cookies, CSRF, redirects, and flash messages are the BFF’s concern.
  4. The Rust API is not internet-facing. See above.

If you find yourself needing to break one, that is an ADR, not a patch.

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_id column 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 on app.*, read-only on catalog.*.
  • platyn_catalog — runs the sync. Zero grants on app.*. 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.

Cache only, and configured to make that hard to forget:

docker-compose.yml (excerpt)
command: >
valkey-server
--save ""
--appendonly no
--maxmemory 256mb
--maxmemory-policy allkeys-lru

No 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}

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.

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).