PortfolioGeoSoul
geosoul.io
Case 01 — technical dossier
GeoSoul

A Tunisian fashion platform where anyone designs a garment in 3D in the browser, publishes it, and earns on every sale. Underneath the brand: a monorepo carrying a marketplace, a creator economy, an admin back office, and an embeddable 3D designer sold as a service.

Visit geosoul.io

Ce dossier technique est en anglais. Le reste du site est bilingue.

01

What it is

The product, the scale of it, and the rule this document follows.

GeoSoul lets anyone design a garment in 3D in the browser, publish it, and earn on every sale. No stock, no minimum order, nothing upfront. A creator sets their own price and keeps a share of the profit; the platform handles printing and delivery.

That is the brand. The system underneath it is a decoupled monorepo — an Angular SPA, a NestJS API and PostgreSQL in containers — carrying a storefront, a creator economy with wallets and payouts, an admin back office that touches nearly every module, a self-hosted observability stack, and a second product: the 3D designer, sold as an embeddable service to other merchants.

42backend feature modules
13garments in the 3D catalogue
3tiers, independently built and deployed
2products — the marketplace, and the embed
The GeoSoul product line-up: tees, a cropped tee, a cap and a tote
What actually ships. Everything after this is about how it gets there.

I founded it and I built it. What follows is the engineering, in the order I would want to read it: the shape of the system, then the two hard parts (painting in 3D, and moving 3D models around without killing a phone), then the unglamorous half that decides whether any of it survives contact with real customers.

What this document does not contain

GeoSoul is live and has real customers. The point-in-time security assessment, its findings, its open items and the disaster-recovery runbook are not published here. A map of where a system is weakest is not a portfolio asset. Everything below is described at the level of decisions and the reasoning behind them.

02

Architecture

Three tiers, thin controllers, no store, and a request path you can follow end to end.

Angular SPA to a NestJS API over a single REST surface, TypeORM to PostgreSQL. Frontend and backend are independently built and deployed containers and share no code — the only contract between them is the API, reverse-proxied under one prefix.

browserangular spa01nginxtls · edge limits02validationdto whitelist03guardsjwt · roles04controllerthin05servicethe logic06postgrestypeorm repo07

The request path. Every response leaves through a role-aware serializer on the way back.

The backend is a feature-module pattern: each domain is a module, a controller, a service, an entity and its DTOs. Controllers stay thin — HTTP and validation only. Business logic lives in the service, persistence in a repository, and the root module wires them together with the global pipes and guards.

The frontend has no NgRx and no Redux. State is RxJS and singleton services — one injectable per domain, subject-backed where it is genuinely stateful, like auth and cart. For an application this size a store would have been ceremony. The one place that actually needed a store — the 3D designer — got its own, and only its own.

Routing is fully lazy: every route loads its component on demand. Translations load synchronously at startup through an initialiser, so they exist before first render and no screen ever flashes its own translation keys at a visitor.

Controllers stay thin. If a controller knows why, it is in the wrong place.

03

The 3D designer

Painting directly onto a garment: the engine/state split, the stroke pipeline, and the seam problem.

The marquee feature. You load a garment and you paint on it — directly on the 3D surface, with layers, brushes, text and images — and what comes out is a texture the print pipeline can use.

Engine and state are deliberately two services. One owns the Three.js scene, the 2D canvas and everything that touches a pixel; the other owns layers, tools, brush settings, blend modes and selection. Rendering never reads component state, and the store never touches a canvas. That separation is what makes the layer system testable and the renderer replaceable.

pointerscreen xy01raycastmesh hit02uvsurface coord03canvastexture px04scratchdirty rect05compositeonce a frame06textureback to mesh07

One brush stroke. The scratch canvas and the dirty rectangle are why dragging stays at frame rate.

A stroke does not draw into its layer. It draws into a scratch canvas while tracking a growing bounding box of everything it has touched, and only that rectangle is composited back. Compositing is coalesced to once per frame — dragging a layer queues the pointer position and flushes it on the next frame rather than recompositing on every pointer event, which at a high-DPI pointer rate is several times per frame.

Blend modes map onto the canvas composite operations, so a layer stack behaves the way a designer expects without a second rendering path. Background removal on uploaded images runs client-side, so an image never has to leave the browser to become a layer.

