twinkl.social
twinkl AGENTS.md
55 kB
Markdown
at main

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-client whose signing key delegates to the stateless /oauth/assertion route (production, when TWINKL_OAUTH_PRIVATE_KEY is 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; see src/lib/data/query-core.ts's header). Domain-level writes are validated client-side immediately before agent.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/api for the XRPC agent; @dasl/tiles + tiles-sidecar/ for loaded/external Web Tiles; css-tree for the client-side CSS sanitizer.
  • pnpm · Vite · ESLint (flat) · Prettier · Vitest.

Registry: Twinkl uses only public OSS packages. A project-level .npmrc pins 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 in routes/** 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/... from features/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; homeJs) 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 + contentType and render in the null-origin srcdoc iframe (the default); resources may instead carry a content-addressed src (cid-link or PDS blob ref, resolved via com.atproto.sync.getBlob) which switches the tile to the DASL loaded path (@dasl/tiles mothership + tiles-sidecar/ loading server);
  • an optional record-level source places an external canonical tile (at://…/ing.dasl.masl/…) rendered through the same loader;
  • resources follows ing.dasl.masl#masl as 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 key is any (one record per tile).

Reused (not owned by Twinkl):

  • Feeds reuse app.bsky.feed.post; chat reuses chat.bsky.* (types from @atproto/api); lists reuse app.bsky.graph.{list,listitem,listblock}; moderation reuses app.bsky.graph.block/the Ozone report defs; account reuses com.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 unknown over any in hand-written code. (The ESLint config tolerates any for 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's defineQuery/useQuery patterns 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 in localStorage, 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 via BroadcastChannel.
  • Logout = revoke + local delete. signOut() always ends anon: 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 — a TypeError, an AS-metadata fetch failure, a timeout — is indeterminate: it surfaces as the retryable expired status and never deletes tokens or prunes the roster (see session.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 — no unsafe-inline, no unsafe-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.com is allowlisted solely for the Google Fonts <link> embed (Comic Neue) in app.html.
  • font-src 'self' https://fonts.gstatic.com — theme @font-face assets resolve same-origin through the blob Service Worker; fonts.gstatic.com is 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's load. mothership + the *.<tile-base> wildcard every loaded tile's random single-use origin lives under (derived by the pure security/tile-frame-src.ts helper — browsers re-check frame-src on each redirect hop, so both must be listed) + plyr.fm's embed origin.
  • frame-ancestors 'none' (belt-and-braced by nginx's X-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> with sandbox="allow-scripts" and no allow-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.masl record, 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 get allow-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 srcdoc iframe inherits the embedder's page CSP, so an inline tile's own <script> cannot execute there under the enforced script-src 'self'. effectiveTileRenderMode() handles this: when a tile domain is configured, a script-bearing inline tile is routed to the sidecar (loaded mode) instead of staying inline. Without a configured tile domain, such a tile still renders as inert HTML/CSS — the same documented degradation loaded/external tiles have (LoadedTile.svelte's unavailable state).
  • 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-tree itself 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 as homeCss — 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 by atproto/client/metadata.ts's fail-closed metadata guard and asserted by tests across client-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_SCOPES is what signIn actually 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:

  1. 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.
  2. All CRITICAL and MAJOR findings (and reasonable MINOR ones) are fixed before the phase is considered done.
  3. Re-verify: pnpm check (0 errors / 0 warnings), pnpm lint, pnpm build, and pnpm 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>.ts modules — agent-injected functions tested with a stubbed Agent, 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's transition:-scope guard.
  • 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-unauthenticated override); 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.