Twinkl.social — Agent & Contributor Guide #
Twinkl is a social platform built on ATProto that fuses the chaotic creativity of the early web — GeoCities/Neocities home pages, MySpace customization, Neopets-style pets, LiveJournal blogging, and Y2K chatrooms — into one nostalgic, deliberately cringe, beautiful amalgamation.
This document is the source of truth for how the codebase is structured. Read it before adding or moving code. It is written for both human contributors and AI coding agents.
1. What Twinkl is (feature surface) #
| Surface | Description |
|---|---|
| Feeds | Standard ATProto microblogging timeline + composer. |
| My Top Friends | Pick your top friends; they get a dedicated feed and their posts are highlighted everywhere. |
| Home page | Fully customizable bento grid of Web Tiles (social.twinkl.homepage.tile) — small script-less tiles render inline in a null-origin sandboxed iframe; content-addressed/external tiles, and any JS-bearing inline tile once a tile domain is configured, render through the DASL tiles loader sidecar on a random single-use origin — plus optional custom CSS. Top Friends and the profile song ship as authored palette tiles. |
| Guestbook | Visitors leave comments that appear on your home page. |
| Songs | Attach songs (plyr.fm) that play when someone visits your page. |
| Pet | A home-page pet you can feed. (Roadmap — stub only.) |
| Chat | Direct messages via the Bluesky chat proxy. |
| Blogging | Long-form posts surfaced from standard.site via the Standard Reader AppView. |
| Themes | Built-in y2kringe (dark neon) and gyaru (light) themes, plus user CSS customization and a shareable theme gallery ("steal this look"). |
| Lists | Bluesky-parity curation + moderation lists (create/edit, members, pin, mute/block-by-list, report-list). |
| Notifications | Grouped like/repost/follow/reply/mention/quote activity feed with type filters and Top Friend highlighting. |
| Saved | A private bookmarked-posts stash. |
| Moderation | Label-driven filter/blur/inform, muted words, labeler subscriptions, mute/block/report/appeal. |
| Account | Handle/email/2FA, logged-out visibility, activity privacy, birthdate, CAR export, password reset, deactivate — behind progressive step-up OAuth. |
2. Tech stack #
- SvelteKit 2 + Svelte 5 (runes:
$state,$derived,$props,$effect). - Tailwind CSS 4 (via
@tailwindcss/vite) + retro CSS variables (--twinkl-*). - TypeScript strict.
@sveltejs/adapter-node→ Docker + nginx deployment, but the server is stateless: it serves static assets, a meta-only SSR shim for public pages (OG card + owner-theme first paint),/oauth/client-metadata.json, and the OAuth assertion signer. It holds no session, no per-user data, and no database.- Browser-side OAuth:
@atproto/oauth-client-browser(public client, loopback/dev and hosted-without-a-signing-key) or a hand-assembled confidential@atproto/oauth-clientwhose signing key delegates to the stateless/oauth/assertionroute (production, whenTWINKL_OAUTH_PRIVATE_KEYis configured). The browser IS the OAuth client and holds the tokens. - IndexedDB for OAuth session/state storage and non-extractable WebCrypto DPoP keypairs, plus
the query-cache's persisted entries — no SQLite, no
better-sqlite3, no@atproto/oauth-client-node; those are gone. - A hand-rolled runes query cache (
src/lib/data/) — TTL/SWR, tag-based invalidation, in-flight dedupe, LRU, IndexedDB persistence — standing in for a library like TanStack Query (deliberately not adopted; seesrc/lib/data/query-core.ts's header). Domain-level writes are validated client-side immediately beforeagent.putRecord/uploadBlob. - A Service Worker (
src/service-worker.ts) resolves same-origin/blob/<did>/<cid>URLs to the referenced author's own PDS (com.atproto.sync.getBlob), fetched browser-direct and cached forever (content-addressed = immutable) — no blob bytes pass through the twinkl server. @atproto/apifor the XRPC agent;@dasl/tiles+tiles-sidecar/for loaded/external Web Tiles;css-treefor the client-side CSS sanitizer.- pnpm · Vite · ESLint (flat) · Prettier · Vitest.
Registry: Twinkl uses only public OSS packages. A project-level
.npmrcpins the public npm registry (registry=https://registry.npmjs.org/) so installs do not depend on any private registry.
3. Architecture: feature-sliced clean code #
Code is organized by feature, not by technical layer. Each feature is a self-contained
vertical slice. Slices never reach into each other's internals — they import only through a
sibling slice's index.ts public surface. Cross-cutting concerns live in dedicated shared
modules that slices may depend on.
┌──────────────────────────────────────────────────────────────┐
│ routes/ (thin — wiring + layout only, no business logic) │
└───────────────┬──────────────────────────────────────────────┘
│ imports public surfaces
┌───────────────▼──────────────────────────────────────────────┐
│ features/ │
│ feeds · homepage · chat · themes · theming-engine · lists · │
│ moderation · notifications · saved · account · theme-gallery │
│ · blogging │
└───────────────┬──────────────────────────────────────────────┘
│ may depend on
┌───────────────▼──────────────────────────────────────────────┐
│ cross-cutting: atproto · data · lexicons · ui · security │
└──────────────────────────────────────────────────────────────┘
Dependency rules
routes/**is thin: authed routes are pure client-rendered (ssr=false) and just mount a slice's page component; a handful of public routes keep a meta-only+page.server.ts(see §4/§9) for a crawler-safe first paint, then hand off to the same client-side slice code. No business logic lives inroutes/**either way.- A slice may import: its own internals, any cross-cutting module, and another slice's
index.ts(public surface) only. - A slice must not import another slice's internal files (
features/x/domain/...fromfeatures/y). If two slices need the same thing, lift it to a cross-cutting module. - Cross-cutting modules must not import from
features/. domain/is framework-free: pure TypeScript, no Svelte, no$app/*, no browser or SvelteKit-server imports. This is what unit tests target.
Slice anatomy #
Every feature folder follows the same shape (create files as needed, omit what you don't use):
src/lib/features/<feature>/
├── index.ts # PUBLIC surface — the only thing other slices/routes may import
├── types.ts # Feature-local types
├── domain/ # Pure business logic (framework-free, unit-tested)
├── components/ # Svelte components (UI)
└── data/
├── <topic>.ts # Framework-free, AGENT-INJECTED I/O: plain `(agent, ...args) =>
│ # Promise` functions that read/write over an injected XRPC agent.
│ # Unit-testable with a stubbed Agent — no session, no query cache,
│ # no `$app/*` import.
└── queries.svelte.ts # The ONLY file in the slice that imports the session store + the
# query cache. Defines the slice's `defineQuery`/`useQuery` reads and
# exposes the mutations components call.
Every slice is client-side: there is no server/ directory and no index.server.ts. Because the
agent-injected data/<topic>.ts modules are framework-free, they are isomorphic — a public
route's meta-shim +page.server.ts can call them with publicAgent() for a crawler-safe first
paint. Three do exactly that today: theming-engine/data/style.ts's loadActorStyle
([[actor]]), lists/data/lists.ts's loadList (lists/[rkey]), and
theme-gallery/data/gallery.ts's getThemePack (themes/[actor]).
4. Directory layout #
twinkl/
├── AGENTS.md
├── src/
│ ├── app.html # pre-hydration chrome-theme boot script (nonce'd, no session to render from)
│ ├── app.css # Tailwind import + --twinkl-* theme tokens + retro base
│ ├── app.d.ts # App.Locals is empty — no per-request user state on the server
│ ├── service-worker.ts # Blob SW: resolves /blob/<did>/<cid> to the author's PDS, browser-direct
│ ├── routes/ # SvelteKit routes (thin; mostly `ssr=false`)
│ │ ├── oauth/callback/ # ssr=false page; response_mode=fragment so codes never hit server logs
│ │ ├── oauth/client-metadata.json/+server.ts # stateless, inlines the public JWKS
│ │ ├── oauth/assertion/+server.ts # confidential-client assertion signer
│ │ └── [[actor=actor]]/ # optional actor param: public home/profile/guestbook/blog
│ │ # (+ lists/[rkey], post/[rkey], themes/[actor]) keep a meta-shim +page.server.ts —
│ │ # see §9 — everything else in this tree is CSR
│ └── lib/
│ ├── features/
│ │ ├── feeds/ · homepage/ (+ guestbook/ songs/ pet-stub) · chat/ · themes/ ·
│ │ │ theming-engine/ · theme-gallery/ · lists/ · moderation/ · notifications/ ·
│ │ │ saved/ · account/ · blogging/
│ ├── atproto/
│ │ └── client/ # browser OAuth: dual-mode client, session/roster stores, agents,
│ │ # identity resolution, repo r/w, step-up, route guard
│ ├── data/ # hand-rolled query cache: query-core (pure), query.svelte (runes
│ │ # shell), mutate (optimistic apply/settle/rollback), persist (IndexedDB)
│ ├── lexicons/ # social.twinkl.* schemas + generated/ types
│ ├── security/ # tiny pure CSP helpers shared with svelte.config.js
│ └── ui/ # retro app shell + shared flourishes
├── static/
├── lexicons/ # source lexicon JSON (social.twinkl.* + vendored ing.dasl.masl)
├── tiles-sidecar/ # DASL tiles loading server (shuttle/worker origins only)
├── deploy/host-nginx.conf.example
└── (Docker/nginx — docker-compose.yml, nginx.conf, Dockerfile)
5. Feature slices #
| Slice | Responsibility | Records / APIs | External refs |
|---|---|---|---|
blogging |
Render a user's standard.site posts (blog tab + home card) | app.standard-reader.* (read-only) |
standard-reader.app |
feeds |
Microblogging timeline, composer, Top Friends feed + highlights, saved feeds/pinned nav | app.bsky.feed.*, social.twinkl.graph.topFriends |
eurosky/bsky app |
homepage |
Customizable bento home page (Web Tiles: inline + DASL loaded/external modes, tiles-loader plumbing, authored palette tiles) + sub-modules below | social.twinkl.homepage.tile, ing.dasl.masl |
DASL Web Tiles (@dasl/tiles) |
homepage/guestbook |
Sign + display guestbook entries | social.twinkl.guestbook.post (written to commenter repo) |
microcosm/constellation linkbacks |
homepage/songs |
Profile music that plays on visit | social.twinkl.actor.profileMusic, plyr.fm |
plyr.fm API |
homepage/pet |
Feedable pet (roadmap stub) | TBD | — |
chat |
Direct messages | chat.bsky.convo.* via did:web:api.bsky.chat#bsky_chat |
eurosky/bsky app |
themes |
Built-in themes + switcher (base look only; the pack itself lives in theming-engine) |
social.twinkl.theme.pack (customBase) |
stratos webapp aesthetic |
theming-engine |
Custom CSS customization + the sanitize-at-viewer choke point (legacy homeJs deprecated by tiles) |
social.twinkl.theme.pack (homeCss/postCss/effects/background; |
MySpace |
theme-gallery |
Browse/publish/"steal" shareable theme packs via constellation discovery | social.twinkl.theme.pack (gallery), constellation |
microcosm/constellation |
lists |
Curation + moderation lists: create/edit, members, pin, mute/block-by-list, report-list | app.bsky.graph.{list,listitem,listblock} |
eurosky/bsky app |
moderation |
Label-driven filter/blur/inform, muted words, labeler subscriptions, mute/block/report/appeal | app.bsky.graph.block, app.bsky.actor.*, labeler withProxy views |
Ozone / @atproto/api moderation SDK |
notifications |
Grouped activity feed (likes/reposts/follows/replies/mentions/quotes) with type filters | app.bsky.notification.* |
eurosky/bsky app |
saved |
The viewer's private bookmarked-posts stash (bookmark toggle itself lives in feeds) |
appview-private (no dedicated record) | — |
account |
Handle/email/2FA, logged-out visibility, activity privacy, birthdate, CAR export, deactivate — progressive step-up OAuth | com.atproto.server.*, com.atproto.identity.*, app.bsky.actor.profile, app.bsky.notification.declaration |
— |
6. Cross-cutting modules #
| Module | Responsibility |
|---|---|
atproto |
Browser-side OAuth (atproto/client/: dual-mode client, IndexedDB session/state store, non-extractable DPoP keys, multi-account roster, step-up) + authenticated XRPC agents + repo getRecord/putRecord/listRecords/uploadBlob/deleteRecord, all run directly from the browser against the user's own PDS. |
data |
The hand-rolled query cache (src/lib/data/): pure TTL/SWR/tag-invalidation core, a runes shell (defineQuery/useQuery/pagedQuery), optimistic-mutation helpers, and IndexedDB persistence (allowlisted entries only). This is the app's only read cache; it lives in the browser, and there is no server-side cache. |
lexicons |
Authored social.twinkl.* schemas + generated types. |
ui |
Retro app shell, navigation, and reusable flourishes (sparkle trail, marquee, visitor counter, music player, login screen) ported from the stratos webapp. |
security |
A couple of tiny pure CSP-derivation helpers (e.g. frame-src sources for the tile loader) shared between svelte.config.js and their unit tests — small enough to live outside atproto/data but still cross-cutting in the sense that no features/ slice imports it. |
There is no server cross-cutting module anymore: no SQLite, no server-held session, no
server-side cache. The stateless server-side surfaces that remain (the meta-shim SSR loads, the
OAuth client-metadata route, the assertion signer) are documented in §9, not as a slice-facing
cross-cutting module — nothing in features/ imports them.
7. Lexicons #
All Twinkl-owned records use the social.twinkl.* namespace. Source JSON lives in
lexicons/; generated TypeScript types are emitted to src/lib/lexicons/generated/ (git-
ignored from lint).
| Lexicon | Type | Notes |
|---|---|---|
social.twinkl.guestbook.post |
record | Written to the commenter's repo. subject points at the page owner (DID/at-uri). The owner's guestbook is assembled via constellation linkbacks (target=owner, collection=social.twinkl.guestbook.post, path=.subject). |
social.twinkl.homepage.tile |
record (key=any) |
A home-page Web Tile: grid geometry (x,y,w,h + optional mobile*), an optional source (at:// URI of an external canonical tile), plus a tile payload that follows ing.dasl.masl#masl (name, optional sizing/categories, and a resources object map keyed by absolute paths such as /). Resources carry inline { contentType, content } and/or a content-addressed src (cid-link or blob ref) with optional srcHints. Inline content renders in a null-origin sandboxed iframe; content-addressed/external tiles render through the DASL tiles loader (see divergences below). |
social.twinkl.graph.topFriends |
record (rkey=self) |
Ordered list of friend DIDs (Top 8). |
social.twinkl.actor.style |
retired | Superseded by theme.pack (key self), which stores the active look. Present-day code neither reads nor writes it; the schema is retained only so records written against it still validate. |
social.twinkl.actor.profile |
record (rkey=self) |
Private viewer prefs — bedazzle (full/tokens/off). Kept separate from actor.style so it is never copied by "steal this look". |
social.twinkl.theme.defs |
defs-only | Deliberately generic shared shapes (tokens string→string map, effect {id, params}) so the token/effect set evolves in code without a lexicon migration. |
social.twinkl.theme.pack |
record (rkey=self) |
The user's active LOOK and the shareable unit (one per user). Holds customBase, generic tokens, homeCss, postCss, effects, background, assets (blobs — kept live here), provenance (sourceTheme/sourceThemeCid), priorThemeCid rollback. Published by setting the gallery property (constellation-discovered); "steal this look" is copy-on-apply into the applier's own self pack, re-homing blobs. |
social.twinkl.actor.profileMusic |
record (rkey=self) |
Selected plyr.fm track references. |
social.twinkl.authBasic |
permission-set | OAuth permission set bundling create/update/delete on all Twinkl-owned collections above. Requested via include:social.twinkl.authBasic in atproto/scopes.ts. Must be published on-network (DNS _lexicon.twinkl.social + schema record) for auth servers to resolve it — see scripts/publish-lexicon-dns.mjs. |
Web Tiles vs. canonical DASL. The tile lexicon follows the DASL Web Tiles design with deliberate divergences:
- small owner-authored tiles store inline
content+contentTypeand render in the null-originsrcdociframe (the default); resources may instead carry a content-addressedsrc(cid-link or PDS blob ref, resolved viacom.atproto.sync.getBlob) which switches the tile to the DASL loaded path (@dasl/tilesmothership +tiles-sidecar/loading server); - an optional record-level
sourceplaces an external canonical tile (at://…/ing.dasl.masl/…) rendered through the same loader; resourcesfollowsing.dasl.masl#maslas an object map keyed by absolute tile paths such as/;- grid geometry is co-located on the record with the tile content (not a separate layout doc); and
- the record
keyisany(one record per tile).
Reused (not owned by Twinkl):
- Feeds reuse
app.bsky.feed.post; chat reuseschat.bsky.*(types from@atproto/api); lists reuseapp.bsky.graph.{list,listitem,listblock}; moderation reusesapp.bsky.graph.block/the Ozone report defs; account reusescom.atproto.server.*/com.atproto.identity.*/app.bsky.actor.profile/app.bsky.notification.declaration.
ATProto records may not contain floating-point numbers — all numeric values are integers (this matters for tile grid geometry).
8. Coding conventions #
| Item | Convention | Example |
|---|---|---|
| Files | kebab-case | top-friends-feed.ts |
| Svelte components | PascalCase | PostCard.svelte |
| Reactive state modules | *.svelte.ts |
session.svelte.ts |
| Interfaces / types | PascalCase | GuestbookEntry |
| Functions / vars | camelCase | loadTopFriends |
| Constants | SCREAMING_SNAKE | DEFAULT_THEME |
- Named exports only (no default exports) except Svelte components and SvelteKit route modules.
- Strict TypeScript. Prefer
unknownoveranyin hand-written code. (The ESLint config toleratesanyfor generated/third-party shims — do not rely on that for new code.) - Svelte 5 runes;
ChangeDetection-style perf isn't a thing here, but prefer derived state and the query cache'sdefineQuery/useQuerypatterns over manual stores. - Formatting: tabs, single quotes, width 100 (Prettier). Run
pnpm format. - Comments explain why, not what. No decorative banners, no commented-out code, no redundant narration, and no phase/plan references (e.g. "P3", "phase 1") — the code should read as if it was always this way. (See the repository comment philosophy.)
9. Security model (CRITICAL) #
Twinkl is a browser-OAuth SPA: the browser itself holds the user's OAuth tokens and DPoP keys and talks to atproto directly (own PDS, public appview, plc.directory, constellation, plyr.fm, standard-reader, and — for theme/tile assets — other users' PDSes). The twinkl server is stateless: it never sees a token, never executes an authed XRPC call, and never proxies a blob. That trade is deliberate — it also means the app origin itself is a credential origin, which frames every rule below.
9.1 Rule zero: the app origin is a credential origin #
OAuth tokens and non-extractable WebCrypto DPoP keypairs live in this origin's IndexedDB. Key non-extractability blocks key exfiltration even under script injection, but a script running on this origin can still use the live session for as long as it runs (issue writes, read cached data, hold the tab open). There is no server-side custody to fall back on. Consequently:
No unsanitized user content may ever execute on the app origin. Every rule in this section exists to enforce that one invariant. Script injection prevention is THE control — not a defense-in-depth nicety.
9.2 Credential custody rules #
- Tokens and DPoP keypairs live only in IndexedDB (
atproto/client/browser-backend.ts), never inlocalStorage, never JSON-serialized. DPoP keypairs are generated non-extractable and structured-cloned, not exported. - The multi-account roster (
atproto/client/roster.ts,localStorage) carries only DIDs and handles — never tokens. It is safe to read from any tab; cross-tab account switches sync viaBroadcastChannel. - Logout = revoke + local delete.
signOut()always endsanon: it revokes the account's tokens (best-effort) and deletes its local session, but leaves every other roster account alone.removeAccount(did)("forget this account") does the same for one identity and falls back to another known account if the removed one was active. - Network-error-is-transient, never destructive: a session is only ever treated as gone on
positive evidence (
TokenRevokedError, or the library's explicit no-stored-session signal). Every other failure — aTypeError, an AS-metadata fetch failure, a timeout — is indeterminate: it surfaces as the retryableexpiredstatus and never deletes tokens or prunes the roster (seesession.svelte.ts's header for the full error-discrimination policy). A flaky network must never look like a forced sign-out.
9.3 The app-origin CSP (enforced) #
svelte.config.js's kit.csp (mode auto, so every non-prerendered response gets a fresh nonce)
plus nginx headers enforce, among others:
script-src 'self'+ kit's per-response nonce — nounsafe-inline, nounsafe-eval, no third-party script origins, ever. The only inline script in the app (app.html's pre-hydration chrome-theme boot snippet) carries the SvelteKit-issued nonce.script-src-attr 'none',object-src 'none',base-uri 'self'.style-src 'self' 'unsafe-inline' https://fonts.googleapis.com—'self'/'unsafe-inline'are safe only because the CSS sanitizer (§9.5) is the actual guard on what reaches a<style>tag, not the CSP.fonts.googleapis.comis allowlisted solely for the Google Fonts<link>embed (Comic Neue) inapp.html.font-src 'self' https://fonts.gstatic.com— theme@font-faceassets resolve same-origin through the blob Service Worker;fonts.gstatic.comis allowlisted for the Comic Neue webfont files that back the Google Fonts embed above. Accepted trade: this discloses each visitor's IP to Google on page load — the cost of embedding a hosted webfont rather than self-hosting it.img-src 'self' https: data: blob:.connect-src https: ws: wss:— cannot be pinned to a fixed host list: every user's own PDS is a legitimate destination, and which PDS that is isn't known until runtime. Do not "tighten" this into an allowlist; doing so breaks federation for any PDS host not on it. (ws:/wss:is dev's Vite HMR socket.)frame-src= the tile loader'sload.mothership + the*.<tile-base>wildcard every loaded tile's random single-use origin lives under (derived by the puresecurity/tile-frame-src.tshelper — browsers re-checkframe-srcon each redirect hop, so both must be listed) + plyr.fm's embed origin.frame-ancestors 'none'(belt-and-braced by nginx'sX-Frame-Origin: DENY),worker-src 'self',report-uri /csp-report(kept wired on the enforced set so a regression surfaces in the report log instead of silently breaking).
9.4 Web Tiles isolation #
Tiles are the primary user-authored-content surface (§1). Isolation depends on how a tile resolves
(homepage/domain/render-mode.ts):
- Script-less inline tiles render in a null-origin
<iframe srcdoc>withsandbox="allow-scripts"and noallow-same-origin— the origin is null, so even if the document carried a script it couldn't read the parent DOM, IndexedDB, or any other tile's data. - JS-bearing content, whether from an inline tile, a content-addressed tile, or an external
ing.dasl.maslrecord, is routed through the tiles-sidecar:load.<tile-base>redirects every request to a fresh<random>.<tile-base>origin per tile (the DASL spec's isolation boundary — a unique origin per tile, not the sandbox attribute). Those origins DO getallow-same-origin(their isolation IS the unique origin), so tile JS can act same-origin within its own random subdomain — reachable only if a tile domain is configured. - Enforced CSP consequence: a
srcdociframe inherits the embedder's page CSP, so an inline tile's own<script>cannot execute there under the enforcedscript-src 'self'.effectiveTileRenderMode()handles this: when a tile domain is configured, a script-bearing inline tile is routed to the sidecar (loadedmode) instead of stayinginline. Without a configured tile domain, such a tile still renders as inert HTML/CSS — the same documented degradationloaded/externaltiles have (LoadedTile.svelte'sunavailablestate). - A separate registrable domain for the tile base is REQUIRED in production, now on storage-origin grounds rather than cookie grounds: the app sets no cookies at all (§9.7), but several browser storage/tracking-protection mechanisms (Storage Access API grants, SameSite "site" groupings) still treat same-registrable-domain subdomains as one site. A genuinely separate domain keeps tile-authored JS fully cross-site from the app's own IndexedDB-held credentials.
- Tiles never get a token or an agent. The sidecar must never receive twinkl cookies (there are none) or proxy an authenticated request; it only ever serves tile shuttle/service-worker machinery on its random origins.
9.5 User CSS: sanitize in the viewer's browser, at render time #
There is no server left to sanitize on the way in. Instead, theming-engine's StyleBlock.svelte
is the single choke point: it accepts RAW, untrusted author CSS (a theme.pack's
homeCss/postCss, straight off the PDS) and calls the unchanged pure css-tree-based
sanitizeUserCss synchronously in the viewer's own browser, on every render — never once at
write time, never cached pre-sanitized. Consequences, both good and bad, that are accepted
on purpose:
- The author never controls the viewer's sanitizer — a sanitizer bugfix takes effect for every viewer on next app load, with no stored-CSS reprocessing migration needed.
- Injection is memoized per
(did, rev)so an unchanged record is parsed once per session, not once per render;css-treeitself lazy-loads (~40kB gz) on first actual use. - Output is set via
node.textContent, never{@html}/innerHTML— a</style>breakout is structurally impossible regardless of what the sanitizer missed. - The
url()allowlist grammar is unchanged and stays same-origin-only, bound to the author's own/blob/<authorDid>/<cid>path (not the viewer's) — physical resolution is the blob Service Worker's job (§9.6), not the sanitizer's. postCss(post-decoration styling) goes through the exact same sanitizer + scoping ashomeCss— it is CSS-only and never executes JS, but it renders inside the viewer's authenticated feed, so it gets no special trust.
9.6 The blob Service Worker #
src/service-worker.ts intercepts same-origin GET /blob/<did>/<cid> (the LOGICAL URL form that
stays in every theme.pack record and the sanitizer's url() grammar), resolves did to its PDS
via the same identity chain the OAuth client uses, fetches com.atproto.sync.getBlob browser to
PDS directly, and caches the response in the Cache API forever, keyed by the (did, cid)
pair (content-addressed = immutable; the pair — not cid alone — closes cross-author cache
poisoning, since nothing here verifies the fetched bytes actually hash to cid). A MIME allowlist
(images + woff/woff2 fonts only) plus a 5MB cap plus nosniff close the residual content-type-
confusion risk of an otherwise-unrestricted same-origin URL. Accepted trade: the viewer's own
IP is now disclosed to the theme/tile asset's author's PDS — the same trade every other
browser-direct read in this app makes. A render-time URL-rewrite fallback
(data/blob-fallback.ts) covers browsers/contexts where the SW isn't controlling the page.
9.7 No cookies at all #
The app sets zero cookies. There is no session cookie to protect with __Host-, and no CSRF
surface to defend — writes are DPoP-bound, per-request-signed XRPC calls a cross-site request
literally cannot forge, so CSRF is structurally eliminated rather than mitigated. Tokens are never
postMessaged to any frame; tile iframes carry referrerpolicy="no-referrer".
9.8 OAuth / step-up rules #
- No
transition:*scope, ever — enforced byatproto/client/metadata.ts's fail-closed metadata guard and asserted by tests acrossclient-metadata.test.ts/stepup.test.ts/stepup.client.test.ts. Only granular, least-privilege scopes are requested (atproto/scopes.ts). - Declared-vs-requested split:
TWINKL_DECLARED_SCOPES(base ∪ every step-up token) is what the OAuth client metadata advertises (PAR requires every requested scope to be a subset of the declared set);TWINKL_BASE_SCOPESis whatsignInactually requests at login — no sensitive account-management grant rides there. - Step-up union invariant: a step-up re-authorization computes its scope by union, never by replacing the base grant, and introspection failure during that flow aborts rather than silently proceeding with an empty union.
- The confidential assertion signer (
POST /oauth/assertion) holds only the CLIENT's identity key (TWINKL_OAUTH_PRIVATE_KEY) — it never sees a user token, DID, or session, and only ever rebuilds a JWT from a same-origin, size-capped, whitelisted claim set (never signs caller- supplied material verbatim).
9.9 Must not be introduced #
The following would each break the trust model in §9.1 and must not be added without revisiting
this section: server-held OAuth session cookies, CSRF tokens, Domain-scoped cookies,
server-side token custody of any kind, a SQLite (or any other) server-side session/data store, a
blob-proxying server route, and a server-side CSS/JS sanitizer step. The only
authenticated-adjacent server surfaces that may exist are the meta-shim's public-only PDS/appview
reads (guarded by url-safety.ts's SSRF checks) and the assertion signer described above — no
other authenticated server surface may be added without revisiting this section.
9.10 What the operator can and cannot observe #
Cannot observe (this is the point of the browser-OAuth design): tokens, DPoP keys, DMs, likes, reposts, follows, blocks, mutes, reports, moderation settings, theme/tile content the user writes, or which posts a user reads — none of it touches the twinkl server at all.
Can still observe: standard web-server telemetry against the stateless surfaces it does
serve — which static assets/HTML shell a browser requested and when (access logs on the meta-shim
and asset routes), aggregate traffic to /oauth/client-metadata.json and /oauth/assertion
(the latter reveals that some client refreshed a token at some IP, not whose), and the tiles-
sidecar's own access logs for load.<tile-base> redirects (which reveal that some tile was loaded,
not by whom, since the sidecar carries no cookies or account context). CSP violation reports land
in /csp-report.
10. Per-phase code-review gate (process) #
Twinkl is built phase by phase. Every phase ends with a code-review gate:
- A reviewer (sub-agent) performs a standard code review of the phase's diff against the repository review guidelines, returning findings classified CRITICAL / MAJOR / MINOR / SUGGESTION.
- All CRITICAL and MAJOR findings (and reasonable MINOR ones) are fixed before the phase is considered done.
- Re-verify:
pnpm check(0 errors / 0 warnings),pnpm lint,pnpm build, andpnpm test.
A phase is not "done" until its review gate is green. (Comments and docs should never call out a phase number once the phase lands — see §8's "no phase references" rule; this section is about the process, not something the shipped code should mention about itself.)
11. Commands #
pnpm install # install (uses public npm via .npmrc)
pnpm dev # dev server (http://127.0.0.1:5179)
pnpm build # production build (adapter-node)
pnpm preview # preview the build
pnpm check # svelte-kit sync && svelte-check (must be 0/0)
pnpm lint # prettier --check . && eslint .
pnpm format # prettier --write . && eslint --fix .
pnpm test # vitest run (domain + data + atproto/client + query-cache unit tests)
No database setup, no migrations, no server-side data directory — the server is stateless. See
.env.example for the (small) set of environment variables (TWINKL_PUBLIC_URL,
TWINKL_OAUTH_PRIVATE_KEY, PUBLIC_TILE_LOAD_DOMAIN, TILE_BASE_HOST) and docker-compose.yml/
deploy/host-nginx.conf.example for deployment.
Before submitting any change: pnpm check (0 errors, 0 warnings), pnpm format,
pnpm lint, pnpm test.
12. Reference repositories #
Twinkl is assembled from patterns proven in these codebases (in this workspace unless noted):
| Repo | Role |
|---|---|
stratos/webapp |
Design/aesthetic reference — retro CSS, sparkle/marquee/music components, OAuth + feed patterns. |
blento |
Historical card-system reference — the prior home page was built on app.blento.card; superseded by Web Tiles. The pure grid layout solver (layout.ts) was retained. |
eurosky-social-app |
Behavioral reference for feeds + chat, AND the proven full-recipe reference for the browser-OAuth model itself — confidential browser OAuth + IndexedDB tokens + non-extractable DPoP keys + Web Locks + step-up + multi-account switching all follow its pattern (React Native fork of bsky — do not port RN code, only UX/flows/auth architecture). |
| standard-reader.app | Blogging AppView API (app.standard-reader.*). |
| plyr.fm | Songs API (https://api.plyr.fm, lexicons fm.plyr). |
| microcosm / constellation | Backlink index for guestbook linkbacks and theme-gallery discovery (https://constellation.microcosm.blue). |
13. Testing #
- Unit (Vitest, ~1082 tests across 83 files):
- Each slice's
domain/pure functions — feed assembly, Top Friends highlight resolution, the CSS sanitizer, list/report validation, the constellation linkback query builder, lexicon validation. Framework-free, importable with no browser or SvelteKit runtime. - Each slice's
data/<topic>.tsmodules — agent-injected functions tested with a stubbedAgent, no real network, no session, no query cache. - The query cache itself:
query-core.ts's pure TTL/SWR/tag-index/LRU/persistence-filter suite,query.svelte.ts's runes-shell behavior,mutate.ts's optimistic apply/settle/rollback,persist.ts's IndexedDB write-behind. atproto/client/: the session state machine (mocked IndexedDB + mocked OAuth facade, exercising the error-discrimination policy in §9.2), identity resolution (mocked fetch), roster/roster-mirror,stepup.client's grant/deny/wrong-account/abandon paths, the assertion signer's whitelist rules, and the OAuth client-metadata builder'stransition:-scope guard.
- Each slice's
- Manual live-PDS smoke matrix (dev loopback per change; full https matrix on staging before a
prod swap): login/refresh/multi-tab/cross-tab logout; multi-account add/switch/remove (per-DID
cache isolation, revoke-on-remove, cross-tab switch sync); step-up grant/deny/wrong-account/
abandon across each step-up feature; feed/thread/profile rendering both authed and logged-out
(including the
!no-unauthenticatedoverride); post/like/report/mute/block/hide; saved; lists CRUD + modlist; chat A↔B; tiles inline/loaded/JS-via-sidecar; theme apply + steal-look + malicious-CSS probes; guestbook sign; blob SW cache hits; CAR export; OG cards rendering for a crawler UA; a clean CSP report log.