Painting across a seam — the problem that has no obvious answer.

A 3D garment is a flat texture wrapped onto a surface, and that surface is cut into islands. Two points can be neighbours on the garment and sit at opposite ends of the texture. Drag a brush across that boundary and the naive implementation joins them with a straight line in texture space — a stripe smeared clean across the garment from a stroke you made in one place.

Detection compares how far the pointer moved in UV space against how far it moved in world space. If it barely moved in 3D but jumped in UV, it crossed an island boundary in place: unambiguously a seam. Otherwise the ratio of the two is weighed against the model's own density.

That density is not a constant, because every model is unwrapped differently. On load the engine samples up to four hundred triangles per mesh, computes the square root of UV area over world area for each, and takes the median. Median rather than mean: a handful of degenerate triangles would drag an average, and the threshold would then fail on exactly the models with the messiest unwraps.

The threshold is model-relative. A magic number would have worked on the t-shirt and failed on the cap.

04

The 3D pipeline

Thirteen Draco-compressed garments, and the memory problem that shaped the whole service.

Thirteen garments, Draco-compressed GLB with WebP textures. Compressed they are small. Decoded they are not, and the gap between those two numbers is the whole problem.

The interesting constraint here was one I could not remove. The garments are heavy and I did not model them — cutting triangle counts, rebuilding UVs, baking detail into normal maps is 3D asset work, and it is not my craft. The obvious lever, making the files smaller, was never in my hands, and a month spent pretending otherwise would have produced worse models and a later launch.

Everything after the file arrives was in my hands. So I went around the problem instead of at it. The weight of a single model is fixed; the number of them decoded at once is not, and that one is entirely a software question.

drag to turn

Those are the production models, loaded by the same pipeline the product uses, and the readout is measured in your browser rather than typed into this page. Switch garments and two numbers diverge: what is resident now, and what would be resident if this viewer cached decoded scenes instead of disposing them.

That second number is the bug. The first version of the model service memoised parsed results — the obvious optimisation, and exactly the wrong one. Every garment a visitor touched stayed decoded, and a mid-range phone died four garments into browsing.

The fix was to memoise the buffer and not the scene: cache the compressed bytes, decode on demand, and dispose the previous model before the next one is built. Peak memory becomes one decoded model, however long somebody plays.

The file never got smaller. It just stopped mattering how big it was.

The generalisable part is not the caching strategy. It is that an optimisation does not have to happen where the problem is. The problem was in the asset pipeline, where I had no reach; the fix went in the service layer, where I had all of it.

Serving is access-controlled rather than a public path — models go through a service that decides who may fetch which asset. In the embeddable viewer they are additionally cached per browser, so a returning shopper fetches a garment once rather than once per page view, and the usage ledger counts a model served roughly once per browser instead of once per visit.

05

The WebGL pieces

Three custom effects in production, and three lessons that generalise.

SEAMHomepage hero. A pointer-led seam runs the hoodie and wanders on its own when idle.
RITESeller onboarding. Turns continuously; horizontal drag scrubs four movements, forward and back.
PLATEModel picker. Baked 3D plates replacing thirteen flat vector rows.

Bloom over a transparent canvas can return a fully opaque alpha. On the page that reads as a black rectangle sitting exactly where the glow should be — worse than no bloom at all, and it never shows up in the isolated demo, only once composited onto the real page. Every workaround costs either a second render target or the transparency the hero depends on, so the pass was dropped and the same read came out of point size and falloff instead, for nothing.

Verify an effect on the page it ships to, not in isolation.

Moving the particle cloud from additive to normal blending broke it in a way that took a while to name: the same alpha has to go up for the scattered state and down for the bound one. Scattered points need weight or the cloud disappears; bound points need transparency or the cloud occludes the garment it is in the middle of forming. Alpha is not a constant, it is a function of assembly — and the crossfade between those two tunings is the effect.

The choreography began scroll-driven across three viewport heights. A homepage hero is not three viewport heights, so the driver had to go, and pointer-led with an idle wander replaced it. Two rules came out of that and both generalise: read relative pointer deltas and never absolute position, because absolute mapping snaps to wherever the cursor entered frame and never reads as answering the gesture; and spend one gesture per axis, because scrub and spin both want horizontal drag, so the autonomous motion has to be the one that never stops.

