Architecture
Internal design notes — stripped from the public build. Do not copy this mechanism into the public pages.
Hosting
Portrait is a self-hosted Cloudflare Worker with an object store for rendered avatars and a database for accounts, email claims, and saved avatar configs. Two hosts:
- Reads —
media.portrait.intrasys.ai, public and unauthenticated, like Gravatar. - Writes —
api.portrait.intrasys.ai, session-gated. Requires an iron-sealed WorkOS session cookie (portrait_session, scoped to.portrait.intrasys.ai), set by the headless Magic Auth flow and sent automatically by the console. Writes only ever act on hashes the session user has verified.
Identity is WorkOS. The WorkOS user id differs per environment/app, so it can't be the shared
key; the email is the only identifier stable across WorkOS environments, which is why the cross-app
key is sha256(lower(trim(email))).
The read path never touches the database
This is the central design property. Both saved states (designed, uploaded) are rendered to PNG variants in the object store when the user saves, so a read is:
object-store lookup → hit means serve the stored PNG · miss means generate from the hash
No database on the hot path. The DB can be down and not a single avatar breaks anywhere in the estate.
Generated avatars
Generated avatars use DiceBear personas by default. The read path's d=
also accepts initials, identicon, and bottts. Because the seed is the hash, the same person
gets the same generated face in every app, forever, with zero storage. Designed avatars — including
those seeded by ✨ Ask AI, which target the avataaars collection — may use any collection the editor
supports.
One face across many emails — write fanout, not lookup
"One avatar per account" means forcing identical bytes into each verified hash's independently-keyed object-store slot. Portrait does this on write (a rare, human-speed save), never on read:
| Event | Storage action |
|---|---|
| Save avatar (design or upload) | Render variants once → write under every verified hash |
| Claim a new email (verified) | Server-side copy the existing variants to the new hash |
| Release a claim | Delete that hash's objects → reverts to generated |
| Delete avatar | Delete objects under every verified hash |
An alias table (hash → account) would be tempting but puts a lookup in front of every read,
including every miss — trading away the one property worth most here. Portrait rejects it by design.