The garment viewer above obeys both of those rules. It is the same lesson, applied to a different piece.

06

Storefront & community

One grid, two kinds of seller, and why the house brand is not a row in the brands table.

House catalogue and creator designs live in the same list. A shopper should not have to know who made a thing in order to find it, so discovery is one grid and the seller is an attribute of the item rather than a separate section of the site.

The house identity is deliberately not a row in the brands table. Items with no brand render under a single admin-editable configuration row instead. Modelling the platform as just another brand would have meant every money path, every payout, every ownership check and every analytics query had to special-case one magic row forever.

Paid placement splices a featured slot into the default sort on the first page, always labelled as such. The policy lives in exactly one file in the brand module and the public read paths call it; they never re-implement it. A placement rule duplicated across two query builders is a rule that will eventually disagree with itself.

Two access paths, kept apart on purpose: owner-only backs every money surface; team membership backs the catalogue.

07

Sellers & money

Brands, wallets, withdrawals, commissions and a referral ledger that owns no balance.

A creator registers a brand, publishes designs, accrues to a wallet, and requests a withdrawal. Around that sit a team roster on the brand, a commission channel where a buyer and a creator negotiate a custom piece with messages and attachments, and a referral programme.

brandThe creator's identity, its roster, and its plan on the commercial ladder.
walletWhere earnings land. One balance, one owner, one source of truth.
earning transactionThe append-only record of how a balance got where it is.
withdrawalA request against a balance, with its own methods and review.
custom requestBuyer to creator, with messages and attachments on the thread.

The referral module owns no balance. It owns the invite links, the who-invited-whom edge, and its own ledger; the money itself lands in wallets like every other kind of earning. It is imported by auth and by orders and never the other way round, so the dependency arrow only ever points one direction and the module can be reasoned about without reading the two that use it.

Ownership and membership are different questions and are answered by different code. The owner-only lookup backs every money surface; a separate access check backs the catalogue. Collapsing them into one convenience helper would have quietly let a team member reach a payout screen.

08

Authentication

Two tokens, rotation with theft detection, and a second factor enforced at the right boundary.

Two tokens, not one, because they answer different questions.

access tokenTwenty minutes. Stateless JWT, sent as a bearer header. Not revocable — that is the price of stateless, and the reason it is short.
refresh tokenThirty days, sliding. Opaque, one row per token in the database, carried in an httpOnly cookie the page cannot read. Revocable: logout-everywhere, password change and deactivation all kill it.

The refresh cookie is scoped to the two routes that can spend it, so the browser never attaches it to the couple of hundred other calls the SPA makes. Its same-site policy is what makes the refresh endpoint safe from cross-site submission without needing a separate anti-forgery token.

Refresh tokens are single-use and rotating. A replay inside a short grace window is treated as what it almost always is — an honest race between two tabs. Outside that window it is treated as theft, and the entire token family is killed.

One piece of deliberate paranoia: when the access-token lifetime moved, the configuration key was renamed rather than reused, and the module logs a warning at boot if it finds the old one. A stale day-long value sitting in a deployed environment file would otherwise have silently overridden the new twenty-minute default, and nothing anywhere would have looked wrong.

Renaming the key is the migration. A config value that can silently win is a bug you cannot see.

passwordstep one01challengeseparate key02totp codestep two03access + refreshsession04

Admin sign-in with a second factor. The challenge is signed with a derived key, not the main one.

Admin two-factor is TOTP and enforced by default — but enforced at the admin boundary, not the authentication boundary. An unenrolled admin can still sign in and use the site as an ordinary user; what they cannot do is anything administrative, and every admin route refuses them until they enrol.

Blocking them at login instead is a bootstrap deadlock: enrolment needs a session, and no session means no enrolment. A lockout with no way out of it is not a security feature. This is the decision most likely to be helpfully 'fixed' by someone who has not thought it through, which is why it is written down next to the code.

Enrolment is a three-state machine, and the middle state is the entire point. The secret must be stored before the user has proved they can generate a code from it, because the QR they are looking at has to be the one we later check against. If the enabled flag flipped at that same moment, anyone whose authenticator silently failed to save the entry — or who closed the tab mid-scan — would be permanently locked out of their own account by a secret they do not possess. The flag only flips on a verified code.

Three bypasses were closed on the way, and each one alone would have made the whole feature decorative:

the challenge as a sessionThe login challenge is a token carrying the user's id. Signed with the main secret it would simply be a valid access token: send the right password, take the challenge out of the response, use it as a bearer header. It is signed with a separately derived key so the signature check rejects it outright, and carries a type claim as a second, independent guard for the same thing.
password resetReset auto-logs the user in, and the link arrives by email — which is exactly the channel a second factor exists to survive. An enrolled account now gets a challenge out of the reset, not a session.
the lockout counterA correct password cleared the failed-attempt counter. Someone who already had the password could therefore replay step one at will, wiping the counter each time, and grind the six digits forever without tripping the lock. The counter now clears only once the second factor is proved as well.

A second factor is only as strong as the paths that do not ask for it.

09

Security posture

Defaults that fail closed, one choke point for uploads, and three traps that would have made it decorative.

Posture, not findings

The security assessment, its findings and its open items are not published. What follows is the shape of the defences and the reasoning behind them — the part that is useful to read and useless to attack.

Input is validated by a global pipe that strips properties nobody declared and rejects payloads that carry them outright, so a field that is not in a DTO cannot reach a service. Output leaves through a role-aware serializer that honours exclusion decorators everywhere and re-exposes group-gated fields only to admins — which means trimming personal data is the default on every endpoint rather than something each one has to remember to opt into.

Passwords are bcrypt with a real complexity policy rather than a length minimum. Lockout is dual-scope, per account and per address, with exponential backoff that decays after a quiet day so yesterday's typos do not punish today's login. The counter is incremented inside a transaction that holds a row lock, because credential stuffing is concurrent and a read-modify-write without one lets parallel guesses clobber each other's count.

Rate limits are counted per real client address by a dedicated guard rather than trusting a proxy header blindly, and the edge adds its own on top. Security headers come from the standard middleware set including strict transport security. The cross-origin allow-list is explicit and never falls back to development defaults in production.

Secrets live in the environment and nowhere else; only example files are in version control. Boot fails hard rather than starting insecure if the signing secret is too short or the database password is missing, and a secret scanner runs in CI over the full history rather than just the diff.

Boot should fail loudly rather than start quietly wrong.

designer texturesprofile imagesbrand logo & coversupport attachmentscommission attachmentspublished designsone functionthe choke point01cheap rejectscategory · size02scanoriginal bytes03sniffcontent, not header04re-encodepixels only05

Every upload path in the application funnels through one function. A call site cannot forget it.

The hardening lives in that one function rather than in a pipe, a decorator or five separate upload configurations, because every path already funnelled through it. A call site cannot forget to apply it, and a new upload route inherits it for free.

The order is not arbitrary. Scanning runs on the original bytes because re-encoding destroys the only copy that still contains what the user actually sent. Sniffing decides what a file is from its content, never from the type the client claimed — that is a hint, not a fact.

What re-encoding buys that a magic-byte check does not: metadata, because a phone photo carries the coordinates it was taken at, and before this a buyer attaching a photo to a support ticket was handing the seller their home address. Polyglots, because a file can be a valid image and a valid script at the same time — the magic bytes at the front say image and the payload rides in a trailing chunk, and it does not survive being decoded to pixels and written back out. And decompression bombs, which are refused from the header before a single pixel is decoded.

The starting position was worse than the roadmap thought. The validation function took a file path and read from disk — and every upload route in this application holds the bytes in memory, so there was never a path to read. It had zero call sites. It had never run once.

Three traps followed, and each would have left the feature decorative while looking finished from the outside. A direct-to-storage upload path whose bytes never reach the API at all, and therefore bypass every check above it — dead surface that was still reachable, now off unless deliberately enabled, and turning it on means first solving validation for a file the API never sees. A default import of a CommonJS imaging library that type-checks, builds clean, and is undefined at runtime, so the app would have crashed on the very first upload. And a virus scanner whose replies are NUL-terminated, where trimming whitespace does not strip NUL, so every parse fell through to an error branch that logged and then allowed the file — including a live test sample.

A scanner is not working until you have watched it refuse something it is supposed to refuse.

10

Data & migrations

Schema is never auto-synced, order lives in a manifest, and data statements have to declare themselves.

The schema is never auto-synchronised — that setting is off, always. Changes are apply-once SQL migrations, and the applied ones are recorded in a ledger.

deployone command01migrateschema only02grantrole privileges03seedonly when asked04

Seeding is never called by a deploy. It is a separate verb a human types.

Migrations apply in the order a manifest lists, not alphabetically. Alphabetical is not a dependency order, which is precisely why building from an empty database used to fail on the first file. Filenames are never changed to encode order either: the ledger keys on the filename, so renaming an applied migration makes it look brand new, and the runner would re-apply every backfill inside it against live production data. The order goes in the manifest instead, and if a new migration is missing from it the runner refuses to run rather than guessing.

Schema and data are different things. A deploy used to write rows through three doors nobody had counted: the database entrypoint script inserting placeholder content merely by starting a container, migrations carrying inserts of their own, and the API upserting reference rows on every boot. None of it was destructive, but it meant a deploy could never be reasoned about as a pure schema change — and each of those statements was one careless edit away from overwriting something an administrator had set.

'No data statements in migrations' is the wrong rule, though, because it breaks them. There are three kinds and only one is seeding. A backfill populates the column the migration just added, and the schema change is simply wrong without it. A repair deletes duplicates so that the unique index below it can be created at all — strip it and the migration fails. Seeding inserts content, and that is the one that moves out.

So the enforced rule is narrower and survivable: a migration may touch rows it structurally changed, and may not seed content. Backfill and repair declare themselves with a marker comment, and the runner refuses to apply a migration carrying data statements without one, printing the offending lines.

The word boundaries in that check are load-bearing. A naive match on UPDATE hits the updated_at column present in nearly every CREATE TABLE in the repository — the guard would have rejected everything, including files with no data statements at all.

Seeds are split into what a fresh install is broken without and what is only sample content, and the seed script is not called by deploy and never will be. Primary keys are UUIDs, every raw query uses placeholders, and the database listens on loopback only.

11

Tests & CI

Integration-first, against a real database, and a pipeline split so a CVE does not wait for a push.

The suite is integration-first. Nineteen spec files stand a real application against a real PostgreSQL rather than mocked repositories, because almost everything that can go wrong in this system is a query, a transaction or a guard — and none of those exist in a mock.

What they cover reads like a list of the things that would actually hurt. Money under concurrency. PII exposure — a test whose whole job is to assert the serializer does not hand a field to the wrong role. Authentication lockout. Then wallet consolidation, order-to-earnings, referral payouts, plan limits, member discounts, brand billing, catalogue placement, multi-brand ownership, the audit trail, compliance, and the SaaS portal's principal model.

money-concurrency and pii-exposure are the two suites I would keep if I could keep only two.

typechecktsc --noEmit01testsreal postgres02frontendproduction build03lighthousereport only04

The main pipeline. The test job starts its own database rather than assuming one.

The pipeline is split across three workflows on purpose. The main one typechecks, runs the suite against a database it stands up itself, builds the frontend for production and runs Lighthouse. A dependency audit runs on its own weekly schedule and blocks on critical findings. A secrets sweep runs over full history, also on its own schedule. Bundling them into one file would mean a vulnerability published on a Tuesday waits for somebody to push.

The production frontend build in CI is passed no secrets at all, deliberately — if the build needs one to succeed, that is a finding, not an inconvenience.

What the performance number does not measure

The Lighthouse job audits one URL — the application's boot shell — and not the designer or the discovery routes, because auditing those needs a backend, a database and a seeded catalogue standing up in CI. That limitation is written in the job itself, next to the job, because a performance score that quietly measures the easiest page in the app is worse than having no score.

12

Operations

Three environments, one deploy path, and why the uptime monitor cannot live on the box.

Development, staging and production are separate container stacks from the same repository. A deploy builds both images and brings the stack up behind a reverse proxy handling TLS termination and edge rate limiting.

Uptime monitoring is deliberately external, and this is not a preference. On-box monitoring watches the machine from inside it — so when the machine is what died, whether that is a kernel panic, a network drop, a full disk or a wedged container daemon, the thing that was supposed to page you is down with it. Only a monitor running somewhere else on the internet can tell you the whole box is unreachable.

Errors report to a self-hosted collector and the host reports its own health separately, so an application error and a sick machine do not arrive as the same signal. Outbound email has its own deliverability work behind it, because a marketplace that cannot reliably deliver a password reset does not have a password reset.

13

Observability

Logs you can follow across services, metrics, errors, host health — and analytics deliberately switched off.

Four separate questions, four separate systems, and not one of them shares a compose project with the application.

logsLoki and Promtail with Grafana over the top. Logs are structured JSON, so a request id filters an entire request's logs across every service it passed through — which is the difference between reading logs and searching them.
metricsPrometheus with host, container and Postgres exporters, on its own Grafana. Machine, containers and database on the same timeline.
errorsA self-hosted, Sentry-compatible collector, with the SDK wired into the API's exception filter so an unhandled error arrives with its request context attached.
hostNetdata, watching the machine itself at a resolution the app-level tools do not have.
uptimeExternal SaaS, deliberately off-box — see Operations. On-box monitoring dies with the box.

The isolation is the actual design decision. Each stack is its own compose project with its own network and volumes; it reads container logs the way the docker CLI does, through a read-only socket, and it never touches an application container. The worst a broken observability stack can do is stop collecting.

A monitoring system that can take production down with it is not monitoring. It is a second production.

Product analytics is self-hosted Umami — cookieless, no consent banner to justify. It is shipped and deliberately switched OFF: the site is still behind an early-access gate and marked noindex, so the only traffic today is mine, and tracking it would be measuring myself.

It is also kept carefully apart from the analytics a seller sees. A brand's dashboard shows that brand's own sales and earnings, computed from the application database and scoped to its owner. Umami shows anonymous visitor behaviour, and only to me. Two surfaces both reasonably called “analytics”, answering different questions for different people — and collapsing them is how a seller ends up looking at somebody else's numbers.

The API also reports its own health, including a storage indicator that actually reaches the object store rather than returning a cached opinion about it. A health check that cannot fail is a status light with the bulb taken out.

14

Backups & recovery

Content-fingerprinted snapshots, two retention policies for two different rhythms, and a restore that was proved.

A nightly database dump, plus configuration and uploads, pushed off the box to object storage. A manual backup also runs before every deploy, which is what makes the worst case theoretical rather than nightly.

nightlytimer01dumpinside the container02fingerprintconfig + uploads03unchanged?skip the write04off-boxobject storage05

If nothing changed, nothing is written. A nightly run on a stable box costs one dump and two hashes.

Configuration and uploads are content-fingerprinted on every run, so an unchanged snapshot is not written or uploaded at all. The retained configuration snapshots are therefore that many real versions of the environment file, rather than that many identical copies of last week's.

Retention differs between them on purpose. The database rolls on date buckets. Configuration is kept by count, because configuration changes are event-driven rather than nightly: five edits can land in one afternoon and then nothing for months, and a date-bucketed scheme would keep only the last of that day and discard the four intermediate versions you would actually want to roll back to.

recovery pointAt most a day of data, and in practice far less, because a backup runs before every deploy.
recovery timeUnder two hours, dominated by provisioning and image builds, with DNS propagation on top of that.

A restore that has never been performed is a hypothesis, not a backup. The first one was proved by restoring into a throwaway database and counting rows in it — never into production, and never as a drill that stops at 'the file exists'.

A backup you have not restored is not a backup.

Not published

The recovery runbook itself — the cold-start kit, where credentials live, and the order of operations — is not in this document. It is the one artefact that turns a stolen laptop into a compromised platform.

15

The embeddable product (SaaS)

The designer sold as a service: three credentials, three trust boundaries, and a limitation stated out loud.

The designer and a showcase viewer are also sold as an embeddable product. A merchant drops an iframe onto their own product page and their customers configure a garment in 3D without ever leaving the shop.

That is one half. The other is a portal the customer signs into: their own staff users, their API keys, their registered origins, their SKUs, their textures, their entitlements and their usage. Tenancy is explicit rather than implied by a column — a tenant, tenant users, per-tenant keys, per-tenant origins, per-tenant theme — and it has its own rate limiting, separate from the marketplace's.

The piece worth stealing is the principal model. Three different things can hold a credential here: a customer's server, a customer's staff member, and an anonymous embed session inside somebody else's page. They are three distinct principal types, named as such, so a guard never has to infer which one it is holding from the shape of a token. Every one of them has an integration test.

The security model is the design. Three credentials, each living on a different side of a trust boundary, and getting that right is most of the work:

secret keyLives on the merchant's server. Exchanged for a session. Never shipped to a browser, never put in a URL, never handed to the frame.
publishable keyLives in page source and is deliberately public. Constrained twice instead: read-only viewer scope, and only from an origin registered to that tenant.
embed tokenShort-lived, held in the frame's memory only. Never a cookie, never local storage.
merchant serverholds the secret01session exchangeone api call02fragmentnever the query03iframetoken in memory04apiscoped calls05

The token reaches the frame through the URL fragment, then is stripped from history immediately.

A publishable key exists because some platforms leave no choice. On hosted storefronts the merchant has no server-side execution available to them — there is nowhere to hide a secret, and theme files are readable by staff. So the credential is public, and constrained instead: it cannot reach anything that writes, and it only works from a registered origin.

The token travels in the URL fragment and never the query string, because a fragment is never sent to a server: it cannot land in an access log, a proxy cache or a referrer header. The shell reads it once on load and strips it from history immediately.

Framing is permitted coarsely at the HTTP layer and checked per tenant in JavaScript, because the server cannot do the per-tenant check itself — at the moment it serves the document, the token is in the fragment and it does not yet know which tenant is asking.

A limitation, stated rather than glossed

An origin header is a browser promise, not a cryptographic one — a scripted request can simply assert it. That is accepted knowingly: a viewer token reveals a showcase item that is already public on the merchant's own product page, so forging the header gets an attacker exactly what opening the shop would. What it would not survive is per-session billing, which is one reason billing is not per session. If that ever changes, this key type needs rework — and that sentence is in the document the customer reads.

A limitation you have to discover is a bug. One that is written down is a boundary.

16

Admin & instrumentation

A horizontal admin surface, reporting that owns nothing, and shareable links without server-side rendering.

The admin surface is horizontal — it touches nearly every module: orders, users, brands, design review, wallets, payouts, support, commissions, even the translation table. It is guarded at class level rather than per handler, so every route on the controller is covered by one decorator pair and a new one cannot arrive unguarded by omission.

Analytics owns no entity of its own. It is read-only aggregation over what the other modules already store — revenue, top sellers, funnels, payouts — because a reporting module with its own tables is a second source of truth, and a second source of truth is a drift waiting to happen.

The designer funnel is instrumented anonymously: milestone events land in their own small module that owns the ingest route, and the report that reads them lives with the rest of analytics. Ingest and reporting are different concerns with different access rules.

There is no server-side rendering, and the site still has to be shareable. A dedicated module serves the sitemap and renders social preview tags for crawlers specifically — a narrow answer to a narrow problem, rather than adopting SSR across the entire application to solve it.

Reporting reads. It does not own.

Forty-two feature modules do not each earn a chapter, but leaving them unmentioned would misrepresent the system. The ones that carry real weight and did not get one:

notificationsIn-app notifications with per-user read state, plus a registry mapping every notification kind to its email template — so a new kind cannot ship without somebody deciding what the email says.
feature flagsDefinitions in code, state in the database, and a guard plus a decorator so a route can declare that it requires a flag rather than checking one by hand.
auditAn interceptor writing an audit log against a named catalogue of actions rather than free text, because a log you cannot query is a log nobody reads.
fraud & integrityOrder integrity checks and a managed set of cancellation and rejection reasons, so a refusal is always attributable to a defined cause.
complianceThe data-subject and legal surface, kept in its own module because its retention rules answer to law, not to product.
early accessThe gate that keeps the entire site closed and unindexed until launch. Several other modules — analytics, SEO — are deliberately inert behind it.
server-side conversionsConversion events sent from the server rather than a browser pixel, so attribution does not depend on whether an ad blocker allowed a script to run.
tasks, notes, social links, payout settings, designer config, print exportThe smaller ones. Unglamorous, each one the reason some screen in the admin panel is not a hard-coded constant.