Outlands SkinForge
A web tool to preview and share UO Outlands human skin, slot, and hue combinations before buying in-game.
15,862 lines · 159,130 words · 29 sections · Sep 3, 2026
Outlands SkinForge — Product Specification #
A web tool for designing, previewing and sharing UO Outlands character skins.
Outlands SkinForge lets a UO Outlands player pick a body, a skin hue, and a cosmetic item and hue for every paperdoll slot, see the result two ways — a live-composited paperdoll and per-slot static previews — and share the exact combination through a permanent link that carries its own social preview image. Behind the public site sits a staff-only pipeline that discovers and extracts art and hue data from the operator's own copy of the game client, an admin console for reviewing and publishing those assets, and server-side hosting of every rendered image. There are no user accounts, no adverts, and no reason for a player to point a game directory at anything.
This document is written to be executed cold by an AI coding agent or a development team. Every decision it needs has been made. Each concern has exactly one owning section; every other section refers to that section by number rather than restating it.
Table of Contents #
- Before You Start — Customization Decisions
- Project Overview & Vision
- Domain Primer — UO Outlands Paperdolls, Layers, Hues & Skins
- Technology Stack & Architecture
- Repository Layout, Conventions & Coding Standards
- Data Model & Database Schema
- Asset Discovery & Extraction Pipeline
- Rendering Engine — Hue Math, Compositing & Encoding
- Asset Ingestion, Versioning & Patch Re-Sync
- Public Web Application — Information Architecture & Routing
- Designer UI — Components, State & Interaction
- Preview Surfaces — Live Paperdoll & Per-Slot Static Previews
- Permalinks & Design Encoding
- Open Graph Image Generation
- Catalog Browse, Search & Filtering
- Public HTTP API
- Admin Console
- Design System, Visual Language & Accessibility
- Performance, Caching & CDN Strategy
- Security, Privacy & Legal Posture
- Observability — Logging, Metrics & Analytics
- Testing Strategy & Quality Gates
- Deployment, Infrastructure & Operations
- Configuration & Environment Variables
- Error Catalog & Edge Case Register
- Milestones & Execution Plan
- Executor Instructions
- Appendices
1. Before You Start — Customization Decisions #
1.1 Purpose #
This section lists every point in this specification where the executing agent may substitute its own value for a working default without breaking the rest of the document. Every other section is written as if the defaults below are already chosen. Nothing downstream is blocked on a decision: if a row is left untouched, the build proceeds exactly as specified elsewhere. If a row is changed, the "Blast radius" column names every section that must be re-read and, where applicable, every test suite in Section 22 that must be re-run before the build is considered done.
No decision in this document is deferred to "later," left as an open question, or handed back to a human for clarification. Every choice below already has a working default. Sections 2 through 28 are written against these defaults and never repeat the phrase "the customer should decide" — that phrasing does not appear anywhere in this specification outside this sentence.
1.2 Decision table #
| ID | Question | Working default | Used in | Blast radius of changing it |
|---|---|---|---|---|
| D-01 | Deployment target | Single Linux VPS, Docker Compose (app + Postgres + optional MinIO + Caddy) | 4.8, 23.1–23.4 | Low if staying container-based; high if moving to a managed platform — re-derive 23.2 provisioning steps and 23.6 backup jobs; re-check 19.3 CDN origin config. |
| D-02 | Object storage driver | fs (local disk under the app container's volume) |
4.7, 6, 23.3, 24 | Switching to s3 requires setting the five SKINFORGE_S3_* variables in Section 24 and re-running the storage integration tests named in Section 22.4; no schema change, because asset_images.storage_key is driver-agnostic in Section 6. |
| D-03 | Public domain name | skinforge.example (placeholder, executor supplies real value) |
10.1, 13, 14, 20, 24 | Cosmetic only. Sets SKINFORGE_PUBLIC_BASE_URL (Section 24); affects OG absolute URLs (Section 14) and sitemap host (Section 10.9). No code change. |
| D-04 | Donation link shown | Enabled, pointing to SKINFORGE_DONATION_URL |
2.6, 10.1, 24 | If disabled (empty env var), the /support route (Section 10) renders without the donation button; no other page changes. |
| D-05 | Hue-group curation policy for the skin group |
Heuristic keyword/range tagging at import (Section 7.7), with staff able to add or remove members afterward in the admin console (Section 17.8) | 3.6, 6, 17.8 | Switching to a fully staff-curated allowlist (no automatic tagging at all) removes the automatic pre-population in Section 7.7 and requires staff to build the group from empty on every new build; the hue_group_members.method column (Section 6) records which path — manual or heuristic — assigned each membership, so this is a config-level toggle, not a schema change. |
| D-06 | Render scales offered to the public | 1, 2, 3 (integer nearest-neighbour upscale) |
6, 7.8, 8, 12, 16, 19 | Removing scale 3 reduces render cache volume (Section 19.4), shrinks the @:scale enum validated in Section 8.8, and requires a new migration relaxing CHECK (scale IN (1,2,3)) on asset_images and design_renders (Sections 6.12, 6.19) plus updates to D6's scale loop (Section 7.8 step 3). |
| D-07 | Default image format for previews | webp, with png always available via the .png render URL suffix |
8.7, 12, 14, 19 | Changing the default to png increases bytes served (Section 19.1 budgets assume WebP); update the <source type="image/webp"> ordering in Section 19.6's <picture> markup. No server-side negotiation exists to change (Section 8.8). |
| D-08 | Number of admin users at launch | 2 (one primary, one backup), both with mandatory TOTP | 17.2, 24 | Adding more admins is a CLI operation (Section 17.2) with no spec impact; removing TOTP is not offered as an option — Section 20 treats it as a fixed control. |
| D-09 | TOTP enforcement | Mandatory for every admin account, no exceptions | 17.2, 20.3 | Not adjustable. Listed here only to document that it was considered and rejected as optional, per the security posture in Section 20. |
| D-10 | CDN in front of the origin | Cloudflare (or any CDN that honors Cache-Control and offers a purge API) in front of the reverse proxy, optional but recommended |
19.3, 23.4, 24 | Running without a CDN is fully supported: the origin's own Cache-Control headers (Section 8, 19) still cache correctly at the browser and at any transparent proxy; SKINFORGE_CDN_PURGE_URL stays empty and purge calls in Section 9.5 become no-ops. |
| D-11 | Backup retention | 14 daily + 8 weekly + 6 monthly Postgres backups, object storage mirrored to a second location on the same rotation (Section 23.6 owns the exact schedule) | 23.6 | Shortening retention lowers storage cost and recovery window; update the retention numbers in 23.6's backup table only. |
| D-12 | Application log retention | 30 days for structured request logs at info level; audit_log rows are retained indefinitely and never pruned (Section 6.33) |
21.1, 20.7 | Lowering request-log retention reduces disk usage; audit_log retention is not adjustable — Section 20.8's legal posture requires a permanent record. |
| D-13 | Public API rate limits | 120 requests/minute per IP for /api/v1/*, 60 renders/minute per IP for /render/*, 30 design creations/hour per IP (defaults; owned by Section 24.2) |
16.7, 19, 24 | Raising limits increases render CPU exposure (Section 19.5); lowering them risks false-positive throttling for shared-NAT users (e.g. mobile carriers, campus networks) — Section 25's SF-3002 errors already document the resulting user-facing message. |
| D-14 | Brand name | "Outlands SkinForge" | throughout | Renaming is a find-and-replace across Sections 2, 10, 14, 18; the SKINFORGE_ env var prefix (Section 24) is a code-level constant and does not need to match the display brand name. |
| D-15 | Brand primary colour | #3F5FD8 (indigo), on a dark slate background #14161C |
18.2 | Purely visual; changing it updates only the design tokens in Section 18.2 and any pre-rendered OG background art regenerated per Section 14.5. |
| D-16 | Catalog indexability by search engines | Indexable: /catalog/*, /hues/*, /about, /faq, /legal, /support, /changelog allowed in robots.txt; /d/* permalinks are indexable too (they are the shareable content); /admin/*, /api/*, and /design disallowed |
10.6 | Blocking /d/* from indexing (e.g. to avoid thin-content concerns) is a one-line robots.txt change in Section 10.6; no functional impact elsewhere. |
| D-17 | Locale | English (en) only |
5.8, 10.2 | Adding a locale means adding a core/i18n/<locale>.ts file (Section 5.8) and extending the language negotiation in the root middleware (Section 10.2); no schema change is required because all user-facing strings already route through core/i18n/en.ts. |
| D-18 | Image download permission | Public downloads allowed for the composite preview and OG image at all offered scales, unauthenticated, no watermark | 12.7, 8.8 | Restricting downloads (e.g. requiring a Referer check) is a single guard in the render route handler (Section 8.10) with no schema or data-model change. |
| D-19 | Third-party analytics | Disabled entirely; only first-party server-side counters (Section 21) | 2.6, 21, 24 | Turning on a third-party analytics script would contradict the no-cookies-for-visitors posture in Section 20.7's privacy posture; not offered as a toggle. Listed to record that it was considered and rejected. |
| D-20 | Maintenance-mode behaviour | When SKINFORGE_MAINTENANCE_MODE=true, all public routes return a static 503 page with Retry-After: 300; /admin/* stays reachable for staff |
23.9, 24, 25 | Changing the retry hint only affects the header value; changing which routes stay reachable during maintenance changes the middleware allowlist described in 23.9. |
| D-21 | Seed data size for local development | 2 bodies × 6 sample assets per slot × 12 sample hues, enough to render a full composite without running the real extraction pipeline | 6.31, 22.10 | Increasing seed volume only changes the row counts inserted by deno task db:fixtures; it never changes the seed script's shape. |
| D-22 | Unofficial fan-project disclaimer placement | Persistent footer strip on every public page plus a dedicated /legal page |
2.8, 10.4, 18.11, 20.8 | Moving it to a dismissible banner is not permitted — Section 10.4 requires it to be persistent and non-dismissible; this row exists only to record that constraint's rationale. |
| D-23 | Session cookie name and TTL for admin | Cookie sf_admin, 30-minute idle timeout plus a 12-hour absolute timeout from created_at (SKINFORGE_ADMIN_SESSION_TTL_HOURS=12, Section 17.1 owns the exact mechanism) |
17.1, 24 | Shortening the TTL only changes the env var default; the cookie name is referenced by exact string in Section 20.4's CSRF notes and should stay in sync if renamed. |
| D-24 | Minimum supported viewport | 360px width (small mobile) up to unbounded desktop, single responsive layout, no separate mobile app | 2.5, 18 | Not adjustable without contradicting the "responsive website only" boundary from Section 2.5; listed for completeness. |
| D-25 | Job runner concurrency | In-process job runner claims up to 4 concurrent jobs (SKINFORGE_RENDER_MAX_CONCURRENCY default 4) |
4.7, 19.8, 24 | Raising concurrency trades CPU contention on the single VPS (Section 4.8) for faster import/render throughput; lowering it protects public-request latency during a large import run. |
1.3 Statement of completeness #
Every default in Section 1.2 is already applied throughout Sections 2–28. The executing agent may build the entire product by implementing this document literally, changing nothing. No decision in this table blocks any other section: every working default is self-sufficient, every env var referenced has a default value in Section 24, and every schema referenced in the "Used in" column already supports both the default and the commonly anticipated alternative (for example, D-02's storage driver and D-05's curation method).
1.4 How to change a default safely #
Follow this procedure for any row in Section 1.2 the executor wants to change:
- Locate the owning sections. Read every section number listed in that row's "Used in" column, in order, in full.
- Change the value at its single source of truth. Most defaults resolve to exactly one of: an
environment variable default in Section 24, a constant in
core/config.ts(Section 5.1), or a row in a lookup table seeded by a migration (Section 6). Change it in that one place. Do not duplicate the value elsewhere — every other section that mentions the concept references the owning section instead of restating the value, so there is nothing else to edit. - Re-run the affected automated checks. Cross-reference the section numbers touched against
Section 22's test map:
- Changing D-01, D-02, D-10, or D-11 (infrastructure/storage) → re-run the storage and deployment integration suite (Section 22.4) and the backup/restore drill (Section 23.6).
- Changing D-06 or D-07 (render scales/formats) → re-run the golden-image tests (Section 22.3) at every scale/format combination now offered.
- Changing D-13 (rate limits) → re-run the rate-limit contract tests (Section 22.4) and confirm the
SF-3002error responses in Section 25 still match the new thresholds in any updated fixtures. - Changing D-05 or D-21 (curation/seed data) → re-run
deno task db:fixturesand the catalog integration tests (Section 22.4). - Changing D-14, D-15, D-16, D-17 (brand/visual/locale) → re-run the accessibility checks
(Section 22.6) and confirm
core/i18n/en.tsstill resolves every key used by the changed components.
- Update the changelog. Record the change in the developer's own
CHANGELOG.mdper the Conventional Commits convention in Section 5.3; this document itself is not versioned per-decision. - Do not introduce a second default. If a decision needs a per-environment override (for example,
a different rate limit in staging versus production), express it as a different value for the same
environment variable in each environment's
.envfile (Section 24), never as new code branching on environment name insidecore/.
1.5 Decisions explicitly not offered #
The following are constraints from the hard scope boundaries and are not customization points. They are listed here once so the executor does not mistake their absence from Section 1.2 for an oversight: elf or gargoyle bodies, animated or multi-directional previews, user accounts or login, a full equipment/armor catalog, in-game account integration, native mobile apps, advertising, and paid tiers. Each is covered as an explicit non-goal in Section 2.5 and, where relevant, as a boundary check in Section 25's edge-case register.
1.6 How decisions interact #
Most rows in Section 1.2 are independent: changing one has no effect on any other row's default. A small number interact, and the executor should read the related row before changing either one:
- D-01 (deployment target) and D-02 (storage driver). The
fsstorage driver assumes the application container and its storage volume are co-located, which is only guaranteed under the single-VPS default in D-01. Moving to a multi-host deployment without also switching D-02 tos3will leave later web process replicas unable to see renders written by earlier ones; Section 4.8 states this dependency as part of the horizontal-scaling path. - D-06 (render scales) and D-13 (rate limits). Offering more render scales increases the CPU cost
of a cache-miss burst (Section 19.5); if D-06 is widened beyond
1, 2, 3, re-check the render rate limit in D-13 against the new worst-case cost per unique design rather than assuming the existing number still protects the single-VPS CPU budget in Section 4.10. - D-07 (default format) and D-19 (analytics). These are independent in mechanism but both feed
the same performance metric in Section 2.6 (time to first preview); changing D-07 to
pngas the default changes the baseline that metric is measured against, so the target number in Section 2.6 should be re-validated, not just the code path. - D-10 (CDN) and D-11 (backup retention). A CDN in front of the origin reduces load on the object storage backing D-02, which in turn changes how urgently a storage restore (Section 23.6) needs to complete during an incident, because cached copies at the CDN edge continue serving reads while the origin recovers. This does not change the backup retention numbers themselves, only the operational urgency of a restore, which is documented in Section 23.6's runbook rather than in this table.
- D-08 (admin user count) and D-09 (TOTP enforcement). These are listed as separate rows because one is adjustable and one is not, but they are provisioned together: every admin user created via the CLI (Section 17.2), regardless of how many there are per D-08, is created with mandatory TOTP per D-09 — there is no code path that creates an admin account without it.
- D-21 (seed data size) and D-05 (curation policy). Local development seed data always uses the
manualcuration method for its seededskinhue group members (Section 6), regardless of which method D-05 designates for production, so thatdeno task db:seednever depends on the heuristic classifier being present or tuned.
No other rows in Section 1.2 have a dependency relationship; every remaining row may be changed in isolation following the procedure in Section 1.4.
1.7 Quick reference by concern #
For an executor who wants to find every customization point touching one area of the system without re-reading the full table in Section 1.2, the rows group as follows:
| Concern | Rows |
|---|---|
| Infrastructure and deployment | D-01, D-02, D-10, D-11, D-25 |
| Domain and branding | D-03, D-14, D-15, D-22 |
| Catalog and content policy | D-05, D-16, D-21 |
| Rendering and media | D-06, D-07, D-18 |
| Security and access | D-08, D-09, D-23 |
| Operations | D-12, D-20 |
| Product policy | D-04, D-13, D-17, D-19, D-24 |
This grouping is a navigation aid only; the authoritative default, usage, and blast-radius for every row remains the table in Section 1.2.
1.8 What "no decision blocks the build" means in practice #
An executor reading this specification top to bottom, applying every default in Section 1.2 without changing a single value, produces a complete, deployable, correctly functioning product. This is verified structurally: every default resolves to a concrete value already written into its owning section (an env var default in Section 24, a schema default in Section 6, a constant in Section 5.1), never to a placeholder, a range without a chosen point, or a cross-reference back to this section asking the executor to pick. Section 1 is read exactly once, at the start of the build, specifically so the executor can decide up front whether any default needs to change before writing the first line of code — not because any later section is incomplete without that decision.
2. Project Overview & Vision #
2.1 The problem, in the players' words #
UO Outlands sells cosmetic customizations — skin hues, hair and beard styles, tattoos, backpack dyes, and clothing dyes — through in-game currency (doubloons) and cosmetic vendors (the Wig Stand and related NPCs). A player deciding whether to spend that currency faces three concrete, recurring frustrations:
- Buying blind. The in-game purchase flow shows a swatch or a name, not the player's own character wearing the result. A player cannot see "my human female in hue 1402 skin with hair style X in hue 1102" before spending currency that, once spent, is not refunded.
- No way to compare combinations. A player who owns three hair styles and is considering a fourth cannot see all four side by side on their own body shape without changing their live character repeatedly and taking screenshots by hand, then flipping between image files.
- No way to share a look. When a player finds a combination they like, or wants a second opinion from a guildmate before spending doubloons, the only sharing mechanism is a screenshot posted to a chat channel or forum — lossy, hard to reproduce exactly, and impossible for the recipient to open and remix into their own variant.
Quantified in gameplay terms: a full cosmetic wardrobe covers 18 choosable slots (Section 3.3) each with potentially dozens of asset choices and thousands of possible hues (Section 3.6). The combinatorial space a player might want to preview before buying is in the hundreds of thousands of distinct looks. No in-game UI attempts to browse that space; the game's own interface is built for selecting one item at a time from a vendor list, not for comparison shopping.
2.2 What SkinForge is #
Outlands SkinForge is a free, public, no-login web tool that lets a UO Outlands player assemble a character's cosmetic appearance — body, skin hue, and up to 18 choosable hueable slots — see the result as both a live composited paperdoll and individual per-slot preview images, and generate a permanent, shareable link that reproduces that exact combination for anyone who opens it, complete with a social preview image for chat and forum links.
Capabilities:
- A designer interface for choosing a body (male/female), a skin hue, and an asset plus hue for each of the 18 choosable cosmetic slots (Section 11).
- A live client-side composited paperdoll preview that updates instantly as choices change (Section 12).
- Static per-slot preview images showing each chosen asset in isolation (Section 12).
- A permanent, immutable link per exact combination, with no account required to create or open one (Section 13).
- An automatically generated Open Graph preview image for every permalink, so links posted in Discord or forums show the character, not a blank card (Section 14).
- A browsable catalog of every available asset and hue, searchable and filterable, for players who want to explore before designing (Section 15).
- A staff-only pipeline that discovers, extracts, and classifies art and hue data from the operator's own local copy of the game client files (Section 7), and an admin console to review, approve, and publish that data, including re-import after game patches (Sections 9, 17).
- A single optional donation link; no ads, no paid tiers, no purchases of any kind inside the tool itself (Section 2.6, Section 1 D-04).
2.3 Personas #
| Persona | Goals | Context | Device | Job they hire the tool for |
|---|---|---|---|---|
| The shopper | Decide whether a specific cosmetic is worth the doubloons before buying it in-game | Mid-session or between sessions, often reacting to a vendor listing they just saw | Mostly desktop while the game client is open in another window; sometimes phone while away from keyboard | "Show me this hair style on my exact body and skin hue so I know if I'll like it before I spend currency." |
| The theorycrafter | Explore the full combinatorial space of looks, compare many options side by side, plan a full outfit | Dedicated browsing session, not concurrent with playing | Desktop, multiple browser tabs | "Let me try dozens of combinations quickly and keep the ones I like." |
| The community sharer | Show off a look, get opinions, start a trend | Posting to Discord, guild forums, or social media | Phone or desktop, whichever they were already using | "Give me one link that shows exactly what I built, with a nice preview image, that anyone can open." |
| The staff curator (admin) | Keep the catalog accurate and complete after every game content patch, with minimal manual effort and no risk of publishing broken art | Working directly against their own local Outlands client install, on a schedule tied to patch releases | Desktop, admin console plus command-line tooling | "Point the tool at my updated client files, review what changed, and publish it without breaking any existing shared link." |
2.4 Primary user journeys #
Journey A — Design a look from scratch.
- Visitor opens
/(Section 10). No login prompt, no interstitial. - Visitor picks body (male/female) and a skin hue from a swatch picker (Section 11.6).
- Visitor opens a slot (e.g. Hair), browses available assets for that slot and gender, picks one, then picks a hue for it (Section 11.6). The live paperdoll preview updates immediately (Section 12.2), composited entirely in the browser.
- Visitor repeats step 3 for any of the other 17 choosable slots, in any order, any number of times.
- Visitor clicks Share. The current combination is canonicalized, hashed, and persisted
server-side (Section 13); the browser navigates to the resulting permalink URL,
/d/:code. - Visitor copies the URL or uses a native share sheet; the page itself already carries Open Graph tags pointing at the generated preview image (Section 14).
Journey B — Open a shared permalink and remix it.
- Recipient clicks a
/d/:codelink shared in chat. The link unfurls with a preview image and title before it is even opened, because the OG image and tags are pre-generated (Section 14). - The page server-renders the full paperdoll composite immediately, so the look is visible even before any client-side JavaScript loads (Section 8.10, Section 10.3).
- Recipient clicks "Open in Designer." The permalink's design JSON (Section 13) hydrates the designer island's state; the recipient is now editing a copy, never the original — the original permalink is immutable (Section 13.7).
- Recipient changes one or more slots and shares a new permalink of their own.
Journey C — Browse the catalog, then jump into the designer.
- Visitor opens
/catalog(Section 15) and filters by slot, then by gender, then by hue group. - Visitor finds an asset they like, opens its detail view showing it on both bodies across a spread of hues.
- Visitor clicks "Try this in the designer," which opens the designer with that slot pre-filled and every other slot at its default empty state.
- Visitor continues as in Journey A from step 3.
Journey D — Staff import after a game patch.
- Outlands ships a client patch. Staff curator runs the discovery stage of the CLI (Section 7, Stage D1) against their local, updated client directory.
- The CLI reports newly discovered or changed source files and stages an import run (Section 9.1).
- Staff curator reviews extraction candidates in the admin console (Section 17.6): new assets,
changed hues, anything flagged
needs_operator_input(Section 7's degrade-gracefully rule). - Staff curator approves the candidates that are correct, corrects classification for anything ambiguous, and publishes the new build (Section 9.5).
- Every existing permalink continues to render exactly as before, because designs pin a
build_id(Section 13.7) and retired assets stay renderable (Section 6's soft-delete rule); only new designs or edits can pick up the newly published assets.
2.5 Goals and explicit non-goals #
Goals:
- Let any UO Outlands player preview any combination of the two bodies, one skin hue, and the 18 choosable cosmetic slots, each with its own hue, before spending in-game currency.
- Produce a permanent, exact, remixable record of any combination, shareable as a single URL.
- Make that URL look good when unfurled in chat apps and forums, with zero manual effort from the player.
- Give staff a repeatable, low-effort process for keeping the catalog in sync with the live game client after patches, without ever breaking an already-shared link.
- Stay free, ad-free, and account-free indefinitely, funded only by an optional donation link.
Non-goals (explicit, matching Section 1.5's decisions-not-offered list exactly):
- Elf or gargoyle bodies. UO Outlands has no elf or gargoyle player bodies; this is not a limitation of SkinForge, it is a fact about the game.
- Animated previews or multiple facing directions. The paperdoll is a single static pose; SkinForge ships exactly that one view, at multiple render scales (Section 8.6), never an animation.
- User accounts, login, or saved galleries. Every visitor is anonymous; the only persistence mechanism is the permalink itself.
- A full clothing, armor, or equipment catalog. SkinForge models exactly the layers needed to render the visible cosmetic look (Section 3.3, Section 3.5); it is not an equipment or stat planner.
- In-game integration, purchasing, or applying a skin to a real account. SkinForge never connects to the game client, the game server, or any Outlands account system.
- Native mobile apps. The product is a single responsive website (Section 1 D-24).
- Advertising or paid subscription tiers. The only monetization surface is the optional donation link (Section 2.6 constraint, Section 1 D-04).
2.6 Success metrics #
All metrics are computed from first-party server-side data only — no third-party analytics, per the no-cookies-for-visitors, no-PII architecture decision (Section 1 D-19) and the observability model in Section 21.
| Metric | Target | How measured |
|---|---|---|
| Designs created per week | 500+ within three months of launch | Count of INSERT rows into designs per ISO week, from stat_counters (Section 6, Section 21.2). |
| Permalink open rate | At least 2 opens per design created, averaged over a rolling 30 days | page_view_daily rows for /d/:code routes divided by designs created in the same window (Section 21.6). |
| Share-image fetch rate | At least 1 OG image fetch per 3 designs created | Count of og_images cache-hit + cache-miss requests logged by the render access log (Section 14.5, Section 21.2). |
| Catalog coverage | 95%+ of in-game cosmetic-eligible assets represented within 14 days of a patch that adds them | assets.status = 'published' count divided by extraction_candidates count for the relevant game_build_id (Section 9.2). |
| Time to first preview | Under 1.5 seconds from first paint to a visible composited paperdoll, at the 75th percentile, on a mid-tier mobile device | Synthetic measurement in CI against the Section 19.1 reference device profile, recorded per release; no client-side beacon is shipped, consistent with D-19's no-third-party-script posture. |
| Staff time per patch re-import | Under 30 minutes of staff review time for a typical minor patch (under 50 new assets) | Self-reported duration field staff enters when closing an import run in the admin console (Section 17.5), stored on import_runs. |
2.7 Constraints and assumptions #
- The operator has, or will obtain, a local copy of the UO Outlands client files. SkinForge never distributes, downloads, or bundles any Outlands game asset itself; it only reads from a local directory the operator supplies to the CLI, and only server-side, only for staff.
- The exact on-disk layout, file formats, and compression schemes used by that client install are unknown at specification time. This document does not assume a specific MUL/UOP/other layout. Section 7 defines a staged discovery capability — inventory, identify, probe, extract, classify, normalize — specifically because the layout must be discovered against the real files, not assumed from a stale reference.
- UO Outlands may change its client format, add new hue ranges, or restructure asset packaging in a future patch. The pipeline's staged design (Section 7) and the immutable-build model (Section 9, Section 13.7) are built so a format change degrades to a review queue, never a crash and never a silent data loss.
- The tool has no relationship with, and is not endorsed or sponsored by, UO Outlands, Broadsword Online Games, or Electronic Arts. It exists as a fan-made companion tool. See Section 2.8.
- Traffic is expected to be modest and bursty (spikes around patch announcements and popular shared links), well within the single-VPS scaling posture described in Section 4.8.
2.8 Unofficial fan-project posture #
Outlands SkinForge is an unofficial, fan-made tool built by and for the UO Outlands player community. Section 10.4 owns the exact disclaimer text rendered on every public page: "Outlands SkinForge is an unofficial fan project. It is not affiliated with, endorsed by, or sponsored by UO: Outlands, Broadsword Online Games, or Electronic Arts." It reads only from art and data files the operator already possesses through their own legitimate client installation, hosts only derived preview imagery necessary to serve its stated purpose, and makes no claim of ownership over any underlying game asset. This posture is stated persistently on every public page (Section 1 D-22) and detailed in full, including takedown procedure, in Section 20.8.
2.9 Glossary #
Every UO-specific and SkinForge-specific term used in this document — gump, layer, tiledata, MUL, IDX, UOP, hue, partial hue, paperdoll, doll art, ClassicUO, doubloon, wig stand, and others — is defined once, precisely, in Section 3.9, with a consolidated glossary appendix in Section 28.
2.10 Risks and mitigations #
| Risk | Impact if unmitigated | Mitigation | Owning section |
|---|---|---|---|
| The operator's client files use an on-disk layout not anticipated by this specification's assumptions | Import pipeline fails to extract any usable art, blocking catalog population | Discovery is staged and format-agnostic: unknown containers are fingerprinted and queued for staff review rather than assumed away, and the pipeline never crashes on an unrecognized format | Section 7 |
| A game patch changes hue ranges or asset ids between imports | Newly published designs render incorrectly, or existing permalinks appear to change | Designs pin an immutable build_id; retired assets stay renderable forever; new builds are staff-reviewed before publish |
Section 9, Section 13.7 |
| A shared permalink stops rendering because an asset was removed from the catalog | Broken links reduce the share-image fetch metric (Section 2.6) and damage trust in the permalink model | Soft-delete only: catalog rows never hard-delete, they gain a retired_at timestamp and remain renderable indefinitely |
Section 6, Section 9.7 |
| A single-VPS deployment experiences a traffic spike around a popular shared link or a patch announcement | Degraded latency or downtime during the moment the product is most visible | Immutable, aggressively cached renders mean steady-state traffic after first view is served without compositing work; rate limits bound abusive traffic | Section 8.9, Section 19, Section 1 D-13 |
| A rights holder requests removal of specific derived imagery | Legal exposure if no process exists to respond | A documented takedown path that can retire specific assets or designs without taking the whole product offline | Section 20.8 |
| Staff curator time per patch grows unbounded as the catalog grows | The success metric in Section 2.6 (staff time per re-import) is missed, and the catalog falls behind the live game | Diffing between builds surfaces only what changed, not the full catalog, for every review pass | Section 9.2 |
The product's overall risk posture favors mechanisms that degrade gracefully — a review queue instead of a crash, a retired asset instead of a broken link, a cached render instead of a compute spike — over mechanisms that require perfect upfront knowledge of the game client's internals, because that knowledge is explicitly unavailable at specification time (Section 2.7).
2.11 Why a dedicated tool, not an existing alternative #
Players currently approximate SkinForge's job with tools and habits that each fall short in a way that motivates building a dedicated product rather than recommending a workaround:
| Existing approach | Why it falls short |
|---|---|
| Changing the look on a live character in-game, then screenshotting it | Costs real doubloons before the player can confirm they like the result; only shows one combination at a time; produces a lossy screenshot, not a reproducible, remixable record. |
| Asking in a Discord or forum channel and hoping someone has the item to compare | Depends on another player happening to own the exact item and being willing to log in and screenshot it; no systematic coverage of the catalog. |
| Community-maintained spreadsheets or image galleries of individual cosmetics | Shows each item in isolation, never composited with a player's own body, skin hue, and other worn items; quickly goes stale as new cosmetics are added and is not interactive. |
| General-purpose UO art viewers built for other UO shards or the base game | Not aware of UO Outlands' own custom art and hue additions (Section 3.7); require the visitor to already have and configure a local client install, which the constraints in Section 2.7 explicitly rule out for SkinForge's own end users. |
SkinForge's contribution is specifically the combination of: zero setup for the visitor, full compositing across every hueable slot at once, and a permanent shareable record — none of the existing approaches provide all three together.
2.12 North star statement #
If SkinForge succeeds, a UO Outlands player never spends doubloons on a cosmetic without first seeing, on their own body shape, exactly what it will look like combined with everything else they already wear — and every combination worth remembering has a link that still works, unchanged, years later.
3. Domain Primer — UO Outlands Paperdolls, Layers, Hues & Skins #
This section teaches an executor with no prior Ultima Online knowledge everything needed to build SkinForge correctly. Read it before Sections 6 through 14; those sections assume this vocabulary and these rules without re-explaining them.
3.1 What a paperdoll is #
In Ultima Online (and every client built on its art conventions, including the ClassicUO-based Outlands client), a character's on-screen inventory and appearance summary is called the paperdoll. It is a flat, front-facing, static 2D illustration of the character standing in a single neutral pose, built by stacking pre-drawn sprite images — one per equipped or worn item — on top of a base body sprite, in a fixed drawing order. There is no 3D model, no skeleton, no animation frames involved in a paperdoll: it is layered 2D art, the same technique used by paper doll toys, hence the name.
The paperdoll art area is 260 pixels wide by 330 pixels tall (Section 8.1 fixes this as the composite canvas). Every sprite involved in a paperdoll — the body, and every worn item — is pre-drawn at a size and offset that positions it correctly inside that fixed canvas. SkinForge does not scale, rotate, or reposition sprites relative to each other; it reproduces exactly what the in-game paperdoll shows, at integer pixel scales (Section 8.6).
Because the entire visual is 2D sprite compositing, SkinForge's rendering problem reduces to: pick the right sprite for each occupied layer, recolor it if a hue applies, and stack the results in the correct order. There is no lighting, no perspective, and no 3D geometry anywhere in the render path (Section 8 owns the full algorithm).
3.2 Bodies #
UO Outlands has exactly two player character bodies available for cosmetic design purposes:
| Body | Body code | Base gump | Notes |
|---|---|---|---|
| Human male | m |
0x000C (12) |
|
| Human female | f |
0x000D (13) |
These are the only two bodies SkinForge supports. UO as a franchise has other playable races in some rule sets (elf, gargoyle) but UO Outlands' player character bodies for the purposes this tool covers are human only, male and female — this is a fact about the game, not a limitation SkinForge imposes (Section 2.5 states this as a non-goal boundary, not an omission).
The base gump number (0x000C / 0x000D) is the identifier the original client art format uses for
the nude/base body sprite that every other layer draws on top of. SkinForge's own code never needs to
special-case these numbers outside the asset extraction pipeline (Section 7), where they are the seed
values used to locate body art during classification (Stage D5).
Gender-specific art variants. Some cosmetic assets have separate artwork for male and female
bodies (because the sprite must fit differently over each body shape); others share one sprite drawn
to fit both. The asset_variants table (Section 6) records, per asset, which bodies it has art for.
Rule when an asset has no variant for the visitor's chosen gender: that asset is not offered in
the picker for that slot when the visitor's body is set to the gender lacking a variant (Section
11.6 filters the asset list by body before rendering it). If a permalink was created for one body and
the design is later inspected in the designer with the other body selected, a slot whose chosen
asset has no variant for the new body is retained but flagged incompatible: it contributes no layer
to the composite while incompatible, and switching back to the original body restores it
automatically, because nothing was cleared. This is the rule for gender/body mismatches; Section
11.13 owns the exact recovery behavior and warning copy, and Section 13 owns dropping a
still-incompatible entry from the canonical document at Share time.
3.3 The canonical slot registry #
SkinForge composites exactly 19 layers per character: one required body layer and 18 choosable
cosmetic slots the visitor can leave empty or fill, backpack included. backpack is always visible
in the rendered output, but it is a choosable slot exactly like any other — the visitor may pick its
asset and hue — it simply falls back to a default asset when the design carries no entry for it,
rather than disappearing the way any other empty slot does. The table below is the canonical
registry — every other section that needs slot order, display names, or gender rules references this
table by section number rather than repeating it.
| z | slot_key | Display name | uo_layer | Hueable | Gender notes |
|---|---|---|---|---|---|
| 10 | body |
Body | — | yes (skin hue) | Required, always present. The base nude sprite for the chosen body code, recolored with the skin hue. |
| 20 | tattoo_body |
Body Tattoo | — (Outlands cosmetic) | yes | Both genders. An Outlands-specific cosmetic layer drawn directly on the body, beneath clothing. |
| 30 | footwear |
Footwear | 3 | yes | Both. Boots, shoes, sandals. |
| 40 | legs_inner |
Pants | 4 | yes | Both. Base legwear, drawn under any skirt/kilt layer. |
| 50 | torso_inner |
Shirt | 5 | yes | Both. Base torso covering, drawn under any chest piece. |
| 60 | torso_middle |
Chest Piece | 17 | yes | Both. Tunics, vests, breastplates when worn as a cosmetic layer rather than a stat item. |
| 70 | arms |
Arms | 19 | yes | Both. Sleeves and arm coverings, drawn over the shirt. |
| 80 | gloves |
Gloves | 7 | yes | Both. |
| 90 | waist |
Belt / Sash | 12 | yes | Both. |
| 100 | legs_outer |
Skirt / Kilt | 23 | yes | Both. Drawn over pants. |
| 110 | torso_outer |
Robe / Outer Torso | 22 | yes | Both. The outermost torso garment; drawn over the chest piece and arms. |
| 120 | neck |
Neck | 10 | yes | Both. Collars, scarves, gorgets worn as cosmetics. |
| 130 | hair |
Hair | 11 | yes | Both, but the two genders draw from different style sets, because hairstyle art is drawn to fit each body's head shape and silhouette. |
| 140 | facial_hair |
Beard | 16 | yes | Male only. Shown disabled with an explanatory tooltip when body is f (Section 11.5), never removed from the rail. |
| 150 | face |
Face Art | 15 | yes | Both. Face paint, warpaint, and similar tight-fit facial cosmetics distinct from a full body tattoo. |
| 160 | earrings |
Earrings | 18 | yes | Both. |
| 170 | head |
Hat / Helm | 6 | yes | Both. Cosmetic headwear; drawn last among the "worn item" layers so it sits over hair. |
| 180 | cloak |
Cloak | 20 | yes | Both. |
| 190 | backpack |
Backpack | 21 | yes | Both. Always visible on every rendered character, because the in-game paperdoll always shows a backpack — but it is a choosable slot like any other: the visitor may pick its asset and hue. When the design carries no entry for it, it renders with the default asset backpack.default at hue 0 (unhued). |
Layers 24 and 13 are the classic client's alternate ids for the same visual slot as legs_inner and
torso_inner respectively; SkinForge records only the primary id shown above, which is the one
classification (Section 7.6) matches on.
That is 19 rows total: one required body slot (row z=10) and 18 choosable cosmetic slots — the 17
rows a visitor may leave empty, plus the always-visible backpack slot (row z=190), whose asset and
hue are visitor-changeable exactly like any other choosable slot. A choosable slot left empty
contributes nothing to the composite — it is simply skipped during compositing (Section 8.4 owns the
composite order), not rendered as a transparent placeholder. backpack is the one choosable slot
that never actually renders empty, because it falls back to the default asset backpack.default at
hue 0 when the design carries no entry for it.
3.4 Composite order and why z-order matters #
The z column in Section 3.3 is the literal paint order: the renderer draws slot body first, then
tattoo_body, and so on up through backpack last, alpha-compositing each non-empty layer's sprite
on top of everything drawn so far (Section 8.4 owns the exact pixel algorithm). Getting this order
wrong produces a character that looks structurally wrong even though every individual sprite is
correct — for example, if torso_outer (a robe) were drawn before torso_middle (a chest piece),
the chest piece would incorrectly appear to float on top of the robe instead of being covered by it.
Worked example — five slots stacking, human female, reading bottom to top:
body(z=10): the base female nude sprite, recolored to skin hue 1002.legs_inner(z=40): plain trousers, hue 0 (as-drawn).torso_inner(z=50): a linen shirt, hue 1302.torso_outer(z=110): a hooded robe, hue 1802, drawn over the shirt — the shirt's sleeves remain visible below the robe's shorter sleeve art because the sprites are drawn to the correct relative proportions by the original artists; SkinForge does not need any collision logic, only correct order.hair(z=130): a long braid style, hue 1102, drawn last in this example — visible because noheadslot is occupied to cover it.
If a head slot were added at z=170, it would draw after hair and would visually cover the top of
the braid where a hat naturally would, without SkinForge writing any per-slot occlusion rule; the
z-order table alone produces correct results because the original sprites were drawn with this
convention in mind.
3.5 Excluded layers #
SkinForge intentionally does not model the following classic UO layers: one-handed weapon (layer 1), two-handed weapon (layer 2), ring (layer 8), bracelet (layer 14), talisman (layer 9), and it does not model any stat-bearing armor or weapon catalog beyond the cosmetic slots in Section 3.3.
Rationale. SkinForge is a skin designer, not an equipment or combat-loadout planner. Weapons and jewelry in UO Outlands are primarily functional items chosen for combat statistics, not cosmetic identity, and including them would expand scope into balance-sensitive game data (damage, resists, enchantments) that is out of date the moment it is captured and is irrelevant to the "what does my character look like" question SkinForge answers. Excluding them keeps the asset pipeline (Section 7) focused on purely cosmetic art and keeps the catalog (Section 15) free of stat comparisons that would require constant balance-patch maintenance.
3.6 Hues #
A hue in UO is a recoloring instruction applied to a grayscale-shaded sprite at render time, rather than a separate full-color image per color variant. This is why one piece of art (say, "long braid hair") can be offered in hundreds of colors without hundreds of separate sprite files: the artist draws the sprite once, using shades of gray to represent light and shadow, and the hue system remaps those gray shades to a chosen color ramp at draw time.
The hues.mul structure. The classic hue data file is organized as 375 blocks of 8 hues each
(3,000 hues total, indices 1 through 3000; index 0 is reserved, see below). Each of the 8 hues within
a block contains:
- A 32-entry color table: 32 packed 16-bit colors forming a ramp from dark to light, used to remap a sprite's gray shades to this hue's color ramp.
- A start/end byte pair: metadata the original client used for certain rendering effects (partial application boundaries); SkinForge's importer records these values for provenance but the render algorithm in Section 8.3 uses only the 32-entry table.
- A 20-byte name field: a fixed-width ASCII name for the hue, used for display in the catalog (Section 15) and admin console (Section 17).
Do not confuse a hue block — the 8-hue grouping in the file layout described above, a fact about
how hues.mul is laid out on disk — with a hue group, the curated, staff-maintained grouping of
hues used for browsing and filtering (hue_groups, Section 6.14). The two are unrelated concepts
that happen to share a word.
ARGB1555 packing. Each of the 32 colors in a hue's table is a 16-bit value in ARGB1555 format: 1 bit alpha, 5 bits red, 5 bits green, 5 bits blue. The exact bit layout, most significant bit first, is:
bit: 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
field: A R R R R R G G G G G B B B B BUnpacking a 16-bit ARGB1555 value into 8-bit RGB channels, and the transparency convention that
applies when a packed value is exactly 0x0000, are specified once, with a single reference
implementation, in Section 8.3.1 — this section introduces the bit layout conceptually but does not
restate the unpacking function.
Hue index 0 means "unhued." A hue value of 0 is not a lookup into the table at all; it is the
sentinel meaning "draw the sprite's own original colors, unmodified." Every choosable slot in Section
3.3 accepts hue 0 as a valid choice with this meaning, and the design JSON (Section 13.1) encodes
it the same way. The renderer's exact handling of hue 0 is owned by Section 8.3.3.
Full hue vs. partial hue. UO's hue system has two application modes:
- Full hue remaps every non-transparent pixel of the sprite: each pixel is looked up in the 32-entry table by table index, and that entry's color replaces the pixel entirely, regardless of the pixel's original color.
- Partial hue applies the same table-lookup process but only to pixels that are already gray/neutral in the source art (used for effects like "just recolor the metal parts of an item, leave the leather parts alone" in the original game). SkinForge's asset importer records, per sprite, whether a partial-hue flag was present in the source data (Section 7's provenance record); this specification does not require SkinForge to expose "partial vs full" as a visitor-facing choice — it is a property of the asset, decided by the original artist, not by the visitor.
How a pixel's table index is derived — the exact bit arithmetic, the grey test that gates partial hue, and the reference implementation — is owned entirely by Section 8.3; this section supplies only the conceptual model above, so a reader who has not yet reached Section 8 understands what a hue is before Section 8 defines exactly how one is applied to a pixel.
3.7 UO Outlands specifics #
UO Outlands runs a client based on ClassicUO, an open-source, modernized UO client, but ships its
own art assets and its own custom hue additions layered on top of (or alongside) the base UO hue
space described in Section 3.6. SkinForge must treat everything Outlands-specific as data the
pipeline discovers, never as a hard-coded assumption, because the exact custom hue index ranges,
the exact set of Outlands-added cosmetic slots such as tattoo_body, and the exact asset ids are not
known at specification time (Section 2.7 states this constraint explicitly; Section 7 is the staged
discovery capability that resolves it against the real files at import time).
What is known and modeled as fact, because it comes from the game's documented cosmetic economy rather than from undiscovered binary layouts:
- Outlands sells skin hues as a cosmetic customization (through the Wig Stand and related
cosmetic vendors, paid for with doubloons, the game's cosmetic currency). SkinForge models this
as the
skinhue group (Section 3.6, Section 6), a curated subset of the hue space tagged during import per the curation policy in Section 1 D-05. - Outlands sells hair and beard styles as cosmetics through the same vendors; the game does not
publish a fixed enumerated list of style ids anywhere outside the client art itself. SkinForge
stores every hair or beard style as a plain catalog
asset(Section 6) with aslot_keyofhairorfacial_hairrespectively, discovered and classified by the pipeline (Section 7) exactly like any other cosmetic asset — no different in structure or handling from a footwear or cloak asset. - Tattoos and backpack dyes are sold the same way, cosmetically, mapping to the
tattoo_bodyandbackpackslots respectively (Section 3.3). - The wardrobe and in-game paperdoll preview UI that inspired this tool already show
gender-restricted items — for example, beard styles are only ever shown for male characters. This
is the precedent SkinForge follows exactly for the
facial_hairslot's male-only rule (Section 3.3, Section 11.5).
SkinForge models these facts about the cosmetic economy in its taxonomy and admin workflows; it does not model or replicate Outlands' purchasing flow, doubloon balances, or vendor inventory — that would be in-game integration, an explicit non-goal (Section 2.5).
3.8 What "a skin" means in SkinForge terms #
Within this specification, "a skin" (the noun, matching the product name) means exactly this tuple:
one body code (m or f), one skin hue applied to the body slot, and a set of zero-or-more
entries for the 18 choosable cosmetic slots (Section 3.3, backpack included), each entry naming
one asset and one hue. This tuple, canonicalized and hashed, is the entire content of a permalink.
The exact JSON shape, canonicalization rule, and hashing algorithm that turn this concept into a
storable, shareable, immutable record are owned by Section 13; this section defines only the concept,
Section 13 defines its encoding.
3.9 Terminology table #
| Term | Definition |
|---|---|
| Gump | In classic UO art terminology, a 2D image resource identified by a numeric id, used for paperdoll art, UI art, and item icons. SkinForge uses "gump id" only during asset extraction (Section 7) to identify source sprites; it is never exposed in public-facing URLs or JSON. |
| Layer | A numbered equipment/body slot in the classic UO client's paperdoll compositing system (e.g. layer 3 = footwear). SkinForge's uo_layer column (Section 3.3) records this for provenance and classification; SkinForge's own slot_key is the identifier used everywhere else in this specification. |
| Tiledata | A UO client data file describing properties of every static and mobile art tile, including flags SkinForge's classifier uses to help determine what a piece of art represents (Section 7, Stage D5). |
| MUL | A classic UO file format: a flat binary blob of concatenated records, almost always paired with an IDX file that supplies offsets and lengths into the MUL. |
| IDX | The index file paired with a MUL file: a sequence of fixed 12-byte entries (lookup offset, length, extra data) that locate each record inside the corresponding MUL file. |
| UOP | MythicPackage, a container format used by newer UO-family clients in place of loose MUL/IDX pairs, identified by the magic bytes MYP\0, containing versioned blocks and a chain of entries addressed by a 64-bit hash of their internal path rather than a simple index. |
| Hue | A recoloring instruction — a table of 32 colors plus metadata — applied to a grayscale-shaded sprite at render time. Full definition in Section 3.6. |
| Partial hue | A hue application mode that recolors only the neutral/gray pixels of a sprite, leaving already-colored pixels untouched. Full definition in Section 3.6. |
| Paperdoll | The flat, static, front-facing 2D compositing of a character's body and worn items into one illustration. Full definition in Section 3.1. |
| Doll art | Colloquial term for the specific sprite variant of an item drawn to be shown on the paperdoll, as distinct from the item's in-world or inventory-icon art. SkinForge's asset pipeline extracts doll art specifically (Section 7, Stage D4). |
| ClassicUO | An open-source, modernized Ultima Online client that UO Outlands' own client is built on. SkinForge never runs or embeds ClassicUO; it only reads static art/data files that happen to be organized the way a ClassicUO-family client expects. |
| Razor | A popular third-party UO client assistant/automation tool. Mentioned only for completeness; SkinForge has no interaction with Razor. |
| Doubloon | UO Outlands' cosmetic currency, used to purchase skin hues, hair/beard styles, tattoos, and similar cosmetics from in-game vendors. SkinForge never handles doubloons or any currency; it only models the resulting cosmetic catalog. |
| Wig Stand | An in-game NPC/fixture in UO Outlands where players spend doubloons to change hair, beard, and skin-hue cosmetics. Mentioned as the real-world source of the skin and hair hue/asset catalog SkinForge models. |
3.10 Worked example: unpacking and applying a hue end to end #
This subsection intentionally carries no worked example of its own. The hue algorithm — table-index derivation, unpacking, and pixel application — is specified exactly once, in Section 8.3, and its one worked example lives in Section 8.3.6, with additional worked examples in Appendix B (Section 28.2); publishing a second, independently-computed set of numbers here would risk the two drifting apart, so this section does not duplicate them. Read Section 8.3 for the full algorithm and its worked arithmetic; this section's role is limited to the conceptual introduction to hues in Section 3.6.
3.11 Gender and slot interaction edge cases #
Beyond the base rule in Section 3.2 (an asset with no variant for the visitor's chosen body is not offered, and is retained-but-flagged if the body changes underneath an existing choice), three further cases are decided here so the executor never has to invent behavior at build time:
- Switching body with
facial_hairoccupied. Becausefacial_hair(Section 3.3) is male-only, switching the body frommtofleaves the slot's stored selection untouched but incompatible — it contributes no layer to the composite while body isf, and Section 11.13 shows the same warning-badge treatment as any other gender-incompatible slot, even though the underlying reason (a slot restriction, not a missing art variant) is different from the general case in Section 3.2. Switching back tomrestores it automatically, because nothing was cleared. - An asset published for only one gender is later given an art variant for the other gender in a
subsequent build. The existing
asset_variantsrow set (Section 6) simply gains a new row for the newly available body; no retroactive change is made to any design that was created before the new variant existed, because a design's rendering is pinned to itsbuild_id(Section 13.7) and its original choice (or lack of one, if the slot was previously incompatible) is preserved exactly as made. - A slot with a gender-neutral display name but gendered art (e.g.
hair). The slot itself is offered identically for both genders (Section 3.3 markshairas "both, different style sets"); the asset picker (Section 11.6) filters the list of selectable assets to only those with a variant for the current body, so a visitor never sees a hairstyle they cannot actually select — this differs fromfacial_hair, where the entire slot control is shown disabled with an explanatory tooltip (Section 11.5) rather than filtering a list, becausefacial_hairis not offered for female bodies at all, not merely absent for the visitor's current asset choice.
3.12 Why hue math and slot rules are decided here, not left to the pipeline #
An executor might expect the discovery pipeline (Section 7) to determine rules like "beard is male only" or "hue table has 32 entries" from the source data at import time, since the pipeline is explicitly designed to discover unknown facts (Section 2.7). These particular facts are different in kind from what the pipeline discovers: they are stable properties of the UO art format and of UO Outlands' own game design that do not vary between client builds or patches, unlike asset ids, hue index ranges, or on-disk file layout, which do vary and are exactly what Section 7's staged discovery resolves. Hard-coding the stable facts in this section, while leaving the genuinely variable facts to discovery, keeps the pipeline focused on what actually changes patch to patch.
3.13 Domain facts quick-reference table #
The following table consolidates the fixed numeric facts introduced throughout this section, for an executor who needs to check a constant while implementing Sections 6 through 14 without re-reading the full prose.
| Fact | Value | Defined in |
|---|---|---|
| Composite canvas size | 260 × 330 px | 3.1 |
| Number of player bodies modeled | 2 (human_male, human_female) |
3.2 |
| Body identifiers | m, f (display names "Male", "Female") |
3.2 |
| Base gump ids | 0x000C (male), 0x000D (female) |
3.2 |
| Total composited layers | 19 (1 required body + 18 choosable, backpack included) |
3.3 |
| Excluded classic UO layers | 1, 2, 8, 9, 14 | 3.5 |
Hue blocks in hues.mul |
375 | 3.6 |
| Hues per block | 8 | 3.6 |
| Total addressable base hue indices | 3,000 (1–3000) | 3.6 |
| Hue color table entries per hue | 32 | 3.6 |
| Hue name field width | 20 bytes, ASCII | 3.6 |
| Color packing format | ARGB1555 (1/5/5/5 bits) | 3.6 |
| Unhued sentinel value | Hue index 0 | 3.6 |
| Hue application modes | Full, partial | 3.6 |
| IDX entry size | 12 bytes (lookup, length, extra) | 3.9 |
| UOP magic bytes | MYP\0 |
3.9 |
3.14 Summary of what this section hands to later sections #
Section 3 is a reference, not an implementation. The tables and rules above are consumed directly by: the slot registry and z-order by Section 6 (schema) and Section 8 (render algorithm); the hue structure and bit-packing math by Section 8.3; the body/gender rules by Section 11.6 (asset picker filtering) and Section 13 (design encoding); the excluded-layer rationale by Section 15 (catalog scope) and Section 28 (appendix reference tables); and the terminology table by every other section that uses a UO-specific term, none of which re-define it.
4. Technology Stack & Architecture #
4.1 Stack table #
Section 4 is the ONLY place this specification states dependency versions. Every other section refers to "the version in Section 4" rather than repeating a number.
| Dependency | Version line | Role | Why chosen |
|---|---|---|---|
| Deno | 2.x | Runtime for both the web process and the staff CLI | Native TypeScript execution with no separate build/transpile step for server code, a built-in permissions model well suited to a tool that reads arbitrary operator-supplied files during import, a single toolchain (deno fmt, deno lint, deno test, deno task) replacing a typical Node project's five separate tools. |
| Fresh | 2.x (jsr:@fresh/core) |
Web framework: routing, server rendering, islands | Islands architecture ships zero JavaScript for pages that need none (catalog browsing, static permalink viewing) and only the interactive designer component's JS to the browser; server-side rendering by default keeps permalinks crawlable and fast without a client-side router. |
| Preact | 10.x | UI component runtime, used by Fresh | Small runtime footprint keeps the designer island's JS bundle light, directly compatible with Fresh's islands model. |
| Preact Signals | @preact/signals 2.x |
Client and server state primitives inside islands | Fine-grained reactivity without a virtual-DOM diff on every keystroke; well suited to a designer UI with many independent per-slot state pieces (Section 11.2). |
| Tailwind CSS | 4.x, via Fresh's official Tailwind plugin | Styling | Utility-first styling keeps the design tokens (Section 18.2) centralized in one config and avoids hand-maintaining a separate CSS file per component. |
| Deno standard library | jsr:@std/* 1.x |
HTTP helpers, assertions, path utilities, etc. | Audited, dependency-free, versioned in lockstep with Deno itself. |
| PostgreSQL | 18 | System of record for all metadata | Mature transactional guarantees, native FOR UPDATE SKIP LOCKED for the job queue (Section 4.7), strong JSON column support for the design/canonical-JSON fields (Section 6, Section 13), no separate database technology needed for search (Section 15 uses Postgres full-text search). |
npm:postgres |
3.x (postgres.js) | Postgres driver | Fast, promise-native, prepared-statement support, works cleanly under Deno's npm compatibility layer without an ORM's code-generation step. |
| Zod | 4.x | Runtime schema validation | One schema module per domain object (Section 5.4), shared verbatim between server route handlers and client-side islands, single source of truth for validation rules referenced throughout Sections 11, 13, 16. |
@jsquash/png |
3.x | PNG encode/decode (WASM) | Pure WASM codec with no native binary dependency, works identically in the Deno server process and (via the same package) in the browser for client-side compositing parity (Section 8.12, Section 22.3). |
@jsquash/webp |
1.x | WebP encode/decode (WASM) | Same rationale as the PNG codec; WebP is the default served format (Section 1 D-07) for its smaller byte size at equivalent visual quality on pixel art with transparency. |
satori |
0.33.x | SVG layout engine for Open Graph images | Lets Section 14's OG image layout be expressed as JSX/Preact-like markup with CSS-like styling rather than manual canvas drawing calls, while staying pure-JS/WASM. |
@resvg/resvg-wasm |
2.x | SVG-to-PNG rasterization (WASM) | Pairs with satori's SVG output to produce the final OG PNG, again with no native binary dependency. |
| Playwright | 1.62.x | End-to-end browser testing | Cross-browser automation for the designer flow and no-JS progressive-enhancement checks (Section 22.5). |
jsr:@std/ulid |
1.x | ULID generation | Sortable, URL-safe, collision-resistant surrogate ids for every table's primary key (Section 6.2). |
lucide-preact |
0.x | Icon set | The single icon library used by every icon-only control in the design system (Section 18.6), packaged as Preact components so tree-shaking removes unused icons. |
@fresh/plugin-tailwind |
0.x (jsr) | Tailwind 4 build integration | The official Fresh 2 plugin that wires Tailwind's build step into fresh.config.ts (Section 5.1); this is the "Fresh's official Tailwind plugin" named in the Tailwind row above. |
Put this paragraph, verbatim, wherever this specification is read as the authoritative statement on dependency freshness:
These version lines are a known-good floor, not a lockfile. At build time, install the current stable release of each dependency (
deno add jsr:@fresh/core,npm install <pkg>@latest, or your ecosystem's equivalent), confirm the major line still matches, and let the lockfile record the exact resolved versions.
4.2 Why Deno + Fresh here #
Four properties of this specific product favor Deno + Fresh over alternative stacks:
- One language, one runtime, two processes. The public web app and the staff CLI (Section 4.3)
share the entire
core/domain library (hue math, compositing, format parsers, schema). A single TypeScript codebase running on a single runtime means the compositing algorithm used for live client-side preview, server-side permalink rendering, and staff-side extraction validation is provably the same code, not three reimplementations that can drift. - Islands avoid a heavyweight client bundle for pages that do not need one. Most of SkinForge's
traffic is people opening a permalink (Journey B, Section 2.4) or browsing the catalog (Journey C).
Those pages are almost entirely static content once rendered; Fresh ships them with no JavaScript
beyond what a specific interactive widget (e.g. a hue swatch hover) needs, as an isolated island.
Only
/, which hosts the full designer, loads the larger designer island bundle;/designis not a separate page — it permanently redirects to/(Section 10.5). - Server-side rendering by default keeps permalinks crawlable and fast. Chat-app and forum link
unfurlers (Journey B, step 1) and search engine crawlers never execute JavaScript reliably; Fresh
renders the full page, including the composited paperdoll's
<img>tag pointing at a server-rendered PNG/WebP (Section 8.10), on the server for every request, so the content is present in the initial HTML response. - A single language for CLI and web removes a translation layer. The staff import pipeline
(Section 7) is not a separate service in a separate language calling into the web app's API; it is
a Deno CLI in the same repository, importing the same
core/modules the web app imports, so a change to the hue math (Section 8.3) or the compositing algorithm (Section 8.4) cannot silently go out of sync between "how the CLI validates an extracted asset" and "how the web app renders it."
4.3 Process topology #
Three processes, one database, one storage target, in front of a reverse proxy and (optionally) a CDN:
┌─────────────────────┐
(optional)│ CDN │ (Section 1 D-10, Section 19.3)
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ Caddy (reverse │ TLS termination, HTTP/2,
│ proxy, TLS) │ routes to the web process
└──────────┬───────────┘
│
┌────────────────────▼────────────────────┐
│ Deno process: web app │
│ Fresh routes (public + admin) + JSON API │
│ + render endpoints (Section 8, 16) │
│ + in-process job runner (Section 4.7) │
└───────┬───────────────────────┬───────────┘
│ │
┌──────────▼─────────┐ ┌──────────▼──────────┐
│ PostgreSQL 18 │ │ Object storage │
│ (metadata, │ │ (fs or S3-compat, │
│ jobs, sessions) │ │ Section 4.7) │
└──────────▲──────────┘ └──────────▲───────────┘
│ │
┌───────┴──────────────────────────┴────────┐
│ Deno process: skinforge-cli (staff-only) │
│ discovery, extraction, import, publish │
│ (Section 7, Section 9), same core/ library │
└──────────────────────────────────────────────┘The web process and the CLI process never run in the same OS process, but they import the exact same
core/ package from the same repository checkout, so there is exactly one implementation of every
domain algorithm (Section 4.5).
4.4 Request flow walkthroughs #
Cold load of /d/:code (a shared permalink, no cache warm anywhere):
Browser Caddy/CDN Fresh web process Postgres Object storage
│ GET /d/abc123def4 │ │ │ │
│───────────────────────▶ (cache miss) │ │ │
│ │───────────────────▶ route handler │ │
│ │ │ look up design ──▶ SELECT design │
│ │ │◀─────────────────── row + build │
│ │ │ check render cache ──────────────▶ HEAD renders/../hash.webp
│ │ │◀────────────────────────────────── 404 (cache miss)
│ │ │ composite server-side (Section 8) │
│ │ │ write render ─────────────────────▶ PUT renders/../hash.webp
│ │ │ render full HTML page │
│ │◀─────────────────── 200 HTML (img src = render URL) │
│◀─────────────────────── 200 HTML │ │ │
│ GET render URL from <img src> │ │ │
│───────────────────────▶ (cache miss) ────────▶ render route ──────────────────────▶ GET renders/../hash.webp
│ │ │◀──────────────────────────────────── 200 image bytes
│◀─────────────────────── 200 image, immutable cache headers (Section 8.9) │Designer slot change with client-side compositing (no network round trip for the preview itself):
Visitor action Designer island (browser) In-memory state
│ picks hair asset+hue │ │
│──────────────────────▶ update signal for slot `hair` ────────────▶ slots.hair = {asset, hue}
│ │ recompute composite on <canvas>: │
│ │ for each occupied slot in z-order: │
│ │ fetch cached sprite bytes (already │
│ │ preloaded per Section 12.2) │
│ │ apply hue table lookup (Section 8.3) │
│ │ alpha-composite onto canvas │
│ │ canvas repaints, visible within one frame │Cache-miss server render (triggered by any first request for a given design/scale/format combination, whether from a browser, a crawler, or the OG image generator):
Requester Fresh render route core/hue+composite (Section 8) Object storage
│ GET /render/d/:code@2.webp │ │ │
│──────────────────────────────▶ compute cache key │ │
│ │ (Section 8.9) ───────────▶ check existence │
│ │ │───────────────────────▶ HEAD
│ │ │◀─────────────────────── 404
│ │ load design + asset refs │ │
│ │ from Postgres │ │
│ │ run composite pipeline ───▶ decode sprites, hue, │
│ │ alpha-composite, encode │
│ │◀────────────────────────── bytes │
│ │ write to object storage ─────────────────────────────▶ PUT
│◀────────────────────────────── 200 image, immutable cache headers │OG image fetch by a crawler (e.g. Discord's link unfurler):
Crawler Fresh /og route core/codec/og.ts (Section 14) Object storage
│ GET /og/d/:code.png │ │ │
│────────────────────────▶ check og_images cache row │ │
│ │───────────────────────────────▶ SELECT og_images │
│ │◀─────────────────────────────── row absent (first fetch) │
│ │ render composite PNG at OG scale (calls Section 8) │
│ │ build satori SVG layout, rasterize with resvg-wasm │
│ │ store PNG ───────────────────────────────────────────────▶ PUT
│ │ insert og_images row │
│◀──────────────────────── 200 PNG, immutable cache headers │Staff import run (Journey D, Section 2.4):
Staff (CLI) cli/main.ts core/import (Section 7, 9) Postgres / storage
│ skinforge-cli import discover --client-dir <path> │ │ │
│─────────────────────────────────────────▶ Stage D1–D3 (walk, identify, probe) │
│ │ insert source_files, import_runs rows ▶ INSERT
│ skinforge-cli import extract --run <id> │ │ │
│─────────────────────────────────────────▶ Stage D4–D6 (extract, classify, │
│ │ normalize) ───────────────────────────▶ INSERT candidates,
│ │ write normalized images
│ (staff reviews candidates in admin console — separate flow, Section 17.6) │
│ skinforge-cli builds publish --build <id> │ │ │
│─────────────────────────────────────────▶ promote approved candidates to assets ──▶ INSERT/UPDATE
│ │ new game_build row, invalidate CDN │
│ │ (Section 9.5) if configured │4.5 Module boundaries #
core/ Pure domain logic. No HTTP, no filesystem access except through injected
interfaces, no framework imports. Sub-modules: formats/ (MUL/IDX/UOP readers),
hue/ (Section 8.3 math), composite/ (Section 8.4), codec/ (PNG/WebP encode via
jsquash), schema/ (Zod modules, Section 5.4), design/ (canonical JSON + hashing,
Section 13), db/ (hand-written SQL repository modules).
web/ Fresh application. routes/ (pages + API + render + admin, following Fresh's file
routing convention), islands/ (interactive components: designer, hue picker,
catalog filters), components/ (server-rendered, non-interactive UI pieces).
cli/ skinforge-cli commands: import discover/identify/probe/extract/classify/normalize/
run/verify, builds submit-review/publish/rollback/diff, db migrate/seed/fixtures,
admin create-user/reset-credentials.
db/ migrations/ (Section 6.1 numbered SQL files), seed data (Section 1 D-21).Dependency direction rule, enforced by lint (Section 5.4) and CI (Section 22.11): core/ imports
nothing from web/ or cli/. web/ and cli/ may both import from core/. web/ never imports
from cli/ and cli/ never imports from web/ — the only shared code path between them is core/.
This guarantees the compositing and hue-math code paths used by the live browser preview, the
server-side permalink render, and the staff-side extraction validation step are the literal same
functions, not parallel reimplementations.
4.6 Rendering architecture decision record #
Decision: all pixel-level work — sprite decoding, hue table application, alpha compositing, and
final image encoding — is pure TypeScript plus WASM codecs (@jsquash/png, @jsquash/webp,
@resvg/resvg-wasm). No ImageMagick, no sharp, no other native image-processing binary, and no
headless browser anywhere in the render path.
Why:
- No native binary dependency.
sharpand ImageMagick require native shared libraries that must match the host OS/CPU architecture and be kept patched independently of the application's own dependency updates; a pure TS/WASM path runs identically wherever Deno runs, including inside the minimal Docker image described in Section 23.1. - Deterministic, portable pixel math. UO's hue algorithm (Section 8.3) is a simple table lookup, not a general image-processing operation; implementing it directly is less code than adapting a general-purpose library's color-manipulation API to replicate a proprietary game's exact recoloring rule, and it guarantees pixel-perfect parity with the original client rather than an approximation.
- Same code client- and server-side.
@jsquash/*codecs and the hand-written hue/composite code run unmodified in both the Deno server process and, compiled for the browser, inside the designer island (Section 12.2), which is what makes the golden-image parity test in Section 22.3 possible in the first place — a native server-only library could never run in the browser to be compared against. - No headless browser for OG images.
satori+resvg-wasmproduce the Open Graph PNG (Section- without launching a Chromium instance, which would be an order of magnitude heavier in both memory and startup latency for a single-VPS deployment (Section 4.8).
Trade-offs accepted:
- WASM codecs are somewhat slower per-image than a native
sharpcall. This is acceptable because SkinForge's images are small (260×330 source canvas, Section 8.1) and render outputs are cached aggressively and immutably (Section 8.9); the cost is paid once per unique design/scale/format combination, never per request. - The team maintains its own hue/compositing implementation rather than delegating to a general-purpose library; this is intentional, per the determinism argument above, and the surface area is small (a handful of pure functions, Section 8.3–8.4), not a general image-processing engine.
Measured expectations: a single composite-and-encode operation (all 19 potential slots, WebP output, scale 1) is expected to complete in under 50ms on typical single-VPS-class CPU hardware; the performance budget and how it is enforced in CI is detailed in Section 19.1 and Section 22.7.
4.7 Data storage decisions #
- PostgreSQL for all metadata. Every table in Section 6 — catalog data, designs, jobs, admin accounts, audit log, rate-limit state, counters — lives in Postgres. One database technology to operate, back up (Section 23.6), and query, with strong consistency for the operations that need it (e.g. the design short-code collision check in Section 13.2 relies on a transactional read-check- insert).
- Object storage for pixels only. Rendered images, normalized source sprites, and OG images are
never stored as database bytea columns; they live in the pluggable object storage driver (
fsors3, Section 1 D-02) addressed by content-hash keys (Section 8.9), keeping the database small and fast to back up, and letting a CDN (Section 19.3) serve image bytes directly from storage or its cache without touching the application process. - No Redis, no message broker, in v1. Background work (rendering a permalink's first request,
running an import stage, generating an OG image) is modeled as rows in a Postgres
jobstable, claimed withSELECT ... FOR UPDATE SKIP LOCKEDso multiple job-runner loop iterations (up toSKINFORGE_RENDER_MAX_CONCURRENCY, Section 1 D-25) never claim the same job twice. Rate limiting (Section 16.7) uses a Postgres-backed token-bucket table for durability across process restarts, fronted by an in-process LRU cache so the hot path for a well-behaved client never touches the database. - Exact reasons this is sufficient at SkinForge's scale: job volume is bounded by request volume
to a single small application (Section 2.6's target metrics imply low hundreds of jobs per day at
launch scale), Postgres's
SKIP LOCKEDpattern is a well-established substitute for a dedicated queue at this volume, and removing Redis/a broker removes an entire additional stateful service to operate, back up, and secure on the single-VPS deployment target (Section 1 D-01).
4.8 Scaling posture #
What a single VPS handles. A modestly sized VPS (4 vCPU / 8GB RAM class) comfortably serves the
target metrics in Section 2.6: the render path is CPU-bound but cached immutably per unique
design/scale/format (Section 8.9), so steady-state traffic after a design has been viewed once is
almost entirely served from object storage or the CDN with no compositing work at all. Postgres,
object storage (if using the fs driver), and the web process share the one host under the default
deployment (Section 1 D-01).
First bottleneck. The first bottleneck under load is CPU contention between (a) render-cache-miss compositing for many distinct new designs at once and (b) a concurrent staff import run's extraction and normalization work (Section 7, Stage D4/D6), because both are CPU-bound WASM/TS work on the same process pool. Section 1 D-25's job-runner concurrency cap exists specifically to bound this.
Horizontal-scaling path. The web process is stateless (all state is in Postgres and object
storage), so the path to handling more traffic is: run multiple web process replicas behind the
existing reverse proxy/CDN, point every replica at the same Postgres instance and the same object
storage bucket (switching the storage driver to s3, Section 1 D-02, becomes necessary once more
than one host needs to read/write the same files), and keep the job runner's SKIP LOCKED claiming
model, which already works correctly with multiple concurrent claimers because it is a database-level
lock, not an in-process one. Postgres itself would move to a managed or replicated instance before it
became the bottleneck, given the read-heavy, small-row-size workload described in Section 6.
What would have to change to scale further: move the staff import/extraction workload
(CPU-heavy, bursty, staff-triggered) to run on a separate host or a separate container from the
public-facing web replicas, so a large import run never competes with public request latency; this
requires no code change, only a deployment topology change, because cli/ already runs as an
independent process from web/ (Section 4.3).
4.9 Alternatives considered and rejected #
| Alternative | Rejected because |
|---|---|
| Deno Deploy as the primary/only deployment target | Deno Deploy's edge model does not support the staff extraction pipeline's need for staff-controlled, potentially large local filesystem access to the operator's game client directory, nor long-running CPU-bound import jobs; it is named in Section 23 only as an alternative for the stateless web tier, with the extraction/import tier staying self-hosted. |
| Node.js + Next.js | Would require a separate TypeScript execution model for the CLI (ts-node or a build step) rather than Deno's native TS execution shared identically between web/ and cli/; Next.js's app-router rendering model provides no advantage over Fresh's islands for this product's static-heavy, low-interactivity page mix. |
| SQLite | Insufficient concurrent-write characteristics for a Postgres-SKIP LOCKED job queue under concurrent public request load plus concurrent staff import work; no built-in full-text search comparable to Postgres's for Section 15's catalog search; would still require a migration to Postgres before any horizontal scaling (Section 4.8). |
| Client-only tool, visitor supplies their own game files | Rejected outright by the scope boundaries in Section 2.5, restated in Section 20.8: end users never install the game client, never point a game directory at the tool, and never download raw game files. A client-only tool would also produce inconsistent results across visitors depending on each visitor's own client version. |
| Storing raw MUL/UOP files on the server for on-the-fly decode per request | Rejected because on-the-fly decode of proprietary container formats on every cold request adds latency and CPU cost that normalized, pre-extracted, provenance-tracked assets (Section 7, Stage D6) avoid entirely; it would also mean redistributing the operator's raw client files indefinitely, which is a legal and scope concern (Section 20) SkinForge avoids by extracting and normalizing once, then discarding the need to touch the original container format again. |
4.10 Non-functional requirements #
| Dimension | Requirement |
|---|---|
| Latency — page load (permalink, cached render) | Under 300ms server response time at the 95th percentile, measured at the origin (before CDN), per Section 19.1. |
| Latency — first composited preview (cold, uncached) | Under 1.5s from first paint to visible paperdoll at the 75th percentile on a mid-tier mobile device (Section 2.6 metric, Section 19.1 budget). |
| Availability target | 99.5% monthly, consistent with a single-VPS deployment (Section 1 D-01) with automated restart (Section 23.2) rather than multi-region failover. |
| Storage growth | Object storage grows primarily with distinct rendered design/scale/format combinations; budgeted and monitored per Section 19.4, with cache-eviction-by-age not applied (renders are immutable and cheap to keep, Section 8.9) but monitored for capacity planning. |
| Concurrency | Job runner bounded at SKINFORGE_RENDER_MAX_CONCURRENCY (default 4, Section 1 D-25) concurrent background jobs; public request handling is otherwise limited only by the reverse proxy and rate limits (Section 1 D-13). |
| Browser support | Last 2 major versions of Chrome, Firefox, Safari, and Edge; iOS Safari 17 and later. The designer island degrades to a clear "unsupported browser" notice (Section 11) rather than a silent failure on anything older; static permalink viewing (Section 10.3) works on any browser that renders HTML and <img>, including no-JS user agents, because the composite is server-rendered. |
These figures are the targets Section 19's caching and performance design is built to meet, and the thresholds Section 22's performance test gate (Section 22.7) checks in CI before a release is considered shippable. They are not aspirational — every other architectural decision in Section 4 (pure TS/WASM rendering, immutable content-addressed caching, a stateless web tier) is chosen specifically because it makes these numbers achievable on the single-VPS deployment target in Section 1 D-01 without requiring a larger infrastructure footprint at launch.
5. Repository Layout, Conventions & Coding Standards #
5.1 Full repository tree #
skinforge/
├── deno.json # tasks, imports map, compilerOptions, lint/fmt config (Section 5.2)
├── deno.lock # generated, committed
├── main.ts # production entrypoint: starts the Fresh web app + job runner
├── dev.ts # development entrypoint: Fresh dev server with hot reload
├── fresh.config.ts # Fresh 2.x App builder: middleware, Tailwind plugin registration,
│ # island discovery (Section 4.1)
├── .env.example # every env var from Section 24, with the working defaults
├── .gitignore
├── README.md # developer's own project readme (not this specification)
├── CHANGELOG.md # Conventional-Commits-derived changelog
├── docker/
│ ├── Dockerfile # multi-stage: deno cache -> deno compile-free runtime image
│ ├── docker-compose.yml # app + postgres + optional minio + caddy (Section 23.1)
│ └── Caddyfile # reverse proxy + TLS config
├── .github/
│ └── workflows/
│ ├── ci.yml # lint, fmt check, type check, unit + golden-image + e2e tests
│ └── deploy.yml # build + push image + trigger deploy (Section 23)
├── core/ # pure domain logic, no HTTP/framework imports (Section 4.5)
│ ├── config.ts # typed env var loader, single source of truth for defaults (Section 24)
│ ├── errors.ts # AppError hierarchy + error code registry (Section 25)
│ ├── hue-preview-reference.ts # static hue-swatch preview data (Section 10.3.7, Section 15.5)
│ ├── formats/
│ │ ├── mul.ts # MUL/IDX reader (Section 7 Stage D2/D4)
│ │ ├── uop.ts # UOP/MythicPackage reader
│ │ ├── tiledata.ts # tiledata.mul parser
│ │ ├── hues.ts # hues.mul parser (Section 3.6)
│ │ └── detect.ts # format fingerprinting (Section 7 Stage D2/D3)
│ ├── hue/
│ │ ├── unpack.ts # ARGB1555 -> RGB8 (Section 3.6, Section 8.3.1)
│ │ └── apply.ts # full/partial hue application (Section 8.3)
│ ├── composite/
│ │ ├── canvas.ts # RGBA buffer compositing primitives (Section 8.4)
│ │ ├── render-design.ts # full design -> composited buffer (Section 8.4)
│ │ └── scale.ts # nearest-neighbour integer upscaling (Section 8.6)
│ ├── codec/
│ │ ├── png.ts # @jsquash/png wrapper
│ │ ├── webp.ts # @jsquash/webp wrapper
│ │ └── og.ts # satori + resvg-wasm wrapper (Section 14)
│ ├── domain/
│ │ └── slots.ts # the slot registry, z-order and the SlotKey type; exports the
│ │ # Zod slot-key validator (Section 3.3) — the single owner of all
│ │ # three; nothing else in core/ redeclares them
│ ├── schema/
│ │ ├── design.ts # Zod schema for design JSON (Section 13.2.5); imports the
│ │ │ # slot-key validator from core/domain/slots.ts rather than
│ │ │ # redeclaring it
│ │ ├── asset.ts # Zod schema for asset/variant records
│ │ ├── hue.ts # Zod schema for hue records
│ │ └── admin.ts # Zod schema for admin console forms
│ ├── http/
│ │ └── security-headers.ts # CSP and the other security response headers (Section 20.4)
│ ├── design/
│ │ ├── canonicalize.ts # sort keys, strip empties (Section 13.3)
│ │ └── short-code.ts # Crockford Base32 hashing (Section 13.4)
│ ├── db/
│ │ ├── client.ts # postgres.js pool setup
│ │ ├── assets.ts # hand-written SQL repository module
│ │ ├── hues.ts
│ │ ├── designs.ts
│ │ ├── jobs.ts
│ │ └── ... # one module per table group (Section 6)
│ ├── storage/
│ │ ├── driver.ts # pluggable object-storage interface (Section 1 D-02)
│ │ ├── fs.ts # local-disk driver
│ │ └── s3.ts # S3-compatible driver
│ ├── auth/
│ │ ├── session.ts # admin session issuance/validation (Section 17.1)
│ │ └── totp.ts # TOTP enrollment/verification (Section 17.2)
│ ├── api/
│ │ └── cursor.ts # opaque, HMAC-signed pagination cursor encode/decode (Section 16.5)
│ ├── search/
│ │ └── tsvector.ts # catalog full-text search query building (Section 15.6)
│ ├── util/
│ │ └── rng.ts # seeded PRNG for the Randomize feature (Section 11.8)
│ ├── jobs/
│ │ ├── runner.ts # SKIP LOCKED claim loop (Section 4.7)
│ │ └── handlers/ # one file per job type (render, og-generate, import-stage)
│ ├── i18n/
│ │ └── en.ts # every user-facing string (Section 5.8, Section 1 D-17)
│ └── import/
│ ├── discover.ts # Stage D1
│ ├── identify.ts # Stage D2
│ ├── probe.ts # Stage D3
│ ├── extract.ts # Stage D4
│ ├── classify.ts # Stage D5
│ └── normalize.ts # Stage D6
├── web/
│ ├── routes/
│ │ ├── index.tsx # `/` (Section 10)
│ │ ├── design.tsx # `/design` (301 redirect to `/`)
│ │ ├── d/[code].tsx # `/d/:code`
│ │ ├── catalog/
│ │ │ ├── index.tsx # `/catalog`
│ │ │ ├── [slotKey]/
│ │ │ │ ├── index.tsx # `/catalog/:slotKey`
│ │ │ │ └── [assetKey].tsx # `/catalog/:slotKey/:assetKey`
│ │ ├── hues/
│ │ │ ├── index.tsx # `/hues`
│ │ │ └── [hueIndex].tsx # `/hues/:hueIndex`
│ │ ├── about.tsx
│ │ ├── faq.tsx
│ │ ├── legal.tsx
│ │ ├── support.tsx
│ │ ├── changelog.tsx
│ │ ├── sitemap.xml.ts
│ │ ├── robots.txt.ts
│ │ ├── opensearch.xml.ts
│ │ ├── render/ # Section 8 render endpoints
│ │ ├── og/ # Section 14 OG endpoints
│ │ ├── api/v1/ # Section 16 JSON API
│ │ └── admin/ # Section 17.17 admin API + admin console routes (`/admin/api/*`)
│ ├── islands/
│ │ ├── designer/ # the main interactive designer island tree (Section 11)
│ │ │ ├── actions.ts # designer state-mutation actions over Preact Signals (Section 11.3)
│ │ │ └── virtual-list.ts # option-panel grid windowing helper (Section 11.15)
│ │ ├── hue-picker/
│ │ ├── catalog-filters/
│ │ └── admin/ # interactive admin console widgets
│ ├── components/
│ │ ├── ui/ # server-rendered design-system primitives (Section 18.5)
│ │ │ ├── button.tsx
│ │ │ └── use-focus-trap.ts
│ │ └── ... # other server-rendered, non-interactive UI (Section 18)
│ ├── styles/
│ │ └── theme.css # design token custom properties (Section 18.2, Section 18.4)
│ └── static/ # non-generated static assets
│ ├── favicon.ico
│ ├── manifest.json
│ ├── social-card.png # site-wide OG fallback card, 1200×630 (Section 14.9)
│ └── fonts/
│ └── PixelifySans-Regular.ttf # OG image generator only (Section 14.4); satori cannot
│ # parse WOFF2, so the OG pipeline needs a TTF
├── scripts/
│ └── build-og-fallback.ts # regenerates web/static/social-card.png (Section 14.9.1)
├── cli/
│ ├── main.ts # skinforge-cli entrypoint
│ └── commands/
│ ├── import-discover.ts
│ ├── import-identify.ts
│ ├── import-probe.ts
│ ├── import-extract.ts
│ ├── import-classify.ts
│ ├── import-normalize.ts
│ ├── import-run.ts # `import run --build <id>`, runs D1-D6 against an existing build
│ ├── import-all.ts # `import all --client-dir <path> --build-label <label>`, creates the build then runs D1-D6
│ ├── import-verify.ts
│ ├── builds-submit-review.ts
│ ├── builds-publish.ts
│ ├── builds-rollback.ts
│ ├── builds-diff.ts
│ ├── db-migrate.ts
│ ├── db-seed.ts # `db seed`: reference/taxonomy data (hue_groups, tags)
│ ├── db-fixtures.ts # `db fixtures`: local-dev sample assets/hues (Section 1 D-21)
│ └── admin-user.ts
├── db/
│ ├── migrations/ # Section 6.1, numbered SQL files
│ │ ├── 0001_init.sql
│ │ └── 0002_seed.sql # reference/taxonomy seed rows: hue_groups, tags (Section 6.31)
│ └── seed/
│ └── dev-fixtures.ts # local-dev sample data, never a numbered migration (Section 1 D-21)
├── tools/
│ └── lint-plugin.ts # custom `deno lint` plugin: floating-promise and JSX-text-node
│ # rules (Section 5.4, Section 5.8)
└── tests/ # cross-cutting suites only — see the note below the tree
├── golden/ # golden-image fixtures + comparison tests (Section 22.3)
├── integration/ # db + storage integration tests (Section 22.4)
├── e2e/ # Playwright specs (Section 22.5)
├── contract/
│ └── api-v1/ # OpenAPI contract fixtures (Section 16.10, Section 16.12)
├── fixtures/ # committed regression fixtures (e.g. captured UOP hash vectors, Section 7.3.4)
└── load/
└── k6/ # k6 load/performance test scripts (Section 19.11, Section 22.7)Every path shown above is created by the developer in their own repository as part of executing this specification.
Section 5.1 is the sole authority on repository file and directory paths. Every module path, script path, or test path named anywhere else in this specification — including Sections 15, 16, 20, 22, 24, 26 and 27 — refers to a path listed in this tree. Where another section's prose and this tree ever disagree, this tree wins and the other section's text is the one that is wrong.
Unit tests are co-located, not gathered into a tests/unit/ tree. Every unit test lives beside
the module it tests, inside core/ or cli/, named <module>_test.ts — the underscore-suffix form
Deno's own test runner discovers natively, e.g. core/hue/apply_test.ts next to core/hue/apply.ts.
Nothing under core/ or cli/ is ever tested from a parallel tests/unit/ or __tests__/ directory.
The tests/ tree above holds only the suites that are not one-to-one with a single module: golden-image
comparisons, integration tests, end-to-end specs, API contract fixtures, regression fixtures, and load
tests.
5.2 deno.json contents #
Section 4 is the ONLY place this specification states dependency versions. The <major line from Section 4.1> placeholders below are filled in at scaffold time from Section 4.1's table, using the
caret form for the stated major line; this block is a rendering of Section 4.1's table, and if the
two ever disagree, Section 4.1 wins and this block must be regenerated. The test:e2e task's
Playwright version is likewise pinned from Section 4.1 so it never floats independently of the E2E
suite it drives.
{
"tasks": {
"dev": "deno run -A --watch=web/,core/ dev.ts",
"build": "deno run -A dev.ts build",
"start": "deno run -A main.ts",
"check": "deno fmt --check && deno lint && deno check main.ts cli/main.ts",
"test": "deno test -A core/ cli/ tests/integration",
"test:golden": "deno test -A tests/golden",
"test:e2e": "deno run -A npm:playwright@<major line from Section 4.1> test",
"lint": "deno lint",
"fmt": "deno fmt",
"db:migrate": "deno run -A cli/main.ts db migrate",
"db:seed": "deno run -A cli/main.ts db seed",
"db:fixtures": "deno run -A cli/main.ts db fixtures",
"import:discover": "deno run -A cli/main.ts import discover",
"import:extract": "deno run -A cli/main.ts import extract",
"builds:publish": "deno run -A cli/main.ts builds publish"
},
"imports": {
"@fresh/core": "jsr:@fresh/core@^<major line from Section 4.1>",
"@fresh/plugin-tailwind": "jsr:@fresh/plugin-tailwind@^<major line from Section 4.1>",
"preact": "npm:preact@^<major line from Section 4.1>",
"@preact/signals": "npm:@preact/signals@^<major line from Section 4.1>",
"lucide-preact": "npm:lucide-preact@^<major line from Section 4.1>",
"@std/http": "jsr:@std/http@^<major line from Section 4.1>",
"@std/path": "jsr:@std/path@^<major line from Section 4.1>",
"@std/ulid": "jsr:@std/ulid@^<major line from Section 4.1>",
"@std/assert": "jsr:@std/assert@^<major line from Section 4.1>",
"postgres": "npm:postgres@^<major line from Section 4.1>",
"zod": "npm:zod@^<major line from Section 4.1>",
"@jsquash/png": "npm:@jsquash/png@^<major line from Section 4.1>",
"@jsquash/webp": "npm:@jsquash/webp@^<major line from Section 4.1>",
"satori": "npm:satori@^<major line from Section 4.1>",
"@resvg/resvg-wasm": "npm:@resvg/resvg-wasm@^<major line from Section 4.1>",
"$core/": "./core/",
"$web/": "./web/",
"$cli/": "./cli/"
},
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"jsx": "precompile",
"jsxImportSource": "preact"
},
"lint": {
"plugins": ["./tools/lint-plugin.ts"],
"rules": {
"tags": ["recommended"],
"include": ["no-unused-vars", "no-explicit-any"]
},
"exclude": ["tests/golden/fixtures/"]
},
"fmt": {
"lineWidth": 100,
"indentWidth": 2,
"singleQuote": false,
"exclude": ["tests/golden/fixtures/"]
}
}Import specifiers use the version-range caret (^) form shown above so deno.lock resolves to the
current patch within each major line at install time, consistent with the "known-good floor, not a
lockfile" rule in Section 4.1.
5.3 Naming conventions #
| Category | Convention | Example |
|---|---|---|
| Directories | kebab-case | web/islands/hue-picker/ |
| TypeScript/TSX files | kebab-case | render-design.ts, hue-picker.tsx |
| Fresh route files | Fresh's own file-routing convention (bracketed dynamic segments) | d/[code].tsx, catalog/[slotKey]/[assetKey].tsx |
| Route parameters | :param in prose and route tables everywhere in this document; [param].tsx only in Fresh file paths |
/d/:code in prose; d/[code].tsx on disk |
| TypeScript types, interfaces, classes | PascalCase | type DesignJson, class AppError |
| TypeScript functions, variables | camelCase | renderDesign(), slotKey |
| TypeScript constants (module-level, immutable config) | SCREAMING_SNAKE_CASE | MAX_RENDER_SCALE |
| SQL tables, columns | snake_case | design_renders, created_at |
| SQL indexes | <table>_<columns>_idx |
designs_short_code_idx |
| SQL constraints | <type>_<table>_<columns> (pk_, fk_, uq_, ck_) |
uq_designs_short_code |
| Env vars | SKINFORGE_SCREAMING_SNAKE_CASE |
SKINFORGE_DATABASE_URL |
| CSS / Tailwind custom classes (rare, only for non-utility cases) | kebab-case, prefixed sf- |
.sf-paperdoll-canvas |
| Test names | describe("<unit under test>") / it("does X when Y") |
it("returns hue 0 as unhued") |
| Git branches | <type>/<short-slug>, type from Conventional Commits types |
feat/hue-picker-keyboard-nav |
| Commit messages | Conventional Commits (type(scope): summary) |
fix(render): correct partial-hue luminance index off-by-one |
5.4 TypeScript standards #
- Strict mode is mandatory (
strict: true,noUncheckedIndexedAccess: true,exactOptionalPropertyTypes: true, Section 5.2). CI fails the build on any type error (deno task check, Section 5.9). - No
anypolicy.no-explicit-anyis an enforced lint rule (Section 5.2). The escape hatch isunknownplus an explicit narrowing function (a Zod.parse()call, a type predicate, or an exhaustiveswitch), never a type assertion (as SomeType) except at the single boundary where external, already-validated data enters the system (e.g. immediately afterpostgres.jsreturns a row whose shape is guaranteed by the migration that created the table). - Error handling pattern. Domain code in
core/returns a typedResult<T, E>union ({ ok: true; value: T } | { ok: false; error: E }) for expected failure modes (validation failure, asset not found, format-detection failure) rather than throwing. Route handlers inweb/and command handlers incli/are the boundary: they call domain functions, inspect theResult, and either proceed or construct and throw anAppError(Section 25) carrying anSF-error code, which a single top-level error-handling middleware catches and converts into the error envelope (Section 16.4). Domain code never throws for an expected failure; it may throw only for a genuine programmer error (e.g. an assertion that should be unreachable). - Async rules. Every async function that can fail is awaited inside a
try/catchat the nearest boundary that can produce a typedResultorAppError; unhandled promise rejections are treated as a bug and fail CI (Section 5.9) via a globalunhandledrejectionlistener in tests. Floating promises are caught bydeno check'sstrictmode where possible and by the custom lint plugin described in Section 5.8;require-awaitis enabled via therecommendedtag (Section 5.2). - Typed-array reads under
noUncheckedIndexedAccess. Every code block in this specification is illustrative: under Section 5.2'snoUncheckedIndexedAccess, every array/typed-array index read has typeT | undefined, so a shipped module must narrow each read (px[o] ?? 0) or open with// deno-lint-ignore-fileplus a localconst at = (a: Uint8Array, i: number) => a[i] as number;helper before the indexed reads compile as written. - Logging rules.
core/andcli/log through a single injected structured logger interface (neverconsole.logdirectly in library code);web/route handlers use the same interface, configured per Section 21.1's log schema. Log calls never include secrets (session tokens, TOTP codes, S3 credentials) — Section 20.4 lists the exact fields that must never appear in a log line. - Import ordering. Three groups, blank line between each, enforced by
deno fmt: (1) standard library and JSR imports, (2) npm imports, (3) local$core/,$web/,$cli/imports. Within a group, alphabetical by specifier. - Barrel-file policy. No barrel files (
index.tsre-exporting an entire directory) insidecore/; every module is imported by its direct path so bundlers anddeno checkcan tree-shake and so circular-import risk stays visible.web/components/andweb/islands/may use a single barrel file per top-level component group purely for import ergonomics in route files, never nested more than one level.
5.5 Fresh-specific conventions #
- Island vs. server component. A component is an island only if it needs client-side interactivity
that cannot be expressed as a plain HTML form submission or a link — for example, the live composited
canvas preview (Section 12.2), the hue swatch picker with keyboard navigation (Section 11.6), and
the catalog's live filter-as-you-type (Section 15.3). Anything that only displays server-computed
data, or that only needs a plain
<form method="post">, is a server component, never an island. - Props serialization limits. Island props must be JSON-serializable (no functions, no class
instances, no
Map/Set— plain objects, arrays, strings, numbers, booleans,null) because Fresh serializes island props into the initial HTML payload for hydration; any richer client-side state is constructed inside the island from those primitive props using signals (Section 4.1), never passed in pre-constructed. - Signals usage. Each independently-changing piece of designer state (Section 11.2: body, skin hue, and one entry per occupied slot) is its own signal, not one large object signal, so a change to one slot does not trigger a recompute of unrelated slot UI; the composite canvas subscribes to a computed signal that depends on all occupied-slot signals and re-renders only when that computed value changes.
- Partials/streaming usage. Fresh partials (out-of-band HTML swaps) are used for the catalog's filter results (Section 15.3) so filtering re-renders only the results grid, not the full page; streaming responses are used for the admin console's import-run progress view (Section 17.5) so long-running extraction stages show live progress without client-side polling.
- Progressive enhancement. The designer requires JavaScript; its no-JS behaviour is the read-only fallback in Section 10.7. Catalog browsing and filtering work with plain link/GET-form navigation when JavaScript is unavailable, falling back from partial-swap to full page loads.
5.6 SQL conventions #
- Migration file naming and immutability.
db/migrations/NNNN_snake_case_description.sql, four- digit zero-padded sequence number, strictly increasing, never renumbered. Once merged, a migration file is never edited; a mistake is corrected by a new, later migration, never by amending history. - Up-only policy with explicit rollback scripts. Each migration file contains only the forward
("up") change. A corresponding
db/migrations/NNNN_snake_case_description.down.sqlfile is written alongside it for local development rollback convenience; the down script is never executed automatically in any deployed environment (Section 23 has no automatic-rollback step) — recovery in production goes through the restore runbook in Section 23.6, not a down migration. - Index naming:
<table>_<column_or_columns>_idx(Section 5.3). Every foreign key column gets an index unless the migration comment explicitly justifies why not (e.g. a column that is always queried together with a more selective leading column in a composite index). - Constraint naming:
pk_<table>,fk_<table>_<column>,uq_<table>_<column_or_columns>,ck_<table>_<short_description>(Section 5.3). - Enum-vs-lookup-table policy. A fixed, small, code-referenced set of values (e.g. a job's
status) uses a PostgresCHECKconstraint against a literal string list, not a nativeENUMtype, so adding a value is a new migration that alters the constraint rather than a harder-to-manageALTER TYPE. A set of values that grows through normal application/admin activity (e.g. tags, hue groups) is a real lookup table with a foreign key, never aCHECK-constrained string column. - Transaction boundaries. Every multi-statement write that must be atomic (e.g. the design
short-code collision check plus insert, Section 13.4.2; an import-run's candidate promotion, Section
9.4) is wrapped in a single
postgres.jstransaction (sql.begin(...)). Read-only multi-statement operations do not open a transaction unless they require a consistent snapshot across statements.
5.7 Code documentation #
- JSDoc on every exported symbol in
core/. Every exported function, type, and class incore/carries a JSDoc comment stating its purpose, parameters, return value, and any errorResultvariants it can produce.web/andcli/code is documented where non-obvious but does not require exhaustive JSDoc, since route handlers and CLI commands are thin wrappers over documentedcore/functions. - Module headers. Every file in
core/begins with a one-paragraph comment stating what the module owns and which specification section it implements (e.g.// Implements the hue application algorithm from Section 8.3.), so a reader can trace code back to the specification section that defines its behavior. - ADR file convention. Architectural decisions made during actual implementation (not already
captured by Section 4.9's alternatives-considered table) are recorded by the developer in their own
repository at
docs/adr/NNNN-title.md, numbered sequentially, using a short "Context / Decision / Consequences" format. This is a convention for the developer's own repository, not a deliverable of this specification.
5.8 Accessibility and i18n conventions #
Every component, island or server-rendered, follows the WCAG 2.2 AA rules detailed in full in Section
18: semantic HTML elements before ARIA roles, visible focus states on every interactive element, hue
swatches identified by name (not color alone) for colorblind users, minimum touch target sizes, and
color-contrast minimums for all text. Every user-facing string is imported from core/i18n/en.ts
(Section 5.1) rather than inlined in a component, so a component never hard-codes English text
directly — this is what keeps the locale decision (Section 1 D-17) a data change rather than a
refactor, and it is enforced by a project lint plugin at tools/lint-plugin.ts, registered in
deno.json as "lint": { "plugins": ["./tools/lint-plugin.ts"] } (Section 5.2), which flags any JSX
text node containing a non-whitespace string literal and any promise-returning expression used as a
statement.
5.9 Definition of done #
A unit of work (a pull request implementing one feature, fix, or section of this specification) is done only when all of the following hold:
- Types, lint and format.
deno task checkpasses with zero findings (it runs format check, lint, and type check). - Tests. New behavior has unit test coverage as
<module>_test.tsbeside the changedcore/orcli/module (Section 5.1); any change to rendering, compositing, or hue math additionally has or updates a golden-image fixture intests/golden/(Section 22.3); any change to a public route or API endpoint has or updates an integration or e2e test (Section 22.4, Section 22.5).deno task test,deno task test:golden, and the relevantdeno task test:e2esuite all pass. - Lint and format.
deno task lintanddeno fmt --checkboth pass with zero findings. - Migration. Any schema change ships as a new numbered migration file (Section 5.6) plus its
.down.sqlcounterpart, anddeno task db:migrateruns cleanly against a fresh database. - Documentation. New exported
core/symbols carry JSDoc (Section 5.7); any behavior that deviates from or extends this specification is recorded as a new ADR in the developer's owndocs/adr/(Section 5.7). - Changelog. An entry is added to
CHANGELOG.mdunder an "Unreleased" heading, following Conventional Commits categorization (Added,Changed,Fixed,Removed). - CI green. The full CI workflow (Section 22.11) passes on the pull request before merge; no step is skipped or force-merged.
6. Data Model & Database Schema #
6.1 Entity-Relationship Overview #
All persistent state lives in PostgreSQL (the major version line is fixed in Section 4.1). The schema has five clusters: build provenance and import pipeline, catalog (slots/assets/hues/tags), designs and renders, admin/auth, and operational tables (jobs, rate limits, stats, settings, takedowns).
game_builds ──< source_files ──< import_run_items >── import_runs
│ │
│ extraction_candidates
│
├──< assets ──< asset_variants ──< asset_images
│ │ │
│ │ └── build_id ── game_builds (build-scoped, Section 6.11)
│ │
│ ├──< asset_tags >── tags
│ │
│ └── slot_key ── slots
│
├──< hues ──< hue_group_members >── hue_groups
│
└──< designs ──< design_renders
│
├──< og_images
└──< takedown_requests
admin_users ──< admin_sessions
│
├──< admin_recovery_codes
├──< admin_pending_mfa
└──< audit_log (actor_id, nullable FK)
admin_login_failures, app_settings, jobs, rate_limit_buckets, stat_counters,
page_view_daily, schema_migrations (standalone)One-line purpose of every table in the schema:
| Table | Purpose |
|---|---|
schema_migrations |
Tracks which migration files have been applied and in what order. |
game_builds |
An immutable snapshot of one imported set of client data, with a lifecycle status. |
source_files |
Every file discovered on the operator's client during a build's inventory stage. |
import_runs |
One row per pipeline stage execution for a build; eight stage values, enumerated in Section 6.6. |
import_run_items |
Per-item outcome (ok/skipped/failed/needs input) inside an import run. |
extraction_candidates |
A sprite or hue candidate proposed by the pipeline, pending staff review. |
slots |
The 19-row registry of designable body/cosmetic slots and their z-order. |
assets |
A catalog entry for one wearable/cosmetic item, keyed by a stable natural key. |
asset_variants |
Per-body, per-build sprite geometry, hue mode and hash for one asset. |
asset_images |
A rendered scale × format file for one asset variant. |
hues |
One decoded hue table (32 colours) with its index, build scope and provenance. |
hue_groups |
Curated groupings of hues for browsing (skin, hair, tattoo, clothing, event). |
hue_group_members |
Many-to-many join between hues and hue_groups, recording how each membership was assigned. |
tags |
Catalog facets used for browse/search filtering. |
asset_tags |
Many-to-many join between assets and tags. |
designs |
One immutable saved combination, addressable by a short code. |
design_renders |
Cache rows for server-rendered composite, asset and swatch images. |
og_images |
Generated Open Graph preview images for a design. |
jobs |
The Postgres-backed background job queue. |
admin_users |
Staff accounts with password + TOTP credentials. |
admin_sessions |
Server-side session records backing the sf_admin cookie. |
admin_recovery_codes |
One-time TOTP recovery codes per admin user. |
admin_pending_mfa |
Short-lived rows for a login that has passed the password step and is awaiting TOTP. |
admin_login_failures |
Rolling record of failed admin login attempts, backing the lockout policy in Section 17.2.3. |
app_settings |
The small set of operator-editable runtime settings the admin console writes (Section 17.14). |
audit_log |
Append-only record of every state-changing admin/pipeline action. |
rate_limit_buckets |
Token-bucket state for API, render, and design-creation rate limits. |
stat_counters |
Per-day and all-time aggregate counters, no PII. |
page_view_daily |
Daily page-view aggregate per path, written server-side, no PII. |
takedown_requests |
Legal/rights takedown requests and their resolution state. |
6.2 Conventions Recap #
All identifiers are snake_case; every table except schema_migrations, slots,
hue_group_members, asset_tags, rate_limit_buckets, stat_counters, page_view_daily and
app_settings uses a CHAR(26) ULID surrogate primary key named id, with stable natural keys (asset_key, slot_key,
short_code) used everywhere else in the system, per the SQL and naming conventions in Sections 5.3
and 5.6. A ULID is 26 characters of Crockford Base32 (0123456789ABCDEFGHJKMNPQRSTVWXYZ — no I,
L, O or U), generated by the application, never by the database.
Timestamps are TIMESTAMPTZ named created_at / updated_at / published_at / retired_at;
catalog rows are soft-retired via retired_at, never hard-deleted, so permalinks never break. No
catalog or design table carries a deleted_at column: there is no soft-delete concept distinct from
retirement, and no hard-delete path outside the takedown process in Section 20.8.
Index names use the suffix form <table>_<columns>_idx, matching Section 5.6's convention; unique
constraints declared inline on a table use PostgreSQL's own generated <table>_<columns>_key names.
Status vocabularies are defined once, here, and every screen, API response and CLI message uses these exact strings:
| Column | Allowed values |
|---|---|
extraction_candidates.status |
pending, needs_operator_input, approved, rejected |
import_runs.status |
queued, running, succeeded, failed, cancelled |
import_run_items.status |
ok, skipped, failed, needs_operator_input |
game_builds.status |
draft, in_review, published, archived, rolled_back |
takedown_requests.status |
new, triaging, upheld, rejected, withdrawn |
jobs.status |
queued, running, succeeded, failed, cancelled |
admin_users.role |
viewer, curator, owner |
6.3 schema_migrations #
Tracks applied migrations. Managed exclusively by the migration runner in Section 6.32; application code never writes to it.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
filename |
TEXT |
not null | — | Migration file name, e.g. 0001_init.sql. Primary key. |
checksum |
CHAR(64) |
not null | — | SHA-256 of the file's contents at apply time (64 hex characters). |
applied_at |
TIMESTAMPTZ |
not null | now() |
When the migration was applied. |
Primary key: filename. No foreign keys. No additional indexes. Typical row count: tens (one per
migration file, growing slowly over the project's life).
6.4 game_builds #
The build lifecycle root. Every catalog row and hue row belongs to exactly one build.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
label |
TEXT |
not null | — | Operator-chosen human label, e.g. 2026-09-01-patch-47. |
status |
TEXT |
not null | 'draft' |
One of draft, in_review, published, archived, rolled_back (Section 6.2). |
build_hash |
CHAR(64) |
null | — | Content fingerprint of the whole build, written once by the publish transaction (Section 9.5). Null until first published. |
source_client_version |
TEXT |
null | — | Free-text client version string supplied by the operator. |
notes |
TEXT |
null | — | Operator notes about this build. |
created_by |
CHAR(26) |
null | — | FK to admin_users.id. |
published_at |
TIMESTAMPTZ |
null | — | When this build became the active published build. |
published_by |
CHAR(26) |
null | — | FK to admin_users.id. |
archived_at |
TIMESTAMPTZ |
null | — | When this build was superseded by a later publish. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
updated_at |
TIMESTAMPTZ |
not null | now() |
Last modification time. |
Primary key: id. Unique: label. Foreign keys: created_by → admin_users(id) ON DELETE SET
NULL; published_by → admin_users(id) ON DELETE SET NULL. Check: status IN ('draft','in_review', 'published','archived','rolled_back').
build_hash is sha256 over the concatenation, in asset_key order, of every asset_variants.sha256
belonging to this build, followed by, in hue_index order, every hues.colors array belonging to this
build. It is computed and written once inside the publish transaction (Section 9.5) and never changes
thereafter, which is what makes it safe to use as the salt of the render cache key in Section 8.9. It
is nullable because a draft or in_review build has no fingerprint yet; a designs row can only
reference a build that has been published (Section 9.1), so every build a render resolves against
always has one.
archived and rolled_back are both terminal, non-current states. archived means the build was
superseded by a normal later publish; rolled_back means it was explicitly withdrawn by the rollback
procedure in Section 9.6. The distinction exists solely so the builds screen and the audit trail can
tell an ordinary supersession from a withdrawal.
CREATE UNIQUE INDEX game_builds_published_singleton
ON game_builds ((1)) WHERE status = 'published';This partial unique index enforces at most one published build at a time; publishing a new build must archive the previous one in the same transaction (Section 9.5). Typical row count: dozens over the product's lifetime (one per game patch re-import).
6.5 source_files #
Every file the discovery stage (Section 7.2.1, D1) found on the operator's client directory for a build. No file bytes are stored in the database; only metadata and provenance.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
build_id |
CHAR(26) |
not null | — | FK to game_builds.id. |
relative_path |
TEXT |
not null | — | Path relative to the operator's client root. |
file_type |
TEXT |
not null | — | One of mul, idx, uop, def, txt, image, archive, unknown. |
size_bytes |
BIGINT |
not null | — | File size in bytes. |
sha256 |
CHAR(64) |
not null | — | Content hash, 64 hex characters. |
magic_bytes |
TEXT |
null | — | Hex string of the first 16 bytes, for diagnostics. |
entropy |
REAL |
null | — | Shannon entropy of a sample, used for D3 compression sniffing. |
discovered_at |
TIMESTAMPTZ |
not null | now() |
When D1 recorded this file. |
Primary key: id. Foreign key: build_id → game_builds(id) ON DELETE CASCADE. Unique:
(build_id, relative_path).
CREATE INDEX source_files_build_type_idx
ON source_files (build_id, file_type);
-- Supports the source-file list on the import detail screen (Section 17.5), filtered by classification.Typical row count: 500–5,000 per build (client asset directories are large but bounded).
6.6 import_runs #
One row per execution of a pipeline stage (Section 7.2) against a build.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
build_id |
CHAR(26) |
not null | — | FK to game_builds.id. |
stage |
TEXT |
not null | — | One of inventory, identify, probe, extract, classify, normalize, import, verify. |
status |
TEXT |
not null | 'queued' |
One of queued, running, succeeded, failed, cancelled (Section 6.2). |
started_at |
TIMESTAMPTZ |
null | — | When the stage started executing. |
finished_at |
TIMESTAMPTZ |
null | — | When the stage finished (success or failure). |
triggered_by |
CHAR(26) |
null | — | FK to admin_users.id; null for scheduled/CLI-unattended runs. |
summary |
JSONB |
not null | '{}' |
Stage-specific counters (files scanned, candidates found, errors). |
error |
TEXT |
null | — | Top-level error message if status = 'failed'. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
Primary key: id. Foreign keys: build_id → game_builds(id) ON DELETE CASCADE; triggered_by →
admin_users(id) ON DELETE SET NULL. Check: stage and status constrained to the enumerations
above.
The granularity is one row per stage, not one row per import. A screen that wants a single
progress indicator for a whole build rolls up the latest row per stage for that build_id
(Section 17.5); the stage identifiers used by every screen, every metric label and every log event
are exactly the eight values in the stage CHECK above, never a D1…D6 abbreviation.
D1…D6 are the prose shorthand Section 7 uses when discussing the extraction pipeline as a
narrative; they are never written to this column, never rendered in a UI and never used as a metric
label. The mapping is fixed and total:
| Section 7 shorthand | import_runs.stage value |
Owner |
|---|---|---|
| D1 | inventory |
Section 7.2.1 |
| D2 | identify |
Section 7.2.2 |
| D3 | probe |
Section 7.2.3 |
| D4 | extract |
Section 7.2.4 |
| D5 | classify |
Section 7.2.5 |
| D6 | normalize |
Section 7.2.6 |
| — | import |
Section 9.2's ingestion step, which has no D shorthand |
| — | verify |
Section 7.9's import verify, run as a publish precondition (Section 9.5) |
Eight stage values, six of which the extraction pipeline produces and two of which ingestion adds.
CREATE INDEX import_runs_build_stage_idx
ON import_runs (build_id, stage, created_at DESC);
-- Lets the admin imports screen (Section 17.5) list the latest run per stage for a build.Typical row count: 8–16 per build (one per stage, occasionally re-run).
6.7 import_run_items #
Per-item outcome inside an import run, used for progress reporting and failure triage.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
import_run_id |
CHAR(26) |
not null | — | FK to import_runs.id. |
source_file_id |
CHAR(26) |
null | — | FK to source_files.id, when the item corresponds to one file. |
item_type |
TEXT |
not null | — | Free-text item classification, e.g. gump-block, hue-group. |
status |
TEXT |
not null | — | One of ok, skipped, failed, needs_operator_input. |
detail |
JSONB |
not null | '{}' |
Item-specific structured detail (offsets, error text, counts, reason). |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
Primary key: id. Foreign keys: import_run_id → import_runs(id) ON DELETE CASCADE;
source_file_id → source_files(id) ON DELETE SET NULL. Check: status IN ('ok','skipped','failed', 'needs_operator_input').
CREATE INDEX import_run_items_run_status_idx
ON import_run_items (import_run_id, status);
-- Supports the per-run failure/needs-input count shown in the admin imports detail screen (Section 17.5).Typical row count: thousands per run (one row per file or extracted unit); the largest table in the pipeline cluster.
6.8 extraction_candidates #
A sprite or hue candidate proposed by classification (Section 7.6), awaiting or having received staff review.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
import_run_id |
CHAR(26) |
not null | — | FK to import_runs.id. |
source_file_id |
CHAR(26) |
null | — | FK to source_files.id. |
candidate_key |
TEXT |
not null | — | Deterministic identifier derived from gump id or content hash. |
suggested_slot_key |
TEXT |
null | — | FK to slots.slot_key, the pipeline's best guess. |
suggested_body |
TEXT |
null | — | One of m, f, or null if body-independent/unknown. |
confidence |
REAL |
not null | 0 |
Classification confidence in [0,1]. |
status |
TEXT |
not null | 'pending' |
One of pending, needs_operator_input, approved, rejected (Section 6.2). |
image_ref |
TEXT |
null | — | Work-directory-relative path of a preview PNG generated for staff review; never an object-storage key and never publicly addressable (Section 7.11). |
metadata |
JSONB |
not null | '{}' |
Raw extraction metadata (dimensions, offsets, format notes, perceptualHash, rejectionReason). |
reviewed_by |
CHAR(26) |
null | — | FK to admin_users.id. |
reviewed_at |
TIMESTAMPTZ |
null | — | When staff resolved this candidate. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
Primary key: id. Foreign keys: import_run_id → import_runs(id) ON DELETE CASCADE;
source_file_id → source_files(id) ON DELETE SET NULL; suggested_slot_key → slots(slot_key) ON
DELETE SET NULL; reviewed_by → admin_users(id) ON DELETE SET NULL. Check: confidence BETWEEN 0 AND 1; suggested_body IN ('m','f') or null; status constrained to the enumeration above. Unique:
(import_run_id, candidate_key), which is the conflict target every D3/D5 write uses so re-running a
stage is idempotent (Section 9.9).
CREATE INDEX extraction_candidates_status_confidence_idx
ON extraction_candidates (status, confidence DESC);
-- Drives the staff review queue (Section 17.6): pending items ordered by ascending confidence surface first.Typical row count: thousands per build; the queue drains toward zero pending rows as staff work
through it, but historical approved/rejected rows are retained for audit.
6.9 slots #
The fixed registry of designable slots from Section 3.3. Rows are seeded once (Section 6.31) and
never change afterwards: z_order participates in the canonicalization that produces a design's
short code (Section 13.3), so editing it would silently change the meaning of every existing
permalink. The admin slots screen (Section 17.9) is therefore read-only with respect to z_order.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
slot_key |
TEXT |
not null | — | Natural key, e.g. hair. Primary key. |
display_name |
TEXT |
not null | — | Human label, e.g. Hair. |
z_order |
INT |
not null | — | Composite stacking order, lowest drawn first. |
display_order |
INT |
not null | — | Order the slot rail and catalog list the slot in, independent of z_order. |
uo_layer |
INT |
null | — | Classic UO layer id, null for slots with no classic layer. |
hueable |
BOOLEAN |
not null | true |
Whether this slot accepts a hue selection. |
gender_scope |
TEXT |
not null | 'both' |
One of both, male, female. |
is_required |
BOOLEAN |
not null | false |
True only for body. |
is_always_visible |
BOOLEAN |
not null | false |
True only for backpack. |
visible |
BOOLEAN |
not null | true |
Whether the slot is exposed in the designer, catalog and API at all. |
hue_swatch_crop |
TEXT |
null | — | Optional CSS object-position/crop hint for hue swatch thumbnails; null means the default centered 60%-scale crop. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
Primary key: slot_key. Unique: z_order; display_order. Check: gender_scope IN ('both','male', 'female').
There is no stored choosable column. The choosable field returned by the API (Section 16.8.1) is
the derived value NOT is_required: exactly one slot (body) is required, and the other 18 —
backpack included — are choosable. gender_scope is the single name and the single vocabulary for
the gender rule; the API exposes it as genderScope with the same three values. There is no
genderRule field and no male_only/female_only spelling anywhere in the system.
visible is a presentation switch and nothing more. visible = false hides a slot from the slot rail
(Section 11.5), the catalog overview and slot pages (Sections 15.2–15.3), and the /api/v1/slots
listing (Section 16.8.1). It never changes CHOOSABLE_SLOT_KEYS (Section 13.2.5), never invalidates
or re-canonicalizes an existing design, and never affects the render path — a design that already
carries an entry for a hidden slot still renders that slot, exactly as it does for a retired asset
(Section 9.7). The registry is 19 rows whatever visible says; hiding a slot changes what a visitor
is offered, never what a permalink means. owner toggles it on the admin slots screen (Section
17.9), which is also the only column on this table staff may write.
The unique constraint on z_order already creates the index the renderer and every catalog listing
use to iterate slots in composite order, so no separate index is declared.
Fixed row count: 19.
6.10 assets #
The catalog entry for one wearable or cosmetic item.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
asset_key |
TEXT |
not null | — | Stable natural key, e.g. hair.long-wavy. |
slot_key |
TEXT |
not null | — | FK to slots.slot_key. |
display_name |
TEXT |
not null | — | Human label shown in the catalog and designer. |
display_order |
INT |
not null | 0 |
Curated order within a slot; ties break on display_name. |
source_file_id |
CHAR(26) |
null | — | FK to source_files.id, provenance of the original art. |
source_offset |
BIGINT |
null | — | Byte offset into the source file, provenance. |
gump_id |
INT |
null | — | Classic UO gump id, when applicable. |
partial_hue |
BOOLEAN |
not null | false |
The asset-level default hue mode D6 copies into each new asset_variants.hue_mode row. |
search_tsv |
tsvector |
null | — | Full-text search vector, maintained by trigger (Section 6.30); read by Section 15.6. |
first_build_id |
CHAR(26) |
not null | — | FK to game_builds.id, the build this asset first appeared in. Informational. |
last_build_id |
CHAR(26) |
not null | — | FK to game_builds.id, the most recent build that still carries it. Informational. |
retired_at |
TIMESTAMPTZ |
null | — | Set when a patch removes the underlying art; asset stays renderable. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
updated_at |
TIMESTAMPTZ |
not null | now() |
Last modification time. |
Primary key: id. Unique: asset_key. Foreign keys: slot_key → slots(slot_key) ON DELETE
RESTRICT; source_file_id → source_files(id) ON DELETE SET NULL; first_build_id →
game_builds(id) ON DELETE RESTRICT; last_build_id → game_builds(id) ON DELETE RESTRICT.
Check: assets_asset_key_shape, asset_key ~ '^[a-z0-9]+([-_.][a-z0-9]+)*$' AND length(asset_key) <= 128 — the same shape AssetKeySchema enforces at the API boundary (Section 13.2.5), applied at
the database so the ingest path, which never passes through an API schema, cannot write a key that
would later be interpolated into an object-storage path.
ON DELETE RESTRICT on slot_key and the build FKs prevents removing a slot or build that catalog
rows still reference; the correct way to remove an asset from circulation is retired_at, never a
row delete, and there is no deleted_at column on this table.
first_build_id/last_build_id are provenance only. Build scoping that the renderer depends on
lives on asset_variants.build_id (Section 6.11); no lookup on the render path consults either of
these two columns.
partial_hue is the asset-level default, not the value the renderer reads. D6 (Section 7.8) copies
it into asset_variants.hue_mode when it creates a variant row, and the renderer resolves the mode
from the variant, so a single asset can carry a corrected mode in a newer build without rewriting the
older build's variant.
CREATE INDEX assets_slot_key_idx ON assets (slot_key, display_order) WHERE retired_at IS NULL;
-- The hot path: list active assets for one slot in the designer and catalog, already ordered.
CREATE INDEX assets_gump_id_idx ON assets (gump_id) WHERE gump_id IS NOT NULL;
-- Used by classification (Section 7.6) to detect duplicate gump ids across builds.
CREATE INDEX assets_search_tsv_idx ON assets USING GIN (search_tsv);
-- Serves the full-text half of Section 15.6's search.
CREATE INDEX assets_display_name_trgm_idx ON assets USING GIN (display_name gin_trgm_ops);
-- Serves the fuzzy/similarity half of Section 15.6's search.Typical row count: 2,000–6,000 (cosmetic items across the 18 choosable slots, both genders where distinct).
6.11 asset_variants #
Per-body, per-build sprite geometry for one asset. An asset with identical art for both genders still gets two rows per build (Section 8.5 requires per-body offsets even when the pixels match).
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
asset_id |
CHAR(26) |
not null | — | FK to assets.id. |
build_id |
CHAR(26) |
not null | — | FK to game_builds.id; the build whose art this row describes. |
body |
TEXT |
not null | — | One of m, f. |
width |
INT |
not null | — | Sprite width in pixels. |
height |
INT |
not null | — | Sprite height in pixels. |
offset_x |
INT |
not null | — | Horizontal blit offset within the 260×330 canvas (Section 8.1). |
offset_y |
INT |
not null | — | Vertical blit offset within the 260×330 canvas. |
hue_mode |
TEXT |
not null | 'full' |
One of full, partial; the mode Section 8.3.3 applies to this sprite. |
sha256 |
CHAR(64) |
not null | — | Hash of the decoded RGBA pixel buffer, 64 hex characters. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
Primary key: id. Unique: (asset_id, body, build_id). Foreign keys: asset_id → assets(id) ON
DELETE CASCADE; build_id → game_builds(id) ON DELETE RESTRICT. Check: body IN ('m','f');
hue_mode IN ('full','partial'); width > 0 AND height > 0.
Why build_id is on this table. Permalink immutability is the product's core promise, and it is
structural, not aspirational. A design pins designs.build_id at creation; the renderer resolves
every asset and every hue inside that build. hues has always been build-scoped by
(hue_index, build_id) (Section 6.13), and asset_variants is scoped the same way for the same
reason: when a patch changes an asset's art, D6 inserts a new variant row for the new build
instead of overwriting the old one, so every permalink created before the patch keeps resolving the
bytes it was created against. Without this column the "Changed" bucket in Section 9.3 would have to
overwrite a single row per (asset_id, body) and would silently re-render every historical design
with the new art.
CREATE INDEX asset_variants_sha256_idx ON asset_variants (sha256);
-- Used by diffing (Section 9.3) to detect unchanged art across builds without pixel comparison.
CREATE INDEX asset_variants_build_idx ON asset_variants (build_id);
-- Serves the render path's build-scoped lookup and the per-build enumeration the publish
-- transaction uses to compute game_builds.build_hash (Section 9.5).Typical row count: roughly double assets per build (most slots have both-gender variants;
facial_hair has male only), and the table therefore grows once per build rather than being
rewritten in place.
6.12 asset_images #
A rendered scale × format output file for one asset variant, produced by normalization (Section 7.8).
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
asset_variant_id |
CHAR(26) |
not null | — | FK to asset_variants.id. |
scale |
SMALLINT |
not null | — | One of 1, 2, 3. |
format |
TEXT |
not null | — | One of webp, png. |
storage_key |
TEXT |
not null | — | Object storage key under the catalog/ prefix, content-addressed on the variant's sha256: catalog/:assetKey/:body/:hash12/:scale.:format (Section 7.8). |
byte_size |
INT |
not null | — | Encoded file size in bytes. |
content_hash |
CHAR(64) |
not null | — | SHA-256 of the encoded file bytes. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
Primary key: id. Unique: (asset_variant_id, scale, format). Foreign key: asset_variant_id →
asset_variants(id) ON DELETE CASCADE. Check: scale IN (1,2,3); format IN ('webp','png').
storage_key is deliberately not unique, and that is a direct consequence of build-scoped
variants (Section 6.11). The key embeds the variant's content hash, so two builds whose art for an
asset is byte-identical produce the same key and share one stored object — the common case, since most
assets are unchanged by any given patch. Each build still gets its own asset_variants row and its own
six asset_images rows, and those rows point at the shared object. A unique constraint here would
reject the second build outright.
The same property is what stops a patch from overwriting history: because the key changes whenever the
bytes change, D6 never writes new art over an object an older build's design still resolves. A
(asset_variant_id, scale, format) pair is still unique, so a variant can never have two rows for the
same scale and format.
Two formats and no others, and the CHECK is the enforcement: the pipeline produces encoded images
only. There is no rgba format value, no raw-buffer object under catalog/, and no per-sprite mask
or index sidecar anywhere in this system — the hue table index is derived from the sprite pixel
itself (Section 8.3), so nothing needs to be stored beside the image. The renderer's input is the
scale-1 PNG, catalog/:assetKey/:body/:hash12/1.png, decoded to RGBA in process (Section 8.2.1); PNG
is lossless, so that decode reproduces exactly the buffer extraction wrote (Section 7.8). Decoded
RGBA buffers do exist during import, but only as scratch files inside the operator's work directory
(Section 7.5); they are never uploaded, never get an asset_images row, and never leave the machine
running the pipeline.
CREATE INDEX asset_images_storage_key_idx ON asset_images (storage_key);
-- Resolves "which rows reference this object", used by the restore validation in Section 6.34.Typical row count: 6× asset_variants (3 scales × 2 formats each); distinct stored objects are far
fewer, since unchanged art is shared across every build that carries it.
6.13 hues #
One decoded 32-colour hue table, per the hue model in Section 3.6.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
hue_index |
INT |
not null | — | The UO hue index (0 = unhued is not stored as a row; see note below). |
source |
TEXT |
not null | — | One of base, outlands, derived. |
name |
TEXT |
not null | — | Decoded 20-byte name from the hue table, trimmed; never blank (Section 7.7 substitutes Hue <index>). |
colors |
INTEGER[32] |
not null | — | 32 packed 0xRRGGBB colours, index 0..31. |
table_start |
INT |
not null | — | Decoded tableStart field (Section 7.3.3). INT, not SMALLINT: the field is an unsigned 16-bit value and values above 32,767 overflow SMALLINT. |
table_end |
INT |
not null | — | Decoded tableEnd field, same reasoning. |
swatch_color |
INTEGER |
not null | — | Packed 0xRRGGBB representative colour for UI swatches, computed per Section 7.7. |
swatch_override_argb |
INTEGER |
null | — | Staff override for the swatch colour, set on the hues admin screen (Section 17.8); when non-null it wins over swatch_color. |
partial |
BOOLEAN |
not null | false |
Whether this hue is conventionally applied as partial-hue. |
build_id |
CHAR(26) |
not null | — | FK to game_builds.id. |
retired_at |
TIMESTAMPTZ |
null | — | Set if a later build removes or renumbers this hue. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
Primary key: id. Unique: (hue_index, build_id) — the same numeric hue index can be re-imported per
build with a possibly different colour table. Foreign key: build_id → game_builds(id) ON DELETE
RESTRICT. Check: source IN ('base','outlands','derived'); array_length(colors,1) = 32.
Because the unique key is (hue_index, build_id) and not hue_index alone, no query anywhere
resolves a hue without a build_id. A lookup that filters on hue_index only returns one row per
build and, if the caller takes the first, renders an old design with a newer colour table — precisely
the failure Sections 9.8 and 13.7 exist to prevent. Section 19.7 states the same rule for the hot
render-path query.
Hue index 0 (unhued) is never stored as a row; it is a reserved sentinel handled entirely in
application code and Section 8.3.3's rendering algorithm, since it has no colour table to decode.
Because there is no row, there is also no name: a design carrying skinHue: 0 is described as
"default skin", never by a hue name (Section 12.10).
Who writes this table. hues rows are inserted by stage D6 (Sections 7.2.6 and 7.7), in the same
run that writes assets, asset_variants and asset_images, and by nothing else. D4 decodes the
hue tables into the work directory and D5 records a proposed group set on the candidate row, but
neither touches this table — a hues row cannot exist before D6 runs, which is why
hue_group_members (Section 6.15) is written by D6 as well. Staff edits on the hues admin screen
(Section 17.8) update name, source and swatch_override_argb on existing rows; they never insert
one.
CREATE INDEX hues_source_idx ON hues (source) WHERE retired_at IS NULL;
-- Powers the hue browser (Section 15.5) filtered by base/outlands/derived.Typical row count: 3,000–4,000 active per build (UO's base hue space) plus Outlands custom hues.
6.14 hue_groups #
Curated groupings of hues for browsing and filtering.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
group_key |
TEXT |
not null | — | Natural key, e.g. skin, hair, tattoo, clothing, event. |
display_name |
TEXT |
not null | — | Human label. |
sort_order |
INT |
not null | — | Display order in the hue browser and picker. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
Primary key: id. Unique: group_key. Fixed row count: 5 (seeded, Section 6.31).
A hue_group here is a curated UI taxonomy, not a file-format structure. The 8-hue records inside
hues.mul described in Section 7.3.3 are hue blocks, a decoding detail that never becomes a row in
this table. Anything joining on group membership joins hue_groups.group_key; there is no
hue_group column on asset_variants or on any other table.
6.15 hue_group_members #
Many-to-many join between hues and hue_groups. A hue may belong to more than one group (e.g. an
Outlands cosmetic hue tagged both skin and event).
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
hue_id |
CHAR(26) |
not null | — | FK to hues.id. |
hue_group_id |
CHAR(26) |
not null | — | FK to hue_groups.id. |
method |
TEXT |
not null | 'manual' |
How this membership was assigned: heuristic (written by D6 as it inserts each hues row, applying Section 7.7's keyword/range rules) or manual (set by staff in Section 17.8). |
assigned_at |
TIMESTAMPTZ |
not null | now() |
When the membership was assigned. |
Primary key: (hue_id, hue_group_id). Foreign keys: hue_id → hues(id) ON DELETE CASCADE;
hue_group_id → hue_groups(id) ON DELETE CASCADE. Check: method IN ('manual','heuristic').
method is what lets the curation policy change without a schema change: the import path writes
heuristic rows, staff edits write manual rows, and a staff edit is never overwritten by a later
import because the import only touches rows it owns.
Membership rows are inserted by D6, never by D5. hue_id is a foreign key to hues.id, and
Section 6.13 establishes that no hues row exists until D6 creates it, so a D5 write here could only
ever violate the constraint. D5's contribution is the proposal: it records the group keys its rule
tables selected on extraction_candidates.metadata.hueGroups, and D6 turns that proposal into rows
at the moment the hue itself becomes real (Section 7.7).
CREATE INDEX hue_group_members_group_idx ON hue_group_members (hue_group_id);
-- Supports "list all hues in group X" for the hue picker (Section 11.6).Typical row count: roughly equal to active hues (most hues belong to exactly one group).
6.16 tags #
Catalog facets for browse/search filtering (Sections 15.3 and 15.6).
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
tag_key |
TEXT |
not null | — | Natural key, e.g. style-fantasy. |
display_name |
TEXT |
not null | — | Human label. |
category |
TEXT |
not null | — | One of style, era, event, source. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
Primary key: id. Unique: tag_key. Check: category IN ('style','era','event','source'). The API
names this field tagKey and matches on the tag_key value; there is no separate short tag
column. Typical row count: 20–60, curated by staff.
6.17 asset_tags #
Many-to-many join between assets and tags.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
asset_id |
CHAR(26) |
not null | — | FK to assets.id. |
tag_id |
CHAR(26) |
not null | — | FK to tags.id. |
Primary key: (asset_id, tag_id). Foreign keys: asset_id → assets(id) ON DELETE CASCADE; tag_id
→ tags(id) ON DELETE CASCADE.
CREATE INDEX asset_tags_tag_idx ON asset_tags (tag_id);
-- Supports "list all assets with tag X" for catalog facet filtering (Section 15.3).An insert or delete here also refreshes the owning asset's search_tsv through the trigger defined
in Section 6.30, so tag names stay searchable without a rebuild step.
Typical row count: 2–6× assets (an asset commonly carries several tags).
6.18 designs #
An immutable saved combination, per the canonical design JSON in Section 13.2.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
short_code |
TEXT |
not null | — | 10-character Crockford Base32 short code (Section 13.4). |
salt |
SMALLINT |
null | — | Collision-resolution salt byte appended before hashing (Section 13.4.2). NULL means no salt byte was appended; 1–255 are collision retries. |
canonical_json |
TEXT |
not null | — | The canonical design document, stored verbatim as produced by canonicalize() (Section 13.3.2); never re-serialized. |
canonical_jsonb |
JSONB |
not null | generated | canonical_json::jsonb, a stored generated column, so the "designs like this" query in Section 13.8 can index and search inside the document without disturbing the byte-exact original. |
build_id |
CHAR(26) |
not null | — | FK to game_builds.id, the build this design was created against. |
view_count |
BIGINT |
not null | 0 |
Lifetime count of permalink views, incremented server-side by the bot-filtered write path in Section 13.10.1. |
render_count |
BIGINT |
not null | 0 |
Lifetime count of composite render requests, incremented server-side. |
taken_down_at |
TIMESTAMPTZ |
null | — | Set by the takedown process in Section 20.8; a non-null value makes every surface serve 410. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time; designs have no updated_at since they are immutable. |
Primary key: id. Unique: short_code. Foreign key: build_id → game_builds(id) ON DELETE
RESTRICT (a build must never be hard-deleted while designs reference it; see Section 6.33).
Checks: short_code ~ '^[0-9A-HJKMNP-TV-Z]{10}$' (exactly ten Crockford Base32 characters — the
alphabet excludes I, L, O and U); salt IS NULL OR salt BETWEEN 1 AND 255.
salt is nullable and never 0, because "unsalted" and "salted with the byte 0x00" are two
different hash inputs and would otherwise be indistinguishable on a stored row. Any code that
re-derives a short code from canonical_json must append a salt byte if and only if salt IS NOT NULL.
canonical_json is TEXT, not JSONB, because Section 13.4.2's collision procedure compares the
stored document byte-for-byte against a freshly canonicalized one. jsonb normalizes whitespace,
reorders object keys and drops duplicates, so the value read back would never be the bytes that were
hashed, and every re-share of an identical design would be misclassified as a true hash collision.
The generated canonical_jsonb column exists so the queryable form is still available without
sacrificing that guarantee.
taken_down_at is the only removal mechanism. Designs are never hard-deleted and there is no
age-based or view-based pruning of this table; the reasoning and the storage-growth answer are in
Section 6.33.
No free-text user-supplied field exists on this table by deliberate moderation decision: designs are combinations of catalog references only, never arbitrary strings, so there is no surface for abusive user text to appear on a permalink.
CREATE INDEX designs_build_id_idx ON designs (build_id);
-- Used by re-sync (Section 9.8) to find all designs pinned to a given build for hue/asset lookups.
CREATE INDEX designs_canonical_gin_idx ON designs USING GIN (canonical_jsonb jsonb_path_ops);
-- Serves Section 13.8's containment query on the permalink page without a sequential scan.
CREATE INDEX designs_created_at_idx ON designs (created_at DESC) WHERE taken_down_at IS NULL;
-- Serves the sitemap and the random-design endpoint (Sections 10.6 and 16.8.13).Typical row count: unbounded growth, the largest table over time (every share action inserts one row); expect hundreds of thousands within the first year at moderate traffic.
6.19 design_renders #
Cache rows for server-composited images, per the cache-key scheme in Section 8.9. The table backs all three render URL shapes in Section 8.8, not only whole-design composites.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
render_kind |
TEXT |
not null | — | One of design, asset, swatch. |
design_id |
CHAR(26) |
null | — | FK to designs.id; non-null exactly when render_kind = 'design'. |
subject_ref |
TEXT |
not null | — | What was rendered: the design's short_code, or the asset_key, or the slot_key plus hue index, depending on render_kind. |
cache_key |
CHAR(64) |
not null | — | sha256(buildHash + canonicalDesignJson + scale + format) for design renders, and the analogous per-subject digest for the other two kinds, per Section 8.9. |
scale |
SMALLINT |
not null | — | One of 1, 2, 3. |
format |
TEXT |
not null | — | One of webp, png. |
storage_key |
TEXT |
not null | — | Object storage key under the renders/ prefix. |
byte_size |
INT |
not null | — | Encoded file size in bytes. |
content_hash |
CHAR(64) |
not null | — | SHA-256 of the encoded bytes, so a read can detect a corrupt or truncated cache object (Section 8.14). |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
last_served_at |
TIMESTAMPTZ |
null | — | Updated on cache hit, for LRU-style storage pruning. |
Primary key: id. Unique: cache_key; storage_key. Foreign key: design_id → designs(id) ON
DELETE CASCADE. Checks: render_kind IN ('design','asset','swatch'); (render_kind = 'design') = (design_id IS NOT NULL); scale IN (1,2,3); format IN ('webp','png').
design_id is nullable because asset and swatch renders have no owning design. The paired CHECK makes
the discriminator honest in both directions, so a design row can never be orphaned and an asset
row can never smuggle in a design reference.
The column is named storage_key, matching asset_images and og_images; no section uses
object_key.
CREATE INDEX design_renders_design_id_idx ON design_renders (design_id) WHERE design_id IS NOT NULL;
-- Enumerates all cached render variants for a design, used by the takedown path (Section 20.8).
CREATE INDEX design_renders_last_served_idx ON design_renders (last_served_at NULLS FIRST);
-- Serves the regenerable-artifact purge runbook (Section 23.8).Typical row count: up to 6× designs (3 scales × 2 formats), in practice lower since most permalinks
are only ever viewed at one scale/format combination, plus one row per cached asset/swatch render.
6.20 og_images #
Generated Open Graph preview images for a design (Section 14.5).
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
design_id |
CHAR(26) |
not null | — | FK to designs.id. |
cache_key |
CHAR(64) |
not null | — | Digest over the design composite's scale = 1, format = png render cache key and the OG template version, exactly as Section 14.5 computes it. Because that render key already folds in the build hash and the canonical JSON, both are covered transitively. |
storage_key |
TEXT |
not null | — | Object storage key under the og/ prefix. |
byte_size |
INT |
not null | — | Encoded PNG size in bytes. |
content_hash |
CHAR(64) |
not null | — | SHA-256 of the encoded file. |
created_at |
TIMESTAMPTZ |
not null | now() |
When the OG image was generated. |
last_served_at |
TIMESTAMPTZ |
null | — | Updated when a crawler fetches it; drives the pruning policy in Section 14.8. |
Primary key: id. Unique: cache_key; storage_key. Foreign key: design_id → designs(id) ON
DELETE CASCADE.
There is deliberately no unique constraint on design_id: bumping the OG template version
produces a second, differently-keyed row for the same design, and the old row stays until the pruning
job removes it. Lookups are by cache_key.
CREATE INDEX og_images_design_idx ON og_images (design_id);
-- Enumerates every generated card for a design, used by the takedown path and the pruning job.Typical row count: equal to the subset of designs that have been shared at least once and crawled,
generated lazily per Section 14.5.
6.21 jobs #
The Postgres-backed background job queue (Section 4.7).
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
job_type |
TEXT |
not null | — | One of render, og_render, import_stage, stat_rollup, notify, reimport_reminder, prune. |
payload |
JSONB |
not null | '{}' |
Job-specific arguments. |
dedupe_key |
TEXT |
null | — | Optional collapse key; at most one non-terminal job may hold a given value. |
priority |
SMALLINT |
not null | 0 |
Higher runs first among jobs whose run_after has passed. |
status |
TEXT |
not null | 'queued' |
One of queued, running, succeeded, failed, cancelled (Section 6.2). |
run_after |
TIMESTAMPTZ |
not null | now() |
Earliest time this job may be claimed. |
attempts |
INT |
not null | 0 |
Number of claim attempts so far. |
max_attempts |
INT |
not null | 5 |
Attempts allowed before marking permanently failed. |
claimed_at |
TIMESTAMPTZ |
null | — | Set when a worker claims the job. |
claimed_by |
TEXT |
null | — | Worker process identifier holding the claim. |
last_error |
TEXT |
null | — | Error message from the most recent failed attempt. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
updated_at |
TIMESTAMPTZ |
not null | now() |
Last state change. |
Primary key: id. Checks: job_type and status constrained to the enumerations above;
attempts >= 0; max_attempts > 0.
There is no done status and no pending status. A finished job is succeeded; a job waiting to
run is queued. Any write that sets another value violates the CHECK.
dedupe_key is what makes the render mutex in Section 8.10 work: a request that wants a render
inserts a render job keyed on the render's cache_key with ON CONFLICT DO NOTHING, and the
partial unique index below is the arbiter, so a thundering herd on a viral permalink produces exactly
one render.
CREATE UNIQUE INDEX jobs_dedupe_key_idx ON jobs (dedupe_key)
WHERE dedupe_key IS NOT NULL AND status IN ('queued','running');
-- The ON CONFLICT arbiter: one live job per dedupe key, freed as soon as the job reaches a terminal state.
CREATE INDEX jobs_claim_idx ON jobs (priority DESC, run_after)
WHERE status = 'queued';
-- The claim query: SELECT ... WHERE status = 'queued' AND run_after <= now()
-- ORDER BY priority DESC, run_after LIMIT n FOR UPDATE SKIP LOCKED, using this partial index.Typical row count: low thousands transient (queued/running), with completed rows pruned per Section 6.33's retention policy.
6.22 admin_users #
Staff accounts.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
email |
TEXT |
not null | — | Login email, stored lowercase. |
password_hash |
TEXT |
not null | — | Argon2id hash. |
totp_secret |
TEXT |
not null | — | Encrypted-at-rest TOTP shared secret. |
role |
TEXT |
not null | 'viewer' |
One of viewer, curator, owner (Section 6.2). |
disabled_at |
TIMESTAMPTZ |
null | — | Set when an account is disabled; a disabled account cannot authenticate. |
last_login_at |
TIMESTAMPTZ |
null | — | Updated on successful login. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
updated_at |
TIMESTAMPTZ |
not null | now() |
Last modification time. |
Primary key: id. Unique: email. Check: role IN ('viewer','curator','owner').
There are exactly three roles, ordered viewer < curator < owner; the per-action permission matrix
that gives them meaning is Section 17.3.1, and this table stores nothing beyond the role name. An
account is active when disabled_at IS NULL; there is no separate is_active flag, so the two can
never disagree.
Rows are created only by the skinforge-cli admin user create command (Section 17.15); there is no
public self-signup and no admin-console endpoint that creates a user without going through the CLI.
Typical row count: under 20.
6.23 admin_sessions #
Server-side sessions backing the sf_admin cookie.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
admin_user_id |
CHAR(26) |
not null | — | FK to admin_users.id. |
session_token_hash |
CHAR(64) |
not null | — | SHA-256 of the opaque session token; the raw token is only ever in the cookie. |
expires_at |
TIMESTAMPTZ |
not null | — | Absolute expiry, set from SKINFORGE_ADMIN_SESSION_TTL_HOURS (Section 24.2). |
last_seen_at |
TIMESTAMPTZ |
not null | now() |
Updated on every authenticated request; backs the idle timeout in Section 17.1. |
revoked_at |
TIMESTAMPTZ |
null | — | Set on explicit logout or forced revocation. |
ip_address |
INET |
null | — | Client IP at session creation, for audit. |
user_agent |
TEXT |
null | — | Client user agent at session creation. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
Primary key: id. Unique: session_token_hash. Foreign key: admin_user_id → admin_users(id) ON
DELETE CASCADE.
CREATE INDEX admin_sessions_user_active_idx
ON admin_sessions (admin_user_id) WHERE revoked_at IS NULL;
-- Supports "list my active sessions" and bulk revoke-all-sessions-for-user.Typical row count: low, one to a few per active staff member; expired/revoked rows pruned per Section 6.33.
6.24 admin_recovery_codes #
One-time TOTP recovery codes.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
admin_user_id |
CHAR(26) |
not null | — | FK to admin_users.id. |
code_hash |
CHAR(64) |
not null | — | SHA-256 of the one-time recovery code. |
used_at |
TIMESTAMPTZ |
null | — | Set the first time the code is redeemed; codes are single-use. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
Primary key: id. Foreign key: admin_user_id → admin_users(id) ON DELETE CASCADE. Ten rows are
generated per admin user at account creation and at explicit staff-initiated regeneration.
The hash is SHA-256, matching Section 17.2.2, not a slow KDF: a recovery code carries 50 bits of entropy, verification costs ten hashes per attempt, and the login-failure lockout in Section 17.2.3 bounds attempts anyway.
6.25 audit_log #
Append-only record of every state-changing admin or pipeline action.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
actor_id |
CHAR(26) |
null | — | FK to admin_users.id; null for system/scheduled actions. |
actor_label |
TEXT |
not null | — | Denormalized email or system:<job_type> at time of action, survives actor deletion. |
action |
TEXT |
not null | — | Action key, e.g. build.publish, asset.retire, candidate.approve. |
entity_type |
TEXT |
not null | — | Entity table name affected. |
entity_id |
TEXT |
null | — | Entity id or natural key affected. |
before |
JSONB |
null | — | Entity state before the action, when applicable. |
after |
JSONB |
null | — | Entity state after the action, when applicable. |
reason |
TEXT |
null | — | Free-text justification, required for rejections and takedown actions. |
ip_address |
INET |
null | — | Client IP of the acting staff session; null for system actions. |
user_agent |
TEXT |
null | — | Client user agent of the acting staff session; null for system actions. |
metadata |
JSONB |
not null | '{}' |
Action-specific structured extras (bulk-action batch id, affected counts, request id). |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
Primary key: id. Foreign key: actor_id → admin_users(id) ON DELETE SET NULL (the row survives
account deletion via the denormalized actor_label).
CREATE INDEX audit_log_entity_idx ON audit_log (entity_type, entity_id, created_at DESC);
-- Powers "show history for this entity" on every admin detail screen (Section 17.13).
CREATE INDEX audit_log_actor_idx ON audit_log (actor_id, created_at DESC);
-- Powers "show this staff member's recent actions" (Section 17.13).Typical row count: unbounded, append-only; the second-largest table over time after designs. Rows
are retained indefinitely and never pruned (Section 6.33).
6.26 rate_limit_buckets #
Token-bucket state for the rate limits in Section 16.7.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
bucket_key |
TEXT |
not null | — | Composite key, e.g. api:ip:203.0.113.4. Primary key. |
tokens |
REAL |
not null | — | Current token count. |
updated_at |
TIMESTAMPTZ |
not null | now() |
Last refill/consume time, used to compute elapsed-time refill. |
Primary key: bucket_key. No foreign keys. Rows are upserted on every rate-limited request; stale
rows (untouched for 24 hours) are pruned by a scheduled job (Section 6.33). Typical row count:
proportional to distinct recent client IPs, low thousands under normal traffic.
6.27 stat_counters #
Aggregate counters computed server-side, no PII.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
counter_key |
TEXT |
not null | — | Counter name, e.g. designs_created_total, design_view:AB12CD34EF. |
day |
DATE |
not null | — | The UTC day the count belongs to, or the reserved sentinel 0001-01-01 for an all-time running total. |
value |
BIGINT |
not null | 0 |
Current counter value. |
updated_at |
TIMESTAMPTZ |
not null | now() |
Last increment time. |
Primary key: (counter_key, day). This composite key is the conflict target every increment uses:
INSERT INTO stat_counters (counter_key, day, value) VALUES ($1, current_date, 1) ON CONFLICT (counter_key, day) DO UPDATE SET value = stat_counters.value + 1, updated_at = now();.
Two shapes share the table. Per-day counters use the real UTC date and are pruned on the same 400-day
window as page_view_daily (Section 6.33). All-time totals use the sentinel day 0001-01-01, are
never pruned, and are the handful of counters the public stats endpoint reads. Row count is therefore
bounded by (named counters × retained days) plus one sentinel row per counter.
6.28 page_view_daily #
Daily page-view aggregate per path, written server-side at request time, no cookies, no PII (Section 21.6).
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
day |
DATE |
not null | — | Calendar day (UTC). |
path |
TEXT |
not null | — | Normalized request path (query string stripped). |
views |
BIGINT |
not null | 0 |
Count of requests for that path on that day. |
Primary key: (day, path). No foreign keys.
CREATE INDEX page_view_daily_day_idx ON page_view_daily (day);
-- Supports the admin dashboard's traffic-by-day rollup (Section 17.4).Typical row count: distinct paths × days; bounded by pruning rows older than the retention window in Section 6.33.
6.29 takedown_requests #
Legal/rights takedown requests (Section 20.8).
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
design_id |
CHAR(26) |
null | — | FK to designs.id, when the request targets a specific permalink. |
asset_id |
CHAR(26) |
null | — | FK to assets.id, when the request targets catalog art. |
requester_email |
TEXT |
not null | — | Contact email supplied by the requester. |
reason |
TEXT |
not null | — | Requester's stated reason. |
status |
TEXT |
not null | 'new' |
One of new, triaging, upheld, rejected, withdrawn (Section 6.2). |
resolution_note |
TEXT |
null | — | Staff note on how the request was resolved. |
handled_by |
CHAR(26) |
null | — | FK to admin_users.id. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
resolved_at |
TIMESTAMPTZ |
null | — | When the request reached upheld, rejected or withdrawn. |
Primary key: id. Foreign keys: design_id → designs(id) ON DELETE SET NULL; asset_id →
assets(id) ON DELETE SET NULL; handled_by → admin_users(id) ON DELETE SET NULL. Check: status IN ('new','triaging','upheld','rejected','withdrawn').
upheld is the only status that changes anything on the public site: it sets designs.taken_down_at
(or retires the asset), after which every surface serves 410 per Section 20.8. Typical row count: low,
expected to stay under a few hundred for the life of the product.
Three further tables — admin_pending_mfa, admin_login_failures and app_settings — are
specified in Sections 6.35, 6.36 and 6.37; they appear in the DDL below in dependency order alongside
the rest of the schema.
6.30 Full DDL — db/migrations/0001_init.sql #
The file contains no BEGIN;/COMMIT;. The migration runner (Section 6.32) opens and commits one
transaction per file; a file that opened its own would leave the runner's bookkeeping insert outside
it.
-- 0001_init.sql
-- SkinForge initial schema. Applied by the Deno migration runner (Section 6.32).
-- The runner owns the transaction; this file must not contain BEGIN or COMMIT.
-- pg_trgm backs the similarity half of catalog search (Section 15.6). Creating an extension
-- requires a superuser or a role with rds_superuser-equivalent rights; the first-time
-- provisioning runbook (Section 23.4) grants it before the first migration runs.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE schema_migrations (
filename TEXT PRIMARY KEY,
checksum CHAR(64) NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE FUNCTION set_updated_at() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE TABLE admin_users (
id CHAR(26) PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
totp_secret TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('viewer','curator','owner')),
disabled_at TIMESTAMPTZ,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TRIGGER admin_users_set_updated_at
BEFORE UPDATE ON admin_users FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE admin_sessions (
id CHAR(26) PRIMARY KEY,
admin_user_id CHAR(26) NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
session_token_hash CHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX admin_sessions_user_active_idx
ON admin_sessions (admin_user_id) WHERE revoked_at IS NULL;
CREATE TABLE admin_recovery_codes (
id CHAR(26) PRIMARY KEY,
admin_user_id CHAR(26) NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
code_hash CHAR(64) NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE admin_pending_mfa (
id CHAR(26) PRIMARY KEY,
admin_user_id CHAR(26) NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
token_hash CHAR(64) NOT NULL UNIQUE,
attempts SMALLINT NOT NULL DEFAULT 0 CHECK (attempts >= 0),
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE admin_login_failures (
id CHAR(26) PRIMARY KEY,
admin_user_id CHAR(26) REFERENCES admin_users(id) ON DELETE CASCADE,
email_attempted TEXT NOT NULL,
ip_address INET,
phase TEXT NOT NULL CHECK (phase IN ('password','totp','recovery_code')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX admin_login_failures_email_time_idx
ON admin_login_failures (email_attempted, created_at DESC);
CREATE INDEX admin_login_failures_ip_time_idx
ON admin_login_failures (ip_address, created_at DESC);
CREATE TABLE app_settings (
setting_key TEXT PRIMARY KEY
CHECK (setting_key IN ('admin_ip_allowlist','maintenance_mode',
'takedown_response_template','featured_designs',
'designs_like_this_threshold','reimport_reminder_cadence_days')),
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by CHAR(26) REFERENCES admin_users(id) ON DELETE SET NULL
);
CREATE TABLE audit_log (
id CHAR(26) PRIMARY KEY,
actor_id CHAR(26) REFERENCES admin_users(id) ON DELETE SET NULL,
actor_label TEXT NOT NULL,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
before JSONB,
after JSONB,
reason TEXT,
ip_address INET,
user_agent TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX audit_log_entity_idx ON audit_log (entity_type, entity_id, created_at DESC);
CREATE INDEX audit_log_actor_idx ON audit_log (actor_id, created_at DESC);
CREATE TABLE game_builds (
id CHAR(26) PRIMARY KEY,
label TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','in_review','published','archived','rolled_back')),
build_hash CHAR(64),
source_client_version TEXT,
notes TEXT,
created_by CHAR(26) REFERENCES admin_users(id) ON DELETE SET NULL,
published_at TIMESTAMPTZ,
published_by CHAR(26) REFERENCES admin_users(id) ON DELETE SET NULL,
archived_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX game_builds_published_singleton
ON game_builds ((1)) WHERE status = 'published';
CREATE TRIGGER game_builds_set_updated_at
BEFORE UPDATE ON game_builds FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE source_files (
id CHAR(26) PRIMARY KEY,
build_id CHAR(26) NOT NULL REFERENCES game_builds(id) ON DELETE CASCADE,
relative_path TEXT NOT NULL,
file_type TEXT NOT NULL
CHECK (file_type IN ('mul','idx','uop','def','txt','image','archive','unknown')),
size_bytes BIGINT NOT NULL,
sha256 CHAR(64) NOT NULL,
magic_bytes TEXT,
entropy REAL,
discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (build_id, relative_path)
);
CREATE INDEX source_files_build_type_idx ON source_files (build_id, file_type);
CREATE TABLE import_runs (
id CHAR(26) PRIMARY KEY,
build_id CHAR(26) NOT NULL REFERENCES game_builds(id) ON DELETE CASCADE,
stage TEXT NOT NULL
CHECK (stage IN ('inventory','identify','probe','extract','classify','normalize',
'import','verify')),
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
triggered_by CHAR(26) REFERENCES admin_users(id) ON DELETE SET NULL,
summary JSONB NOT NULL DEFAULT '{}',
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX import_runs_build_stage_idx ON import_runs (build_id, stage, created_at DESC);
CREATE TABLE import_run_items (
id CHAR(26) PRIMARY KEY,
import_run_id CHAR(26) NOT NULL REFERENCES import_runs(id) ON DELETE CASCADE,
source_file_id CHAR(26) REFERENCES source_files(id) ON DELETE SET NULL,
item_type TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('ok','skipped','failed','needs_operator_input')),
detail JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX import_run_items_run_status_idx ON import_run_items (import_run_id, status);
CREATE TABLE slots (
slot_key TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
z_order INT NOT NULL UNIQUE,
display_order INT NOT NULL UNIQUE,
uo_layer INT,
hueable BOOLEAN NOT NULL DEFAULT true,
gender_scope TEXT NOT NULL DEFAULT 'both' CHECK (gender_scope IN ('both','male','female')),
is_required BOOLEAN NOT NULL DEFAULT false,
is_always_visible BOOLEAN NOT NULL DEFAULT false,
visible BOOLEAN NOT NULL DEFAULT true,
hue_swatch_crop TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE assets (
id CHAR(26) PRIMARY KEY,
asset_key TEXT NOT NULL UNIQUE,
slot_key TEXT NOT NULL REFERENCES slots(slot_key) ON DELETE RESTRICT,
display_name TEXT NOT NULL,
display_order INT NOT NULL DEFAULT 0,
source_file_id CHAR(26) REFERENCES source_files(id) ON DELETE SET NULL,
source_offset BIGINT,
gump_id INT,
partial_hue BOOLEAN NOT NULL DEFAULT false,
search_tsv tsvector,
first_build_id CHAR(26) NOT NULL REFERENCES game_builds(id) ON DELETE RESTRICT,
last_build_id CHAR(26) NOT NULL REFERENCES game_builds(id) ON DELETE RESTRICT,
retired_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT assets_asset_key_shape
CHECK (asset_key ~ '^[a-z0-9]+([-_.][a-z0-9]+)*$' AND length(asset_key) <= 128)
);
CREATE INDEX assets_slot_key_idx ON assets (slot_key, display_order) WHERE retired_at IS NULL;
CREATE INDEX assets_gump_id_idx ON assets (gump_id) WHERE gump_id IS NOT NULL;
CREATE INDEX assets_search_tsv_idx ON assets USING GIN (search_tsv);
CREATE INDEX assets_display_name_trgm_idx ON assets USING GIN (display_name gin_trgm_ops);
CREATE TRIGGER assets_set_updated_at
BEFORE UPDATE ON assets FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE extraction_candidates (
id CHAR(26) PRIMARY KEY,
import_run_id CHAR(26) NOT NULL REFERENCES import_runs(id) ON DELETE CASCADE,
source_file_id CHAR(26) REFERENCES source_files(id) ON DELETE SET NULL,
candidate_key TEXT NOT NULL,
suggested_slot_key TEXT REFERENCES slots(slot_key) ON DELETE SET NULL,
suggested_body TEXT CHECK (suggested_body IN ('m','f')),
confidence REAL NOT NULL DEFAULT 0 CHECK (confidence BETWEEN 0 AND 1),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','needs_operator_input','approved','rejected')),
image_ref TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
reviewed_by CHAR(26) REFERENCES admin_users(id) ON DELETE SET NULL,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (import_run_id, candidate_key)
);
CREATE INDEX extraction_candidates_status_confidence_idx
ON extraction_candidates (status, confidence DESC);
CREATE TABLE asset_variants (
id CHAR(26) PRIMARY KEY,
asset_id CHAR(26) NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
build_id CHAR(26) NOT NULL REFERENCES game_builds(id) ON DELETE RESTRICT,
body TEXT NOT NULL CHECK (body IN ('m','f')),
width INT NOT NULL CHECK (width > 0),
height INT NOT NULL CHECK (height > 0),
offset_x INT NOT NULL,
offset_y INT NOT NULL,
hue_mode TEXT NOT NULL DEFAULT 'full' CHECK (hue_mode IN ('full','partial')),
sha256 CHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (asset_id, body, build_id)
);
CREATE INDEX asset_variants_sha256_idx ON asset_variants (sha256);
CREATE INDEX asset_variants_build_idx ON asset_variants (build_id);
CREATE TABLE asset_images (
id CHAR(26) PRIMARY KEY,
asset_variant_id CHAR(26) NOT NULL REFERENCES asset_variants(id) ON DELETE CASCADE,
scale SMALLINT NOT NULL CHECK (scale IN (1,2,3)),
format TEXT NOT NULL CHECK (format IN ('webp','png')),
storage_key TEXT NOT NULL,
byte_size INT NOT NULL,
content_hash CHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (asset_variant_id, scale, format)
);
CREATE INDEX asset_images_storage_key_idx ON asset_images (storage_key);
CREATE TABLE hues (
id CHAR(26) PRIMARY KEY,
hue_index INT NOT NULL,
source TEXT NOT NULL CHECK (source IN ('base','outlands','derived')),
name TEXT NOT NULL,
colors INTEGER[32] NOT NULL CHECK (array_length(colors,1) = 32),
table_start INT NOT NULL,
table_end INT NOT NULL,
swatch_color INTEGER NOT NULL,
swatch_override_argb INTEGER,
partial BOOLEAN NOT NULL DEFAULT false,
build_id CHAR(26) NOT NULL REFERENCES game_builds(id) ON DELETE RESTRICT,
retired_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (hue_index, build_id)
);
CREATE INDEX hues_source_idx ON hues (source) WHERE retired_at IS NULL;
CREATE TABLE hue_groups (
id CHAR(26) PRIMARY KEY,
group_key TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
sort_order INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE hue_group_members (
hue_id CHAR(26) NOT NULL REFERENCES hues(id) ON DELETE CASCADE,
hue_group_id CHAR(26) NOT NULL REFERENCES hue_groups(id) ON DELETE CASCADE,
method TEXT NOT NULL DEFAULT 'manual' CHECK (method IN ('manual','heuristic')),
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (hue_id, hue_group_id)
);
CREATE INDEX hue_group_members_group_idx ON hue_group_members (hue_group_id);
CREATE TABLE tags (
id CHAR(26) PRIMARY KEY,
tag_key TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
category TEXT NOT NULL CHECK (category IN ('style','era','event','source')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE asset_tags (
asset_id CHAR(26) NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
tag_id CHAR(26) NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (asset_id, tag_id)
);
CREATE INDEX asset_tags_tag_idx ON asset_tags (tag_id);
-- Full-text search vector, maintained by trigger. A GENERATED column cannot be used here:
-- PostgreSQL rejects a generation expression containing a subquery or a cross-table reference,
-- and the vector must include tag names from asset_tags.
--
-- These two functions and three triggers are defined here and nowhere else. Section 15.6 owns the
-- *query* that reads assets.search_tsv and the weighting rationale; it does not redeclare the
-- trigger. Both triggers fire AFTER, not BEFORE: the refresh function re-reads the row through a
-- sub-select, so on BEFORE INSERT the row would not yet be visible and every newly imported asset
-- would land with a NULL vector. The refresh writes only search_tsv, while the assets trigger is
-- scoped to UPDATE OF display_name, asset_key -- so the write cannot re-fire the trigger and the
-- recursion terminates after one pass.
-- tags columns are (id, tag_key, display_name, category, created_at): the vector reads
-- tags.display_name. There is no tags.name and no tags.slug.
CREATE FUNCTION assets_search_tsv_refresh(target_asset_id CHAR(26)) RETURNS void
LANGUAGE sql AS $$
UPDATE assets a
SET search_tsv =
setweight(to_tsvector('english', coalesce(a.display_name, '')), 'A')
|| setweight(to_tsvector('english', replace(coalesce(a.asset_key, ''), '.', ' ')), 'B')
|| setweight(to_tsvector('english', coalesce((
SELECT string_agg(t.display_name, ' ')
FROM asset_tags xt
JOIN tags t ON t.id = xt.tag_id
WHERE xt.asset_id = a.id), '')), 'C')
WHERE a.id = target_asset_id;
$$;
CREATE FUNCTION assets_search_tsv_trigger() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
PERFORM assets_search_tsv_refresh(NEW.id);
RETURN NULL;
END;
$$;
CREATE TRIGGER assets_search_tsv_aiu
AFTER INSERT OR UPDATE OF display_name, asset_key ON assets
FOR EACH ROW EXECUTE FUNCTION assets_search_tsv_trigger();
CREATE FUNCTION asset_tags_search_tsv_trigger() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
-- NEW is unassigned in a DELETE trigger, so it must not be referenced there at all.
IF TG_OP = 'DELETE' THEN
PERFORM assets_search_tsv_refresh(OLD.asset_id);
ELSE
PERFORM assets_search_tsv_refresh(NEW.asset_id);
END IF;
RETURN NULL;
END;
$$;
CREATE TRIGGER asset_tags_search_tsv_aid
AFTER INSERT OR DELETE ON asset_tags
FOR EACH ROW EXECUTE FUNCTION asset_tags_search_tsv_trigger();
CREATE TABLE designs (
id CHAR(26) PRIMARY KEY,
short_code TEXT NOT NULL UNIQUE
CHECK (short_code ~ '^[0-9A-HJKMNP-TV-Z]{10}$'),
salt SMALLINT CHECK (salt IS NULL OR salt BETWEEN 1 AND 255),
canonical_json TEXT NOT NULL,
canonical_jsonb JSONB NOT NULL GENERATED ALWAYS AS (canonical_json::jsonb) STORED,
build_id CHAR(26) NOT NULL REFERENCES game_builds(id) ON DELETE RESTRICT,
view_count BIGINT NOT NULL DEFAULT 0,
render_count BIGINT NOT NULL DEFAULT 0,
taken_down_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX designs_build_id_idx ON designs (build_id);
CREATE INDEX designs_canonical_gin_idx ON designs USING GIN (canonical_jsonb jsonb_path_ops);
CREATE INDEX designs_created_at_idx ON designs (created_at DESC) WHERE taken_down_at IS NULL;
CREATE TABLE design_renders (
id CHAR(26) PRIMARY KEY,
render_kind TEXT NOT NULL CHECK (render_kind IN ('design','asset','swatch')),
design_id CHAR(26) REFERENCES designs(id) ON DELETE CASCADE,
subject_ref TEXT NOT NULL,
cache_key CHAR(64) NOT NULL UNIQUE,
scale SMALLINT NOT NULL CHECK (scale IN (1,2,3)),
format TEXT NOT NULL CHECK (format IN ('webp','png')),
storage_key TEXT NOT NULL UNIQUE,
byte_size INT NOT NULL,
content_hash CHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_served_at TIMESTAMPTZ,
CONSTRAINT design_renders_kind_ref
CHECK ((render_kind = 'design') = (design_id IS NOT NULL))
);
CREATE INDEX design_renders_design_id_idx ON design_renders (design_id) WHERE design_id IS NOT NULL;
CREATE INDEX design_renders_last_served_idx ON design_renders (last_served_at NULLS FIRST);
CREATE TABLE og_images (
id CHAR(26) PRIMARY KEY,
design_id CHAR(26) NOT NULL REFERENCES designs(id) ON DELETE CASCADE,
cache_key CHAR(64) NOT NULL UNIQUE,
storage_key TEXT NOT NULL UNIQUE,
byte_size INT NOT NULL,
content_hash CHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_served_at TIMESTAMPTZ
);
CREATE INDEX og_images_design_idx ON og_images (design_id);
CREATE TABLE jobs (
id CHAR(26) PRIMARY KEY,
job_type TEXT NOT NULL
CHECK (job_type IN ('render','og_render','import_stage','stat_rollup',
'notify','reimport_reminder','prune')),
payload JSONB NOT NULL DEFAULT '{}',
dedupe_key TEXT,
priority SMALLINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
run_after TIMESTAMPTZ NOT NULL DEFAULT now(),
attempts INT NOT NULL DEFAULT 0 CHECK (attempts >= 0),
max_attempts INT NOT NULL DEFAULT 5 CHECK (max_attempts > 0),
claimed_at TIMESTAMPTZ,
claimed_by TEXT,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX jobs_dedupe_key_idx ON jobs (dedupe_key)
WHERE dedupe_key IS NOT NULL AND status IN ('queued','running');
CREATE INDEX jobs_claim_idx ON jobs (priority DESC, run_after) WHERE status = 'queued';
CREATE TRIGGER jobs_set_updated_at
BEFORE UPDATE ON jobs FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE rate_limit_buckets (
bucket_key TEXT PRIMARY KEY,
tokens REAL NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE stat_counters (
counter_key TEXT NOT NULL,
day DATE NOT NULL,
value BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (counter_key, day)
);
CREATE TABLE page_view_daily (
day DATE NOT NULL,
path TEXT NOT NULL,
views BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (day, path)
);
CREATE INDEX page_view_daily_day_idx ON page_view_daily (day);
CREATE TABLE takedown_requests (
id CHAR(26) PRIMARY KEY,
design_id CHAR(26) REFERENCES designs(id) ON DELETE SET NULL,
asset_id CHAR(26) REFERENCES assets(id) ON DELETE SET NULL,
requester_email TEXT NOT NULL,
reason TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'new'
CHECK (status IN ('new','triaging','upheld','rejected','withdrawn')),
resolution_note TEXT,
handled_by CHAR(26) REFERENCES admin_users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ
);Cascade discipline. Two rules govern every foreign key above, and they are deliberately opposite:
- Rows a permalink depends on use
ON DELETE RESTRICT—designs.build_id,asset_variants.build_id,hues.build_id,assets.first_build_id/last_build_id,assets.slot_key. A build or slot that designs still reference cannot be deleted at all. - Rows that are regenerable derived artifacts use
ON DELETE CASCADE—design_renders.design_id,og_images.design_id,asset_images.asset_variant_id. This is what lets the artifact-pruning runbook in Section 23.8 delete cached renders and OG cards without a manual cleanup step, and it is safe precisely because those rows can always be regenerated from the design and its pinned build.
6.31 Seed Data #
Seed data is applied by db/migrations/0002_seed.sql, which — like every migration file — contains no
BEGIN;/COMMIT;. It runs in every environment (production, staging, dev) except the dev-only fixture
build, which is gated behind SKINFORGE_ENV = 'development' at the application layer, not inside the
SQL file — the migration runner applies every file unconditionally, and Section 6.32 explains why
fixture data is instead seeded by a separate skinforge-cli db fixtures command, never a numbered
migration.
Every literal id below is a valid 26-character Crockford Base32 ULID: the alphabet is
0123456789ABCDEFGHJKMNPQRSTVWXYZ, with I, L, O and U excluded so a hand-transcribed id
cannot be confused with 1 or 0.
-- 0002_seed.sql
-- The runner owns the transaction; this file must not contain BEGIN or COMMIT.
INSERT INTO slots (slot_key, display_name, z_order, display_order, uo_layer, hueable, gender_scope, is_required, is_always_visible, visible) VALUES
('body', 'Body', 10, 10, NULL, true, 'both', true, false, true),
('tattoo_body', 'Body Tattoo', 20, 20, NULL, true, 'both', false, false, true),
('footwear', 'Footwear', 30, 30, 3, true, 'both', false, false, true),
('legs_inner', 'Pants', 40, 40, 4, true, 'both', false, false, true),
('torso_inner', 'Shirt', 50, 50, 5, true, 'both', false, false, true),
('torso_middle', 'Chest Piece', 60, 60, 17, true, 'both', false, false, true),
('arms', 'Arms', 70, 70, 19, true, 'both', false, false, true),
('gloves', 'Gloves', 80, 80, 7, true, 'both', false, false, true),
('waist', 'Belt / Sash', 90, 90, 12, true, 'both', false, false, true),
('legs_outer', 'Skirt / Kilt', 100, 100, 23, true, 'both', false, false, true),
('torso_outer', 'Robe / Outer Torso',110, 110, 22, true, 'both', false, false, true),
('neck', 'Neck', 120, 120, 10, true, 'both', false, false, true),
('hair', 'Hair', 130, 130, 11, true, 'both', false, false, true),
('facial_hair', 'Beard', 140, 140, 16, true, 'male', false, false, true),
('face', 'Face Art', 150, 150, 15, true, 'both', false, false, true),
('earrings', 'Earrings', 160, 160, 18, true, 'both', false, false, true),
('head', 'Hat / Helm', 170, 170, 6, true, 'both', false, false, true),
('cloak', 'Cloak', 180, 180, 20, true, 'both', false, false, true),
('backpack', 'Backpack', 190, 190, 21, true, 'both', false, true, true);
INSERT INTO hue_groups (id, group_key, display_name, sort_order) VALUES
('01J8Z3K9H0HGRPSKN000000001', 'skin', 'Skin Hues', 1),
('01J8Z3K9H0HGRPHA1R00000002', 'hair', 'Hair Hues', 2),
('01J8Z3K9H0HGRPTATT00000003', 'tattoo', 'Tattoo Hues', 3),
('01J8Z3K9H0HGRPCTH000000004', 'clothing', 'Clothing Hues', 4),
('01J8Z3K9H0HGRPEVNT00000005', 'event', 'Event Hues', 5);
INSERT INTO tags (id, tag_key, display_name, category) VALUES
('01J8Z3K9H1TAGSTYFANTASY001', 'style-fantasy', 'Fantasy', 'style'),
('01J8Z3K9H1TAGSTYR0GVE00002', 'style-rogue', 'Rogue', 'style'),
('01J8Z3K9H1TAGSTYN0B1E00003', 'style-noble', 'Noble', 'style'),
('01J8Z3K9H1TAGSTYRSTC000004', 'style-rustic', 'Rustic', 'style'),
('01J8Z3K9H1TAGERA0R1G1N0005', 'era-original', 'Original UO', 'era'),
('01J8Z3K9H1TAGERA0VT1ANDS06', 'era-outlands', 'Outlands', 'era'),
('01J8Z3K9H1TAGSRCSH0P000007', 'source-shop', 'Item Shop', 'source'),
('01J8Z3K9H1TAGSRCDR0P000008', 'source-drop', 'In-World Drop', 'source');
INSERT INTO app_settings (setting_key, value) VALUES
('admin_ip_allowlist', '[]'),
('maintenance_mode', 'false'),
('takedown_response_template', '""'),
('featured_designs', '[]'),
('designs_like_this_threshold', '2'),
('reimport_reminder_cadence_days', '30');The 19 seeded slots are the whole registry: one required slot (body) and 18 choosable cosmetic
slots, backpack among them. backpack is is_always_visible = true because a paperdoll always
draws one, and it is nonetheless choosable — the visitor may change its asset and its hue — which is
why the design document in Section 13.2 may carry a backpack entry like any other choosable slot,
and why omitting it falls back to backpack.default at hue 0 rather than rendering nothing. That
default asset key is produced by the pipeline (Section 7.8) and guaranteed present in every published
build.
is_always_visible is about the rendered paperdoll, not about the stored document: the backpack is
always drawn, but the entry that names which backpack is optional, exactly like every other choosable
slot.
A dev-only fixture build is provided by skinforge-cli db fixtures, which runs a Deno script (not raw
SQL) at db/seed/dev-fixtures.ts — the path Section 5.1 declares, and the only name for it — that
inserts one game_builds row with label = 'dev-fixture-001' and status = 'published', a handful
of assets/asset_variants/asset_images rows pointing at placeholder sprites checked into
tests/fixtures/sprites/, and 16 representative hues rows spanning all five hue groups, each with
its hue_group_members rows. The script generates every id with the application's own ULID
generator, so fixture rows satisfy the same 26-character shape as production rows. It refuses to run
when SKINFORGE_ENV is not development, to guarantee fixture data never reaches a production
database.
6.32 Migration Policy and Runner #
Migration files live in db/migrations/, named NNNN_snake_case_description.sql with a
zero-padded, strictly increasing four-digit sequence number. NNNN is never reused, even if a
migration is later found to be wrong; a correction ships as a new migration with the next number.
A migration file never contains BEGIN; or COMMIT;. The runner owns transaction boundaries, and a
file that opened its own transaction would commit the schema change before the runner recorded it,
leaving schema_migrations and the actual schema able to disagree after a crash.
The Deno migration runner (skinforge-cli db migrate, implemented at cli/commands/db-migrate.ts,
sharing its DB connection module with core/db/) does the following, in order, inside a single
transaction per file:
- Connect using
SKINFORGE_DATABASE_URL(Section 24.2). - Ensure
schema_migrationsexists (bootstrapCREATE TABLE IF NOT EXISTSbefore the transactional loop, since the table cannot track its own creation). - List
db/migrations/*.sqlsorted lexically (equivalent to numeric order given the zero-padded prefix). - For each file not present in
schema_migrations, in ascending order: open a transaction, run the file's SQL verbatim, insert aschema_migrationsrow with the file's SHA-256 checksum, commit. - If any file fails, roll back that file's transaction, leave all prior files applied, print the failing filename and the database error, and exit non-zero. The runner never applies a later file after an earlier one fails.
- On every run, for files already recorded in
schema_migrations, verify the stored checksum still matches the on-disk file; a mismatch aborts immediately with a checksum-drift error, since it means a migration was edited after being applied — the fix is always a new migration, never an edit.
Reversible-change rules: a migration must never be written as a destructive DROP COLUMN or DROP TABLE in the same release that also removes the application code reading that column, because a
zero-downtime rolling deploy briefly runs old and new code against the same database. The pattern is:
release N adds the new column/table (never dropping anything old); release N+1 switches application
code to the new shape; release N+2 (a later migration file) drops the now-unused old column/table.
Column additions must either be nullable or carry a DEFAULT, since adding a NOT NULL column
without a default locks and rewrites the table. Renaming a column is always two migrations: add the
new column and backfill in a batched UPDATE, then drop the old column in a later migration once
application code no longer references it.
Zero-downtime rules for indexes: every CREATE INDEX on a table expected to hold more than a few
thousand rows at migration time (designs, audit_log, import_run_items) uses CREATE INDEX CONCURRENTLY, which cannot run inside the runner's per-file transaction; such migrations are marked
with a -- concurrent comment on the first line, and the runner detects that marker and runs the
file outside a transaction, in autocommit mode, for that one file only. 0001_init.sql is exempt:
it creates every table empty, so its indexes build instantly.
6.33 Data Lifecycle and Retention #
| Table | Growth pattern | Retention rule |
|---|---|---|
designs |
Unbounded, grows forever | Never deleted. There is no age-based, view-based or size-based pruning of this table. The only removal path is the takedown process in Section 20.8, which sets taken_down_at and serves 410 while leaving the row in place. Permalink immutability depends on this, and the privacy text published at /legal (Section 20.7) promises it. |
design_renders, og_images |
Unbounded, grows with traffic | Regenerable derived artifacts. The purge runbook in Section 23.8 deletes renders/ objects and their rows once last_served_at is older than 90 days (or null and created_at older than 90 days); an OG card older than 90 days with no recent service is pruned the same way. Both regenerate on the next request, so a purge is never data loss. |
assets, asset_variants, asset_images, hues |
Grows per build, soft-retired | Never hard-deleted; retired_at hides an asset from catalog/designer while keeping every historical render working. asset_variants accumulates one row per (asset, body, build); that growth is the mechanism of immutability, not a leak. |
source_files, import_runs, import_run_items, extraction_candidates |
Grows per import run | Retained 2 years for provenance/audit. A scheduled prune job then deletes rows belonging to game_builds whose created_at is older than 2 years and whose status IN ('archived','rolled_back'), deleting from these four pipeline tables directly, oldest build first. The game_builds row itself is never deleted: designs, assets and hues reference it ON DELETE RESTRICT, so a build delete could never succeed and attempting one would only produce a failing job every night. |
audit_log |
Append-only, unbounded | Never deleted; this is the compliance record for admin and pipeline actions, and Section 20.8's legal posture requires it to be permanent. |
jobs |
Transient | Rows with status IN ('succeeded','cancelled') older than 7 days are deleted by the scheduled prune job; failed rows are kept 90 days for triage. |
admin_sessions |
Transient | Rows with expires_at < now() or revoked_at IS NOT NULL older than 30 days are deleted by a scheduled job. |
admin_pending_mfa |
Transient | Rows with expires_at < now() are deleted on every login attempt and by the scheduled job; nothing survives more than a few minutes. |
admin_login_failures |
Transient | Rows older than 30 days are deleted by a scheduled job; the lockout windows in Section 17.2.3 are minutes long, so nothing older is ever read. |
rate_limit_buckets |
Transient | Rows with updated_at older than 24 hours are deleted by a scheduled job. This 24-hour figure is the one the privacy statement in Section 20.7 must quote. |
page_view_daily |
Grows daily | Rows older than 400 days are deleted by a scheduled job (13 months of history retained for year-over-year comparison). |
stat_counters |
Grows daily for per-day rows | Per-day rows older than 400 days are deleted alongside page_view_daily; the all-time sentinel rows (day = 0001-01-01) are never deleted. |
slots, hue_groups, tags, app_settings |
Static | No pruning; row counts are bounded by design. |
game_builds, takedown_requests, admin_users, admin_recovery_codes |
Slow, bounded growth | No automatic pruning. |
Permalink immutability is the reason catalog rows are never hard-deleted: a designs row references
assets and hues only by the build_id it was created against, and Sections 9.7 and 9.8 guarantee that
a retired asset or a superseded hue table still renders exactly as it did at design-creation time.
Deleting the underlying assets, asset_variants, asset_images or hues rows would break every
design ever created against that build, which is the one outcome the schema is designed to prevent.
The corollary matters just as much: storage growth is bounded by pruning regenerable artifacts, never by pruning designs. A design row is a few hundred bytes; its cached renders are hundreds of kilobytes. Deleting the renders reclaims essentially all of the space and costs only a re-render on the next view, whereas deleting the design would break a link somebody has already shared.
6.34 Backup and Restore Expectations #
The database is the single source of truth for every table above. Object storage (Section 4.7) holds
image bytes under four prefixes — catalog/, renders/, og/ and imports/ — of which catalog/
and og/ are backed up and renders//imports/ are excluded as regenerable or scratch. The
content_hash columns on asset_images, design_renders and og_images let a restore verify that
restored object storage still matches the database's expectation.
Backup requirements at the data level (operational mechanics — schedule, tooling, retention location — are Section 23.6's concern):
- Full logical backups (
pg_dump) must be taken at an interval no coarser than daily, and must succeed before being counted; a failed backup triggers an alert (Section 21.5). - Point-in-time recovery must be possible to within 5 minutes of any point in the last 7 days, which requires continuous WAL archiving in addition to daily logical dumps, and therefore requires WAL segments to be retained for at least those 7 days (Section 23.6 states the operational retention).
- A restore is validated periodically by restoring into a scratch database and running:
SELECT count(*) FROM schema_migrationsmatches the expected migration count;SELECT count(*) FROM designsis non-zero and non-decreasing versus the prior validation; and oneasset_imagesrow'sstorage_keyis fetched from object storage and its bytes hash to the row'scontent_hash, proving database and object storage stayed consistent.asset_imagesis the right table to sample becausecatalog/is backed up; samplingdesign_renderswould fail legitimately on any object the purge job has reclaimed. - Object storage itself (the
fsors3driver, Section 4.7) must be backed up or replicated independently of the database; a database-only restore without matching object storage produces a site that returns correct JSON but broken images, which the restore validation step above is designed to catch before it reaches production. - Because
designsandaudit_logare permanent, append-only records, a restore must never be treated as an opportunity to prune them; retention rules (Section 6.33) are enforced only by the scheduled cleanup jobs, never by excluding tables from a backup.
The catalog/ objects a restore validates are the content-addressed keys described in Section 6.12;
because those keys never change once written, a restored bucket and a restored database agree or the
content_hash check fails loudly, with no third possibility.
6.35 admin_pending_mfa #
A login that has passed the password step and is waiting for a TOTP or recovery code (Section 17.2.1).
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
admin_user_id |
CHAR(26) |
not null | — | FK to admin_users.id. |
token_hash |
CHAR(64) |
not null | — | SHA-256 of the opaque token carried in the short-lived sf_admin_pending cookie. |
attempts |
SMALLINT |
not null | 0 |
TOTP attempts made against this pending login. |
expires_at |
TIMESTAMPTZ |
not null | — | Absolute expiry, five minutes after creation. |
created_at |
TIMESTAMPTZ |
not null | now() |
Row creation time. |
Primary key: id. Unique: token_hash. Foreign key: admin_user_id → admin_users(id) ON DELETE
CASCADE. Rows are deleted on successful login and pruned on expiry (Section 6.33). Typical row count:
zero to a handful at any moment.
6.36 admin_login_failures #
The rolling record the lockout policy in Section 17.2.3 counts.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
id |
CHAR(26) |
not null | — | ULID surrogate key. |
admin_user_id |
CHAR(26) |
null | — | FK to admin_users.id; null when the submitted email matched no account. |
email_attempted |
TEXT |
not null | — | The lowercase email that was submitted. |
ip_address |
INET |
null | — | Client IP of the attempt. |
phase |
TEXT |
not null | — | One of password, totp, recovery_code. |
created_at |
TIMESTAMPTZ |
not null | now() |
When the failure occurred. |
Primary key: id. Foreign key: admin_user_id → admin_users(id) ON DELETE CASCADE. Check:
phase IN ('password','totp','recovery_code').
CREATE INDEX admin_login_failures_email_time_idx
ON admin_login_failures (email_attempted, created_at DESC);
CREATE INDEX admin_login_failures_ip_time_idx
ON admin_login_failures (ip_address, created_at DESC);
-- The two lockout counters in Section 17.2.3: per-account and per-IP, each over a rolling window.Only failures are recorded; a successful login updates admin_users.last_login_at and writes an
audit_log row instead. Rows older than 30 days are pruned (Section 6.33).
6.37 app_settings #
The small, explicitly enumerated set of operator-editable runtime settings the admin console writes (Section 17.14). Everything else about this system is configured by environment variable (Section 24.1); this table is the carved-out exception for the handful of values staff must be able to change without a redeploy.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
setting_key |
TEXT |
not null | — | Setting name. Primary key. |
value |
JSONB |
not null | — | The setting's current value. |
updated_at |
TIMESTAMPTZ |
not null | now() |
Last change time. |
updated_by |
CHAR(26) |
null | — | FK to admin_users.id. |
Primary key: setting_key. Foreign key: updated_by → admin_users(id) ON DELETE SET NULL. Check:
setting_key IN ('admin_ip_allowlist','maintenance_mode','takedown_response_template', 'featured_designs','designs_like_this_threshold','reimport_reminder_cadence_days') — a fixed
vocabulary, so an unknown key cannot be smuggled in and silently ignored.
Every write also writes an audit_log row. Fixed row count: six — the CHECK above is the complete
list, and adding a seventh setting is a migration, not a configuration change.
The division of labour is stated once, in Section 24.1, and this table is its other half: environment variables configure the process at boot and are immutable for the life of that process, while these six values are read at request time through a cached accessor so an operator can change them from the admin console without a redeploy. No value appears in both places.
7. Asset Discovery & Extraction Pipeline #
7.1 Operating Model #
Extraction runs entirely on the operator's own machine or the same server that hosts the application,
against the operator's own legally installed UO Outlands client. Nothing in this pipeline downloads
game files from any third party, and the public web application never touches raw client files. End
users of the public site never supply files, never see a file picker, and never interact with this
pipeline in any form; everything in Section 7 is staff/operator tooling exposed through
skinforge-cli (Section 4.3) and surfaced read-only in the admin console (Section 17.5).
The operator points the CLI at a local directory:
skinforge-cli import discover --client-dir /path/to/uo-outlands/client --build-label 2026-09-01-patch-47This creates a game_builds row (Section 6.4) in status = 'draft' and begins Stage D1. Every later
stage operates against that build_id and its findings end up in source_files, import_runs,
import_run_items, and extraction_candidates (Sections 6.5–6.8). No stage mutates the operator's
client directory; it is opened read-only throughout.
The work directory for a build (SKINFORGE_IMPORT_WORKDIR/<build_id>/, Section 24.2) holds all
intermediate artifacts — decoded sprite buffers before normalization, decoded hue tables, the stage
journal, manifest JSON, format notes — and is never web-accessible (Section 7.11).
Process model, and why it is split in two. Every byte this pipeline parses is untrusted input from a binary file format with no integrity guarantees. So the code that parses it is never the code that holds a network socket:
- The extractor child. Each stage runs its decoding work in a child process launched with
deno run --allow-read=<client-dir>,<workdir> --allow-write=<workdir>and no--allow-netflag at all. It cannot open a socket to anything — not the database, not object storage, not the internet. It reads client bytes, decodes them, and appends its findings as newline-delimited JSON to the stage journal under<workdir>/<build_id>/journal/, plus binary artifacts underextracted/andnormalized/. - The driver parent.
skinforge-cliitself holds the database connection and the object-storage client, launched with--allow-net=<database-host>,<storage-endpoint>and nothing else. It tails the child's journal as it is written and mirrors each entry intosource_files,import_runs,import_run_itemsandextraction_candidates, and uploads the encoded images the child wrote. This is whyimport_runs.summaryreflects live progress (Section 7.10) even though the decoder itself cannot reach the database.
This split is what makes the isolation guarantee in Section 20.6 literally true rather than merely intended: a malicious or corrupt game file that achieves code execution inside the decoder lands in a process with no network permission and write access to one scratch directory.
7.2 The Six Stages #
The pipeline is six ordered stages, D1 through D6, each a separate CLI subcommand and a separate
import_runs row (stage column, Section 6.6). A stage can be re-run independently once its
predecessor has succeeded; each stage reads only the database rows and work-directory artifacts its
predecessor produced, never the operator's client directory directly except D1, D2 and D4, which read
raw client bytes.
D1…D6 is prose shorthand used inside this section only. The value actually written to
import_runs.stage, shown on every screen and used as every metric label, is the stage name from
Section 6.6's eight-value vocabulary — inventory, identify, probe, extract, classify,
normalize, plus the two ingestion stages import and verify that this section does not produce.
Section 6.6 carries the full mapping; no surface outside this section ever writes or renders a D
abbreviation.
Throughout this section, "records a source_files row" and "writes an import_run_items row" describe
the outcome, not the mechanism: the decoding child appends the entry to the stage journal and the
driver parent performs the actual INSERT (Section 7.1). The row shapes, conflict targets and status
vocabularies are exactly those in Sections 6.5–6.8, and every write uses the natural key as its
ON CONFLICT target so re-running a stage is idempotent (Section 9.9).
7.2.1 Stage D1 — Inventory #
Input: the operator's client directory path.
Output: one source_files row per file found, and an import_runs row summarizing counts by
file_type.
Command: skinforge-cli import discover --client-dir <path> --build-label <label> (creates the
build) or skinforge-cli import discover --build <build_id> --client-dir <path> (re-run D1 against an
existing build).
D1 walks the directory tree recursively, and for every regular file:
- Records
relative_pathrelative to the client directory root. - Records
size_bytesfrom the filesystem. - Reads the first 4,096 bytes (or the whole file if smaller) and computes
sha256over the full file streamed in 64 KB chunks (never loading a multi-hundred-MB file fully into memory),magic_bytesas the hex of the first 16 bytes, andentropy(Shannon entropy in bits/byte) over the sampled prefix. - Classifies
file_typeby extension first (.mul→mul,.idx→idx,.uop→uop,.def→def,.txt/.cfg→txt,.png/.bmp/.tga→image,.zip/.rar/.7z→archive), then corrects the classification using magic bytes when the extension is missing or ambiguous (e.g. aMYP\0magic reclassifies any extension asuop). - Inserts the
source_filesrow.
Failure modes: unreadable file (permission denied) is recorded as an import_run_items row with
status = 'failed' and the OS error in detail, and D1 continues past it — a single unreadable file
never aborts inventory. A client directory that does not exist or is empty aborts the whole run
immediately with a non-zero CLI exit code, since that indicates operator error, not pipeline risk.
7.2.2 Stage D2 — Identify #
Input: source_files rows from D1.
Output: an import_run_items row per file recording its identified structure (or "unidentified"),
feeding D3 for anything unidentified.
Command: skinforge-cli import identify --build <build_id>.
D2 probes every mul, idx, uop, and unknown-typed file against the known UO structures from
Section 7.3, in this order, stopping at the first match:
- MUL/IDX pair: file has a same-stem
.idxcompanion (or is itself a.idxwith a same-stem.mul) and the.idxfile's size is an exact multiple of 12 bytes → identified asmul_idx_pair. - UOP MythicPackage: first 4 bytes equal
4D 59 50 00(MYP\0) → identified asuop. - hues.mul: filename is exactly
hues.mul(case-insensitive) or file size is an exact multiple of708bytes (4-byte header + 8 × 88-byte hue entries per group, Section 7.3.3) → identified ashues_table. - tiledata.mul: filename is exactly
tiledata.mul→ identified astiledata. .def/.txtcontrol files: matched by filename against a fixed dictionary (body.def,bodyconv.def,mobtypes.txt) → identified ascontrol_filewith akindsub-field.- Loose image:
file_type = 'image'from D1 → identified asloose_image. - Anything not matched by 1–6 is left
status = 'needs_operator_input'and queued for D3.
Each identification is written as an import_run_items row with item_type = 'identify' and
detail.structure set to the matched structure name.
7.2.3 Stage D3 — Probe Unknown Containers #
Input: files D2 left needs_operator_input.
Output: extraction_candidates rows of a special candidate_key prefix probe: describing what
D3 learned, still status = 'needs_operator_input' unless a later heuristic upgrades it.
Command: skinforge-cli import probe --build <build_id>.
For every unidentified file, D3 runs, in order:
- Header fingerprinting: compares the first 32 bytes against a signature table (zlib
78 9C/78 01/78 DA, gzip1F 8B, LZ4 frame04 22 4D 18, generic PNG/BMP/TGA signatures already handled by D1's classification). A match is recorded even though D2 did not resolve it to a known UO structure, since it may be a compressed variant of one. - Entropy scoring:
entropyfrom D1, already computed, is bucketed aslow(< 5.0, likely structured/uncompressed data or padding),medium(5.0–7.2, likely mixed binary),high(> 7.2, likely compressed or encrypted). High-entropy unidentified files are flagged for compression sniffing before anything else. - Compression sniffing: for files flagged high-entropy without a header match, D3 attempts a
speculative decompress of the first 256 KB using
DecompressionStream('deflate'),DecompressionStream('deflate-raw'), andDecompressionStream('gzip')(Section 7.3.5); the first variant that decompresses without a stream error and yields entropy < 6.5 on the decompressed sample is recorded as the working transform. - Repeated-record-stride detection: computes byte-difference histograms at candidate strides (4,
8, 12, 16, 20, 24, 32, 64 bytes) over the first 64 KB, looking for a stride at which a
fixed-position sub-field repeats a recognizable pattern (e.g. a monotonically increasing
pseudo-offset, consistent with an index file at a stride D1/D2 did not anticipate). A stride with a
fit score above 0.8 is recorded as
detail.likelyStride. - Manual format note: D3 checks for a file named
format-note.jsonin the work directory (Section 7.4) authored by the operator, matched byrelative_pathglob; if present, its declaredstructureand parameters are trusted and the file is re-run through D4 as that structure instead of being left unidentified.
Every file leaves D3 as an extraction_candidates row: either upgraded (a later stage can now attempt
extraction) or still status = 'needs_operator_input' with all of the above evidence recorded in
metadata, ready for a human to read on the import detail screen (Section 17.5) and, if needed, write
a format-note.json. D3 never crashes on a file it cannot resolve and never silently drops it — every
input file has a corresponding output row by the end of D3.
7.2.4 Stage D4 — Extract #
Input: source_files identified by D2 (directly) or upgraded by D3.
Output: decoded raw RGBA sprite buffers and decoded hue tables written to the work directory,
each with an extraction_candidates row recording source offset and provenance.
Command: skinforge-cli import extract --build <build_id> [--only mul_idx_pair|uop|hues_table|...].
For mul_idx_pair and uop structures identified as containing gump art, D4 walks every index entry
(Section 7.3.1/7.3.4), decodes the gump run-length encoding (Section 7.3.2) into a raw RGBA buffer, and
writes it to <workdir>/<build_id>/extracted/gumps/<gump_id>.rgba alongside a JSON sidecar with
width, height, and source provenance (source_file_id, byte offset, and for UOP entries the 64-bit
path hash that resolved it). For hues_table structures, D4 decodes every one of the 375 groups
(Section 7.3.3) into 3,000 individual hue records held in memory, then written to
<workdir>/<build_id>/extracted/hues.json.
Failure modes: a corrupt index entry (Section 7.12) is recorded as import_run_items with
status = 'failed' and extraction continues with the next entry — one bad record never aborts a
50,000-entry file. A gump whose decoded byte length does not match its declared extra dimensions
(Section 7.3.1) is recorded as status = 'failed' with detail.reason = 'dimension_mismatch' and is
not written to the work directory.
7.2.5 Stage D5 — Classify #
Input: extracted sprites and hue records from D4.
Output: extraction_candidates rows with suggested_slot_key, suggested_body, and confidence
populated (Section 7.6).
Command: skinforge-cli import classify --build <build_id>.
D5 runs the heuristics in Section 7.6 against every D4 output, writing or updating the corresponding
extraction_candidates row. Rows scoring at or above the acceptance threshold (0.85, or 0.65 under
the cold-start rule in Section 7.6) are marked status = 'approved' automatically; rows below it stay
status = 'pending' for the staff review queue (Section 17.6).
7.2.6 Stage D6 — Normalize #
Input: approved candidates from D5 (automatically or via staff approval), plus the decoded hue
records D4 wrote to the work directory.
Output: assets, asset_variants, asset_images, hues and hue_group_members rows
(Sections 6.10–6.13 and 6.15), with encoded WebP/PNG files written to object storage.
Command: skinforge-cli import normalize --build <build_id>.
D6 performs the trimming, offset preservation, multi-scale generation, and encoding described in
Section 7.8, and is the only stage that writes to the permanent catalog tables rather than pipeline
scratch tables. D6 never runs against a candidate that has not reached status = 'approved'. Nothing
else in the system writes assets, asset_variants, asset_images or hues: approving a candidate
in the admin console (Section 17.6) only sets its status, and the catalog rows appear when the
operator next runs D6.
Hues are D6's output too, and this is the only place they are written. D4 decodes every hue table
into <workdir>/<build_id>/extracted/hues.json and D5 proposes a group assignment for each one, but
neither writes a row: hues is a permanent catalog table, so it follows the same rule as every other
one. In the same run, and inside the same per-build transaction boundary, D6 inserts one hues row
per decoded hue for this build_id, substitutes the Hue <index> placeholder for any blank decoded
name (Section 7.7), and inserts the hue_group_members rows that realize D5's proposal. The ordering
is forced rather than stylistic — hue_group_members.hue_id is a foreign key to hues.id
(Section 6.15), so a membership row written before D6 has no row to point at.
7.3 File Format Reference #
This reference is sufficient, on its own, to implement every reader without consulting another source. All multi-byte integers are little-endian unless stated otherwise, matching the original client's x86 origin.
7.3.1 MUL/IDX Pairs #
A MUL/IDX pair is two files: a .mul file holding concatenated binary records, and a .idx file
holding one fixed 12-byte index entry per logical record, pointing into the .mul file.
Each index entry:
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 4 | lookup |
uint32. Byte offset into the .mul file where this record's data begins. 0xFFFFFFFF means the record is absent (sentinel). |
| 4 | 4 | length |
uint32. Byte length of this record's data in the .mul file. 0xFFFFFFFF or 0 alongside a 0xFFFFFFFF lookup means absent. |
| 8 | 4 | extra |
uint32. Record-type-specific metadata. For gump art, this packs width in the high 16 bits and height in the low 16 bits: width = extra >>> 16, height = extra & 0xFFFF. |
Walking a pair: recordCount = Math.floor(idxFileSize / 12). For i in 0..recordCount, read the 12-byte entry
at offset i * 12. If lookup === 0xFFFFFFFF, the record at index i does not exist — skip it,
never treat it as an error. Otherwise read length bytes from the .mul file starting at lookup.
interface MulIdxEntry {
lookup: number;
length: number;
extra: number;
}
function readIdxEntries(idxBytes: Uint8Array): MulIdxEntry[] {
const view = new DataView(idxBytes.buffer, idxBytes.byteOffset, idxBytes.byteLength);
// Floored: a .idx whose size is not an exact multiple of 12 has a partial trailing entry,
// and reading it would run past the end of the DataView and throw a RangeError.
const count = Math.floor(idxBytes.byteLength / 12);
const entries: MulIdxEntry[] = [];
for (let i = 0; i < count; i++) {
const base = i * 12;
entries.push({
lookup: view.getUint32(base, true),
length: view.getUint32(base + 4, true),
extra: view.getUint32(base + 8, true),
});
}
return entries;
}7.3.2 Gump Art Encoding #
A gump art record (the .mul bytes located by one MUL/IDX entry, or one decompressed UOP entry) is a
per-row lookup table followed by run-length-encoded pixel data.
Layout:
| Offset | Size | Field |
|---|---|---|
| 0 | 4 * height |
Row lookup table: height entries, each a uint32 byte offset (relative to the end of this lookup table, i.e. relative to offset 4 * height) to that row's run data. |
4 * height onward |
variable | Run data for all rows, concatenated. |
width and height come from the owning MUL/IDX entry's extra field (Section 7.3.1) or, for UOP
gump entries, from a 8-byte header prefix (uint32 width, uint32 height) placed before the row lookup
table by the UOP-specific packaging (Section 7.3.4).
Each row's run data is a sequence of runs, each 4 bytes: uint16 color (ARGB1555 packed pixel value)
followed by uint16 runLength (count of consecutive pixels with that color). A row ends when the sum
of runLength values for that row reaches width; there is no explicit end-of-row marker — the
reader must track the running x-position and stop consuming runs for the current row once it reaches
width.
The packed value 0x0000 is the transparency sentinel: a run whose color is zero writes
runLength fully transparent pixels. Every other packed value decodes as opaque. Bit 15 is never
consulted — UO gump art carries no per-pixel alpha channel beyond that zero/opaque split, so
0x8000 (alpha bit set, all colour bits zero) is still transparent.
The unpacking function has exactly one definition in this system; Section 8.3.1 owns it, and it is reproduced here because D4 runs long before any rendering code is loaded. Its signature and its semantics are identical in both places:
interface Rgba { r: number; g: number; b: number; a: number }
function unpackArgb1555(c: number): Rgba {
// ARGB1555: bits 14-10 = R, 9-5 = G, 4-0 = B. Bit 15 is not consulted.
const r5 = (c >> 10) & 0x1f;
const g5 = (c >> 5) & 0x1f;
const b5 = c & 0x1f;
// 5-bit to 8-bit expansion: replicate the top 3 bits into the low 3 bits for full dynamic range.
return {
r: (r5 << 3) | (r5 >> 2),
g: (g5 << 3) | (g5 >> 2),
b: (b5 << 3) | (b5 >> 2),
a: c === 0x0000 ? 0 : 255,
};
}The decoder validates every offset it reads out of the file before using it. A gump record is
untrusted input, and three separate malformed shapes — a truncated lookup table, a row offset pointing
outside the record, and a run whose declared length is zero — will otherwise throw a RangeError or
spin forever, because a zero-length run advances the cursor but never advances x:
class GumpDecodeError extends Error {
constructor(public readonly reason: string) {
super(`gump decode failed: ${reason}`);
this.name = 'GumpDecodeError';
}
}
function decodeGump(data: Uint8Array, width: number, height: number): Uint8ClampedArray {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const rgba = new Uint8ClampedArray(width * height * 4);
const lookupBase = 0;
const runDataBase = 4 * height;
if (data.byteLength < runDataBase) throw new GumpDecodeError('lookup_table_truncated');
for (let row = 0; row < height; row++) {
const rowOffset = runDataBase + view.getUint32(lookupBase + row * 4, true);
if (rowOffset < runDataBase || rowOffset > data.byteLength - 4) {
throw new GumpDecodeError('row_offset_out_of_bounds');
}
let x = 0;
let cursor = rowOffset;
while (x < width) {
if (cursor + 4 > data.byteLength) throw new GumpDecodeError('run_data_truncated');
const color = view.getUint16(cursor, true);
const runLength = view.getUint16(cursor + 2, true);
cursor += 4;
if (runLength === 0) throw new GumpDecodeError('zero_length_run');
if (color === 0) {
// Transparent run: leave RGBA buffer at its zero-initialized (0,0,0,0) default.
x += runLength;
continue;
}
const { r, g, b, a } = unpackArgb1555(color);
for (let i = 0; i < runLength && x < width; i++, x++) {
const pixelIndex = (row * width + x) * 4;
rgba[pixelIndex] = r;
rgba[pixelIndex + 1] = g;
rgba[pixelIndex + 2] = b;
rgba[pixelIndex + 3] = a;
}
}
}
return rgba;
}Transparency rule: any pixel never written by a run (which does not occur in well-formed data, since
runs sum to exactly width per row) is fully transparent because the buffer is zero-initialized.
Failure rule: the decoder throws a typed GumpDecodeError on any malformed input and never returns a
partially-decoded buffer. D4 catches it per Section 7.2.4 and records an import_run_items row with
status = 'failed' and detail.reason set to the error's reason code — one of lookup_table_truncated,
row_offset_out_of_bounds, run_data_truncated or zero_length_run (Section 7.12). A throw inside
the decoder is therefore a handled outcome for one record, not an unhandled exception, which is what
Section 7.4's "nothing in D1–D3 ever throws an unhandled exception" means: the throw is caught at the
stage boundary, one row down.
No mask, index map or palette sidecar is produced alongside the decoded buffer. Gump art is direct-colour ARGB1555, not palette-indexed, so there is no palette position to capture; the hue table index for a pixel is derived from the pixel itself at render time (Section 8.3), never stored.
7.3.3 hues.mul #
hues.mul holds 375 groups of 8 hues each (3,000 hues total), each hue occupying a fixed 88 bytes.
Group layout (708 bytes: 4-byte header + 8 × 88-byte entries):
| Offset (within group) | Size | Field |
|---|---|---|
| 0 | 4 | Header (unused padding in the original format; skip). |
| 4 | 8 * 88 |
8 hue entries, 88 bytes each. |
Each 88-byte hue entry:
| Offset (within entry) | Size | Field |
|---|---|---|
| 0 | 32 * 2 = 64 |
colorTable: 32 × uint16 ARGB1555 colours, index 0 (darkest) to 31 (lightest). |
| 64 | 2 | tableStart: uint16. |
| 66 | 2 | tableEnd: uint16. |
| 68 | 20 | name: fixed 20-byte ASCII, NUL-padded. |
Total per entry: 64 + 2 + 2 + 20 = 88 bytes, confirming the layout. Global hue index for group g
(0-based) and slot s (0-based, 0..7) is g * 8 + s + 1 (hue indices are 1-based; index 0 is the
reserved "unhued" sentinel per Section 3.6, never present in this file).
interface DecodedHue {
hueIndex: number;
colors: number[]; // 32 entries, packed 0xRRGGBB after ARGB1555 expansion
tableStart: number;
tableEnd: number;
name: string;
}
function decodeHuesMul(data: Uint8Array): DecodedHue[] {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const hues: DecodedHue[] = [];
const GROUP_SIZE = 708;
const groupCount = Math.floor(data.byteLength / GROUP_SIZE);
for (let g = 0; g < groupCount; g++) {
const groupBase = g * GROUP_SIZE + 4; // skip 4-byte header
for (let s = 0; s < 8; s++) {
const entryBase = groupBase + s * 88;
const colors: number[] = [];
for (let c = 0; c < 32; c++) {
const argb1555 = view.getUint16(entryBase + c * 2, true);
const { r, g: gc, b } = unpackArgb1555(argb1555);
colors.push((r << 16) | (gc << 8) | b);
}
const tableStart = view.getUint16(entryBase + 64, true);
const tableEnd = view.getUint16(entryBase + 66, true);
const nameBytes = data.subarray(entryBase + 68, entryBase + 88);
const nul = nameBytes.indexOf(0);
const name = new TextDecoder('ascii').decode(nul >= 0 ? nameBytes.subarray(0, nul) : nameBytes).trim();
hues.push({ hueIndex: g * 8 + s + 1, colors, tableStart, tableEnd, name });
}
}
return hues;
}7.3.4 UOP (MythicPackage) #
UOP files package many records behind a hashed-filename index rather than a sequential MUL/IDX pair. Header:
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | Magic: ASCII MYP\0 (bytes 4D 59 50 00). |
| 4 | 4 | version: uint32. |
| 8 | 4 | misc: uint32 (format-specific, informational). |
| 12 | 8 | firstBlockOffset: uint64 (as two uint32 reads combined, since Deno's DataView.getBigUint64 returns a bigint; the offset fits in 53 safe integer bits for all known UOP files, so Number(view.getBigUint64(12, true)) is safe). |
| 20 | 4 | blockSize (aka filesPerBlock in some tooling; entry-count field, name varies by source — treat as entriesPerBlockHint; the authoritative per-block count is read from each block header below, this field is advisory only). |
| 24 | 4 | entryCount: uint32, total number of hashed entries across all blocks. |
Each block, starting at firstBlockOffset and chained via nextBlockOffset:
| Offset (within block) | Size | Field |
|---|---|---|
| 0 | 4 | entryCount: uint32, number of entries in this block. |
| 4 | 8 | nextBlockOffset: uint64 (0 = last block). |
| 12 onward | entryCount * 34 |
Entries, 34 bytes each. |
Each 34-byte entry:
| Offset (within entry) | Size | Field |
|---|---|---|
| 0 | 8 | dataOffset: uint64. Absolute file offset of the entry's payload. |
| 8 | 4 | headerLength: uint32. Extra per-entry header preceding the payload at dataOffset (commonly 0). |
| 12 | 4 | compressedLength: uint32. |
| 16 | 4 | decompressedLength: uint32. |
| 20 | 8 | pathHash: uint64. 64-bit hash of the entry's logical filename (Section 7.3.4, Hash algorithm). |
| 28 | 4 | dataHash: uint32 (Adler32 of the payload, used for integrity verification, not identification). |
| 32 | 2 | compressionFlag: uint16. 0 = uncompressed, 1 = zlib-compressed. |
To read one entry's payload: seek to dataOffset + headerLength, read compressedLength bytes, and
if compressionFlag === 1 decompress with DecompressionStream('deflate') (raw zlib per Section
7.3.5) to decompressedLength bytes; if 0, use the bytes as-is (decompressedLength should equal
compressedLength).
Traversal bounds (required, not optional — a UOP is untrusted input and the block chain is a
linked list read straight out of the file). The reader maintains a Set<number> of visited block
offsets. It aborts the file with a typed UopDecodeError('block_chain_cycle'), recorded as an
import_run_items failure, on any of: a repeated offset; an offset that is not strictly greater than
the previous block's offset; an offset at or beyond the file length; or a cumulative block count whose
entries exceed the header's entryCount. Per entry, dataOffset + headerLength + compressedLength
must be <= fileSize, and decompressedLength must be <= MAX_DECOMPRESSED_ENTRY_BYTES (16 MiB),
both checked before any decompression is attempted, so a decompression bomb is rejected rather
than expanded. Because the offset must strictly increase and is bounded by the file length, traversal
terminates on every input, including a block that points at itself.
The hashed-filename problem: a UOP entry carries no filename, only a 64-bit pathHash. UOP
packaging was designed for the client to look up known logical paths (e.g.
build/gumpartlegacymul/000000c8.tga) by hashing the candidate string and comparing against
pathHash. To read a UOP whose contents are gump art, D4 builds a dictionary of candidate path
strings using the pattern build/gumpartlegacymul/%08x.tga for every plausible gump id in the range
0x00000000–0x0000FFFF (65,536 candidates, covering every documented classic gump id range with
headroom for Outlands-added ids), hashes each candidate, and builds a Map<hash, gumpId> used to
resolve every entry's pathHash back to a gump id in one pass. Candidate patterns are configurable
per source-file classification (Section 7.6) so a UOP later found to hold a different content type
(e.g. tiledata) uses a different dictionary pattern without a code change — patterns live in
core/pipeline/uop-dictionaries.ts as a Record<contentKind, (id: number) => string> map.
Hash algorithm: the classic UOP hash is a specific 64-bit variant of the "hashlittle2" /
Jenkins one-at-a-time family used by the original client. It combines two 32-bit halves seeded from
the string length, producing (upper32 << 32n) | lower32. Implementation:
function uopHash(pathAscii: string): bigint {
const bytes = new TextEncoder().encode(pathAscii.toLowerCase());
let length = bytes.length;
let a = 0xdeadbeef + length;
let b = 0xdeadbeef + length;
let c = 0xdeadbeef + length;
a = (a + 0x9e3779b9) >>> 0;
b = (b + 0x9e3779b9) >>> 0;
c = (c + 0x9e3779b9) >>> 0;
let i = 0;
const rot = (x: number, k: number) => ((x << k) | (x >>> (32 - k))) >>> 0;
while (length > 12) {
a = (a + (bytes[i] | (bytes[i + 1] << 8) | (bytes[i + 2] << 16) | (bytes[i + 3] << 24))) >>> 0;
b = (b + (bytes[i + 4] | (bytes[i + 5] << 8) | (bytes[i + 6] << 16) | (bytes[i + 7] << 24))) >>> 0;
c = (c + (bytes[i + 8] | (bytes[i + 9] << 8) | (bytes[i + 10] << 16) | (bytes[i + 11] << 24))) >>> 0;
a = (a - c) >>> 0; a ^= rot(c, 4); c = (c + b) >>> 0;
b = (b - a) >>> 0; b ^= rot(a, 6); a = (a + c) >>> 0;
c = (c - b) >>> 0; c ^= rot(b, 8); b = (b + a) >>> 0;
a = (a - c) >>> 0; a ^= rot(c, 16); c = (c + b) >>> 0;
b = (b - a) >>> 0; b ^= rot(a, 19); a = (a + c) >>> 0;
c = (c - b) >>> 0; c ^= rot(b, 4); b = (b + a) >>> 0;
i += 12;
length -= 12;
}
const rem = bytes.slice(i, i + length);
const get = (idx: number) => (idx < rem.length ? rem[idx] : 0);
c = (c + (get(11) << 24)) >>> 0;
c = (c + (get(10) << 16)) >>> 0;
c = (c + (get(9) << 8)) >>> 0;
c = (c + get(8)) >>> 0;
b = (b + (get(7) << 24)) >>> 0;
b = (b + (get(6) << 16)) >>> 0;
b = (b + (get(5) << 8)) >>> 0;
b = (b + get(4)) >>> 0;
a = (a + (get(3) << 24)) >>> 0;
a = (a + (get(2) << 16)) >>> 0;
a = (a + (get(1) << 8)) >>> 0;
a = (a + get(0)) >>> 0;
c ^= b; c = (c - rot(b, 14)) >>> 0;
a ^= c; a = (a - rot(c, 11)) >>> 0;
b ^= a; b = (b - rot(a, 25)) >>> 0;
c ^= b; c = (c - rot(b, 16)) >>> 0;
a ^= c; a = (a - rot(c, 4)) >>> 0;
b ^= a; b = (b - rot(a, 14)) >>> 0;
c ^= b; c = (c - rot(b, 24)) >>> 0;
return (BigInt(c) << 32n) | BigInt(b);
}A silently wrong hash implementation would resolve zero UOP entries with no other symptom, so the
function is covered by a regression test rather than by trust. The test is one the developer can write
without any external data: compute uopHash("build/gumpartlegacymul/000000c8.tga") on first
implementation, commit the resulting value as a fixture, and assert against it thereafter (Section
22.2). This proves the implementation is stable across refactors, which is the property that actually
matters. It is a unit test, not a startup check: extract never aborts on it. If a real UOP resolves
zero entries, D4 records that as an import_run_items failure naming the file and continues, and the
uop-unresolved: candidates in Section 7.12 make the miss visible to staff.
7.3.5 Compression #
Both UOP zlib-compressed entries and speculative D3 probing use Deno's built-in
DecompressionStream. Two caveats:
- Raw-vs-zlib header:
DecompressionStream('deflate')expects a 2-byte zlib header (78 xx) followed by the raw deflate stream and a 4-byte Adler32 trailer.DecompressionStream('deflate-raw')expects the deflate stream with no header/trailer at all. UOPcompressionFlag = 1payloads are zlib-wrapped, so use'deflate'. Bare speculative D3 probing tries'deflate-raw'first (more common for hand-rolled containers) and falls back to'deflate'. - Deno's streams API requires wrapping a
Uint8Arrayin aReadableStreamto decompress:
async function inflate(compressed: Uint8Array, mode: 'deflate' | 'deflate-raw' | 'gzip'): Promise<Uint8Array> {
const stream = new Blob([compressed]).stream().pipeThrough(new DecompressionStream(mode));
const buf = await new Response(stream).arrayBuffer();
return new Uint8Array(buf);
}A DecompressionStream that throws (malformed stream) rejects the returned promise; D4 and D3 both
wrap this call in try/catch and record the failure as an import_run_items/extraction_candidates
row rather than letting the exception propagate and abort the whole stage.
7.3.6 tiledata.mul, body.def, bodyconv.def, mobtypes.txt #
tiledata.mul: fixed-record land/static tile metadata (flags, name, texture id). SkinForge uses only the static-tile flag bits that indicate "wearable"/"translucent" for cross-checking gump id ranges during classification (Section 7.6); it does not import land tiles at all, since SkinForge renders paperdolls, not the game world.body.def: text control file mapping body id to animation/paperdoll body group and gender. Body ids and paperdoll gump ids are two different namespaces in every UO client, so SkinForge does not assert that one equals the other. It records whatever body ids this file maps tohuman/male/femaleasmetadata.bodyIdson the relevant candidates, for staff reference. A disagreement with the expected paperdoll gump ids in Section 3.2 is recorded asstatus = 'needs_operator_input'on the affected candidates; it never aborts a stage and never blocks the other bodies.bodyconv.def: text control file mapping one body id to an alternate body id used for equipment-driven body swaps in the original client (e.g. mounted forms). SkinForge parses it only to confirm no swap rule affectshuman_male/human_female; any that do are logged and otherwise ignored, since SkinForge has no equipment-driven body-swap feature (an explicit non-goal, Section 2.5).mobtypes.txt: text control file mapping body id to a broad creature-type category (human/animal/monster/sea/equip). SkinForge parses it solely as a sanity cross-check that the body idsbody.defmapped tohumanare categorizedhumanhere too; a mismatch is recorded asmetadata.mobtypesDisagreementand sets the affected candidates toneeds_operator_input. It never aborts a stage: a control file this pipeline reads for corroboration must never be able to stop an import, because a correct-but-unexpected client file would then halt the whole build.
7.3.7 ClassicUO Override Folders and Loose Images #
Some UO Outlands distributions layer a ClassicUO-compatible client with an Assets/ or
Data/Client/ override folder containing loose PNG/BMP files named by gump id
(gumpart/0x0C34.png-style) instead of packed MUL/UOP data. D1 classifies these as file_type = 'image'; D2 identifies them as loose_image; D4 reads them directly with Deno's WASM PNG/BMP
decoders (the same @jsquash/png used for encoding, Section 4.1, decodes as well as encodes) rather
than the gump run-length format. This path exists as a fallback source when MUL/UOP data is absent or
incomplete for a given id — D5's classification step treats a loose_image match for an id already
resolved from MUL/UOP as a corroborating signal that raises confidence, and a loose_image match for
an id with no MUL/UOP counterpart as the sole source for that id.
7.4 Unknown-Container Strategy #
When D2 and D3 (Section 7.2.3) cannot resolve a file to a known structure, the pipeline never crashes and never silently drops the file. The full strategy:
- Magic-byte catalogue:
core/pipeline/magic-bytes.tsholds an extensible table of{ bytes: Uint8Array, structure: string }entries, checked in D2 and D3. Adding support for a newly discovered format is a data change to this table plus a new decoder module, never a change to the pipeline's control flow. - Entropy scoring: computed once in D1, reused by D3, bucketed as described in Section 7.2.3.
- Record-stride detection: the byte-difference histogram approach in Section 7.2.3, surfaced in the admin console as a small histogram chart so staff can visually confirm a stride guess.
format-note.json: an operator can hand-write this file into<workdir>/<build_id>/notes/format-note.json, keyed byrelative_path, with this Zod-validated shape:
import { z } from 'zod';
export const FormatNoteSchema = z.object({
relativePath: z.string(),
structure: z.enum(['mul_idx_pair', 'uop', 'hues_table', 'tiledata', 'control_file', 'loose_image']),
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
note: z.string().min(1),
});
export type FormatNote = z.infer<typeof FormatNoteSchema>; params carries structure-specific overrides, e.g. { "recordStride": 16, "headerBytes": 8 } for
a mul_idx_pair variant that does not match the default 12-byte index stride. D3 re-validates every
format-note.json against this schema on every run and rejects (with a clear CLI error, not a
crash) a note that fails validation, so a typo in a hand-written note is caught immediately.
5. needs_operator_input candidate state: the terminal state (Section 6.8) for anything D3 cannot
resolve even with a format note attempt. It is a first-class, visible state in the admin console
(Section 17.5), not a hidden failure — the admin imports screen shows a count of
needs_operator_input items per build and links directly to each one's evidence (magic bytes,
entropy, stride guesses).
Nothing in D1–D3 ever throws an unhandled exception for a malformed or unrecognized file; every code
path that reads file bytes is wrapped so a failure becomes a row with status IN ('failed', 'needs_operator_input'), never a crashed CLI process. A crashed process is treated as a pipeline bug
to be fixed, not an acceptable outcome for unusual input.
7.5 Extraction Outputs #
Work directory layout (SKINFORGE_IMPORT_WORKDIR/<build_id>/):
<build_id>/
notes/
format-note.json # optional, operator-authored (Section 7.4)
journal/
d1-inventory.jsonl # one JSON object per finding, tailed by the driver (Section 7.1)
d2-identify.jsonl
d3-probe.jsonl
d4-extract.jsonl
d5-classify.jsonl
d6-normalize.jsonl
extracted/
gumps/
<gump_id>.rgba # decoded RGBA buffer, width*height*4 bytes
<gump_id>.json # sidecar: width, height, provenance
hues.json # all decoded hue records (Section 7.3.3)
manifest.json # full-run manifest (schema below)
normalized/
<asset_key>/
m/
1.webp 1.png 2.webp 2.png 3.webp 3.png
f/
1.webp 1.png 2.webp 2.png 3.webp 3.pngThat is the complete list of artifact kinds. In particular there is no per-sprite .mask file and
no per-pixel index sidecar anywhere in the pipeline, at any stage: nothing downstream needs one,
because the hue table index for a pixel is derived from the pixel's own red channel at render time
(Section 8.3). The only per-sprite artifacts are the decoded raw buffer, its .json metadata
sidecar, and the encoded WebP/PNG files under normalized/.
The decoded raw buffer is scratch and never leaves this directory. D4 writes it so D5 and D6 have
something to analyse and encode without re-parsing the client; D6 then encodes it and uploads only
the encoded results. It is never uploaded to object storage, never gets an asset_images row, and is
not a storage format: asset_images.format admits webp and png and nothing else (Section 6.12),
and object storage holds no raw-buffer object under any prefix. The renderer's input is the scale-1
PNG (Section 8.2.1), which is lossless and therefore decodes back to exactly these bytes. Deleting a
build's work directory after publish costs nothing but the re-encode shortcut below.
Files under normalized/ are the exact bytes uploaded to object storage by D6; they are kept in the
work directory after upload as a local cache so a re-run of normalize can skip re-encoding unchanged
assets (Section 7.9's resumability). The journal/ files are append-only and are the only channel by
which the decoding child reports anything (Section 7.1); they are safe to delete once the build is
published.
Manifest JSON schema — one manifest per build, written incrementally as each stage completes, validated against this Zod schema before any downstream stage reads it:
import { z } from 'zod';
const ProvenanceSchema = z.object({
sourceFileId: z.string().length(26),
relativePath: z.string(),
byteOffset: z.number().int().nonnegative(),
structure: z.enum(['mul_idx_pair', 'uop', 'hues_table', 'loose_image']),
uopPathHash: z.string().optional(), // present only when structure === 'uop'
});
const ExtractedGumpSchema = z.object({
gumpId: z.number().int().nonnegative(),
width: z.number().int().positive(),
height: z.number().int().positive(),
sha256: z.string().length(64),
provenance: ProvenanceSchema,
});
const ExtractedHueSchema = z.object({
hueIndex: z.number().int().min(1).max(65535),
name: z.string(),
tableStart: z.number().int(),
tableEnd: z.number().int(),
colors: z.array(z.number().int()).length(32),
provenance: ProvenanceSchema,
});
export const ManifestSchema = z.object({
buildId: z.string().length(26),
buildLabel: z.string(),
generatedAt: z.string().datetime(),
stagesCompleted: z.array(z.enum(['inventory', 'identify', 'probe', 'extract', 'classify', 'normalize'])),
gumpCount: z.number().int().nonnegative(),
hueCount: z.number().int().nonnegative(),
needsOperatorInputCount: z.number().int().nonnegative(),
gumps: z.array(ExtractedGumpSchema),
hues: z.array(ExtractedHueSchema),
});
export type Manifest = z.infer<typeof ManifestSchema>;Example (truncated to two gumps and one hue for illustration; a real manifest holds every extracted
item). Every value below satisfies the schema above, so it can be pasted into a test fixture as-is:
identifiers are 26-character ULIDs, hashes are 64 hex characters, and the hue's byteOffset is the
one Section 7.3.3's layout actually produces — hue index 1002 sits at group g = 125, slot s = 1,
so 125 * 708 + 4 + 1 * 88 = 88,592. One exception, stated so nobody builds a golden test on it: the
32 colors values below are illustrative placeholders chosen to read as a smooth ramp, not decoder
output. Real values are always 5-bit expansions per Section 8.3.1 ((c5 << 3) | (c5 >> 2)), whose
channel bytes can only be 0, 8, 16, 24, 33, 41, …, 255; any fixture that asserts decoder behaviour
must be generated from a real hues.mul byte range rather than copied from here:
{
"buildId": "01J8Z3K9QABCDEFGHJKMNPQRST",
"buildLabel": "2026-09-01-patch-47",
"generatedAt": "2026-09-01T14:32:07Z",
"stagesCompleted": ["inventory", "identify", "probe", "extract"],
"gumpCount": 4821,
"hueCount": 3000,
"needsOperatorInputCount": 3,
"gumps": [
{
"gumpId": 200,
"width": 44,
"height": 68,
"sha256": "b1946ac92492d2347c6235b4d2611184d0d1c58d8c1f0c9c5c9c5c9c5c9c5c97",
"provenance": {
"sourceFileId": "01J8Z3K9SRCF0AB3CDEFGH2345",
"relativePath": "gumpart.mul",
"byteOffset": 883412,
"structure": "mul_idx_pair"
}
},
{
"gumpId": 201,
"width": 44,
"height": 68,
"sha256": "d2a8c1f4e9b7a3c6f1e8d4b2a9c7e5f3b1d8a6c4e2f0b8d6a4c2e0f8b6d4a2c0",
"provenance": {
"sourceFileId": "01J8Z3K9SRCF0AB3CDEFGH2346",
"relativePath": "Gumps.uop",
"byteOffset": 1204890,
"structure": "uop",
"uopPathHash": "8f3a2c1e9b7d4f60"
}
}
],
"hues": [
{
"hueIndex": 1002,
"name": "Sea Green",
"tableStart": 25,
"tableEnd": 31,
"colors": [534046, 929060, 1323817, 1718831, 2113589, 2508602, 2903360, 3298374, 3758667, 4153681, 4548438, 4943452, 5338210, 5733223, 6127981, 6522995, 6917752, 7312766, 7707524, 8102537, 8497295, 8892309, 9287066, 9682080, 10142373, 10537387, 10932145, 11327158, 11721916, 12116930, 12511687, 12906701],
"provenance": {
"sourceFileId": "01J8Z3K9SRCF0AB3CDEFGH2347",
"relativePath": "hues.mul",
"byteOffset": 88592,
"structure": "hues_table"
}
}
]
}Provenance fields (sourceFileId, relativePath, byteOffset, and structure) are carried forward
into assets.source_file_id/assets.source_offset (Section 6.10) at D6, so every catalog row remains
traceable back to the exact byte range it came from, which is essential for triaging a bad extraction
without re-running the whole pipeline. The perceptual hash used for diffing (Section 9.3) is computed
in D6 alongside the sha256, using a 64-bit difference hash (dHash) over a downscaled 9×8 greyscale of
the sprite, stored in extraction_candidates.metadata.perceptualHash and not persisted onto the final
assets/asset_variants rows, since it is a pipeline-internal diffing aid, not catalog data.
7.6 Classification #
D5 maps every extracted gump to a slot_key and, for slots with per-gender art, a body, then
assigns a confidence score.
Signal 1 — gump id range. UO's classic client groups gump ids into broad, documented ranges per
equipment category (e.g. certain ranges are conventionally hats, others robes). D5 maintains a range
table in core/pipeline/gump-ranges.ts mapping [start, end] → slot_key for every range the operator
or a prior build's staff review has confirmed; an id inside a confirmed range contributes +0.4 to
that slot's confidence.
Signal 2 — dimensions. Sprite width/height are compared against a learned expected envelope
per slot (e.g. hair sprites cluster tightly around a known width/height band derived from
previously-approved assets in the same slot from earlier builds). A dimension within one standard
deviation of the slot's learned mean contributes +0.2; outside two standard deviations contributes
-0.2. On a build with no prior approved assets for a slot (first-ever import), this signal
contributes 0 (neutral) rather than penalizing, since there is no learned envelope yet.
Signal 3 — alpha silhouette heuristics. The decoded RGBA buffer's alpha channel is analyzed for
gross shape signals: a bounding-box aspect ratio and an approximate vertical centroid of opaque
pixels. Slots with a known expected silhouette (e.g. footwear sprites are wide and short, hair
sprites are tall and narrow with mass concentrated in the upper third) get +0.15 when the candidate
matches, -0.15 when it strongly contradicts (e.g. a candidate proposed as footwear with an aspect
ratio and centroid matching hair instead).
Signal 4 — body.def cross-check. For candidates whose gump id is referenced (directly or via a
known offset convention) in body.def/bodyconv.def, agreement contributes +0.15; a direct
contradiction (the control file asserts this id belongs to a non-human body) is a hard rejection
regardless of other signals, since it means the sprite is not a player cosmetic at all.
Signal 5 — name dictionary. When a candidate arrived via loose_image (Section 7.3.7) with a
filename that embeds a descriptive token (e.g. robe_plain.png), the token is matched against a
per-slot keyword dictionary (core/pipeline/name-dictionary.ts); a match contributes +0.1.
Confidence is the sum of all applicable signals, clamped to [0, 1]. Automatic acceptance
threshold: confidence >= 0.85 is approved automatically by D5 and proceeds to D6 without staff
action. Everything below the threshold is written with status = 'pending' and appears in the staff
review queue (Section 17.6), ordered ascending by confidence so the least-certain items surface first
for review.
Cold-start rule. The five signals sum to at most 0.4 + 0.2 + 0.15 + 0.15 + 0.1 = 1.0, but on a
build where a slot has no prior approved assets, Signal 2 has no learned envelope and contributes 0,
and Signal 5 applies only to loose_image candidates. A first-ever import of MUL/UOP data therefore
caps at 0.4 + 0 + 0.15 + 0.15 = 0.70, below the threshold, which would push 100% of the first
build's several thousand candidates into the manual queue. So: on a build where the slot has no
prior approved assets, Signal 2 is unavailable and the threshold for that slot drops to 0.65. The
import_runs.summary for D5 records coldStartSlots — the list of slots approved under the reduced
threshold — so staff know exactly which slots to spot-check in the diff view (Section 9.3). Once a
slot has approved assets from any earlier build, the envelope exists and the threshold returns to
0.85 for that slot.
Hard rejection. A Signal-4 direct contradiction is not a low score; it is a decision. It sets
status = 'rejected', confidence = 0 and metadata.rejectionReason = 'body_def_contradiction', and
the candidate never enters the review queue — asking staff to adjudicate a sprite the control files
say is not a player cosmetic wastes the queue's only scarce resource, which is attention.
Hue mode. D5 also decides the candidate's hue mode and records it as
metadata.hueMode (full or partial), from the same silhouette analysis Signal 3 uses: a sprite
whose opaque pixels are predominantly grey (r8 === g8 && g8 === b8 for a majority of them) is a
partial-hue candidate, since partial hue is exactly the rule that recolours only grey pixels (Section
8.3.3). D6 copies this onto assets.partial_hue when it first creates the asset and onto
asset_variants.hue_mode on every variant it writes (Sections 6.10 and 6.11); without this step every
asset would be full forever and no asset would ever partial-hue.
Body assignment for slots with gender_scope = 'both' (Section 6.9) uses the same signal set
evaluated independently per body id found in body.def's cross-reference, or, when the source
structure carries no body distinction at all (some cosmetic layers share one sprite across genders in
the original client), D5 assigns the single extracted sprite to both asset_variants rows (m and
f) with identical geometry — this is recorded explicitly in
extraction_candidates.metadata.sharedAcrossGenders = true so staff reviewing the catalog understand
why only one sprite backs two variant rows.
7.7 Hue Extraction and Validation #
D4 decodes every hue in hues.mul (or its UOP equivalent, when Outlands ships hues packaged as UOP)
using Section 7.3.3's algorithm, producing 3,000 base-range DecodedHue records unconditionally.
Those records live in the work directory. The hues table itself is written by D6, in the same
run that writes the rest of the catalog (Section 7.2.6); everything this subsection specifies —
source tagging, naming, grouping and the swatch colour — is computed here and persisted there.
Detecting Outlands custom hues: the rule is an index-range rule, decided here and stated in full,
and it deliberately depends on no external data file. The operator has exactly one client — the
Outlands one (Section 2.7) — so any rule requiring a pristine stock client's hues.mul for comparison
could never be executed and would block every import.
- A hue with
hue_indexin1..3000is taggedsource = 'base'. - A hue with
hue_index > 3000is taggedsource = 'outlands'. source = 'derived'is reserved for any hue this pipeline itself synthesizes rather than reads from client data. v1 never produces derived hues; the taxonomy leaves room for a future feature (e.g. procedurally generated skin-hue ramps) without a schema change.
Colour-table divergence within 1..3000 — Outlands overriding a stock hue slot — does not change
source. It is detected by comparing the hue against the same hue_index in the previous build (the
diff in Section 9.3 already does this) and recorded as
extraction_candidates.metadata.overridesStockHue = true for staff visibility on the hues admin
screen (Section 17.8). It matters because a design created against an older build renders a visibly
different colour than one created after the override, which is exactly why designs.build_id pins
every design to the hue table active at creation time (Sections 9.8 and 13.7). The first build has no
previous build to compare against, so no hue is flagged on it; that is correct, since "overrides the
stock table" is only meaningful once there is a prior table to have overridden.
Staff may correct source on the hues admin screen (Section 17.8); that edit is a normal audited
write, not a pipeline concern.
Naming: name comes directly from the decoded 20-byte name field (Section 7.3.3), trimmed. A
blank decoded name (all-NUL or all-whitespace) is replaced with a generated placeholder — the literal
string "Hue " followed by the decoded hueIndex, so hue 1847 becomes Hue 1847 — written by D6 as
it inserts the row, never left blank, since the admin console and public hue browser (Section 15.5)
both require a non-empty display string. The placeholder is a prefix form and it applies to any hue
whose decoded name was blank, whatever its source; it has nothing to do with derived hues, which
v1 never produces.
Grouping: every hue ends up in one or more of the five hue_groups (Section 6.14), decided by a
rule table: hues whose name matches a skin-tone keyword dictionary ("flesh", "tan", "pale",
"bronze", etc.) or whose index falls in a curated range confirmed by staff in a prior build are
tagged skin; a parallel keyword/range approach handles hair, tattoo, and event; every hue not
matched by a more specific rule falls into clothing as the default group, since most of the 3,000
base hues are general-purpose dye colours with no skin/hair/event connotation. This grouping is
data-driven (the keyword and range tables), so refining it in a later build never requires a code
change, only an update to the rule tables under core/pipeline/hue-grouping-rules.ts.
The rules run at D5; the rows are written at D6. D5 evaluates the rule table and records the
resulting group keys on extraction_candidates.metadata.hueGroups — a proposal, held on a pipeline
scratch row. D6 inserts the corresponding hue_group_members rows at the moment it creates each
hues row (Section 7.2.6). The split is not a matter of taste: hue_group_members.hue_id is a
foreign key to hues.id (Section 6.15), and no hues row exists until D6 writes it, so D5 has
nothing to point a membership row at. The same is true of the placeholder name below, which is why
that substitution also happens at D6.
Every membership row D6 writes carries hue_group_members.method = 'heuristic' (Section 6.15). Staff
additions and removals in the admin console write method = 'manual', and a later import never
overwrites a manual row — that is the whole point of the column. Curation policy can therefore move
between "heuristic with staff corrections" and "staff-curated allowlist" without a migration: it is a
question of which rows exist, not of what the schema can express.
Swatch colour rule: swatch_color (Section 6.13) is computed as the colour at index 16 of the
32-entry colors array — the visual midpoint of the hue ramp, chosen because index 0 and index 31 are
frequently near-black/near-white outliers that make a poor representative swatch, while index 16 is
consistently the most saturated, recognizable tone across the sampled hue table. Index 16 is the one
and only swatch index: a picker filled from index 0 would render every skin tone as near-black and be
unusable, since the ramp runs darkest-to-lightest (Section 8.3.2). Staff can override the computed
value per hue via hues.swatch_override_argb (Section 6.13), which wins whenever it is non-null.
7.8 Normalization #
D6 turns an approved extraction_candidates row into permanent catalog rows and stored image files.
For every approved candidate:
- Trim: the decoded RGBA buffer is scanned for the tightest bounding box containing any non-transparent pixel (alpha > 0). The buffer is cropped to that bounding box. If the entire buffer is transparent (a zero-length sprite, Section 7.12), the candidate is rejected rather than normalized.
- Offset preservation: cropping shifts the sprite's origin, and the crop's top-left
(dx, dy)is the offset. Paperdoll gump art is authored full-canvas — the untrimmed sprite's origin is the canvas origin(0, 0)— sooffset_x = dxandoffset_y = dy, with no external offset source required and nothing to recover fromtiledata.mulor a gump header. The trimmed sprite therefore composites at the exact same visual position on the 260×330 canvas (Section 8.1) as the untrimmed original would have. A sprite whose untrimmed dimensions exceed 260×330 cannot have been authored full-canvas for this paperdoll and is rejected withdetail.reason = 'oversize'(Section 7.12) rather than silently mis-positioned. - Scale generation: for each of scale
1,2,3, the trimmed RGBA buffer is upscaled by nearest-neighbour pixel replication (never smoothed — this is pixel art, Section 8.6): scalenmaps source pixel(x, y)to then×nblock of output pixels starting at(x*n, y*n), each copied verbatim. - Encoding: each of the 3 scaled buffers is encoded to both
webp(via@jsquash/webp) andpng(via@jsquash/png), per the dependency table in Section 4.1, producing 6 files per variant. - Storage keys: uploaded to object storage at
catalog/:assetKey/:body/:hash12/:scale.:format, where:hash12is the first 12 hex characters of the variant'ssha256— e.g.catalog/hair.long-wavy/m/9f2c41ab77de/2.webp. This is the key shape Section 8 names wherever it refers to a catalog object, and the encoded formats arewebpandpngonly (Section 6.12); no raw buffer is ever uploaded. The scheme is deterministic and human-diagnosable, and the hash segment is what makes it safe across builds: identical art in two builds produces the same key and shares one object, while changed art produces a different key, so D6 can never overwrite the bytes an older build's designs still resolve. The build id is deliberately not in the key — putting it there would give every build its own copy of unchanged art — and content addressing is what replaces it. Because several builds legitimately share one object,asset_images.storage_keyis not unique and carries a plain index rather than a unique one (Section 6.12); uniqueness lives on(asset_variant_id, scale, format)instead. If the object already exists at that key, the upload is skipped.catalog/is one of exactly four object-storage prefixes this system uses —catalog/,renders/,og/andimports/— enumerated in Section 8.9; D6 writes only undercatalog/. - Row writes: one
assetsrow per uniqueasset_key(first variant processed for that key creates it; subsequent variants for the same key addasset_variantsrows only), oneasset_variantsrow per body per build, and oneasset_imagesrow per scale × format, all in a single transaction per asset so a partial failure never leaves anassetsrow with no variants. Each variant row carriesbuild_idandhue_mode(Section 6.11):build_idis the build D6 is running against, andhue_modeis copied from the candidate'smetadata.hueMode(Section 7.6). D6 never updates an existing variant row belonging to an earlier build — when a patch changes an asset's art, it inserts a new row for the new build, which is exactly what keeps every previously-created permalink rendering the bytes it was created against (Section 9.3). - Hue rows: in the same run, D6 reads
<workdir>/<build_id>/extracted/hues.json(D4's output) and inserts onehuesrow per decoded hue for thisbuild_id, applying the naming,sourcetagging and swatch-colour rules in Section 7.7, followed by thehue_group_membersrows realizing the group proposal D5 left on each candidate. This is the only write path to either table (Sections 6.13 and 6.15). It is idempotent onhues (hue_index, build_id)and onhue_group_members (hue_id, hue_group_id), and it never overwrites a membership row whosemethodismanual. - Backpack default: every published build is required to contain the asset
backpack.default, the standard leather backpack. If classification produced nobackpackcandidate for a build, D6 carries the previous published build'sbackpack.defaultart forward as a new build-scoped variant rather than leaving the slot empty, because the paperdoll always draws a backpack (Section 3.3) and a design that omits one has no valid render. A build with nobackpack.defaultfrom any source failsverify(Section 7.9) and cannot be published.
asset_key generation: every generated key must satisfy the pattern
^[a-z0-9]+([-_.][a-z0-9]+)*$ with a maximum length of 128 characters — the same shape
AssetKeySchema enforces at the API boundary (Section 13.2.5) and the database enforces as a CHECK
constraint (Section 6.10). D6 interpolates the key straight into an object-storage path, so this is
the check that keeps a path-traversal sequence out of a storage key on the ingest side, where no API
schema ever runs. D5 assigns a stable natural key of the form <slot_key_short>.<slug>, where
slot_key_short drops the _inner/_outer/_middle/_body suffixes for readability (e.g.
torso_outer → robe, chosen from a fixed per-slot label map, not a mechanical string transform) and
slug is a kebab-case slug derived from the D5 name-dictionary match (Section 7.6) when available, or
from "item-" + gumpId when no descriptive name was recoverable. Collisions on asset_key within one
build are resolved by appending -2, -3, etc.; a collision against a different build's asset with
different underlying art is treated as a data error and routed to needs_operator_input, since it
means two visually distinct items would otherwise share one catalog identity.
7.9 CLI Surface #
The CLI is a single binary, skinforge-cli, and every command is skinforge-cli <noun> <verb>. This
is the same form used in Sections 4.4, 5.2 and 9, and it is the only form: there is no bare
skinforge alias and no flat-verb spelling.
| Command | Purpose |
|---|---|
skinforge-cli import discover --client-dir <path> --build-label <label> |
Create a build and run D1 (inventory). |
skinforge-cli import identify --build <id> |
Run D2 against an existing build's inventory. |
skinforge-cli import probe --build <id> |
Run D3 against files D2 left unidentified. |
skinforge-cli import extract --build <id> [--only <structure>] |
Run D4. --only restricts to one structure type for incremental re-runs. |
skinforge-cli import classify --build <id> |
Run D5. |
skinforge-cli import normalize --build <id> |
Run D6. |
skinforge-cli import run --build <id> |
Run D1–D6 in sequence against an existing build, stopping at the first stage that leaves any needs_operator_input item above a --max-unresolved threshold (default 0). |
skinforge-cli import all --client-dir <path> --build-label <label> |
Create the build, then run D1–D6 in one command. The one-shot form of discover followed by run. |
skinforge-cli import verify --build <id> |
Re-read every asset_images row for the build, re-fetch the object from storage, and confirm its hash matches content_hash; also confirm the build contains backpack.default. Used before publish (Section 9.5) and after a restore (Section 6.34). |
skinforge-cli builds submit-review --build <id> |
Move a build draft → in_review (Section 9.1). |
skinforge-cli builds diff --from <id> --to <id> |
Print the build diff (Section 9.3). |
skinforge-cli builds publish --build <id> |
Run the publish transaction (Section 9.5). |
skinforge-cli builds rollback --build <id> |
Re-publish an earlier build (Section 9.6). |
skinforge-cli db migrate / db seed / db fixtures |
Migration runner and seed data (Sections 6.31 and 6.32). |
skinforge-cli admin user create |
Create a staff account (Section 17.15). |
import run requires a build that already exists, because only discover can create one; import all is the command that does both, and it is the one an operator uses after a patch.
Every command accepts:
--dry-run: performs all reads and computation but writes nothing to the database or object storage; prints a summary of what would have changed. Used by staff to preview a re-import's impact before committing.--json: emits a single machine-readable JSON object to stdout on completion (counts, timings, error list) instead of human-readable text, for CI and scripting.--build <id>: everyimportverb exceptdiscoverandallrequires an explicit build id; there is no implicit "current build" state, so operators can run stages for multiple builds concurrently without cross-contamination.
Process permissions: each command launches the decoding child described in Section 7.1 with
--allow-read scoped to the client directory and the work directory, --allow-write scoped to the
work directory, and no network permission. The driver process itself runs with
--allow-net=<database-host>,<storage-endpoint> and --allow-env for the SKINFORGE_* variables in
Section 24.2. Neither process is ever granted a broader network permission, and neither is ever run
inside the public-facing web process.
Exit codes: 0 success with zero failed/needs_operator_input items; 1 success with at least
one needs_operator_input item (recoverable, needs staff attention, not a pipeline bug); 2 stage
aborted before completion (unreadable client directory, checksum drift, schema validation failure);
3 invalid CLI arguments. Scripts and CI treat 0 and 1 as "the command ran"; only 2 and 3 are
treated as pipeline failures worth alerting on.
Resumability: every stage is idempotent per Section 7.5's manifest — re-running import extract --build <id> re-reads manifest.json, skips any gump whose sha256 already matches a prior run's
recorded value for the same gump_id, and only re-decodes gumps that are new or whose source bytes
changed. import normalize similarly skips re-encoding a variant whose normalized files already exist
in the work directory with a hash matching the current asset_variants.sha256 for this build. An
interrupted run (process killed mid-stage) leaves partially-written
import_run_items/extraction_candidates rows with whatever status they had at interruption;
re-running the same stage command re-processes only items not yet in a terminal state (ok,
approved, rejected), so no work is duplicated and no row is silently skipped.
7.10 Performance #
Expected volumes: tens of thousands of sprites (roughly 5,000–8,000 catalog-relevant gumps after classification, out of a raw index that may contain 50,000+ total MUL/UOP entries once non-cosmetic game art is counted) and 3,000 hues per build.
- Streaming decode: D1's hashing and D4's MUL/IDX reads never load an entire multi-hundred-MB
.mulfile into memory at once; both stream in 64 KB chunks using Deno'sDeno.open+readablestream API, seeking only for the specific byte ranges an index entry names. - Memory ceilings: the pipeline enforces a soft in-process ceiling of 512 MB for any single stage
invocation, checked via
Deno.memoryUsage().heapUsedsampled every 500 processed items; exceeding it triggers a forced flush of in-memory buffers to the work directory and, if still over budget after flushing twice, an early, clean stage abort with exit code2and a message naming the offending item, rather than an uncontrolled out-of-memory crash. - Parallelism: D4 (extract) and D6 (normalize) — the two CPU-bound stages — use a worker pool
(Deno
Workerthreads) sized toSKINFORGE_RENDER_MAX_CONCURRENCY, whose default is4(Section 24.2, reused for both render-time and import-time concurrency budgeting since both are CPU-bound image work competing for the same cores). Each worker processes one gump or one asset variant at a time, claimed from a shared in-process queue; database writes are batched by the main process, not performed from workers, to avoid connection-pool contention. - Progress reporting: every stage command prints a progress line to stderr every 2 seconds (item
count processed / total, current throughput, ETA) when not run with
--json; with--json, the same information is instead available by pollingimport_runs.summary(Section 6.6), which the driver process updates every 2 seconds from the child's journal (Section 7.1), so the admin console imports screen (Section 17.5) can show a live progress bar by polling that column.
7.11 Legal/Technical Guardrails #
No raw game file, and no byte-for-byte extracted sprite, is ever served to a public visitor. The
public application only ever serves the outputs of D6 normalization (WebP/PNG files the pipeline
itself generated) through the render endpoints in Section 8.8, or composite renders built from those
normalized files. The work directory (SKINFORGE_IMPORT_WORKDIR) is not mounted under any public web
route, is not in the object storage bucket, and the application's HTTP router has no handler that
resolves any path under it — there is no code path, intentional or accidental, that could expose a raw
MUL/IDX/UOP byte range or an untrimmed pre-normalization sprite to a public request. This holds even
in the admin console: admin preview images shown during candidate review (Section 17.6) are served
from a dedicated, session-authenticated admin-only route, /admin/artifacts/:importRunId/:name, that
reads from the work directory server-side and streams a re-encoded preview. It is never a redirect,
and no import artifact is ever addressable at a public object-storage URL — extraction_candidates. image_ref (Section 6.8) is a work-directory-relative path, not a storage key, precisely so that no
code path can accidentally hand one to the public base URL. The overall legal posture for distributing
derived, transformative renders (rather than original game assets) is Section 20.8's concern; this
section only guarantees the technical boundary that makes that posture true in practice.
7.12 Edge Cases #
| Case | Detection | Pipeline behaviour |
|---|---|---|
Corrupt index entry (garbage lookup/length pointing past end of file) |
lookup + length > fileSize bounds check before read |
import_run_items row status = 'failed', detail.reason = 'out_of_bounds'; D4 continues to the next entry. |
Zero-length sprite (length = 0 with a non-sentinel lookup, or a decoded RGBA buffer that is fully transparent after decode) |
Explicit length === 0 check pre-decode; post-decode alpha-sum check |
Recorded status = 'failed', detail.reason = 'zero_length'; never reaches D5. |
| Duplicate content hash (two different gump ids decode to byte-identical RGBA) | asset_variants.sha256 unique-per-row check at D6 (not a DB constraint, since legitimate duplicates like symmetric left/right variants can share art; a warning, not a rejection) |
Both proceed to separate assets rows; extraction_candidates.metadata.duplicateOf cross-references the other gump id for staff visibility in the review queue. |
Oversize sprite (width or height exceeds 512px, far beyond any real paperdoll layer) |
Bounds check against a constant MAX_SPRITE_DIMENSION = 512 at D4 |
Rejected before decode with detail.reason = 'oversize', since it almost always indicates a misread extra field (Section 7.3.1) rather than real art; routed to needs_operator_input for a human to confirm. |
Missing female variant (a slot with gender_scope = 'both' has only a male-assignable candidate after D5) |
Post-D5 completeness check per asset_key group |
Not an error: the single variant is assigned to both m and f per Section 7.6's shared-across-genders rule, unless staff explicitly marks the asset male-only in the review queue, which sets a per-asset override consulted by D6. |
Palette anomaly (a hue's colors array decodes to fewer than 32 distinguishable colours, e.g. many entries identical) |
Post-decode distinct-colour count on the 32-entry array | Recorded but not rejected — some legitimate hues (e.g. pure greyscale ramps) can legitimately repeat colours; extraction_candidates.metadata.paletteWarning = true surfaces it for staff awareness only. |
Gap in an index file (a run of consecutive sentinel 0xFFFFFFFF entries, e.g. reserved-but-unused gump id ranges) |
Sentinel check per Section 7.3.1 | Silently skipped, not logged as failed — this is the documented, expected meaning of the sentinel, not an anomaly. |
Truncated .mul file (declared length for the last entry extends past physical end of file) |
Bounds check identical to the corrupt-index case | Recorded status = 'failed', detail.reason = 'out_of_bounds'; the rest of the file (entries before the truncation point) still processes normally. |
UOP entry whose pathHash matches no candidate in the dictionary (Section 7.3.4, Hash algorithm) |
Hash lookup miss during D4 | Recorded as an extraction_candidates row of candidate_key prefix uop-unresolved:, status = 'needs_operator_input', with the raw pathHash and block/entry position in metadata so staff can extend the dictionary pattern and re-run import extract --only uop. |
hues.mul file size not an exact multiple of 708 bytes |
Modulo check before decoding | The trailing partial block (fewer than 708 bytes remaining) is not decoded and is logged as a warning; every complete block before it decodes normally — a truncated tail never blocks the other 99%+ of hues. |
Malformed gump record: lookup table shorter than 4 * height |
data.byteLength < 4 * height check before the row loop (Section 7.3.2) |
GumpDecodeError('lookup_table_truncated') caught at the stage boundary; import_run_items row status = 'failed', detail.reason = 'lookup_table_truncated'; D4 continues with the next record. |
| Malformed gump record: a row offset pointing outside the record | Range check on every row offset before the first read (Section 7.3.2) | detail.reason = 'row_offset_out_of_bounds', same handling. |
| Malformed gump record: run data ends mid-run | cursor + 4 > data.byteLength check inside the run loop (Section 7.3.2) |
detail.reason = 'run_data_truncated', same handling. |
Malformed gump record: a run declaring runLength = 0 |
Explicit check after reading the run header (Section 7.3.2) | detail.reason = 'zero_length_run', same handling. This is the shape that would otherwise spin the row loop forever, since a zero-length run advances the cursor but never advances x. |
| UOP block chain that cycles, moves backwards, or runs past the file | Visited-offset set plus strict-increase and file-length checks (Section 7.3.4) | UopDecodeError('block_chain_cycle'); import_run_items row status = 'failed', detail.reason = 'block_chain_cycle'; the whole UOP is abandoned, other source files are unaffected. |
UOP entry declaring a decompressedLength above 16 MiB, or a payload extending past the end of the file |
Checked before decompression is attempted (Section 7.3.4) | detail.reason = 'entry_bounds'; the entry is skipped without being decompressed, so a decompression bomb is rejected rather than expanded. |
.idx file whose size is not an exact multiple of 12 |
Floored entry count in readIdxEntries (Section 7.3.1) |
The trailing partial entry is not read; a warning row records detail.reason = 'idx_partial_entry' and every complete entry processes normally. |
8. Rendering Engine — Hue Math, Compositing & Encoding #
This section defines the rendering engine that turns a design (Section 13) into pixels. It is the
single source of truth for canvas geometry, sprite representation, the hue algorithm, compositing
order, scaling, encoding, the render URL scheme, cache keys, the runtime pipeline, pre-warming,
client/server parity, performance budgets, and rendering error handling. Every implementation detail
here binds both the server renderer (the core/hue/, core/composite/ and core/codec/ modules of
the repository tree in Section 5.1, used by the Deno web process) and the browser renderer (those same
modules compiled for the browser, per Section 8.12). There is exactly one algorithm; it must produce
bit-identical output in both environments.
8.1 Canvas geometry #
The composite canvas is 260×330 px at scale 1. This is the classic UO paperdoll art area and is fixed for the lifetime of the product.
- Origin: top-left corner,
(0, 0). X increases right, Y increases down. This matches HTML Canvas 2D and typed-array row-major layout, so no coordinate flip is needed anywhere in the pipeline. - Coordinate system: integer pixel coordinates only. No sub-pixel placement, no rotation, no
perspective. A sprite is placed by adding its recorded offset (
offsetX,offsetY, both signed integers, origin at the sprite's own top-left) to the canvas origin. - Sprite placement: each
SpriteLayer(Section 8.2) carries the(offsetX, offsetY)recorded at extraction time (Section 7, Stage D6). The renderer computes the destination rectangle as[offsetX, offsetX + width) × [offsetY, offsetY + height)and blits into that rectangle. - Overflow clipping: a sprite whose destination rectangle extends outside
[0, 260) × [0, 330)is clipped to the canvas bounds. Source pixels that map to out-of-bounds destination coordinates are skipped (never wrapped, never written). Clipping is silent — it is not an error — because some Outlands cosmetic layers (long cloaks, oversized hats) intentionally bleed past the paperdoll frame in the original client art, and the classic client itself clips them the same way. Partial overflow is therefore silent and expected. A destination rectangle that lies entirely outside[0, 260) × [0, 330)is different: it means the recorded offset is corrupt, nothing would be drawn, and the render aborts withSF-5006(500, "composite canvas geometry mismatch", Section 25.2) rather than silently producing a layer-less image. This is the only geometry condition that is an error. - Why the canvas never changes size: the render URL scheme (Section 8.8) and the cache key (Section
8.9) do not carry width/height, only
scale. A fixed base resolution means:- Every asset's recorded offset is meaningful without a per-render coordinate transform.
- The
scaleparameter is a pure integer multiplier applied after compositing (Section 8.6), so compositing math and hue math run once at scale 1 and scaling is a separate, later step. - OG image generation (Section 14) and the designer UI (Section 11) can embed the composite at a
known aspect ratio (
260:330) without querying render metadata first.
- Background: the composite canvas starts fully transparent (
RGBA(0,0,0,0)at every texel) before any layer is drawn. Thebodyslot (Section 3.3) is drawn first and is expected to fill the humanoid silhouette; areas the body sprite does not cover (the full canvas margin) remain transparent in the output. Presentation backgrounds (checkerboard, parchment, dark) are a UI concern, not a render concern — see Section 12.2. PNG and WebP outputs always carry a true alpha channel.
8.2 Sprite representation in storage and in memory #
8.2.1 Storage representation #
Each asset_images row (Section 6.12) has an associated binary artifact in object storage, written by
extraction (Section 7, Stage D6) under the catalog/ prefix defined in Section 8.9, at the
content-addressed key catalog/:assetKey/:body/:hash12/:scale.:format (Section 8.9):
- The renderer's input is the scale-1 PNG,
catalog/:assetKey/:body/:hash12/1.png, decoded to RGBA with@jsquash/png(Section 8.7) and held in the in-process sprite cache (Section 8.13). PNG is lossless, so the decoded buffer is byte-identical to what extraction wrote (Section 7.8), which is what makes the browser-parity contract in Section 8.12 hold. catalog/:assetKey/:body/:hash12/:scale.webpandcatalog/:assetKey/:body/:hash12/:scale.pngat scales 1, 2 and 3 — six files per variant, the complete set Stage D6 writes (Section 7.8). These are the encodings the catalog and the browser fetch directly (Section 8.12, Section 12.5); only the scale-1.pngamong them doubles as the renderer's input above, and none of the six is produced by the compositor itself — the compositor always starts from the decoded pixels of that scale-1 PNG.
asset_images.format is webp or png and admits no other value — the CHECK constraint Section 6.12
owns. There is no raw-pixel storage artifact anywhere in the system.
There is no per-sprite sidecar of any kind. A sprite is exactly its RGBA pixels. Everything the hue algorithm needs is derivable from those pixels, by the rule in Section 8.3.3 — one artifact per variant per scale, nothing to keep in sync, and nothing the extraction pipeline has to invent. This matters because the browser (Section 8.12) receives only a decoded PNG and must run the identical algorithm; any input the browser cannot see would make byte-for-byte parity impossible by construction.
8.2.2 In-memory representation #
// core/composite/types.ts
/** A single decoded sprite: one asset's art at one body/variant, before hue is applied. */
export interface Sprite {
/** Natural key of the owning asset, e.g. "hair.long-wavy". */
readonly assetKey: string;
/** Body this sprite was extracted for. */
readonly body: "m" | "f";
/** The build this variant belongs to (Section 6.11, `asset_variants.build_id`). */
readonly buildId: string;
/** Pixel width and height. Both > 0. */
readonly width: number;
readonly height: number;
/** Placement offset relative to canvas origin, at scale 1. */
readonly offsetX: number;
readonly offsetY: number;
/** width * height * 4 bytes, RGBA8888, row-major, straight (non-premultiplied) alpha. */
readonly pixels: Uint8ClampedArray;
/** sha256 of `pixels` at extraction time, for provenance and cache-busting. */
readonly contentHash: string;
}
/** One layer in a composite request: a sprite plus the hue to apply to it. */
export interface SpriteLayer {
readonly slotKey: string;
readonly sprite: Sprite;
/** 0 = as-drawn (no hue applied). Any other value is a hue index into HueTable. */
readonly hue: number;
/** True when the owning asset is partial-hue (Section 8.3.3). */
readonly partial: boolean;
/** z-order from the slot registry in Section 3.3. Layers are composited ascending by z. */
readonly z: number;
}
/** A resolved 32-entry ARGB1555-derived color ramp for one hue index, within one build. */
export interface HueEntry {
readonly hueIndex: number;
readonly buildId: string;
readonly name: string;
readonly source: "base" | "outlands" | "derived";
/** Exactly 32 entries, each pre-unpacked to 8-bit RGB. Index 0 = darkest, 31 = lightest. */
readonly colors: ReadonlyArray<readonly [r: number, g: number, b: number]>;
}
/** In-memory lookup structure for all hues of one build, keyed by hue index. */
export interface HueTable {
readonly buildId: string;
get(hueIndex: number): HueEntry | undefined;
}
/** Full input to a composite operation. */
export interface CompositeRequest {
readonly body: "m" | "f";
/** The design's pinned build. Every sprite and every hue is resolved within it (Section 8.9). */
readonly buildId: string;
readonly skinHue: number;
/** Already sorted ascending by z; see Section 8.4. */
readonly layers: ReadonlyArray<SpriteLayer>;
readonly scale: 1 | 2 | 3;
}
/** Output of a composite + encode operation. */
export interface RenderResult {
readonly format: "webp" | "png";
readonly scale: 1 | 2 | 3;
readonly width: number;
readonly height: number;
readonly bytes: Uint8Array;
/** sha256 of `bytes`, stored as `design_renders.content_hash` (Section 8.9). */
readonly contentHash: string;
}Sprite.pixels uses Uint8ClampedArray because it is written to directly by ImageData-compatible
code paths in the browser (Section 8.12) and clamps out-of-range arithmetic during alpha blending
(Section 8.4) without extra bounds checks.
Sprite carries no geometry that the render URL does not: width, height, offsetX and offsetY
come from the asset_variants row and are delivered to the browser by the catalog API (Section
8.12), not encoded in the image.
8.3 The hue algorithm #
Hue is UO's palette-remapping color system. SkinForge implements it exactly as the classic client
does, because Section 8.12 requires the browser and server to produce pixel-identical output, and
because a design's stored hue value (Section 13) must always render the same way regardless of
which engine renders it.
This subsection is the sole definition of the algorithm. Section 3.6 introduces the concept in prose and defers here; Appendix B (Section 28.2) is a lookup card that reproduces the numbers below and adds none of its own. Where any other text appears to describe the algorithm differently, this subsection is correct.
8.3.1 ARGB1555 unpacking #
Source hue tables (hues.mul, Section 7.3.3, Section 28.1.3) store colors as 16-bit ARGB1555: 1
alpha bit, 5 bits red, 5 bits green, 5 bits blue. Unpacking to 8-bit-per-channel RGB uses bit
replication (repeating the top 3 bits into the bottom 3) rather than a naive left-shift, so that
full-scale 5-bit white (0x1F) unpacks to full-scale 8-bit white (0xFF), not 0xF8.
Alpha does not come from bit 15. The classic art convention is that the packed value 0x0000 is
the transparency sentinel and every other packed value is opaque; bit 15 is not consulted anywhere in
SkinForge. This matches the run-length color == 0 sentinel described in Section 28.1.2.
// core/hue/unpack.ts
export interface Rgba {
readonly r: number;
readonly g: number;
readonly b: number;
readonly a: number;
}
/**
* Unpack one ARGB1555 value to 8-bit-per-channel RGBA.
* Transparency comes from the packed value being exactly 0x0000; bit 15 is ignored.
*/
export function unpackArgb1555(c: number): Rgba {
const r5 = (c >> 10) & 0x1f;
const g5 = (c >> 5) & 0x1f;
const b5 = c & 0x1f;
return {
r: (r5 << 3) | (r5 >> 2),
g: (g5 << 3) | (g5 >> 2),
b: (b5 << 3) | (b5 >> 2),
a: c === 0x0000 ? 0 : 255,
};
}This is the only unpackArgb1555 in the codebase and the only signature it has. Every other section
that shows unpacking reproduces this function verbatim.
8.3.2 Building the 32-entry table #
Each hue record in hues.mul (Section 28.1.3) stores exactly 32 ARGB1555 color values, ramped from
darkest (index 0) to lightest (index 31), plus a start/end byte pair (legacy client hint, not
used by SkinForge) and a 20-byte name. Import (Section 9) unpacks all 32 values once, at import time,
into a HueEntry (Section 8.2.2) and persists the unpacked RGB triples in the hues table (Section
6.13) so the renderer never re-parses ARGB1555 at request time. Because hues is keyed
(hue_index, build_id) (Section 6.13), each build carries its own table and a design pinned to an
old build keeps the colors it was created with.
// core/hue/unpack.ts (continued)
import type { HueEntry } from "../composite/types.ts";
export function buildHueEntry(
hueIndex: number,
buildId: string,
name: string,
source: "base" | "outlands" | "derived",
raw32: Uint16Array, // exactly 32 ARGB1555 values, darkest to lightest
): HueEntry {
if (raw32.length !== 32) {
throw new RangeError(`hue ${hueIndex}: expected 32 color entries, got ${raw32.length}`);
}
const colors = Array.from(raw32, (c): readonly [number, number, number] => {
const { r, g, b } = unpackArgb1555(c);
return [r, g, b] as const;
});
return { hueIndex, buildId, name, source, colors };
}8.3.3 Full hue vs. partial hue vs. hue 0 #
The table index is read from the source pixel itself. For any pixel considered for remapping, the 32-entry table index is the pixel's own red channel reduced to 5 bits:
index = r8 >> 3 // range 0-31, exactly the 32 ramp positionsThat is the whole index rule. There is no sidecar, no stored index, no luminance conversion and no
per-pixel metadata. It is correct because UO paperdoll art is authored greyscale wherever huing is
meant to apply — r8 === g8 === b8 on every huable pixel — so the red channel is the ramp position
the original artist chose, at exactly the 5-bit resolution the source palette had.
Every SpriteLayer.hue value selects one of three behaviors:
- Hue 0 ("as-drawn") — pass the sprite's own RGBA pixels through unchanged. No table lookup, no index computation, no grey test. This is the default for every slot until the user picks a color, and is always valid regardless of asset.
- Full hue — every opaque source pixel is replaced by
hue.colors[r8 >> 3]. The grey test is not applied; a coloured pixel is remapped by its red channel just like a grey one. - Partial hue — a pixel is remapped only when its channels are exactly equal
(
r8 === g8 && g8 === b8), which is the definition of a true grey, black and white included. There is no tolerance window: the comparison is exact equality on the 8-bit values. Non-grey pixels (the pink of exposed skin, the leather-brown of a strap) pass through unchanged. Body, hair, beard and tattoo art is partial-hue by convention (Section 3.6): the sprite is drawn greyscale where huing applies and in full color where it must not.
A tolerance window was considered and rejected. Bit replication (Section 8.3.1) is exact, extraction performs no resampling (Section 7.8), and the source art is genuinely greyscale in its huable regions — so a tolerance would only capture pixels the artist deliberately drew off-grey, which is precisely the signal partial hue exists to respect. Exact equality also removes the last free parameter from the algorithm, which is what makes the zero-tolerance parity contract in Section 8.12 checkable.
Which of the two hue modes an asset uses is a property of the asset, not of the request:
asset_variants.hue_mode (Section 6.11) holds full or partial. It is written by classification
(Section 7.6) from the source layer's known convention — bodies, hair, beards and tattoos partial;
cloth and leather goods full — and can be corrected per asset by a curator on the assets admin
screen (Section 17.7), which is the authoritative override. The renderer reads hue_mode off the
Sprite's owning asset variant and never guesses.
8.3.4 Alpha handling and the transparency convention #
- Every sprite pixel has a real alpha channel (Section 8.2.1: RGBA8888). A pixel with
alpha === 0is never hued and never blended — it is skipped entirely during compositing (Section 8.4). - The packed-zero transparency convention is resolved at extraction time (Section 7, Stage
D4/D6), not at render time: the extractor writes
alpha = 0for any source texel whose packed ARGB1555 value is0x0000, exactly asunpackArgb1555(Section 8.3.1) does, andalpha = 255otherwise. By the time aSpritereaches the renderer, alpha is already correct and the renderer performs no transparency special-casing of its own. - A consequence worth stating plainly: because
0x0000is the sentinel, absolute black (r5 = g5 = b5 = 0) is not an expressible opaque color in the source format. The darkest opaque value the art can carry isr5 = g5 = b5 = 1, which unpacks to RGB(8, 8, 8). Extraction never needs to disambiguate the two, and the renderer never encounters an opaque(0, 0, 0)source pixel. Table index0(Section 8.3.3) is therefore reached only by an already-near-black pixel, never by a transparent one. - Anti-aliased edge pixels, where present in normalized art, keep their exact alpha value through hue and compositing; hue changes RGB only, never alpha.
8.3.5 Reference implementation #
Complete and runnable as written. No branch below is unreachable and no helper is left undefined.
// core/hue/apply.ts
import type { HueEntry, Sprite } from "../composite/types.ts";
import { RenderError } from "../errors.ts";
/**
* Apply a hue to one sprite, producing a new RGBA buffer. Does not mutate the input.
* `partial` is the owning asset variant's `hue_mode === "partial"` (Section 6.11).
* `hue` must be the HueEntry for `hueIndex` within the request's build (Section 8.9).
*/
export function applyHue(
sprite: Sprite,
hue: HueEntry | undefined,
hueIndex: number,
partial: boolean,
): Uint8ClampedArray {
const out = new Uint8ClampedArray(sprite.pixels.length);
out.set(sprite.pixels); // hue 0 returns this identity copy unchanged
if (hueIndex === 0) return out;
if (!hue) {
// The hue index passed URL validation but has no row in this build's hue table.
throw new RenderError("SF-5005", `hue ${hueIndex} missing from build ${sprite.buildId}`);
}
const px = sprite.pixels;
const n = sprite.width * sprite.height;
for (let i = 0; i < n; i++) {
const o = i * 4;
if (px[o + 3] === 0) continue; // transparent: never hued, Section 8.3.4
const r = px[o];
const g = px[o + 1];
const b = px[o + 2];
if (partial && !(r === g && g === b)) continue; // non-grey under partial hue: pass through
const [hr, hg, hb] = hue.colors[r >> 3]; // Section 8.3.3: index = r8 >> 3, always 0-31
out[o] = hr;
out[o + 1] = hg;
out[o + 2] = hb;
// out[o + 3] already carries the source alpha from the initial out.set(px).
}
return out;
}hue.colors always has exactly 32 entries (Section 8.3.2 throws at import time otherwise) and
r >> 3 on a Uint8ClampedArray read is always 0..31, so the lookup can never be out of range and
needs no guard.
8.3.6 Worked example #
All four cases below are computed with the rules above and nothing else. Appendix B (Section 28.2) reproduces these same numbers; if the two ever disagree, this subsection is correct.
Example 1 — partial hue on a grey pixel.
| Field | Value |
|---|---|
| Source RGBA | (96, 96, 96, 255) — a mid-grey, opaque |
Asset hue_mode |
partial |
| Requested hue | 1102, source = outlands |
| Grey test | 96 === 96 && 96 === 96 → passes |
| Table index | 96 >> 3 = 12 |
| Hue 1102, table index 12 (ARGB1555) | 0x4852 |
Unpack 0x4852 with Section 8.3.1:
r5 = (0x4852 >> 10) & 0x1F = 18 → (18 << 3) | (18 >> 2) = 144 | 4 = 148.
g5 = (0x4852 >> 5) & 0x1F = 2 → (2 << 3) | (2 >> 2) = 16 | 0 = 16.
b5 = 0x4852 & 0x1F = 18 → 148.
a = 255 (the packed value is not 0x0000).
Result: RGB (148, 16, 148), a magenta-purple consistent with a dyed-hair Outlands hue.
Output pixel: (148, 16, 148, 255).
Example 2 — full hue on a non-grey pixel.
Source pixel (180, 92, 40, 255), a leather-brown on a cloth asset whose hue_mode is full.
Under full hue the grey test is skipped entirely, so this pixel is remapped like any other:
table index = 180 >> 3 = 22. Suppose the requested hue's table index 22 is 0x6A4F:
r5 = (0x6A4F >> 10) & 0x1F = 26 → (26 << 3) | (26 >> 2) = 208 | 6 = 214.
g5 = (0x6A4F >> 5) & 0x1F = 18 → 148.
b5 = 0x6A4F & 0x1F = 15 → (15 << 3) | (15 >> 2) = 120 | 3 = 123.
Output pixel: (214, 148, 123, 255). Note that only the red channel selected the entry — the
source green and blue are discarded, which is why full hue flattens a multi-colored garment onto one
ramp.
Example 3 — partial hue on the same non-grey pixel (pass-through).
Source pixel (180, 92, 40, 255), asset hue_mode = partial. The grey test
180 === 92 fails immediately, so the pixel is skipped before any index is computed.
Output pixel: (180, 92, 40, 255) — byte-identical to the input.
Example 4 — hue 0.
Any pixel, any hue_mode: applyHue returns at the hueIndex === 0 guard and the output buffer is
a byte-for-byte copy of the input. This is the identity path exercised by every render where the user
has not chosen a color for a slot, and by every sprite the browser fetches (Section 8.12).
8.4 Composite order #
Layers composite bottom-to-top in the z-order given by the slot registry in Section 3.3 (column z,
10 through 190). CompositeRequest.layers (Section 8.2.2) must already be sorted ascending by z
before reaching the compositor; the compositor itself does not sort, so callers (Section 8.10,
Section 8.12) are responsible for ordering. body (z=10) is always present and always drawn first;
backpack (z=190) is always present and always drawn last — it is a designable slot like any other
cosmetic slot, and when the visitor has not chosen one it resolves to the default asset key
backpack.default (Section 3.3, Section 13.2.1). Slots with no selection (hue 0 aside — "no
selection" means the slot is entirely absent from CompositeRequest.layers, distinct from "hue 0 on
a chosen asset") contribute no layer at all.
All 19 slots in the registry are therefore representable in one composite: one required body plus
18 choosable cosmetic slots, backpack included.
Alpha-over (source-over) compositing, per pixel, top layer over the accumulated result so far:
// core/composite/canvas.ts
export function compositeOver(
dst: Uint8ClampedArray, // canvas accumulator, RGBA8888, straight (non-premultiplied) alpha
dstW: number,
src: Uint8ClampedArray, // already hued sprite pixels, RGBA8888, straight alpha
srcW: number,
srcH: number,
offsetX: number,
offsetY: number,
canvasW: number,
canvasH: number,
): void {
for (let sy = 0; sy < srcH; sy++) {
const dy = offsetY + sy;
if (dy < 0 || dy >= canvasH) continue; // Section 8.1 overflow clipping
for (let sx = 0; sx < srcW; sx++) {
const dx = offsetX + sx;
if (dx < 0 || dx >= canvasW) continue;
const so = (sy * srcW + sx) * 4;
const sa = src[so + 3] / 255;
if (sa === 0) continue;
const doff = (dy * dstW + dx) * 4;
const da = dst[doff + 3] / 255;
const outA = sa + da * (1 - sa);
if (outA === 0) {
dst[doff] = dst[doff + 1] = dst[doff + 2] = dst[doff + 3] = 0;
continue;
}
dst[doff] = (src[so] * sa + dst[doff] * da * (1 - sa)) / outA;
dst[doff + 1] = (src[so + 1] * sa + dst[doff + 1] * da * (1 - sa)) / outA;
dst[doff + 2] = (src[so + 2] * sa + dst[doff + 2] * da * (1 - sa)) / outA;
dst[doff + 3] = outA * 255;
}
}
}compositeOver handles partial clipping only. The entirely-outside check that raises SF-5006
(Section 8.1) lives one level up, in renderDesign (core/composite/render-design.ts), which asserts
offsetX + width > 0 && offsetX < canvasW && offsetY + height > 0 && offsetY < canvasH per layer
before calling the compositor.
- Premultiplication policy: buffers are stored and exchanged as straight (non-premultiplied)
alpha everywhere in the pipeline — in
Sprite.pixels, in the composite accumulator, and in encoder input. The compositor above does the premultiply/un-premultiply arithmetic internally, per pixel, so no buffer at rest is ever premultiplied. This avoids an entire class of double- premultiplication bugs when the same buffer is reused across the WASM encoder boundary (Section 8.7) and the browserImageDataboundary (Section 8.12), both of which expect straight alpha. - Determinism: the algorithm above uses only integer/float arithmetic with no random component,
no platform-dependent rounding modes, and
Uint8ClampedArraywrites (which round-to-nearest and clamp to0..255per the Web IDL specification forUint8ClampedArray, identical in Deno's V8 and browser V8/SpiderMonkey/JavaScriptCore engines). Given the sameCompositeRequest, the output is byte-identical across runs, processes, and the server/browser boundary. This determinism is what makes content-addressed caching (Section 8.9) and the golden-image parity test (Section 8.12, Section 22.3) valid.
8.5 Gender variant resolution #
CompositeRequest.bodyis always exactlymorf(Section 3.2).asset_variants.body(Section 6.11) takes exactly those same two values — there is nobothvalue and no body-agnostic variant. A sprite shared between bodies is stored as two rows, one per body, by extraction (Section 7.6).- Resolution rule: for a chosen
asset_key, the request'sbodyand the request'sbuildId, the renderer looks up the singleasset_variantsrow matching(asset_id, body, build_id). There is no fallback lookup, because there is nothing to fall back to.
SELECT v.*
FROM asset_variants v
JOIN assets a ON a.id = v.asset_id
WHERE a.asset_key = $1
AND v.body = $2
AND v.build_id = $3; The build_id predicate is not optional. Dropping it is the exact failure Sections 9.8 and 13.7
forbid: an old permalink would silently re-render with art from a later patch. The same predicate
applies to the hue lookup (Section 8.9).
- A missing variant is not a render failure. If that query returns no row, the layer is omitted
from the composite, the omission is recorded as a
render.layer_skippedevent (Section 21.2) with the design code, slot key and asset key, and the render completes normally with the remaining layers. Failing the whole image because one cosmetic slot has no art for the selected body would make the live body toggle destructive and would turn a data gap into a broken permalink. This also matches the non-destructive body-switch behaviour Section 11.13 specifies for the designer: an incompatible slot is retained in the editor's state, contributes no layer while incompatible, and is dropped from the canonical document at Share time (Section 13.3). - A direct single-asset render request (
/render/a/..., Section 8.8) naming an asset/body pair with no variant is a different case: nothing else in that request would render, so it is answered400SF-1017("That item isn't available for the selected body.") rather than an empty image. facial_hairmale-only rule:facial_hairasset variants only ever exist withbody = m; nof-bodyfacial_hairvariant is ever imported (Section 9 rejects one if the classification pipeline proposes it, Section 7, Stage D5). A design document carrying afacial_hairentry withbody = "f"fails validation at creation time (Section 13.2.5,SF-1017) and so never reaches the compositor; a live editor state in that shape simply drops the layer per the rule above.- Body-switch handling in the live editor (choosing an asset, then switching body, when that asset has no variant for the new body) is a UI behaviour owned by Section 11.13; this section guarantees only that the render layer resolves the combination deterministically and observably, so the UI has a reliable signal to react to.
8.6 Scaling #
Scales 1, 2, 3 are produced by integer nearest-neighbour upscaling applied once, after the
full composite is finished at scale 1 (260×330). Compositing and hue math never run at any scale
other than 1.
- Why smoothing is forbidden: all source art is hand-drawn pixel art at native UO resolution. Bilinear or bicubic interpolation blurs hard pixel edges and introduces new intermediate colors not present in any hue table, which breaks the visual identity of the tool and would make per-pixel parity testing (Section 8.12) meaningless (interpolation is not perfectly reproducible across codecs/engines the way nearest-neighbour integer replication is).
- Exact algorithm: for scale factor
k(2 or 3), output pixel(x, y)for0 <= x < 260k,0 <= y < 330kequals input pixel(floor(x / k), floor(y / k)). Implemented as direct row/column replication, not a general resampling filter:
// core/composite/scale.ts
export function scaleNearest(
src: Uint8ClampedArray, srcW: number, srcH: number, k: 1 | 2 | 3,
): { pixels: Uint8ClampedArray; width: number; height: number } {
if (k === 1) return { pixels: src, width: srcW, height: srcH };
const dstW = srcW * k;
const dstH = srcH * k;
const dst = new Uint8ClampedArray(dstW * dstH * 4);
for (let y = 0; y < dstH; y++) {
const sy = (y / k) | 0;
for (let x = 0; x < dstW; x++) {
const sx = (x / k) | 0;
const so = (sy * srcW + sx) * 4;
const doff = (y * dstW + x) * 4;
dst[doff] = src[so]; dst[doff + 1] = src[so + 1];
dst[doff + 2] = src[so + 2]; dst[doff + 3] = src[so + 3];
}
}
return { pixels: dst, width: dstW, height: dstH };
}8.7 Encoding #
- WebP lossless is the default output format for all render URLs (Section 8.8) that omit an explicit extension resolution preference and for every internal/preview use. Lossless mode is mandatory (never lossy WebP) because hue-mapped flat color regions and hard pixel edges compress losslessly to a small size with zero artifacting, and any lossy quantization would defeat the golden-image parity guarantee in Section 8.12.
- PNG is produced for: (a) the
/render/*.pngextension explicitly requested, (b) the file offered by the download feature (Section 12.7), because PNG has the widest compatibility for users saving an image locally, and (c) every input to OG image generation (Section 14), becausesatoriembeds images as data URLs and PNG avoids a second WebP-decode dependency in that pipeline. PNG uses standard filtering (adaptive, encoder default) with maximum compression effort — render outputs are generated once and cached forever (Section 8.9), so encode time is not latency-sensitive on the cache-hit path. - Encoder settings:
| Format | Encoder | Mode | Effort/quality |
|---|---|---|---|
| WebP | @jsquash/webp (Section 4.1) |
lossless | quality: 100, lossless: 1, method: 6 |
| PNG | @jsquash/png (Section 4.1) |
n/a (PNG is always lossless) | encoder default (adaptive filtering, zlib level 9) |
- Expected byte sizes: at scale 1 (260×330, mostly-transparent RGBA), a typical fully-dressed
composite encodes to 8–18 KB as lossless WebP and 12–28 KB as PNG. At scale 3 (780×990), 25–55 KB
WebP and 40–90 KB PNG. A single-asset variant render (Section 8.8,
/render/a/...) is smaller, typically 1–6 KB at scale 1. These figures inform the storage growth estimate in Section 8.9 and Section 14.8. - WASM codec lifecycle:
@jsquash/webpand@jsquash/pngship WebAssembly modules that must be instantiated before use. In the long-running Deno server process, both modules are instantiated exactly once at process startup (top-level await incore/codec/png.tsandcore/codec/webp.ts, both imported by the server entrypoint before it accepts connections) and the resulting encoder/decoder handles are held in module-level singletons for the process lifetime. Encode calls are synchronous CPU-bound WASM calls with no per-call instantiation cost. In the browser (Section 8.12), the same module is lazy-instantiated on first use inside the designer island and cached onglobalThisfor the page session, since the browser only needs client-side compositing for live preview (which uses Canvas natively, Section 12.3) and rarely needs to encode — the browser path encodes only when a user explicitly triggers a client-side download fallback (Section 12.7) with no network available. - Failure mode: if WASM instantiation fails at server startup (corrupt bundle, unsupported runtime), the process exits non-zero at boot with a clear log line — this is a deploy-blocking condition, not a runtime-degraded condition, because every render request depends on the encoder.
8.8 The render URL scheme #
Three render URL shapes, all content-addressed and immutable once resolved:
| Shape | Example | What it renders |
|---|---|---|
| Design composite | /render/d/:code@:scale.:ext |
The full paperdoll for one saved design |
| Single asset variant | /render/a/:assetKey/:body/:hue@:scale.:ext |
One sprite on its own, on the standard canvas |
| Slot representative preview | /render/s/:slotKey/:body@:scale.:ext |
The slot's representative asset, at hue 0 |
The slot representative preview exists so that a caller wanting "a picture of this slot" does not
have to first discover which asset represents it. It renders exactly one sprite: the slot's
representative asset for that body, defined as the non-retired asset with the lowest
assets.display_order and, on ties, the lowest asset_key, resolved within the currently published
build — drawn at hue 0 on the same 260×330 canvas as every other render, with the same recorded
offset. Its consumer is the per-slot card on /catalog (Section 15.2). A slot with no published
asset for that body returns a valid, fully transparent 260×330 image with 200 rather than an error,
because an empty slot is a legitimate state while a catalog is being populated (Section 25.4). It
never composites more than one sprite: there is no multi-asset strip anywhere in the product, and the
per-asset tiles of the picker grid come from /render/a/... instead (Section 12.5).
:code— 10-character Crockford Base32 design short code (Section 13.4). Case-insensitive on lookup; the canonical form is lowercase, matching the lowercase-URL naming rule in Section 5.3. A request carrying uppercase letters issues a301redirect to the lowercase canonical URL.:assetKey— the asset's natural key, e.g.hair.long-wavy. Asset keys match^[a-z0-9]+(?:[-_.][a-z0-9]+)*$with a maximum length of 128 characters — the canonical pattern defined in Section 13.2.5 and enforced identically here. That alphabet contains no character requiring percent-encoding, so an asset key always appears literally in the path; a value that does not match the pattern is rejected before any lookup, which is also what keeps the object-key construction in Section 8.9 free of path traversal.:body— exactlymorf.:hue— a non-negative decimal integer,0meaning as-drawn.:slotKey— one of the 19 slot keys in the registry in Section 3.3.:scale— exactly1,2, or3.:ext— exactlywebporpng.
Parameter validation (evaluated in this order; first failure wins). Every code below is the code
the registry in Section 25.2 defines for that condition — Section 25.2 is the sole authority for
SF- codes and this table quotes it rather than assigning its own:
| Check | Failure status | Error code (Section 25.2) |
|---|---|---|
:ext not webp/png |
400 | SF-1013 |
:scale not 1/2/3 |
400 | SF-1012 |
:hue not a non-negative integer |
400 | SF-1004 |
:hue parses but is outside the allowed hue range |
400 | SF-1005 |
:body not m/f |
400 | SF-1003 |
:assetKey does not match the asset-key pattern above |
400 | SF-1006 |
:slotKey not one of the 19 slot keys in Section 3.3 |
404 | SF-2004 |
:code fails normalization or has no matching design |
404 | SF-2000 |
:assetKey well-formed but no matching asset |
404 | SF-2001 |
:assetKey/:body well-formed but no variant in the resolved build (Section 8.5) |
400 | SF-1017 |
:hue well-formed but no hues row for that index in the resolved build |
404 | SF-2002 |
:code resolves to a design removed by takedown (Section 20.8) |
410 | SF-2005 |
| Well-formed request for a retired asset or hue that still has a row in the resolved build | 200 | — (renders normally from the archived row) |
Malformed and unknown design codes are deliberately indistinguishable. Both return 404
SF-2000 with an identical body and message ("We couldn't find that design. The link may be
mistyped."). A shape check that returned 400 for a bad alphabet and 404 for a valid-but-unknown
code would be a free enumeration oracle on the highest-volume endpoint in the system, letting a
scraper filter its guess space without ever spending a database lookup. Section 13.4.1 states the
same rule for the permalink route, and the two must never drift apart.
A request outside these shapes entirely (an unknown path prefix under /render/) is a plain 404
with SF-2006 (route not found), not one of the specific codes above.
Canonical redirect rules: uppercase design codes redirect (301) to lowercase. No other
redirects exist in the render namespace — asset keys, slot keys and hue numbers are compared exactly
(case-sensitive for keys, numeric equality for hue) with no normalization beyond what is stated
above, because they are always generated in canonical form by the systems that link to them (Section
10, Section 15).
Content negotiation is not offered. The extension in the path is authoritative and the Accept
header is never consulted in this namespace. No request here can therefore produce 406.
One query parameter is recognized. /render/d/:code@:scale.png?dl=1 is the download variant owned
by Section 12.7: identical pixels, plus PNG tEXt metadata. It bypasses the cache entirely — no
design_renders row, no stored object — and is served Cache-Control: no-store. It is valid only on
the .png design-composite shape; ?dl=1 on any other shape or extension is ignored. Every other
query parameter is ignored on this namespace.
415 behaviour: :ext values outside webp/png are rejected as 400 SF-1013, not 415,
because the extension is a routing parameter parsed from the path rather than a negotiated content
type — there is no request body and no Content-Type header to be "unsupported", so 415 Unsupported Media Type never applies to this GET-only namespace. This paragraph exists to make that decision
explicit rather than leaving 415 an unstated option.
Cross-origin headers. Every /render/* response carries
Access-Control-Allow-Origin: <SKINFORGE_PUBLIC_BASE_URL> (Section 24.2) and
Cross-Origin-Resource-Policy: cross-origin. This is not for a JSON API — it is what keeps the
browser canvas untainted when images are served from a storage or CDN origin that differs from the
page origin. Without it the client compositor's getImageData read (Section 12.3) and the print
snapshot in Section 12.8 both throw SecurityError, and client-side compositing stops working the
moment a CDN is placed in front of the render routes. Every <img> and fetch the browser uses for
compositing sets crossorigin="anonymous" to complete the pair.
Method support. GET and HEAD only. Any other method is 405 with an Allow: GET, HEAD
response header.
8.9 Cache keys and storage layout #
- Cache key:
sha256(buildHash + canonicalDesignJson + scale + format)for design composites, wherebuildHashisgame_builds.build_hash(Section 6.4) for the design's recordedbuild_idandcanonicalDesignJsonis the exact canonical byte string defined in Section 13.3, read verbatim fromdesigns.canonical_json(aTEXTcolumn, Section 6.18, precisely so those bytes survive a round trip). For single-asset and slot-representative renders the same formula applies with a synthetic canonical payload ({"v":1,"assetKey":…,"body":…,"hue":…}or{"v":1,"slotKey":…,"body":…}respectively, under the same canonicalization rules: sorted keys, no whitespace, UTF-8) in place of the design JSON, still salted withbuildHashso a patch re-import (Section 9) that changes an asset's art invalidates old single-asset renders too. - Everything is resolved inside one build. Both the asset variant (Section 8.5) and the hue
(Section 8.3.2) are looked up with a
build_idpredicate equal to the design's pinnedbuild_id—asset_variantsis keyedUNIQUE (asset_id, body, build_id)andhuesis keyedUNIQUE (hue_index, build_id)(Section 6.11, Section 6.13). BecausebuildHashis a function of exactly those rows, the cache key and the pixels it addresses cannot disagree. A hue lookup that omitsbuild_idwould re-render an old design with new colours, which is the failure Sections 9.8 and 13.7 forbid outright; it is called out here because it is the single easiest predicate in the system to leave off. - Object-key prefixes — the canonical, complete set for the whole product:
| Prefix | Contents | Owner | Regenerable? |
|---|---|---|---|
catalog/ |
Normalized per-variant art from Stage D6 (.webp, .png) |
Section 7.8 | No — needs the operator's original client files |
renders/ |
Content-addressed composite, single-asset and slot renders | This section | Yes |
og/ |
Generated Open Graph cards | Section 14.5 | Yes |
imports/ |
Admin-visible import run artifacts and logs | Section 17.5.3 | Yes |
No other prefix is created by any part of the system. Backup and purge runbooks (Section 23.6, Section 23.8) operate on exactly these four names.
- Render object key:
renders/:first2/:next2/:hash.:ext, where:first2and:next2are the first four hex characters of the cache key split into two 2-character path segments (standard fan-out to avoid unbounded directory sizes on thefsstorage driver, Section 4.7).:hashis the full 64-character lowercase hex sha256 cache key. Because the key is a hash the path can never contain a traversal sequence, and the asset-key validation in Section 8.8 means no user-supplied string reaches an object key at all. - The cache row: one
design_rendersrow (Section 6.19) per cached render, carryingdesign_id(NULL for single-asset and slot renders),render_kind(design,assetorswatch),cache_key(unique),storage_key,format,scale,byte_size,content_hash,created_atandlast_served_at.storage_keyis the column name — Section 6.19 owns it, and this section uses no other name for it.content_hashis the sha256 of the encoded bytes and is what the corrupt-entry check in Section 8.14 verifies against on read. The row is the database half of the two-part cache lookup in Section 8.10. - Cache headers: every successfully resolved render response carries
Cache-Control: public, max-age=31536000, immutable(the render row of the matrix in Section 19.3) plusContent-Type: image/webporimage/pngandETag: "<cache_key>". Because the URL is content-addressed, a304onIf-None-Matchis valid and cheap (a string compare, no re-encode). - The application sets
Cache-Control, not the proxy. Values on this namespace are status-dependent, so the reverse proxy (Section 23.2) must fill in aCache-Controlheader only when the application did not set one, and must never overwrite one. A blanketimmutablerule on/render/*would keep a taken-down image alive at the edge for a year after the takedown, which is a legal exposure and not merely a caching bug. The status-dependent values are:
| Response | Cache-Control |
|---|---|
200 resolved render |
public, max-age=31536000, immutable |
404 definitive not-found (unknown code/asset/hue/slot) |
public, max-age=60 |
410 taken down (Section 20.8) |
public, max-age=86400 |
400 validation failure |
no-store |
5xx and 503/504 transient failures |
no-store |
200 ?dl=1 download variant (Section 8.8, Section 12.7) |
no-store |
- Negative caching: the short
max-age=60on definitive404s absorbs scraper and bot retry storms at the CDN without re-querying Postgres on every hit, while staying short enough that a design saved seconds ago becomes visible almost immediately. - Build publish and old links: publishing a new build (Section 9.5) changes
game_builds.build_hashgoing forward for new designs, but every existingdesignsrow keeps its originalbuild_id(Section 13.7) and therefore its original cache-key namespace. A publish never deletes or invalidatesdesign_rendersrows for prior builds — old permalinks keep resolving to the exact bytes they always have, from the same object key. No cache-busting or migration step is needed on publish; content addressing makes old and new renders coexist by construction. - Pruning derived artifacts:
design_rendersrows and their objects are regenerable and MAY be pruned (Section 14.8 does the same for OG cards); regeneration lands at the identical object key, so a pruned render is invisible to callers apart from one cold render. Thedesignsrow itself is never pruned — the only removal path for a design is the takedown process in Section 20.8, which yields410. Foreign keys on derived rows areON DELETE CASCADE(Section 6.19, Section 6.20) so that a takedown can remove the derived artifacts it must.
8.10 The render pipeline at runtime #
Request lifecycle for any /render/* URL:
- Validate — parse and validate the URL per Section 8.8. Invalid requests short-circuit here.
- Resolve inputs — look up the design (or asset/slot), body, hue and build; assemble the
would-be
CompositeRequestand compute its cache key (Section 8.9) without doing any pixel work yet. Every lookup carries thebuild_idpredicate from Section 8.5 and Section 8.9. - Cache lookup — select the
design_rendersrow with this exactcache_key. If found, issue aHEAD(or consult the short-TTL in-process existence cache, Section 8.10.1) against object storage for itsstorage_key; if the object is present, stream it with the headers from Section 8.9 and updatelast_served_at. This is the cache-hit path and is the overwhelming majority of production traffic — every previously viewed permalink, asset tile and slot preview. - Cache miss → claim — insert a
jobsrow (Section 6.21) withjob_type = 'render'anddedupe_key = <cache_key>,ON CONFLICT DO NOTHING. The arbiter is the partial unique indexjobs_dedupe_key_idx ON jobs (dedupe_key) WHERE dedupe_key IS NOT NULL AND status IN ('queued','running')(Section 6.21), so exactly one in-flight job can exist per cache key while allowing the same key to be rendered again later. If the insert is a no-op (another request already claimed this render), skip to step 6 and poll. - Composite + encode (claimer only) — load sprites (from an in-process LRU sprite cache backed
by object storage, Section 8.13) and the build's hue table (loaded into process memory at startup
and refreshed on publish, Section 8.13), run Section 8.4's compositor, Section 8.6's scaler and
Section 8.7's encoder, write the object to storage, insert the
design_rendersrow, and mark the jobsucceeded(the status vocabulary isqueued | running | succeeded | failed | cancelled, defined once in Section 6.21 — there is nodonestate). - Serve — the claimer streams its own freshly encoded bytes directly to its own HTTP response,
with no extra storage round-trip. A request that lost the claim race in step 4 polls the
jobsrow (short interval, Section 8.10.1) until it reaches a terminal status, then re-runs step 3's storage read, capped by the render timeout in step 8. - Concurrency limit — the number of simultaneous in-flight composite+encode operations (step 5)
across the process is bounded by
SKINFORGE_RENDER_MAX_CONCURRENCY(Section 24.2; default4). Requests beyond the limit queue in-process on a FIFO semaphore rather than being rejected, up to the timeout in step 8. If the in-process queue itself is saturated beyond that bound, the request is shed with503SF-5004("render queue full") andRetry-After: 2rather than being held until it times out. - Timeout —
SKINFORGE_RENDER_TIMEOUT_MS(Section 24.2; default8000) bounds the total time a request may spend from step 4 (claim) to a served response, whether it is claiming or polling. On timeout the server responds504withSF-5002("That image is taking too long to generate. Please try again.") andRetry-After: 2; the underlying job, if still the claimer's, keeps running to completion in the background so the next request — including the client's own retry — hits the now-warm cache.504rather than503because the failure is an upstream generation timeout, and because that is the status Section 25.2 assignsSF-5002.
8.10.1 Queued-request response and polling #
A request that is polling (having lost the claim race in step 6) never blocks the HTTP event loop: it
awaits the jobs row with exponential backoff starting at 50 ms, doubling to a 500 ms ceiling,
checked against the SKINFORGE_RENDER_TIMEOUT_MS deadline measured from the moment the original
request arrived, not from when polling started. There is no separate "202 Accepted, check back later"
response — because render latency at any single scale/format is small (Section 8.13's budget), the
design keeps the request open and streams the final image once it is ready, which is what <img>
tags and crawlers can actually consume. A 202 on this namespace would be rendered by every browser
as a broken image.
8.10.2 Thundering herd on a viral permalink #
When a permalink is shared widely and a burst of first-time requests for the same
:code@:scale.:ext arrives before any render exists:
- All but one request lose the
jobsinsert race (step 4) and become pollers (Section 8.10.1). Only one composite+encode operation ever runs for that exact cache key, regardless of burst size. Differentscale/extcombinations for the same design are different cache keys and therefore different jobs — but Section 8.11's pre-warming generates the common combinations at save and publish time specifically so a viral burst almost always lands on step 3 (cache hit) rather than step 4. Uncommon combinations (say@3.pngfor a design nobody pre-warmed at that scale) still collapse to a single job under this scheme. - The concurrency limit (step 7) protects the process from unrelated renders (other designs, asset tiles) being starved by the burst, since the burst itself only ever occupies one concurrency slot no matter how many HTTP requests are waiting on it.
- If the single in-flight job fails (Section 8.14), the
jobsrow is markedfailedwith an error reason; all pollers waiting on it receive the mapped error response from Section 8.14 rather than hanging until the timeout. Because the unique index in step 4 is partial onstatus IN ('queued','running'), marking the rowfailedimmediately frees the key for a new claim, so the very next request retries the render from scratch instead of the failure being cached.
8.11 Pre-warming #
This subsection owns when renders are generated. No other section decides it.
Generated eagerly, synchronously as part of the write transaction described in Section 13.5 (design save) and Section 9.5 (build publish):
- On design save: the design composite at
scale=2, format=webp— the exact variant the permalink page embeds (Section 13.8) — and atscale=1, format=png— the exact variant Open Graph generation consumes (Section 14.3). Both are rendered inline before the save response is returned. Warming the variant the page actually requests is the whole point: warming some other combination would leave the first visitor to a freshly shared permalink paying a cold render anyway. Two renders at the budgets in Section 8.13 add well under 200 ms to a save. - On build publish (Section 9.5): every
(assetKey, body, hue=0, scale=1, format=webp)single-asset render for every published asset — these are exactly the tiles the picker grid and the catalog grid request (Section 12.5, Section 15.3) — plus every(slotKey, body, scale=1, format=webp)slot representative preview (19 slots × 2 bodies = 38 renders) for the catalog overview cards (Section 15.2). This batch is bounded and known-size, and runs inside the same background job runner as import (Section 9) so it never blocks the curator's publish action.
Generated lazily — the first request triggers the pipeline in Section 8.10 — for everything else:
- All other
(scale, format)combinations of an already-warmed design (@1.webp,@3.webp,@2.png,@3.png). - Every single-asset render at a hue other than 0, and every single-asset render above scale 1.
- Any render for a design created before this rule existed, if ever restored from a backup (Section 23.6) that predates it.
This split keeps save-time and publish-time cost bounded while guaranteeing that the two hottest paths — a shared permalink and a catalog browse — never show a cold-render delay to a real visitor.
8.12 Client-side compositing parity #
The designer island (Section 11) composites live, in the browser, on every user interaction (asset
pick, hue pick, body toggle) for instant feedback, using the identical core/hue/ and
core/composite/ TypeScript modules described in this section, compiled for the browser by the same
build step that produces the Fresh island bundle. There is no separate reimplementation and no server
round-trip per interaction.
What the browser fetches, exactly. Three things, and nothing else:
- Sprite pixels, as
/render/a/:assetKey/:body/0@1.png— always hue 0, always scale 1, always PNG — one request per(assetKey, body)pair per page session, loaded withcrossorigin="anonymous"(Section 8.8). Decoded viacreateImageBitmapinto anOffscreenCanvasand read back as aUint8ClampedArraythroughgetImageData. - Sprite geometry —
width,height,offsetX,offsetYand the asset'shue_mode— fromGET /api/v1/assets?slot=<slotKey>(Section 16.8.3), fetched with the catalog data the picker already needs. The render URL deliberately carries no geometry, so this is where placement comes from. - The hue table for the current build, once, as
GET /api/v1/hues?full=true(Section 16.8.5), held in memory for the page session. Hue changes — the most frequent interaction — are therefore entirely local, with zero network round-trips after mount.
Why the two paths produce identical bytes. This is the contract that makes the whole client/server split safe, and it is buildable only because the algorithm has no hidden input:
- Same source module.
applyHue(Section 8.3.5),compositeOver(Section 8.4) andscaleNearest(Section 8.6) are one implementation, compiled twice. - Same inputs, provably. The hue index for a pixel is
r8 >> 3, read from the pixel itself (Section 8.3.3). There is no sidecar, no per-pixel metadata and no extraction-time annotation that the browser would have no way to obtain — which is precisely why the index rule is defined that way. A PNG carries the full RGBA the server composites from, so the browser holds the identical bytes the server holds. - Lossless transport. PNG is lossless, so
createImageBitmap+getImageDatain the browser and@jsquash/pngdecode on the server both recover the exact RGBA values written at extraction time (Section 7.8). The image is fetched at hue 0 so no server-side remapping has been applied to it. - Same arithmetic. Both run IEEE-754 double arithmetic with
Uint8ClampedArraywrites, which round-half-to-even and clamp to0..255identically per Web IDL — the same behaviour in Deno's V8 and in every evergreen browser engine. - Same order. Layers are sorted ascending by
z(Section 3.3) before reaching the compositor in both environments; the compositor never sorts.
Tolerance: zero. Not "visually similar", not "within N/255 per channel". The server-side render
used for the permalink's <img>, for downloads and for OG images, and the last client-side frame the
user saw before pressing Share, must match exactly, pixel for pixel, so what a user shares is
provably what they designed.
Enforcement: Section 22.3 requires a golden-image suite that runs the same fixed set of
CompositeRequest values through both the server renderer (in Deno) and a headless-browser instance
of the client renderer (via Playwright, Section 22.5), asserting
sha256(serverOutput) === sha256(browserOutput) for every fixture on every CI run. Any change to
core/hue/ or core/composite/ that breaks parity fails CI before it can merge.
Content Security Policy dependency. Client-side compositing needs 'wasm-unsafe-eval' in
script-src for the browser to compile the WebAssembly codec module, and the storage/CDN origin in
img-src and connect-src for the sprite fetches to be permitted at all. Section 20.4's public CSP
grants exactly that: script-src 'self' 'wasm-unsafe-eval' 'nonce-<per-response>',
img-src 'self' data: blob: <public storage origin> and connect-src 'self' <public storage origin>.
Without 'wasm-unsafe-eval' the compositor cannot run in Chromium-family browsers at all and the
parity test in Section 22.3 cannot pass; this dependency is stated here because it is invisible from
the security section's side.
JS-disabled / API-unavailable fallback: if OffscreenCanvas or createImageBitmap is
unavailable, or JavaScript is disabled, the designer island does not attempt client-side compositing
at all. The live preview area instead shows the server-rendered
<img src="/render/d/:code@2.webp"> for the currently-encoded URL state (Section 13.6's transient
query-string form still round-trips through a debounced navigation, Section 12.3), updating on each
change via a normal page/query update rather than canvas redraws. This is strictly a
progressive-enhancement fallback: the tool remains fully usable, just without sub-100 ms live
feedback. Section 12.3 states the debounce and request-coalescing behaviour for this path.
8.13 Performance budgets #
| Metric | Budget |
|---|---|
| Per-layer hue application, single sprite, scale 1 | < 1.5 ms server CPU (typical sprite ≤ 64×64 visible art within the 260×330 frame) |
| Full composite (19 layers max — one per slot in Section 3.3), scale 1, hue + blend, cache miss | < 25 ms server CPU |
| Full composite, scale 3 (post-composite upscale, Section 8.6) | < 10 ms additional (nearest-neighbour replication is O(pixels), no per-pixel math) |
| Encode, WebP lossless, scale 1 | < 15 ms |
| Encode, WebP lossless, scale 3 | < 60 ms |
| Encode, PNG, scale 1 | < 20 ms |
| Memory ceiling per in-flight render | 16 MB (sprite buffers + accumulator + encoder scratch, worst case scale 3 RGBA accumulator = 780×990×4 ≈ 3.1 MB, remainder is codec working memory) |
| Cold render, end-to-end (cache miss, p50) | < 60 ms |
| Cold render, end-to-end (cache miss, p95) | < 150 ms |
| Warm render, end-to-end (cache hit, storage-backed, p50) | < 15 ms |
| Warm render, end-to-end (cache hit, p95) | < 40 ms |
| Warm render, end-to-end, CDN-cached (edge hit, Section 19) | < 10 ms, network-bound, not server-bound |
These budgets assume the reference deployment topology (Section 23: single VPS, local or same-DC
object storage) and the SKINFORGE_RENDER_MAX_CONCURRENCY default of 4 (Section 24.2). They
are verified by the performance test gate in Section 22.7.
8.14 Error handling #
Every code in this table is the code the registry in Section 25.2 assigns to that condition, with the status Section 25.2 assigns it. This table quotes the registry; it does not extend it.
| Condition | Error code (Section 25.2) | HTTP | User-visible outcome |
|---|---|---|---|
Missing sprite — the asset_variants row exists in the resolved build, but its catalog/:assetKey/:body/:hash12/1.png object is absent from storage |
SF-5000 |
500 | A server-side data fault, never a 404: the asset is real and the art is broken. The response is a generic "We couldn't render that combination." error, the incident is logged at error on render.failed (Section 21.2) for staff follow-up, and a neutral placeholder image is served with Cache-Control: no-store so nothing broken is ever cached at the edge |
Unknown hue — the hue index has no hues row for the resolved build |
SF-2002 |
404 | Per Section 8.8's validation table |
| Hue table row exists but is unusable (wrong length, unreadable colors) once loaded | SF-5005 |
500 | Thrown by applyHue (Section 8.3.5); logged; retryable |
| Retired asset or hue referenced by an existing, immutable design | — (not an error) | 200 | Renders normally from the archived row. Soft-delete keeps rows renderable forever (Section 9.7), which is what makes permalink immutability true |
Asset exists in the catalog but has no asset_variants row in the build the request resolves against |
SF-2007 |
404 | Not the same as "retired": a retired asset that still has a row in the resolved build renders normally (Section 8.8, Section 16.8.4). This code fires only when the row is genuinely absent from that build |
| Design layer references an asset/body pair with no variant in the resolved build | — (not an error) | 200 | The layer is omitted, a render.layer_skipped event is logged, and the remaining layers render (Section 8.5). A direct /render/a/... request for the same pair is 400 SF-1017 instead, because nothing would remain to render |
Corrupt cache entry — object storage returns bytes that fail verification against design_renders.content_hash and byte_size on read |
SF-5008 |
500 | The design_renders row and its object are deleted in one transaction and the request re-enters the pipeline at Section 8.10 step 4 as a cache miss. If verification fails twice in a row for the same key, SF-5008 is returned to the client and the incident logged, so a systemically broken storage backend cannot drive an infinite regenerate loop |
| Object storage read or write error during a render (not a corruption — the backend is unreachable or refuses) | SF-5003 |
502 | Retryable; logged; no partial object is left behind |
| Encoder failure — the WASM encode call throws, e.g. out-of-memory on a pathological input | SF-5001 |
500 | The render job is marked failed (Section 8.10.2); no partial object is ever written, because the write happens only after a successful encode |
| A sprite's destination rectangle lies entirely outside the canvas (Section 8.1) — a corrupt recorded offset | SF-5006 |
500 | Logged for staff follow-up; partial overflow is clipped silently and is not this condition |
| Render queue saturated beyond the in-process bound (Section 8.10 step 7) | SF-5004 |
503 | Retry-After: 2; safe to retry |
| Render timeout exceeded (Section 8.10 step 8) | SF-5002 |
504 | Retry-After: 2; safe to retry, and the underlying job continues in the background so the retry is warm |
| Design removed by takedown (Section 20.8) | SF-2005 |
410 | The permanent removal path; 410 specifically so caches drop the URL |
Two rules bind every row above:
- No render failure is ever cached. Every
4xxother than the definitive not-founds in Section 8.9, and every5xx, is servedCache-Control: no-store. A one-yearimmutableheader on a failure would make a transient outage permanent from the CDN's point of view. - A placeholder is a response body, never a cache entry. The placeholder image served alongside
SF-5000is a static, neutral asset; it is not written torenders/, it gets nodesign_rendersrow, and it therefore cannot be mistaken for a real render once the underlying data fault is fixed.
9. Asset Ingestion, Versioning & Patch Re-Sync #
9.1 The Build Model #
A game_build (Section 6.4) is an immutable snapshot of one operator-run import: a labeled bundle of
every source_files, assets, asset_variants, asset_images, hues and hue_group_members row
produced by running Section 7's pipeline once against one copy of the client. "Immutable" means a build's row set never
changes once written by D1–D6 (Section 7.2); the only thing that changes on a game_builds row after
import is its status column, its build_hash (written exactly once, by the publish transaction in
Section 9.5), and the bookkeeping columns that accompany a status transition (published_at,
published_by, archived_at).
Lifecycle: draft → in_review → published → archived or rolled_back, strictly forward, never
backward as a direct write. A build enters draft the moment skinforge-cli import discover creates
its row (Section 7.1) and stays draft through D1–D6. It moves to in_review when D6 (normalize)
completes for at least one approved candidate and the operator runs skinforge-cli builds submit-review --build <id>, which is a metadata-only transition (no data changes) that unlocks the
admin console's builds screen (Section 17.10). It moves to published only through the publish
transaction in Section 9.5, which simultaneously moves the previously published build (if any) to
archived. There is no path back from published/archived/rolled_back to draft/in_review:
correcting a published build's mistakes always means importing a new build and publishing that
instead, or re-publishing an older one (Section 9.6 covers the rollback case, which is itself a
forward transition).
archived and rolled_back are both terminal and both mean "not the current build." They differ only
in how the build stopped being current: archived is ordinary supersession by a newer publish,
rolled_back is explicit withdrawal because the build was found to be broken. Keeping the two
distinct is what lets the builds screen and the audit trail show an operator, at a glance, which
builds were withdrawn rather than simply superseded.
Every asset_variants row and every hues row carries the build_id it belongs to (Sections 6.11
and 6.13), and every designs row pins its build_id at creation (Section 6.18). This is the entire
mechanism by which permalink immutability (Section 13.7) holds: a design's render never depends on
"the current published build," only on the specific build it was created against, whose asset and hue
rows are never mutated and never deleted (Section 6.33). assets.first_build_id/last_build_id
(Section 6.10) are provenance only and are not consulted on the render path.
| Status | Entered by | Catalog visible publicly? | Can be diffed against? | Can be rolled back to? |
|---|---|---|---|---|
draft |
import discover (Section 7.1) |
No | Yes, as a diff target | No |
in_review |
builds submit-review (Section 9.2 step 5) |
No | Yes, as a diff target | No |
published |
builds publish (Section 9.5) |
Yes, exactly one build at a time | Yes, as a diff source | N/A (already active) |
archived |
Automatically, when a later build is published (Section 9.5) | No, but its designs still render (Section 9.1) | Yes, as a diff source | Yes (Section 9.6) |
rolled_back |
builds rollback (Section 9.6), applied to the build being withdrawn |
No, but its designs still render (Section 9.6) | Yes, as a diff source | Yes, though doing so re-publishes a build already judged broken |
Only a published or archived build can ever have designs rows pointing at it, since a design can
only be created against the currently published build (Section 13.5); draft/in_review builds never
accumulate designs references, which is why they are safe to leave abandoned indefinitely if an
operator decides not to proceed with a particular import (no cleanup obligation, though Section 6.33's
retention policy eventually prunes their pipeline scratch rows after two years).
A build's label (Section 6.4) is operator-chosen free text but is conventionally
YYYY-MM-DD-<patch-descriptor> (e.g. 2026-09-01-patch-47), which sorts chronologically alongside
created_at and gives staff a human-recognizable anchor when scanning /admin/builds (Section 17.10)
without opening any individual row.
9.2 The Re-Import Workflow After a Game Patch #
When UO Outlands ships a client patch, the operator re-runs the full pipeline to pick up new or changed art. Step by step:
- Obtain the patched client. Outside this system entirely — the operator updates their own local UO Outlands client installation through Outlands' own patcher, exactly as any player would.
- Start a new build.
skinforge-cli import discover --client-dir <path> --build-label <label>(Section 7.1). This always creates a brand-newgame_buildsrow; there is no "update an existing build" CLI verb, because builds are immutable snapshots by design (Section 9.1). Steps 2 and 3 together are whatskinforge-cli import alldoes in one command (Section 7.9). - Run the pipeline.
skinforge-cli import run --build <id>(Section 7.9) executes D1–D6 in sequence. Because normalization (D6, Section 7.8) skips re-encoding any asset whose extracted bytes hash identically to a prior build's (Section 7.9's resumability, extended across builds by content-hash comparison rather than just within one build's manifest), a patch that changes only a handful of items still runs quickly even though every file is re-scanned. - Resolve
needs_operator_inputitems, if the exit code from step 3 was1(Section 7.9):skinforge-cli import probe --build <id>with a hand-writtenformat-note.json(Section 7.4), or accept that some items remain unresolved and will simply not appear in this build's catalog (they can be picked up in a later build once understood, without blocking publish of everything else). - Submit for review.
skinforge-cli builds submit-review --build <id>moves the build toin_review. - Diff against the currently published build. In the admin console, open
/admin/builds/:id(Section 17.10) — the diff view described in Section 9.3 — or runskinforge-cli builds diff --from <published_build_id> --to <new_build_id> --jsonfor a scriptable summary. - Review changed/added items per-item or in bulk. In
/admin/candidatesfiltered to this build (Section 17.6), staff approve or reject items per Section 9.4's approval gate. - Publish. Once every item staff cares about has been reviewed (Section 9.4 covers what happens
to anything left unreviewed),
/admin/builds/:idexposes a Publish action, orskinforge-cli builds publish --build <id>on the CLI, executing Section 9.5's transaction. - Verify.
skinforge-cli import verify --build <id>(Section 7.9) confirms everyasset_imagesrow's bytes are actually present and correctly hashed in object storage, and that the build containsbackpack.default. It is a publish precondition (Section 9.5), so in practice it runs before step 8; running it again after publish is a cheap confirmation, not a substitute. - Monitor. The admin dashboard (
/admin/dashboard, Section 17.4) shows the newly published build's summary (counts of new/changed/retired assets and hues) for the first 24 hours after publish, and the metrics and alerts in Sections 21.3 and 21.5 track render error rates for that window, since a publish is the single highest-risk operational event in the system.
9.3 Diffing Two Builds #
A diff compares a source build (typically the currently published one) against a target build
(typically a new in_review one) and classifies every asset and hue into exactly one bucket. The
bucket vocabulary is defined once, here, and is the same set of strings in the human table below, in
the --json output, in the admin diff view and in the integration tests (Section 22.9):
bucket value |
Applies to | Definition |
|---|---|---|
added |
assets, hues | asset_key (or hue_index) exists in the target build's rows but not the source build's. |
changedMinor |
assets | sha256 differs but the perceptual hash's Hamming distance is below 6 bits — a cosmetic revision. |
changedMajor |
assets | sha256 differs and the perceptual hash Hamming distance is 6 bits or more — a materially different image. |
changedMetadata |
assets, hues | Pixel or colour content identical, but display_name, slot assignment, hue name, tableStart or tableEnd differ. |
changedColors |
hues | The 32-entry colors array differs at one or more entries. |
removed |
assets, hues | asset_key/hue_index exists in the source build but not the target build. |
unchanged |
assets, hues | Exists in both with identical content. |
"Changed" in prose means any of changedMinor, changedMajor, changedMetadata or changedColors;
it is a category, not a bucket value, and never appears as a string in output. There is no
modified bucket.
Example output of skinforge-cli builds diff --from <published_build_id> --to <new_build_id> --json:
{
"fromBuildId": "01J8Z3K9QABCDEFGHJKMNPQRST",
"toBuildId": "01J9A1M2XYZ0123456789ABCDE",
"assets": {
"added": 42,
"changedMinor": 11,
"changedMajor": 6,
"changedMetadata": 3,
"removed": 2,
"unchanged": 5891
},
"hues": {
"added": 18,
"changedMetadata": 4,
"changedColors": 2,
"removed": 0,
"unchanged": 2976
},
"items": [
{ "kind": "asset", "assetKey": "hair.long-wavy-braided", "bucket": "added", "slotKey": "hair" },
{ "kind": "asset", "assetKey": "robe.plain", "bucket": "changedMinor", "slotKey": "torso_outer" },
{ "kind": "asset", "assetKey": "hat.wide-brim", "bucket": "removed", "slotKey": "head" },
{ "kind": "hue", "hueIndex": 1847, "bucket": "changedColors", "name": "Doubloon Bronze" }
]
}The items array in the full (non-truncated) output enumerates every non-unchanged entry, which is
what the admin builds screen (Section 17.10) paginates through; the summary counts at the top let
staff gauge patch size before opening the item list.
Change detection for assets: two signals, both computed at D6 (Section 7.8) and D4/D5
(perceptual hash) respectively, and stored on asset_variants.sha256 (exact content hash) and
extraction_candidates.metadata.perceptualHash (Section 7.5) carried forward into a diff-time
lookup:
- If the exact
sha256of everyasset_variantsrow for theasset_keymatches between builds, the asset is unchanged — this is the common case for a patch that touches unrelated content. - If
sha256differs but the perceptual hash's Hamming distance is below a threshold of 6 bits (of 64), the asset is changed — cosmetic revision: visually similar but not byte-identical (common after Outlands re-exports art with a different compressor or minor palette touch-up). ItsbucketischangedMinor, which the admin builds screen (Section 17.10) labels "minor" to help staff triage quickly. - If
sha256differs and the perceptual hash Hamming distance is 6 or above, the asset is changed — visual revision: a materially different image. ItsbucketischangedMajor, labelled "major". - Metadata-only differences (an asset's
display_nameor slot assignment changed between builds with identical pixel content) getbucket = 'changedMetadata', labelled "metadata", detected by comparing the D5 classification output rather than pixel hashes.
What "Changed" does to the database. This is the mechanism permalink immutability rests on, so it
is worth stating explicitly rather than leaving to inference. When an asset lands in any changed*
bucket, D6 (Section 7.8) inserts a new asset_variants row carrying the new build_id, the new
geometry and the new sha256. It does not update the previous build's row, and it cannot: the unique
constraint is (asset_id, body, build_id) (Section 6.11), so the old row and the new row coexist. The
assets row is shared across builds — it is the identity of the item, not its art — and only its
display_name, display_order and last_build_id are ever updated.
The consequence is the whole product promise. A permalink created before the patch pins
designs.build_id to the old build, the renderer resolves asset_key + body + that build_id, and
it finds the old row with the old art, forever. A permalink created after the patch resolves the new
row. Neither one can be disturbed by the other. Had asset_variants been keyed on (asset_id, body)
alone, this diff bucket would have had to overwrite a single row, and every historical permalink
referencing that asset would have silently re-rendered with art its creator never chose — a change no
user could see coming and no cache invalidation could undo.
An unchanged asset still gets a new build-scoped variant row, and its own six asset_images rows.
The storage cost is those rows, not the images: catalog objects are keyed on the variant's content
hash (Section 7.8), so unchanged art resolves to the same key, D6 skips both the re-encode and the
upload (Section 7.9), and the new rows simply point at the object that is already there. This is why
asset_images.storage_key is not unique (Section 6.12) — several builds legitimately reference one
object.
Change detection for hues: a hue gets bucket = 'changedColors' if its colors array differs at
any of the 32 entries, added if its hue_index has no row in the source build, removed if the
reverse, and unchanged otherwise. name, tableStart or tableEnd differences alone (colours
identical) get changedMetadata, same treatment as assets. Hues were already build-scoped by
(hue_index, build_id) (Section 6.13), so a colour-table change has always produced a new row rather
than an overwrite; asset_variants now behaves the same way for the same reason.
Presentation for review: the diff view groups by slot (for assets) or hue group (for hues), shows
a side-by-side thumbnail (source build's normalized image vs. target build's) for every Changed item,
and a single thumbnail with an "Added"/"Removed" badge for the other two buckets. Bulk counts per
bucket are shown at the top of /admin/builds/:id (Section 17.10) so staff can gauge the size of a
patch before diving into individual items.
9.4 The Approval Gate #
Nothing an import run produces is visible to public visitors until a staff member explicitly publishes the build (Section 9.5). Within that build, individual review is per-item, and it happens in the candidate review queue (Section 17.6):
- Every
extraction_candidatesrow that D5 did not auto-approve (below the threshold in Section 7.6) requires an explicit staff decision: Approve (moves tostatus = 'approved') or Reject (moves tostatus = 'rejected', never reaches the catalog for this build). Approving does not itself write catalog rows:assets,asset_variantsandasset_imagesare created only by D6, which the operator runs afterwards and which processes onlyapprovedcandidates (Section 7.2.6). - Hues are not gated by this queue. D4 decodes every hue in the client unconditionally and D6
writes the
huesandhue_group_membersrows for the build in the same run that writes the asset catalog (Sections 7.2.6 and 7.7). Hues have no per-item approval step because there is nothing to adjudicate: a hue is a 32-entry colour table read straight out of the client, not a classification guess. Staff curation of hues happens after the fact on the hues admin screen (Section 17.8) — renaming, re-grouping, overriding a swatch — and the diff view (Section 9.3) surfaces every added, renamed and recoloured hue for review before publish. - Display names are curated here. Part of reviewing an item is confirming its
display_namereads correctly as a catalog label and inside a generated sentence, because the alt-text generator (Section 12.10) inserts the name verbatim with no article added or removed.Long WavyandPlain Robeare good names;a Plain Robeandrobe_plainare not. This is a review criterion, not a validation rule — nothing rejects an awkward name, but the queue is where it gets fixed. - Auto-approved candidates (at or above the threshold) are not blocked on staff review to reach the
catalog within a
draft/in_reviewbuild — D6 runs on them automatically — but they remain visible in the diff view (Section 9.3) asadded/changed*items staff can still inspect and, if wrong, reject after the fact via/admin/assets/:assetKey(Section 17.7), which re-opens that specific asset for re-review even after D6 has processed it (settingretired_aton the erroneous version and letting the correct extraction, if any, take its place before the build is published). - Bulk actions:
/admin/candidates(Section 17.6) supports "Approve all filtered" and "Reject all filtered" scoped to the current filter (by slot, by confidence band, by build), so a staff member confident in a whole batch (e.g. allfootwearcandidates above 0.7 confidence) does not have to click through each one. A bulk action still writes oneaudit_logrow per affected item (Section 9.10), never a single collapsed row, so individual accountability is preserved. - What happens to unreviewed items when a build is published: any
extraction_candidatesrow stillstatus = 'pending'at publish time is leftpending— publish does not implicitly approve or reject anything. Because D6 never normalizes apendingitem, that item's underlying sprite simply does not exist as a catalogassetsrow in this build and is invisible to the public site, exactly as if it had never been extracted. It remains available for review in a later build (itsextraction_candidatesrow persists, unaffected by the publish) or can be approved retroactively, which then requires a follow-up build to actually publish it, since apublishedbuild's row set is immutable per Section 9.1 (approving a candidate after publish does not retroactively add it to the already-published build's catalog).
9.5 Publishing #
Publishing is the atomic transition of one build from in_review to published, and — in the same
transaction — the previously published build (if one exists) to archived.
Preconditions, checked by skinforge-cli builds publish --build <id> before opening the
transaction. This list is complete and it is the only list — no other section adds a publish
precondition of its own; screens that gate a Publish button check exactly these four and display which
one is unmet:
game_builds.status = 'in_review'for the target build (publishing adraftbuild directly is rejected with a clear CLI error instructing the operator to runsubmit-reviewfirst).skinforge-cli import verify --build <id>has been run successfully at least once since the last D6 run for this build (checked via animport_runsrow withstage = 'verify',status = 'succeeded', andcreated_atafter the most recentnormalizerun'sfinished_at); if not, the publish command refuses and instructs the operator to runverifyfirst, since publishing unverified storage state is exactly the failure mode Section 6.34's restore validation exists to catch, and it is cheaper to prevent than to recover from.- The build has at least one non-retired
assetsrow. A build with zero approved assets would publish an empty catalog over a working one; the publish is refused with the error code for an empty build in Section 25.2. - Every
huesrow for this build has a non-emptyname. D6 substitutesHue <index>for a blank decoded name (Section 7.7), so this precondition can only fail if something upstream wrote a blank directly, and it is cheap insurance rather than a routine gate. It is not a "staff must name every hue" rule: the hues admin screen (Section 17.8) flags hues still carrying the generated placeholder as a quality nudge, and that flag never blocks a publish.
Transaction shape (the application opens and commits the transaction; the SQL below is the statement sequence inside it, in order):
-- 1. Compute and pin the build fingerprint. This is the value Section 8.9's render cache key is
-- salted with, and it is written exactly once, here, for the life of the build.
UPDATE game_builds
SET build_hash = encode(
sha256(convert_to(
coalesce((SELECT string_agg(v.sha256, '' ORDER BY a.asset_key, v.body)
FROM asset_variants v
JOIN assets a ON a.id = v.asset_id
WHERE v.build_id = $targetBuildId), '')
|| coalesce((SELECT string_agg(h.colors::text, '' ORDER BY h.hue_index)
FROM hues h
WHERE h.build_id = $targetBuildId), ''),
'UTF8')),
'hex'),
updated_at = now()
WHERE id = $targetBuildId AND build_hash IS NULL;
-- 2. Archive the currently published build, if any.
UPDATE game_builds
SET status = 'archived', archived_at = now(), updated_at = now()
WHERE status = 'published';
-- 3. Publish the target build.
UPDATE game_builds
SET status = 'published', published_at = now(), published_by = $adminUserId, updated_at = now()
WHERE id = $targetBuildId AND status = 'in_review';
-- Guard: statement 3 must have affected exactly one row. Its WHERE clause fails, and the
-- application aborts the transaction with ROLLBACK, if a concurrent publish already moved this
-- build out of 'in_review' between the precondition check and here.
-- 4. Process retirements determined by the diff (Section 9.7): assets present in the outgoing
-- published build and absent from this one are retired, not deleted.
UPDATE assets
SET retired_at = now(), updated_at = now()
WHERE id = ANY($retiredAssetIds) AND retired_at IS NULL;
-- 5. Audit. One row for the publish, plus one row per retired asset (Section 9.10).
INSERT INTO audit_log (id, actor_id, actor_label, action, entity_type, entity_id, before, after, created_at)
VALUES ($ulid(), $adminUserId, $adminEmail, 'build.publish', 'game_builds', $targetBuildId,
$beforeJson, $afterJson, now());build_hash is sha256 over every asset_variants.sha256 in this build, ordered by
(asset_key, body), concatenated with every hues.colors array in this build ordered by hue_index.
The build_hash IS NULL guard in statement 1 makes it write-once: a rollback (Section 9.6) re-runs
this same transaction against a build that already has a hash, and must not disturb it, because every
render URL ever issued for that build is keyed on it.
The application wraps this in a single postgres.js transaction (sql.begin(...), Section 4.7) and
checks the affected-row count of statement 3; a count of 0 triggers ROLLBACK (implicit on throwing
inside sql.begin) and a 409 Conflict surfaced to the admin UI, covering the race where two staff
members attempt to publish concurrently.
game_builds_published_singleton (Section 6.4's partial unique index) is a second, database-level
guarantee against ever having two published rows simultaneously, independent of the application-level
transaction correctness above — belt and suspenders.
Cache invalidation: publishing changes which build's assets and hues are "current" for the
designer UI's default catalog view (Section 11.6) and the catalog browse pages (Section 15.3), both of
which query "assets/hues in the currently published build" rather than a specific build id. Because
those queries are dynamic (not cached at the HTTP layer beyond the s-maxage on HTML pages given in
the Cache-Control matrix in Section 19.3), a publish's effect on those pages propagates within that
window without any explicit purge. Object storage-backed render URLs (Section 8.8) are
content-addressed and never need invalidation — a
design's render URL for the previously published build's art continues to resolve correctly forever,
and a design created after publish gets a new cache key naturally, since its canonicalDesignJson
resolves against the new build's asset/hue data. The CDN purge mechanism (SKINFORGE_CDN_PURGE_URL,
Section 24.2) is invoked by the publish job only for the small set of dynamic HTML pages most
sensitive to staleness — /, /catalog, /catalog/:slotKey — as a courtesy to reduce the window
below the natural s-maxage, not because correctness depends on it. The purge endpoint and the
invalidation rules it follows are Section 19.5's.
Regeneration policy: publishing does not eagerly regenerate every design_renders or og_images
row for existing designs — those remain valid indefinitely because they are keyed off the design's
own build_id (Section 6.19's cache key includes that build's build_hash, per Section 8.9), not the
currently-published build. Only new renders requested after publish (new designs, or explicit
re-renders of old designs at a new scale/format) are affected, and they naturally use whichever build
the requesting design is pinned to. No batch regeneration job runs on publish.
9.6 Rollback #
Rollback reverts the currently published build to the immediately prior one, for use when a
publish is discovered to be broken shortly after going live (e.g. a batch of assets was approved in
error and now renders visibly wrong art on the public site).
Mechanism: skinforge-cli builds rollback --build <id> where <id> is the target build to
re-publish (normally the one that was published immediately before the currently broken one, found
via its archived_at timestamp being the most recent among status = 'archived' rows). Rollback is
the Section 9.5 publish transaction run again with the target being the older build, plus two
differences and no others:
- The build being withdrawn is moved to
status = 'rolled_back'rather than'archived', so the builds screen and the audit trail distinguish a withdrawal from an ordinary supersession (Section 6.4). - The
audit_logrow'sactionis'build.rollback'instead of'build.publish'(Section 9.10).
There is no separate rollback code path and no rollback table. The target build already has a
build_hash from its original publish, and statement 1 of the publish transaction leaves it untouched
(Section 9.5), so every render URL issued during that build's first period as the published build
still resolves to the same cached bytes.
What rollback does: makes the older build's catalog (assets, hues) the one served by the dynamic
"current build" queries again (Section 9.5's cache invalidation paragraph applies identically). The
broken build moves to rolled_back.
What rollback does not undo: any designs row created while the broken build was published
remains pinned to that broken build's build_id (Section 6.18) and continues to render using its art
forever — rollback is not a data-loss operation and never touches designs, design_renders, or
og_images rows. A rolled_back build's asset_variants and hues rows are never deleted either,
for the same reason (Section 6.33). If the broken build's art was visually wrong, permalinks created
during that window keep showing the wrong art; this is a deliberate consequence of the immutability
guarantee (Section 13.7) outweighing convenience, and is called out explicitly in the operator runbook
(Section 23.8) so staff understand rollback fixes the site going forward, not retroactively.
Guarantee: existing permalinks (any designs row, from any build) keep rendering after a
rollback, with zero exceptions — rollback only ever changes which build answers "what's currently
published," never any historical row's build_id reference or any stored render/OG image.
9.7 Asset Retirement #
When a patch's diff (Section 9.3) shows an asset as Removed relative to the currently published build, and that removal is confirmed (not rejected as a false detection) during review, the asset is retired, not deleted, at publish time:
- The
assetsrow'sretired_atis set to the publish transaction's timestamp, by statement 4 of the transaction in Section 9.5. last_build_idon theassetsrow is left pointing at the last build that did contain it (i.e. it is not bumped forward to the new build, since the new build is precisely the one that lacks it).- The asset disappears from the designer's option panel (Section 11.6) and the catalog browse pages
(Section 15.3) for any query scoped to "currently published build," because those queries filter
WHERE retired_at IS NULL(matching the partial index in Section 6.10). - Storage consequence: none of the asset's
asset_imagesrows or underlying object storage files are deleted. They remain exactly as normalized, because anydesignsrow created before retirement that references thisasset_keystill needs them at render time. The render path resolves a layer withWHERE a.asset_key = $1 AND v.body = $2 AND v.build_id = $3againstassets a JOIN asset_variants v(Sections 6.10 and 6.11) and applies noretired_atfilter at all — retirement only affects discovery surfaces, never the render path, and the build scoping is what guarantees the row it finds is the one that existed when the design was created. - UI treatment: the designer (Section 11.6) and preview surfaces (Section 12.5) never let a user
newly select a retired asset, but if a permalink whose saved design already references a retired
asset_keyis opened, the composite still renders normally and the remix flow in Section 11.10 displays a small non-blocking notice next to the affected slot ("This item is no longer available in the catalog; your saved design is unaffected") rather than silently reverting to a different asset or blocking the load — the exact copy is Section 11.14's concern, this section only fixes the rule: retirement never changes what a design renders.
If a later build re-adds an asset with the exact same underlying content (same sha256), diffing
(Section 9.3) shows it as unchanged relative to two builds back, but since it was removed in the
intervening build, the practical re-publish path clears its retired_at back to NULL rather than
creating a duplicate assets row, keyed by the stable asset_key/content match. It also writes a
fresh asset_variants row for the new build, exactly as any other asset in that build does. This
un-retire path is a normal part of D6's row-write logic (Section 7.8), not a special case requiring
staff intervention beyond the normal review.
9.8 Hue Re-Sync #
Patches can add new custom hues, rename existing ones, or change colour tables (Section 7.7 covers how D5 detects and classifies these at extraction time; this section covers what happens at publish).
- New custom hues (
bucket = 'added', Section 9.3) become newly selectable in the hue picker (Section 11.6) once the build containing them is published, with no special handling — they are simply newhuesrows with no prior history. - Renamed hues (
bucket = 'changedMetadata':namechanged, colours identical) update what text the public hue browser (Section 15.5) and designer tooltip show for thathue_indexgoing forward. Becausehuesrows are keyed(hue_index, build_id)(Section 6.13's unique constraint), the rename is a new row for the new build, not an in-place update to an old row — old builds'huesrows keep their original name forever, which matters for the next bullet. - Changed colour tables (
bucket = 'changedColors', Section 9.3) are the case that matters most for rendering correctness: a design pinned to a changed hue renders with the hue table archived in its own build, never the currently published build's version. Mechanically, this falls out of the samebuild_idpinning described in Sections 9.1 and 9.7 — the render pipeline (Section 8.3) resolves a design'sskinHue/per-slothuevalues withWHERE hue_index = $1 AND build_id = $2, where$2isdesigns.build_id, so a colour-table change in a newer build simply does not affect any existing design's rendered output. Thebuild_idpredicate is not optional:huesis unique on(hue_index, build_id)(Section 6.13), so a lookup without it returns one row per build and picking the first would render every historical design with the newest colours — the exact failure this section exists to prevent, and the reason Section 19.7 states the same rule for the hot query. A user opening the designer fresh (Section 11.6) and picking that samehue_indexvalue, by contrast, gets the new colour table, since the designer always operates against the currently published build.
Which queries filter retired_at, and which must not. The render-path query above applies no
retired_at filter at all, for exactly the reason Section 9.7 applies none to assets: retirement
governs discovery, never rendering. A hue retired by a later build still has a row in the build the
design pinned, and that row is what renders — otherwise a single later patch would blank out the skin
tone of every design created before it, which is the failure mode permalink immutability exists to
prevent. The filter belongs only to surfaces that are offering a choice:
| Query | retired_at IS NULL? |
Why |
|---|---|---|
Render path — resolving a pinned design's skinHue and per-slot hues (Sections 8.3 and 19.7) |
No | The design pinned this build; the row it pinned must resolve regardless of later retirement. |
| Designer hue picker and skin-tone rail (Section 11.6) | Yes | A visitor must not be able to newly select a hue the current build no longer carries. |
| Public hue browser and hue detail pages (Section 15.5) | Yes | Catalog discovery surface, scoped to the currently published build. |
/api/v1/hues listing (Section 16.8.5) |
Yes | Same rule as the browse surfaces it backs. |
| Admin hues screen (Section 17.8) | No — retired rows are shown, badged | Staff need to see what was retired and when. |
The identical split applies to assets (Section 9.7): the same predicate, present on discovery
queries and absent from the render path, applied to assets.retired_at instead of hues.retired_at.
- This means the same numeric hue index can visibly mean two different colours depending on which
design you look at, if a colour table changed between the two designs' respective builds. This is
the deliberate, documented consequence of prioritizing permalink immutability, called out in the
/changelogpage (Section 10.3.12) whenever a colour-table-changing build is published, so end users encountering the discrepancy have a place to find the explanation.
9.9 Idempotency and Resumability #
Re-running an import must never duplicate rows, and an interrupted import must be safely resumable from wherever it stopped. This section states the build-level guarantees; Section 7.9 states the stage-level mechanics that implement them.
- Idempotency: every write in D1–D6 is either an
INSERT ... ON CONFLICT DO NOTHING/DO UPDATEkeyed on a natural uniqueness constraint —source_files (build_id, relative_path),extraction_candidates (import_run_id, candidate_key),asset_variants (asset_id, body, build_id),asset_images (asset_variant_id, scale, format),hues (hue_index, build_id),hue_group_members (hue_id, hue_group_id), all per Section 6's unique constraints — or a lookup-then-conditional-write guarded by the same key, so running any stage twice against the same build with unchanged inputs produces zero net row changes on the second run beyond updatedupdated_attimestamps where applicable. - What idempotency does not mean: re-importing the same client as a new build is not a no-op and
is not supposed to be. Step 2 of Section 9.2 always creates a fresh
game_buildsrow, and bothhuesandasset_variantsare keyed per build, so a second import of identical data legitimately produces a full set of new rows scoped to the new build — and a diff (Section 9.3) that reports every asset and every hueunchanged. Those are the two distinct properties a test suite should assert, and Section 22.9 asserts them separately. - Resumability: Section 7.9 already specifies per-stage resumability via the manifest (Section
7.5) and per-row terminal-state checks. At the build level, this composes:
skinforge-cli import run --build <id>re-run after an interruption re-enters at whichever stage theimport_runstable (Section 6.6) shows as the latest non-succeededstage for that build, skipping stages alreadysucceededunless--force-restage <stage>is explicitly passed (used when an operator wants to deliberately redo a stage, e.g. after fixing aformat-note.json). - Safe-to-retry guarantee: every stage command can be killed (SIGKILL, power loss, out-of-memory abort per Section 7.10) at any point and re-run with identical arguments, and will converge to the same end state as an uninterrupted run. This is validated by an integration test (Section 22.4) that runs D1–D6 against a fixture client directory, kills the process at a randomized point in each stage across repeated runs, restarts it, and asserts the final row counts and content hashes match an uninterrupted control run exactly.
9.10 Audit Trail #
Every ingestion action that changes state is written to audit_log (Section 6.25), with:
action value |
entity_type |
Written when |
|---|---|---|
build.create |
game_builds |
import discover creates a new build row. |
build.submit_review |
game_builds |
Build moves draft → in_review. |
build.publish |
game_builds |
Section 9.5's transaction. |
build.rollback |
game_builds |
Section 9.6's transaction (the publish transaction with the withdrawn build set to rolled_back, and this distinct action label). |
candidate.approve |
extraction_candidates |
Single or bulk approval (Section 9.4); one row per affected candidate even for bulk actions. |
candidate.reject |
extraction_candidates |
Single or bulk rejection; reason column populated, required for rejections. |
asset.retire |
assets |
Section 9.7's retirement, written as part of the publish transaction. |
asset.unretire |
assets |
An asset's retired_at cleared back to NULL (Section 9.7's re-add case). |
hue.import |
hues |
The hues and hue_group_members rows D6 inserts for the build (D6 is the only writer of either table, Section 7.2.6), batched into one audit row per import run rather than per hue — 3,000 audit rows per build would bury every other event — summarizing counts by source, by change bucket and by hue group. |
before/after columns capture the affected row's relevant column values as JSON snapshots (for
game_builds, the status/build_hash/published_at/archived_at columns; for
extraction_candidates, the status/reviewed_by columns; for assets, the retired_at column),
giving a reconstructable history without needing a separate row-versioning table.
actor_id/actor_label record the staff member for console-driven actions, or actor_id = NULL with
actor_label = 'system:cli' or 'system:scheduled-job' for unattended CLI/cron-triggered actions
(Section 9.11), so the distinction between human and automated actions is always visible in
/admin/audit (Section 17.13).
Console-driven rows also carry ip_address and user_agent from the acting staff session, and
metadata (Section 6.25) carries action-specific extras — for a bulk approval, the batch id shared by
every row the action produced, so the audit viewer can group them back together. CLI and job-runner
rows leave ip_address and user_agent null, which is itself the signal that no browser session was
involved. Audit rows are never pruned (Section 6.33).
9.11 Scheduled and Manual Triggers, Notification, Runbook Summary #
Manual trigger: the default and expected mode — an operator runs skinforge-cli import all (or
import discover then import run) by hand after noticing an Outlands client patch. This system
has no automated patch-detection against Outlands' own release channel; patch awareness is an
operational, not a technical, responsibility (stated explicitly so no agent building this system
mistakenly scopes in a patch-watcher).
Scheduled trigger: SKINFORGE_JOB_RUNNER_ENABLED (Section 24.2) governs whether the in-process
job runner (Section 4.7) is active at all; when it is, a jobs row of job_type = 'reimport_reminder' is enqueued on a recurring run_after cadence — configured, not hard-coded, by
the reimport_reminder_cadence_days row in app_settings (Sections 6.37 and 17.14) — purely to
produce a notification (next bullet) reminding staff to check for patches. It never triggers
discover/import automatically, since starting an import against a client directory the job runner
cannot itself verify is current would risk importing stale or partial data unattended.
Notification of results: SkinForge sends no outbound email. There is no SMTP dependency in the stack (Section 4.1), no mail-server configuration in Section 24.2, and no third-party notification integration (Slack, Discord, webhooks) in v1. Notifications are in-app only.
Every import_runs row completing with status IN ('succeeded','failed'), and every
build.publish/build.rollback audit event, enqueues a jobs row of job_type = 'notify' whose
payload names the event and the target build. The notify handler's only job is to make the event
visible on /admin/dashboard (Section 17.4), and it does that without a dedicated table: the
dashboard's activity feed is rendered directly from audit_log and import_runs (Sections 6.25 and
6.6), which already hold every fact a notification would have carried. The notify job exists so the
feed's derived counters are recomputed promptly rather than on next page load.
This keeps the notification surface consistent with the single-VPS posture and with the
"configuration is environment variables, plus the six rows in app_settings" rule in Section 24.1:
there is nothing to configure, because there is nothing to send.
Operator runbook summary (the authoritative, detailed runbook lives in Section 23.8; this is the
ingestion-specific excerpt): after a patch, (1) update the local client, (2) skinforge-cli import all, (3) resolve any needs_operator_input items, (4) review the diff, (5) verify, (6) publish,
(7) watch the dashboard for 24 hours. If anything looks wrong post-publish, roll back (Section 9.6) first and
investigate second — rollback is cheap, fast, and safe, so it is always the correct first response to
a suspected bad publish rather than attempting a live fix.
9.12 Failure and Recovery Matrix #
This matrix is the ingestion-specific complement to the platform-wide error catalog in Section 25.2;
codes there (SF-6xxx, the import group) map to the rows below where an API-surfaced error applies.
| Failure | Detection | Automatic behaviour | Operator action |
|---|---|---|---|
Client directory unreadable/missing at discover |
D1 aborts immediately (Section 7.2.1) | CLI exits code 2, no build row side effects beyond the already-created game_builds row (left in draft with zero source_files) |
Fix the path or permissions, re-run import discover (creates a fresh build; the empty one can be left as-is, since it is harmless and never published). |
| A pipeline stage crashes with an unhandled exception | Process exit with non-0/1/2/3 code, or a stack trace on stderr |
The in-flight import_runs row is left status = 'running' with no finished_at — no automatic recovery |
Treated as a pipeline bug (Section 7.4 requires every anticipated failure to be handled, not thrown); operator files it and re-runs the stage after a fix. The runner detects a stale running row (started_at older than a configurable staleness window) and flags it in the admin dashboard as "possibly stuck" rather than silently leaving it. |
needs_operator_input count stays high after probe |
import_run_items/extraction_candidates counts queried after D3 |
None — this is an expected, non-blocking outcome (Section 7.4) | Write/refine format-note.json and re-run import probe/import extract for just the affected files. |
Storage upload fails mid-normalize |
D6's upload call throws; caught per-asset | That asset's transaction rolls back (Section 7.8's "single transaction per asset" rule), leaving it as if not yet normalized; other assets in the same run are unaffected | Re-run import normalize --build <id>; resumability (Section 9.9) skips already-succeeded assets and retries only the failed one. |
verify finds a hash mismatch between asset_images.content_hash and the actual stored object |
skinforge-cli import verify (Section 7.9) |
Publish is blocked (Section 9.5's precondition) | Re-run import normalize for the affected asset (forces re-upload), then re-run import verify. |
| Concurrent publish attempts by two staff members | The publish transaction's affected-row-count guard (Section 9.5) | Second attempt's transaction rolls back; API returns 409 Conflict |
Retry the publish; it will now either succeed (if the first attempt's target was different) or report the build is already published. |
| Publish succeeds but post-publish monitoring (Section 9.11) shows a spike in render errors | the error-rate metrics and alerts in Sections 21.3 and 21.5 | None automatic in v1 (no auto-rollback) | Staff-initiated rollback (Section 9.6) as the first response, per the runbook (Section 9.11), before investigating root cause. |
A format-note.json fails Zod validation |
D3 validates on every run (Section 7.4) | That file is left needs_operator_input; D3 continues with other files |
Fix the note's JSON against the schema (Section 7.4) and re-run import probe. |
Diff (Section 9.3) misclassifies an unchanged asset as changedMinor due to a re-compression artifact with no real visual change |
Staff visual review during Section 9.4's approval step | None automatic — this is why every Changed item requires a human look, even changedMinor ones |
Reject the spurious re-approval (leave the old build's asset as the effective one by not approving a change) or approve it if staff judges the difference immaterial; either is a normal review outcome, not an error state. |
10. Public Web Application — Information Architecture & Routing #
10.1 Site map #
All routes are served by the single Deno process described in Section 4. Rendering mode is one of:
SSR (fully server-rendered HTML, no island), SSR+island (server-rendered shell with one or more
hydrated Preact islands), or static (build-time or edge-cached HTML with no per-request data).
Cache policy pointers name the response class from Section 19.4.
| Route | Purpose | Rendering mode | Cache policy | Indexing |
|---|---|---|---|---|
/ |
Designer home, empty or query-string-seeded design | SSR+island | HTML class (Section 19.4) | index |
/design |
Alias of /, 301 redirect, see 10.5 |
— (redirect) | no-store | noindex (redirect target inherits) |
/d/:code |
Permalink view of a saved design | SSR+island | HTML class | index |
/catalog |
Catalog overview, slot cards | SSR | HTML class | index |
/catalog/:slotKey |
Asset grid for one slot | SSR+island (filters) | HTML class | index |
/catalog/:slotKey/:assetKey |
Asset detail page | SSR | HTML class | index |
/hues |
Hue browser overview | SSR | HTML class | index |
/hues/:hueIndex |
Single hue detail | SSR | HTML class | index |
/about |
Project background, credits | static | HTML class, long TTL | index |
/faq |
Frequently asked questions | static | HTML class, long TTL | index |
/legal |
Fan-project disclaimer, IP notice, takedown policy | static | HTML class, long TTL | index |
/support |
Donation link and thanks | static | HTML class, long TTL | index |
/changelog |
Human-readable build/import history | SSR | HTML class | index |
/sitemap.xml |
Sitemap document, see 10.6 | SSR | HTML class, 1 hour TTL | n/a |
/robots.txt |
Crawler policy | static | long TTL | n/a |
/opensearch.xml |
Browser search-engine descriptor | static | long TTL | n/a |
/render/... |
Rendered images | static (content-addressed) | Section 8.9 | n/a |
/og/d/:code.png |
Open Graph image | SSR (generated once, cached) | Section 14 | n/a |
/api/v1/... |
Public JSON API | SSR (no HTML) | Section 16 | n/a |
/admin/... |
Staff console | SSR+island | no-store | noindex, and X-Robots-Tag: noindex header |
10.2 Fresh 2.x routing implementation #
Fresh 2.x resolves routes from the file tree under web/routes/. File paths for every public route:
web/routes/
index.tsx -> /
design.tsx -> /design (redirect handler only)
d/[code].tsx -> /d/:code
catalog/index.tsx -> /catalog
catalog/[slotKey]/index.tsx -> /catalog/:slotKey
catalog/[slotKey]/[assetKey].tsx -> /catalog/:slotKey/:assetKey
hues/index.tsx -> /hues
hues/[hueIndex].tsx -> /hues/:hueIndex
about.tsx -> /about
faq.tsx -> /faq
legal.tsx -> /legal
support.tsx -> /support
changelog.tsx -> /changelog
sitemap.xml.ts -> /sitemap.xml
robots.txt.ts -> /robots.txt
opensearch.xml.ts -> /opensearch.xml
render/d/[...path].ts -> /render/d/:code@:scale.:ext (parsed in handler, Section 8)
render/a/[...path].ts -> /render/a/:assetKey/:body/:hue@:scale.:ext
render/s/[...path].ts -> /render/s/:slotKey/:body@:scale.:ext
og/d/[code].png.ts -> /og/d/:code.png
api/v1/[...path].ts -> catch-all dispatcher, Section 16
admin/index.tsx -> /admin (login)
admin/dashboard.tsx -> /admin/dashboard
admin/imports/index.tsx -> /admin/imports
admin/imports/[id].tsx -> /admin/imports/:id
admin/candidates.tsx -> /admin/candidates
admin/assets/index.tsx -> /admin/assets
admin/assets/[assetKey].tsx -> /admin/assets/:assetKey
admin/hues.tsx -> /admin/hues
admin/slots.tsx -> /admin/slots
admin/tags.tsx -> /admin/tags
admin/builds.tsx -> /admin/builds
admin/designs.tsx -> /admin/designs
admin/takedowns.tsx -> /admin/takedowns
admin/audit.tsx -> /admin/audit
admin/settings.tsx -> /admin/settings
admin/users.tsx -> /admin/users
_404.tsx -> not-found fallback
_500.tsx -> error fallback
_app.tsx -> root HTML shell (Section 10.4)
_middleware.ts -> root middleware chain
admin/_middleware.ts -> admin auth gateDynamic params ([code], [slotKey], [assetKey], [hueIndex], [id]) are typed via each route's
define.page/define.handlers generics against the Zod schemas in core/schema/. [...path]
catch-all segments are used only for the render endpoints and the API dispatcher, both of which parse
and validate the remaining segments manually against the URL grammars owned by Section 8 and Section
16 respectively.
Route groups: web/routes/admin/ is a Fresh route group gated by admin/_middleware.ts, which runs
after the root _middleware.ts and enforces the session check from Section 17.2. There is no other
route group; public routes share the root middleware only.
Middleware order (root _middleware.ts, applied to every request):
- Request-id assignment (ULID, stored on
ctx.state.requestId, echoed as headerX-Request-Id). - Trusted-proxy-aware client IP resolution (
SKINFORGE_TRUSTED_PROXY_HOPS, Section 24). - Maintenance-mode short-circuit (Section 10.9) when
SKINFORGE_MAINTENANCE_MODE=true. - Security headers injection (Section 20.4).
- Rate-limit check for
/api/v1/*and/render/*only (Section 16.7, Section 19.8). - Access log write (Section 21.1).
_404.tsx renders the 404 page (10.9) for any unmatched path. _500.tsx renders the 500 page (10.9)
for any uncaught handler error; the error is logged with requestId before rendering.
10.3 Page-by-page specification #
10.3.1 / — Designer home #
Purpose: primary entry point. Loads a blank default design, or a design seeded from the transient query string described in Section 13.6, into the designer island.
Above-the-fold layout (desktop): header (10.4) at top; below it, the three-region designer layout from Section 11.1 fills the viewport with no scroll required to see the preview stage.
Components present: global shell (10.4); Designer root island (Section 11.2 DesignerRoot) which
contains the preview stage (Section 12), the slot rail (Section 11.5), and the option panel (Section
11.6); Share button (Section 11.9); Randomize button (Section 11.8).
Server data loaded: full slot registry and the first page (60) of asset summaries for the body slot
only (id, key, name, thumbnail ref, gender compatibility) for initial hydration — the option panel
fetches each other slot's assets on demand when it becomes active (Section 11.6) — current published
game_builds row, and — when the request has a non-empty query string — the design is parsed and
validated server-side per Section 13 before being passed as the island's initial state, so first paint
already shows the seeded design with no client-side flash.
Empty state: no query string present renders the default design: body=m, skinHue=0, all optional
slots empty. Error state: an invalid or unknown query-string value (unknown assetKey, out-of-range
hue) is dropped from the parsed state field-by-field (never a full-page error) and a dismissible
banner reports which fields were reset, per Section 11.13.
Title: Outlands SkinForge — Design Your UO Outlands Character Skin.
Meta description: Design a UO Outlands character skin free, no login. Pick body, hues, hair and cosmetics, preview live, and share a permanent link.
10.3.2 /d/:code — Permalink view #
Purpose: canonical, shareable, immutable view of one saved design. Doubles as the editor entry point (11.10 Remix flow).
Above-the-fold layout: same three-region layout as /, pre-populated from the stored design, plus a
"Remix" label state (11.10) instead of a blank-canvas state.
Components present: global shell; Designer root island initialized from the server-loaded design; Share button (relabelled contextually, 11.9); a read-only summary strip showing body and slot names above the preview on first paint (server-rendered, before hydration) so no-JS visitors still see the design described in text and image (10.7).
Server data loaded: designs row by short code (case-insensitive lookup per Section 13.4), its
resolved slot list joined against assets/asset_variants/hues, and the pre-rendered composite
image reference from design_renders (Section 8.9) for the <img> fallback and OG tag.
Empty/error states: unknown code renders the 404 page (10.9). A code resolving to a design that has
been taken down (designs.taken_down_at set through the legal process in Section 20.8) renders the
410 page (10.9) instead of design content — designs are never auto-deleted; takedown is the only
removal path (Section 20.8), and it never depends on the state of the design's build. A code that
resolves but references a since-retired individual asset still renders normally: retired assets remain
servable per the immutability rule in Section 6's soft-delete convention; no error state exists for
that case.
Title: <Body> skin — <first 2 slot names, if any> · Outlands SkinForge (e.g. Female skin — Hair, Robe · Outlands SkinForge); when no optional slots are filled: <Body> skin · Outlands SkinForge.
Meta description: A shared UO Outlands character skin design. View it, remix it, or make your own at Outlands SkinForge.
10.3.3 /catalog — Catalog overview #
Purpose: browsing entry point, independent of the designer.
Above-the-fold layout: page heading, one card per slot (19 cards: body and backpack included) in a
responsive grid (10.8), each card showing slot display name, published asset count, and a
representative thumbnail; below the grid, a "Hues" entry card linking to /hues.
Components present: global shell; slot card grid (Section 15.2); no islands — this page is fully server-rendered because it carries no interactive filter state.
Server data loaded: per-slot published asset counts and one representative asset_images thumbnail
per slot (most-recently-published), and total published hue count for the hues card.
Empty state: a slot with zero published assets still renders its card, with count 0 and a
placeholder silhouette thumbnail instead of an asset thumbnail; the card remains a working link to
/catalog/:slotKey, which then shows the catalog empty state (15.7).
Title: Browse the Catalog · Outlands SkinForge.
Meta description: Browse every hair style, hue, and cosmetic slot available in Outlands SkinForge, extracted from the live UO Outlands client.
10.3.4 /catalog/:slotKey — Slot asset grid #
Purpose: filterable, searchable listing of every published asset in one slot. Full specification of grid, filters and search is owned by Section 15.3 and 15.6; this entry covers only IA and routing.
Above-the-fold layout: page heading with slot display name and count, filter panel (collapsed to a toggle on mobile, 10.8), asset grid below.
Components present: global shell; CatalogFilterBar island (Section 15.3) hydrated for
filter/sort/search interactivity; server-rendered asset grid, re-rendered via a fetch to Section 16's
catalog endpoint when filters change client-side (no full page reload).
Server data loaded: first page of published assets for the slot (default sort, default filters) for first paint; full filter facet counts (body, hue group, tags, new-in-build).
Empty/error states: unknown slotKey renders the 404 page. Zero results after filtering renders the
zero-result state (15.7), not a 404, because the slot itself is valid.
Title: <Slot display name> · Catalog · Outlands SkinForge.
Meta description: Browse all <slot display name> options for UO Outlands character skins. Preview hues, compare styles, and use any item directly in the designer.
10.3.5 /catalog/:slotKey/:assetKey — Asset detail #
Purpose: single-asset deep dive. Full content spec owned by Section 15.4.
Above-the-fold layout: large preview with hue chooser, both gender variants side by side where the asset supports both, metadata panel, primary actions.
Components present: global shell; AssetHuePreview island (Section 15.4); "Use in Designer" button
(navigates to / with the transient query string set to a single-slot design, Section 13.6); "Copy
Asset Key" button (clipboard write, toast per 11.9's toast pattern).
Server data loaded: the assets row, its asset_variants (per body), representative asset_images,
related assets (same slot, shared tags, limit 6), and first-seen game_builds label.
Empty/error states: unknown slotKey or assetKey, or an assetKey that exists but belongs to a
different slotKey than the URL states, renders the 404 page. A retired asset renders normally with a
visible "Retired" badge (15.4) — retirement is not a 404 condition.
Title: <Asset display name> — <Slot display name> · Outlands SkinForge.
Meta description: <Asset display name>, a <slot display name> option for UO Outlands character skins. Preview every hue and use it in your own design.
10.3.6 /hues — Hue browser overview #
Purpose: entry point to the hue catalog. Full content spec owned by Section 15.5.
Above-the-fold layout: hue group tabs, swatch grid for the active group.
Components present: global shell; HueGroupBrowser island (Section 15.5).
Server data loaded: all hue_groups with member counts; first group's hues rows (swatch colours,
name, index) for first paint.
Empty/error states: none — hue data is always present once a build is published; if no build has ever
been published, the page renders the "no build published yet" state (10.3.12 shared empty state,
below), which also covers /catalog and /.
Title: Browse Hues · Outlands SkinForge.
Meta description: Every hue available in UO Outlands, organized by group, with the full 32-colour ramp for each.
10.3.7 /hues/:hueIndex — Hue detail #
Purpose: single hue deep dive. Full content spec owned by Section 15.5.
Above-the-fold layout: hue name and index, 32-colour ramp, sample application on a reference asset, copy-hue-number action.
Components present: global shell; HueDetailPanel island for the copy action and reference-asset
switcher.
Server data loaded: the hues row, its hue_group_members group, and a fixed reference asset per
slot family (configured in core/hue-preview-reference.ts, one asset_key per representative
slot: hair, torso_inner, body) used to render the sample application.
Empty/error states: unknown or out-of-range hueIndex renders the 404 page.
Title: Hue <hueIndex> — <hue name> · Outlands SkinForge.
Meta description: Preview hue <hueIndex> (<hue name>) applied to hair, clothing, and skin in UO Outlands SkinForge.
10.3.8 /about — About #
Purpose: project background, relationship to UO Outlands, credits, fan-project framing (full legal wording owned by Section 20.8 and cross-linked here).
Above-the-fold layout: heading, short mission paragraph, disclaimer banner (10.4), credits list.
Components present: global shell only. No islands.
Server data loaded: none (static content compiled into the route).
Empty/error states: none.
Title: About · Outlands SkinForge.
Meta description: Outlands SkinForge is a free, unofficial fan tool for designing UO Outlands character skins. Learn how it works and who built it.
10.3.9 /faq — FAQ #
Purpose: answers to recurring questions (permalinks never expiring, no accounts, asset accuracy after patches, how to report a wrong asset).
Above-the-fold layout: heading, list of expandable question/answer pairs (native <details> elements,
no JS dependency).
Components present: global shell only. No islands.
Server data loaded: none.
Empty/error states: none.
Title: FAQ · Outlands SkinForge.
Meta description: Answers to common questions about Outlands SkinForge: permalinks, accuracy, updates after game patches, and how to report an issue.
10.3.10 /legal — Legal #
Purpose: single page hosting the full unofficial-fan-project disclaimer, intellectual-property statement, privacy stance (Section 20.7), and takedown request process (Section 20.8).
Above-the-fold layout: heading, table of contents (anchor links), sections below.
Components present: global shell only. No islands.
Server data loaded: none.
Empty/error states: none.
Title: Legal & Fan-Project Disclaimer · Outlands SkinForge.
Meta description: Outlands SkinForge's fan-project disclaimer, intellectual property notice, privacy practices, and takedown request process.
10.3.11 /support — Support #
Purpose: hosts the donation link (SKINFORGE_DONATION_URL, Section 24) and a short thank-you note.
No payment processing occurs on this site; the link is an outbound link to a third-party donation
page.
Above-the-fold layout: heading, short paragraph, prominent donation button (outbound link, rel="noopener noreferrer"), note that donations are optional and support hosting costs only.
Components present: global shell only. No islands. The donation button is server-rendered from
SKINFORGE_DONATION_URL; if that variable is empty the button is omitted and replaced with "Donations
are not currently open" text (never a broken link).
Server data loaded: SKINFORGE_DONATION_URL value only.
Empty/error states: covered above (missing env var case).
Title: Support the Project · Outlands SkinForge.
Meta description: Outlands SkinForge is free with no ads. If you'd like to help cover hosting costs, here's how.
10.3.12 /changelog — Changelog #
Purpose: human-readable history of published game builds and notable asset/hue additions, generated
from game_builds and import_runs (Section 6), for transparency after UO Outlands patches.
Above-the-fold layout: heading, reverse-chronological list of build entries, each with build label, publish date, and a short auto-generated summary (counts of new/changed/retired assets).
Components present: global shell only. No islands.
Server data loaded: published game_builds rows ordered by published_at descending, joined to
aggregate counts from the import_runs that fed each build.
Empty state (shared with /, /catalog, /hues when no build has ever been published): a full-width
banner reading "SkinForge is being set up. Check back soon." replaces the list; this is the only page
state reachable before the first admin publish action (Section 17.10).
Title: Changelog · Outlands SkinForge.
Meta description: What's new in Outlands SkinForge: game build imports, new assets, hues, and fixes.
10.4 Global shell #
Every public and admin page shares one root layout (web/routes/_app.tsx):
+----------------------------------------------------------+
| Skip-to-content link (visually hidden until focused) |
+----------------------------------------------------------+
| Header: logo/wordmark | nav: Designer · Catalog · Hues |
| · About | theme is automatic, no toggle |
+----------------------------------------------------------+
| Disclaimer banner (see below) |
+----------------------------------------------------------+
| <main id="main-content"> page content </main> |
+----------------------------------------------------------+
| Footer: nav: FAQ · Legal · Support · Changelog |
| donation link | "Unofficial fan project" line |
| | contact email (SKINFORGE_CONTACT_EMAIL) |
+----------------------------------------------------------+Skip-to-content link: first focusable element in the DOM, <a href="#main-content" class="skip-link">Skip to content</a>, visually hidden by default and shown on :focus per Section
18.5's visually-hidden pattern.
Header navigation: five items — wordmark links to /; Designer links to /; Catalog links to
/catalog; Hues links to /hues; About links to /about. Active route gets aria-current="page".
No theme toggle is present; theming is automatic per Section 18.10.
Disclaimer banner: persistent, rendered on every page directly under the header, not dismissible. Exact wording, fixed site-wide:
"Outlands SkinForge is an unofficial fan project. It is not affiliated with, endorsed by, or sponsored by UO: Outlands, Broadsword Online Games, or Electronic Arts."
This wording also appears verbatim in the footer and in the /legal page's opening statement, so the
disclaimer is textually identical and always present everywhere it appears, satisfying the "persistent
and clear" requirement from the project's scope. Section 18.11 owns the content-style rule that
produced this exact wording, quoted verbatim; this section owns its placement.
Donation link placement: footer only, labelled Support this project, linking to /support (not
directly to the outbound donation URL, so the destination always passes through the framing text on
that page first).
Footer contact: mailto: link built from SKINFORGE_CONTACT_EMAIL (Section 24), labelled Contact.
10.5 URL and state rules #
- Canonical URLs are lowercase kebab-case with no trailing slash. A request with a trailing slash
(e.g.
/catalog/) issues a 301 redirect to the slash-free form. This is enforced in root middleware before route matching. /designis a permanent alias of/: any request to/design, with or without a query string, issues a 301 redirect to/with the same query string appended unchanged.- The designer's transient query-string form (Section 13.6) is never itself canonicalized or redirected — two different query strings are two different transient states, both valid, neither indexed (10.6).
- 301 (permanent) is used for: trailing-slash normalization,
/design→/, and slot/asset key casing normalization (/catalog/Hair→/catalog/hair). No 302 is issued anywhere: the post-Share transition to/d/:codeis a client-sidehistory.pushState(Section 11.9), not an HTTP redirect. - Case-insensitive route segments:
:code(Section 13.4),:slotKey,:assetKey. All are lowercased server-side before lookup; the canonical rendered URL and all internal links always use the stored lowercase form.
10.6 SEO #
Title and meta description templates are given per-page in 10.3. General rules:
- Every page sets a unique
<title>and<meta name="description">. No page inherits a generic fallback. - Open Graph tags on every public, indexable page:
og:type(websiteon every page, including/d/:code),og:title(same as<title>),og:description(same as meta description),og:url(canonical absolute URL),og:site_name(Outlands SkinForge),og:image,og:image:width(1200),og:image:height(630), andog:image:alt(the generated sentence from Section 12.10 on/d/:code; the site tagline elsewhere).og:imagevalue: for/d/:codeit is the absolute URL to/og/d/:code.png(Section 14); for every other page it is a single static site-wide social card at/social-card.png(1200×630, committed atweb/static/social-card.png, Section 14.9 owns it; no per-page variants outside/d/:code). - Twitter tags:
twitter:card=summary_large_imageon every page,twitter:title,twitter:description,twitter:imagemirroring the Open Graph values above. - JSON-LD:
/usesWebApplicationtype (name, description, applicationCategoryDesignApplication, operatingSystemAny, offers withprice: "0")./d/:codeusesImageObjecttype (name derived from the page title, url, image pointing at the OG image,isPartOfreferencing theWebApplicationat/); noCreativeWorkmarkup is emitted./catalog/:slotKey/:assetKeyusesImageObjecttype for the primary preview image. No other route emits JSON-LD. sitemap.xml: generated per-request byweb/routes/sitemap.xml.ts, cached at the edge for 1 hour (Section 19.4). Contents: every static/marketing route (/,/catalog,/hues,/about,/faq,/legal,/support,/changelog), every/catalog/:slotKeyfor published slots, every/catalog/:slotKey/:assetKeyfor published, non-retired assets, every/hues/:hueIndexfor published hues, and every/d/:codefor designs created in the last 90 days (older designs are excluded from the sitemap to bound its size, but remain individually indexable and crawlable via internal links — see the indexability decision below).changefreqisweeklyfor/,dailyfor/changelog,monthlyfor catalog and hue pages, andneverfor/d/:codeentries (designs are immutable).lastmodusesupdated_at/published_atwhere available,created_atfor designs.robots.txtcontents (static):User-agent: * Allow: / Disallow: /admin Disallow: /api Disallow: /design Sitemap: <SKINFORGE_PUBLIC_BASE_URL>/sitemap.xmlopensearch.xml: static OpenSearch descriptor,ShortName=SkinForge,Description=Search the Outlands SkinForge catalog,Urltemplate pointing at/catalog?search={searchTerms}(Section 15.6),Image16×16 favicon reference.- Indexability decision for design permalinks:
/d/:codepages ARE indexable (index, follow). Justification: each permalink is a unique, immutable, content-rich page (a specific character skin with a real preview image) that provides genuine search value ("UO Outlands hair hue X preview") and drives organic discovery of the tool; the risk of low-value thin pages is mitigated because every permalink carries a real composited image, a text summary of its slots, and canonical URL — there is no near-duplicate content risk since each code maps to exactly one immutable design. Designs older than 90 days are dropped from the sitemap only to bound sitemap size, not de-indexed; they remainindex, followand reachable through the/catalogand/hues"used in" cross-links (Section 15.4's related-assets panel links back to designs is explicitly out of scope — no such reverse index exists — so in practice very old permalinks rely on external backlinks and direct sharing for discovery, which is an accepted tradeoff).
10.7 Progressive enhancement contract #
Works fully with JavaScript disabled:
/d/:codepermalink view: the server-rendered summary strip and the<img>composite (Section 8.10's server-side render pipeline) show the complete design. The designer island does not hydrate, so no editing controls render; in their place, the no-JS fallback below is shown./catalog,/catalog/:slotKey,/catalog/:slotKey/:assetKey,/hues,/hues/:hueIndex: fully browsable. Filters on/catalog/:slotKeydegrade to a server-rendered<form method="get">that reloads the page with query parameters (Section 15.3); search likewise degrades to a GET form./about,/faq,/legal,/support,/changelog: fully static content, no JS dependency at all.- All static preview images (
/render/...) load as plain<img>tags everywhere; they never depend on client-side canvas.
Requires JavaScript:
- The live designer at
/and the editing capability at/d/:code(slot rail interaction, option panel, hue picker, live canvas preview, undo/redo, randomize, share, URL query-string sync). - Client-side catalog filter/search refinement without a full page reload (the no-JS GET-form path above always remains available as the underlying mechanism).
No-JS fallback UI for the designer: when the DesignerRoot island's mount point renders with
JavaScript unavailable, the server still renders, inside a <noscript> block styled as the primary
content, a static read-only view: the same summary strip and composite image used on /d/:code, plus
a short message: "Enable JavaScript to use the interactive designer. You can still browse the catalog
without it." with a link to /catalog. On / with no query string and no JS, this fallback shows the
default blank design's composite (a bare human_male body, skin hue 0) instead of an empty page.
10.8 Responsive breakpoints #
Named breakpoints (Tailwind 4.x default scale, used verbatim so Section 18.4's token mapping applies without renaming):
| Name | Min width | Typical device |
|---|---|---|
base |
0px | small phones |
sm |
640px | large phones |
md |
768px | tablets portrait |
lg |
1024px | tablets landscape, small laptops |
xl |
1280px | desktops |
2xl |
1536px | large desktops |
Layout at each breakpoint:
- Designer (
/,/d/:code):base–sm— single column, preview stage on top, slot rail as a horizontal scroll strip below it, option panel as a bottom sheet (Section 11.12) triggered by selecting a slot.md–lg— two columns: preview stage + slot rail on the left (slot rail becomes a vertical list), option panel as a fixed-height side panel on the right that is always visible.xl+ — three columns as in Section 11.1's desktop wireframe: slot rail, preview stage, option panel, all simultaneously visible with no overlay behavior. - Catalog (
/catalog/:slotKey):base–sm— single column grid (2 cards per row), filter panel collapsed behind a "Filters" toggle button opening a full-screen sheet.md— 3 cards per row, filter panel still collapsed.lg+ — 4 cards per row, filter panel permanently visible as a left sidebar. - Permalink (
/d/:code): same layout rules as the designer at each breakpoint; the read-only summary strip sits above the preview stage at all breakpoints.
10.9 Error pages #
| Page | HTTP status | Trigger | Copy | Behaviour |
|---|---|---|---|---|
| 404 | 404 | Unmatched route; unknown :code, :slotKey, :assetKey, :hueIndex |
Heading "Page not found". Body: "That page doesn't exist, or the link may be broken." Links: "Go to the designer" (/), "Browse the catalog" (/catalog). |
Global shell renders around it; X-Robots-Tag: noindex header set. |
| 410 | 410 | :code resolves to a design with designs.taken_down_at set (SF-2005; legal process in Section 20.8) |
Heading "This design is no longer available". Body: "This design has been removed following a rights request." Link: /legal. |
No image or design data is included in the response body; X-Robots-Tag: noindex. |
| 429 | 429 | Rate limit exceeded on /api/v1/* or /render/* (Section 16.7, 19.8) reached via direct navigation (rare; normally only fetch calls hit this) |
Heading "Too many requests". Body: "You've hit a rate limit. Please wait a moment and try again." | Response includes Retry-After header; page auto-retries the triggering fetch client-side once the header's interval elapses, if the trigger was a fetch rather than navigation. |
| 500 | 500 | Uncaught handler exception | Heading "Something went wrong". Body: "An unexpected error occurred. It has been logged. Please try again." Includes the requestId in small text for support reference. |
Error and requestId logged server-side per Section 21.1 before the page renders; no stack trace ever reaches the response body. |
| Maintenance | 503 | SKINFORGE_MAINTENANCE_MODE=true (Section 24) |
Heading "SkinForge is temporarily down for maintenance". Body: "We'll be back shortly. Thanks for your patience." | Root middleware short-circuits every route except /admin/* and static assets, returning this page with Retry-After: 300 and Cache-Control: no-store. |
All error pages use the same global shell (10.4) so the disclaimer and navigation remain present.
10.10 Analytics events recorded server-side per page #
Recorded as structured log lines consumed by the aggregate counters in Section 21.2; no client-side analytics script exists (Section 21.6 and Section 20.7 both prohibit one).
| Page/route | Event | Notable fields |
|---|---|---|
/ |
page_view_designer |
hasSeedQuery: boolean |
/d/:code |
page_view_design |
code, isRemixEntry: boolean (always true for this route) |
/catalog/:slotKey |
page_view_catalog_slot |
slotKey, filterCount |
/catalog/:slotKey/:assetKey |
page_view_asset_detail |
slotKey, assetKey |
/hues/:hueIndex |
page_view_hue_detail |
hueIndex |
| any route | http_error |
status, requestId, path |
Every event additionally carries the fields common to all server log lines defined in Section 21.1
(timestamp, requestId, route, method, status, durationMs). No IP address, user agent
string, or any other client-identifying field is included in analytics events; only the access log
(Section 21.1) retains a truncated, hashed IP for abuse detection, per Section 20.7.
11. Designer UI — Components, State & Interaction #
11.1 Screen anatomy #
Three regions: slot rail (choose which slot is being edited), preview stage (live composite and per-slot static previews, Section 12), option panel (assets and hues available for the selected slot).
Region proportions by breakpoint (breakpoint names per Section 10.8):
| Breakpoint | Layout | Slot rail | Preview stage | Option panel |
|---|---|---|---|---|
base–sm |
stacked | horizontal scroll strip, 72px tall, under preview | full width, fixed 1:1 aspect area at top | bottom sheet overlay (Section 11.12), hidden until a slot is tapped |
md–lg |
two column | vertical list, fixed 88px wide, left column | remaining left-column height, right of rail | right column, 360px fixed width, always visible |
xl+ |
three column | vertical list, 96px wide, column 1 | column 2, flexible width, min 420px | column 3, 380px fixed width, always visible |
ASCII wireframe, desktop (xl+):
+--------+-----------------------------------+------------------------+
| Slot | | Option Panel |
| Rail | Preview Stage | [Search within slot] |
| [body] | +---------------------------+ | [Sort: Name v] |
| [tatt] | | | | +----+ +----+ +----+ |
| [foot] | | live composite canvas | | |thmb| |thmb| |thmb| |
| [legI] | | 260x330 upscaled | | +----+ +----+ +----+ |
| [torI] | | | | +----+ +----+ +----+ |
| [torM] | +---------------------------+ | |thmb| |thmb| |thmb| |
| [arms] | [Static per-slot preview row] | +----+ +----+ +----+ |
| ... | [Undo][Redo][Reset] | === Hue picker(pinned)|
| | [Randomize] [Share] | |
+--------+-----------------------------------+------------------------+The hue picker (bottom row of the option panel above) is pinned to the panel's bottom edge and stays visible while the asset grid above it scrolls independently (11.6).
ASCII wireframe, tablet (md–lg):
+--------+--------------------------+------------------+
| Rail | Preview Stage | Option Panel |
| (icons | live composite | (always visible, |
| only) | static preview row | 360px column, |
| | [Undo][Redo][Reset] | hue picker |
| | [Randomize] [Share] | pinned bottom) |
+--------+--------------------------+------------------+ASCII wireframe, mobile (base–sm):
+---------------------------------------------+
| Preview Stage |
| live composite canvas |
| [Undo][Redo][Reset] |
| [Randomize] [Share] |
+---------------------------------------------+
| < [body][tatt][foot][legI][torI] ... > <- scroll
+---------------------------------------------+
(tapping a slot opens the bottom sheet)
+---------------------------------------------+
| ============ bottom sheet ================ |
| [Search] [Sort v] |
| [thmb][thmb][thmb] |
| === Hue picker (pinned to sheet bottom) === |
+---------------------------------------------+11.2 Component/island tree #
All designer components live under web/islands/designer/ (islands) and web/components/designer/
(server-only presentational components). Only DesignerRoot is mounted as a Fresh island; everything
under it is a plain Preact component tree hydrated within that one island boundary — Fresh does not
nest separate islands inside it, to avoid duplicate hydration boundaries and state-sharing friction.
| Component | Kind | Props (TypeScript) | Children | State location |
|---|---|---|---|---|
DesignerRoot |
island | { initialDesign: Design; slots: SlotSummary[]; assetIndex: AssetSummary[]; mode: "create" | "view-code"; code?: string } |
SlotRail, PreviewStage, OptionPanel, ShareButton, RandomizeButton, UndoRedoControls, ResetButton |
owns the root DesignState signal (11.3) |
SlotRail |
plain | { slots: SlotSummary[]; activeSlot: SlotKey; filledSlots: Set<SlotKey>; bodyCode: BodyCode; onSelect: (slot: SlotKey) => void } |
SlotRailItem × 19 |
reads root signal, no local state |
SlotRailItem |
plain | { slot: SlotSummary; isActive: boolean; isFilled: boolean; isDisabled: boolean; onSelect: () => void } |
icon, label, badge | none |
BodyToggle |
plain | { value: BodyCode; onChange: (b: BodyCode) => void } |
two role="radio" segments (Male/Female) |
reads root signal, child of PreviewStage |
PreviewStage |
plain | { design: DesignState; scale: 1 | 2 | 3 } |
LiveCompositeCanvas, StaticPreviewRow (Section 12 owns both), BodyToggle |
reads root signal |
OptionPanel |
plain | { activeSlot: SlotKey; design: DesignState; assetIndex: AssetSummary[] } |
AssetSearchBar, AssetSortControl, AssetGrid, HuePicker |
local signal for search text, sort order, pagination cursor |
AssetSearchBar |
plain | { value: string; onChange: (v: string) => void } |
text input | none, controlled |
AssetSortControl |
plain | { value: SortOrder; onChange: (v: SortOrder) => void } |
segmented control (18.5) | none, controlled |
AssetGrid |
plain | { items: AssetSummary[]; selectedAssetKey: string | null; onSelect: (assetKey: string | null) => void; hasMore: boolean; onLoadMore: () => void } |
AssetGridItem ×N, "None" tile, load-more control |
none, controlled |
AssetGridItem |
plain | { asset: AssetSummary; isSelected: boolean; onSelect: () => void } |
thumbnail, label | none |
HuePicker |
plain | { value: number; onChange: (hue: number) => void; recentHues: number[] } |
group tabs, swatch grid, search input | local signal for active group tab, search text |
ShareButton |
plain | { design: DesignState; existingCode?: string } |
button, toast trigger | local signal for request status (idle|pending|done|copy-failed|error) |
RandomizeButton |
plain | { onRandomize: (seed: string) => void } |
button | none |
UndoRedoControls |
plain | { canUndo: boolean; canRedo: boolean; onUndo: () => void; onRedo: () => void } |
two icon buttons | none |
ResetButton |
plain | { onReset: () => void } |
button (secondary variant, 18.5) |
none |
DescribeDesignButton |
plain | { onActivate: () => void } |
icon button, aria-label="Describe this design" |
none; moves focus to the sr-only summary and triggers a polite announcement (Section 18.9) |
11.3 State model with Preact Signals #
Root type, owned by DesignerRoot as a single Signal<DesignState>:
// core/schema/design-state.ts
export interface DesignState {
body: "m" | "f";
skinHue: number; // 0 = as-drawn
slots: Partial<Record<SlotKey, { assetKey: string; hue: number }>>;
// slots omits keys entirely for empty slots, mirroring the canonical JSON in Section 13.2
}
export interface DesignerUiState {
activeSlot: SlotKey;
recentHues: number[]; // max 8, most-recent-first, session-scoped only
history: { past: DesignState[]; future: DesignState[] };
}Signals, all created inside DesignerRoot with @preact/signals:
designSig: Signal<DesignState>— the authoritative design.uiSig: Signal<DesignerUiState>— transient UI state, never persisted, never part of the design hash.- Derived (via
computed()):filledSlotsSig(theSet<SlotKey>of non-empty slots, for the rail badge and option panel "None" highlighting),compositeLayersSig(the z-ordered array of{ slot, assetKey, hue }consumed by the live canvas, Section 12.2),canShareSig(trueoncebodyis set — always true, since body has a default — kept as a computed for future validation hooks),canUndoSig/canRedoSig(fromuiSig.value.history).
Update actions, all pure functions (state: DesignState) => DesignState applied via designSig.value = action(designSig.value, ...), each pushing the pre-action state onto uiSig.value.history.past
(subject to the coalescing rules in 11.7) and clearing history.future:
| Action | Signature | Rule |
|---|---|---|
selectBody |
(state, body: "m" | "f") => DesignState |
Sets body. No slot is ever cleared, facial_hair included: a slot whose selection is incompatible with the new body is retained and flagged per Section 11.13, contributes no layer to the composite (Section 8.5), and is restored automatically on switching back. A still-incompatible entry is dropped from the canonical document at Share time (Section 13.3), never from editor state. |
setSkinHue |
(state, hue: number) => DesignState |
Sets skinHue. No range validation here; validation happens at the hue picker input boundary (11.6), which only ever offers valid hues. |
selectAsset |
(state, slot: SlotKey, assetKey: string | null) => DesignState |
assetKey !== null sets { assetKey, hue: state.slots[slot]?.hue ?? 0 } (preserves a previously chosen hue for the slot if one exists in the current session, otherwise defaults to 0). assetKey === null is equivalent to clearSlot. |
setSlotHue |
(state, slot: SlotKey, hue: number) => DesignState |
No-op if the slot is empty (there is nothing to hue). Otherwise updates slots[slot].hue. |
clearSlot |
(state, slot: SlotKey) => DesignState |
Deletes the slot key from slots entirely. |
randomize |
(state, seed: string) => DesignState |
Full algorithm in 11.8. Replaces body, skinHue, and all slots in one action (one history step). |
undo |
(uiState) => { state, uiState } |
Pops history.past, pushes current designSig.value onto history.future, sets designSig.value to the popped state. No-op if past is empty. |
redo |
(uiState) => { state, uiState } |
Symmetric: pops history.future, pushes current onto history.past. No-op if future is empty. |
reset |
(state) => DesignState |
Returns the hard-coded default design: { body: "m", skinHue: 0, slots: {} }. Counts as one history step. |
undo/redo are implemented outside the plain-reducer table above because they mutate uiSig as
well as designSig; they are exposed as two additional functions in
web/islands/designer/actions.ts — not core/, since Preact Signal types are a framework import and
Section 4.5's dependency rule keeps core/ free of them — with signature (designSig, uiSig) => void
that perform both writes atomically inside a batch() call from @preact/signals, so subscribers see
one combined update.
Supporting types referenced throughout the props table in 11.2 and the state model above:
// core/domain/slots.ts — the slot registry, z-order and SlotKey type live here; also exports the
// Zod slot-key validator. core/schema/design.ts imports this rather than redeclaring it (Section 13).
export type SlotKey =
| "body" | "tattoo_body" | "footwear" | "legs_inner" | "torso_inner" | "torso_middle"
| "arms" | "gloves" | "waist" | "legs_outer" | "torso_outer" | "neck" | "hair"
| "facial_hair" | "face" | "earrings" | "head" | "cloak" | "backpack";
// order and membership fixed by the z-order table in Section 3; never extended without a schema
// migration and a corresponding update to Section 3's canonical table.
export type BodyCode = "m" | "f";
export type SortOrder = "name" | "newest" | "most_used";
// core/schema/slot-summary.ts
export interface SlotSummary {
key: SlotKey;
displayName: string;
zOrder: number; // matches Section 3's z column
isHueable: boolean; // always true per Section 3, kept explicit for forward compatibility
genderScope: "both" | "male" | "female";
publishedAssetCount: number;
}
// core/schema/asset-summary.ts
export interface AssetSummary {
assetKey: string;
slot: SlotKey;
displayName: string;
bodies: BodyCode[]; // which bodies have a published variant
thumbnailUrl: string; // resolves to a Section 8 render URL at hue 0
isRetired: boolean;
firstSeenBuildLabel: string;
useCount: number; // backs the "Most Used" sort, Section 21.2
}SlotSummary.genderScope is the single source the rail (11.5) and the body-switch validation
(11.13) both read to decide whether a slot is disabled or merely flagged; it is computed server-side
once from the slot registry in Section 3.3 and shipped as part of the initial slots prop, so no
client-side logic hard-codes the facial_hair exception outside this field.
11.4 URL synchronization #
designSig is mirrored to the query string in the transient form owned by Section 13.6
(?b=m&s=1002&hair=hair.long-wavy:1102&…). Sync is one-directional on load (URL → state, parsed once
at DesignerRoot mount from initialDesign, which the server already parsed per Section 10.3.1) and
one-directional on change thereafter (state → URL), to avoid feedback loops.
- A
useEffectinsideDesignerRootsubscribes todesignSigand callshistory.replaceState(null, "", url)with the freshly serialized query string. - Debounce: 250ms, reset on every
designSigwrite, using a singlesetTimeoutheld in a ref (not a signal, since it is not rendered). Hue-drag interactions (11.7) can fire many writes per second; the debounce collapses them to one URL write per pause. - History entries:
replaceStateis used for every routine edit (body change, asset pick, hue change) so the browser back button does not step through every micro-edit.pushStateis used exactly once per designer session: on a successful Share (11.9), navigating to/d/:code, so the back button from a shared permalink returns to the pre-share editing state rather than exiting the site. - Back-button behaviour: because routine edits use
replaceState, pressing back from/mid-edit leaves the designer (browser history has no intermediate designer states to step through) — this is intentional; the transient query string is not meant to build a linear undo history in the URL, only inuiSig.history(11.3), which persists across areplaceStatesince it does not reload the page.
11.5 Slot rail #
Ordered list: exactly the 19-row z-order table from Section 3 (body through backpack), rail order
top-to-bottom matches that z-order regardless of visual composite order elsewhere.
Icon/label treatment: each SlotRailItem shows a 24×24 line icon (Section 18.6) plus a text label —
inline text label at xl+ (the icon and label sit side by side, matching Section 11.1's xl
wireframe); at base–lg the label is not rendered inline (keeping the rail narrow) and the icon
instead carries an aria-label plus a tooltip on hover/focus, per Section 18.5's Tooltip rules.
States:
- Selected:
activeSlot === slot.key. Accent-coloured left border (4px) and background tint (Section 18.2accenttoken at 12% opacity). - Filled: slot has a non-empty entry in
designSig.value.slots(or isbody, which is always "filled"). Shows a small filled-dot badge in the icon's bottom-right corner. - Empty: default state, no badge, muted icon colour (
text-mutedtoken). - Disabled: only
facial_haironbody === "f". Rendered at reduced opacity (Section 18.2 disabled treatment),aria-disabled="true",tabindex="-1", not reachable by arrow-key navigation (skipped in the traversal order below), with a tooltip on hover/focus reading "Not available for female bodies."
Badge counts: the badge is a presence dot, not a count (there is at most one asset per slot); no numeric badges appear on the rail.
Keyboard navigation: the rail has role="listbox" with aria-orientation matching its current axis
("vertical" at md+, "horizontal" at base–sm); each SlotRailItem has role="option" and
carries aria-selected reflecting activeSlot. Focus is managed with roving tabindex: the active
item is tabindex="0", every other item tabindex="-1", so the rail is a single Tab stop.
ArrowDown/ArrowRight moves focus and selection to the next non-disabled item; ArrowUp/ArrowLeft
to the previous; Home jumps to body (first item); End jumps to backpack (last item). Selection
follows focus (single-select listbox pattern) — moving focus immediately calls onSelect, matching
the visible highlight to the keyboard position with no extra confirm step, since selecting a slot has
no destructive effect.
11.6 Option panel #
Asset grid: thumbnail source is the slot swatch/asset static preview described in Section 12.5
(/render/a/:assetKey/:body/0@1.webp at the current body, always hue 0, with
/render/a/:assetKey/:body/0@2.webp as the high-DPI srcset entry — the same contract Section 12.5
owns, and exactly the variant Section 8.11 pre-warms at publish — grid thumbnails always show the
as-drawn hue regardless of the currently selected hue, so browsing hues does not have to re-fetch every
thumbnail).
Search-within-slot: AssetSearchBar issues a debounced (250ms) request to GET /api/v1/assets?slot= <slotKey>&q=<text>&limit=60 (Section 16) and replaces the grid with the result; there is no
client-side index, so search behaves identically whether the slot has ten assets or several hundred.
This reuses the same query shape as the standalone catalog's search (Section 15.6) with the results
scoped to one slot and rendered at the option panel's smaller tile size.
Sort order: AssetSortControl offers Name (A-Z) (default), Newest, Most Used, mirroring
Section 15.3's sort vocabulary for consistency; "Most Used" uses the same stat_counters aggregate
Section 21.2 owns.
"None" option: always the first tile in the grid, a dashed-border placeholder tile labelled "None",
selected when the slot is empty; selecting it calls clearSlot. Not shown for the body slot (body
is mandatory and has no "None").
Pagination: infinite scroll, not a page-numbered control, because the option panel is a compact
secondary surface where "load more" friction should be minimal. Page size 60 assets per fetch (larger
than the standalone catalog's 24, since grid tiles here are smaller). Triggered by an
IntersectionObserver on a sentinel element 400px before the grid's end; while a fetch is in flight a
skeleton row (18.5) of 6 placeholder tiles appears at the bottom.
Hue picker: rendered in a sticky region pinned to the bottom of the option panel (and of the bottom
sheet at base–sm), always visible regardless of asset-grid scroll position, with the asset grid
scrolling independently above it — shown whenever the active slot is non-empty (or always for the
implicit skinHue control when activeSlot === "body", which shows only the hue picker; the body
itself is chosen via the preview-stage body toggle, Section 12.2). Structure:
- Grouped tabs across the top: one tab per
hue_groupthat contains at least one hue applicable to the active slot (theskingroup only appears whenactiveSlot === "body"), plus an always-present "As-drawn" pseudo-tab that is not a group but a single always-first swatch representing hue0. - Swatch grid below the active tab: one swatch per hue in that group, 24×24px, filled with the hue's
resolved swatch colour —
swatch_override_argbwhen non-null, otherwiseswatch_color, which import computes from table index 16, the visual midpoint of the ramp (Sections 6.13 and 7.7). The API ships the resolved value on every hue resource (Section 16.8.5), so the swatch is still a pure CSSbackground-colorwith zero extra requests. Selected swatch gets a 2px accent ring. - Search by hue number and name: a text input above the swatch grid; digits match
hueIndexprefix, text matches hue name substring (case-insensitive); matches from any group, and a match jumps the active tab to that hue's group. - Recently used: a "Recent" row above the tabs, populated from
uiSig.value.recentHues(max 8, most recent first, session-only — never persisted to the URL or storage), shown only when non-empty. Selecting any hue anywhere unshifts it into this list (de-duplicated). - "As-drawn": hue
0, always the first swatch, labelled "As-drawn" instead of a hue number.
11.7 Undo/redo #
Stack depth: 50 steps (uiSig.value.history.past capped at 50 entries; oldest dropped on overflow).
Applies uniformly to past and future.
What counts as one step: one call to any action in the 11.3 table, with one exception (coalescing,
below). randomize and reset are each one step despite changing multiple fields, because they are
issued as single atomic actions.
Coalescing rules for rapid hue changes: setSkinHue and setSlotHue calls are coalesced into the
in-progress history step when they occur within 400ms of the previous history push AND target the
same field (same slot for setSlotHue, or both setSkinHue). This makes dragging a hue slider or
rapidly clicking through swatches produce one undo step per "session" of interaction rather than one
per intermediate value. Coalescing is implemented by comparing a lastPushMeta: { field: string; at: number } | null kept alongside the history stacks (not itself a signal, a plain ref): if the
incoming action matches lastPushMeta.field and Date.now() - lastPushMeta.at < 400, the top of
past is replaced instead of a new entry pushed; otherwise a new entry is pushed and lastPushMeta is
updated. Any other action type resets lastPushMeta to null, ending the coalescing window.
11.8 Randomize #
Triggered by RandomizeButton. Algorithm, deterministic given a seed so results are reproducible for
support/debugging:
- Generate a seed: an 8-character Crockford-Base32 string from 40 random bits
(
crypto.getRandomValues), unless a seed is supplied programmatically (used only in golden-image tests, Section 22.3). - Seed a deterministic PRNG (
xorshift32, implemented incore/util/rng.ts) with the seed string's hash. - Body: uniform choice between
"m"and"f"— 50/50. - Skin hue: uniform choice among the
skin-tagged hue group's members (Section 3's skin hue subset), plus a 10% chance of0(as-drawn) — i.e. weight0.9spread uniformly across skin hues, weight0.1on as-drawn. - Every optional slot except
facial_hairparticipates with independent 70% probability of being filled (30% chance of staying empty), drawn from the PRNG per slot in z-order. facial_hairparticipates only if body resolved to"m"in step 3, with the same 70% fill probability.- For each participating slot, the asset is a uniform choice among that slot's published,
non-retired, body-compatible assets. If a slot has no such asset, it resolves to empty (or, for
backpack, to the default leather backpack,backpack.default, at hue 0). - For each participating slot's chosen asset, the hue is drawn with weights: 40% as-drawn (
0), 60% uniform among all hues belonging to any hue group applicable to that slot (not restricted to the skin group, since onlybodyuses the skin group). - The resulting
DesignStatereplaces the current design in a singlerandomizehistory step (11.7);uiSig.value.activeSlotis left unchanged (randomize does not change which slot's panel is open).
Reproducibility: the client logs the generated seed to the browser console (development builds only,
gated by SKINFORGE_ENV !== "production") and the seed is never sent to the server or stored, since
randomize results are not required to be reproducible across sessions — only within one page load's
support/debug workflow.
11.9 Share flow #
ShareButton posts the current designSig.value to the design-creation endpoint owned by Section 16
(POST /api/v1/designs). Request body is the canonical design JSON derived from designSig.value
per Section 13.3.1's canonicalization rule (slots sorted by z-order, empty slots omitted, hue 0 kept
explicit). The button's local status signal drives its label: idle → "Share", pending → "Sharing…"
(spinner, disabled), done → briefly "Copied!" before reverting to "Share" after 2 seconds,
copy-failed → "Copy manually" (the fallback input is focused and pre-selected), error → "Try Again".
On success, the server returns { data: { code: string, url: string, isNew: boolean } }. The client:
- Writes
url(an absolute URL) to the clipboard vianavigator.clipboard.writeText. - Fallback when the Clipboard API is unavailable or throws (older browsers, insecure context,
permission denied): sets status to
copy-failed, and renders a temporary read-only text input pre-filled and pre-selected with the URL, inside the toast itself, so the user can manually copy with the browser's native copy command. - Shows a toast (18.5): "Link copied to clipboard" on success, or "Copy this link" (with the fallback
input) when the Clipboard API path failed (
copy-failedstatus). - Issues
history.pushState(null, "", url)— the onepushStatecase from 11.4 — so the address bar now shows/d/:codewithout a full page navigation/reload.
On failure, the request itself can reject two ways: a network/server error sets status error (button
label "Try Again", toast "Couldn't create your link. Check your connection and try again."), or a
429 rate-limit response (the design-create bucket, Section 16.7) sets the same error status with
toast "You've created a lot of links just now. Try again in a few minutes." (Section 25 owns the
SF-3002 mapping this response carries).
Idempotency: when the posted canonical JSON already matches a stored design exactly, the server
returns the existing row's code with isNew: false (Section 13.5's collision rule: identical
canonical JSON never creates a duplicate row). The client treats isNew: false identically to
isNew: true in the UI — same toast, same clipboard write — the flag exists for analytics only
(Section 21.2), not for differing UI copy, so re-sharing an unchanged design is silent and friction-free.
11.10 Remix flow #
Opening /d/:code loads that design into the same DesignerRoot used at /, with mode: "view-code" and code set to the loaded value. Any edit action (11.3) immediately and silently
transitions the session to an unsaved-changes state: the ShareButton's existingCode prop is
cleared internally the moment designSig diverges from the design that was loaded, so pressing Share
after an edit always performs a fresh POST /api/v1/designs (never a PATCH — designs have no update
endpoint, Section 16, because they are immutable) and therefore always yields either the same code
(if the edit round-trips back to an identical canonical JSON, which is treated as the idempotent case
in 11.9) or a brand-new code. The original design row is never mutated by this flow; there is no
server-side concept of "editing" a stored design, only creating new ones.
UI copy: immediately below the summary strip on /d/:code (10.3.2), a small text line reads:
"Editing this design creates a new link when you share — the original stays unchanged." This line is
removed from view (or its layout slot repurposed for the standard designer copy) as soon as the design
diverges from the loaded one and a fresh Share has not yet happened, replaced by "You're remixing
this design. Share to get your own link." — reinforcing that the currently visible state is no longer
the saved one.
11.11 Keyboard shortcuts, focus management, focus trapping, Escape #
Global shortcuts (active whenever no text input has focus):
| Key | Action |
|---|---|
Alt+R (Option+R on macOS) |
Randomize |
Ctrl+Z / Cmd+Z |
Undo |
Ctrl+Shift+Z / Cmd+Shift+Z, and Ctrl+Y |
Redo |
Alt+S (Option+S on macOS) |
Share |
Escape |
Close any open overlay (hue picker search focus, mobile bottom sheet); if none is open, do nothing (focus is never moved by Escape outside a dismissible container) |
No single-character shortcut exists anywhere in the designer (satisfying WCAG 2.2's 2.1.4 Character
Key Shortcuts without a remap UI): every shortcut above either requires a modifier or is a standard
multi-key combination. The numeric rail-jump shortcuts (1–9, 0) from earlier drafts are removed;
rail navigation is arrow-key/Tab-only (11.5).
Focus management: after any slot rail selection, focus moves to the option panel's first focusable
element (the search bar) only when the selection was made by pointer input — distinguished by the
originating event's PointerEvent.pointerType ('mouse'/'touch'/'pen' moves focus; an empty
string or a KeyboardEvent origin does not); keyboard-driven rail
navigation (11.5) keeps focus on the rail itself so repeated arrow-key browsing of slots stays fluid,
and the option panel updates its content without stealing focus. After Share succeeds, focus moves to
the toast's dismiss control (or the fallback copy input, 11.9) so screen reader users immediately land
on the actionable element.
Focus trapping in dialogs: the mobile bottom sheet (11.12) and any modal confirmation (none exist in
the designer today, but the mechanism is shared with the admin console's modals per Section 18.5) trap
focus using a standard cycle: Tab from the last focusable element inside the container moves to the
first, Shift+Tab from the first moves to the last, and focus is restored to the element that opened
the container when it closes.
Escape behaviour: closes the mobile bottom sheet or clears hue-search focus (returns focus to the search input's parent tab) without discarding any state — Escape never reverts a selection, it only dismisses UI chrome. When no overlay is open, Escape does nothing; it never blurs or moves focus away from whatever control the visitor was using.
11.12 Touch interactions #
Tap targets: every interactive control (rail items, grid tiles, swatches, buttons) has a minimum 44×44 CSS px hit area, enforced via padding even where the visible icon/thumbnail is smaller (e.g. a 24×24 swatch sits inside a 44×44 tappable wrapper).
Swipe between slots: on the preview stage at base–sm, a horizontal swipe gesture (detected via
pointermove delta, threshold 60px, velocity-independent) moves activeSlot to the next/previous
rail item in z-order, mirroring the ArrowLeft/ArrowRight keyboard behaviour in 11.5. Swipes on the
option panel or bottom sheet do not trigger this (swipe is scoped to the preview stage element only,
to avoid conflicting with the bottom sheet's own drag-to-dismiss gesture below).
Pinch-zoom on the preview: two-finger pinch over the live composite canvas scales its CSS transform
between 1x and 3x (matching the scale values from Section 8.6) with no network re-fetch — this is a
pure CSS transform: scale() on the already-rendered canvas, not a re-render at a different source
resolution, since the canvas element itself always draws at the highest available scale (3x source
sprites) already. Pinch is bounded to the 1x–3x range and snaps back to 1x on release if released
below 1.1x.
Mobile bottom-sheet option panel: at base–sm, selecting a slot opens the option panel as a bottom
sheet sliding up from the viewport bottom, starting at 50% of viewport height, with a resize handle at
the top. The handle is a real <button> (aria-label="Resize sheet"): Enter/Space cycles the
sheet between 50% and 85% height, so resizing is always available without a drag; dragging the handle
is a pointer-only enhancement over the same two heights, never the only path (satisfying WCAG 2.2's
2.5.7 Dragging Movements). Dismissal: tap the scrim above the sheet, drag the sheet down past a 30%
threshold and release, or press Escape (11.11). The sheet does not auto-close when an asset or hue is
selected — the user can keep browsing/adjusting within the same sheet session and closes it explicitly.
11.13 Validation and impossible states #
| Situation | Detection | Recovery | User-visible copy |
|---|---|---|---|
| Gender-incompatible selection on body switch | After selectBody, any filled slot whose assetKey has no asset_variants row for the new body |
The slot is NOT auto-cleared (so switching back restores it); instead the SlotRailItem shows a warning badge (amber, 18.2 warning token) instead of the filled dot, and the slot contributes no layer to the composite (there is no body-agnostic variant to fall back to, Section 8.5); the stored selection itself is untouched, so switching back restores it |
Tooltip on the warning badge: "Not available for this body. Pick a new option or switch back." |
Retired asset in a loaded design (via /d/:code or a hand-edited URL) |
assetKey resolves to an assets row with retired_at set |
The slot still renders (retired assets remain servable, per Section 6's soft-delete rule) but the SlotRailItem shows an info badge (18.2 text-muted icon variant) and the option panel, if that slot is opened, shows the retired asset pinned at the top of the grid labelled "Currently selected (retired)" even though it is excluded from the normal browsing list below it |
Banner on first load only, dismissible: "Some items in this design have been retired from the catalog but are shown as originally selected." |
| Unknown asset key in a URL (transient query string, 13.4) | assetKey does not exist in assets at all (not even retired) |
The field is dropped during server-side parse (10.3.1) before the client ever sees it — the slot is simply absent from initialDesign |
Banner: "One or more items in the link couldn't be found and were skipped." (same banner mechanism as the hue case below; both conditions combine into one banner listing affected slot names) |
| Hue index that no longer exists | hue value not present in hues for the resolved slot's applicable groups |
Same server-side drop as above; the slot falls back to hue 0 (as-drawn) rather than being cleared entirely, since the asset selection itself is still valid |
Included in the same combined banner: "…and some hues were reset to as-drawn." |
The combined banner from the last two rows is one dismissible component rendered once per page load when the server-side parse in Section 10.3.1 dropped or altered any field; it lists the affected slot names by display name, joined with commas, and is never shown again for that page load once dismissed.
11.14 Microcopy inventory #
| Context | Copy |
|---|---|
| Share button, idle | "Share" |
| Share button, pending | "Sharing…" |
| Share button, done | "Copied!" |
| Share button, error | "Try Again" |
| Share button, copy failed | "Copy manually" |
| Share toast, success | "Link copied to clipboard" |
| Share toast, fallback | "Copy this link" |
| Share toast, error | "Couldn't create your link. Check your connection and try again." |
| Share toast, rate limited | "You've created a lot of links just now. Try again in a few minutes." |
| Randomize button | "Randomize" |
| Body toggle (12.2) | "Male", "Female" |
| "Describe this design" button (18.9) | "Describe this design" |
| Undo button tooltip | "Undo" |
| Redo button tooltip | "Redo" |
| Reset button | "Reset" |
| Reset confirm (none needed — reset is itself undoable, 11.3) | n/a |
| "None" grid tile | "None" |
| Asset search placeholder | "Search this slot…" |
| Hue search placeholder | "Search hue name or number…" |
| Sort control options | "Name (A–Z)", "Newest", "Most Used" |
| Hue picker "as-drawn" swatch label | "As-drawn" |
| Hue picker recent row label | "Recently used" |
| Facial hair disabled tooltip | "Not available for female bodies." |
| Gender-incompatible warning tooltip | "Not available for this body. Pick a new option or switch back." |
| Retired asset banner | "Some items in this design have been retired from the catalog but are shown as originally selected." |
| Dropped-field banner | "One or more items in the link couldn't be found and were skipped." |
| Dropped-field + hue banner | "One or more items in the link couldn't be found and were skipped, and some hues were reset to as-drawn." |
| Remix hint (unedited) | "Editing this design creates a new link when you share — the original stays unchanged." |
| Remix hint (edited) | "You're remixing this design. Share to get your own link." |
| Empty option panel (slot has zero published assets) | "No options available for this slot yet. Check back after the next update." |
| Load-more sentinel, loading | "Loading more…" (visually hidden text for the skeleton state, announced to screen readers) |
| Bottom sheet resize handle, accessible label | "Resize sheet" (aria-description: "Press Enter to toggle sheet height, Escape to close") |
| Skip-to-content link | "Skip to content" (Section 10.4) |
11.15 Performance #
Interaction-to-paint budget: 100ms from any pointer/keyboard input that changes designSig to the
live composite canvas reflecting the change, measured on a mid-tier mobile device profile (Section
19.1 defines the reference device). This is achievable because the live composite is pure client-side
canvas compositing over already-fetched sprite bitmaps and the hue table (Section 8.3) — no network
round-trip sits on the critical path for any edit once the design's referenced assets are cached.
Image prefetch strategy: when a slot becomes activeSlot, the option panel's first page of asset
thumbnails (60 items) is fetched immediately; additionally, DesignerRoot prefetches (low-priority
fetch with priority: "low") the full-resolution sprite bitmap for every asset currently present in
designSig.value.slots on mount, so switching hues or re-selecting an already-chosen asset never
blocks on a network request. Hovering an AssetGridItem for more than 150ms triggers a prefetch of
that asset's full-resolution sprite ahead of an actual click. For keyboard focus the delay is 400ms
instead of 150ms, and any pending prefetch timer is cancelled when focus moves off the item before it
fires — so continuous arrow-key traversal of the grid issues no requests, keeping prefetch volume
under 12.9's 6-concurrent cap regardless of input method.
Virtualization: AssetGrid virtualizes rows once more than 120 items have been loaded (i.e. after the
third infinite-scroll page), using a fixed-row-height windowing approach
(web/islands/designer/virtual-list.ts — a UI windowing helper, not pure domain logic, so it lives
under web/ rather than core/, per Section 4.5) that keeps at most 3 screens' worth of rows
mounted; below 120 items no virtualization overhead is
introduced. The hue picker's swatch grid uses the same windowing approach once a group's member count
exceeds 120 (some hue groups, per Section 7.7's curation, run into the thousands); groups below that
size render unvirtualized.
12. Preview Surfaces — Live Paperdoll & Per-Slot Static Previews #
This section specifies both required preview modes end to end: the live composited paperdoll and the per-slot static previews. It owns preview layout, browser rendering path, loading/error states, download, print/accessibility preference handling, preview performance limits, accessibility of the visual surface, and the empty/random-design affordance. It consumes the rendering engine defined in Section 8 and the design state model owned by Section 11; it does not redefine either.
12.1 The two modes side by side #
| Live paperdoll preview | Per-slot static previews | |
|---|---|---|
| What it shows | The full composited character at the current design state, updating on every change | Every available asset in one slot, each pre-rendered as-drawn (hue 0) |
| Who it serves | A user actively designing, and any visitor to a permalink page | A user choosing which asset to put in a slot, before it is part of the composite |
| Where it appears | Center stage on / (Section 10; /design permanently redirects here, Section 10.5) above the fold; the sole image on /d/:code; embedded (read-only, no controls) on /catalog/:slotKey/:assetKey at a neutral default hue |
The asset picker panel for the currently open slot in the designer UI (Section 11); the slot grid pages under /catalog/:slotKey (Section 15) |
| Update trigger | Any slot/hue/body change in the current session (Section 11's state model) | Opening a slot's picker, or switching body (which changes the variant each tile resolves to) |
| Backing render | Client-side canvas composite (Section 12.3) with server render as source-of-truth fallback (Section 8.12) | Server-side per-asset renders (/render/a/..., Section 8.8), never client-composited — each thumbnail is a single sprite at hue 0, pre-warmed at publish (Section 8.11) |
Both modes are required simultaneously in the designer UI: the live paperdoll shows the assembled result, the slot strip shows the alternatives. Neither replaces the other.
12.2 Live paperdoll preview #
- Layout: a single square-ish stage, aspect ratio locked to the canvas's
260:330(Section 8.1), centered in its container, with a fixed-position toolbar (zoom controls, background switcher, body control, download button) docked below the stage on narrow viewports and to the stage's right on wide viewports. - Size at each breakpoint (Tailwind CSS breakpoints, Section 4.1, Section 18.4 owns the token scale this references):
| Breakpoint | Stage width | Stage height | Toolbar position |
|---|---|---|---|
< 640px (mobile) |
min(88vw, 320px) |
proportional (width * 330/260) |
below stage, horizontal scroll row |
640–1024px (tablet) |
360px |
457px |
below stage, wrapped row |
≥ 1024px (desktop) |
480px |
609px |
right of stage, vertical column |
- Zoom controls: four states —
1×,2×,3×(mapped directly to Section 8.6'sscalevalues) andFit(scales the rendered260×330bitmap to fill the current stage container via CSSobject-fit: contain, using whichever of1×/2×/3×was last rendered —Fitis a display-only transform, never a distinct render request). Default on load:Fit. Zoom state is session-only (not part of the shareable design, Section 13.2) and resets toFiton a fresh page load. Section 12.2 owns the zoom model outright: there are four discrete states and no continuous or percentage zoom anywhere in the product. Section 11.12's pinch gesture snaps to the nearest of1×,2×,3×on release and never produces an intermediate scale. - Background options:
transparent(checkerboard, 8px squares at1×, alternating the two neutral tokens defined in Section 18.2),parchment(a flat warm off-white token, evoking the UO paperdoll frame without depicting copyrighted art),dark(a flat near-black token, for users previewing against a dark game UI). Default:transparent. Background is a CSS treatment behind the canvas element only — it is never baked into any rendered or downloaded image, since Section 8.1 fixes the render output as transparent-background RGBA. - Body control: a two-option
role="radiogroup"labelled "Body", withrole="radio"optionsMaleandFemale, reflectingCompositeRequest.body(Section 8.2.2). It is a radiogroup and not anaria-pressedtoggle because choosing between two named alternatives is a selection, not an on/off state, and it renders as the segmented control in Section 18.5. This is the only affordance in the product for changing body; Section 11.6's option panel shows the skin-hue picker when thebodyslot is active and defers the body choice here. Changing it re-resolves every currently selected slot per Section 8.5 and triggers a recomposite; a slot whose selection has no variant for the new body is retained but contributes no layer, per Section 11.13, and the preview reflects that state. - DOM/canvas structure:
<figure class="sf-preview" data-testid="live-preview">
<div class="sf-preview__stage" style="aspect-ratio: 260 / 330;">
<canvas class="sf-preview__canvas" width="260" height="330"
aria-hidden="true"></canvas>
<img class="sf-preview__fallback" alt="" hidden />
</div>
<p class="sf-preview__alt-text sr-only" data-testid="preview-alt-text"><!-- Section 12.10 --></p>
<div class="sf-preview__toolbar" role="group" aria-label="Preview controls">
<div class="sf-preview__zoom" role="radiogroup" aria-label="Zoom level"><!-- 1x/2x/3x/Fit buttons --></div>
<div class="sf-preview__background" role="radiogroup" aria-label="Preview background"><!-- 3 buttons --></div>
<div class="sf-preview__body" role="radiogroup" aria-label="Body"><!-- Male/Female radios --></div>
<button class="sf-preview__download"><!-- Section 12.7 --></button>
</div>
</figure>The <canvas> carries aria-hidden="true" because it is a bitmap with no accessible semantics of its
own; the adjacent sf-preview__alt-text element (visually hidden, screen-reader-visible) is the
accessible description, per Section 12.10. The <img> fallback element is shown, and the <canvas>
hidden, whenever Section 8.12's JS-disabled/API-unavailable path is active; both never render
simultaneously.
12.3 Rendering path in the browser #
- Sprite fetch strategy: for each layer in the current design, the island requests
/render/a/:assetKey/:body/0@1.png(hue 0, so the browser applies hue itself per Section 8.12) the first time that exact(assetKey, body)pair is needed in the session, and never again — fetched sprites are kept in an in-memoryMap<string, Sprite>keyed by`${assetKey}:${body}`for the page lifetime (cleared only on full page reload). Every variant is scoped to exactly one body (Section 8.5:asset_variants.bodyismorf, never both), so switching body refetches one sprite per populated slot the first time that body is used in the session, and hits the map on every switch after that. - Sprite geometry:
width,height,offsetX,offsetYand the asset'shue_modecome fromGET /api/v1/assets?slot=<slotKey>(Section 16.8.3), which the picker already fetches. The render URL carries no geometry (Section 8.12), so placement is never inferred from the bitmap. - Cross-origin loading: every sprite
<img>andfetchused for compositing setscrossorigin="anonymous", and the render routes returnAccess-Control-Allow-Origin: <SKINFORGE_PUBLIC_BASE_URL>(Section 8.8). Without both halves the canvas becomes tainted the moment a CDN fronts/render/*, and everygetImageDataread below — plus the print snapshot in Section 12.8 — throwsSecurityError. - Hue table fetch and caching:
GET /api/v1/hues?full=true(Section 16.8.5) is fetched once, on designer mount, before the first composite; the fullHueTable(Section 8.2.2) is held in memory for the page lifetime. This single request (not per-hue lookups) keeps hue changes — the most frequent interaction — entirely local with zero network round-trips after mount. - Off-screen canvas: compositing runs against an
OffscreenCanvas(or, when unavailable butcreateImageBitmap/getImageDataare present, an in-memoryUint8ClampedArrayaccumulator with no canvas at all until the final blit) sized260×330at scale 1, using Section 8.4's compositor and Section 8.6's scaler; only the final scaled bitmap is written to the visible<canvas>viaputImageData, so intermediate layer composition never causes visible flicker. - Request coalescing: rapid changes (e.g. dragging a hue slider) do not trigger one fetch or one
recomposite per intermediate value. Sprite fetches are deduplicated by the in-memory map above
(identical URLs in flight share one
fetchpromise). Recomposite work is debounced (below); at most one composite is in flight at a time, and if a new change arrives mid-composite, the in-flight result is discarded on completion and a fresh composite is scheduled immediately rather than queued. - Debounce values: hue slider/continuous input changes debounce at 80 ms before triggering a recomposite (chosen to feel instantaneous — under the ~100ms threshold for perceived immediacy — while collapsing the 10-20 intermediate events a typical slider drag produces). Discrete changes (asset pick, body change, hue-swatch click) recomposite with no debounce (0 ms), since they are already discrete, deliberate clicks with no rapid-fire sequence to collapse. The debounced navigation that persists transient design state to the URL query string (Section 13.6) uses a separate, longer debounce of 400 ms, decoupled from the recomposite debounce, so URL history does not accumulate an entry per keystroke-equivalent interaction.
- Fallback to server renders: per Section 8.12, if
OffscreenCanvas/createImageBitmapare unavailable or JS is disabled, the live preview area shows/render/d/:code@2.webp-equivalent server-rendered<img>output driven by the query-string design state, updating via the 400 ms debounced navigation above rather than canvas redraws. This is the only condition under which the 400 ms navigation debounce also gates the visible preview update (in the canvas path, the preview updates immediately at 80 ms/0 ms and the 400 ms debounce only affects when the URL — not the picture — catches up). - Reference debounce/coalescing implementation:
// web/islands/designer/preview-controller.ts
const RECOMPOSITE_DEBOUNCE_CONTINUOUS_MS = 80;
const RECOMPOSITE_DEBOUNCE_DISCRETE_MS = 0;
const URL_SYNC_DEBOUNCE_MS = 400;
let recompositeTimer: number | undefined;
let inFlight: Promise<void> | null = null;
let pendingRerun = false;
function scheduleRecomposite(kind: "continuous" | "discrete") {
const delay = kind === "continuous"
? RECOMPOSITE_DEBOUNCE_CONTINUOUS_MS
: RECOMPOSITE_DEBOUNCE_DISCRETE_MS;
clearTimeout(recompositeTimer);
recompositeTimer = setTimeout(runComposite, delay);
}
async function runComposite() {
if (inFlight) {
pendingRerun = true; // Section 12.3: discard-and-restart, never queue
return;
}
inFlight = doComposite().finally(() => {
inFlight = null;
if (pendingRerun) {
pendingRerun = false;
runComposite();
}
});
await inFlight;
} doComposite() fetches any not-yet-cached sprites (Section 12.3's in-memory map, deduplicated by
URL), then calls Section 8.4's compositeOver and Section 8.6's scaleNearest against the current
design state snapshot taken at call time — not at schedule time — so a rapid sequence of changes
always composites the latest state even if several intermediate states were skipped.
12.3.1 Off-thread composition #
Composition and hue application run on the main thread by default, since Section 8.13's budgets keep
a single composite under 25 ms of CPU — well within a frame budget and not a source of jank even on
mid-range mobile hardware. On devices where navigator.hardwareConcurrency >= 4 and
Worker is available, the designer island optionally offloads compositing to a dedicated Web Worker
running the same core/hue/ and core/composite/ modules (Section 8), transferring sprite buffers via Transferable ArrayBuffers
to avoid structured-clone copy cost; this is a pure performance optimization with no behavioral
difference, and its absence (worker unavailable, low core count) never disables live preview — it only
means composite work runs on the main thread instead.
12.4 Loading, skeleton, partial and error states #
| State | Trigger | Visual | Copy |
|---|---|---|---|
| Initial skeleton | Designer mounts, hue table and/or first sprites not yet fetched | Pulsing neutral-token placeholder shaped to the 260:330 stage |
"Loading preview…" (visually hidden aria-live="polite" announcement; no visible text over the skeleton itself) |
| Per-slot loading | A newly selected asset's sprite is still in flight | The stage keeps the last valid composite fully visible; no spinner overlay, no dimming — avoids visual flicker on fast networks | none (silent; if the fetch exceeds 600 ms, a small non-blocking corner spinner appears with aria-label="Updating preview") |
| Partial state | One or more layers failed to load but at least one layer (typically body) succeeded |
Composite renders with the failing layer(s) simply omitted; a dismissible inline notice appears below the stage | "Some parts of this design could not be shown. [Retry]" |
| Retired-asset notice | A design references a slot asset whose retired_at is set (still renders per Section 9.7 and Section 8.14, but is no longer choosable going forward) |
A small "Archived" badge on the affected slot's picker entry point only — the preview image itself is unaffected, since retired assets still render normally | "This item has been retired and can no longer be added to new designs, but existing designs that use it still display it." |
| Missing-variant notice | User switches body and the current selection has no variant for the new body (Section 8.5, Section 11.13) | The affected slot contributes no layer on the next composite, but its selection is retained so switching back restores it; a toast/inline notice names the slot | " is hidden because it isn't available for <Male/Female>." |
| Hard error | Hue table fetch fails, or every sprite fetch fails | Full-stage error illustration-free placeholder (a neutral icon token, no image dependency) with retry | "The preview couldn't load. [Try again]" — the rest of the designer (asset pickers, URL state) remains usable; only the visual stage is affected |
All transient text-only states use aria-live="polite" on a single shared live region so screen
reader users hear updates without duplicate announcements; the hard-error state additionally moves
focus to the retry button only if the error occurs during initial mount (never steals focus for an
error that occurs mid-session while the user is actively interacting elsewhere).
12.5 Per-slot static previews #
- Layout: a responsive grid (CSS grid,
auto-fill,minmax(96px, 1fr)) of thumbnail tiles, one per asset variant available for the slot and current body, opened from the slot's entry in the designer's slot list (Section 11.5) or browsed directly at/catalog/:slotKey(Section 15.3, which owns the page chrome; this section owns the tile/grid rendering contract itself). - Source: each tile's image is
/render/a/:assetKey/:body/0@1.webp— the asset as drawn, at hue 0, always. Tiles never carry the slot's currently selected hue. This is a deliberate cost decision as much as a UX one: hueing the grid would multiply the render cache by the number of selectable hues, defeat the publish-time pre-warm in Section 8.11 (which warms exactly the hue-0 scale-1 variant of every published asset), and make every hue change re-request the whole grid. Section 11.6 states the same rule for the option panel, and the two must not drift apart. The user sees the chosen hue applied on the live stage (Section 12.2), which updates instantly and needs no network request at all. - Thumbnail dimensions:
96×96CSS px tile, image itself is260×330scale-1 art centered andobject-fit: containwithin a96×122inner frame (preserving the260:330aspect ratio, not cropping), so garment silhouettes are comparable across tiles regardless of a slot's typical sprite footprint. Retina/high-DPI displays receive the scale-2 render (/render/a/:assetKey/:body/0@2.webp) viasrcset, never a CSS-upscaled scale-1 bitmap. That scale-2 variant is generated lazily on first request (Section 8.11) rather than pre-warmed, since only high-DPI visitors ask for it. - Hover/focus behavior: on hover (pointer) or focus (keyboard), a tile scales to
1.05×(CSS transform, GPU-composited, no layout shift), gains a visible focus/hover ring (Section 18.2 token), and shows the asset's display name in a tooltip/label that is always present but visually de-emphasized at rest (never hover-only text, for touch and accessibility parity). Activating a tile (click, Enter, Space) selects that asset for the slot and closes the picker, returning focus to the slot's entry point in the designer list. - Lazy loading: tiles use native
loading="lazy"plus anIntersectionObserver-driven fetch-priority hint; only tiles within roughly one viewport height of the visible grid are requested eagerly (fetchpriority="high"for the first row,loading="lazy"for the rest), keeping the initial picker open cost proportional to visible tiles, not total catalog size, for slots with large option counts (e.g.hair). - Clearing a slot: every optional slot's grid includes one leading tile labelled "None", which
removes the slot from the design entirely (distinct from setting its hue to 0 — see Section 12.6's
"As drawn" swatch, which is a hue choice, not an asset choice). The
bodyslot's grid has no "None" tile, becausebodyis required (Section 3.3);backpackdoes, and clearing it falls back to the default assetbackpack.defaultrather than removing the layer, since the backpack is always visible.
12.6 Hue swatch previews #
- The swatch form is a flat colour chip. Each hue in the picker renders as a solid square filled
with the hue's resolved swatch colour —
swatch_override_argbwhen non-null, otherwiseswatch_color, which import computes from table index 16, the visual midpoint of the 32-entry dark-to-light ramp (Sections 6.13 and 7.7) — labelled with the hue's name fromhues.name. The API ships the resolved value asswatchArgbon every hue resource (Section 16.8.5), already present in the hue table fetched at mount (Section 12.3), so the chip is still a pure CSSbackground-colorcomputed client-side and opening the hue picker issues zero additional network requests. - Index 16 and not index 0: index 0 is the darkest entry of the ramp, so a swatch drawn from it would
render every hue as a near-identical near-black square and the picker would be unusable.
swatch_coloris computed from index 16 at import time (Section 6.13, Section 7.7), and a curator may override it per hue viaswatch_override_argbon the hues admin screen (Section 17.8), which always wins when set. Sections 11.6 and 15.5 use the same resolved value, so all three surfaces show the same chip for the same hue, override included. - Why not render each swatch on the selected asset. Doing so would need one server render per (asset, body, hue) combination — thousands of cache entries per asset, none of them pre-warmable (Section 8.11), all of them re-requested the moment the user changes asset. The chip is instant and cache-free; the actual asset-with-hue preview is the live stage, which is one canvas redraw away.
- Focused hue preview on the stage. While a hue picker is open, the live stage (Section 12.2)
crops and zooms to the slot's most hue-visible region so the effect of the highlighted hue is
legible without hunting for it. The crop comes from
slots.hue_swatch_crop(Section 6.9), a per-slot hint, and falls back to a centred60%-scale crop when the column is null. This exists because a sparse sprite such asearringsoccupies a small corner of the 260×330 frame and its hue change is otherwise invisible at stage size. The crop is a display transform on the already-composited canvas — it is never a distinct render request, never affects the shared design, and is released when the picker closes. - Swatches share the same grid, hover/focus, keyboard and lazy-loading rules as Section 12.5, minus lazy image loading, which a CSS-filled chip does not need.
- The "As drawn" swatch: every hue grid leads with one swatch labelled "As drawn", which sets the
slot's hue to
0, meaning the asset renders in its original authored colours. It is rendered as a chip with the checkerboard treatment from Section 12.2 rather than a solid colour, so it reads as "no hue" rather than as one more colour. For a slot whose hue is currently0, this swatch is the selected one.
12.6.1 Hue grid layout #
Hue swatches are grouped by hue_groups (Section 6.14). The seed data (Section 6.31) creates exactly
five groups, and these are their display names: "Skin Hues", "Hair Hues", "Tattoo Hues", "Clothing
Hues" and "Event Hues". They are presented as labelled sections within the same slot picker surface
used in Section 12.5 — each section a horizontally scrollable row on mobile and a wrapped grid on
desktop, using the identical 96×96 tile size.
The "Skin Hues" group is shown first, and only when the currently open picker is the skin-hue control
(Section 11.6's body/skin panel, not a cosmetic slot). Cosmetic slot pickers order groups by
hue_groups.sort_order (Section 6.14) with "Event Hues" last, since event hues are the least
game-authentic and most novel option — appropriate as a discovery-oriented final group rather than a
default-facing first one.
12.7 Download #
- What: a PNG (never WebP — Section 8.7's rationale: maximum local-viewer compatibility) of the
current live design, at a scale the user chooses from the same
1×/2×/3×control used for zoom (Section 12.2) — the download always uses the currently selected zoom scale, exceptFit, which downloads at2×(a sensible fixed default when the user has not committed to a specific pixel scale). - Source: if the current design has already been saved (has a
/d/:code), the download button requests/render/d/:code@:scale.pngdirectly (Section 8.8) — a plain navigation-triggered download (<a download>with the render URL ashref), no client compositing involved. If the design is unsaved (still in transient query-string form, Section 13.6), the button first performs an implicit save (same persistence call Section 13.5's Share action uses) and then downloads from the resulting permalink's render URL — a design is never downloadable without also becoming a permalink, so every downloaded image remains traceable to a shareable, immutable source. - Filename convention:
skinforge-<code>-<scale>x.png, e.g.skinforge-4kx9m2p7qa-2x.png(the example is a valid 10-character Crockford Base32 code: the alphabet excludesi,l,oandu, Section 13.4). - Attribution note in metadata: the downloaded PNG's
tEXtchunk carriesSoftware: Outlands SkinForgeandDescription: Unofficial fan-made design preview — <public base URL>/d/:code(base URL fromSKINFORGE_PUBLIC_BASE_URL, Section 24.2), written by the same PNG encoder pass described in Section 8.7 as an additional metadata-only step applied to download-purposed encodes (design composites requested with.pngthrough the ordinary render pipeline are not required to carry this chunk — Section 8.9's cache key does not include a metadata variant, so the download flow requests through a distinct, non-cached/render/d/:code@:scale.png?dl=1variant whose only difference from the cached path is this metadata injection, generated on demand and not itself cached, since it is requested rarely relative to the plain cached render). - Rate limiting: the
dl=1metadata-injection path is rate-limited on the samerenderbucket as every other render request —SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE, default 60 per minute per IP (Section 16.7.1, Section 24.2). It does not get a separate budget, since it reuses the same encoder capacity described in Section 8.13. Because the response is deliberately uncached, it is also the one render path where a client can force real CPU work per request, which is exactly why it sits inside that bucket rather than beside it.
12.8 Print/high-contrast/reduced-motion behaviour #
Print: a dedicated print stylesheet (
@media print) hides the toolbar, background switcher and all chrome, and renders only the preview snapshot (captured as a static<img>viacanvas.toDataURLat the moment print is invoked, since a<canvas>element does not print reliably across browsers) at2×scale on a plain white background, with the design's short code and the unofficial-fan-project disclaimer printed as a caption beneath the image. The disclaimer is quoted verbatim from Section 10.4, which owns its single wording:"Outlands SkinForge is an unofficial fan project. It is not affiliated with, endorsed by, or sponsored by UO: Outlands, Broadsword Online Games, or Electronic Arts."
canvas.toDataURLthrowsSecurityErroron a tainted canvas, which is exactly what a CDN-fronted/render/*would produce without the CORS pair described in Section 12.3 — so the print path depends on that pair being in place, not merely the compositing path. If the snapshot nevertheless fails, printing falls back to the server-rendered/render/d/:code@2.pngimage, which is same-content and always printable.High-contrast (
prefers-contrast: more): the checkerboard transparency background (Section 12.2) switches to a higher-contrast two-tone pair from Section 18's high-contrast token set; all toolbar controls gain visible borders (rather than relying on background-color differences alone) to meet WCAG 2.2 AA non-text contrast requirements per Section 18.Reduced motion (
prefers-reduced-motion: reduce): the hover/focus1.05×tile scale transform (Section 12.5) is disabled (swapped for a static ring/border change only); the corner "updating" spinner (Section 12.4) is replaced by a static pulsing-free "Updating…" text label; no other motion exists in the preview surface to disable, since compositing itself is instantaneous, not animated.
12.9 Performance rules for the preview #
- Maximum simultaneous image requests: the sprite fetch layer (Section 12.3) caps concurrent
in-flight sprite fetches at 6 (a
fetch-wrapping semaphore in the same module), queuing the rest FIFO. The bound exists to cap concurrent decode work and peak decoded-pixel memory, not to respect a connection limit — the origin is served over HTTP/2 (Section 23.1), where the classic six-per-origin cap does not apply. The slot grid (Section 12.5) has its own independent 6-slot cap, since it is a logically separate consumer with a different eviction policy. - Sprite cache size in the browser: the in-memory sprite map (Section 12.3) is unbounded by count
but capped at 24 MB of estimated decoded pixel bytes (
width * height * 4summed across cached entries); on exceeding the cap, entries are evicted least-recently-used, excluding any sprite currently part of the live composite (the active design's own layers are never evicted while displayed). - Memory ceiling on mobile: the same 24 MB cap applies on all devices; on devices reporting
navigator.deviceMemory <= 2(where supported; treated as "low-memory" when the API is absent and the viewport matches the mobile breakpoint, Section 12.2) the cap is reduced to 8 MB and the slot-grid thumbnail concurrency (Section 12.5) drops from 6 to 3 simultaneous requests. - What is dropped first under pressure: eviction order is (1) sprite cache entries for assets not
in the current design or currently open picker grid, oldest-used first; (2) slot-grid thumbnails
scrolled out of view (their
<img>srcis cleared and lazily restored on re-entering the viewport, sameIntersectionObserverfrom Section 12.5); (3) as a last resort, on aWebGLContextLost-style canvas failure signal or repeatedOffscreenCanvasallocation failure, the designer falls back to the server-rendered<img>path (Section 12.3's fallback), which has near-zero client memory cost. The live composite's own current-frame buffers (accumulator, final scaled bitmap) are never dropped while visible — they are the last thing eviction touches.
12.10 Accessibility of an inherently visual surface #
Alt text generation rule: the live preview's accessible description (the visually-hidden
sf-preview__alt-textelement from Section 12.2's DOM structure, referenced byaria-describedbyon the<canvas>/<img>) is a generated sentence naming the body, the skin hue, and each chosen asset with its hue, in the z-order given by the slot registry in Section 3.3, omitting empty slots and omitting a hue mention when a slot's hue is0:"Male character with Sun-Kissed skin, wearing Long Wavy hair in Raven Black, Plain Robe, and Leather Backpack."
Generation algorithm:
"<Body display name> character with <skin hue name> skin"— or, whenskinHue === 0, the fixed opening clause"<Body display name> character with default skin"— then, for each populated slot in z-order excludingbody,", wearing <asset display name>"optionally followed by" in <hue name>"whenhue !== 0, joined with commas and a final"and"before the last item, terminated with a period. Asset and hue display names are inserted verbatim, with no article inserted or removed by the algorithm — the catalog'sdisplay_namevalues are curated to read correctly in this position with no algorithmic help (Section 9's import review). Thebackpackslot is included like any other populated slot — it is a designable slot (Section 3.3), it always has a value, and a description that silently omitted the one item the character is always wearing would be wrong. Slot and asset display names come fromslots.display_nameandassets.display_name, hue names fromhues.name(Section 6) — never the internalslot_key/asset_keystrings. This same sentence is reused verbatim as the design'saltattribute wherever the permalink's static image appears outside the designer (Section 10.3.2's/d/:codepage, and theog:image:altvalue Section 10.6 emits for the card generated in Section 14).Text-mode listing: a
<dl>(description list) immediately following the preview, inside a<details>element labelled "View design as text" (collapsed by default, always present in the DOM for screen readers and search engines regardless of collapsed state), listing every populated slot's display name and hue name as term/definition pairs — the structured equivalent of the alt sentence above, for users who want to scan rather than read prose.backpackappears in this listing on the same terms as every other populated slot, for the same reason.Keyboard operation: the zoom radiogroup, background radiogroup and body radiogroup (Section 12.2) each follow the WAI-ARIA radiogroup pattern (arrow keys move selection; Tab enters and exits the group once); the download button is a standard button. No preview control requires a pointer, and every control in the toolbar is reachable in a single static Tab sequence with no roving focus traps.
Announcements are on demand, not per change. The design description is not re-announced on every edit — during hue browsing that would produce an announcement every few hundred milliseconds and drown out everything else. Section 18.9 owns the on-demand model: a "Describe this design" control reads the current sentence into the live region when the user asks for it. The full sentence is also available at all times through
aria-describedbyand the text-mode listing. The sharedaria-live="polite"region described in Section 12.4 is reserved for state transitions (loading, partial, error), not for design content.Success criteria this surface must satisfy, beyond the automated audit in Section 22.6 — none of these three is detectable by an automated checker, so each is a manual gate in Sections 26 and 27:
- 2.1.4 Character Key Shortcuts — the preview surface defines no single-character shortcut. Every keyboard affordance here is either a standard control interaction (Tab, arrows, Enter, Space) or, in Section 11.11's shortcut table, modifier-qualified. A bare letter key typed while the preview has focus does nothing, so a speech-input user can never trigger it by accident.
- 2.5.7 Dragging Movements — no preview interaction requires a drag. Zoom is a radiogroup, not a slider; the mobile bottom sheet that hosts the picker (Section 11.12) opens, closes and resizes from buttons as well as from the drag handle; hue selection is a grid of activatable tiles, never a drag-along ramp. Every drag in this surface has a single-pointer, non-path alternative.
- 2.4.11 Focus Not Obscured — the sticky toolbar and the mobile bottom sheet must never cover
the focused element. The stage scrolls focused picker tiles into a viewport region inset by the
sheet's own height, and the toolbar is
position: stickywith ascroll-marginequal to its height on every focusable descendant, so a keyboard user is never left with the focus ring hidden behind chrome.
Edge cases in generation: an entirely empty design (Section 12.11) produces "Male character with default skin, wearing Leather Backpack." — the backpack clause is present because the backpack is present; a design with exactly one populated cosmetic slot omits the Oxford-comma "and" construction, e.g. "Female character with Porcelain skin, wearing Plain Robe."; hue names that contain the word "Hue" as a literal prefix (hues whose decoded name was blank and received the generated
Hue <index>placeholder, Section 7.7) are used verbatim fromhues.namewith no text massaging, because that column is curated specifically to read naturally in this sentence position (Section 9's import review step includes a "reads naturally in a sentence" check for exactly this reason).
12.10.1 Preview surface test hooks #
Every interactive or state-bearing element in this section carries a stable data-testid so Section
22's component and end-to-end tests can target them without depending on CSS class names or visible
text (which change with copy edits):
| Element | data-testid |
|---|---|
| Live preview stage | live-preview |
| Zoom control group | preview-zoom |
| Background control group | preview-background |
| Body radiogroup | preview-body |
| Download button | preview-download |
| Alt-text description element (not a live region) | preview-alt-text |
| Shared status live region (Section 12.4) | preview-status |
| Text-mode design listing | preview-text-listing |
| Per-slot picker grid | slot-grid-<slotKey> |
| Per-slot picker tile | slot-tile-<assetKey> |
| Hue picker grid | hue-grid-<slotKey> |
| Hue picker tile | hue-tile-<hueIndex> |
| Randomize button (empty-state) | preview-randomize |
| Empty-state banner | preview-empty-state |
| Retired-asset badge | slot-badge-retired-<slotKey> |
These identifiers are stable across releases; renaming one is a breaking change to the test suite and must be coordinated with Section 22's fixture updates in the same change.
12.11 Empty state and the "random design" affordance #
- Empty state: on first visit to
/(or/design, which redirects here, Section 10.5) with no query-string design state (Section 13.6), the preview shows thebody = mdefault with every optional slot empty (only the always-presentbodyand the defaultbackpack.defaultasset populate, per Section 3.3) and skin hue0. The alt text (Section 12.10) for this state reads "Male character with default skin, wearing Leather Backpack." A single prominent affordance sits beneath the stage: a "Randomize" button. - Random design affordance: the empty state surfaces the same Randomize control specified in Section 11.8 — same label ("Randomize"), same seeded algorithm, same single history step — rendered more prominently while the design is empty. Section 11.8 owns the probabilities; this section adds no variant of its own. Activating it recomposites immediately (0 ms debounce, Section 12.3, since it is a discrete action) and updates the transient URL state per Section 13.6. It never auto-saves a permalink — a random result is exploratory until the user explicitly presses Share (Section 13.5).
13. Permalinks & Design Encoding #
13.1 Requirements #
A SkinForge design is a specific, named combination of a body, a skin hue, and zero or more cosmetic slot choices (asset + hue per slot, per the slot registry in Section 3). The permalink system exists to satisfy five hard requirements:
- Permanence. A link created today must resolve to the same visual result indefinitely. There is no expiry, no login-gated deletion, and no mechanism for a design to silently disappear except the legal takedown path in Section 20.
- No login. Anyone can create a permalink. Creation requires no account, no session, no cookie.
- Determinism. The same design, submitted twice, always yields the same short code. Submitting an identical design is not an error and does not create a duplicate row.
- Meaning never changes. A link's rendered picture is fixed at creation time. Later changes to the underlying game art (new builds, retired assets, re-hued items) never alter what an existing link shows. Section 13.7 specifies the pinning mechanism.
- Patch survival. UO Outlands ships patches that add, remove, or change art. A permalink created before a patch must keep working and keep rendering correctly after the patch, even if the original asset is later retired from the live catalog.
These requirements drive three linked mechanisms: a canonical JSON document (13.2), a canonicalization algorithm that turns that document into a fixed byte string (13.3), and a short-code algorithm that turns that byte string into a URL-safe identifier (13.4).
13.2 The canonical design document #
The canonical design document is the single source of truth for what a design looks like. It is stored, hashed, and used to re-render the design at any point in the future.
{
"v": 1,
"body": "m",
"skinHue": 1002,
"slots": [
{ "slot": "hair", "asset": "hair.long-wavy", "hue": 1102 },
{ "slot": "torso_outer", "asset": "robe.plain", "hue": 0 }
]
}13.2.1 Field definitions #
| Field | Type | Required | Allowed values | Notes |
|---|---|---|---|---|
v |
integer | yes | 1 (current) |
Document format version. Bumped only if the shape changes incompatibly. A design row always records the v it was created with; old versions are read-migrated at render time, never rewritten in place. |
body |
string | yes | "m", "f" |
Matches the body code strings from Section 3. |
skinHue |
integer | yes | 0 or a hue index tagged into the skin hue group (Section 3, Section 6) |
0 means the body renders with the art's own unhued skin tone. |
slots |
array | yes (may be empty []) |
see 13.2.2 | One entry per occupied cosmetic slot, out of the 18 choosable slots in the registry (Section 3), backpack included. Never contains an entry for body itself — body and skinHue are top-level fields, not slot entries. May contain an entry for backpack, exactly like any other choosable slot; when a backpack entry is absent, rendering (Section 8) applies the default asset key backpack.default at hue 0, since a paperdoll always shows a backpack layer — backpack is the one slot whose omission has a non-empty rendering fallback rather than "nothing rendered." |
13.2.2 Slot entry shape #
Each element of slots is:
| Field | Type | Required | Allowed values | Notes |
|---|---|---|---|---|
slot |
string | yes | one of the 18 choosable slot_key values in Section 3 (the 17 optional cosmetic slots plus backpack) |
Duplicate slot values in the same array are rejected. |
asset |
string | yes | a published asset_key whose slot_key matches this entry's slot, and whose gender compatibility matches body (Section 3's gender notes; facial_hair is male-only, checked here) |
Must resolve at write time; an unknown key is rejected with SF-2001, a key that exists but belongs to a different slot with SF-1019, and a gender-incompatible key with SF-1017 (Section 25). |
hue |
integer | yes | 0 or a published hue index |
0 means "as-drawn" — the asset's native colours from its source art, no hue table applied. |
13.2.3 Ordering rule #
slots is always stored and served sorted by ascending z-order, using the z values from the slot
registry in Section 3 (tattoo_body=20 first, ... cloak=180, backpack=190 last among choosable
slots). Ordering is not client-supplied; the server re-sorts on every write. This makes the
canonical form independent of the order slots were chosen in the UI and is required for the hashing in
13.3 to be deterministic.
13.2.4 Omission rule #
A slot with no selection is omitted entirely from slots — there is no null-asset placeholder
entry. An empty slots: [] array is valid and represents a bare body with only the skin hue applied.
13.2.5 Full Zod 4.x schema #
Lives at core/schema/design.ts, imported by the API handler in Section 16.8, the designer island in
Section 11, and the CLI. This is the single validation source; no other module re-implements these
rules.
// core/schema/design.ts
import { z } from "zod";
import { SLOT_KEYS, CHOOSABLE_SLOT_KEYS } from "../domain/slots.ts";
export const BodyCodeSchema = z.enum(["m", "f"]);
export const HueIndexSchema = z.number().int().min(0).max(65535);
export const AssetKeySchema = z
.string()
.min(1)
.max(128)
.regex(/^[a-z0-9]+(?:[-_.][a-z0-9]+)*$/, "must be a lowercase dotted/kebab asset key");
export const DesignSlotEntrySchema = z.object({
slot: z.enum(CHOOSABLE_SLOT_KEYS as [string, ...string[]]),
asset: AssetKeySchema,
hue: HueIndexSchema,
}).strict();
export const DesignDocumentSchema = z.object({
v: z.literal(1),
body: BodyCodeSchema,
skinHue: HueIndexSchema,
slots: z.array(DesignSlotEntrySchema).max(CHOOSABLE_SLOT_KEYS.length),
}).strict()
.superRefine((doc, ctx) => {
const seen = new Set<string>();
for (const [i, entry] of doc.slots.entries()) {
if (seen.has(entry.slot)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `duplicate slot "${entry.slot}"`,
path: ["slots", i, "slot"],
});
}
seen.add(entry.slot);
}
});
export type DesignDocument = z.infer<typeof DesignDocumentSchema>;.strict() rejects unknown top-level and per-slot keys, satisfying the "unknown keys rejected" rule.
Cross-field checks that require catalog lookups (asset exists, asset's slot_key matches, gender
compatibility, hue exists and is published) run in the API handler after this shape-level parse,
because they need database access that a pure schema module does not have. Those checks report the
code matching the specific failure — SF-2001 (asset key does not exist), SF-1019 (asset exists but
does not belong to the declared slot), SF-1017 (gender-restricted asset on an incompatible body), or
SF-2002 (hue index does not exist) — each with the offending field path (Section 16.4).
13.3 Canonicalization algorithm #
Canonicalization produces one exact byte string for a given DesignDocument value, so that hashing is
deterministic regardless of how the object was constructed or serialized upstream.
13.3.1 Rules #
- Start from the validated, server-normalized document:
slotsalready sorted by z-order (13.2.3), no omitted-slot placeholders, no unknown keys (guaranteed by.strict()in 13.2.5). - Serialize as JSON with keys in a fixed order, not alphabetical:
v,body,skinHue,slots; within each slot entry:slot,asset,hue. This fixed order matches the example in 13.2 and is chosen for human readability of the canonical form; it is fixed by code, not by a generic "sort object keys" step, because object key order is not semantically meaningful in JSON and a naive alphabetical sort would reorderslots/slotcollisions inconsistently across engines. - No whitespace: no spaces after
:or,, no newlines, no trailing newline. - Numbers: integers only in this document (no floats appear in the schema). Serialize with no
leading zeros, no leading
+, no exponential notation, matchingJSON.stringifybehaviour for safe integers, which every field in this schema is (HueIndexSchemacaps at 65535). - Strings: standard JSON string escaping.
assetandslotvalues are already constrained to[a-z0-9._-]by 13.2.5, so no escaping ever triggers in practice, but the encoder does not assume this and always applies standardJSON.stringifystring escaping. - Encoding: the canonical string is encoded as UTF-8 bytes before hashing. Since the schema only permits ASCII in every field, canonical bytes are always plain ASCII in practice, but the algorithm is defined as UTF-8 for correctness in principle.
slots: []serializes as"slots":[](empty array, not omitted, notnull).
13.3.2 Reference implementation #
// core/design/canonicalize.ts
import type { DesignDocument } from "../schema/design.ts";
export function canonicalize(doc: DesignDocument): string {
const slots = doc.slots
.map((s) => `{"slot":${JSON.stringify(s.slot)},"asset":${JSON.stringify(s.asset)},"hue":${s.hue}}`)
.join(",");
return `{"v":${doc.v},"body":${JSON.stringify(doc.body)},"skinHue":${doc.skinHue},"slots":[${slots}]}`;
}
export function canonicalBytes(doc: DesignDocument): Uint8Array {
return new TextEncoder().encode(canonicalize(doc));
}This hand-written serializer is deliberate: it guarantees byte-for-byte stability across Deno versions
and V8 upgrades, whereas relying on JSON.stringify over a plain object depends on insertion order,
which is an implementation detail this system must not depend on for a value that is cryptographically
hashed.
13.3.3 Worked example #
Input document (already normalized: sorted, no omissions):
{
"v": 1,
"body": "m",
"skinHue": 1002,
"slots": [
{ "slot": "torso_outer", "asset": "robe.plain", "hue": 0 },
{ "slot": "hair", "asset": "hair.long-wavy", "hue": 1102 }
]
}Note torso_outer (z=110) sorts before hair (z=130), matching Section 3's z-order table (the input
above is shown pre-sort to illustrate that input order is irrelevant).
Canonical byte string (exact, no line breaks — shown wrapped only for legibility here):
{"v":1,"body":"m","skinHue":1002,"slots":[{"slot":"torso_outer","asset":"robe.plain","hue":0},{"slot":"hair","asset":"hair.long-wavy","hue":1102}]}That string is 147 bytes of ASCII, UTF-8-encoded, and is the exact input to SHA-256 in 13.4.
13.4 Short-code algorithm #
13.4.1 Steps #
Compute
digest = SHA-256(canonicalBytes(doc))— 32 bytes.Take the leading 50 bits of
digest: read the first 7 bytes (56 bits) as a big-endian integer and discard the low 6 bits of the 7th byte, leaving the top 50 bits.Encode those 50 bits as Crockford Base32, using a 32-symbol alphabet, producing exactly 10 characters (50 bits / 5 bits-per-symbol = 10 symbols).
The alphabet excludes
i,l,o,uto eliminate visual ambiguity with1,0,v/wand to avoid accidentally spelling profanity fragments. SkinForge's alphabet (lowercase canonical form):0123456789abcdefghjkmnpqrstvwxyz(32 symbols: digits
0–9, lettersa b c d e f g h j k m n p q r s t v w x y z— note the gaps ati,l,o,u.)The code is always lowercase in URLs (
/d/:code). Lookup is case-insensitive: incoming codes are uppercased-then-lowercased through the same normalization function before the database query — concretely, the API/route layer runs the code throughnormalizeShortCode()(13.4.4) before any lookup, rejecting characters outside the alphabet (case-folded), wrong length, or a well-formed code with no matching row uniformly withSF-2000(404, not found), never a generic 400, so that malformed, well-formed-but-unknown, and merely-differently-cased codes are all indistinguishable to a client probing for valid codes.
13.4.2 Collision procedure #
Short codes are 50-bit values; collisions across unrelated designs are possible (13.4.3 gives the
probability). The designs table has a unique index on short_code. On insert:
- Compute the candidate code from
canonicalBytes(doc)as above. - Attempt
INSERT ... ON CONFLICT (short_code) DO NOTHING RETURNING *. - If the insert succeeded, done — return the new code with HTTP 201 (Section 13.5, Section 16.8).
- If it conflicted, fetch the existing row by
short_codeand compare its stored canonical JSON (columncanonical_json, Section 6) byte-for-byte againstcanonicalize(doc):- Identical: this is the idempotent-create case (13.5). Return the existing code with HTTP 200.
- Different: this is a true hash collision. Recompute the code using a salted digest:
SHA-256(canonicalBytes(doc) || saltByte)wheresaltByteis a single appended byte starting at0x01and incrementing. Repeat steps 2–4 with the new candidate code, persisting the winningsaltBytein thedesigns.saltcolumn (SMALLINT NULL, Section 6).NULLmeans "unsalted" — the common case, and the only value for which re-deriving a code fromcanonical_jsonalone (with no salt byte appended) reproduces the storedshort_code.1–255record which salt byte was appended for a collision retry;0is never written, soNULLand a salt value are always unambiguous. Salt search is capped at 255 attempts (values1–255); exhausting it returnsSF-9007(Section 25) — a distinct, detected-and-handled condition, never the generic unhandled-exception code, logged asdesign.salt_exhaustedand treated as an operational alert (since it implies either a bug or an astronomically unlikely event per 13.4.3).
This procedure is race-safe under concurrent requests for the same design because the uniqueness constraint is enforced by Postgres, not by an application-level check-then-insert.
13.4.3 Probability analysis #
50 bits gives a code space of 2^50 ≈ 1.126 × 10^15. Using the standard birthday-bound approximation
p ≈ n² / (2 × N) for n stored designs and N = code space:
Designs stored (n) |
Approx. collision probability |
|---|---|
1e5 (100,000) |
≈ 4.4 × 10^-6 |
1e6 (1,000,000) |
≈ 4.4 × 10^-4 |
1e7 (10,000,000) |
≈ 0.044 (about 4.4%) |
At 1e7 stored designs the probability that at least one pair of the stored codes collides is
non-trivial (this is the birthday bound across all pairs, not the chance any single new insert
collides — the chance a single new design collides with the existing 10 million is only
≈ 10^7 / 2^50 ≈ 8.9 × 10^-9). The salted-retry procedure in 13.4.2 handles every case: collisions are
detected deterministically via the unique index and resolved without user-visible impact except a few
extra milliseconds of write latency. No monitoring threshold changes behaviour; the salt column exists
precisely so the system scales past 1e7 designs without a migration.
13.4.4 Reference TypeScript #
// core/design/short-code.ts
const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"; // Crockford, no i/l/o/u
const ALPHABET_INDEX = new Map([...ALPHABET].map((c, i) => [c, i]));
export async function computeShortCode(
canonicalBytesValue: Uint8Array,
salt?: number, // undefined or 0 means "unsalted"; 1-255 is an appended collision-retry byte
): Promise<string> {
const input = (salt === undefined || salt === 0)
? canonicalBytesValue
: concat(canonicalBytesValue, Uint8Array.of(salt));
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));
return encode50Bits(digest);
}
function encode50Bits(digest: Uint8Array): string {
// Leading 50 bits = first 7 bytes (56 bits) as a big-endian integer, top 50 bits kept
// (the low 6 bits of the 7th byte are discarded).
let full = 0n;
for (let i = 0; i < 7; i++) full = (full << 8n) | BigInt(digest[i]);
let v = full >> 6n;
let out = "";
for (let i = 0; i < 10; i++) {
out = ALPHABET[Number(v & 0x1fn)] + out; // 5 bits per symbol
v >>= 5n;
}
return out;
}
export function normalizeShortCode(input: string): string | null {
const lowered = input.trim().toLowerCase();
if (!/^[0-9a-z]{10}$/.test(lowered)) return null;
for (const ch of lowered) {
if (!ALPHABET_INDEX.has(ch)) return null; // rejects i, l, o, u explicitly
}
return lowered;
}
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.length + b.length);
out.set(a, 0);
out.set(b, a.length);
return out;
}normalizeShortCode is the single normalization entry point used by the route handler
(/d/:code, Section 10) and the API handler (GET /api/v1/designs/{code}, Section 16.8) — both
reject a code that fails normalization with SF-2000 (404, not found), never a 400, per 13.4.1.
13.5 Creation semantics #
POST /api/v1/designs is idempotent by construction, not by an idempotency-key header. The full
endpoint contract — request schema, response schema, status codes, error cases — is owned by
Section 16.8. This section states only the semantics that the encoding scheme guarantees:
- Submitting the same
DesignDocument(after server-side normalization: sorting, omission of empty slots) twice, from the same client or different clients, at any time apart, always returns the sameshort_code. - The first successful creation returns HTTP 201 with the new code.
- Every subsequent identical submission returns HTTP 200 with the same code — never a second 201, never an error. This is what makes retries and double-submits (for example, a user double-clicking Share, or a client retrying after a timeout that actually succeeded server-side) safe by default, with no separate deduplication mechanism required.
- A submission that is valid but differs in any field (different asset, different hue, different body) is a different design and receives its own code.
13.6 The transient query-string form #
Before a design is saved, the in-progress editor state lives entirely in the URL query string of the
editor route (/ or /design, Section 10) — never in server state, never in a cookie, never in
localStorage. This makes the editor URL itself shareable-but-unstable (13.6.4 explains why that
instability is intentional) and lets a user's browser back/forward buttons step through edit history
for free.
13.6.1 Parameter grammar #
| Param | Meaning | Format | Example |
|---|---|---|---|
b |
body | m | f |
b=m |
s |
skin hue | integer | s=1002 |
<slotKey> |
one query param per occupied slot, named exactly as the slot_key |
<assetKey>:<hue> |
hair=hair.long-wavy:1102 |
Full example: /?b=m&s=1002&hair=hair.long-wavy:1102&torso_outer=robe.plain:0
Rules:
bandsare the only two reserved, non-slot parameter names. Every other recognized query key must exactly match one of the 18 choosableslot_keyvalues from Section 3 (includingbackpack, which follows the same<assetKey>:<hue>grammar as any other slot param; an absentbackpackparam behaves like an absent entry in the permanent form — 13.2.1'sbackpack.defaultfallback applies at render time).- A slot parameter's value is
<assetKey>:<hue>— a single colon separates the two parts.assetKeymatchesAssetKeySchema(13.2.5);hueis a non-negative integer. - Omitting a slot parameter means that slot is empty, mirroring the omission rule in 13.2.4.
- No percent-encoding is required for
assetKeyvalues because the schema restricts them to[a-z0-9._-], none of which are reserved in a query string; the colon separator is also unreserved in a query value per RFC 3986 and needs no escaping.
13.6.2 Parsing rules #
- Parse
b: must bemorf. Missing or invalid → default tomand continue (this is an editor default, not an error state — the editor always renders something). - Parse
s: must be an integer in range and a known hue index at render time (existence is checked against the loaded hue catalog client-side; the server-side render endpoint re-validates per Section 8). Missing or invalid → default to0. - For every other query key: if it matches a
slot_key, parse<assetKey>:<hue>. A value missing the colon, or with a non-integer hue part, is dropped silently (the slot is treated as empty) — the editor never hard-errors on a malformed share link; it degrades to "as much of the design as parsed successfully." - Unknown parameter handling: any query key that is neither
b,s, nor a validslot_keyis ignored — not an error, not preserved on re-serialization. This keeps the form forward-compatible (old links with since-removed experimental params still load) and prevents the query string from accumulating cruft as the editor state changes. - Length limits: the full query string is capped at 2000 characters total, matching the
practical URL-length ceiling respected by all major browsers, proxies and messaging apps' link
unfurlers. The editor enforces this client-side by refusing to add a further slot selection (with an
inline notice) once the serialized query string would exceed the cap; in the extremely unlikely case
a hand-crafted URL exceeds it, the server-rendered editor route truncates at the last fully-formed
&-delimited parameter before the 2000-character mark and parses only what remains.
13.6.3 Reference parser #
// core/design/query-form.ts
import { CHOOSABLE_SLOT_KEYS } from "../domain/slots.ts";
export interface TransientDesign {
body: "m" | "f";
skinHue: number;
slots: Partial<Record<string, { asset: string; hue: number }>>;
}
const SLOT_KEY_SET = new Set(CHOOSABLE_SLOT_KEYS);
export function parseTransientDesign(search: URLSearchParams): TransientDesign {
const body = search.get("b") === "f" ? "f" : "m";
const sRaw = search.get("s");
const skinHue = sRaw !== null && /^\d+$/.test(sRaw) ? Number(sRaw) : 0;
const slots: TransientDesign["slots"] = {};
for (const [key, value] of search.entries()) {
if (!SLOT_KEY_SET.has(key)) continue; // unknown param, ignored
const idx = value.indexOf(":");
if (idx < 0) continue; // malformed, slot treated as empty
const asset = value.slice(0, idx);
const hueRaw = value.slice(idx + 1);
if (!/^\d+$/.test(hueRaw)) continue;
slots[key] = { asset, hue: Number(hueRaw) };
}
return { body, skinHue, slots };
}
export function serializeTransientDesign(d: TransientDesign): string {
const params = new URLSearchParams();
params.set("b", d.body);
params.set("s", String(d.skinHue));
for (const [slot, entry] of Object.entries(d.slots)) {
if (!entry) continue;
params.set(slot, `${entry.asset}:${entry.hue}`);
}
const out = params.toString();
return out.length > 2000 ? out.slice(0, out.lastIndexOf("&", 2000)) : out;
}13.6.4 Why this is deliberately not the permanent form #
The query-string form is optimized for the editor's moment-to-moment state changes: it is cheap to mutate (one param per slot), human-scannable while debugging, and requires no server round-trip to update as the user experiments. It is deliberately not treated as a stable identifier because:
- It has no canonicalization step — key order in a
URLSearchParamsis insertion order, not the fixed z-order from 13.2.3, so two editors could produce different query strings for the same visual result. - It has no collision resistance or fixed length — it grows linearly with slots occupied, unsuitable for a short, brandable share link.
- It carries no immutability guarantee — nothing prevents a future format change to the parameter
grammar itself, whereas the canonical JSON document's
vfield (13.2.1) exists exactly so the permanent form can evolve without breaking old links.
Pressing Share in the designer (Section 11) takes the current TransientDesign, converts it to a
DesignDocument (filling in the omission and sort rules), submits it to POST /api/v1/designs
(13.5), and the client issues history.pushState(null, "", url) to /d/:code with no page
navigation and no HTTP redirect (Sections 10.5 and 11.9). From that point on, /d/:code — not the
query string — is the canonical shareable link.
13.7 Immutability guarantees #
A permalink's rendered meaning is fixed forever at creation time. This is achieved through three mechanisms:
build_idpinning. Everydesignsrow (Section 6) storesbuild_id, a foreign key to thegame_buildsrow that was the active published build at the moment the design was created (Section 9 owns build publishing). All rendering for that design — live preview regeneration, OG image regeneration, re-renders after a cache purge — resolves assets and hues as they existed in that build, never against "whatever is currently published." Section 9's versioning model keeps every published build's asset/hue rows queryable indefinitely — retirement and rollback are always soft-delete (a flag, never a row deletion) — sobuild_idpinning is always resolvable.- Retired-asset rendering. If an asset referenced by an old design is later retired (Section 9),
it still renders: retirement sets
retired_aton theassetsrow but never deletes the row or itsasset_images(Section 6). The design's render pipeline (Section 8) looks up the asset byasset_keywithin the pinnedbuild_id's asset set, ignoringretired_atentirely for this lookup path. Retirement only affects whether an asset is offered in the live editor and catalog (Section 10, Section 11) — never whether it can still be rendered for an existing design. - Changed-hue behaviour. Hue rows are also soft-delete-only. If a hue's swatch metadata is
corrected by staff (Section 17.8) — for example, a mislabelled name — the numeric hue table itself
(the actual colour ramp, Section 8) is treated as immutable once published in a build; corrections
to non-visual metadata (name, grouping) do not affect rendering. If a genuine colour-table
correction is ever required (for example, the original extraction mis-decoded a colour ramp), that
correction ships as a new build (Section 9), and existing designs keep pointing at their
original
build_id's hue table, so their rendered colours do not shift underneath them.
The explicit promise: a shared link renders the same picture in five years as it did the day it was created, even if every asset it references has since been retired from the live catalog, even if the game itself is patched dozens more times, and even if hue metadata is corrected. Designs are never auto-deleted (13.9.3); the only way a permalink's rendered picture can legitimately stop being servable is the legal takedown path in Section 20, which replaces the page with an HTTP 410 notice rather than silently altering or removing the underlying row.
13.8 Design page behaviour #
/d/:code (Section 10 owns the route and layout shell) is a server-rendered page whose content
includes:
- Server-rendered preview: the composite paperdoll image at scale 2, embedded as
/render/d/:code@2.webp(Section 8) inside a standard<img>tag with explicitwidth/heightattributes matching the 260×330 canvas at that scale, so the page has no cumulative layout shift. A<noscript>-safe<picture>element also provides a.pngsource for user agents or link unfurlers that request PNG. - Remix affordance: a prominent "Remix this design" control that navigates to the editor route
(Section 10) with the query string pre-filled from the design's
DesignDocument, usingserializeTransientDesign(13.6.3) applied to the loaded document. Remixing never mutates the original design row; it only seeds a new transient editor session that, if shared, produces a new, independent short code. - "Designs like this": a strip of up to 6 other designs sharing the same
bodyand at least two identical(slot, asset)pairs, ranked by count of shared(slot, asset)pairs descending, then bypage_view_daily-derived popularity (Section 6, Section 21) descending, ties broken bycreated_atdescending. Computed with a single indexed query at request time (no precomputed similarity table in v1); if fewer than 2 candidates are found the strip is omitted from the page entirely, not rendered empty. - Structured data: a
<script type="application/ld+json">block with schema.orgImageObject(contentUrlpointing at the scale-2 WebP render,creatoromitted since there are no accounts,descriptiona generated sentence such as "A UO Outlands character skin design created with SkinForge — unofficial fan tool."). NoProductorCreativeWorkmarkup, since this is not a commercial listing. - OG tags pointer: the page's
<head>OG/Twitter meta tags point at the design's Open Graph image per Section 14's generation and caching rules; this section does not redefine those tags.
13.9 Abuse and hygiene #
13.9.1 No moderation surface for free text #
A DesignDocument contains no user-supplied free text anywhere — every field is either a constrained
enum, an integer bounded against a catalog, or a machine-generated key validated against the catalog
(13.2.5). There is therefore no profanity, harassment, spam-link, or PII surface inside a design
document itself. The abuse surface that does exist is entirely about volume (mass creation to
exhaust storage or the short-code space) and about the rendered image itself potentially depicting
a combination someone finds objectionable — the latter is addressed by the takedown path (Section 20),
not by pre-moderation, since there is no practical way to pre-moderate a paperdoll composite and doing
so would contradict the no-login, no-review, instant-share design goal.
13.9.2 Rate limiting on creation #
POST /api/v1/designs is rate-limited per the SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR bucket
(Section 19 owns the mechanism; Section 24 owns the env var's default and type). Rate-limit responses
follow the 429 contract in Section 16.7.
13.9.3 Storage growth and pruning policy #
Each design row is small (a canonical_json payload under 1 KB in the overwhelming majority of cases,
plus fixed-width columns, per Section 6) but its rendered artifacts (composite images at 3 scales
× 2 formats, per Section 8, plus an OG image per Section 14) are the real storage cost — roughly
150–400 KB per fully-rendered design once all cached variants exist.
Decision: Section 8.11 owns when renders are generated; no other section decides it. Two variants —
@2.webp (the permalink page's embed, 13.8) and @1.png (the Open Graph card's source, Section 14) —
are rendered eagerly, enqueued inside the same transaction that saves the design. Every other
(code, scale, format) combination is generated lazily, on first request (Section 8.9 owns cache-key
mechanics; Section 8.10 owns the generation-trigger/runtime pipeline). This means a design that is
created but never viewed by anyone but its creator consumes the small database row plus exactly those
two pre-warmed render objects, not the full 3-scale × 2-format set.
Designs are never auto-deleted. A designs row, once created, is retained forever — there is no
view-count threshold, no age threshold, and no nightly pruning job that removes a design row. This
matches the permanence promise in 13.1 and 13.7 and the legal posture in Section 20: the only way a
design stops being servable is the takedown process (Section 20.8), which sets taken_down_at on the
row (410, Section 13.11) rather than deleting it, so the row and its audit trail persist even after
takedown.
Rendered artifacts (composite images and the OG image) are a different matter: they are derived,
regenerable data (Section 8, Section 14), not the design itself, so they may be pruned to control
storage growth without affecting the permanence promise — a pruned render is simply regenerated
lazily on the next request. A nightly job (Section 9's job runner, Section 21's job metrics) removes
rendered artifacts whose last_served_at (og_images, Section 6) or storage-object last-access
timestamp is older than 90 days, leaving the owning designs row untouched; the foreign keys from
design_renders/og_images to designs permit this via ON DELETE CASCADE in the other direction
only (deleting a render row never deletes its design). A design that has never been rendered simply has
no artifacts to prune yet — pruning only ever removes bytes that can be regenerated on demand.
13.10 Analytics on permalinks #
13.10.1 View counting without cookies #
Each GET /d/:code request that is not filtered as a bot (13.10.2) increments a counter. Public
visitors are never tracked with cookies, sessions, or persistent identifiers of any kind (Section 20
owns the full privacy posture), so view counting is not deduplicated by visitor identity — there is
no session, no cookie, no IP-derived fingerprint stored per view. The
counter is a simple monotonic increment per page load, recorded server-side into stat_counters
(daily-bucketed) and reflected on the designs row as a denormalized view_count maintained by the
same write path, both defined in Section 6. This intentionally means "view count" measures page loads,
not unique visitors — stated here as the definition so no other section redefines it.
13.10.2 Bot filtering #
Before incrementing, the request is checked against a bot-detection rule set:
- User-Agent allowlist for link unfurlers: known crawler UAs (Slackbot, Discordbot, Twitterbot, facebookexternalhit, TelegramBot, WhatsApp, LinkedInBot) are recognized explicitly by substring match and are excluded from the view counter but are still served the full page (they need the OG tags), because a chat-app link preview is not a human view.
- Generic bot heuristic: requests with no
Accept-Languageheader and noAcceptheader containingtext/htmlare treated as non-browser and excluded. - Everything else increments the counter exactly once per request. There is no further deduplication — a human refreshing the page ten times counts as ten views. This is a stated, final decision: precise unique-visitor analytics would require cookies or fingerprinting, both excluded by this system's no-tracking privacy posture (Section 20).
13.10.3 The counter table #
Owned in full by Section 6; referenced here only for the columns this section's logic depends on:
stat_counters(counter_key, day, value) with counter_key = 'design_view:' || short_code, and the
denormalized designs.view_count column updated in the same transaction as the daily bucket row, using
INSERT ... ON CONFLICT (counter_key, day) DO UPDATE SET value = stat_counters.value + 1.
13.11 Edge cases #
Every row below is authoritative for its scenario; Section 25 owns the full error code registry and repeats these entries there for completeness, but this table is where the behaviour is decided.
| Scenario | HTTP status | Error code | User-visible copy |
|---|---|---|---|
| Unknown code (well-formed, no matching row) | 404 | SF-2000 |
"We couldn't find that design. The link may be mistyped." |
Malformed code (fails normalizeShortCode, 13.4.4) |
404 | SF-2000 |
Same code and same copy as unknown code — malformed and unknown are indistinguishable to the visitor by design (13.4.1). |
| Retired asset referenced by the design | 200 | — | No error. Renders normally per 13.7's retired-asset rendering guarantee; the page shows the image exactly as at creation time. |
| Missing render variant (cache miss for a valid design) | 200 | — | No error. The render endpoint (Section 8) generates it synchronously on first request within SKINFORGE_RENDER_TIMEOUT_MS; the page's <img> simply takes slightly longer to load. If generation itself fails (corrupt source asset), see "render failure" below. |
| Render failure (a referenced sprite is missing or unreadable within the pinned build for an otherwise-valid asset) | 200 (page) / 500 (image) | SF-5000 |
This is a server-side data fault, not a client error: the page still renders with a static "preview unavailable" placeholder image in place of the broken <img>; the underlying image URL returns 500 with SF-5000 in its JSON error variant (for API consumers), is never cached, and logs an operational alert (Section 21), since this indicates a pipeline bug, not user error (Section 8.14 states the same rule). |
| Deleted design (takedown-removed per Section 20.8 — designs are never auto-pruned, 13.9.3) | 410 | SF-2005 |
"This design is no longer available." (Section 20 owns the exact takedown copy shown on the page; this row states the status code and family.) |
Extremely old build (design's pinned build_id predates the oldest build still queryable) |
This cannot occur by construction | — | Builds are never hard-deleted (Section 9); every build_id ever assigned to a design remains queryable forever. Stated here as a guarantee, not handled as an error path. |
Code valid but references a design created under a future schema version the running server does not understand (v newer than supported) |
200, degraded | — | The render pipeline reads the highest v it supports; a document with a higher v than the running server understands cannot exist in practice because v is written by the same codebase that reads it, and deployments are single-version (Section 23). No forward-compatibility path is needed; this scenario is excluded by construction, not handled defensively. |
14. Open Graph Image Generation #
This section owns the generation, layout, typography, pipeline, crawler handling, platform-specific requirements, cost controls, and error handling for the social preview image served at every design permalink. It consumes the design and render primitives owned by Sections 8 and 13; it does not redefine the meta tags themselves (Section 10 owns those) or the design JSON/short-code format (Section 13 owns those).
14.1 Purpose and where the URL appears #
Every design permalink needs a rich preview when shared on social platforms, chat apps, and messaging
clients, because the permalink itself (/d/:code) is an opaque short code with no inherent visual
information. The OG image closes that gap: it shows the actual designed character, not a generic site
banner.
- URL:
/og/d/:code.png, where:codeis the same 10-character Crockford Base32 design short code used throughout the render namespace (Section 8.8, Section 13.4). Only PNG is offered — no.webpvariant — because social platform crawlers (Section 14.7) are inconsistent about WebP support for OG images, while PNG is universally accepted. - Where referenced: the
og:imageandtwitter:imagemeta tags on the/d/:codepage point to this URL. Section 10 owns the full set of meta tags and their exact attribute values; this section owns only what is served when that URL is requested. - Not used elsewhere: catalog pages, hue pages, and the homepage use a static fallback card (Section 14.9) rather than a generated one, since they have no single design to depict.
14.2 Exact canvas #
- Dimensions: 1200×630 px, the standard Open Graph image size recommended by Facebook, X/Twitter
(
summary_large_image), and Discord, chosen specifically because it avoids cropping on any of the three. - Safe area: platform crawlers may crop toward a
1.91:1center on some surfaces; all essential content (the character composite, the wordmark, the fan-project disclaimer) is placed within a 1160×590 px safe area centered in the canvas (a 20px margin on all sides), so nothing essential is lost to platform-side cropping variance. - Layout grid: a two-column layout within the safe area:
| Region | Bounds (within 1200×630 canvas) | Content |
|---|---|---|
| Left panel | x: 60–460, y: 65–565 (400×500) |
The paperdoll composite (Section 8), placed 1:1 and unscaled at x: 130–390, y: 150–480 — its natural 260×330 pixels, centred in the panel |
| Divider | x: 500, y: 65–565 |
A 1px vertical rule, low-emphasis token colour (Section 18.2) |
| Right panel, top | x: 560–1140, y: 90–140 |
Site wordmark ("Outlands SkinForge") in the display type scale (Section 18.3) |
| Right panel, middle | x: 560–1140, y: 170–500 |
Slot summary list (Section 14.3), up to 8 lines plus an optional 9th overflow line |
| Right panel, bottom | x: 560–1140, y: 510–565 |
Unofficial fan-project disclaimer (Section 14.3), smaller type, muted colour |
The composite is never resampled. Section 8.6 permits integer nearest-neighbour scaling only, and forbids smoothing outright, because interpolation introduces colours that exist in no hue table. Of the integer scales available (1, 2, 3) only scale 1 — 260×330 — fits inside the 400×500 left panel at all; scale 2 is 520×660 and is taller than the entire 630 px card. The card therefore embeds the scale-1 PNG at its natural size, 1:1, and satori is given explicit
width: 260; height: 330on the image node so it cannot choose a fit for itself. The panel is larger than the image on purpose: the surrounding margin is deliberate whitespace, not a box the image is expected to fill. The composite's centred box is derived asx = 60 + (400 − 260) / 2 = 130andy = 65 + (500 − 330) / 2 = 150, givingx: 130–390, y: 150–480— both offsets are whole pixels, so the image lands on the pixel grid exactly.Colour treatment: background is a flat solid fill using the darkest neutral background token in Section 18.2 (not pure black, for the print/screen contrast reasons established there), giving the transparent-background composite (Section 8.1) consistent contrast regardless of the viewer's own theme — the OG image is always rendered in one fixed (dark) treatment, independent of any future site light/dark mode, because a shared social image must look correct on every downstream platform's own chrome, not adapt to the visitor's device.
Fallback layout when the design is empty: an "empty" design (a permalink saved with every optional slot unselected and skin hue 0 — a legal, if unusual, permalink) still renders the left-panel composite exactly as normal (the default body plus
backpack.defaultsilhouette, Section 12.11). Its right-panel summary is not empty either: the skin line is always present (Section 14.3), so an empty design showsSkin: As-drawnfollowed by the singleBackpack: Leather Backpackline, since the backpack always resolves (Section 14.3). There is no "no items" state. No special-cased alternate layout exists; the same grid handles the empty case by having less content in the middle region.
14.3 Content of the card #
Paperdoll composite: the design's server-side composite (Section 8, the same
CompositeRequestthe permalink itself resolves to), sourced from/render/d/:code@1.png— scale 1, PNG. Scale 1 because it is the only integer scale that fits the card's left panel without resampling (Section 14.2), and PNG because satori embeds images as data URLs and PNG avoids a second WebP decode dependency in this pipeline (Section 8.7). That exact variant is pre-warmed on design save (Section 8.11), so OG generation normally finds it already in the render cache; if it is not warm — for a design restored from a backup that predates the rule — the ordinary pipeline in Section 8.10 produces it on demand. The PNG bytes are embedded into the satori layout as a base64 data URL image node with explicitwidth: 260; height: 330.Skin line: the first summary line is always
"Skin: <hue name>", or"Skin: As-drawn"when the skin hue is0. It is always present, including on an otherwise empty design, because skin is the one choice every design makes.Slot summary: up to 8 lines including the skin line, one per populated slot in the z-order given by the slot registry in Section 3.3, excluding
body(whose skin hue is reported on the skin line above).backpackis listed like any other populated slot — it is a designable slot (Section 3.3) and it always has a value, defaulting tobackpack.default, so it always appears. Each line reads"<Slot display name>: <Asset display name>"with" (<Hue name>)"appended when that slot's hue is non-zero. If more than 8 lines would be produced (the maximum possible is the skin line plus the 18 choosable cosmetic slots), the first 8 are shown and the remainder is summarized on an optional 9th line reading"+<n> more". The middle region in Section 14.2 is sized for 9 lines for exactly this reason.No user text ever reaches the card. Designs carry no user-authored strings — Section 13.2's design JSON has no text fields at all — so every word on the card comes from catalog
display_namevalues (Section 6) andhues.name, which are curated by staff during import and admin review (Section 9, Section 17). Beyond standard XML-escaping for the SVG layout (Section 14.5) there is nothing to sanitize, because there is nothing untrusted.Hue names: taken from
hues.name(Section 6.13), never a raw hue index — a hue is never shown to an end user as a bare number anywhere in the product, OG cards included.Site wordmark: the literal text "Outlands SkinForge", set in the display weight of Section 18.3's type scale, colour token
text-primary-on-dark(the OG canvas's fixed dark treatment, per Section 14.2, uses the dark-mode token set regardless of site-wide theme).Unofficial fan-project disclaimer: quoted verbatim from Section 10.4, which owns the single wording used everywhere in the product:
"Outlands SkinForge is an unofficial fan project. It is not affiliated with, endorsed by, or sponsored by UO: Outlands, Broadsword Online Games, or Electronic Arts."
This section fixes only its position (bottom-right, Section 14.2's grid) and typographic treatment (Section 18.3's smallest body size, muted colour token). It is set on two lines at that size within the 580 px column; the wording is never abbreviated or paraphrased to fit, because a shortened disclaimer is a different legal statement.
14.4 Typography #
- Bundled font: one static font file is bundled in the repository at
web/static/fonts/PixelifySans-Regular.ttf— Pixelify Sans, the display face named in Section 18.3 — committed alongside itsOFL.txt. TTF, not WOFF2: satori parses TTF, OTF and WOFF and explicitly does not support WOFF2, so a WOFF2 subset would fail at call time. No monospace face is bundled, because the card renders no monospace text; the design short code is not shown on the card in this layout. - Licence: the SIL OFL 1.1 font file is committed alongside its
OFL.txtlicence text, per the licence's redistribution requirement. This is a repository content decision, not a runtime dependency, and needs no attribution string inside the generated image. - No build-time subsetting step. A single-weight Latin TTF is already small enough to hold in
memory for the process lifetime, and a subsetting step would add a build tool, a
deno taskand a Dockerfile stage for no measurable gain — and would silently break the moment a curateddisplay_nameintroduced a character the subset dropped. The full face ships as-is. - Offline resolution by satori:
satori(Section 4.1) requires font data supplied as raw bytes at call time — it does not fetch fonts over the network and does not resolve system fonts. The OG generation module reads the bundled TTF once, at process startup (alongside the WASM codec initialization described in Section 8.7), into an in-memoryUint8Arraycache keyed by(family, weight), and passes that cache into everysatori()call'sfontsoption. OG generation therefore has zero external network dependency and zero per-request file I/O.
14.5 Generation pipeline #
- Trigger (Section 14.5.1) fires with a design
:code. - Build layout: construct a JSX-like element tree (satori's supported subset of CSS-in-JS
flexbox layout) matching Section 14.2's grid, embedding the scale-1 paperdoll PNG (Section 14.3)
as a base64 data URL
<img>node with explicitwidth: 260; height: 330, and the bundled font buffer (Section 14.4). - satori → SVG:
satori(element, { width: 1200, height: 630, fonts })produces an SVG string. - resvg → PNG:
@resvg/resvg-wasm(Section 4.1) rasterizes the SVG string to a 1200×630 RGBA bitmap, which is then PNG-encoded via the same@jsquash/pngencoder module described in Section 8.7 (shared WASM instance, no duplicate initialization). - Persist: the PNG bytes are written to object storage at
og/:first2/:next2/:hash.png— theog/prefix and the two-segment fan-out are defined in Section 8.9 — where:hashissha256(designCacheKey + "og" + templateVersion).designCacheKeyis the design composite's own cache key from Section 8.9 (so any change that would change the composite also changes the OG image), andtemplateVersionis an integer bumped whenever the OG layout itself changes, so a layout redesign invalidates every previously generated card without touching the design composite cache. - Record: an
og_imagesrow (Section 6.20) is written withdesign_id,cache_key,storage_key,byte_size,content_hash,created_atandlast_served_at(updated on every serve, and read by the pruning rule in Section 14.8).cache_keyis the unique key on that table, notdesign_id— a bumpedtemplateVersionproduces a second row for the same design, and the old row remains valid until it is pruned. - Serve: the identical HEAD-then-stream pattern as Section 8.10's cache-lookup step, with the
same
Cache-Control: public, max-age=31536000, immutableheader, since the URL — once the hash-bearing object exists — is equally content-addressed and immutable. As on/render/*(Section 8.9), the application setsCache-Controlper response and the reverse proxy must not override it: the degraded paths in Section 14.9 deliberately carry short or absent caching.
Reference cache-key computation, sharing the canonicalization helper used by Section 8.9:
// core/codec/og.ts
const OG_TEMPLATE_VERSION = 1; // bump on any layout change, Section 14.5.2
export function ogCacheKey(designCacheKey: string): string {
const input = `${designCacheKey}|og|${OG_TEMPLATE_VERSION}`;
return sha256Hex(new TextEncoder().encode(input));
}
export function ogStorageKey(hash: string): string {
return `og/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}.png`;
}designCacheKey here is the design composite's cache key computed exactly as Section 8.9 defines
it, for scale=1, format=png — the same variant Section 14.3 embeds. OG generation does not invent a
second design-hashing scheme; it consumes the render layer's own cache key as an opaque input.
14.5.1 Regeneration triggers #
An OG image is generated when /og/d/:code.png is requested and no og_images row exists for the
current cache_key. That single condition covers first view, any change to the underlying design
composite, and any bump of the template version.
OG generation is always lazy — never eager at design-save time. Section 8.11 pre-warms the source composite the card is built from, but not the card itself, because the majority of saved designs are never shared to a platform that renders link previews, and generating a card for all of them would spend the cost budget tracked in Section 14.8 on images nobody requests.
14.5.2 Synchronous-vs-queued decision #
OG generation reuses the exact claim/poll job pattern from Section 8.10 — a jobs row (Section 6.21)
with job_type = 'og_render' and dedupe_key set to the OG cache_key, arbitrated by the same
partial unique index — and not a fire-and-forget background task, because the first request for a
freshly shared link is very often the platform crawler itself (Section 14.6) making a synchronous
fetch with its own short timeout. The response must be a real image or a deliberate placeholder,
never a 202.
- Timeout:
SKINFORGE_OG_TIMEOUT_MS(Section 24.2; default3000) bounds how long a request waits for OG generation. It is a separate variable fromSKINFORGE_RENDER_TIMEOUT_MSbecause the two are answering different questions: the render timeout is sized for a browser that will happily wait, while the OG timeout is sized for a crawler that will not (Section 14.6). - Placeholder behaviour on timeout: if generation has not completed within
SKINFORGE_OG_TIMEOUT_MS, the request is served the static fallback card (Section 14.9) with a short cache lifetime (Cache-Control: public, max-age=30) rather than the design-specific card. The in-flight generation job continues in the background exactly as Section 8.10.2 describes, so the very next request — a crawler retry, or a second platform fetching the same link — gets the real card once it completes. The job's own deadline isSKINFORGE_RENDER_TIMEOUT_MS, so it is never killed merely because the waiting request gave up first.
14.6 Crawler handling #
- User-agent-agnostic behaviour: the OG endpoint applies identical logic regardless of requesting
user agent — no
User-Agentsniffing, no crawler allowlist, no different content for Facebook's crawler versus a plain browser tab versuscurl. This is a deliberate anti-cloaking stance (Section 14.6 heading below) and also simpler to reason about and test. - No cloaking: the image served at
/og/d/:code.pngis always exactly what a human visiting that URL directly would see — there is no hidden "crawler-only" code path, satisfying every major platform's anti-cloaking policy without needing platform-specific logic. - First-request latency budget: crawlers commonly apply their own 3-5 second fetch timeout and do
not retry indefinitely. That is why the OG path has its own variable,
SKINFORGE_OG_TIMEOUT_MS(Section 24.2, default3000), rather than inheriting the render timeout's8000ms default: a waiting crawler must be handed something before it gives up. Exceeding it serves the fallback card (Section 14.5.2) while the generation job continues to completion under the render timeout, so the retry is warm. In normal operation the crawler never reaches this path at all — Section 14.5's steps 1-7 complete in well under 200 ms once the source composite is warm, which Section 8.11 arranges at design-save time. - The "generating" placeholder image: identical to the static fallback card (Section 14.9), not a distinct "please wait" graphic — this avoids maintaining a third card design and means a platform that caches the placeholder aggressively (against the guidance below) still shows a legitimate, on-brand image rather than a broken-looking interim state.
- Why the placeholder is never cached long:
max-age=30(Section 14.5.2) is deliberately short so that if a platform's own crawler fetches a link within the first few seconds of it being shared (the highest-traffic moment for any given link) and happens to hit the placeholder, a re-crawl shortly after — which most platforms perform on first real click-through, and which SkinForge cannot force but can make cheap to benefit from — picks up the real card instead of being stuck with a long-lived generic placeholder for the link's entire lifetime.
14.7 Twitter/X card, Discord embed and Facebook specifics #
| Platform | Required meta tags (names only; Section 10 owns exact tag emission) | Image dimensions expected | Notes |
|---|---|---|---|
| X (Twitter) | twitter:card = summary_large_image, twitter:title, twitter:description, twitter:image |
1200×630 (matches summary_large_image recommendation exactly) |
No Twitter-specific account/site tags are emitted — SkinForge has no X account to reference |
| Discord | Standard Open Graph tags only (og:title, og:description, og:image, og:url); Discord has no proprietary tags |
1200×630, embed renders at reduced width in-client | Discord respects og:image:width/og:image:height hints (Section 10 emits these as 1200/630) to avoid a layout-shift flash while the embed loads |
og:title, og:description, og:image, og:image:width, og:image:height, og:image:alt, og:type = website |
1200×630 | og:type is website on every page including /d/:code; object is not a valid Open Graph type. og:image:alt is set by Section 10.6 to the generated alt sentence defined in Section 12.10, for the design being shared |
- Testing procedure: before each release that touches OG generation (Section 14.5) or the meta
tags (Section 10), staff manually verify a representative permalink against three validators:
Meta's Sharing Debugger (
https://developers.facebook.com/tools/debug/), the Discord embed behaviour observed by pasting the link in a private Discord channel (no formal Discord validator exists), and the X Card Validator equivalent check viahttps://cards-dev.twitter.com/validatorwhere available, or a direct tweet-compose preview otherwise (X has periodically retired its public validator; the compose-box live preview is the durable fallback check). This procedure is recorded as a manual QA step in Section 22.12's release checklist (referenced, not restated) rather than an automated test, since it depends on third-party services outside CI's control.
14.8 Cost controls #
- Generation rate limit: the OG generation path shares the
renderbucket —SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE, default 60 per minute per IP (Section 16.7.1, Section 24.2) — with the pixel-render pipeline. It is not a separate budget, since both consume the same CPU-bound WASM encode capacity (Section 8.13's performance budgets apply unchanged; satori and resvg add roughly 30-80 ms on top of the underlying composite fetch, which is normally already cached). - Dedupe by design code: the cache-key formula in Section 14.5 step 5 means requesting the same
:coderepeatedly, or from multiple simultaneous crawlers, only ever generates once per(designCacheKey, templateVersion)pair — identical dedupe guarantee to Section 8.10's job-claim mechanism, reused as-is for theog_renderjob type. - Storage growth estimate: a generated OG PNG is 60-140 KB (1200×630, mostly flat background,
Section 8.7's PNG size class scaled up for the larger canvas). At a projected steady state of a few
hundred newly shared designs per week, the
og/prefix grows by roughly 5-15 MB per week — materially smaller than the render cache (Section 8.9) and not a meaningful line item in the deployment sizing in Section 23.11. - Pruning policy: an
og_imagesrow whoselast_served_at(Section 14.5 step 6) is older than 180 days is eligible for pruning. A monthly scheduled job (Section 23.8's operational job registry) deletes the object from storage and the row. The condition islast_served_atalone — not a join against page views — becauselast_served_atis exactly the signal being asked about ("has anything requested this card lately?"), and because view counters deliberately exclude unfurler previews (Section 21.6), which are the very traffic that keeps an OG card alive. - Why pruning stays safe: pruning removes only the cached rendered card, never the design. The
design row is never deleted; the sole removal path for a design is the takedown process in Section
20.8, which yields
410(Section 13.7). The next request for a pruned card's/og/d/:code.pngsimply regenerates it on demand (Section 14.5.1), at the identical content address it had before pruning, because the cache key depends only on the still-present design and a stable template version — not on the pruned row's existence. Pruning is therefore a pure storage optimization whose only user-visible effect is one extra cold generation the next time a dormant link resurfaces.og_images.design_idisON DELETE CASCADE(Section 6.20) so that a takedown can clear a design's derived cards; that cascade is the only path by which a card disappears for a reason other than pruning.
14.9 Error handling and the static fallback card #
- Static fallback card: a single pre-built 1200×630 PNG committed to the repository at
web/static/social-card.pngand served at/social-card.png(Fresh servesweb/static/at the URL root, so a file underweb/static/og/would land inside the/og/d/:code.pngroute namespace — hence the root-level name). It shows the site wordmark, the site's icon mark, and the disclaimer quoted verbatim from Section 10.4, with no character composite region. It is used for (a) every non-design page'sog:image— homepage, catalog, hues, about and the rest, with Section 10.6 owning which pages reference it — and (b) the placeholder path in Section 14.5.2 and Section 14.6. There is exactly one such artifact in the product; Section 10.6 points at this same path. - Error handling table. Every code below is the code the registry in Section 25.2 assigns to that
condition, used here as the logged code. The HTTP status in the Response column is the status
this endpoint returns, which deliberately differs from the registry's status for the render-family
codes:
/og/d/:code.pngnever surfaces a5xxto a crawler (see the note below the table).
| Condition | Error code (Section 25.2) | Response |
|---|---|---|
:code fails normalization or has no matching design |
SF-2000 |
404; the response body is the static fallback card with Cache-Control: public, max-age=60, not a JSON error — an image-typed endpoint must return an image so embedding clients degrade gracefully even on a 404 status. Malformed and unknown codes are deliberately indistinguishable here for the same anti-enumeration reason as Section 8.8 and Section 13.4.1 |
:code resolves to a design removed by takedown (Section 20.8) |
SF-2005 |
410; static fallback card with Cache-Control: public, max-age=86400 — a 410 exists so caches and crawlers drop the URL, and a long-lived immutable header would defeat that |
| Source composite render fails (Section 8.14's table) | inherits the underlying code, e.g. SF-5000/SF-5001 |
200 with the static fallback card and Cache-Control: max-age=30, treated identically to the timeout placeholder path (Section 14.5.2) — OG generation never surfaces a bare 5xx to a social crawler, because a broken-image embed is worse for shareability than a generic-but-valid card. The underlying failure is still logged at its own severity |
| satori layout throws (a future font or layout regression) | SF-5007 |
Same fallback treatment as the row above; the incident is logged (Section 21.2) for staff follow-up, since this indicates a code defect rather than transient infrastructure |
| resvg rasterization throws | SF-5007 |
Same as above |
| Object storage write fails after a successful generation | SF-5003 |
The freshly generated bytes are still served for this one request — generation succeeded, only persistence failed — with Cache-Control: no-store, since the image was never durably cached and must not be treated as permanent by any downstream cache. The failure is logged for staff follow-up |
Generation exceeds SKINFORGE_OG_TIMEOUT_MS (Section 14.5.2) |
— (not an error) | 200 with the static fallback card and Cache-Control: public, max-age=30; the job continues in the background |
No condition in this table ever returns a non-image response body from /og/d/:code.png. Every row
resolves to either the generated card or the static fallback card, which is the defining contract of
this endpoint for the crawlers and chat clients that consume it. The Content-Type is always
image/png, whatever the status.
14.9.1 Fallback card content and provenance #
web/static/social-card.png is produced by the same satori+resvg pipeline (Section 14.5) as a
one-time build-time artifact — a small script at scripts/build-og-fallback.ts in the executing
repository, run manually whenever the brand mark or wordmark changes, not on every deploy — and then
committed as a binary asset. This keeps the hot request path free of any "is this the fallback case"
branching beyond serving a static file, and guarantees the fallback never depends on the live render
pipeline that might itself be the thing failing. The card's layout intentionally omits the left-panel
composite region (Section 14.2) entirely, replacing it with a centred version of the site's icon mark
on a flat background, so it reads as a deliberate brand card rather than a broken character render at
any size or platform crop.
14.9.2 Monitoring fallback-rate as a health signal #
Every response served via the fallback-card paths in Section 14.9's table increments the
skinforge_og_fallback_total counter (Section 21.3's metrics registry owns the full metric catalog;
referenced here only to establish that this endpoint's degraded-path rate is observable), labeled by
reason (not_found, taken_down, render_error, timeout, storage_error). A sustained rise
in the render_error or timeout label specifically — as opposed to not_found, which simply tracks
normal bad-link traffic — is treated as an operational signal warranting the alert thresholds defined
in Section 21.5, since it indicates the shared rendering pipeline (Section 8) is degraded in a way that
also affects every ordinary pixel-render request, not just OG cards.
A weekly rollup of the same counters (Section 21.4's dashboard, referenced not redefined) gives staff a
simple health check: a healthy steady state shows not_found dominating (normal expired/mistyped
link traffic) and the four failure-labelled reasons (render_error, timeout, storage_error, taken_down)
combined staying under 0.5% of total OG requests;
crossing that threshold for more than one rollup period is the trigger condition for the alert in
Section 21.5, not a fixed absolute request count, since OG traffic volume itself scales with how many
permalinks are actively being shared in a given week.
15. Catalog Browse, Search & Filtering #
15.1 Catalog purpose and relationship to the designer #
The catalog is a standalone browsing surface for players who want to explore available options before or without using the designer (Section 11), for search engines indexing individual assets and hues (Section 10.6), and as the landing surface for shared links to specific items. It is never required to use the designer — every asset and hue is also reachable inside the designer's own option panel (Section 11.6) — but it offers deeper metadata (first-seen build, tags, related items) that the compact in-designer panel omits.
The hand-off from catalog to designer is one direction only: catalog pages never read or write
designSig; they are plain navigations. The "Use in Designer" action (15.4) constructs a transient
query string (Section 13.6) representing a single-slot design and navigates to /, where the designer
parses it exactly as it would any shared link. No client-side state is shared between the two surfaces
beyond that URL.
15.2 /catalog overview page #
Specified structurally in Section 10.3.3 (IA/routing owner). This section owns its content model:
- Slot cards: one per row of the 19-row taxonomy in Section 3 — the required
bodyslot and all 18 choosable cosmetic slots,backpackincluded (Section 11.6 lets the visitor change its asset and hue like any other slot; it defaults tobackpack.defaultat hue 0 when untouched). Each card shows: slot display name, published asset count (asset_variantsrows withassets.retired_at IS NULLandasset_imagespresent), a representative thumbnail (most-recently-published asset in that slot), and a gender-availability icon pair (male/female) reflecting whether the slot has at least one asset for each body. - Featured/new assets: a horizontal strip above the slot grid, titled "Recently added", showing up to
12 assets across all slots published in the most recent
game_buildsrow, sorted byasset_variants.published_atdescending. Omitted entirely (strip not rendered) when the most recent build introduced zero new assets. - Hue groups entry: a single card, visually distinct (accent border) from the slot cards, linking to
/hues, showing the total published hue count.
15.3 /catalog/:slotKey — grid, filters, query parameters #
Grid layout: responsive card grid per Section 10.8's breakpoint table (2/3/4 columns at
base–sm/md/lg+). Each AssetCard:
interface AssetCard {
assetKey: string;
displayName: string;
thumbnailUrl: string; // /render/a/:assetKey/:body/0@2.webp, body = current filter or "m"
bodies: ("m" | "f")[];
isNew: boolean; // published in the current game_builds row
isRetired: boolean;
tags: string[]; // up to 3 shown, rest collapsed behind a "+N" chip
}Card anatomy: thumbnail (top, a 1:1 card region containing the 260:330 art via object-fit: contain
per Section 12.4, transparent-background composited on the surface token per Section 18.2), display
name (below, single line, ellipsis-truncated), gender-availability icon pair
(small, bottom-left), "New" chip (accent, top-right corner, shown only when isNew), "Retired" chip
(muted, top-right corner, shown only when isRetired; mutually exclusive with "New" by construction
since a retired asset cannot be new). The whole card is one link to
/catalog/:slotKey/:assetKey.
Sorting options: name (default, ascending display name, locale-aware collation), newest
(published_at descending), most_used (stat_counters use count descending, Section 21.2). Exposed
via a segmented control (Section 18.5) above the grid.
Filter panel fields:
| Filter | UI control | Query param | Values |
|---|---|---|---|
| Body | segmented control, 3 options | body |
m, f, omitted = both |
| Tags | multi-select chip list, searchable if the slot has more than 20 tags | tag |
repeatable, tag slugs |
| New in build | toggle | newOnly |
true | omitted |
| Show retired | toggle, off by default | showRetired |
true | omitted |
There is no hue-group filter on the asset grid: hue application is universal (any huable pixel accepts
any hue, Section 8.3), so an asset does not itself belong to a hue group — only individual hues do
(hue_group_members). Hue-group browsing lives on /hues and /hues/:hueIndex (15.5) and inside the
hue picker's grouped tabs (Section 11.6), not as an asset-list facet.
Full query parameter grammar for /catalog/:slotKey:
?sort=name|newest|most_used
&body=m|f
&tag=<slug> (repeatable)
&newOnly=true
&showRetired=true
&cursor=<opaque>
&limit=1..100 (default 24)These map directly onto the GET /api/v1/assets query parameters owned by Section 16; the page's
server render performs the same request server-side for first paint, then CatalogFilterBar
(Section 11's sibling island, defined here since it is Section 15's component) re-issues it
client-side on any control change, replacing the grid in place and updating the URL via
history.pushState (unlike the designer's replaceState pattern in Section 11.4 — catalog filter
states are meaningful, shareable, back-button-navigable pages, not micro-edits).
Filter facet counts shown next to each tag chip (e.g. "Metal (12)") are computed with one aggregate query, executed alongside the primary list query for the current slot and any already-applied filters from the OTHER dimensions (so picking a body filter narrows the tag counts but the tag dimension never narrows its own counts, letting the visitor see what widening or changing tags would yield):
SELECT t.tag_key, t.display_name, count(DISTINCT a.id) AS count
FROM tags t
JOIN asset_tags at2 ON at2.tag_id = t.id
JOIN assets a ON a.id = at2.asset_id
JOIN asset_variants av ON av.asset_id = a.id
WHERE a.slot_key = :slotKey
AND a.retired_at IS NULL
AND (:bodyFilter IS NULL OR av.body = :bodyFilter)
GROUP BY t.tag_key, t.display_name
ORDER BY count DESC;The facet query runs with a 50ms statement timeout distinct from the main list query's timeout, so a slow facet count degrades to "count unavailable" (chip renders without a number) rather than blocking the page.
15.4 /catalog/:slotKey/:assetKey — asset detail page #
Layout: large preview (left or top, per breakpoint) with a hue chooser identical in behaviour to
Section 11.6's HuePicker component (same component, reused, props adapted to a standalone
selectedHue local signal instead of designSig); both gender variants shown side by side when
bodies.length === 2 (two preview panes, each independently reflecting the same selected hue), or a
single pane with a "Not available for the other body" note when only one variant exists.
Metadata panel: slot (linking back to /catalog/:slotKey), first-seen build label and date (from
game_builds.published_at of the build referenced by the asset's earliest asset_variants row),
tags (each a link to /catalog/:slotKey?tag=<slug>), retirement status if applicable (date retired,
plain statement that it remains permanently available in any design that used it, referencing the
soft-delete convention in Section 6).
Actions:
- "Use in Designer": navigates to
/?b=<currentBody>&s=0&<slotKey>=<assetKey>:<selectedHue>(or theskinHue/no-slot-param form whenslotKey === "body", since body has noassetKey, only a hue), i.e. a single-slot transient design (Section 13.6). All other slots are left at their default empty state — this action does not attempt to merge with any prior designer session. - "Copy Asset Key": copies the raw
assetKeystring to the clipboard (same clipboard/fallback pattern as Section 11.9's Share button), toast "Asset key copied".
Related assets: up to 6 assets sharing at least one tag with the current asset, same slot, excluding
the current asset itself, sorted by shared-tag count descending then published_at descending. Empty
related list renders no section at all (heading and grid both omitted), never an empty-state message —
this is a supplementary panel, not a primary content area.
15.5 /hues and /hues/:hueIndex #
/hues: tabs across the top, one per hue_groups row, ordered by the group's sort_order column
(Section 6). Active tab's swatch grid below: one 32×32 swatch per hue in the group, showing the sRGB
conversion of the hue's table index 16, the ramp's visual midpoint (same convention as Section 11.6's
swatch rendering, Section 8.3.1), each
swatch linking to /hues/:hueIndex.
/hues/:hueIndex detail:
- Hue name and index, heading format
Hue <hueIndex> — <name>. - The full 32-colour ramp: a horizontal strip of 32 swatches, one per palette entry, each showing its
index (0–31) on hover/focus via
titleattribute and an accessible labelShade <n> of 32. - Sample application: the hue applied live (client-side canvas, same hue-application algorithm as
Section 8.3, reused as a small standalone component
HueSwatchPreview) to the fixed reference asset configured per slot family (Section 10.3.7 names the mechanism:core/hue-preview-reference.ts). A small selector lets the visitor switch the reference asset among the 3 configured families (hair,torso_inner,body) without leaving the page. - Copy-hue-number action: copies the bare
hueIndexinteger as a string, toast "Hue number copied".
15.6 Search #
Search grammar, parsed by a hand-written tokenizer in core/search/tsvector.ts (no external
query-language library):
<query> ::= <term>*
<term> ::= <field> ":" <value> | <freetext>
<field> ::= "slot" | "hue" | "tag" | "body"Examples and their resolution:
| Query | Interpretation |
|---|---|
long hair |
free text: both terms ANDed against search_tsv |
slot:hair hue:1102 |
slot filter hair AND hue filter exact index 1102 |
tag:wig-stand body:f red |
tag filter wig-stand AND body filter f AND free text red |
hue:crimson |
hue filter by name, trigram match against hues.name |
tag:wig-stand tag:event |
tag filter, either tag present (OR within the field) |
color:red |
no recognized field named color; entire token falls back to free text color:red |
Field values are case-insensitive for slot, tag, and body (matched against lowercase stored
keys); hue numeric matching is exact-integer only, with no leading-zero or whitespace tolerance
beyond standard integer parsing.
Rules:
- Multiple free-text words are ANDed as separate tsquery lexemes.
- A repeated field (e.g. two
tag:terms) is ORed within that field, then ANDed against other fields — mirrors thetagquery parameter's repeatable-OR semantics in 15.3. hue:accepts either a numeric index or a quoted/unquoted hue name substring; numeric input matcheshues.hue_indexexactly, non-numeric input matcheshues.namevia trigram similarity.body:acceptsmorfonly; any other value is treated as a free-text term instead of an error, since the reader is an end user, not an API client — malformed structured terms degrade to text rather than producing a visible error.- Unrecognized field names (e.g.
color:red) degrade the same way: the wholefield:valuetoken is treated as free text.
Postgres implementation: search_tsv cannot be a GENERATED ALWAYS AS column here — Postgres rejects
a generated column whose expression contains a subquery, and aggregating an asset's tag names requires
one. search_tsv is instead a plain, indexed column maintained by a trigger (Section 6 owns
assets.search_tsv tsvector in its table definition; this section owns the expression and the trigger
that keeps it current):
-- Shared helper: computes the tsvector for one asset id from its current row plus its current tags.
-- Used by the AFTER trigger below, where the row is already committed and a fresh SELECT is safe.
CREATE OR REPLACE FUNCTION compute_asset_search_tsv(p_asset_id text) RETURNS tsvector AS $$
SELECT
setweight(to_tsvector('english', coalesce(a.display_name, '')), 'A') ||
setweight(to_tsvector('english', coalesce((
SELECT string_agg(t.display_name, ' ')
FROM asset_tags at2 JOIN tags t ON t.id = at2.tag_id
WHERE at2.asset_id = a.id), '')), 'B')
FROM assets a WHERE a.id = p_asset_id;
$$ LANGUAGE sql STABLE;
-- Fires on the assets row itself (display_name changed). Reads NEW directly rather than calling
-- compute_asset_search_tsv, because on BEFORE INSERT the row is not yet visible to a sub-SELECT —
-- calling the helper here would return NULL on every newly imported asset.
CREATE OR REPLACE FUNCTION assets_search_tsv_refresh() RETURNS trigger AS $$
BEGIN
NEW.search_tsv :=
setweight(to_tsvector('english', coalesce(NEW.display_name, '')), 'A') ||
setweight(to_tsvector('english', coalesce((
SELECT string_agg(t.display_name, ' ')
FROM asset_tags at2 JOIN tags t ON t.id = at2.tag_id
WHERE at2.asset_id = NEW.id), '')), 'B');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER assets_search_tsv_biu
BEFORE INSERT OR UPDATE OF display_name ON assets
FOR EACH ROW EXECUTE FUNCTION assets_search_tsv_refresh();
-- Tag changes don't touch the `assets` row directly, so this AFTER trigger on the join table
-- re-derives and writes the affected asset's tsvector explicitly:
CREATE OR REPLACE FUNCTION asset_tags_search_tsv_sync() RETURNS trigger AS $$
DECLARE
v_asset_id text := coalesce(NEW.asset_id, OLD.asset_id);
BEGIN
UPDATE assets SET search_tsv = compute_asset_search_tsv(v_asset_id) WHERE id = v_asset_id;
RETURN NULL; -- AFTER trigger; return value is ignored
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER asset_tags_search_tsv_aiud
AFTER INSERT OR DELETE ON asset_tags
FOR EACH ROW EXECUTE FUNCTION asset_tags_search_tsv_sync();
CREATE INDEX assets_search_tsv_idx ON assets USING GIN (search_tsv);
CREATE INDEX assets_display_name_trgm_idx ON assets USING GIN (display_name gin_trgm_ops);Import (Section 7) populates search_tsv the same way: every insert/update to assets.display_name
or asset_tags fires the trigger above, so there is no separate backfill step to keep in sync.
Query shape for a free-text term combined with the slot: structured filter (hue-group membership is
keyed by hue_groups.group_key via hue_group_members, but — per 15.3 — hue group is a property of
individual hues, not of an asset, so it is never a join condition against assets/asset_variants
here; a hue: term instead resolves directly against the hues table, below):
SELECT a.*, ts_rank(a.search_tsv, query) AS rank
FROM assets a, plainto_tsquery('english', :freeTextTerms) AS query
WHERE a.search_tsv @@ query
AND (:slotKey IS NULL OR a.slot_key = :slotKey)
AND a.retired_at IS NULL
ORDER BY rank DESC, a.display_name ASC
LIMIT :limit;A hue: term is resolved separately against the hues table (hues.hue_index for numeric input,
hues.name via trigram similarity for text input) and the two result sets are intersected by the
slots each hue's matching assets occupy; a bare hue:<index> search with no other terms returns the
/hues/:hueIndex page's own content directly (15.5) rather than an asset list.
Typo tolerance: when plainto_tsquery returns zero rows for a free-text term, a fallback query re-runs
using similarity(display_name, :term) > 0.3 (the pg_trgm extension, same GIN index as above),
ordered by similarity(...) descending. This fallback only triggers on a true zero-result first pass,
never blended with tsvector-ranked results, to keep ranking predictable.
Ranking weights: display name matches are weight A (1.0), tag matches are weight B (0.4) — the
standard ts_rank default weight set {0.1, 0.2, 0.4, 1.0} for D,C,B,A is used unmodified. "Most
Used" sort (15.3) is independent of search ranking and only applies when no free-text term is present;
combining a free-text search with sort=most_used is rejected at the API validation layer (Section
16) in favor of relevance ranking, since the two orderings are not composable.
Result limits: search results follow the same cursor pagination as the rest of the catalog (Section
16), default limit=24, max limit=100. A free-text search additionally caps total scanned rows at
1,000 via LIMIT 1000 in a wrapping CTE before ranking, to bound query cost on the trigram fallback
path; this cap is invisible to the user (no UI ever shows "1000+ results", pagination simply ends).
15.7 Empty, zero-result and error states #
| State | Trigger | Copy | Suggested action |
|---|---|---|---|
| Slot has zero published assets | publishedAssetCount === 0 on /catalog/:slotKey |
"No options available for this slot yet. Check back after the next update." | Link: "See the changelog" (/changelog) |
| Zero results after filtering | Filters applied, meta.count === 0 |
"No results match your filters." | Button: "Clear filters" (resets query string to bare /catalog/:slotKey) |
| Zero results from search | Free-text term present, meta.count === 0 after both tsquery and trigram fallback |
"No matches for "". Try a different search term." | Button: "Clear search" |
| Unknown slot key | Route-level, Section 10.9 owns as 404 | (404 page copy) | — |
| Unknown asset key | Route-level, Section 10.9 owns as 404 | (404 page copy) | — |
| Backend/query error | Any unhandled exception in the catalog data loader | Section 10.9's 500 page | — |
15.8 Pagination behaviour #
Cursor model per Section 16. UI presentation is a "Load more" button below the grid, not automatic infinite scroll (unlike the designer's option panel, 11.6) — the standalone catalog favors an explicit action so the page remains a normal, indexable, back-button-friendly page with a bounded initial payload for SEO crawlers, which do not execute scroll-triggered fetches.
"Load more" button: appears when meta.nextCursor !== null, disabled with a spinner while the next
page is in flight, replaced by the newly appended cards otherwise; removed entirely once
meta.nextCursor === null. Clicking it updates the URL's limit parameter via history.replaceState
(not pushState — intermediate pagination cursors are not meaningful back-button stops, only the
initial filtered view is) to 24 × pagesLoaded and carries no cursor parameter, so a page reload
replays from the start as a single larger first page rather than resuming mid-list — limit and
cursor are independent parameters, and the "Load more" path only ever uses limit.
Accessible announcement: the grid container has aria-live="polite"; on successful load-more, a
visually hidden status message is written: "Loaded more results, total" using meta.count
values, so screen reader users get confirmation without the entire grid being re-announced.
15.9 Deep-linkable filter state, canonical URLs, indexability #
Every filter combination is representable in the URL query string (15.3's grammar) and is shareable/bookmarkable. Canonical URL rules:
- Parameters are always emitted in a fixed order (
sort,body,tag,newOnly,showRetired,cursor,limit) when the page itself constructs a URL (filter changes, load-more), so two users applying the same filters in a different UI order still land on byte-identical URLs. sort=name(the default) andlimit=24(the default) are omitted from constructed URLs entirely rather than written explicitly, keeping default-state URLs minimal (bare/catalog/:slotKey).- A
<link rel="canonical">tag is emitted using the fully-normalized URL (defaults omitted, fixed param order) even when the incoming request URL differs only in param order or included explicit defaults, so search engines consolidate equivalent URLs.
Indexable filter combinations: only the bare /catalog/:slotKey (no query string) and
/catalog/:slotKey?body=m / ?body=f are indexable (index, follow); every other filter combination
(tag, search, newOnly, showRetired, pagination cursor) is noindex, follow — indexable via
crawling but not intended as a search-landing page, since the combinatorial space of filters is large
and low-value for search while the bare and body-only views cover the meaningful "browse this slot"
intent. This mirrors the pattern already used for the transient designer query string in Section 10.6.
15.10 Performance #
Query budgets: every catalog list query (grid, filter facets, search) targets p95 under 80ms measured
at the database, and under 200ms end-to-end including the WASM-free JSON response assembly, consistent
with the API latency budget Section 19.1 sets for /api/v1/*.
Index usage: the GIN indexes in 15.6 back all text search; standard B-tree indexes on
assets(slot_key, retired_at), asset_variants(asset_id, body), and asset_tags(tag_id, asset_id)
(Section 6 owns their formal definitions) back the structured filters so no filtered list query
requires a sequential scan at expected catalog sizes (low thousands of asset rows).
Thumbnail payload per page: at the default limit=24 and the WebP thumbnail size used by
AssetCard (2x scale per Section 8.6, roughly 6–12KB per image based on sprite complexity), a first
catalog page's image payload is budgeted at under 300KB total, enforced informally by the fixed
thumbnail dimensions and format rather than per-request compression tuning.
Caching: catalog list responses follow the JSON API cache class (Section 19.4: short s-maxage with
stale-while-revalidate, since catalog contents change only on an admin publish action, Section
17.5); rendered thumbnail images follow the content-addressed immutable render cache class (Section
8.9 / Section 19.4). Search results are never cached server-side beyond the standard JSON API class —
free-text queries have too large a cardinality for a dedicated cache layer, and the underlying Postgres
query is already fast enough (budget above) that a cache would add complexity without a measurable
latency win.
16. Public HTTP API #
16.1 Base URL, versioning policy, deprecation policy #
Base URL: /api/v1, served from the same Deno process as the public site (Section 4). The API is
public and unauthenticated end to end — it is the same API the front end's islands (Section 11) call,
and any third party may call it directly under the polite-use terms in 16.11.
Versioning policy: the version segment (v1) bumps only on a breaking change — removing a field,
changing a field's type or meaning, removing an endpoint, or changing an error code's meaning. Adding a
new optional field to a response, adding a new endpoint, adding a new query parameter, or adding a new
enum value to a field that is documented as open-ended, are all additive and never bump the version.
Deprecation policy: when v2 ships, v1 remains fully functional for a minimum of 12 months
from v2's release, with deprecation communicated three ways: (1) a Deprecation: true and
Sunset: <HTTP-date> header pair on every v1 response from the day v2 ships, per RFC 8594; (2) an
entry in the public /changelog page (Section 10); (3) the /api/v1/version endpoint (16.8.18)
reporting "deprecated": true and "sunset": "<ISO-8601 date>". There is no v2 in scope for this
specification; this subsection defines the policy that governs one if it is ever built.
16.2 Request conventions #
- Methods:
GETandHEADfor every read endpoint;POSTonly forPOST /designs(the sole write endpoint in the public API — everything else is read-only, matching the "read-mostly" framing of this section). NoPUT,PATCH, orDELETEexist in the public API; admin mutations live under the separate/admin/apinamespace (Section 17.17). - Content types: requests with a body (
POST /designs) must sendContent-Type: application/json; any other content type on that endpoint returnsSF-1015(415-mapped, see 16.4.2) with a 415 status. All responses areContent-Type: application/json; charset=utf-8except the non-/apiimage endpoints (16.8.19), which are owned by Section 8 and Section 14. - Accepted headers:
Accept(onlyapplication/jsonand*/*are honored; anything else that explicitly excludes JSON returns 406 withSF-1016),Accept-Encoding(see below),If-None-Match/If-Modified-Since(16.6),X-Request-Id(optional client-supplied request id, echoed back — see below). Accept-Encoding: the server transparently appliesbr(Brotli) orgzipcompression based on the client'sAccept-Encodingheader, using Deno's built-in compression stream support at the HTTP layer; every JSON response is eligible. Responses under 512 bytes are sent uncompressed (compression overhead exceeds the saving).- Request id generation and echo: every request is assigned a ULID-based request id
(
jsr:@std/ulid, Section 4) at the top of the middleware chain. If the client suppliedX-Request-Idon the request, that value is used verbatim (allowing client-side request tracing to correlate); otherwise the server generates one. The id is echoed on the response asX-Request-Idand embedded in the error envelope'srequestIdfield (16.4) when an error occurs, and in every log line for that request (Section 21). - CORS policy:
Access-Control-Allow-Origin: *on every/api/v1/*response — the API is intentionally open to cross-origin fetches from any web page, since it serves no user-specific or sensitive data (no accounts, no cookies read by these routes) and 16.11 explicitly invites third-party use.Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS.Access-Control-Allow-Headers: Content-Type, X-Request-Id, If-None-Match.Access-Control-Max-Age: 86400. PreflightOPTIONSrequests are answered directly by the CORS middleware with a 204 and these headers, before reaching any route handler. Credentials (Access-Control-Allow-Credentials) are never enabled — the public API never reads or sets cookies, so credentialed CORS has no purpose here and enabling it would be a needless attack-surface increase. HEADsupport: everyGETendpoint also answersHEADwith identical headers (includingETag,Cache-Control,Content-Length) and an empty body, implemented generically by running the full handler and stripping the body, not by separate handler code paths.
16.3 Success envelope #
Canonical shape, used by every successful response in the public API:
{
"data": { "...": "endpoint-specific payload, object or array" },
"meta": { "...": "optional; present only when the endpoint defines pagination or extra context" }
}Rules:
datais always present. Its shape is a single resource object for singular endpoints (GET /designs/{code}) or an array of resource objects for collection endpoints (GET /assets).metais present only on endpoints that document it — primarily paginated collections (meta.nextCursor,meta.count, 16.5) and a small number of endpoints with extra context (GET /searchincludesmeta.tookMs). An endpoint that does not documentmetanever includes it, not even asnullor{}.- No top-level keys other than
dataandmetaever appear on a success response.
Example — singular resource (GET /slots/{slotKey}):
{
"data": {
"slotKey": "hair",
"displayName": "Hair",
"zOrder": 130,
"hueable": true,
"genderScope": "both",
"choosable": true
}
}Example — paginated collection (GET /assets?slot=hair):
{
"data": [
{ "assetKey": "hair.long-wavy", "slotKey": "hair", "displayName": "Long Wavy" },
{ "assetKey": "hair.short-crop", "slotKey": "hair", "displayName": "Short Crop" }
],
"meta": { "nextCursor": "eyJrIjoiY3JlYXRlZF9hdCIsInYiOiIyMDI2LTAxLTAxIiwidCI6IjAxSjhaSzNRTkdYUThONkM0VDJNMUZBVkIyIn0.qX3nR8vM2wZpL5tK9cB1yD7hF0eN4sJ6", "count": 2 }
}16.4 Error envelope #
16.4.1 Canonical definition #
Every non-2xx JSON response uses exactly this shape:
{
"error": {
"code": "SF-2001",
"message": "That item isn't in our catalog.",
"field": "slots[0].asset",
"details": [],
"requestId": "01J8X9QK3R7VZQZ8N6C4T2M1FA"
}
}| Field | Type | Always present | Notes |
|---|---|---|---|
code |
string | yes | SF- + 4 digits, see 16.4.2. Stable identifier for programmatic handling — clients should branch on code, never on message. |
message |
string | yes | Human-readable English sentence. Not localized (Section 3's locale decision: English only in v1). Safe to display directly to end users. |
field |
string | null |
yes (may be null) |
Dot/bracket path into the request body or query identifying the offending value, using the same path syntax Zod issues use (e.g. slots[0].asset, limit). null when the error is not attributable to one field (e.g. not-found, rate-limit, internal). |
details |
array | yes (may be []) |
Populated only for multi-field validation errors (16.4.3); each entry has the same shape as the top-level error object minus requestId and details itself (no nesting beyond one level). |
requestId |
string | yes | The ULID from 16.2, always present so a user can quote it when reporting an issue. |
16.4.2 Code shape and HTTP status mapping #
SF- followed by exactly 4 digits, grouped by leading digit into families. This section defines the
shape and the family-to-HTTP mapping; Section 25 owns the exhaustive per-code registry.
| Family | Code range | HTTP status | Meaning |
|---|---|---|---|
| Validation | SF-1000–SF-1999 |
400 (422 for semantically invalid but syntactically well-formed input, e.g. unknown enum value) / 415 (wrong content type) / 406 (unacceptable Accept) |
Request shape or values are invalid. |
| Not found | SF-2000–SF-2999 |
404 (unknown resource) / 410 (resource existed but is permanently gone — retired design, takedown) | Requested resource does not resolve. |
| Rate limit | SF-3000–SF-3999 |
429 | Client exceeded a rate-limit bucket (16.7). |
| Auth | SF-4000–SF-4999 |
401 / 403 | Reserved for the admin API (Section 17.17); the public API in this section never returns this family, since it requires no authentication. Listed here for completeness of the shape. |
| Render | SF-5000–SF-5999 |
500 (data/pipeline fault) / 502 (upstream object storage failure) / 503 (render queue back-pressure) / 504 (generation exceeded SKINFORGE_RENDER_TIMEOUT_MS) |
Rendering pipeline failure (Section 8) surfaced through an API response, e.g. GET /designs/{code}/render-urls when the underlying build data is unreadable. |
| Import | SF-6000–SF-6999 |
— (409 for SF-6012) |
Reserved for the admin/CLI import pipeline (Section 7, Section 17.5); the public API returns exactly one code from this family, SF-6012 (409, no build has ever been published yet — 16.8.14), since that is fundamentally a state of the import/publish pipeline rather than a validation or not-found condition. |
| Internal | SF-9000–SF-9999 |
500 (unexpected server error) / 502 (non-render-path storage unreachable) / 503 (maintenance mode active) | message is a generic "Something went wrong. Please try again." — never leaks internal detail; the real cause is in the server log keyed by requestId (Section 21). |
16.4.3 Multi-field validation errors #
When a request fails validation on more than one field at once (for example, POST /designs with both
an invalid body and an invalid slot entry), the response uses a single top-level error whose code
is SF-1000 (generic "request failed validation"), whose field is null, whose message is
"Request failed validation. See details." and whose details array carries one entry per offending
field, each with its own code (a more specific code where one applies, otherwise SF-1000
again), message, and field:
{
"error": {
"code": "SF-1000",
"message": "Request failed validation. See details.",
"field": null,
"details": [
{ "code": "SF-1003", "message": "Choose a valid body type.", "field": "body" },
{ "code": "SF-2001", "message": "That item isn't in our catalog.", "field": "slots[0].asset" }
],
"requestId": "01J8X9QK3R7VZQZ8N6C4T2M1FA"
}
}A single-field validation failure is reported with details: [] and the specific code/field/message
promoted to the top level directly (no redundant single-entry details array), matching the shape shown
in 16.4.1.
16.5 Pagination #
Cursor-based only — chosen for stability under concurrent writes (16.5.4) and to avoid the cost of a
COUNT(*)/offset scan on large tables. No endpoint anywhere in this API accepts offset or page
parameters.
16.5.1 Cursor format #
A cursor is an opaque, HMAC-signed, base64url-encoded payload of the form
base64url(JSON) + "." + base64url(HMAC-SHA256(JSON, cursorKey)), where JSON is
{"k": "<sort-key-name>", "v": "<sort-key-value-at-boundary>", "t": "<ULID tie-breaker>"} and
cursorKey is an HKDF subkey derived from SKINFORGE_ADMIN_SESSION_SECRET with the fixed info string
"skinforge-cursor-v1" (Section 20.2) — a subkey, not the raw secret, since the public API has no
admin context and must not share key material directly. Clients never construct or parse a cursor;
they only ever pass back a nextCursor value verbatim. A cursor whose signature does not verify,
whose k does not match the endpoint's expected sort key (for example, a cursor from a
differently-filtered request), or which was signed under a rotated secret, returns SF-1010 (400) —
"That page reference isn't valid; start from the first page."
Example, decoded for illustration only (the . separator divides the payload from the signature; the
signature bytes below are an illustrative placeholder, not a computed value):
{ "k": "created_at", "v": "2026-01-01T00:00:00.000Z", "t": "01J8ZK3QNGXQ8N6C4T2M1FAVB2" }Encoded form (what actually appears in URLs and meta.nextCursor):
eyJrIjoiY3JlYXRlZF9hdCIsInYiOiIyMDI2LTAxLTAxVDAwOjAwOjAwLjAwMFoiLCJ0IjoiMDFKOFpLM1FOR1hROE42QzRUMk0xRkFWQjIifQ.qX3nR8vM2wZpL5tK9cB1yD7hF0eN4sJ6
Each endpoint documents its own sort key (k) in 16.8; most default to created_at descending with a
tie-breaker on the row's ULID primary key ascending, carried in the payload's t field (ULIDs are
lexicographically time-ordered, so this gives a fully stable total order with no duplicate or skipped
rows across pages even under concurrent inserts).
16.5.2 Parameters and bounds #
cursor(query, optional): an opaque cursor from a previous response'smeta.nextCursor. Omitted or empty on the first page. A cursor that fails to decode, or whosekdoes not match the endpoint's expected sort key (for example, a cursor from a differently-filtered request), returnsSF-1010(400) — "That page reference isn't valid; start from the first page."limit(query, optional): integer, 1 to 100 inclusive, default 24. A value outside range is clamped, not rejected: values below 1 become 1, values above 100 become 100 (chosen over hard rejection because it is friendlier to third-party integrators per 16.11 and never produces a security or performance concern, unlike accepting an unbounded value).
16.5.3 Response shape #
{
"data": [ /* up to `limit` resources */ ],
"meta": { "nextCursor": "eyJrIjoi....<sig>", "count": 24 }
}meta.nextCursor is null when the returned page is the last page (exhausted). meta.count is the
number of items in this page's data array (not a total count — a total count would require an
expensive COUNT(*) and cursor pagination deliberately avoids it; endpoints that need an approximate
total say so explicitly in their own reference entry, and none in this API do).
16.5.4 Stability guarantees #
- Cursors are stable across concurrent writes: a page fetched with a given cursor never re-shows an item from an earlier page (no duplicates) and never skips an item that existed at the time the cursor was issued, because the sort key includes a unique, monotonic tie-breaker (the ULID primary key).
- Cursors are not guaranteed stable across deploys that change an endpoint's default sort key — this
would be a breaking, version-bumping change per 16.1, so it cannot happen within
v1. - Cursors do not expire on a timer, but every outstanding cursor is invalidated when
SKINFORGE_ADMIN_SESSION_SECRETis rotated (Section 24.5); a client receivingSF-1010restarts from the first page. - A cursor referencing a since-retired filter combination (for example, a
slotvalue that no longer exists) returns an emptydata: []withnextCursor: nullrather than an error, since the absence of matching rows is a valid, representable result.
16.5.5 Worked example #
Request 1: GET /api/v1/assets?slot=hair&limit=2
{
"data": [
{ "assetKey": "hair.long-wavy", "slotKey": "hair" },
{ "assetKey": "hair.short-crop", "slotKey": "hair" }
],
"meta": { "nextCursor": "eyJrIjoiY3JlYXRlZF9hdCIsInYiOiIyMDI1LTExLTAzVDA5OjEyOjAwLjAwMFoiLCJ0IjoiMDFKOFpLM1FOR1hROE42QzRUMk0xRkFWQjMifQ.r7Mx2Ns5Vp0Lc9wK1yE6bT3dH8fQ4jZ7", "count": 2 }
}Request 2: GET /api/v1/assets?slot=hair&limit=2&cursor=eyJrIjoiY3JlYXRlZF9hdCIsInYiOiIyMDI1LTExLTAzVDA5OjEyOjAwLjAwMFoiLCJ0IjoiMDFKOFpLM1FOR1hROE42QzRUMk0xRkFWQjMifQ.r7Mx2Ns5Vp0Lc9wK1yE6bT3dH8fQ4jZ7
{
"data": [
{ "assetKey": "hair.buzz-cut", "slotKey": "hair" },
{ "assetKey": "hair.braided", "slotKey": "hair" }
],
"meta": { "nextCursor": null, "count": 2 }
}nextCursor: null signals the client to stop paging.
16.6 Caching and conditional requests #
Applies to every /api/v1/* JSON endpoint (image endpoints under /render and /og follow Section 8
and Section 14's own cache rules, referenced in 16.8.19; the health checks referenced in 16.8.17 are
owned entirely by Section 21.7, including their own caching rule).
ETag: every response carries a strongETagcomputed as"sha256:<first16hexchars>"of the serialized response body (thedata+metaJSON, before compression). Catalog endpoints (slots/assets/hues/hue-groups/tags/builds) additionally fold the current publishedbuild_idinto the tag input, so theETagchanges automatically the moment a new build publishes (Section 9), without any endpoint-specific invalidation code.Last-Modified: set to the endpoint's most relevant timestamp — for a catalog resource, its row'supdated_at; for a collection, themax(updated_at)across the page's rows; forGET /designs/{code}, the design'screated_at(designs are immutable, so this never changes, per Section 13.7).If-None-Match: if the client's suppliedETagmatches the freshly computed one, the server returns 304 Not Modified with no body and only cache-relevant headers (ETag,Cache-Control,X-Request-Id) — computed by generating the full response internally and comparing tags (simple, since these endpoints are cheap reads against an indexed schema), not by a separate cheaper existence check.If-Modified-Since: honored as a fallback whenIf-None-Matchis absent, compared againstLast-Modifiedat one-second granularity per HTTP semantics.Cache-Controlper endpoint class:
| Endpoint class | Cache-Control |
Rationale |
|---|---|---|
| Catalog reads (slots, assets, hues, hue-groups, tags, builds) | public, max-age=60, s-maxage=300, stale-while-revalidate=3600 |
Changes only on admin publish (Section 9), which is infrequent; short browser TTL, longer shared/CDN TTL. |
GET /designs/{code} |
public, max-age=31536000, immutable |
Designs never change after creation (Section 13.7); safe to cache forever. |
GET /designs/random |
no-store |
Must not be cached or it stops being random. |
GET /search |
public, max-age=30, s-maxage=60 |
Results can shift as the catalog grows; short TTL keeps results fresh without hammering the database on repeat queries. |
GET /stats/popular |
public, max-age=300, s-maxage=900 |
Aggregate stats change slowly by nature. |
GET /version |
public, max-age=60 |
Rarely changes; short TTL is enough to avoid staleness after a deploy. |
POST /designs |
no-store |
Write endpoint; never cached. |
16.7 Rate limiting #
Implemented via the Postgres-backed token bucket described in Section 4 (no Redis in v1), with an in-process LRU fast path for the common case of the same client hitting the same bucket repeatedly within a short window.
16.7.1 Buckets #
| Bucket | Scope key | Limit | Window |
|---|---|---|---|
api-read |
client IP (respecting SKINFORGE_TRUSTED_PROXY_HOPS, Section 24) |
SKINFORGE_RATE_LIMIT_API_PER_MINUTE (default 120/min, Section 24) |
rolling 60s |
render |
client IP (respecting SKINFORGE_TRUSTED_PROXY_HOPS, Section 24) |
SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE (default 60/min, Section 24) |
rolling 60s |
design-create |
client IP | SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR (default 30/hour, Section 24) |
rolling 3600s |
search |
client IP | 30/min (fixed; folded into api-read accounting but capped independently to protect the trigram query in 16.8.9 from abuse) |
rolling 60s |
api-read covers every GET/HEAD endpoint under /api/v1 in this section except GET /designs/random,
which is excluded from rate limiting entirely (random-design requests are cheap indexed lookups and are
a core, expected-to-be-frequent interaction on the homepage per Section 10). The render bucket
separately covers image requests under /render/* and /og/* (Section 8, Section 14) — not part of
the /api/v1 namespace, but sharing this section's rate-limiting mechanism and header conventions.
Health-check requests (Section 21.7) are excluded from rate limiting entirely, since a monitor probing
liveness/readiness on a tight interval must never itself trip a limit. design-create covers only
POST /designs and stacks with, rather than replaces, api-read.
16.7.2 Headers #
Every response, whether or not the client is being limited, carries:
RateLimit-Limit: 120
RateLimit-Remaining: 117
RateLimit-Reset: 42(RateLimit-Reset is seconds until the current window resets, per the IETF RateLimit header draft
adopted here as the concrete format.) A response that exceeds the bucket additionally carries:
Retry-After: 42(seconds, matching RateLimit-Reset at the moment of the 429).
16.7.3 429 body #
{
"error": {
"code": "SF-3002",
"message": "You've created a lot of designs recently. Please wait before creating another.",
"field": null,
"details": [],
"requestId": "01J8X9QK3R7VZQZ8N6C4T2M1FA"
}
}SF-3000 is the generic read-rate-limit code (api-read, search buckets); SF-3001 is specifically
the render bucket; SF-3002 is specifically design-create — so clients can distinguish "back off
browsing" from "back off requesting images" from "back off creating."
16.8 Endpoint reference #
All paths below are relative to /api/v1. Every endpoint supports HEAD per 16.2 and returns the
error envelope (16.4) for its documented error cases; unlisted errors (malformed JSON body, wrong
Content-Type, unacceptable Accept) apply uniformly per 16.2/16.4 and are not repeated per endpoint.
16.8.1 GET /slots #
Purpose: list the full slot registry (Section 3) so clients can build slot pickers without hardcoding the taxonomy.
Query parameters: none.
Response — data: array of SlotResource (16.9), every row from the Section 3 registry with
visible = true (WHERE visible = true, Section 6.9 and 17.9's toggle) — up to all 19, including
body and backpack when they are visible — ordered by zOrder ascending. No pagination (meta
absent) — the set is small and fixed.
Example request: GET /api/v1/slots
Example response:
{
"data": [
{ "slotKey": "body", "displayName": "Body", "zOrder": 10, "hueable": true, "genderScope": "both", "choosable": false },
{ "slotKey": "hair", "displayName": "Hair", "zOrder": 130, "hueable": true, "genderScope": "both", "choosable": true },
{ "slotKey": "facial_hair", "displayName": "Beard", "zOrder": 140, "hueable": true, "genderScope": "male", "choosable": true }
]
}Errors: none beyond the common set. Caching: catalog class (16.6). Rate class: api-read.
16.8.2 GET /slots/{slotKey} #
Purpose: fetch one slot's definition.
Path parameters: slotKey (string, must match a known slot_key).
Response — data: single SlotResource.
Example request: GET /api/v1/slots/hair
Example response:
{ "data": { "slotKey": "hair", "displayName": "Hair", "zOrder": 130, "hueable": true, "genderScope": "both", "choosable": true } }Errors: SF-2004 (404) if slotKey does not match any known slot. Caching: catalog class. Rate class:
api-read.
16.8.3 GET /assets #
Purpose: browse published assets, the primary data source for both the catalog (Section 15) and the designer's per-slot pickers (Section 11).
Query parameters:
| Param | Type | Constraint | Default |
|---|---|---|---|
slot |
string | must be a slot_key |
none (unfiltered) |
body |
string | m | f |
none (returns assets compatible with either) |
tag |
string | a known tag key; repeatable (tag=fantasy&tag=dark); repeated values are ORed within the field, then the field's result is ANDed against every other filter |
none |
q |
string | free text, 1–200 chars, matched against display name via trigram search (same engine as 16.8.9) | none |
sort |
string | name | newest | most_used; most_used is rejected with SF-1019 (422) when q is present, since relevance and popularity orderings are not composable (Section 15.6) |
newest |
newOnly |
boolean (true) |
restricts to assets first published in the current game_builds row |
omitted (unfiltered) |
showRetired |
boolean (true/false) |
when true, includes assets with a non-null retired_at |
false |
cursor, limit |
see 16.5 | — | — |
Cursor sort key (k, 16.5.1): created_at by default and when sort=newest; display_name when
sort=name; use_count when sort=most_used.
Response — data: array of AssetResource (16.9). meta: pagination (16.5).
Example request: GET /api/v1/assets?slot=hair&body=f&limit=2
Example response:
{
"data": [
{
"assetKey": "hair.long-wavy",
"slotKey": "hair",
"displayName": "Long Wavy",
"genderScope": "both",
"tags": ["fantasy"],
"thumbnailUrl": "/render/a/hair.long-wavy/f/0@2.webp",
"retiredAt": null
},
{
"assetKey": "hair.short-crop",
"slotKey": "hair",
"displayName": "Short Crop",
"genderScope": "both",
"tags": [],
"thumbnailUrl": "/render/a/hair.short-crop/f/0@2.webp",
"retiredAt": null
}
],
"meta": { "nextCursor": null, "count": 2 }
}Errors: SF-1007 (400) if slot references an unknown slot key; SF-1000 (400) if tag references
an unknown value; SF-1019 (422) if sort=most_used is combined with q; SF-1010 (400) for a bad
cursor. Caching: catalog class. Rate class: api-read.
16.8.4 GET /assets/{assetKey} #
Purpose: fetch one asset's full detail, including its variants.
Path parameters: assetKey (string, AssetKeySchema shape from 13.2.5).
Response — data: single AssetResource, with an additional variants array of AssetVariantResource
(16.9) — one entry per (body, hue) combination that has a generated render, used by the designer to
know what is instantly available versus what triggers on-demand generation (Section 8).
Example request: GET /api/v1/assets/hair.long-wavy
Example response:
{
"data": {
"assetKey": "hair.long-wavy",
"slotKey": "hair",
"displayName": "Long Wavy",
"genderScope": "both",
"tags": ["fantasy"],
"thumbnailUrl": "/render/a/hair.long-wavy/f/0@2.webp",
"retiredAt": null,
"variants": [
{ "body": "m", "hue": 0, "renderUrl": "/render/a/hair.long-wavy/m/0@2.webp" },
{ "body": "f", "hue": 0, "renderUrl": "/render/a/hair.long-wavy/f/0@2.webp" }
]
}
}Errors: SF-2001 (404, and only for a truly unknown key — showRetired semantics from 16.8.3 do not
apply here, since a direct-by-key lookup always returns a retired asset, matching the immutability
guarantee in Section 13.7; a retired asset returns 200 with retiredAt set, never 404 or 410, because
it can still be referenced by existing designs). Caching: catalog class. Rate class: api-read.
16.8.5 GET /hues #
Purpose: browse the hue catalog.
Query parameters:
| Param | Type | Constraint | Default |
|---|---|---|---|
group |
string | a known hue-group key (e.g. skin) |
none |
source |
string | base | outlands | derived (Section 3) |
none |
q |
string | free text against hue name, 1–100 chars | none |
cursor, limit |
see 16.5 | — | — |
Response — data: array of HueResource (16.9). meta: pagination.
Example request: GET /api/v1/hues?group=skin&limit=2
Example response:
{
"data": [
{ "hueIndex": 1002, "name": "Fair", "source": "base", "swatchArgb": "#F2C9A5" },
{ "hueIndex": 1003, "name": "Tan", "source": "base", "swatchArgb": "#D9A876" }
],
"meta": { "nextCursor": "eyJrIjoiaHVlX2luZGV4IiwidiI6MTAwMywidCI6IjAxSjhaSzNRTkdYUThONkM0VDJNMUZBVkI0In0.tN4pV6xR1sQ8wL2yG9cE5bM7fH3jK0oZ", "count": 2 }
}Errors: SF-1000 (400) for unknown group/source. Caching: catalog class. Rate class: api-read.
16.8.6 GET /hues/{hueIndex} #
Purpose: fetch one hue's full detail.
Path parameters: hueIndex (integer, 0–65535; 0 is a valid lookup and returns the sentinel "as-drawn"
definition: {"hueIndex":0,"name":"As Drawn","source":"base","swatchArgb":null}).
Response — data: single HueResource.
Example request: GET /api/v1/hues/1002
Example response:
{ "data": { "hueIndex": 1002, "name": "Fair", "source": "base", "swatchArgb": "#F2C9A5" } }Errors: SF-2002 (404) if hueIndex is out of the valid published range or otherwise unknown, except
0 which always resolves. Caching: catalog class. Rate class: api-read.
16.8.7 GET /hue-groups #
Purpose: list hue groups (e.g. skin, and any other curated groupings from Section 17.8), each with
its member count, for building filter UIs.
Query parameters: none. Response — data: array of { "groupKey": string, "displayName": string, "memberCount": integer }. No pagination — the set is small.
Example request: GET /api/v1/hue-groups
Example response:
{ "data": [ { "groupKey": "skin", "displayName": "Skin Tones", "memberCount": 58 } ] }Errors: none beyond the common set. Caching: catalog class. Rate class: api-read.
16.8.8 GET /tags #
Purpose: list all tags in use, for catalog filter chips (Section 15).
Query parameters: q (optional free text, 1–50 chars). Response — data: array of
{ "tagKey": string, "assetCount": integer } — tagKey is the tags.tag_key value, not the curated
display name — ordered by assetCount descending. No pagination — tags are a small, curated set
(Section 17.8's counterpart for tags is asset admin, Section 17.7).
Example request: GET /api/v1/tags
Example response:
{ "data": [ { "tagKey": "fantasy", "assetCount": 214 }, { "tagKey": "noble", "assetCount": 87 } ] }Errors: none beyond the common set. Caching: catalog class. Rate class: api-read.
16.8.9 GET /search #
Purpose: a single cross-catalog search over assets, hues, and tags, backing the site search box (Section 10, Section 15).
Query parameters:
| Param | Type | Constraint | Default |
|---|---|---|---|
q |
string | required, 1–200 chars | — |
kind |
string | asset | hue | tag | all |
all |
cursor, limit |
see 16.5 | — | — |
Response — data: array of { "kind": "asset"|"hue"|"tag", "score": number, "resource": <AssetResource | HueResource | {tagKey, assetCount}> }, ordered by score descending. meta: { "nextCursor": ..., "count": ..., "tookMs": number }.
Search implementation: PostgreSQL trigram similarity (pg_trgm extension, GIN indexes on
assets.display_name, hues.name, tags.display_name, per Section 6), score is the trigram
similarity() value, results below 0.15 similarity excluded.
Example request: GET /api/v1/search?q=wavy&kind=asset&limit=1
Example response:
{
"data": [
{ "kind": "asset", "score": 0.42, "resource": { "assetKey": "hair.long-wavy", "slotKey": "hair", "displayName": "Long Wavy" } }
],
"meta": { "nextCursor": null, "count": 1, "tookMs": 4 }
}Errors: SF-1001 (400) if q is missing; SF-1014 (400) if q is present but out of length bounds.
Caching: search class (16.6). Rate
class: search (16.7.1), stacked with api-read.
16.8.10 POST /designs #
Purpose: create (idempotently) a permalink for a design. Full semantics owned by Section 13.5; canonicalization and short-code mechanics by Section 13.3–13.4.
Request body: a DesignDocument per Section 13.2.5's Zod schema (no wrapper object — the body is the
document itself).
Example request:
POST /api/v1/designs
Content-Type: application/json
{
"v": 1,
"body": "m",
"skinHue": 1002,
"slots": [
{ "slot": "hair", "asset": "hair.long-wavy", "hue": 1102 }
]
}Example response (new design, 201):
{ "data": { "code": "7h3k9m2wq1", "created": true, "renderUrls": { "primary": "/render/d/7h3k9m2wq1@2.webp" } } }Example response (identical resubmission, 200): identical body shape with "created": false.
Errors: SF-1000 (400, multi-field wrapper, 16.4.3) for combined failures, or a single specific code for
one offending field — SF-1001 (missing required field), SF-1008 (unknown top-level key), SF-1009
(duplicate slot), SF-2001 (unknown asset key), SF-1019 (422, asset exists but belongs to a different
slot), SF-1017 (gender-incompatible asset), SF-2002 (unknown hue index); SF-3002 (429) for
rate-limit. Caching: no-store. Rate class: design-create (stacked with api-read).
16.8.11 GET /designs/{code} #
Purpose: fetch a design's full document and metadata, used by the design page (Section 13.8) and by third parties embedding design data.
Path parameters: code (string, normalized per 13.4.4 before lookup).
Response — data: DesignResource (16.9).
Example request: GET /api/v1/designs/7h3k9m2wq1
Example response:
{
"data": {
"code": "7h3k9m2wq1",
"document": {
"v": 1, "body": "m", "skinHue": 1002,
"slots": [ { "slot": "hair", "asset": "hair.long-wavy", "hue": 1102 } ]
},
"buildId": "01J7Z0N4C2E8QK3T9W1R6X5H4B",
"createdAt": "2026-08-14T10:22:31.000Z",
"takenDown": false
}
}viewCount is intentionally not part of this resource: it changes continuously (every page load,
13.10.1) while this response is cached immutable for a year (below), and a live counter has no place
in a response the cache is told never to revalidate. Aggregate popularity is exposed separately via
GET /stats/popular (16.8.16); the admin design view (17.11) reads designs.view_count directly from
the database, not through this endpoint.
Errors: SF-2000 (404) unknown or malformed code, indistinguishable by design (Section 13.4.1);
SF-2005 (410) if the design was taken down (designs.taken_down_at IS NOT NULL, Section 6) — the
standard error envelope (16.4.1) with code: "SF-2005" and field: null, and no design data; the
short code is already in the request path and is not echoed in the body. Caching:
public, max-age=31536000, immutable per 16.6 (except a taken-down design, which is no-store, since
its status could later change via restore, Section 17.11). Rate class: api-read.
16.8.12 GET /designs/{code}/render-urls #
Purpose: enumerate every render URL (all scales and formats) for a design without the client having to construct the render URL scheme itself (Section 8 owns the scheme; this endpoint is a convenience wrapper over it).
Path parameters: code.
Response — data: { "code": string, "renders": [ { "scale": 1|2|3, "format": "webp"|"png", "url": string } ] } — 6 entries (3 scales × 2 formats).
Example request: GET /api/v1/designs/7h3k9m2wq1/render-urls
Example response:
{
"data": {
"code": "7h3k9m2wq1",
"renders": [
{ "scale": 1, "format": "webp", "url": "/render/d/7h3k9m2wq1@1.webp" },
{ "scale": 1, "format": "png", "url": "/render/d/7h3k9m2wq1@1.png" },
{ "scale": 2, "format": "webp", "url": "/render/d/7h3k9m2wq1@2.webp" },
{ "scale": 2, "format": "png", "url": "/render/d/7h3k9m2wq1@2.png" },
{ "scale": 3, "format": "webp", "url": "/render/d/7h3k9m2wq1@3.webp" },
{ "scale": 3, "format": "png", "url": "/render/d/7h3k9m2wq1@3.png" }
]
}
}Errors: SF-2000 (404), SF-2005 (410, standard error envelope, no renders data) — same rules as
16.8.11. Caching: same as 16.8.11. Rate class: api-read.
16.8.13 GET /designs/random #
Purpose: fetch one random, non-taken-down, at-least-once-rendered-successfully design, backing a "surprise me" homepage feature (Section 10).
Query parameters: body (optional, m|f, filters the pool).
Implementation: TABLESAMPLE SYSTEM over designs filtered to taken_down_at IS NULL, retried up to 3
times if the sample misses (empty result), falling back to ORDER BY random() LIMIT 1 with the same
filter if all 3 sampling attempts miss (only relevant when the table is small).
Response — data: DesignResource, same shape as 16.8.11.
Example request: GET /api/v1/designs/random?body=f
Errors: SF-2000 (404) — no designs exist yet matching the filter (only possible immediately after
launch with zero designs created, or an implausible body filter — there are only two valid body
values so this is otherwise unreachable). Caching: no-store (16.6). Rate class: excluded from rate
limiting per 16.7.1.
16.8.14 GET /builds/current #
Purpose: fetch the currently published build's metadata, used by the front end to display a version footer and by third parties to know which build's data they are viewing.
Response — data: BuildResource (16.9).
Example request: GET /api/v1/builds/current
Example response:
{
"data": {
"buildId": "01J7Z0N4C2E8QK3T9W1R6X5H4B",
"label": "2026.08-outlands-patch-114",
"publishedAt": "2026-08-10T18:00:00.000Z",
"assetCount": 1842,
"hueCount": 3106
}
}Errors: SF-6012 (409) — no build has ever been published yet; only possible pre-launch, not reachable
in normal operation (16.4.2 documents this as the one Import-family code the public API returns, since
"is there a build to serve" is a state of the publish pipeline, Section 9, not a not-found condition).
Caching: catalog class. Rate class: api-read.
16.8.15 GET /builds #
Purpose: list build history (currently- and formerly-published builds only, i.e. status IN ('published', 'archived', 'rolled_back') — builds still in draft or in_review are admin-only,
Section 17.10).
Query parameters: cursor, limit (16.5), sorted by publishedAt descending.
Response — data: array of BuildResource. meta: pagination.
Example request: GET /api/v1/builds?limit=1
Example response:
{
"data": [ { "buildId": "01J7Z0N4C2E8QK3T9W1R6X5H4B", "label": "2026.08-outlands-patch-114", "publishedAt": "2026-08-10T18:00:00.000Z", "assetCount": 1842, "hueCount": 3106 } ],
"meta": { "nextCursor": "eyJrIjoicHVibGlzaGVkX2F0IiwidiI6IjIwMjYtMDgtMTAiLCJ0IjoiMDFKOFpLM1FOR1hROE42QzRUMk0xRkFWQjUifQ.wQ8rT2vN6yM0sL4xE1cB9dJ7fP5hZ3k", "count": 1 }
}Errors: SF-1010 (400) bad cursor. Caching: catalog class. Rate class: api-read.
16.8.16 GET /stats/popular #
Purpose: aggregate, non-PII popularity stats — most-used assets and hues over a trailing window, for a "trending" widget (Section 10) and community curiosity. No per-design or per-visitor data is ever exposed here (Section 20's privacy posture).
Query parameters: window (7d | 30d | all, default 7d), kind (asset | hue, default
asset).
Response — data: array of { "assetKey"?: string, "hueIndex"?: integer, "useCount": integer },
top 20, computed from page_view_daily/stat_counters aggregates (Section 6, Section 21) — never from
raw per-request logs.
Example request: GET /api/v1/stats/popular?window=7d&kind=asset
Example response:
{ "data": [ { "assetKey": "hair.long-wavy", "useCount": 812 }, { "assetKey": "robe.plain", "useCount": 754 } ] }Errors: SF-1000 (400) for unknown window/kind. Caching: public, max-age=300, s-maxage=900
(16.6). Rate class: api-read.
16.8.17 Health endpoints (referenced, not redefined) #
This section does not define its own health-check endpoint. Liveness and readiness are owned entirely
by Section 21.7: GET /health (liveness) and GET /health/ready (readiness, checks database and
object storage). Both are root-level paths, not under /api/v1, carry no data/meta success
envelope, and are excluded from rate limiting entirely (Section 21.7, Section 16.7.1) — a monitor
polling liveness on a tight interval must never itself trip a limit. GET /api/v1/version (16.8.18)
remains the one version-and-deprecation endpoint under this namespace.
16.8.18 GET /version #
Purpose: report the running application version and API deprecation status (16.1).
Response — data: { "appVersion": string (git short SHA or release tag), "apiVersion": "v1", "deprecated": boolean, "sunset": string | null }.
Example request: GET /api/v1/version
Example response: { "data": { "appVersion": "2026.09.03-a1b2c3d", "apiVersion": "v1", "deprecated": false, "sunset": null } }
Errors: none. Caching: public, max-age=60. Rate class: api-read.
16.8.19 Image endpoints (referenced, not redefined) #
/render/...— composite, per-asset, and slot-swatch image rendering. Full URL scheme, cache-key derivation, and headers are owned by Section 8./og/d/:code.png— per-design Open Graph image. Full generation trigger and caching are owned by Section 14.
These are not under /api/v1, carry no JSON envelope, and are not versioned by 16.1 — they are
content-addressed binary endpoints whose contracts live entirely in their owning sections.
16.9 Response type definitions #
All types below live in core/schema/*.ts as Zod schemas (z.object(...)) with a corresponding
inferred TypeScript type exported alongside; the API layer never hand-writes a parallel interface
that could drift from the schema actually used to build responses.
// core/schema/resources.ts
import { z } from "zod";
export const SlotResourceSchema = z.object({
slotKey: z.string(),
displayName: z.string(),
zOrder: z.number().int(),
hueable: z.boolean(),
genderScope: z.enum(["both", "male", "female"]),
choosable: z.boolean(),
});
export type SlotResource = z.infer<typeof SlotResourceSchema>;
export const AssetVariantResourceSchema = z.object({
body: z.enum(["m", "f"]),
hue: z.number().int().min(0),
renderUrl: z.string(),
});
export type AssetVariantResource = z.infer<typeof AssetVariantResourceSchema>;
export const AssetResourceSchema = z.object({
assetKey: z.string(),
slotKey: z.string(),
displayName: z.string(),
genderScope: z.enum(["both", "male", "female"]),
tags: z.array(z.string()),
thumbnailUrl: z.string(),
retiredAt: z.string().datetime().nullable(),
variants: z.array(AssetVariantResourceSchema).optional(), // present only on the single-resource endpoint (16.8.4)
});
export type AssetResource = z.infer<typeof AssetResourceSchema>;
export const HueResourceSchema = z.object({
hueIndex: z.number().int().min(0).max(65535),
name: z.string(),
source: z.enum(["base", "outlands", "derived"]),
swatchArgb: z.string().nullable(), // "#RRGGBB", null only for hueIndex 0
});
export type HueResource = z.infer<typeof HueResourceSchema>;
export const DesignResourceSchema = z.object({
code: z.string().length(10),
document: z.custom<import("./design.ts").DesignDocument>().optional(), // absent when takenDown
buildId: z.string().length(26),
createdAt: z.string().datetime(),
takenDown: z.boolean(),
});
export type DesignResource = z.infer<typeof DesignResourceSchema>;
export const BuildResourceSchema = z.object({
buildId: z.string().length(26),
label: z.string(),
publishedAt: z.string().datetime(),
assetCount: z.number().int().min(0),
hueCount: z.number().int().min(0),
});
export type BuildResource = z.infer<typeof BuildResourceSchema>;These same schemas parse and validate outbound responses in a development-mode assertion middleware (disabled in production for latency) and are the source for the generated OpenAPI document in 16.10.
16.10 Machine-readable contract #
GET /api/v1/openapi.json serves an OpenAPI 3.1 document generated at build time (not
hand-written) from the Zod schemas in 13.2.5 and 16.9, using zod-to-openapi-style schema conversion
wired into a small in-repo generator script (scripts/generate-openapi.ts) that:
- Imports every route module under
routes/api/v1/. - Reads a co-located
openapiMetaexport from each route (method, path, summary, tags, the request and response Zod schemas, and the documented error codes) — a convention enforced by a lint rule that fails CI if a route file lacksopenapiMeta. - Converts each Zod schema to a JSON Schema fragment and assembles the full OpenAPI document.
- Writes it to
static/openapi.jsonat build time; the/api/v1/openapi.jsonroute serves that static file with the catalogCache-Controlclass (16.6).
Testing (Section 22 owns the full test pyramid): a CI check runs the generator and diffs its output
against the committed static/openapi.json, failing the build if they differ — this prevents the
document from drifting from the actual route schemas. A separate contract test loads the generated
document into an OpenAPI validator and replays a fixture request/response pair per endpoint, asserting
both validate against the document.
16.11 Client usage guidance for the community #
SkinForge is an unofficial fan tool (Section 20 owns the full legal posture) and its API is intentionally open for third-party use — Discord bots, other fan sites, or personal scripts. Guidance, stated here as the canonical polite-use expectations referenced by 16.1 and 16.7:
- Respect rate limits (16.7). They are generous for interactive use and are not intended to block reasonable automation; a script that needs sustained high-volume access should cache catalog data locally rather than polling.
- Caching advice: catalog data (slots, assets, hues, hue-groups, tags, builds) changes only when a
new build publishes, at most a few times a month (Section 9). Third-party consumers should cache
these endpoints for at least the
s-maxagegiven in 16.6 and treatETag/If-None-Match(16.6) as the way to cheaply check for updates rather than re-fetching full payloads. - Attribution request: not legally required, but requested — any product or bot built on this API should credit "Outlands SkinForge (unofficial fan tool)" with a link back to the SkinForge site, consistent with the fan-project spirit and the disclaimer wording owned by Section 10.4.
- No SLA: this is a free community tool with no uptime guarantee or support contract.
GET /healthandGET /health/ready(Section 21.7) andGET /version(16.8.18) are provided so automated consumers can detect and gracefully handle downtime or version changes. - Do not scrape image endpoints to rebuild a local copy of the game's art files. The render endpoints (Section 8) serve derived, hue-composited previews for display purposes; bulk-downloading them to reconstruct source game assets is outside the intended use and outside what Section 20's legal posture covers.
16.12 Backwards-compatibility test requirements #
Every endpoint in 16.8 has a corresponding contract fixture — a stored example request and its
exact expected response shape (not exact dynamic values like timestamps or counts, which are matched by
type/format instead) — checked into the test suite under tests/contract/api-v1/. Section 22's CI
gates require:
- Every contract fixture passes against the live route handlers on every CI run.
- Any change to a response schema in 16.9 that would fail an existing fixture is treated as a breaking
change requiring either (a) the change is reverted, (b) the fixture is deliberately updated with a
changelog entry justifying why the change is additive, not breaking (reviewed by a human, not
auto-approved), or (c) if genuinely breaking, deferred to a
v2per the versioning policy in 16.1 —v1route handlers are never modified in a way that breaks an existing fixture. - The OpenAPI-document diff check from 16.10 runs in the same CI job as the contract fixtures, so a schema change and its documentation are verified together.
17. Admin Console #
The admin console is staff-only. There is no public account system anywhere in SkinForge — end users
never register, log in, or hold any kind of identity (Section 3, Section 13). Every admin capability
described in this section is reachable only after the authentication flow in 17.1–17.2 succeeds, is
served from the /admin route tree (Section 10's route list), and is excluded from search indexing per
17.16.
17.1 Access model #
Account creation: admin users are created exclusively by CLI (
skinforge-cli admin create-user --email <email> --role <owner|curator|viewer>), which prompts for a password, generates a TOTP enrollment secret, and prints a provisioning QR code (as ANSI-art in the terminal, and as a.pngwritten to the operator's working directory) plus 10 one-time recovery codes. There is no admin self-signup route, no public registration form, and no "forgot password" email flow anywhere in the system — admin auth deliberately has no email-based recovery path anywhere in this design; a locked-out admin uses a recovery code or CLI access instead (below).Password reset: there is no password-reset-by-email mechanism. A locked-out admin authenticates with a recovery code (17.2.3) to regain access, then rotates their password and/or TOTP secret from the admin users screen (17.15) or via CLI (
skinforge-cli admin reset-credentials --email <email>, which requires shell access to the host running the app — an equivalent trust boundary to SSH access, which is the accepted trust model for this staff-only tool per Section 20's threat model).Password hashing — Argon2id, parameters (fixed, not configurable per-deployment, to keep the security posture uniform and auditable):
Parameter Value Memory cost 19456 KiB (19 MiB) Time cost (iterations) 2 Parallelism 1 Hash output length 32 bytes Salt length 16 bytes, randomly generated per password These match the OWASP Password Storage Cheat Sheet's Argon2id baseline recommendation for a single-parallelism deployment target (Section 4's single-VPS-first posture). Stored as a PHC-format string (
$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>) inadmin_users.password_hash.TOTP — mandatory, no opt-out. Every admin account requires TOTP from first login; there is no "skip for now." Parameters: RFC 6238, SHA-1 (the near-universal authenticator-app default — chosen for compatibility with Google Authenticator, Authy, 1Password, etc., all of which assume SHA-1 unless told otherwise), 6 digits, 30-second step, verification window of ±1 step (accepts the previous, current, and next 30-second code to tolerate clock drift). The shared secret is a 160-bit (20-byte) random value, base32-encoded for the provisioning URI (
otpauth://totp/SkinForge:<email>?secret=<secret>&issuer=<SKINFORGE_ADMIN_TOTP_ISSUER>&algorithm=SHA1&digits=6&period=30, Section 24 owns theSKINFORGE_ADMIN_TOTP_ISSUERenv var).Session cookie: name
sf_admin, flagsHttpOnly; Secure; SameSite=Lax, value is an opaque 128-bit random session token (base64url), never a JWT — the token is a lookup key into theadmin_sessionstable (Section 6), so a session can be revoked server-side instantly (17.2, 17.15).Server-side sessions:
admin_sessionsrows carryadmin_user_id,token_hash(the cookie value is hashed with SHA-256 before storage, so a database read alone cannot yield a usable session token),created_at,last_seen_at,expires_at,ip_address,user_agent, andrevoked_at(nullable —NULLmeans live; set to the current timestamp by force-logout, 17.15, and never by a hard delete, per Section 6.23's soft-revoke convention).Idle timeout: 30 minutes of inactivity (no authenticated request) invalidates the session. Every authenticated request that succeeds updates
last_seen_atand extends the idle window.Absolute timeout: 12 hours from
created_atregardless of activity — a long-running admin session is force-expired and must re-authenticate, bounding the blast radius of a stolen cookie. Controlled bySKINFORGE_ADMIN_SESSION_TTL_HOURS(Section 24), default12; the idle timeout of 30 minutes is fixed in code, not separately configurable, to keep the security posture predictable.IP allowlist (optional control): if the database-backed setting
admin_ip_allowlist(stored inapp_settings, Section 6; edited in 17.14) is non-empty (a list of CIDR blocks), every/admin/*and/admin/api/*request is checked against it before authentication even runs; a non-matching IP gets a generic 404 (never a 403, to avoid confirming the admin console's existence to an unauthorized network position). Empty allowlist (the default) means no IP restriction — reasonable for a single small-team deployment where TOTP is the primary control, and left off by default so a locked-out staff member changing networks is never a self-inflicted outage.
17.2 Login, TOTP challenge, recovery codes, lockout #
17.2.1 Login flow #
POST /admin/api/auth/loginwith{ email, password }. On success, the server does not yet setsf_admin— it creates a short-lived (5-minute)admin_pendingcookie (same flags assf_admin) referencing a row inadmin_pending_mfa(Section 6), and responds{ "data": { "mfaRequired": true } }.POST /admin/api/auth/totpwith{ code }, authenticated by theadmin_pendingcookie. On a valid 6-digit code within the ±1-step window, the server deletes theadmin_pending_mfarow, creates a realadmin_sessionsrow, setssf_admin, clearsadmin_pending, and responds{ "data": { "redirectTo": "/admin/dashboard" } }.- A wrong password returns
SF-4002"That email or password isn't correct." (401) — this never reveals whether the email itself is a known account, only that the password/email pair was rejected. A wrong TOTP code returns the registry's distinctSF-4004"That code isn't correct." (401): revealing that the second factor was wrong carries no account-enumeration risk, because by the time a client can attempt the TOTP step it has already passed the password step (theadmin_pendingcookie only exists after a correct password) — the same reasoning 17.2.2 applies to a failed recovery code. An expired or missingadmin_pendingcookie (the TOTP step attempted with no valid pending session) returnsSF-4001"Your session has expired. Please sign in again." (401), directing the client back to step 1. Each failure also writes a row toadmin_login_failures(Section 6; 17.2.3 owns how that row feeds lockout).
17.2.2 Recovery code use #
At the TOTP step, POST /admin/api/auth/recovery with { recoveryCode } is an alternate path: each of
the 10 codes issued at account creation (17.1) is a 10-character Crockford-Base32 string (same alphabet
as Section 13.4.1, reused for consistency, not for any cryptographic relationship to short codes),
stored hashed (SHA-256) in admin_recovery_codes, one row per code, each usable exactly once
(used_at set on use). An invalid or already-used recovery code returns SF-4007 "That recovery code
isn't valid." (401) — distinct from SF-4002 (wrong password) and SF-4004 (wrong TOTP code), since by
the time a client can attempt a recovery code it has already passed the password step (the
admin_pending cookie only exists after a correct password), so this code carries no
account-enumeration risk. Using a
recovery code completes login exactly like a valid TOTP code and additionally triggers an audit event
(17.2.4) and an in-app banner on the next dashboard load: "You signed in with a recovery code. N codes
remain. Rotate your recovery codes soon" — surfaced when remaining unused codes drop to 3 or fewer,
prompting a visit to /admin/account (17.3, 17.15) to rotate.
17.2.3 Lockout policy #
- 5 consecutive failed attempts (counting failed password, failed TOTP, and failed recovery code
together — each tallied as a row in
admin_login_failures, Section 6, scoped peradmin_users.id) within a rolling 15-minute window trigger a 15-minute cooldown: further attempts for that account returnSF-3003"Too many login attempts. Try again in 15 minutes." (429) regardless of whether the credentials supplied are actually correct, until the cooldown elapses. The rolling window is computed as a count ofadmin_login_failuresrows for thatadmin_user_idwithcreated_atwithin the last 15 minutes; rows older than the window are irrelevant to the count and are pruned by routine housekeeping (Section 23), not read on the hot path. - The failure counter resets to zero on any successful full login (password + second factor) — in
practice, a successful login does not need to delete prior
admin_login_failuresrows, since only rows within the trailing 15-minute window are ever counted. - Lockout is per-account, not per-IP — an IP-based limit would let an attacker lock out a legitimate admin from a shared/proxy IP; a per-account limit only affects the targeted account and is paired with the audit trail in 17.2.4 so repeated lockouts are visible to other admins.
17.2.4 Audit events #
Every step writes an audit_log row (Section 6 owns columns; this section fixes the action values
emitted here): admin.login.password_ok, admin.login.password_fail, admin.login.totp_ok,
admin.login.totp_fail, admin.login.recovery_used, admin.login.locked_out, admin.logout,
admin.session.expired_idle, admin.session.expired_absolute. Each row records admin_user_id
(nullable — a failed login before the email resolves to a known user still logs with admin_user_id
null and the attempted email in metadata), ip_address, user_agent, created_at, and a metadata
JSON blob. Viewable in 17.13.
17.3 Navigation map and roles #
Three roles, strictly ordered by privilege: viewer < curator < owner. A role is
assigned per admin user at creation (17.1) and changeable only by an owner (17.15).
| Route | Purpose | Minimum role |
|---|---|---|
/admin |
Login | none (pre-auth) |
/admin/dashboard |
Overview tiles (17.4) | viewer |
/admin/account |
Own password, TOTP and recovery codes (17.15) | viewer |
/admin/imports |
Import activity list, grouped by build (17.5) | viewer (start/cancel needs curator) |
/admin/imports/:buildId |
Import detail for one build's stage-progress strip (17.5) | viewer (retry/cancel needs curator) |
/admin/candidates |
Candidate review queue (17.6) | curator |
/admin/assets |
Assets admin (17.7) | viewer (edit/retire needs curator) |
/admin/assets/:assetKey |
Asset detail (17.7) | viewer (edit/retire needs curator) |
/admin/hues |
Hues admin (17.8) | viewer (edit needs curator) |
/admin/slots |
Slots admin (17.9) | viewer (edit needs owner) |
/admin/tags |
Tag management | viewer (edit needs curator) |
/admin/builds |
Builds admin (17.10) | viewer (publish/rollback needs owner) |
/admin/designs |
Designs admin (17.11) | viewer (takedown/restore needs curator) |
/admin/takedowns |
Takedown inbox (17.12) | curator (resolve needs owner) |
/admin/audit |
Audit log viewer (17.13) | owner |
/admin/settings |
Settings (17.14) | owner |
/admin/users |
Admin users (17.15) | owner |
17.3.1 Permission matrix over every action #
| Action | viewer |
curator |
owner |
|---|---|---|---|
| View any admin screen and its data | yes | yes | yes |
| Start/cancel an import run | no | yes | yes |
| Approve/reject/reassign candidates | no | yes | yes |
| Edit asset metadata (name, tags, thumbnail) | no | yes | yes |
| Retire/unretire an asset | no | yes | yes |
| Edit hue metadata, grouping | no | yes | yes |
| Edit slot ordering/visibility/gender rules | no | no | yes |
| Publish or roll back a build | no | no | yes |
| Take down or restore a design | no | yes | yes |
| Resolve a takedown request (final decision) | no | no | yes |
| View audit log | no | no | yes |
| Change settings | no | no | yes |
Rotate own password / TOTP / recovery codes (/admin/account) |
yes | yes | yes |
| Create/disable admin users, force-logout, rotate another user's recovery codes | no | no | yes |
A request attempting an action above the caller's role returns SF-4005 "Insufficient permissions"
(403) from the /admin/api layer (17.17); the corresponding UI never renders the control at all for a
role that lacks it (controls are omitted, not disabled-but-visible, to avoid confusing lower-privilege
staff with actions they cannot use).
17.4 Dashboard #
/admin/dashboard — six tiles, each independently loaded (skeleton-loading per tile, so one slow query
never blocks the others), each with its own data source:
| Tile | Content | Data source |
|---|---|---|
| Active build | Build label, publish date, asset/hue counts | GET /admin/api/builds/current → game_builds row where status = 'published' order by published_at desc limit 1 |
| Pending candidates | Count of extraction_candidates awaiting staff action |
SELECT count(*) FROM extraction_candidates WHERE status IN ('pending', 'needs_operator_input') |
| Designs created (7d) | Count | SELECT count(*) FROM designs WHERE created_at > now() - interval '7 days' |
| Render cache size | Total bytes in object storage under renders/ |
Storage-driver-reported bucket/prefix size (Section 4's storage interface exposes a sizeOf(prefix) method for both fs and s3 drivers), refreshed at most every 10 minutes via a cached job result (Section 9's job runner), not computed synchronously on every dashboard load. |
| Failed jobs | Count of jobs rows with status = 'failed' in the last 24h |
SELECT count(*) FROM jobs WHERE status = 'failed' AND created_at > now() - interval '24 hours' |
| Storage usage | Total object storage bytes (all prefixes: catalog/, renders/, og/, imports/, per Section 8.9's canonical layout) |
Same storage-driver size method, same 10-minute cache |
Each tile links through to its detail screen (imports, candidates, designs, or — for cache/storage tiles — the settings screen's maintenance section, 17.14).
17.5 Imports screen and import detail #
17.5.1 Starting a run #
/admin/imports lists past import activity grouped by build — one row per game_builds row
(Section 6.4) with a rolled-up stage-progress strip built from that build's import_runs rows
(Section 6.6: one row per pipeline-stage execution, not one row per import), newest build first. Each
strip shows all eight stages (inventory → identify → probe → extract → classify → normalize → import → verify, Section 6.6's canonical stage enumeration) with the latest row's status per stage,
started_at/finished_at, triggered_by (admin user), and a summary of candidates produced. A "New
Import" button (role curator+) opens a form with two mutually exclusive source modes:
- Server path: the operator enters an absolute path on the host running
skinforge-cli, which must be run from a terminal with access to that path — the web form only records the path and enqueues a job; the actual file walk happens in the CLI process, not the web process, per Section 4's two-process architecture. This mode is only usable when staff have shell access to the host, which is the expected common case for a self-hosted operator (Section 23). - Upload: a
.zipupload of the client directory (or a relevant subset), capped atSKINFORGE_IMPORT_MAX_UPLOAD_MB(Section 24), streamed toSKINFORGE_IMPORT_WORKDIR(Section 24), unpacked, then processed identically to the server-path mode from that point on. Chosen as the fallback for operators without shell access to the deployment host.
Either mode creates a new game_builds row (status = 'draft', Section 6.4) and enqueues a jobs row
(Section 4's Postgres-backed job runner) that runs the discovery/extraction pipeline (Section 7's eight
stages) as a background job. Each stage the job executes creates its own import_runs row scoped to
(build_id, stage), with that row's status progressing queued → running → one of succeeded,
failed, cancelled independently per stage (Section 6's canonical import_runs.status vocabulary) —
a build therefore accumulates one row per stage, typically 8–16 rows counting occasional re-runs
(Section 6.6), never a single row for the whole import.
17.5.2 Live progress #
/admin/imports/:buildId polls GET /admin/api/imports/:buildId every 2 seconds while
any stage's status is queued/running, switching to no further polling once every stage has
reached a terminal state. The response includes:
stages: the latestimport_runsrow per stage for this build, each{ stage, status, startedAt, finishedAt }, in the pipeline order from Section 6.6.currentStage: thestagevalue of the most recent non-terminal run for this build, ornullif nothing is currently running.stageProgress:{ current: number, total: number }— item counts withincurrentStage(e.g. files inventoried so far / files discovered).log: an array of{ ts, level, message }entries for the current stage'simport_runsrow, append-only, capped to the most recent 500 in the API response (the full log is retained in object storage as a plain-text artifact, per below).
17.5.3 Stage-by-stage logs and artifacts #
Every stage transition writes a structured log line ({ts, level: "info", stage, message}) tied to
that stage's import_runs row, and, on completion of the verify stage (the pipeline's last, Section
6.6), the build's artifacts are written to object storage under imports/<build_id>/: the full
per-stage logs as log.ndjson, a manifest of every classified file (manifest.json), and per-candidate
normalized image sets. The import detail page links each artifact by its storage URL (owner: this
screen — object storage layout for imports is not reused by any other section).
17.5.4 Cancel, retry, states #
Each stage's import_runs.status moves through exactly these values (Section 6's canonical
vocabulary): queued → running → one of succeeded, failed, cancelled.
- Cancel (role
curator+, available while any stage's status isqueuedorrunning): sets a cooperative cancellation flag the background job checks between stage steps (never mid-file-decode, to avoid leaving corrupt partial artifacts); the currently-running stage'simport_runsrow then finalizes withstatus = 'cancelled', already-completed stages keep theirsucceededstatus unchanged, and whatever candidates the pipeline had already produced remain inextraction_candidatesfor review — cancellation does not discard partial progress. - Retry (role
curator+, available when the build's furthest-attempted stage hasstatus = 'failed'): creates a newimport_runsrow for that stage referencing the same source (path or the previously-uploaded, still-retained archive) rather than mutating the failed row — failed stage rows are kept for diagnosis, never overwritten — and resumes the pipeline forward from there.
Pipeline mechanics (what each stage actually does) are owned by Section 7; this section owns only the screen and the stage-lifecycle states.
17.6 Candidate review queue #
/admin/candidates is the highest-volume staff screen — reviewing hundreds to thousands of extracted
sprites after an import run — and is designed for speed above all else.
17.6.1 Grid layout #
A dense grid (configurable 4/6/8 columns, persisted per-admin-user in browser localStorage, never
server-side) of candidate thumbnails, each tile showing: the extracted sprite at scale 2, a confidence
badge (Section 7's classification confidence score, 0–100%, colour-coded: green ≥85%, amber 50–84%,
red <50%), the guessed slot_key and gender, and a small "previous build" thumbnail overlay toggle for
side-by-side diffing (17.6.2).
17.6.2 Side-by-side diff #
Hovering (or, on touch, tapping a compare icon) swaps the tile's image with a 50/50 split-view against
the same asset_key's art from the previous published build, when one exists (identified by matching
asset_key — a candidate whose classification assigns it a pre-existing asset_key is treated as an
update to that asset; a candidate with no matching prior key is a wholly new asset). Purely additive new
assets show no diff toggle.
17.6.3 Filters #
status (pending default, needs_operator_input, approved, rejected — Section 6's canonical
extraction_candidates.status vocabulary), slotKey, confidenceMin/confidenceMax,
isNewAsset (boolean), importRunId. Filters combine as AND and are reflected in the URL query string
so a filtered view is bookmarkable/shareable among staff.
17.6.4 Bulk actions #
Multi-select via click+shift-click range selection or "select all matching filter" (capped at 500 per
bulk action to keep the confirming operation bounded). Bulk actions: Approve selected, Reject
selected, Assign slot (opens a slot picker, applies to all selected), Assign gender (male,
female, both), Tag (opens a multi-tag picker, adds tags to all selected). Every bulk action
requires a confirmation modal stating the count and action, and writes one audit_log row per affected
candidate (not one row for the whole batch), so individual reversals/investigations remain precise.
17.6.5 Per-candidate actions #
Renaming (asset_key and display name — renaming a candidate that will become a new asset only;
renaming an already-published asset happens in 17.7, not here), slot reassignment (dropdown, filtered to
Section 3's slot registry), gender assignment, individual approve/reject.
17.6.6 Keyboard-driven review flow #
The queue supports full keyboard operation once a tile has focus (arrow keys move focus through the grid in reading order):
| Key | Action |
|---|---|
Arrow keys |
Move focus between tiles |
A |
Approve focused candidate, advance focus to next |
R |
Reject focused candidate, advance focus to next |
Space |
Toggle selection of focused candidate (for a subsequent bulk action) |
Shift+Arrow |
Extend selection range from the last focused tile |
D |
Toggle diff view on focused candidate |
1–9, 0 |
Quick-assign one of the 10 most-recently-used slot keys to the focused candidate (a most-recently-used list shown in a persistent legend at the top of the grid) |
G then M/F/B |
Assign gender male/female/both to the focused candidate (two-key chord, mirroring the slot quick-assign pattern) |
Enter |
Open the full-size single-candidate detail panel |
Esc |
Close the detail panel / clear selection |
Approvals and rejections issued via keyboard are optimistic in the UI (the tile updates immediately, Section 17.16 owns the general optimistic-update rule) with the network request firing asynchronously; a failed request reverts the tile and shows a toast error, never a blocking dialog, so the reviewer's keyboard flow is never interrupted by a transient network hiccup.
Approving a candidate creates (or updates, for a matched asset_key) the corresponding assets /
asset_variants / asset_images rows (Section 6) in draft state within the current in-progress build
(Section 9); it does not publish anything — publishing is a separate, explicit action in 17.10.
17.7 Assets admin #
/admin/assets — search (by asset_key or display name, trigram-backed like Section 16.8.9's public
search), filters (slotKey, tag, retired tri-state: active/retired/all).
/admin/assets/:assetKey detail view, editable fields (role curator+): display name, tags
(multi-select against the tag catalog, with inline "create new tag" for curator+), canonical
thumbnail (a picker among the asset's own rendered variants — Section 8's render URLs — defaulting to
the (body="m", hue=0) variant if unset). Retire/unretire toggle: retiring sets retired_at (does not
touch deleted_at, per the soft-delete convention); a retired asset immediately stops appearing in the
public catalog and designer picker (Section 10, Section 11) but keeps rendering for any existing design
that references it (Section 13.7). The detail view also shows, read-only: full provenance (the
import_run_id, source_files offsets, and extraction metadata that produced this asset, per
Section 7), and every render (design_renders / asset_images, Section 6) ever derived from it, as a
thumbnail grid with generation timestamps — useful for spotting a bad extraction that produced visibly
wrong output across many renders.
17.8 Hues admin #
/admin/hues — a table of every hue with columns hueIndex, name, source (base/outlands/
derived), swatch preview, and group memberships.
- Import results: after an import run classifies hue data (Section 7's D4/D6 stages), newly
discovered hues appear here with
sourcepre-set from the discovery heuristic (an index within the documented base 1–3000 range defaults tobase; anything outside it, or matching a known Outlands custom-hue signature, defaults tooutlands) and aneeds_namingflag if no name could be derived from source data. - Naming: inline edit of
name, required before a hue can be included in a publish (17.10) — an unnamed hue blocks publish with a clear inline count ("14 hues need naming before this build can publish"). - Grouping, including curating
skin: a many-to-manyhue_group_memberseditor (Section 6); staff add/remove hues from theskingroup here, which is what makes them selectable asskinHuevalues in the public designer (Section 11) and searchable viaGET /hues?group=skin(Section 16.8.5). Groups beyondskincan be created freely (e.g. thematic groups like "metallics") for catalog browsing (Section 15) without special-casing in code — groups are pure data. - Swatch override: the swatch preview is normally computed from the hue table itself (Section 8's
hue math, taking a representative mid-ramp colour); an override lets staff pin a specific ARGB value
when the computed representative colour is misleading (rare, mostly for gradient hues), stored in
hues.swatch_override_argb, nullable. - Marking custom Outlands hues: the
source = 'outlands'value itself is the marker (Section 3); this screen is where staff correct a hue'ssourcewhen the automatic discovery heuristic guessed wrong. - Bulk edit: multi-select rows, apply a group add/remove or a
sourcecorrection to all selected, same confirmation-modal and per-row-audit-log pattern as candidate bulk actions (17.6.4).
17.9 Slots admin #
/admin/slots — the 19-row slot registry (Section 3) is seeded by migration (Section 6) and is
editable only by owner, covering: display name, zOrder, gender rule, and a visible toggle (a
slot hidden from the public designer/catalog while still valid in stored historical designs — used if a
slot type needs to be temporarily pulled, e.g. discovering a whole category of art is misclassified).
Blast-radius warning: changing zOrder on any slot changes the render composite order for
every existing design that includes that slot, the moment it takes effect, because rendering always
composites in current z-order (Section 8.4) rather than a per-design snapshot of z-order — z-order is
part of the rendering algorithm, not part of the immutable design document (Section 13.2 deliberately
excludes z-order from what is stored per design; only the set of slot/asset/hue choices is stored).
The edit form therefore requires typing the slot's slotKey to confirm (a "type to confirm" pattern,
not just an OK button) and shows a live count of how many published designs include that slot, computed
before the confirmation is accepted. Section 8 owns the rendering algorithm this affects; this screen
only owns the admin UI and warning.
17.10 Builds admin #
/admin/builds — list with status (Section 6's canonical game_builds.status vocabulary: draft,
in_review, published, archived, rolled_back), label, counts, and publish/created timestamps.
- Diff view:
/admin/builds/:id/diffcompares a draft build's asset/hue set against the currently published build: added, changed (sameasset_key, different image data — detected by comparing content hashes, Section 7's D6 stage), retired-in-this-build, and unchanged counts, each expandable to a thumbnail grid. - Publish (role
owner): requires passing the naming-completeness check (17.8) and a confirmation summary dialog showing: number of new assets, number of changed assets, number of retirements, number of new hues, and the exactlabeland timestamp that will becomepublished_at. Publishing is transactional: it flips the build'sstatustopublished, flips the previously-published build (if any) toarchived— ordinary supersession by a newer build — and is the trigger that changes catalogETags (Section 16.6) — implemented as a single database transaction that updatesgame_builds.statusand writes anaudit_logrow (action = 'build.publish') together, so the two are never observed inconsistently. - Rollback (role
owner): re-publishes a previously-published, now-archivedbuild as current, through the same transactional path as publish (it is implemented as "publish this older build," not a distinct code path), and triggers the same cache invalidation described next. The build being rolled back away from (the one currently published at the moment rollback is invoked) transitions torolled_backrather thanarchived, so the audit trail and build list distinguish an explicit rollback from ordinary supersession. Rollback does not delete or alter build rows — every build remains in history and is itself re-rollback-able (re-publishable) regardless of its current status. - Cache invalidation on publish/rollback: per Section 19's CDN mechanics, publishing enqueues a job
that purges the catalog-endpoint cache tier and, if
SKINFORGE_CDN_PURGE_URL/_TOKEN(Section 24) are configured, calls the CDN purge API for the catalog JSON paths (never for/render/content, which is content-addressed and immutable by construction, per Section 8, and therefore never needs purging).
17.11 Designs admin #
/admin/designs — lookup by short code (exact match) or by pasting a full /d/:code URL (the form
strips the path). No free-text search over design contents, since designs carry no free text
(Section 13.9.1) — lookup is by code only.
- View: renders the same preview as the public design page (Section 13.8) plus admin-only metadata:
buildId,viewCount, creation timestamp, and — if applicable — takedown status and history. - Takedown (role
curator+): setsdesigns.taken_down_at(Section 6) to the current timestamp, immediately making the public page and API return 410 (Section 13.11, Section 16.8.11). Requires a mandatory reason, free text, 10–500 characters, recorded in the audit log (action = 'design.takedown',metadata.reason). The design row and its rendered image artifacts are not deleted — a takedown is a visibility change, not a data-destruction event; the row and its history persist exactly as they do for any other design, since designs are never auto-deleted (Section 13.9.3). - Restore (role
curator+): clearstaken_down_at(sets it back toNULL), also requires a mandatory reason, also audited (action = 'design.restore'). The full legal process governing when a takedown is warranted, and the relationship to atakedown_requestsinbox entry, is owned by Section 20; this screen is the mechanical control that a decision under that process is executed through.
17.12 Takedown requests inbox #
/admin/takedowns — intake and triage for external takedown requests (e.g. a rights-holder or a
concerned party emailing SKINFORGE_CONTACT_EMAIL, Section 24).
- Intake form fields (entered by staff on the requester's behalf, since there is no public
submission form — matching the no-accounts, no-public-write-surface posture): requester name,
requester email,
designCode(the specific permalink, if applicable — a request can also target a general asset rather than one design), description (free text, required), evidence links (array of URLs, optional), received-at timestamp. - States (Section 6's canonical
takedown_requests.statusvocabulary — four terminal-or-transit values, no separate "closed" state):new→triaging→ one ofupheld(takedown applied via 17.11's mechanism, request linked to the resulting audit entry),rejected(with a mandatory internal-only reason), orwithdrawn(the requester rescinds the request before triage completes, recorded with the same mandatory-reason field, e.g. "requester withdrew via email"). - SLA: a
newrequest must move totriagingwithin 2 business days and reach a final state (upheld/rejected/withdrawn) within 10 business days; the inbox surfaces overdue requests with a red badge computed client-side fromreceived_atagainst these thresholds (no automated escalation emails in v1 — the badge is the entire mechanism, reviewed manually by anownerwho checks the inbox as part of routine operations, per Section 23's operational runbooks). - Response template: a canned, editable-before-send email body (sent manually by staff through their own email client — SkinForge sends no outbound email itself, matching the no-third-party-service, minimal-infrastructure posture) with placeholders for requester name and outcome, stored as a single string in the database-backed settings (17.14) so it can be tuned without a deploy.
17.13 Audit log viewer #
/admin/audit (role owner only — the audit trail itself is treated as more sensitive than the data it
describes, since it can reveal staff behaviour patterns).
- Filters:
action(exact or prefix match, e.g.design.*),adminUserId, date range, free-text search overmetadata(Postgresjsonbcontainment/text search, Section 6). - Retention: audit log rows are retained indefinitely — never pruned, unlike the regenerable render/OG-image artifacts that Section 13.9.3 prunes after 90 days of no traffic — because the audit log is the accountability record for every privileged action in the system and its evidentiary value does not decay.
- Export: a "download CSV" action (role
owner) streams the currently filtered result set as CSV, capped at 100,000 rows per export (pagination via repeated exports with a narrower date range for larger needs) — implemented as a streaming response so large exports do not buffer the full result set in memory. - Immutability guarantee: audit log rows are insert-only at the database level — no admin API route
anywhere issues an
UPDATEorDELETEagainstaudit_log, and this is enforced defense-in-depth by a PostgresREVOKE UPDATE, DELETE ON audit_log FROM skinforge_appgrant (Section 6), so even a bug in application code cannot alter or remove an existing entry — only the database owner role, used solely for migrations, has that privilege.
17.14 Settings #
/admin/settings (role owner) — a single screen listing every configurable behaviour, split into two
groups:
- Database-backed settings, all stored as rows in the
app_settingstable (Section 6) — a key-value store of operator-tunable content, editable here, taking effect immediately with no deploy required: the takedown response template (17.12),admin_ip_allowlist(17.1), the donation link display text (the URL itself is environment-only, below), the "designs like this" strip's minimum shared-slot threshold (Section 13.8, default 2), and the homepage's featured/curated design list (an ordered array of short codes staff can pin, if any — an empty list means the homepage shows only the random-design feature from Section 16.8.13, which is the default and requires no curation to operate). - Environment-only settings (displayed read-only here for operational visibility, sourced from
the running process's environment — the screen never accepts edits to these, since changing them
requires a redeploy, and their canonical names/types/defaults are owned entirely by Section 24): every
SKINFORGE_*variable, masked for secret-shaped ones (*_SECRET,*_TOKEN,*_KEY, matching the redaction rule applied throughout this system) and shown as their literal value otherwise. - Maintenance mode toggle: writes the database-backed
maintenance_modesetting (distinct from, and layered on top of, the environment-onlySKINFORGE_MAINTENANCE_MODEkill-switch in Section 24 — the env var is the "hard off" used during deploys/migrations and requires a restart to change; this database toggle is the "soft" version anownercan flip instantly from the UI for planned pauses, and the middleware checks both, treating either as true as sufficient to show the maintenance page to public visitors). Toggling is audited (action = 'settings.maintenance_mode'). - Donation link: the display text/label editable here; the destination URL is
SKINFORGE_DONATION_URL(Section 24, environment-only, since it is operationally a "who receives the money" concern that should require the same trust level as a deploy, not a UI toggle).
17.15 Admin users screen #
/admin/users (role owner) — table of every admin_users row: email, role, created_at,
last_seen_at (derived from the most recent admin_sessions.last_seen_at across that user's sessions),
enabled/disabled status.
- Create: this screen does not create users directly (17.1's CLI-only rule); instead it shows the exact CLI command to run, pre-filled with a suggested role, for staff to copy and execute on the host — a deliberate friction point that keeps account creation tied to host access.
- Disable (role
owner): setsadmin_users.disabled_at, immediately invalidating every active session for that user (17.15's force-logout mechanism, below, runs automatically as part of disable) and rejecting future login attempts at the password step withSF-4002(the same wrong-password message a disabled account would get if its password were merely incorrect — a disabled account never gets a distinguishing error, to avoid confirming account existence/status to someone who should not have access). - Force-logout: sets
revoked_at = now()on every non-revokedadmin_sessionsrow for the target user immediately, independent of disabling the account (usable when a session is merely suspected compromised but the account itself should stay active) — audited asaction = 'admin_user.force_logout'. Revoked rows are never deleted (Section 6.23's soft-revoke convention); they are pruned by routine housekeeping 30 days after revocation (Section 6.33), which preserves the session audit trail in the meantime. - Rotate recovery codes (role
owner, for any user, from this screen; or any role, for their own codes only, from/admin/account, 17.3): invalidates all 10 existing recovery codes and generates 10 new ones, displayed exactly once at generation time (never retrievable again after navigating away — matching standard recovery-code handling practice), audited asaction = 'admin_user.rotate_recovery_codes'.
17.16 Admin UX rules #
- Destructive-action confirmations: any action that is hard to reverse (retiring many assets in bulk, publishing a build, taking down a design, disabling an admin user, deleting nothing — since hard deletes barely exist per the soft-delete convention) requires a confirmation modal naming the exact consequence in one sentence and, for the highest-blast-radius actions (build publish, slot z-order change, admin user disable), a type-to-confirm field matching a specific identifier (build label, slot key, or user email respectively) rather than a bare OK button.
- Optimistic vs. pessimistic updates: single-item toggles with cheap, easily-reversible effects (candidate approve/reject via keyboard, 17.6.6; asset retire/unretire toggle, 17.7) are optimistic — the UI updates immediately, the request fires async, a failure reverts with a toast. Actions that are expensive, irreversible, or affect many rows at once (bulk candidate actions, 17.6.4; build publish/rollback, 17.10; takedown/restore, 17.11) are pessimistic — the UI shows a loading state and waits for the server's confirmation before reflecting the change, because a false-positive optimistic update on an irreversible action is worse than a moment's latency.
- Table density: every list screen (imports, candidates, assets, hues, designs, audit) defaults to a
compact row height (32px) with an optional "comfortable" density toggle (44px row height), persisted
per-admin-user in browser
localStorage— a staff preference, not a system setting, so it is not part of 17.14. noindexand sitemap exclusion: every/admin/*response carriesX-Robots-Tag: noindex, nofollow, and the entire/adminpath prefix is disallowed in/robots.txt(Section 10) and never appears in/sitemap.xml(Section 10). This is defense-in-depth alongside the auth requirement itself — the admin console must never be discoverable via search engines even before considering that it also requires credentials.
17.17 Admin API surface #
Admin actions go through a separate /admin/api namespace, distinct from the public /api/v1
namespace in Section 16 — never mixed into it, so the public API's open-CORS, no-auth, no-CSRF posture
(Section 16.2) never accidentally applies to a privileged action.
- CSRF protection:
/admin/apiis cookie-authenticated (sf_admin, 17.1), which makes it a CSRF target unlike the public API (which reads no cookies). Every state-changing/admin/apirequest (POST/PUT/PATCH/DELETE) requires a CSRF token. Mechanism: the signed double-submit cookie owned by Section 20.5 — cookiesf_csrf(HttpOnly: false; Secure; SameSite=Lax), headerX-SF-CSRF, valuebase64url(HMAC-SHA256(sessionTokenHash, SKINFORGE_ADMIN_SESSION_SECRET)), compared in constant time by middleware ahead of every mutating/admin/apiroute. A mismatch or missing header returnsSF-4003"Your session looks out of date. Please refresh and try again." (403), and the request is rejected before any handler logic runs.GET/HEADrequests to/admin/apiare exempt (they are not state-changing and CSRF only protects state-changing requests). The cookie is reissued on every login and cleared on logout, alongsidesf_admin. This section owns only the fact that/admin/apirequires CSRF protection; Section 20.5 owns the mechanism's exact shape, so it is specified in exactly one place. - Every
/admin/apiresponse uses the same success/error envelopes as the public API (Section 16.3, 16.4), includingSF-error codes, so admin-console frontend code reuses the same client-side response handling as the public site's islands — the only differences from Section 16 are the auth requirement, the CSRF requirement, and the absence of open CORS (/admin/apisends noAccess-Control-Allow-Originheader at all, relying on same-origin requests only, since the admin console is never embedded or called cross-origin).
18. Design System, Visual Language & Accessibility #
18.1 Design principles for a pixel-art fan tool #
- The art is the hero. Sprites are small, high-detail pixel art. UI chrome around them uses flat colour, no gradients, no drop shadows heavier than a 1px separation line, so nothing competes with the composited character for visual attention.
- Chrome recedes. Panels, rails and controls use low-contrast surface tones; only interactive affordances (buttons, active states, focus rings) use saturated accent colour. Borders are thin (1px) and low-contrast except where they carry meaning (selection, warning, error).
- No visual noise near the preview. The preview stage (Section 12) has no background texture, no decorative imagery, and a flat single-tone backdrop so pixel-art edges stay crisp and colour judgements (hue picking) are not biased by a busy surrounding.
- Dark-first with a light theme. The product is designed dark-first because pixel art historically reads best against a dark backdrop and most reference screenshots of the game itself are dark-UI. A fully equivalent light theme is provided (18.2) for users who prefer it or whose system preference requests it; neither theme is "secondary" in implementation effort or contrast compliance.
18.2 Colour tokens #
All tokens are defined once as CSS custom properties on :root (dark, default) and overridden under
:root[data-theme="light"] (18.10 owns the selection mechanism). Values are sRGB hex.
| Token | Dark value | Light value | Usage |
|---|---|---|---|
--sf-surface |
#14161c |
#f7f7f9 |
page background |
--sf-surface-raised |
#1c1f27 |
#ffffff |
cards, panels, rails |
--sf-border |
#4a5060 |
#9aa0ae |
hairline separators |
--sf-text-primary |
#f1f2f5 |
#15171c |
body text, headings |
--sf-text-muted |
#9aa0ae |
#5b6070 |
secondary text, placeholders |
--sf-accent |
#3f5fd8 |
#3355dd |
primary actions, active/selected state, white-on-accent surfaces (the Share button) |
--sf-accent-hover |
#5476ea |
#2846c2 |
hover/active state of accent elements |
--sf-accent-tint |
color-mix(in srgb, var(--sf-accent) 12%, transparent) |
same formula | selected-state tints (rail, chips) — an alpha derivation, not a separate hex token |
--sf-success |
#4ade80 |
#1a8a4a |
success toast, valid states |
--sf-warning |
#fbbf24 |
#a15c00 |
gender-incompatible badge, non-blocking warnings |
--sf-danger |
#f87171 |
#c92a2a |
destructive actions, error states |
--sf-focus-ring |
#3f5fd8 |
#3355dd |
focus outline colour, same as accent per theme, rendered with a 2px offset (18.5) so the visible ring always sits against surface, never directly over an accent-filled element |
Contrast ratios (WCAG 2.2 AA; body text minimum 4.5:1, large text/UI boundaries and non-text UI components minimum 3:1), recomputed honestly against the token each is drawn on:
| Pairing | Dark ratio | Light ratio | Requirement | Pass |
|---|---|---|---|---|
text-primary on surface |
16.2:1 | 16.8:1 | 4.5:1 | yes |
text-primary on surface-raised |
14.7:1 | 17.9:1 | 4.5:1 | yes |
text-muted on surface |
6.9:1 | 5.9:1 | 4.5:1 | yes |
text-muted on surface-raised |
6.3:1 | 6.3:1 | 4.5:1 | yes |
accent (as icon/large UI element only — never body-sized text, see note) on surface |
3.3:1 | 5.6:1 | 3:1 | yes |
white text on accent (button label) |
5.5:1 | 6.0:1 | 4.5:1 | yes |
border on surface (decorative panel hairline — see note) |
2.3:1 | 2.5:1 | 3:1 | no |
focus-ring on surface |
3.3:1 | 5.6:1 | 3:1 | yes |
warning (as icon) on surface |
10.8:1 | 4.9:1 | 3:1 | yes |
danger (as icon) on surface |
6.5:1 | 5.1:1 | 3:1 | yes |
Notes on the two rows above that are not simple passes:
accentas text/icon:accentis never set as the colour of body-sized running text anywhere in this document — only as a button/chip fill, a focus ring, a selection border, or an icon at 20px+ (Section 18.6). Its applicable bar is therefore the 3:1 large-scale/UI-component threshold, which both themes clear.borderonsurfacefails 3:1 in both themes at the exact values fixed above. This token draws only the hairline edge of asurface-raisedpanel (18.4) — a decorative separator, not the sole means of identifying a UI component or its state (WCAG 1.4.11 governs "graphical objects required to understand content" and UI component boundaries; a panel that remains fully legible and operable without its hairline is not one). Every control whose boundary IS required to understand its state — the rail's selected item, swatches, buttons, form inputs, the focus ring — uses theaccentorfocus-ringtoken instead, both verified ≥3:1 above.borderis not used as a substitute for those states anywhere in this document.
success/warning/danger are never used as the ONLY signal (icon shape and text label always
accompany colour, satisfying 1.4.1 Use of Color alongside the 18.8 conformance table).
18.3 Typography #
Font stack: system-ui stack for all UI and body text —
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif — chosen
over a webfont to keep the interactive designer's first paint free of font-loading layout shift, which
matters directly for the interaction-to-paint budget in Section 11.15.
Bundled display face: "Pixelify Sans" (open-licence, self-hosted as a WOFF2 subset), used exclusively inside generated Open Graph images (Section 14) for the heading/wordmark, never in the live UI — this keeps the OG image visually distinctive and on-brand for a pixel-art tool while the live app stays on fast system fonts.
Type scale (rem, 1rem = 16px base):
| Role | Size | Line height | Weight |
|---|---|---|---|
| Display (page hero, rare) | 2.25rem | 1.2 | 700 |
| H1 | 1.75rem | 1.25 | 700 |
| H2 | 1.375rem | 1.3 | 600 |
| H3 | 1.125rem | 1.35 | 600 |
| Body | 1rem | 1.5 | 400 |
| Body small | 0.875rem | 1.5 | 400 |
| Caption/label | 0.75rem | 1.4 | 500 |
Rule: pixel-art thumbnails never sit on a textured or gradient background. Every thumbnail container
uses a flat --sf-surface-raised fill so nearest-neighbour-scaled sprite edges stay legible; this
applies uniformly to the catalog grid (Section 15.3), the designer option panel (Section 11.6), and
the preview stage backdrop (Section 12).
18.4 Spacing, radii, elevation, base grid #
4px base grid. All spacing, sizing, and radii are multiples of 4px, expressed as a Tailwind 4.x
@theme block:
/* web/styles/theme.css */
@theme {
--spacing-0_5: 0.125rem; /* 2px, hairline nudge only */
--spacing-1: 0.25rem; /* 4px */
--spacing-2: 0.5rem; /* 8px */
--spacing-3: 0.75rem; /* 12px */
--spacing-4: 1rem; /* 16px */
--spacing-6: 1.5rem; /* 24px */
--spacing-8: 2rem; /* 32px */
--spacing-12: 3rem; /* 48px */
--spacing-16: 4rem; /* 64px */
--radius-sm: 0.25rem; /* 4px, chips, inputs */
--radius-md: 0.5rem; /* 8px, cards, buttons */
--radius-lg: 0.75rem; /* 12px, panels, modals */
--radius-full: 9999px; /* pills, swatches, avatars */
--color-surface: var(--sf-surface);
--color-surface-raised: var(--sf-surface-raised);
--color-border: var(--sf-border);
--color-text-primary: var(--sf-text-primary);
--color-text-muted: var(--sf-text-muted);
--color-accent: var(--sf-accent);
--color-accent-hover: var(--sf-accent-hover);
--color-accent-tint: var(--sf-accent-tint);
--color-success: var(--sf-success);
--color-warning: var(--sf-warning);
--color-danger: var(--sf-danger);
--color-focus-ring: var(--sf-focus-ring);
}Elevation is expressed as border + background-tone changes, not box-shadow, to keep the flat, low-noise
principle from 18.1: surface-raised panels have a 1px solid var(--color-border) outline and no
shadow at rest; the only shadow in the system is a small 0 4px 12px rgba(0,0,0,0.25) used exclusively
on the mobile bottom sheet (Section 11.12) and dropdown/tooltip popovers, to separate a floating
layer from page content behind it.
Z-index scale, fixed to four layers to keep stacking predictable across the designer, catalog and admin surfaces:
| Layer | z-index | Contents |
|---|---|---|
| Base | 0 | page content, cards, rail, panels |
| Sticky | 10 | sticky filter bars, sticky catalog headers |
| Overlay | 100 | tooltips, dropdown popovers |
| Modal | 1000 | bottom sheet, modal, toast container |
18.5 Component library #
For each component: anatomy, states, class/API surface, accessibility requirements.
Button (web/components/ui/button.tsx)
- Variants:
primary(accent fill, white text — Share, Randomize),secondary(bordered, transparent fill — Reset, Cancel),ghost(no border, text-only — inline actions),danger(danger-fill, admin destructive actions only, Section 17). - Sizes:
sm(32px height),md(40px height, default),lg(48px height). - States: default, hover, active/pressed, focus-visible (2px
focus-ringoutline, 2px offset — the offset is load-bearing on theprimaryvariant: it keeps the ring drawn againstsurface, never directly on top of the button's ownaccentfill, so ring and fill are always visually distinct even though both derive from the same token), disabled (reduced opacity 0.5,aria-disabled="true", pointer-events none), loading (spinner replaces label,aria-busy="true", label retained in anaria-liveregion for announcement). - API:
{ variant, size, disabled?, loading?, onClick, children }. Accessibility: renders a native<button>; icon-only buttons requirearia-label(18.6).
Icon button
- Square, sized to match
Buttonheights (32/40/48px), single icon, no visible label. - States: same as Button. Accessibility:
aria-labelis mandatory (enforced by a TypeScript required prop, not optional), never satisfied bytitlealone.
Chip/swatch
- Chip: pill shape (
radius-full), used for tags, filter selections, "New"/"Retired" badges. States: default, selected (accent border +--color-accent-tintfill, 18.2/18.4), removable (trailing "x",aria-label="Remove <label>"). - Swatch: fixed-size square (24px in pickers, 32px in the hue browser grid),
radius-sm, filled with the hue's computed sRGB colour, 2px accent ring when selected. Accessibility:role="button",aria-pressedreflecting selection,aria-label= hue name and index.
Card
- Used for catalog
AssetCard(15.3) and catalog overview slot cards (15.2). Anatomy: thumbnail region (flat surface background per 18.3), title, optional badges/chips, optional metadata line. Entire card is a single<a>with the visible content inside, never a<div onClick>, so it is keyboard and screen-reader navigable natively.
Tabs
- Used for hue group tabs (11.6, 15.5) and any future grouped content.
role="tablist"on the container,role="tab"witharia-selectedon each item,role="tabpanel"witharia-labelledbyon the content region. Keyboard:ArrowLeft/ArrowRightmoves selection,Home/Endjump to first/last tab, matching the standard ARIA tabs pattern.
Segmented control
- Used for body filter (15.3), sort control (11.6, 15.3). 2–4 options, single-select, styled as a
connected pill group.
role="radiogroup"on the container,role="radio"witharia-checkedon each segment. Keyboard: arrow keys move and select (single-select group, not tab-then-activate).
Select
- Native
<select>element wherever the option set is short and unstyled behaviour is acceptable (used nowhere in the designer, which prefers segmented controls and grids for its short, meaningful choice sets); reserved for admin console table page-size and sort dropdowns (Section 17). Always paired with a visible<label>.
Text input with search affordance
- Used for
AssetSearchBar(11.6), hue search (11.6, 15.5), catalog search (15.6). Leading magnifying-glass icon (decorative,aria-hidden="true"), trailing clear ("x") button shown only when non-empty,aria-label="Clear search". The input itself always has a visible oraria-label-equivalent label — placeholder text alone never substitutes for a label, per 18.8's 4.1.2 row.
Tooltip
- Used for rail item labels at
lg+ (11.5), disabled-state explanations. Appears on hover AND focus (never hover-only), dismissed onEscape,pointerleave, or blur. Implemented as a positioned popover using the nativepopoverattribute where supported, associated to its trigger viaaria-describedby(notaria-label, since a tooltip supplements rather than replaces the trigger's own accessible name).
Toast
- Used for Share success/error (11.9), copy-hue-number (15.5), copy-asset-key (15.4).
role="status",aria-live="polite"container fixed at the bottom of the viewport (bottom-center onbase–sm, bottom-right onmd+), auto-dismisses after 4 seconds unless it contains an interactive element (the clipboard fallback input, 11.9), in which case it persists until manually dismissed via a close button.
Modal
- Reserved for the admin console (Section 17); not used anywhere in the public designer.
role="dialog",aria-modal="true", labelled viaaria-labelledbypointing at its heading, focus-trapped (Section 11.11's trapping mechanism, shared code inweb/components/ui/use-focus-trap.ts), closed onEscapeand on scrim click, focus restored to the trigger on close.
Bottom sheet
- Mobile option panel container (Section 11.12).
role="dialog",aria-modal="true"while open, same focus-trap hook as Modal, resize handle is a<button>witharia-label="Resize sheet"andaria-description="Press Enter to toggle sheet height, Escape to close"(Section 11.14) — dragging the handle is a pointer-only enhancement over the same Enter-toggled heights, never the only way to resize (WCAG 2.2's 2.5.7 Dragging Movements).
Skeleton
- Flat
surface-raisedrectangles with a subtle shimmer animation (18.7 governs the animation, including its reduced-motion fallback), used for asset grid loading (11.6, 15.3) and load-more states (15.8).aria-hidden="true"on the skeleton nodes themselves; the loading state is announced separately via thearia-liveregion already described for that context (15.8).
Empty state
- Icon or illustration (flat, single-colour, matching
text-muted), heading, short body text, optional action button. Used throughout (11.13, 11.14 empty option panel copy, 15.7).
Banner
- Full-width, dismissible or persistent, coloured by intent (
accentfor informational — e.g. remix hint 11.10;warningfor the retired-asset/dropped-field notices, 11.13). Dismissible banners store dismissal in-memory only (component state), never in storage, so they reappear on next page load — a "no persistent tracking of UI dismissals" stance. The one exception is the fan-project disclaimer banner (Section 10.4), which is not dismissible at all and therefore has no dismissal state to store.role="status"for informational,role="alert"for the ones surfacing dropped/invalid input (11.13), since the latter warrants immediate announcement.
Pagination control
- The "Load more" button (15.8) and its accessible live-region announcement. No numbered page-link pagination exists anywhere in the product, keeping one pagination mental model everywhere (cursor-based, Section 16).
18.6 Iconography #
Source: Lucide (open-licence, ISC, lucide-preact package), used exclusively — no mixing icon
sets, no custom SVG icons outside Lucide's set except the SkinForge wordmark/logo itself (a bespoke
static SVG, not part of the icon system).
Sizes: 16px (inline with body-small text, e.g. chip icons), 20px (default UI icon size, buttons and
rail items at base–md), 24px (rail items at lg+, section headings).
Rule against icon-only controls without accessible names: every icon-only interactive element
(IconButton, swatch, chip remove control) MUST carry an explicit aria-label prop; this is enforced
at the TypeScript level (the prop is required, not optional, on every icon-only component in
web/components/ui/) and checked by an automated accessibility test in Section 22.6's test suite that fails the
build if an icon-only interactive element is found in the rendered DOM without an accessible name.
Icon usage table (Lucide names, mapped to their exact UI role):
| Icon | Used for |
|---|---|
shirt |
torso_inner / torso_middle rail items |
footprints |
footwear rail item |
scissors |
hair rail item |
smile |
face rail item |
gem |
earrings rail item |
crown |
head rail item |
backpack |
backpack rail item |
shuffle |
Randomize button |
undo-2 |
Undo control |
redo-2 |
Redo control |
rotate-ccw |
Reset button |
share-2 |
Share button |
copy |
Copy Asset Key / Copy Hue Number actions |
search |
search input leading icon |
x |
clear-search, chip remove, dialog close |
chevron-down |
select affordance, expandable filter groups |
sliders-horizontal |
mobile "Filters" toggle (Section 15.3) |
info |
retired-asset info badge (Section 11.13) |
triangle-alert |
gender-incompatible warning badge (Section 11.13) |
check |
selected state confirmation inside swatches/chips where a checkmark supplements the ring |
zoom-in / zoom-out |
keyboard-accessible preview zoom controls (Section 18.8's 2.1.1 row) |
Every icon above is imported individually from lucide-preact (tree-shaken, never the full icon
barrel) at its component's declared size from the three sizes above.
18.7 Motion #
| Element | Duration | Easing |
|---|---|---|
| Hover/focus state changes (colour, border) | 120ms | ease-out |
| Toast enter/exit | 200ms | ease-out (enter), ease-in (exit) |
| Bottom sheet slide | 250ms | cubic-bezier(0.32, 0.72, 0, 1) (standard "sheet" easing) |
| Modal fade/scale | 180ms | ease-out |
| Tooltip fade | 100ms | ease-out |
| Skeleton shimmer | 1400ms loop | linear |
| Live composite canvas redraw | 0ms (instant) | n/a — the canvas itself never animates a transition; only its CSS pinch-zoom transform (11.12) animates, at 150ms ease-out when snapping back |
What animates: only opacity, transform, and background-color/border-color properties — never
width/height/top/left, keeping every transition compositor-friendly.
prefers-reduced-motion behaviour: a single global CSS rule disables all of the above:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}Under reduced motion, state changes (toast, modal, bottom sheet) still occur but appear instantly rather than animating; no functionality is lost, only the transition. The skeleton shimmer becomes a static flat-tone placeholder instead of a looping animation.
18.8 Accessibility standard #
WCAG 2.2 Level AA is the committed target for the entire public application and the admin console.
Conformance table for the criteria most at risk in this product:
| Criterion | Risk area | How met |
|---|---|---|
| 1.4.3 Contrast (Minimum) | Small text on tinted panels | Every token pairing verified in 18.2's table, both themes, ≥4.5:1 body / ≥3:1 large text |
| 1.4.11 Non-text Contrast | Swatches, focus rings, control borders | accent and focus-ring tokens verified ≥3:1 against surface (18.2) and are what carries every required UI-component boundary (selection, focus, swatch ring); the border token itself is a decorative panel hairline only (18.2's note) and is never the sole means of identifying a component or its state |
| 2.1.1 Keyboard | Slot rail, hue picker, canvas preview, catalog filters | Every interactive element reachable and operable via keyboard: rail (11.5 listbox pattern), hue picker (grouped tabs + swatch grid, standard tab/arrow patterns), pinch-zoom has a keyboard equivalent (+/- keys zoom the preview stage in 25% steps when focused, an addition to the touch gesture in 11.12), filters (native form controls, 15.3) |
| 2.1.4 Character Key Shortcuts | Global shortcuts (11.11) | No single-character shortcut exists anywhere in the designer: every designer shortcut requires a modifier (Alt+R, Alt+S, Ctrl+Z/Ctrl+Shift+Z/Ctrl+Y); the numeric rail-jump shortcuts from earlier drafts are removed entirely. The candidate-queue shortcuts in 17.6.6 are single-character but are active only while a candidate tile has focus, satisfying 2.1.4's activation-on-focus exception; they are never global |
| 2.4.7 Focus Visible | Custom controls (swatches, rail items, cards) | All custom components use the shared --color-focus-ring outline via :focus-visible (never suppressed with outline: none without a replacement); verified per-component in Section 22.6's accessibility test pass |
| 2.4.11 Focus Not Obscured (Minimum) | Sticky filter bar (18.4 z=10), bottom sheet/modal/toast (z=1000) | Every scroll container sets scroll-margin-block equal to the sticky header height, so a focused element scrolls clear of it; while the bottom sheet, a modal, or a toast with focusable content is open, focus is trapped inside it (11.11's trapping mechanism, 18.5), so no other focusable element can sit behind an overlay layer |
| 2.5.7 Dragging Movements | Bottom sheet resize handle (11.12) | The handle is a real <button>: Enter/Space cycles the sheet between 50% and 85% height; dragging the handle is a pointer-only enhancement over those same two heights, never the only way to resize or dismiss (scrim tap and Escape also close it) |
| 2.5.8 Target Size (Minimum) | Dense grids (hue swatches, asset thumbnails) on touch | All interactive targets meet the 44×44 CSS px minimum via padding (Section 11.12); the 24px visual swatch sits inside a 44×44 tappable wrapper as the exception permitted by the criterion's spacing alternative, satisfied here directly by size instead |
| 4.1.2 Name, Role, Value | Icon-only controls, custom widgets (rail, tabs, sheet) | Every icon-only control has aria-label (18.6); every custom widget uses the matching ARIA role/state pattern documented per-component in 18.5 and per-interaction in Section 11 |
Automated tooling (axe-core, Section 22.6) catches structural violations (missing labels, contrast, roles) but cannot evaluate 2.1.4, 2.5.7, or 2.4.11 — each requires exercising the actual interaction (triggering a shortcut, dragging a handle, opening an overlay over scrolled content) with a human tester or a targeted Playwright script that asserts the behaviour described above. The launch checklists in Sections 26 and 27 require these three as explicit manual checks, not only the automated audit.
18.9 Screen-reader model for the designer and preview #
Landmark structure: <header> (site header), <nav aria-label="Main"> inside it, <main id="main-content"> wrapping the designer/page content, <aside aria-label="Slot selection"> for the
rail, <aside aria-label="Options"> for the option panel, <footer> for the global footer (Section
10.4). Exactly one <main> per page.
Live-region policy: exactly three live regions exist at once in the designer, each with a single clear purpose, to avoid the common failure mode of competing or redundant announcements:
- A
politeregion for non-urgent status (load-more results, hue applied confirmation) — reused across the catalog (15.8) and designer. - An
assertiveregion reserved for the dropped-field/retired-asset banners (11.13), since those represent a change the user did not request and should hear promptly. - The toast container (18.5),
polite, dedicated to action confirmations (Share, copy actions).
Design-as-text listing: the preview stage exposes a visually-hidden (sr-only utility class, not
display:none, so it remains in the accessibility tree) textual summary alongside the canvas:
"Character skin: , skin hue <hue name or 'as-drawn'>. : , hue <hue name or 'as-drawn'>." repeated per filled slot, in z-order. This listing
updates live (inside the polite region above, but only announced on an explicit request — see
announcement wording below — not on every keystroke, to avoid announcement flooding during rapid hue
adjustments).
Announcement wording: the live text summary is exposed via a "Describe this design" icon button
(18.6-compliant, aria-label="Describe this design") next to the preview stage; activating it moves
focus to the sr-only summary and triggers a single polite announcement of its full current text.
The summary is NOT announced automatically on every designSig change — only recomputed silently in
the DOM so it is current whenever requested — because the coalescing/debounce behaviour in Section
11.7 that governs undo history does not apply to screen-reader announcement pacing, and continuous
auto-announcement during interactive hue adjustment would be unusable.
18.10 Theming implementation #
Theme choice: system preference only (prefers-color-scheme), read server-side is not possible (no
client hints are trusted for this), so the mechanism is:
- The server always renders with
data-theme="dark"on the root<html>element (the default, matching 18.1's dark-first stance) and includes a tiny inline, blocking<script>in<head>(before any stylesheet paints) that readsmatchMedia("(prefers-color-scheme: light)").matchesand, if true, swaps the attribute todata-theme="light"before first paint. - This inline script is the ONLY blocking script on the page and is under 200 bytes minified; it contains no framework code, only the media-query check and attribute swap, to avoid a formal sr-blocking bootstrap cost.
- No cookie, no
localStorage, no user override control exists, because there is no account system to persist a preference against (Section 20.7's no-PII, no-persistent-tracking stance extends to theme choice) — the system preference IS the preference, re-evaluated fresh on every load.
No-flash guarantee: because the theme-detecting script runs before any CSS referencing theme tokens is
applied to visible content (it is placed as the first child of <head>, before the stylesheet
<link>), there is no flash of the wrong theme. This is verified in Section 22's visual regression
pass (Section 22.5) by asserting computed background colour matches the expected theme within the
first animation frame under a forced prefers-color-scheme: light emulation.
18.11 Content style guide #
Capitalization: sentence case for all UI text (buttons, headings, labels) — "Use in designer", not "Use In Designer". Proper nouns (SkinForge, UO Outlands, hue/asset names as stored) keep their source casing.
Tone: plain, friendly, brief. No exclamation points except in success toasts sparingly ("Copied!" is the sole exception, kept for its established micro-interaction convention). No jargon beyond the domain terms defined in Section 3's glossary, which the UI uses consistently rather than inventing synonyms (always "hue", never "color"/"tint" interchangeably; always "asset", never "item"/"piece" interchangeably).
Numbers: hue indexes are always shown as plain integers with no leading zeros or grouping separators ("1102", never "1,102"). Counts under 1,000 are shown exactly; counts of 1,000 or more are abbreviated with one decimal ("1.2k") only in compact contexts (catalog card badges), and shown exact with grouping separators ("1,204") in full metadata contexts (asset detail page, admin console).
Naming hues and assets in the UI: hues are always shown as "" with the numeric index available on demand (tooltip, detail page) rather than inline in running text, except where the index itself is the actionable content (copy-hue-number, 15.5). Assets are always shown by their curated
display_name(Section 6), never their rawasset_key, in any player-facing surface; the raw key appears only in the "Copy Asset Key" action's copied value and in URLs.Unofficial fan-project disclaimer, exact wording owned by Section 10.4; quoted verbatim everywhere it appears:
"Outlands SkinForge is an unofficial fan project. It is not affiliated with, endorsed by, or sponsored by UO: Outlands, Broadsword Online Games, or Electronic Arts."
This exact sentence is used verbatim in the header banner, the footer, and the
/legalpage's opening statement — never paraphrased — so the disclaimer is textually identical everywhere it appears, satisfying the "persistent and clear" scope requirement with one canonical string maintained incore/i18n/en.tsunder the keydisclaimer.fanProject.Consistency check: every UI string that is not itself content-driven (asset names, hue names, tag names, build labels) lives in
core/i18n/en.ts; no component hard-codes an inline literal string for a label, button, tooltip, or message, which is what allows Section 4's future-locale note to hold — adding a language is a data change to that one file, never a component rewrite.
19. Performance, Caching & CDN Strategy #
19.1 Performance budgets #
All budgets are measured on a mid-tier mobile device profile (Moto G Power class, 4x CPU throttle, simulated Slow 4G for the cold case and cable-equivalent for the warm case) unless stated otherwise. Budgets apply to the production deployment described in Section 23, fronted by the CDN in Section 19.2.
| Metric | Target | Route scope | Notes |
|---|---|---|---|
| LCP (Largest Contentful Paint) | ≤ 2.0s (warm), ≤ 3.0s (cold) | /, /d/:code |
LCP element is the paperdoll composite image |
| INP (Interaction to Next Paint) | ≤ 200ms | / |
Measured on hue-swatch click and asset-picker open |
| CLS (Cumulative Layout Shift) | ≤ 0.05 | all public routes | Preview surfaces reserve fixed aspect-ratio boxes (Section 12) |
| Time-to-first-preview | ≤ 1.2s (warm cache), ≤ 2.5s (cold cache) | / |
Time from navigation start to first paperdoll pixel visible, default body/skin only |
| TTFB | ≤ 150ms (cache hit), ≤ 500ms (cache miss) | all HTML routes | Measured at origin, before CDN |
API latency budgets, by endpoint class (class definitions per Section 16):
| Endpoint class | p50 | p95 | Notes |
|---|---|---|---|
| Reads — catalog list/detail, hue list | 30ms | 120ms | Postgres-backed, indexed lookups only |
| Reads — design resolve by short code | 15ms | 60ms | Single indexed lookup on designs.short_code |
| Writes — design create | 60ms | 250ms | Includes canonicalization, hashing, insert, and enqueue of render job |
| Admin reads | 50ms | 200ms | Lower priority than public traffic; not CDN-cached |
| Admin writes | 80ms | 400ms | Includes audit log insert (Section 20.5) |
Render latency budgets (Section 8 defines the render algorithm; this section owns the numeric targets):
| Render class | p50 cold | p95 cold | p50 warm (cache hit) |
|---|---|---|---|
| Single design composite, scale 1 | 40ms | 150ms | 4ms |
| Single design composite, scale 3 | 90ms | 300ms | 6ms |
| Single asset variant | 15ms | 60ms | 3ms |
| Slot swatch strip | 60ms | 220ms | 4ms |
| OG image (Section 14) | 180ms | 500ms | 5ms |
"Cold" means no object-storage or in-process cache hit — the server performs the full composite and encode. "Warm" means a Cache-Control-eligible response served from the CDN edge or the in-process LRU (Section 19.2). These numbers are the pass/fail thresholds for the load tests in Section 22.7.
Page-weight ceiling per route class, transferred bytes after compression, excluding the first paperdoll composite image itself (which is budgeted separately above):
| Route class | Ceiling |
|---|---|
/ — the designer (initial HTML + CSS + islands JS) |
180 KB |
/d/:code (permalink, no-JS-capable) |
90 KB |
/catalog and /catalog/:slotKey |
130 KB |
/hues |
110 KB |
/admin/* |
260 KB (not CDN-cached, budget is generous but still enforced) |
The designer is / and only /. /design is a permanent redirect to it (Section 10.5), not a page
with a budget, a cache entry or a test scenario of its own; every budget above that concerns the
designer names /.
19.2 Cache layers #
Five layers, checked in this order on a read path. A hit at any layer short-circuits every layer below it.
| Layer | What lives there | Key shape | TTL | Invalidation trigger |
|---|---|---|---|---|
| Browser cache | Rendered images, static JS/CSS bundles, fonts | Full request URL | Per Cache-Control in Section 19.3 |
Content-addressed URLs never need invalidation; HTML uses short max-age |
| CDN (edge) | Rendered images, OG images, static assets, HTML with s-maxage |
Full request URL | Per Cache-Control in Section 19.3 |
Explicit purge call (Section 19.5) for HTML on publish; images are immutable and never purged |
| Object storage | Encoded render outputs (PNG/WebP) | renders/:first2/:next2/:hash.:ext (Section 8) |
Infinite — content-addressed | Never invalidated; a new build hash produces a new key |
| In-process LRU | Decoded sprite RGBA buffers, hue tables, hot design JSON, rate-limit bucket counters | Application-defined string keys (e.g. sprite:<assetKey>:<body>) |
Process lifetime, capacity-bounded (see below) | Evicted by LRU capacity; cleared on process restart; explicit invalidation on publish (Section 19.5) |
| Postgres | Source of truth: designs, assets, hues, builds, jobs | SQL primary/unique keys | N/A | N/A — always consulted on a full miss |
In-process LRU sizing on the reference 4-vCPU / 8 GB VPS (Section 23.1):
| Cache | Max entries | Max bytes | Eviction policy |
|---|---|---|---|
| Sprite RGBA buffers | 4,000 | 512 MB | LRU, size-weighted |
| Hue tables | 4,000 (one per hue group member; effectively all of them) | 32 MB | LRU, never evicted in practice — dataset is small |
| Hot design JSON | 10,000 | 64 MB | LRU, size-weighted |
| Rate-limit token buckets | 50,000 | 16 MB | TTL 1 hour, then LRU |
These are process-local, not shared across app replicas. Section 23.12 addresses multi-replica scaling; at that point sprite/hue caches remain per-replica (cheap to rebuild, read-only data) and rate-limit buckets move to the Postgres-backed path exclusively (Section 20.1 threat: render-queue exhaustion; the in-process LRU is a fast path only, never the sole enforcement point).
19.3 Cache-Control matrix #
| Route class | Example | Cache-Control value |
Reason |
|---|---|---|---|
| HTML — designer home | / |
public, max-age=0, s-maxage=300, stale-while-revalidate=86400 |
Content rarely changes; CDN can serve slightly stale during a burst. /design is a permanent redirect to / (Section 10.5); the redirect response itself carries public, max-age=86400 |
| HTML — permalink page | /d/:code |
public, max-age=0, s-maxage=300, stale-while-revalidate=86400 |
Immutable design (Section 13), but the wrapping HTML (nav, footer, ads-free banner) can change |
| HTML — catalog/hues | /catalog, /catalog/:slotKey, /hues |
public, max-age=0, s-maxage=300, stale-while-revalidate=86400 |
Same rationale; catalog content changes only on publish |
| HTML — static info | /about, /faq, /legal, /support, /changelog |
public, max-age=60, s-maxage=3600, stale-while-revalidate=86400 |
Rarely edited; longer edge TTL acceptable |
| Render URLs | /render/d/:code@:scale.:ext, /render/a/..., /render/s/... |
public, max-age=31536000, immutable |
Content-addressed by build hash + design JSON (Section 8.9); the URL never changes meaning |
| OG images | /og/d/:code.png |
public, max-age=31536000, immutable |
Same content-addressing argument, generation trigger in Section 14 |
| API reads | GET /api/v1/* |
Per endpoint class, Section 16.6 (owner) — not a single value | Catalog, design-resolve, search, stats and version each have their own TTL; GET /api/v1/designs/random is no-store, because an edge-cached random endpoint stops being random. This matrix does not restate those six values; Section 16.6 is the one place they are defined |
| API writes | POST /api/v1/designs, any admin mutation |
no-store |
Writes must never be cached or replayed from a cache |
| Sitemap/robots/opensearch | /sitemap.xml, /robots.txt, /opensearch.xml |
public, max-age=3600, s-maxage=86400 |
Crawled infrequently, cheap to regenerate |
| Admin pages | /admin/* |
private, no-store |
Never cached by any shared cache; contains session-scoped content |
| Admin API | /admin/api/* |
private, no-store |
Same reasoning |
| Health checks | /health, /health/ready |
no-store |
Must always reflect live state |
| Metrics | /metrics |
no-store |
Token-protected, must reflect live state (Section 21.3) |
19.4 Immutable content addressing #
Render URLs and OG image URLs embed a hash derived from buildHash + canonicalDesignJson + scale + format (Section 8.9) or, for OG images, the design's short code plus its build hash (Section 14).
Because the URL path itself changes whenever the underlying pixels would change, every such response
can carry Cache-Control: public, max-age=31536000, immutable with no correctness risk: a stale copy
of an immutable URL is impossible, since staleness would require the same URL to serve different
bytes, which cannot happen by construction.
This removes the need for time-based expiry, cache versioning query parameters, or manual purge for
the overwhelming majority of bytes served by the application — every rendered image, every OG image,
and every hashed static JS/CSS bundle (Fresh's build step fingerprints asset filenames). The only
remaining invalidation surface is HTML (which references the current catalog and current builds by
label, not by hash) and the small set of non-hashed top-level files (robots.txt, sitemap.xml).
Section 19.5 covers what must be purged when a build is published.
When SKINFORGE_STORAGE_DRIVER=s3, every public render and OG URL is rewritten to
SKINFORGE_S3_PUBLIC_BASE_URL (Section 24.2) instead of the app's own origin, so the CDN and browser
fetch object bytes directly from the configured public storage origin; the immutability and
content-addressing guarantees above are unaffected by which origin ultimately serves the bytes.
19.5 Invalidation on build publish #
Publishing a build (Section 9) changes which assets are active, which can change catalog listings,
hue lists, and — for any design created against the newly retired build's assets — nothing, because
designs pin build_id and continue rendering from the archived build's assets (Section 13,
immutability rule). Publish therefore requires:
Must be purged:
- CDN cache for
/catalog,/catalog/:slotKey,/catalog/:slotKey/:assetKey(all combinations touched by the build's asset changes),/hues,/hues/:hueIndexfor changed hues,/sitemap.xml. - CDN cache for
/(the default preview may reference a newly retired asset key and must fall back per Section 19.5's fallback rule below). - In-process LRU sprite and hue caches. In v1 the application is a single process (Section 23.1), so
this is a direct in-process function call made by the publish workflow —
invalidateSpriteAndHueCaches()— not an HTTP endpoint. There is no/internal/*route in v1, and none appears in Section 10's route list or Section 16's endpoint list. Section 23.12 step 4 (a second app replica) is the point at which this becomes a fan-out, and that step owns specifying its transport, its authentication and its envelope; until then, adding an HTTP form would be an unauthenticated internal surface with no caller.
Must not be purged:
- Any
/render/...or/og/...URL. These are immutable per Section 19.4; a publish never changes what an existing hash means, it only stops new designs from referencing the retired asset keys. - Any existing
/d/:codepermalink's rendered content — only its wrapping HTML cache entry is purged, and only because the HTML includes catalog navigation links, not because the design itself changed.
CDN purge call: the admin publish workflow (Section 17) issues an HTTP request to the URL in
SKINFORGE_CDN_PURGE_URL, authenticated with SKINFORGE_CDN_PURGE_TOKEN (Section 24), with a JSON
body listing the exact paths above (never a wildcard purge, to avoid a thundering-herd of cache
misses against origin for immutable render URLs that do not need purging):
async function purgeCdnPaths(paths: string[]): Promise<void> {
const res = await fetch(Deno.env.get("SKINFORGE_CDN_PURGE_URL")!, {
method: "POST",
headers: {
"Authorization": `Bearer ${Deno.env.get("SKINFORGE_CDN_PURGE_TOKEN")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ paths }),
});
if (!res.ok) {
throw new PurgeFailedError(res.status, await res.text());
}
}Fallback when purge fails: the publish workflow does not roll back the database change — the
publish itself succeeds and is recorded (Section 9 owns publish semantics). The purge call is
retried up to 3 times with exponential backoff (500ms, 2s, 8s) via the job runner (Section 6, jobs
table). If all retries fail, an SF-9002 internal error is logged (Section 25.2) and an
admin.publish.purge_failed event is emitted (Section 21.2); the operator sees a banner in
/admin/dashboard prompting a manual purge. Because HTML pages also carry s-maxage=300, the worst
case is stale catalog navigation for up to 5 minutes even with a fully failed purge — never stale
render content, and never a broken permalink.
19.6 Image delivery #
Format selection is client-side, not server-side. The extension in the path is authoritative
(Section 8.8) and the Accept header is never consulted on /render/* or /og/* — the URL grammar
in 8.8 makes the extension mandatory, so there is nothing left to negotiate and no request in this
namespace can produce a 406. Preview surfaces choose WebP by publishing both URLs in
<picture>/srcset markup and letting the browser pick the first source it can decode; download
links (Section 12's "Download" action) request .png explicitly, since WebP support in image-editing
tools remains inconsistent and downloads are meant for external use. Keeping the choice in the client
is also what preserves the immutability argument in 19.4: one URL always maps to exactly one byte
stream, which a Vary: Accept negotiation would break. The <picture> markup used on preview
surfaces:
<picture>
<source type="image/webp" srcset="/render/d/{code}@1.webp 1x, /render/d/{code}@2.webp 2x, /render/d/{code}@3.webp 3x">
<img src="/render/d/{code}@1.png" width="260" height="330" alt="Character preview" loading="eager" fetchpriority="high">
</picture>Responsive srcset for thumbnails: catalog and slot-swatch thumbnails (Section 15) use scale-1
art at native 260×330 (or the per-slot swatch dimensions in Section 12) with 1x/2x/3x srcset
density descriptors as shown above. No separate width-based breakpoints — the paperdoll art is a
fixed small canvas per Section 8, so density switching (not viewport-width switching) is the correct
model.
Lazy loading rules: the primary paperdoll composite on / and /d/:code uses
loading="eager" fetchpriority="high" since it is the LCP element (Section 19.1). Every other image
— catalog grids, slot swatch strips below the fold, hue swatches beyond the first row — uses
loading="lazy" with no fetchpriority override (defaults to auto).
Sprite sheet vs individual files: individual files, not sprite sheets. Decision and
justification: render URLs are content-addressed per design/asset/hue combination (Section 8.9),
which is the property that makes Cache-Control: immutable safe (Section 19.4) and that lets the CDN
cache each combination independently with no coupling between unrelated assets. A sprite sheet would
require either (a) one sheet per possible asset combination, which does not reduce request count
because there is no finite shared sheet across the space of designs, or (b) a shared per-slot sheet
of all variants, which forces every client to download every hue variant of every asset up front —
far exceeding the page-weight ceilings in Section 19.1 for a designer UI that only ever displays a
handful of variants at a time. Individual, small (a few KB), immutably-cached, HTTP/2-multiplexed
requests are cheaper end to end for this access pattern than any sprite-sheet scheme.
Preload hints for the permalink page: /d/:code includes in its <head>:
<link rel="preload" as="image" href="/render/d/{code}@1.webp" type="image/webp" fetchpriority="high">
<link rel="preconnect" href="https://{SKINFORGE_PUBLIC_BASE_URL host}">No other route preloads images — preload is reserved for the confirmed LCP element to avoid contending with it for bandwidth.
19.7 Database performance #
Query patterns that matter, in descending order of request volume:
- Design resolve by short code:
SELECT * FROM designs WHERE short_code = $1— the hottest query in the system, backing every/d/:codeand/render/d/:code@...request. Indexed uniquely ondesigns.short_code(Section 6). - Catalog listing by slot:
SELECT * FROM assets WHERE slot_key = $1 AND retired_at IS NULL ORDER BY display_order— indexed on(slot_key, retired_at)partial indexWHERE retired_at IS NULL. - Hue lookup by index, render path:
SELECT * FROM hues WHERE hue_index = $1 AND build_id = $2— served by the unique constraint on(hue_index, build_id)(Section 6.13). Two properties of this query matter and are easy to get wrong in opposite directions.build_idis never omitted:huesrows are unique per build, not globally, so a lookup missingbuild_idwould return one row per build that has ever defined that index and risk rendering an existing design with a newer build's colour table — exactly the failure Sections 9.8 and 13.7 forbid. And noretired_atpredicate is applied here: retirement governs discovery, never rendering, so a design that pinned a since-retired hue still renders it from its own build (Sections 8.8, 8.14, 9.7, 9.8, 13.7). The discovery-side queries are the mirror image — the/hueslisting, the hue picker and the catalog queries in item 2 above all carryAND retired_at IS NULL, because a retired hue must stop being offered the moment its build is superseded. - Design insert on create: single-row insert into
designs, guarded by the short-code collision check in Section 13 (aSELECTonshort_codeinside the same transaction as theINSERT, usingINSERT ... ON CONFLICT (short_code) DO NOTHING RETURNING *and a re-hash loop on conflict). - Job claiming:
UPDATE jobs SET status = 'running', claimed_at = now() WHERE id = (SELECT id FROM jobs WHERE status = 'queued' AND run_after <= now() ORDER BY priority DESC, created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED) RETURNING *— indexed on(status, run_after, priority, created_at). The waiting state isqueued, notpending:jobs.statususes the same five values asimport_runs.status—queued,running,succeeded,failed,cancelled(Section 6) — and no query or screen in this document sayspendingfor a job.
N+1 avoidance rules:
- Every list endpoint that returns nested data (e.g. a design with its slot assignments, an asset
with its images at every scale) fetches nested rows with a single follow-up query using
WHERE x = ANY($1::text[])batched over the parent IDs already fetched — never one query per row. - Repository modules under
core/db/expose batch-shaped functions (getAssetsByKeys(keys: string[])) as the only way to fetch by key; there is no public single-row-in-a-loop helper, which makes the N+1 pattern structurally unavailable rather than merely discouraged. - Every admin list screen (Section 17) that joins to a count (e.g. asset count per slot) uses a single
aggregate query with
GROUP BY, never a per-row count query.
Connection pool sizing: postgres.js pool size is set by SKINFORGE_DB_POOL_SIZE (Section 24),
default 10 for the web process and 4 for the CLI process (Section 4). On the reference 4-vCPU VPS
(Section 23.1) with Postgres also co-located, total connections across both processes plus the
Postgres max_connections default of 100 leaves headroom for psql and admin tooling. Sizing rule:
pool size ≈ 2 × vCPU count for the primary web process, since queries are short (sub-10ms typical
per 19.7 query patterns above) and the workload is not connection-hold-heavy.
Statement timeouts: SET statement_timeout = '2000ms' at the pool-connection level for the web
process (public-facing queries must never hang a request past the API budget in Section 19.1), and
SET statement_timeout = '30000ms' for the CLI/import process (bulk inserts during import can
legitimately take longer). A statement timeout breach raises Postgres error 57014, mapped to
SF-9001 (Section 25.2).
19.8 Render queue tuning #
The render queue is the Postgres jobs table (Section 6) claimed with FOR UPDATE SKIP LOCKED
(Section 4). Starting numbers for the reference 4-vCPU / 8 GB VPS:
| Parameter | Starting value | Env var | Rationale |
|---|---|---|---|
| Render worker concurrency | 4 | SKINFORGE_RENDER_MAX_CONCURRENCY |
Matches the reference 4-vCPU VPS core count (Section 23.1); compositing is CPU-bound WASM work (Section 8) and the HTTP server's largely I/O-bound work shares cores opportunistically rather than needing one held in permanent reserve |
| Queue depth soft limit | 200 render jobs in queued status |
(internal constant, not env-configurable) | Beyond this, back-pressure engages (below) |
| Per-render timeout | 8,000 ms | SKINFORGE_RENDER_TIMEOUT_MS |
Generous relative to the p95 cold budget of 300ms (Section 19.1); catches hangs, not slow-but-normal renders. A breach aborts that render and returns 504 with SF-5002 (Section 25.2) |
| Back-pressure response | HTTP 503 with Retry-After: 2 and error SF-5004 |
— | Applied when queue depth exceeds the soft limit; protects the process from unbounded memory growth under a render storm |
Why four workers, and when to lower it. Four render workers saturate a 4-vCPU host without starving the web tier: encoding releases the CPU during WASM I/O, so the HTTP server's largely I/O-bound work interleaves with compositing rather than queuing behind it, and the queue-depth soft limit — not a spare core — is what protects the web tier under a render storm. An operator who co-locates Postgres on the same host under sustained load should lower this to 3, which is the one supported deviation; the value stays 4 everywhere else in this document (Sections 1, 4, 8, 23.1).
Every render request follows the single claim/poll path owned by Section 8.10. The first
requester claims the jobs row and streams its own freshly encoded bytes; concurrent requesters for
the same cache key poll that row and are served the same bytes when it completes. There is no
scale-based or format-based split, no 302, and no 202 on the image namespace — a still-generating
request is served 200 with the pending placeholder and X-SF-Render-Pending: 1 per Section 25.3,
and 202 only to a client that explicitly sends Accept: application/json. A bodyless 202 to an
<img> tag is a broken image in every browser, which is why the image namespace never issues one.
This section owns only the concurrency, queue-depth and timeout numbers; Section 8.10 owns the
mechanism.
Back-pressure specifically protects against the render-queue-exhaustion threat in Section 20.1: an
attacker requesting many distinct never-before-seen design/hue combinations cannot force unbounded
concurrent WASM work, because concurrency is capped at SKINFORGE_RENDER_MAX_CONCURRENCY and excess
requests queue up to the soft limit before the server sheds load with 503s rather than degrading
every in-flight request's latency.
19.9 Front-end performance #
Island payload budget: 60 KB gzipped total for all islands loaded on / (the designer, and the
heaviest route), broken down as: designer state/controls island ≤ 30 KB, preview-canvas island ≤ 20 KB,
share/permalink island ≤ 10 KB. /d/:code ships zero required islands for the read-only view (server-
rendered HTML is fully functional without JS per Section 19.9's no-blocking-JS rule below); an
optional "open in designer" island loads on interaction only.
Code splitting: Fresh's per-route island boundary is the unit of splitting — each island is its
own JS chunk, fetched only on the routes that reference it. The catalog browse islands (filters,
infinite-scroll trigger) never ship on /d/:code, and the designer islands never ship on /catalog.
Font loading: none on the live UI. Section 18.3 fixes the interface on the system-ui stack
precisely to eliminate webfont-induced layout shift on first paint, so no route preloads, links or
@font-face-declares a font, and no font bytes count against the page-weight ceilings in 19.1. The
one bundled face, Pixelify Sans (Section 18.3), is loaded only inside the OG image generator's own
process (Section 14) and is never referenced by a page.
No-blocking-JS rule for permalink pages: /d/:code renders the composite image via the
<picture> markup in Section 19.6 server-side; no JavaScript is required to see the shared design.
Any <script> tag on that route carries type="module" defer or is an island's own deferred hydration
script — never a synchronous, render-blocking <script> in <head>. This is verified by the no-JS
E2E scenario in Section 22.5.
19.10 Load expectations and capacity model #
Normal traffic (baseline planning assumption): 5,000 unique visitors/day, 15,000 page views/day, 2,000 design creations/day, 40,000 render requests/day (most served from CDN cache after the first render). Sustained request rate: roughly 0.5 req/s average, bursty around UO Outlands event announcements.
Reddit/Discord spike on one permalink: a single /d/:code gets linked from a high-traffic
external source. Modeled spike: 2,000 requests in 5 minutes (≈ 7 req/s sustained, higher in the first
30 seconds) concentrated on one permalink page and its one render URL at up to 3 scales.
| Layer | Behavior under the spike |
|---|---|
| CDN | Absorbs nearly all of it — the permalink HTML has s-maxage=300, the render URLs are immutable; after the first request each, every subsequent request in the spike is an edge cache hit and never reaches origin |
| Origin (app process) | Sees at most a handful of origin requests: one per (page, scale×format) combination, until the CDN's s-maxage window rolls over — well under the render concurrency limit in Section 19.8 |
| Render queue | Not engaged beyond the first 1-3 render requests for that design (one per requested scale/format); repeat requests are cache hits at the CDN before reaching the queue |
| Database | One designs row lookup on first HTML render per edge PoP (each PoP's first miss), then nothing — no per-request DB load during the spike |
| Rate limiter | Per-IP design-creation and render rate limits (Section 20.1, Section 24) are irrelevant here since this is read traffic to a cached permalink, not write or render-generation traffic |
The architecture's central capacity property: because permalinks are immutable and content-addressed
(Section 19.4), a viral spike on one design degrades to "CDN serves a popular file," which requires no
origin capacity planning beyond the first-render cost. The failure mode that would matter — many
distinct new designs created in a short window, each requiring a fresh render — is bounded by the
design-creation rate limit (SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR, Section 24) and by the
render queue back-pressure in Section 19.8, not by CDN behavior.
Capacity ceiling on the reference VPS: at SKINFORGE_RENDER_MAX_CONCURRENCY=4 and a cold p95 of
150ms (scale 1), sustained new-design-render throughput is roughly 26 renders/second before queueing
begins, comfortably above the 2,000-creations/day baseline (≈0.02/s average) and able to absorb a
burst of a few hundred simultaneous new designs before back-pressure engages.
19.11 Performance testing method #
Performance budgets in Section 19.1 are enforced automatically in CI (Section 22.11) using Lighthouse CI for LCP/INP/CLS/page-weight against a locally built production bundle, and a custom k6 load-test script for the API and render latency budgets, run against a disposable Docker Compose stack seeded with the fixture data set (Section 22.10). The load scenarios (normal traffic and the permalink spike, Section 19.10) are codified as k6 scenarios and re-run before every release (Section 22.7, Section 22.12). A budget breach fails the release gate; it does not fail every commit's CI run, since the load scenarios take several minutes and run on a separate, slower CI job than the unit/integration suite.
20. Security, Privacy & Legal Posture #
20.1 Threat model #
Assets to protect: the design database (public data, but integrity matters — a corrupted design must never render), the asset/hue catalog and its source game-file provenance, admin credentials and sessions, the extraction pipeline's host filesystem access, server compute and bandwidth, and the operator's reputation as a fan-project operator (legal exposure per Section 20.8).
Actors:
| Actor | Access | Motivation |
|---|---|---|
| Anonymous public visitor | Public pages, public API, render endpoints | Legitimate use; occasionally abusive (spam, scraping) |
| Automated bot/scraper | Public pages, public API | Bulk data harvesting, hotlinking, SEO scraping |
| Malicious visitor | Same as anonymous, at volume or with crafted input | Denial of service, data exfiltration attempts, defacement via crafted names |
| Staff/admin | Admin console, CLI on the host | Legitimate operation; account compromise is the risk, not intent |
| Operator (host owner) | Full host access, CLI, database | Trusted; out of scope as an adversary |
Trust boundaries: (1) public internet → CDN, (2) CDN → app process (HTTP), (3) app process → Postgres (SQL over a local/private network link), (4) app process → object storage (S3 API or local filesystem), (5) admin browser → app process (authenticated HTTP), (6) app process → the operator's local game-client directory, read-only, CLI-invoked only, never reachable from the public HTTP surface, (7) extraction sandbox (Section 20.6) → host filesystem, permission-scoped.
Top ten threats:
| # | Threat | Likelihood | Impact | Control |
|---|---|---|---|---|
| 1 | Scraping/hotlinking of rendered images at volume | High | Medium (bandwidth cost) | Content-addressed immutable URLs are cheap to serve from CDN (Section 19.4); Cross-Origin-Resource-Policy header (20.4) limits cross-site embedding without blocking normal <img> use; render rate limit per IP (SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE) caps origin-generation cost specifically, not CDN-served cache hits |
| 2 | Render-queue exhaustion via many distinct uncached combinations | Medium | High (origin CPU/availability) | Render concurrency cap and back-pressure (Section 19.8); per-IP render rate limit; per-IP design-create rate limit bounds the rate of new renderable combinations |
| 3 | Design-spam (mass automated design creation) | Medium | Medium (storage growth, DB bloat) | SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR per IP; designs are cheap (a JSON row) so impact is capped even under sustained abuse; no CAPTCHA in v1 — rate limiting is the sole control, revisited if abuse is observed (Section 21.5 alerting) |
| 4 | Path traversal in asset keys or file paths | Low | High (arbitrary file read/write) | Asset keys validated against a strict pattern (Section 20.2); all filesystem access under the storage driver's fs root uses path.resolve + a prefix check that rejects any resolved path escaping the root, never raw string concatenation |
| 5 | SSRF via import paths or operator-supplied URLs | Low | High (internal network access) | The extractor takes only local filesystem paths, never URLs (Section 7); no code path in the web app accepts an operator-supplied URL for server-side fetch; the CDN purge call (Section 19.5) targets a fixed, configured, admin-only URL, never user input |
| 6 | Malicious/malformed client files fed to the extractor | Medium | Medium (crash, resource exhaustion, memory corruption in a parser) | Extraction runs sandboxed (Section 20.6) with strict permission scoping, size caps, and per-file timeouts; format readers are fuzz-tested (Section 22.8); a parse failure produces a flagged candidate, never a crash of the host process |
| 7 | Admin credential theft (phishing, credential stuffing, leaked password) | Medium | High (full catalog control) | Argon2id password hashing, mandatory TOTP second factor, brute-force lockout, session hardening (Section 20.5); credential stuffing mitigated by TOTP requirement even if a password leaks |
| 8 | Session fixation / session hijack | Low | High (admin takeover) | Session ID regenerated on login and on privilege change; HttpOnly; Secure; SameSite=Lax cookie; session bound to a server-side row invalidated on logout, password change, or by admin action (Section 20.5) |
| 9 | XSS via asset/hue names extracted from game files | Medium | Medium (stored XSS in catalog/admin pages) | All extracted strings are untrusted (Section 20.3); output-encoded on every render path (Preact JSX auto-escapes; no dangerouslySetInnerHTML anywhere in the codebase, enforced by lint rule in Section 5); CSP (Section 20.4) as defense in depth |
| 10 | Supply-chain compromise (malicious dependency update) | Low | High (full compromise) | Lockfile-pinned installs, deno.lock integrity checking, minimal dependency surface (Section 4), scheduled dependency audit (Section 20.10), least-privilege Deno permission flags in production (Section 20.10) limiting blast radius even if a dependency is compromised |
20.2 Input validation posture #
Every external input — query parameters, path parameters, JSON bodies, headers used for logic
(never for security decisions), and file uploads (admin only) — is validated by a Zod 4.x schema
under core/schema/ before it reaches any handler logic, per the shared-validation rule in Section 5
and Section 16. No handler performs ad hoc type coercion or manual regex checks outside a schema
module. Validation failures return SF-1000-family errors (Section 25.2) with the offending
field populated in the error envelope (Section 16).
Specific rules:
| Input | Rule |
|---|---|
Asset key (asset_key) |
Pattern ^[a-z0-9]+(?:[-_.][a-z0-9]+)*$, max 128 characters, exactly as defined by AssetKeySchema in Section 13.2.5 — this section does not restate it, it enforces it. Rejects anything containing /, \, .., %, control characters, or non-ASCII (all fall outside the pattern already). A key failing the pattern returns 404 SF-2001 and never reaches the storage driver or a lookup query. |
Hue index (hue) |
Integer, 0 <= hue <= 65535 per HueIndexSchema (Section 13.2.5); existence is additionally checked against the hues row for the design's pinned build_id (Section 19.7), not just the numeric range |
| Short code | Exactly 10 characters, Crockford Base32 alphabet minus i/l/o/u (Section 13), case-insensitive on input, canonicalized to lowercase before lookup. A code failing this shape check and a well-formed code with no matching design are deliberately indistinguishable: both are rejected as SF-2000 (404) before any further lookup, never as a 400, so a client probing for valid codes cannot tell "malformed" from "not found" (Section 13.4.1). |
| File paths (CLI/import only, never public) | Resolved with path.resolve against the configured work directory (SKINFORGE_IMPORT_WORKDIR), then checked with path.relative to confirm the result does not start with .. or become absolute outside the root. Symlinks are resolved and re-checked (Section 20.6). |
| Body code | Enum m | f exactly, per Section 3. |
| Pagination cursor | Opaque, application-generated, HMAC-signed with SKINFORGE_ADMIN_SESSION_SECRET-derived subkey so a client cannot forge a cursor to probe internal row ordering; malformed or invalid-signature cursors return SF-1010. |
| Search/query grammar (Section 15) | Parsed by a dedicated grammar parser under core/search/, itself covered by the Zod schema for its raw string length (max 200 characters) before parsing; the parser never constructs SQL by string concatenation — it builds a parameterized query via postgres.js tagged templates only. |
20.3 Untrusted content from game files #
Every string extracted from client files during import (Section 7, Section 9) — sprite/gump display names, hue group names, tiledata flags text, any operator-supplied "format note" — is treated as untrusted user input, identical in posture to a public form submission, even though the operator is trusted to run the extractor. The game files themselves may be corrupted, hand-edited, or sourced from a modified client, so the content within them is not assumed clean.
Rules applied at import time, before any extracted string reaches the database:
- Length cap: display names truncated to 120 characters; any
format notefield truncated to 2,000 characters. Truncation is logged (import.string_truncatedevent, Section 21.2), never silent data loss without a trace. - Character allowlist: printable UTF-8 only; control characters (except tab/newline in the
format-note free-text field) are stripped. Names used as
*_keynatural keys (Section 6.2) are additionally restricted to the asset-key pattern in Section 20.2 — any extracted name that does not fit is not used as a key; the importer generates a synthetic key and stores the raw extracted name only as a display label. - Escaping: no HTML escaping is performed at import time — escaping is an output-time responsibility (Section 20.1, threat #9) so that the stored value remains the faithful source string and every rendering surface (HTML via Preact auto-escaping, JSON via standard serialization, log lines via structured JSON per Section 21.1) applies its own correct encoding. This avoids the classic double-escaping bug where an already-HTML-escaped string gets escaped again in a different output context.
- Null bytes: rejected outright; a string containing a null byte fails import validation for
that field and the candidate is flagged
needs_operator_input(Section 7's staged-discovery fallback rule) rather than silently truncating at the null byte.
20.4 HTTP security headers #
Applied by shared middleware in core/http/security-headers.ts, mounted before every route handler.
Two profiles: public (all routes under /, /d, /catalog, /hues, /about, /faq, /legal,
/support, /changelog, /render, /og, /api/v1) and admin (all /admin/* routes, including
/admin/api/*, Section 17.17).
Public profile:
Content-Security-Policy: default-src 'self'; img-src 'self' data: blob: ${SKINFORGE_S3_PUBLIC_BASE_URL}; script-src 'self' 'wasm-unsafe-eval' 'nonce-{PER_RESPONSE_NONCE}'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' ${SKINFORGE_S3_PUBLIC_BASE_URL}; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=(), usb=(), interest-cohort=()
Cross-Origin-Resource-Policy: cross-origin
Cross-Origin-Opener-Policy: same-origin
X-Frame-Options: SAMEORIGINAdmin profile: identical except:
Content-Security-Policy: default-src 'self'; img-src 'self' data: blob: ${SKINFORGE_S3_PUBLIC_BASE_URL}; script-src 'self' 'wasm-unsafe-eval' 'nonce-{PER_RESPONSE_NONCE}'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' ${SKINFORGE_S3_PUBLIC_BASE_URL}; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests
Cross-Origin-Resource-Policy: same-origin
X-Frame-Options: DENYThe middleware generates a fresh, per-response 128-bit base64 nonce and every inline script — the
theme script (Section 18.10) and each island's hydration script — carries nonce={nonce}; no
<script> is ever inline without it. ${SKINFORGE_S3_PUBLIC_BASE_URL} is substituted only when
SKINFORGE_STORAGE_DRIVER=s3 (Section 24.2); on the fs driver it is omitted entirely since renders
are served same-origin.
| Header | Reason |
|---|---|
Content-Security-Policy |
default-src 'self' blocks any third-party script/style/connect injection, consistent with the no-third-party-request privacy stance (Section 20.7); style-src 'unsafe-inline' is required because Fresh/Preact islands set inline styles for dynamic hue-preview swatches; script-src permits no bare inline script — the one blocking theme script (Section 18.10) runs only via its per-response nonce, and 'wasm-unsafe-eval' is required for the client-side WASM compositor (Section 8.12) to compile at all in Chromium-family browsers; img-src data: blob: plus the configured public storage origin allows the client-side composite canvas (Section 8) to embed data/blob-URL sprites and, on the s3 storage driver, to load renders from a different origin than the app; connect-src carries the same storage-origin addition for the same reason; object-src 'none' and base-uri 'none' close classic injection vectors; frame-ancestors differs by profile — public pages are same-origin-framable only, and social unfurls consume the OG image (Section 14) rather than iframing the page, while admin pages must never be framed at all (clickjacking defense) |
Strict-Transport-Security |
Forces HTTPS for a year including subdomains; preload opts into browser HSTS preload lists since the site has no legitimate plain-HTTP use case |
Referrer-Policy |
Sends full referrer to same-origin, only the origin to cross-origin — avoids leaking full permalink paths (which could reveal a design someone shared privately) to third-party analytics on outbound links |
X-Content-Type-Options |
Prevents MIME-sniffing of render/OG image responses or JSON API responses as executable content |
Permissions-Policy |
Explicitly denies powerful browser features the app never uses, reducing the attack surface of any future injected content |
Cross-Origin-Resource-Policy |
cross-origin on public routes intentionally allows hotlinking of render/OG images (the whole point of a shareable permalink and OG preview) while same-origin on admin routes blocks any cross-site read of admin responses |
Cross-Origin-Opener-Policy |
Isolates the browsing context group, mitigating cross-window attacks (e.g. Spectre-class side channels, window.opener abuse) |
X-Frame-Options |
Legacy-browser fallback for frame-ancestors, same reasoning per profile |
20.5 Admin security #
Authentication and TOTP enrollment flows are owned by Section 17. This section specifies the security controls around those flows:
- CSRF: every state-changing admin request (
POST/PUT/PATCH/DELETEunder/admin/*and/admin/api/*) requires a CSRF token issued as a signed double-submit cookie (sf_csrf,HttpOnly: falseso the client script can read it,Secure; SameSite=Lax) matched against a request headerX-SF-CSRF. A mismatch or missing token returnsSF-4003(Section 25.2).SameSite=Laxon the session cookie itself already blocks the classic cross-site form-POST CSRF case; the double-submit token is defense in depth against subdomain-hosted content and covers fetch-based CSRF thatSameSite=Laxalone does not fully address for non-navigational requests. The CSRF cookie value isbase64url(HMAC-SHA256(sessionTokenHash, SKINFORGE_ADMIN_SESSION_SECRET)), so a token minted for one session is not valid for another and a cookie-injection attack on a sibling subdomain cannot forge a matching pair. - Session hardening: sessions are server-side rows in
admin_sessions(Section 6), referenced by an opaque, high-entropy (128-bit) token in thesf_admincookie — the token itself carries no decodable claims. Session lifetime, idle timeout and absolute timeout are owned by Section 17.1; this section adds no separate TTL. Session ID is regenerated on login and immediately invalidated on logout, password change, or TOTP re-enrollment. - Brute-force protection: login attempts are rate-limited per account exactly as specified in
Section 17.2.3 — 5 combined failures in a rolling 15-minute window trigger a 15-minute cooldown
returning
SF-3003(429) — counted inadmin_login_failures(Section 6). There is no per-IP login lockout, for the reason 17.2.3 gives: an IP-based limit would let an attacker lock a legitimate admin out from a shared or proxied address. There is no exponential backoff beyond that fixed cooldown, and TOTP failures are counted in the same 5-failure budget rather than a separate one, so brute-forcing the 6-digit code within its 30-second window is bounded by the same counter. Section 17.2 owns this mechanism; this section states the control and adds no second one. - Privilege separation: role model and the per-action permission matrix are owned by Section 17.3
(three ordered roles —
viewer,curator,owner); this section adds no separate role rules. Every admin user (admin_userstable, Section 6) is created via CLI only, per Section 17.1's access model; there is no in-console user self-registration or self-elevation. - Audit requirements: every admin mutation (publish, retire, edit, user create/delete, settings
change, takedown action) writes a row to
audit_log(Section 6) withactor(admin user id),action,target,before/afterJSON diff where applicable,created_at, and the request'srequestIdfor cross-referencing with structured logs (Section 21.1). Audit log rows are append-only — no update or delete path exists in the application layer.
20.6 Extraction sandboxing #
The extractor (skinforge-cli extract, Section 7) runs entirely offline against a local directory
the operator points it at. It never touches the network and never runs inside the public-facing web
process — it has no network permission at all, not even a scoped one. It writes an extraction
manifest to disk; a separate import command, invoked afterward with its own, distinct permission set,
reads that manifest and performs every database and object-storage write. Splitting the two steps is
what makes the "never touches the network" claim in this section literally true rather than
contradicted by the pipeline's need to reach Postgres. Sandboxing is enforced via Deno's permission
model — each command is invoked with an explicit, minimal permission set rather than --allow-all:
Step 1 — extract (offline, sandboxed):
deno run \
--allow-read="$SKINFORGE_IMPORT_WORKDIR,<operator-supplied-client-dir>" \
--allow-write="$SKINFORGE_IMPORT_WORKDIR" \
--allow-env=SKINFORGE_ENV,SKINFORGE_IMPORT_WORKDIR,SKINFORGE_IMPORT_MAX_UPLOAD_MB \
--no-prompt \
cli/main.ts extract --source <operator-supplied-client-dir> --build-label <label>Step 2 — import (network-permitted, ingests the manifest):
deno run \
--allow-read="$SKINFORGE_IMPORT_WORKDIR" \
--allow-write="$SKINFORGE_IMPORT_WORKDIR,$SKINFORGE_STORAGE_FS_ROOT" \
--allow-net="<db-host>:5432,<s3-host>:443" \
--allow-env=SKINFORGE_ENV,SKINFORGE_DATABASE_URL,SKINFORGE_IMPORT_WORKDIR,SKINFORGE_S3_ENDPOINT,SKINFORGE_S3_BUCKET,SKINFORGE_S3_ACCESS_KEY_ID,SKINFORGE_S3_SECRET_ACCESS_KEY,SKINFORGE_STORAGE_DRIVER,SKINFORGE_STORAGE_FS_ROOT \
--no-prompt \
cli/main.ts import --manifest "$SKINFORGE_IMPORT_WORKDIR/<label>.manifest.json"Notes on each flag:
- Step 1's
--allow-readis scoped to exactly two directories: the import working directory and the operator-supplied source client directory, passed as an explicit CLI argument, never inferred or expanded. Its--allow-writeis scoped to the import working directory only — the extractor never writes inside the source client directory (read-only respect for the operator's game installation) and never writes anywhere else on the host. - Step 1 has no
--allow-netflag at all — the extractor has no legitimate reason to make a network call, closing the SSRF and exfiltration surface (Section 20.1, threat #5) at the permission level, not just by code review. Its--allow-envallowlist correspondingly omitsSKINFORGE_DATABASE_URLand every storage credential — the extractor never needs them, because it never talks to Postgres or object storage; it only writes the manifest to disk for step 2 to consume. - Step 2's
--allow-netlists exactly the database host and the object-storage endpoint it needs to reach to ingest the manifest; it carries no listener since the import command is not a server. - Neither command uses
--allow-run— nothing ever shells out to another process or binary; all format decoding is pure TypeScript/WASM per Section 4.2's stack decision. --allow-envin both commands is an explicit allowlist, not blanket env access.--no-promptensures a permission gap fails closed (the process exits with an error) rather than blocking on an interactive terminal prompt that would hang an unattended re-import run.
Resource limits: the extractor enforces its own internal caps independent of the OS: per-file
read cap of 512 MB (larger files are flagged needs_operator_input, never streamed unbounded into
memory), per-file decode timeout of 30 seconds (a hung decode is killed and the candidate flagged
decode_timeout), and a total run wall-clock budget of 2 hours after which the run is marked
partial and resumable (Section 9 owns resumability semantics). These caps directly address threat
#6 in Section 20.1 — a malformed or adversarially crafted file can degrade that one candidate's
processing, never exhaust host memory or hang the run indefinitely.
20.7 Privacy #
No accounts, no cookies for public visitors. The single exception: the transient editor state in
the query string (Section 13) is not a cookie and carries no identifier — it is justified because it
is the mechanism by which the designer UI persists in-progress work across a page reload without any
server-side state or client-side storage, and it contains only the design fields already defined in
Section 13, never anything identifying the visitor. No localStorage, no cookie, no tracking pixel
and no analytics storage is used for public visitors. The single exception is a sessionStorage key
recording dismissal of the one-time informational banner (Section 11), which contains no identifier,
is per-tab, and is never read by the server. Admin visitors receive the sf_admin session cookie and
sf_csrf token (Section 20.5) — necessary-for-the-service cookies that, under GDPR Article
6(1)(b)/(f) and the ePrivacy Directive's "strictly necessary" exemption, do not require consent, and
which no public/anonymous visitor ever receives.
What is logged: structured request logs per Section 21.1 — method, path, status, duration, truncated IP (below), user agent, referrer. No request body is logged for public endpoints. Admin request logs additionally include the acting admin user id (never a public visitor identifier, since none exists).
IP handling and truncation: the full IP address is used transiently, in memory, for rate-limit
bucket keys (Section 20.1). Full IP addresses are additionally written to (a)
rate_limit_buckets.bucket_key, (b) admin_sessions.ip_address and audit_log.ip_address for staff
accounts only, and (c) Caddy's own access log (Section 23.2) — none of these are public-visitor
identifiers in the personal sense, since staff accounts are trusted operators and rate-limit buckets
are transient. What is persisted in the application's own structured request logs (Section 21.1) is a
truncated form — the last octet zeroed for IPv4 (203.0.113.0) and the last 80 bits zeroed for IPv6
(/48 network) — sufficient for coarse abuse-pattern analysis (Section 21.6) without storing an
individually identifying address in that stream. Rate-limit buckets themselves (rate_limit_buckets
table, Section 6) key on the full IP but expire and are deleted within 24 hours of the bucket's last
activity (Section 6.33).
Retention periods:
| Data | Retention |
|---|---|
| Structured request logs (Section 21.1) | Container stdout only, rotated by Docker (50 MB × 5 files, Section 21.1); never queried by the application and never a system of record |
| Rate-limit buckets (full IP, transient) | 24 hours after last activity (Section 6.33) |
Aggregate daily counters (page_view_daily, design_view_daily, stat_counters, Section 6) |
Indefinite — contains no per-visitor data, only rollup counts |
| Designs | Never deleted; indefinite and immutable (Section 13.7), except that the takedown path (20.8) marks one taken-down and serves 410 without deleting the row |
| Admin audit log | Indefinite — an operational and legal record |
| Admin sessions | Soft-revoked (revoked_at set) on expiry, logout or admin action per Section 17.1's TTLs; revoked rows are pruned after 30 days (Section 6.33). Rows are never hard-deleted, so the session audit trail survives the revocation |
Caddy access log (/data/access.log, full untruncated IPs, Section 23.2) |
Operator-managed infrastructure logging, excluded from the application-log rotation policy above (Section 21.1) |
Why no consent banner is required: the site sets no cookie and performs no client-side storage
for any visitor who is not staff logging into the admin console. It loads no third-party script, no
third-party font, no third-party analytics, and no third-party embed (Section 20.4's CSP
structurally enforces this — default-src 'self' would block any such inclusion). Under the GDPR and
the ePrivacy Directive, a consent banner is required for non-essential tracking technologies; since
none are used, none is shown. The privacy statement (below) discloses this reasoning explicitly so
the absence of a banner is not itself confusing to a visitor who expects one.
Plain-English privacy statement (published at /legal, Section 10):
SkinForge does not require an account and does not use tracking cookies. We do not run analytics scripts, advertising, or any third-party tracker. When you visit the site, our server temporarily sees your IP address to prevent abuse (for example, to stop one visitor from overwhelming the service); only a coarsened version of it ever appears in our application logs, which are a rotating operational tail we do not keep or query long-term. If you create a character design, it is saved so its shareable link keeps working — the design itself contains no personal information, only your chosen character appearance. We do not sell or share any data, because we do not collect any to sell. If you have questions, contact us at the address on our support page.
20.8 Legal posture of a fan tool #
Unofficial/not-affiliated disclaimer — the exact wording is owned by Section 10.4 and is reproduced verbatim wherever it appears, including in the footer of every public page (Section 10 owns placement):
Outlands SkinForge is an unofficial fan project. It is not affiliated with, endorsed by, or sponsored by UO: Outlands, Broadsword Online Games, or Electronic Arts.
All game art, names, and trademarks referenced or displayed are the property of their respective owners and are used here solely to preview character appearances for players of the game. No raw game files are distributed by this site — every image shown is a preview rendered from data extracted by the site operator from their own licensed client installation.
No raw game files are ever distributed. Only derived preview renders (PNG/WebP composites, Section 8) are served publicly. The original MUL/UOP/hue-table bytes never leave the server's private storage; there is no download, export, or API endpoint anywhere in the system (Section 16's endpoint list, Section 10's route list) that returns raw extracted game data. This is a structural property, not a policy note — no route handler in the codebase has read access to the raw source-file storage location; only the CLI/import process does (Section 20.6).
Operator must own a legitimate client installation. The extractor operates on a local directory the operator supplies (Section 7); the documentation (Section 27) states plainly that the operator is responsible for running the tool only against a client installation they are legitimately entitled to use, and the tool performs no license verification of its own — that responsibility is the operator's, stated here as a condition of use rather than a technical control.
Trademark acknowledgement: stated in the paragraph above ("the property of their respective
owners") and repeated in full on /legal alongside the takedown process.
Takedown/DMCA process, published at /legal:
If you are a rights holder and believe content on SkinForge infringes your rights, contact us at the address below with (1) a description of the content, (2) its URL, and (3) your contact information. We will review requests within 5 business days and remove or disable access to content found to infringe. Contact: the address configured for this purpose (see below).
The contact address is SKINFORGE_CONTACT_EMAIL (Section 24), rendered into the /legal and
/support pages at build/request time rather than hard-coded, so it stays a single source of truth.
Response SLA: 5 business days to first response, stated in the published text above.
410 behaviour for removed designs: a design actioned through the takedown workflow
(takedown_requests table, Section 6; workflow owned by Section 17) is marked with a takedown
timestamp. Its permalink (/d/:code) and every render URL derived from it (/render/d/:code@...,
/og/d/:code.png) return HTTP 410 Gone with error SF-2005 (Section 25.2) and a short HTML page
stating the content was removed following a rights request, rather than 404 — 410 signals
permanence to crawlers and CDNs so the URL is dropped from caches and search indexes rather than
retried. This is the sole exception to the immutability rule in Section 13.7, and it
is implemented as a status flag checked before the normal render/lookup path, not by deleting the
underlying row (audit and reversal remain possible; Section 17 owns the reversal workflow).
20.9 Content licensing #
The tool's own source code, original UI copy, and original design-system assets (Section 18) are the
operator's work product; the specification does not mandate a particular open-source license and
leaves that choice to the operator as a business decision outside this document's scope, except to
state that whatever license is chosen must be recorded in a LICENSE file in the repository root
(Section 5 owns repository layout) so the choice is unambiguous to contributors.
Game art, sprite data, hue tables, and any other content extracted from UO Outlands client files remain the property of their original owners at all times. SkinForge's database stores only positional/provenance metadata and derived renders (Section 6, Section 9) — it never asserts ownership over the underlying art, and the disclaimer in Section 20.8 states this explicitly and persistently.
20.10 Dependency and supply-chain security #
Lockfile policy: deno.lock is committed to the repository and is the sole source of truth for
resolved dependency versions at deploy time; deno.json's import map states version ranges per
Section 4's major-line policy, and deno.lock pins the exact resolved versions within those ranges.
CI (Section 22.11) fails if deno.lock is out of sync with deno.json (deno install --frozen
detects drift).
Integrity checking: Deno verifies module integrity against deno.lock's recorded hashes on every
run; a tampered or substituted module fails to load rather than silently executing. npm: dependency
integrity is additionally covered by the standard npm package-lock-equivalent hash verification Deno
performs when resolving npm: specifiers.
Update cadence: dependency versions are reviewed monthly (a recurring operator task, Section 23.10) and immediately upon a published security advisory affecting a direct dependency. Updates follow the major-line policy in Section 4 — patch and minor updates within the stated major line are applied routinely; a major-line bump is a deliberate, tested change, not an automatic one.
Vulnerability scanning: CI runs deno outdated and an npm-advisory check against the resolved
npm: specifiers (Section 22.11) on every push to the main branch and on a weekly schedule
independent of pushes, so a newly published advisory against an already-deployed dependency is caught
even without new commits. A finding at "high" or "critical" severity blocks the release gate (Section
22.11) until resolved or explicitly waived with a recorded reason in the CI configuration.
Deno permission flags used in production: the web process runs with an explicit permission set,
never --allow-all:
deno run \
--allow-net="0.0.0.0:${SKINFORGE_PORT},postgres:5432,minio:9000,api.cdn.example:443" \
--allow-read="/app,${SKINFORGE_STORAGE_FS_ROOT},${SKINFORGE_RENDER_CACHE_DIR},${SKINFORGE_IMPORT_WORKDIR}" \
--allow-write="${SKINFORGE_STORAGE_FS_ROOT},${SKINFORGE_RENDER_CACHE_DIR},${SKINFORGE_IMPORT_WORKDIR}" \
--allow-env \
--no-prompt \
main.ts--allow-net lists the listen address plus every outbound host the web process actually dials —
Deno's --allow-net allowlist governs outbound connects as well as inbound listens, so omitting the
database host, the object-storage endpoint and the CDN purge host would make it impossible for the
process to ever reach Postgres, object storage, or the CDN purge API (Section 19.5). postgres and
minio are the Compose service DNS names (Section 23.2); api.cdn.example is a placeholder for the
operator's real SKINFORGE_CDN_PURGE_URL host (Section 24.2) and must be replaced with it.
--allow-read additionally covers the storage filesystem root and the render cache directory — the
fs driver reads stored renders and catalog objects back out of both on every cache hit, not just
writes them — and the import work directory (the admin candidate-preview route, Section 7.11, which
reads it server-side). --allow-write covers the same three directories: the storage root and render
cache for generated images, and the import work directory because the admin ZIP-upload source mode
(Section 17.5.1) has the web process stream an uploaded archive into it. Omitting the third would
make that upload fail on its first written byte with Deno.errors.PermissionDenied, and --no-prompt
makes that failure hard rather than an interactive prompt.
The flags above are written with ${VARIABLE} names for readability. Docker performs no variable
expansion inside an exec-form CMD, so the Dockerfile in Section 23.2 carries the same flags with the
literal container paths those variables resolve to (/data/storage, /data/render-cache,
/data/import-work) — the two are the same permission set, written for two different readers. --allow-env is unscoped here (unlike the extractor in Section 20.6) because the web
process legitimately consumes the full SKINFORGE_* configuration table (Section 24); this is an
accepted, documented asymmetry — the web process's blast radius from a compromised dependency is
bounded instead by the narrow, explicitly-listed --allow-net/--allow-read/--allow-write scopes,
which prevent arbitrary outbound connections or filesystem access even if application code is
compromised.
20.11 Incident response #
Severity levels:
| Severity | Definition | Example |
|---|---|---|
| SEV1 | Public site down, or data integrity/confidentiality breach in progress | Database compromised, admin account taken over, site returning 5xx for all traffic |
| SEV2 | Significant degradation, no confirmed breach | Render pipeline down, sustained high error rate on one route class |
| SEV3 | Localized or cosmetic issue | One asset renders incorrectly, a single admin workflow broken |
First responder steps (the operator, since this is a one-person-operator system per Section 23.10):
- Confirm scope using the dashboards in Section 21.4 and the alert that fired (Section 21.5).
- For a suspected breach (SEV1): rotate the affected secret immediately (Section 24.5's rotation
procedure), invalidate all admin sessions with
UPDATE admin_sessions SET revoked_at = now() WHERE revoked_at IS NULL;— a soft revoke, never aDELETE, so the session rows remain available for the incident timeline (Section 6.23) — and enable maintenance mode (Section 23.9) if public data integrity is in question. - Contain: for a render-queue or rate-limit-driven outage, tighten the relevant
SKINFORGE_RATE_LIMIT_*value (Section 24) and redeploy; for a bad publish, roll back to the prior build (Section 9's rollback rule, Section 23.5). - Eradicate and recover: apply the fix, verify with the smoke test (Section 23.5), disable maintenance mode.
- Record a timeline as it happens — this becomes the post-incident review input.
Communication template, used for any SEV1/SEV2 publicly visible incident, posted on /changelog
or the operator's chosen community channel:
[Date] — SkinForge experienced [brief description] between [start] and [end]. Impact: [what users saw]. Cause: [one sentence, non-technical]. Status: resolved / monitoring. We will [any follow-up action] to reduce the chance of recurrence.
Post-incident review requirement: every SEV1 and SEV2 incident gets a written review within 7 days covering timeline, root cause, what worked, what did not, and concrete follow-up actions with owners (the operator, in the one-person model) — filed as a dated entry in the operations log referenced in Section 23.10. SEV3 incidents are logged but do not require a formal review unless a pattern of related SEV3s emerges (three or more related SEV3s in 30 days is treated as one SEV2 for review purposes).
20.12 Security testing requirements and pre-launch checklist #
Security testing is specified in full in Section 22.8: dependency audit, HTTP header assertions, CSP validation, an authorization matrix test suite (every admin route tested for a rejection when called without a session, with an expired session, and with a non-admin-scoped session), and fuzzing of the MUL/UOP/hue-table format readers with malformed and truncated inputs.
Pre-launch security checklist, run once before the first production deploy and again before any major release (Section 22.12 ties this to the definition of done):
- All HTTP security headers present and correct on both profiles (20.4), verified by the CI header-assertion test (22.8).
- CSP contains no bare
'unsafe-eval'token in any directive — matched as a whole token, never by substring, since the expected and required'wasm-unsafe-eval'containsunsafe-evalas a substring and a substring check would fail on a correct policy;unsafe-inlineappears only instyle-src; every inline script is permitted only via the per-response nonce, never a bare'unsafe-inline'inscript-src(20.4). - Admin login requires TOTP with no bypass path; TOTP recovery codes (
admin_recovery_codes, Section 6) are single-use and hashed at rest. - CSRF protection verified on every state-changing admin route (20.5).
- Rate limits configured and verified against the values in Section 24, not left at development-friendly defaults.
-
deno.lockcommitted and CI drift check passing (20.10). - Dependency audit clean of high/critical findings, or findings explicitly waived and recorded (20.10).
- Extractor invoked only with the scoped permission flags in Section 20.6, verified by inspecting
the CLI's documented invocation, never
--allow-all. - No secret present in the repository (
git log -pscan plus.gitignorecovering.env*, Section 24.5). - Object storage bucket/filesystem root is not publicly listable/browsable independent of the application (verified against the storage driver configuration, Section 23.2).
- Takedown workflow tested end to end: a design actioned through it returns
410on every derived URL (20.8). - Privacy statement (20.7) published at
/legaland matches the actual data practices verified by code review of logging call sites. - Backup restore drill (Section 23.6) completed successfully within the last 90 days.
21. Observability — Logging, Metrics & Analytics #
21.1 Logging #
All application logs are structured JSON, one object per line, written to stdout (captured by Docker Compose's logging driver per Section 23.2). No plain-text log lines anywhere in the codebase.
Log schema — every log line carries these fields; handler-specific fields are added on top:
| Field | Type | Always present | Description |
|---|---|---|---|
timestamp |
string, ISO-8601 UTC with Z |
yes | Event time, generated at log-call time, not at flush time |
level |
"debug" | "info" | "warn" | "error" |
yes | See level guidance below |
event |
string, dot-namespaced | yes | Machine-readable event name, e.g. design.created, see the catalogue in 21.2 |
requestId |
string, ULID | yes for request-scoped logs | Matches the requestId in the API error envelope (Section 16), correlates every log line for one request |
route |
string | yes for HTTP logs | The matched route pattern, e.g. /d/:code, never the raw path with live values |
method |
string | yes for HTTP logs | HTTP method |
status |
number | yes for HTTP logs | Response status code |
durationMs |
number | yes for HTTP/job logs | Wall-clock duration of the handler or job |
buildId |
string, ULID | when relevant | The active game build involved, for render/catalog/import events |
actor |
string, ULID | admin events only | The acting admin_users.id; never present for public-visitor events since none are identified (Section 20.7) |
errorCode |
string | on error | The SF-XXXX code (Section 25) |
errorMessage |
string | on error | Internal diagnostic message — distinct from the user-facing message in the API envelope, may include stack context |
ip |
string | HTTP logs only | Truncated per Section 20.7 (last octet/80 bits zeroed) before it is ever passed to the logger — the logger never receives a full IP |
Levels and when to use each:
| Level | Use |
|---|---|
debug |
Verbose internal state, disabled by default (SKINFORGE_LOG_LEVEL=info default per Section 24), enabled only for local troubleshooting |
info |
Normal operational events: requests served, designs created, jobs completed, imports progressing |
warn |
Recoverable anomalies: a retry occurred, a cache purge failed and fell back, a rate limit was hit, an extraction candidate needed operator input |
error |
Unrecovered failures: an unhandled exception, a job exhausted its retries, a render failed, an internal error (SF-9xxx) was returned to a client |
What must never be logged: full IP addresses (only the truncated form, above), session tokens,
CSRF tokens, TOTP secrets or codes, password hashes, admin recovery codes, database connection
strings, S3 credentials, or any raw request body for public endpoints. Admin request bodies are
logged only with secret-bearing fields (password, totpCode, any *_secret/*_token key) redacted
by a shared redactSecrets() pass applied before every log call that includes a request body — this
is enforced by a lint rule (Section 5) that forbids console.log/direct body interpolation outside
the logging module.
Sampling: info-level HTTP access logs for high-volume, low-value routes (/render/... cache
hits served without touching application logic beyond a storage read) are sampled at 10% in
production (configurable via SKINFORGE_LOG_LEVEL's effect combined with an internal sampler, not a
separate env var — sampling only applies to the specific render-hit access log event, never to
warn/error events, which are always logged at 100%). All other events are logged at 100%.
Rotation: the application does not implement its own log rotation — stdout is captured by
Docker's json-file logging driver configured with max-size: "50m" and max-file: "5" in the
Compose file (Section 23.2), giving a bounded 250 MB per container with automatic rotation. Container
stdout is the only place structured request logs live; it is an operational tail for debugging, never
a queryable system of record. This rotation policy is the whole of the application's log-retention
commitment — there is no separate 30-day log-retention promise anywhere in this document, and Sections
20.7 and 23.2 refer back to this paragraph rather than to one. Anything that must survive rotation
lives instead in the synchronously-written aggregate tables — page_view_daily and
design_view_daily (Section 6, Section 21.6) — which contain no per-visitor data and are retained
indefinitely; the specification does not mandate shipping raw stdout anywhere beyond Docker's own
rotation.
21.2 Event catalogue #
Every named event the application emits, grouped by area. Each event's row lists the event-specific fields beyond the common schema in 21.1.
Public traffic:
| Event | Fields |
|---|---|
http.request |
route, method, status, durationMs — emitted for every HTTP response |
design.created |
designId, shortCode, buildId, slotCount |
design.resolved |
shortCode, cacheHit (boolean), found (boolean) |
design.takedown_served |
shortCode — a 410 was served for a taken-down design |
render.served |
renderKey, scale, format, cacheHit, durationMs |
render.queued |
renderKey, queueDepth |
render.failed |
renderKey, errorCode, durationMs |
og.generated |
shortCode, durationMs |
catalog.viewed |
slotKey (nullable), assetKey (nullable) |
rate_limit.exceeded |
bucket, limit, windowSeconds |
Import/extraction:
| Event | Fields |
|---|---|
import.run_started |
importRunId, sourcePath (host path is never logged; only a redacted label the operator assigns per run) |
import.stage_completed |
importRunId, stage (one of inventory|identify|probe|extract|classify|normalize|import|verify, Section 6.6), durationMs, itemCount |
import.candidate_flagged |
importRunId, candidateId, reason (needs_operator_input, decode_timeout, checksum_mismatch) |
import.string_truncated |
importRunId, field, originalLength |
import.run_completed |
importRunId, durationMs, candidateCount, status (succeeded|failed|cancelled, Section 6.6) |
build.published |
buildId, actor, assetCount |
build.rolled_back |
buildId, previousBuildId, actor |
Admin:
| Event | Fields |
|---|---|
admin.login_succeeded |
actor |
admin.login_failed |
reason (bad_password|bad_totp|locked), attempted account identifier is not logged in cleartext beyond an internal hash for lockout accounting |
admin.session_revoked |
actor, reason (logout|expired|admin_action) |
admin.publish.purge_failed |
buildId, attempt, errorCode |
admin.audit_write |
actor, action, target — mirrors the audit_log row (Section 20.5) into the log stream for real-time alerting |
admin.takedown_actioned |
actor, shortCode, takedownRequestId |
Jobs:
| Event | Fields |
|---|---|
job.claimed |
jobId, jobType, attempt |
job.completed |
jobId, jobType, durationMs |
job.failed |
jobId, jobType, attempt, errorCode, willRetry (boolean) |
job.exhausted |
jobId, jobType, attempts — final failure after all retries |
System:
| Event | Fields |
|---|---|
app.started |
version, env, effectiveConfigSummary (non-secret config, per Section 24.6) |
app.migration_applied |
migrationName |
app.shutdown_initiated |
reason (sigterm|sigint) |
21.3 Metrics #
Exposed at GET /metrics in Prometheus text exposition format, protected by a bearer token
(SKINFORGE_METRICS_TOKEN, Section 24) checked against the Authorization header — this endpoint is
never publicly cacheable (no-store, Section 19.3) and never listed in the public route map (Section
10). Gated entirely by SKINFORGE_METRICS_ENABLED; when false, the route returns 404.
Registry:
| Metric | Type | Labels | Description |
|---|---|---|---|
skinforge_http_requests_total |
Counter | route, method, status |
Total HTTP requests |
skinforge_http_request_duration_ms |
Histogram | route, method |
Request latency, buckets at 5/10/25/50/100/250/500/1000/2500ms |
skinforge_render_queue_depth |
Gauge | — | Render jobs currently in queued status (Section 6's jobs.status vocabulary: queued, running, succeeded, failed, cancelled — a job is never pending) |
skinforge_render_duration_ms |
Histogram | scale, format, cacheHit |
Render latency, same buckets as above |
skinforge_cache_hit_ratio |
Gauge | layer (cdn|lru|storage) |
Rolling 5-minute hit ratio, computed from counters below |
skinforge_cache_hits_total |
Counter | layer |
Cache hits by layer |
skinforge_cache_misses_total |
Counter | layer |
Cache misses by layer |
skinforge_designs_created_total |
Counter | — | Total designs created |
skinforge_import_stage_duration_ms |
Histogram | stage |
Import stage duration, labelled with the import_runs.stage value (Section 6.6: inventory, identify, probe, extract, classify, normalize, import, verify) — never a D1…D6 abbreviation, which is Section 7's internal name for the pipeline stage, not a column value |
skinforge_job_failures_total |
Counter | jobType |
Job failures after final retry |
skinforge_job_queue_depth |
Gauge | jobType |
Jobs in queued status, by type |
skinforge_storage_bytes |
Gauge | kind (catalog|renders|og|imports) |
Object storage usage, sampled hourly |
skinforge_db_pool_in_use |
Gauge | — | Active Postgres connections held by the pool |
skinforge_rate_limit_rejections_total |
Counter | bucket |
Requests rejected by rate limiting |
skinforge_admin_login_failures_total |
Counter | reason |
Failed admin login attempts |
Exposition format: standard Prometheus text format v0.0.4, generated by an in-process registry
(core/metrics/registry.ts) with no third-party metrics dependency — counters/gauges/histograms are
maintained as plain in-memory structures and rendered to text on scrape, consistent with the
no-Redis/no-broker architecture decision (Section 4.9).
Scrape guidance: scrape interval 15 seconds, recommended via a self-hosted Prometheus container or the operator's existing monitoring stack (Section 21.10 does not mandate a specific product); histograms are cheap enough at this traffic volume (Section 19.10) that no down-sampling is needed.
21.4 Dashboards #
Four dashboards, each described panel by panel. Implementation is left to whatever the operator
points at the /metrics endpoint (Grafana is the reference assumption for panel layout, but the
metric names in 21.3 are the actual contract).
Traffic dashboard:
- Requests per second, stacked by route class (line, 5-minute rate of
skinforge_http_requests_total). - Status code breakdown (2xx/3xx/4xx/5xx stacked area, from the same counter's
statuslabel). - p50/p95/p99 request duration by route class (from
skinforge_http_request_duration_ms). - Top 10 permalinks by view rate over the last 7 days (derived from
design_view_daily, Section 6, Section 21.6 — synchronously written at request time, not log-derived; annotated on the panel as aggregate-table-derived).
Rendering dashboard:
- Render queue depth over time (
skinforge_render_queue_depth). - Render duration p50/p95 split by cache-hit vs cold (
skinforge_render_duration_ms). - Cache hit ratio by layer (
skinforge_cache_hit_ratio). - Render failures per minute (
render.failedevent rate). - Storage bytes by kind, trended over 30 days (
skinforge_storage_bytes).
Ingestion dashboard:
- Import stage duration, latest run, one bar per stage — eight bars, in
import_runs.stageorder (Section 6.6) — fromskinforge_import_stage_duration_ms. - Candidates flagged over time, by reason (
import.candidate_flaggedevent rate, stacked byreason). - Job queue depth by type (
skinforge_job_queue_depth). - Job failure rate by type (
skinforge_job_failures_total). - Time since last successful build publish (single-stat panel, derived from
build.publishedevents).
Health dashboard:
- Uptime / restart count (from process start events,
app.started). - Database connection pool utilization (
skinforge_db_pool_in_usevs configuredSKINFORGE_DB_POOL_SIZE). - Admin login failure rate (
skinforge_admin_login_failures_total). - Rate-limit rejection rate by bucket (
skinforge_rate_limit_rejections_total). - Current readiness/liveness status (from the
/health//health/readychecks in 21.7, polled externally).
21.5 Alerts #
| Alert | Condition | Threshold | Severity | First response |
|---|---|---|---|---|
| Site down | /health fails |
3 consecutive failed external checks (1-minute interval) | SEV1 | Follow Section 20.11 incident response |
| High 5xx rate | 5xx / total requests | > 5% over 5 minutes | SEV1 | Check the traffic and health dashboards; likely a bad deploy — consider rollback (Section 23.5) |
| Render queue backed up | skinforge_render_queue_depth |
> 150 for 5 minutes (soft limit is 200, Section 19.8) | SEV2 | Check for an abuse pattern (Section 20.1 threat #2) vs a legitimate traffic spike (Section 19.10); tighten render rate limit if abuse |
| Render failure spike | render.failed rate |
> 10/minute for 5 minutes | SEV2 | Check errorCode distribution; likely a missing sprite from a bad publish or an encoder fault |
| DB pool saturation | skinforge_db_pool_in_use |
≥ SKINFORGE_DB_POOL_SIZE for 2 minutes |
SEV2 | Check for a slow-query regression (Section 19.7) or a connection leak |
| Job failures | skinforge_job_failures_total rate |
> 5 in 10 minutes for one jobType |
SEV2 | Inspect job.exhausted logs for the failing type; check downstream dependency (storage, DB) |
| Admin login abuse | skinforge_admin_login_failures_total rate |
> 20/hour | SEV2 | Confirm brute-force protection engaged (Section 20.5); consider IP block at the CDN/firewall layer |
| CDN purge failures | admin.publish.purge_failed |
any occurrence after all retries exhausted | SEV3 | Manually purge per Section 19.5's fallback; investigate purge endpoint health |
| Storage growth anomaly | skinforge_storage_bytes week-over-week delta |
> 3x the trailing 4-week average growth rate | SEV3 | Check for a design-spam pattern (Section 20.1 threat #3) or a runaway render cache |
| Certificate expiry | Caddy-managed TLS cert | < 14 days to expiry | SEV3 | Should auto-renew (Section 23.2); investigate Caddy ACME logs if not |
21.6 Product analytics without tracking #
Counted server-side, with no client-side tracking script and no per-visitor identifier (Section
20.7): design creations, permalink views (design.resolved events), catalog page views
(catalog.viewed events), and top assets/hues by appearance count in created designs.
Bot exclusion: the bot filter used for every view/page-count purpose is defined exactly once, in Section 13.10.2, and applied identically here — no second definition exists. Excluded requests are still served, still logged (21.1), and still count toward rate limits (Section 20.1); the exclusion applies only to the analytics rollup, not to serving or protection.
Counting is synchronous, not log-derived. Page views are written at request time into
page_view_daily and per-design views into design_view_daily (Section 6), using the bot filter
defined once in Section 13.10.2. A nightly job (Section 6 jobs table, run once daily at 00:10 UTC)
only computes the derived top-N asset/hue counters in stat_counters from designs.canonical_json;
it reads no logs and no raw request data. The job is idempotent — re-running it for a day it has
already rolled up recomputes and overwrites that day's counters rather than double-counting.
Retention of raw versus rolled-up data: structured request logs live only in container stdout,
rotated by Docker (Section 21.1, Section 20.7) — there is no queryable raw-log retention job. Rolled-up
daily aggregate rows in page_view_daily, design_view_daily and stat_counters (Section 6) are
retained indefinitely since they contain no per-visitor data, only counts, and are cheap to store at
one row per route-class/design per day.
21.7 Health checks #
Two endpoints, both no-store (Section 19.3), both excluded from the public route list in Section 10
(operational endpoints, not product surface) but reachable without authentication since external
uptime monitors need them. This section is the sole owner of both; Section 16.8 references them
without redefining their shape, and GET /api/v1/version remains a separate, versioned endpoint.
GET /health — liveness. Returns 200 if the process is running and able to handle requests at
all, independent of dependency health. Used by Docker Compose's healthcheck (Section 23.2) and
container orchestration to decide whether to restart the container.
{ "status": "ok", "version": "2026.09.1" }GET /health/ready — readiness. Returns 200 only if every hard dependency is reachable; 503 if
any is not, with the specific failing dependency named so the health dashboard (21.4) can pinpoint the
cause immediately. database and storage are the two checks mandated as readiness signals;
jobRunner is included as an additional, non-critical signal.
{
"status": "ok",
"checks": {
"database": { "status": "ok", "latencyMs": 3 },
"storage": { "status": "ok", "latencyMs": 8 },
"jobRunner": { "status": "ok", "queueDepth": 4 }
}
}A failing check replaces its "status": "ok" with "status": "error" and adds an "error" string
field; the top-level status becomes "degraded" (some non-critical check failing, e.g. job runner
slow but DB/storage fine — still returns 200 since the site can serve reads) or "error" (a
critical check failing, e.g. database unreachable — returns 503). database and storage are
critical; jobRunner being behind is degraded, not critical, since cached/already-rendered content
still serves.
21.8 Tracing decision #
Decision: no distributed tracing system (no OpenTelemetry collector, no Jaeger/Tempo/Zipkin) in v1. Justification: the deployment topology is a single Deno process talking to Postgres and object storage on the same or an adjacent host (Section 23.1) — there is no multi-service call graph to trace. A trace would add operational complexity (a collector to run, another dependency to secure per Section 20.10) for a topology where the correlation problem it solves does not yet exist.
Correlation-id approach that replaces it: every inbound HTTP request is assigned a requestId
(ULID, generated at the top of the middleware chain if the client did not supply one) which appears
in: the structured log line for that request (21.1), every log line emitted by code called during
that request's handling (passed through as request-scoped context, not a global), the API error
envelope (Section 16), and any job enqueued as a direct result of that request (the job's jobId is
distinct, but its first log line records the originating requestId). This gives full request-to-
completion traceability across the synchronous HTTP path and the asynchronous job path within the
single process, which is the entire call graph that exists — sufficient without a tracing backend.
If a future version introduces additional services (Section 23.12's scaling path), this correlation
ID is the field a tracing system would key on, so the decision does not foreclose adding one later.
21.9 Operational reporting #
A weekly summary is rendered as a panel on /admin/dashboard (Section 17.4), computed on demand from
the aggregate tables each time it is viewed, for the week ending on the requested Sunday (defaulting
to the most recent one). It is deliberately not stored: every input is already a retained
aggregate row, so a stored copy would be a second, drifting source of the same numbers, and no report
retention rule or storage location is needed. In particular it is not written to app_settings, whose
contents are the operator-editable settings enumerated in Section 17.14 and Section 24.1 and nothing
else. A scheduled job (Sunday 00:20 UTC, immediately after the daily rollup in Section 21.6) warms the
panel's query so the first view of the week is fast, and emits nothing else. SkinForge sends no
outbound email of any kind; this in-app dashboard panel is the entire delivery surface for the report
(Section 17.12). Contents, all sourced from
page_view_daily and stat_counters (21.6) plus the metrics registry (21.3) sampled at report time:
- Total page views and unique route-class breakdown for the week, and week-over-week delta.
- Designs created this week, cumulative total, and the top 10 assets and top 10 hues by appearance count across designs created this week.
- Render cache hit ratio for the week (from
skinforge_cache_hit_ratiohistory, averaged). - Any SEV1/SEV2 incidents in the week (pulled from the post-incident review log, Section 20.11).
- Storage bytes used and week-over-week growth.
- Pending admin queue sizes at report time: unreviewed extraction candidates, open takedown requests.
The report is generated server-side from the same data the dashboards read — it introduces no new data source and no new table, only a summary view over existing aggregates, keeping the one-person-operator model (Section 23.10) informed through a single dashboard panel rather than requiring the operator to piece the same picture together from raw metrics.
22. Testing Strategy & Quality Gates #
22.1 Test pyramid #
| Layer | Target count | Runtime budget | Runs on |
|---|---|---|---|
| Unit | ~600+ tests | ≤ 60s total | Every push, every PR |
| Integration (HTTP + real Postgres) | ~150 tests | ≤ 4 minutes total | Every push, every PR |
| Golden-image (render/compositor) | ~40 fixtures × 3 scales × 2 formats = ~240 assertions | ≤ 90s total | Every push, every PR |
| End-to-end (Playwright) | ~25 scenarios × 3 viewports where noted | ≤ 8 minutes total | Every PR, and on merge to main |
| Accessibility (axe + manual) | Automated: every route in Section 10.1's site map (13 routes) plus the four error pages in Section 10.9 (17 total); manual: quarterly and at every launch gate | Automated ≤ 2 minutes | Automated: every PR; manual: quarterly, pre-major-release |
| Performance (k6 + Lighthouse CI) | 2 load scenarios + budget checks on 6 routes | ≤ 10 minutes total | Pre-release gate only (Section 22.11), not every PR |
| Security | Dependency audit, header assertions, authz matrix (~40 cases), fuzzing (4 format readers, 10k iterations each) | ≤ 5 minutes for audit/headers/authz; fuzzing runs nightly, not per-PR | Audit/headers/authz: every PR; fuzzing: nightly schedule |
| Pipeline (extraction/import) | ~30 tests against the synthetic fixture client | ≤ 3 minutes | Every PR |
Total CI wall-clock for the PR-blocking layers (unit, integration, golden-image, E2E, a11y automated, security minus fuzzing, pipeline): under 20 minutes, parallelized across CI jobs per Section 22.11.
22.2 Unit tests #
Run with Deno's built-in test runner (deno test), no external test framework dependency, consistent
with the minimal-dependency architecture decision (Section 4.2). Every module path named in this
section is a path Section 5.1's repository tree defines; that tree is the authority and this section
never introduces a path of its own.
What must be unit tested: every pure function in core/ — hue math, canonical JSON
serialization, short-code encoding/decoding, cursor pagination encode/decode, search grammar parsing,
Zod schema modules (both accept and reject cases), format-reader byte-level decoding functions, and
every repository module's SQL-building logic (using a query-capturing stub, not a live database —
live-database behavior is integration-tested per 22.4).
Naming and placement: unit tests are co-located beside the module they test and named
<module>_test.ts — the Deno runtime's own convention, so deno test core/ discovers them with no
configuration (e.g. core/hue/apply.ts → core/hue/apply_test.ts). They never live in a parallel
__tests__ tree and never in a mirrored tests/unit/ tree, so a reader browsing core/hue/ sees the
test immediately next to the code. Integration and end-to-end suites are the opposite: they are not
tied to one module, so they live under tests/ (tests/integration/, tests/e2e/, tests/golden/,
tests/load/), and every committed fixture lives under tests/fixtures/. Section 5.1 defines these
directories; this section states the testing-specific application of them.
Structure: Deno.test() with a descriptive name string in the form "<unit> — <behavior under test>", e.g. Deno.test("applyHue — partial hue only remaps grey pixels"). Related cases group under
Deno.test.step inside one top-level Deno.test when they share expensive setup (e.g. a decoded
fixture sprite); independent cases are independent top-level tests so failures report individually.
Fixtures: small, hand-constructed, committed binary/JSON fixtures under tests/fixtures/units/
(e.g. a 4×4 known-pixel gump, a 3-entry hue table) — never derived from real game files (Section
22.10's "no real game files in tests" rule) and never randomly generated without a fixed seed
(determinism requirement, 22.2 applies to every layer that touches fixtures).
High-risk modules and their specific required tests:
| Module | Required test cases |
|---|---|
Hue math (core/hue/apply.ts) |
Partial hue remaps a pixel only when r8 === g8 === b8 (exact equality, no tolerance window); a partial hue on a non-grey pixel leaves it unchanged; full hue remaps every pixel using table index r8 >> 3 (the pixel's 5-bit red channel, range 0-31), per Section 8.3; hue index 0 passes every pixel through untouched; a hue table with fewer than 32 entries is rejected at load time, not at apply time |
ARGB1555 unpacking (core/hue/unpack.ts) |
unpackArgb1555 round-trips a known 16-bit value to {r,g,b,a} using the 5-to-8-bit channel expansion in Section 8.3.1; color === 0x0000 decodes to a = 0; every other value, including 0x8000, decodes to a = 255 since bit 15 is never consulted (Section 8.3.1); boundary values 0x0001 and 0xFFFF round-trip correctly |
MUL/IDX reader (core/formats/mul.ts) |
A well-formed 12-byte index entry resolves to the correct offset/length; a length of -1 (removed entry) is skipped, not read; an index entry pointing past end-of-file raises a decode error rather than an out-of-bounds read |
UOP reader (core/formats/uop.ts) |
Magic bytes MYP\0 validated before any further parsing; block-chain traversal terminates on a self-referential or cyclic next-block pointer (fuzz-adjacent unit case) rather than looping forever; 64-bit path hash lookup matches a known fixture hash |
Canonical JSON (core/design/canonicalize.ts) |
Two structurally-equal design objects with keys in different insertion order produce byte-identical canonical output; an empty slots array serializes as [] not omitted; unknown top-level keys are rejected before canonicalization is attempted |
Short-code encoding (core/design/short-code.ts) |
A known SHA-256 input produces the documented 10-character Crockford-Base32-minus-ambiguous output; decoding rejects any character outside the restricted alphabet; the salt-increment collision path produces a different code on the second attempt for the same input |
Cursor pagination (core/api/cursor.ts) |
Encode-then-decode round-trips to the identical position; a tampered (bit-flipped) cursor fails HMAC verification and returns the malformed-cursor error rather than an incorrect page; the final page returns nextCursor: null |
Search grammar parsing (core/search/tsvector.ts) |
Each supported token type (bare term, slot:, hue:, quoted phrase) parses to the correct AST node; an unterminated quote is a graceful parse error, not a thrown exception that reaches the HTTP layer unhandled; a 200-character input at the cap parses without truncation mid-token |
Boolean config parser (core/config.ts) |
The exact string "false" parses to boolean false, never true; the exact string "true" parses to true; any value other than the exact strings true/false fails validation at boot rather than silently coercing (Section 24.3) |
22.3 Golden-image tests #
Fixture set: a synthetic mini client committed to the repository under
tests/fixtures/synthetic-client/, containing hand-authored MUL/IDX pairs and a small hues.mul-format file
covering: 6 synthetic sprites (one per major slot family: body, hair, torso_inner, footwear,
torso_outer, head) at known, deliberately non-photographic pixel patterns (solid colors, a checkerboard,
a single-pixel-wide diagonal line) chosen so any compositing or hue-math regression produces an
immediately visible, easily diffed pixel change; 8 synthetic hues covering unhued (0), a partial-hue
grey-ramp hue, a full-hue saturated hue, and boundary hue indexes. This fixture set is also the input
to the pipeline tests in 22.9 and the E2E synthetic-import scenario in 22.5.
Expected artifacts: for each of the 40+ meaningful combinations (a subset of body × slot-asset ×
hue × scale × format sufficient to exercise every composite z-order boundary in Section 3's slot
table and every hue-math branch), a pre-rendered PNG is committed under
tests/golden/fixtures/<combination-name>.png (the directory Section 5.1's deno.json excludes from
deno fmt/deno lint, since it holds binary art rather than source). The comparison test itself is
tests/golden/composite_test.ts. Goldens are regenerated deliberately (never automatically) via
deno task test:golden:update, which requires an explicit flag (--confirm-visual-review) and is a
manual step a developer runs only after visually confirming the new output is correct — CI never
regenerates goldens itself.
Comparison method: exact byte equality after decode, zero tolerance. The test decodes both the freshly rendered PNG and the committed golden PNG to raw RGBA buffers (never comparing compressed bytes directly, since two correct encodes of identical pixels are not guaranteed to be byte-identical at the container level) and asserts every pixel's RGBA channel values match exactly. Zero tolerance is deliberate: this is pixel art with hard edges and a hue table that is a discrete lookup, so there is no legitimate source of small numerical drift — any difference at all indicates a real regression in compositing, hue math, or encoding.
Failure artifact: on mismatch, the test writes three files to a CI artifact directory:
<name>.actual.png (what was rendered), <name>.expected.png (the committed golden), and
<name>.diff.png (a per-pixel visual diff — mismatched pixels rendered in solid magenta against a
50%-dimmed copy of the expected image, generated by a small diff utility at
tests/golden/pixel-diff.ts), written to /tmp/skinforge-golden-diffs/. CI uploads this
artifact directory (Section 22.11) so a developer can inspect the failure without reproducing it
locally first.
Parity test (browser compositor vs. server compositor): required by the render contract in
Section 8.12. The test runs the identical composite algorithm twice against the identical fixture
inputs — once through the server-side TypeScript compositor (core/composite/render-design.ts, run directly
under Deno) and once through the client-side compositor bundle (islands/preview-canvas.ts's
compositing logic, run headlessly under Playwright against a blank test page that loads the bundle and
exposes a test hook to call it directly with fixture inputs and read back the canvas pixel buffer via
getImageData). Both raw RGBA outputs are compared with the same zero-tolerance byte-equality rule
above. This test is the single most important regression guard in the suite: it is the only test that
would catch the two compositors silently drifting apart, which per Section 8.12 must never happen
since permalinks, downloads, and OG images all depend on the server compositor being the authoritative
match for what the visitor saw live in their browser.
22.4 Integration tests #
HTTP-level tests that start the actual Fresh application (in-process, via Deno's fetch-compatible
test server helper) against a real PostgreSQL instance — never a mock database and never SQLite —
running in a Docker container spun up by the test harness (docker compose -f docker/docker-compose.test.yml up -d postgres invoked by the CI job, Section 22.11) or, for local
development, a container the developer starts once and reuses. Integration suites live under
tests/integration/ and are named <area>_test.ts; the two subtrees that get their own CI job are
tests/integration/pipeline/ (22.9) and tests/integration/security/ (22.8).
Fixture build: before the integration suite runs, a seed script (tests/fixtures/seed-test.ts,
shared with the E2E setup in 22.5) applies every migration (Section 6) then inserts a minimal but complete
fixture data set: one published game_builds row, the 19-row slots table exactly as specified in
Section 3, ~20 assets rows covering every slot family with at least one variant, ~15 hues rows
covering the unhued/partial/full/skin-group cases, and 5 pre-created designs rows (including one
referencing a retired asset, to exercise the immutable-render-from-archived-build path).
Transaction rollback per test: each test runs inside a Postgres transaction opened in a
beforeEach-equivalent (Deno's Deno.test with a t.step-per-case pattern wrapping a helper
withTestTransaction(fn)) and rolled back after the test completes, regardless of pass/fail. This
gives every test a clean, fixture-only database state without re-running the full seed script per
test, keeping the integration suite inside its 4-minute budget (22.1). Tests that specifically need to
verify cross-transaction behavior (e.g. the FOR UPDATE SKIP LOCKED job-claiming race in Section
19.7) are the documented exception and use two real, separately committed connections instead of the
rollback wrapper — flagged with a comment explaining why.
Build-scoped resolution test (required): with two published builds present in the fixture data, a
design pinned to the older build's build_id is rendered and asserted to resolve its assets and hue
colours from that older build only, never the newer build's — directly exercising the build_id
filter that every asset and hue lookup carries (Section 8.9, Section 13.7, Section 19.7).
Endpoint coverage requirement: every route listed in Section 10's page list and every endpoint in
Section 16's API list has at least one integration test exercising its success path and at least one
exercising its primary failure path (validation error, not-found, or auth failure as applicable). This
is enforced by a CI check (deno task test:coverage:routes) that diffs the route list against a
generated manifest of which routes have at least one integration test referencing them by path
constant, failing the build if any route is untested — this prevents route-list drift going stale
against the actual test suite.
22.5 End-to-end tests with Playwright #
Run against a fully assembled application (real Fresh server, real Postgres, real object storage using
the fs driver pointed at a disposable temp directory) started fresh for the E2E job in CI.
Scenarios:
- Design a skin and share it. Land on
/, change body to female, pick a hair asset and hue, pick a torso_outer asset, click Share, land on/d/:code, assert the rendered composite is visually present (via the<picture>markup, Section 19.6) and the URL is the expected short-code shape. - Open a permalink cold. Navigate directly to a pre-seeded
/d/:codewith no prior visit, network throttled to Slow 4G. Assert the page renders correctly and completely with zero console errors. The numeric time-to-first-preview budget (Section 19.1) is not asserted here — that is 22.7's job. - Browse the catalog and hand off to the designer. Land on
/catalog, filter by slot, open an asset detail page, click "Try this in the designer," assert/loads with that asset pre-selected in the correct slot via the query-string transient state (Section 13). A companion assertion follows the legacy path: requesting/designreturns a permanent redirect to/(Section 10.5) and the transient state survives it. - No-JS permalink rendering. With JavaScript disabled at the browser-context level
(
context.routeblocking script requests, or Playwright'sjavaScriptEnabled: falsecontext option), navigate to/d/:code, assert the composite image is present and correctly sourced — proves the no-blocking-JS rule (Section 19.9) holds in practice, not just in markup review. - Mobile bottom sheet. At a mobile viewport (390×844), open
/, assert the slot picker presents as a bottom sheet (Section 11's responsive behavior, referenced not restated), interact with it via touch-equivalent taps, assert selection updates the live preview. - Admin login with TOTP. Navigate to
/admin, submit valid credentials, submit a TOTP code computed in-test from a known seeded TOTP secret (using a TOTP-generation helper, never a real authenticator app), assert redirect to/admin/dashboardand a validsf_adminsession cookie is set with the correct flags (Section 20.5). - Candidate review. As an authenticated admin, navigate to
/admin/candidates, open a pre-seededneeds_operator_inputcandidate, assign it to a slot/asset, approve it, assert it moves out of the pending queue and anaudit_logrow is created. - Publish a build. As an authenticated admin, navigate to
/admin/builds, publish a pre-seeded draft build, assert the catalog reflects the new build's assets after publish and the CDN-purge call is invoked (mocked in the E2E environment, asserted via a call-capture, since no real CDN exists in CI). - CSP against the real client workload. Load
/in a real browser context with the production security headers applied (Section 20.4), and assert the client-side WASM compositor (Section 8.12) actually initializes and produces a composite, and that the inline theme script (Section 18.10) actually executes with no CSP violation report — a string-level header assertion (Section 22.8) cannot prove'wasm-unsafe-eval'and the per-response nonce work in a real browser, only that the header text is well-formed.
Visual regression on three viewports: scenarios 1 and 3 additionally run a full-page screenshot
comparison (Playwright's built-in toHaveScreenshot) at three viewports — 390×844 (mobile), 768×1024
(tablet), 1440×900 (desktop) — with a near-zero pixel-diff threshold, committed baseline screenshots
under tests/e2e/__screenshots__/ (Playwright specs live at tests/e2e/), and the same
manual-regeneration discipline as the golden-image tests in 22.3 (never auto-updated by CI).
22.6 Accessibility testing #
Automated axe checks on every route: every route in Section 10.1's site map (13 routes) plus the
four error pages in Section 10.9 (17 total) has an automated axe-core scan run via Playwright's axe
integration, asserting zero violations at the WCAG 2.2 AA level (Section 18 owns the accessibility
standard; this section owns the testing of conformance to it). Admin routes are included in a
separate, equally-required scan set. A violation of any severity fails the check — there is no
"moderate is acceptable" carve-out. Automation alone is not a sufficient accessibility gate: axe
detects only a subset of WCAG 2.2 AA success criteria and, in particular, cannot evaluate any of
steps 6-8 of the manual script below — the launch gate (Sections 26, 27) requires both the automated
scan and a passing manual script, never the automated scan alone.
Manual keyboard-only script, run quarterly and before any major release, and required at every launch gate (tied to the definition of done in 22.12):
- From a fresh tab focus, Tab through
/and confirm every interactive element (body toggle, every slot swatch, every hue swatch, Share button) is reachable in a logical order and has a visible focus ring. - Operate the entire design flow — select a body, select an asset for at least 3 slots, change hues, trigger Share — using only Tab, Shift+Tab, Enter, Space, and arrow keys (for swatch grids, arrow keys move selection per Section 11's keyboard behavior spec).
- Confirm Escape closes any open picker/bottom-sheet without losing prior selections.
- Repeat steps 1-3 on
/catalogand/admin(post-login). - 2.1.4 Character Key Shortcuts: confirm every single-key keyboard shortcut defined in Section 11.11 is either remappable from a settings control, requires a modifier key (Ctrl/Cmd/Alt), or is active only while a specific component has focus (2.1.4's activation-on-focus exception — this is the answer that covers the admin candidate-queue shortcuts in Section 17.6.6, which fire only once a candidate tile is focused). A bare, non-remappable, globally-active single-character shortcut fails this check.
- 2.5.7 Dragging Movements: on the mobile bottom sheet (Section 11's responsive slot picker), confirm every action reachable by a drag/swipe gesture has a non-drag alternative — a tap target, an explicit button, or a keyboard equivalent.
- 2.4.11 Focus Not Obscured: tab through every route and confirm that whenever an element receives keyboard focus, at least part of it remains visible and is not fully hidden behind a sticky header, the bottom sheet, or any open modal/overlay.
- Record pass/fail per step in the release checklist (22.12).
Screen-reader spot checks, quarterly, using VoiceOver (macOS/Safari) and NVDA (Windows/Firefox) as the two reference combinations:
- Confirm the body toggle, every slot control, and every hue control announce a clear name and current state (e.g. "Hair, Long Wavy, hue 1102, button").
- Confirm the live preview image has appropriate alt text that updates as the design changes (or is correctly marked decorative if a redundant text description is provided elsewhere per Section 18).
- Confirm form validation errors on any admin form are announced (via
aria-liveor equivalent) when they appear. - Confirm the permalink page's content is fully readable start to finish without encountering an unlabeled control.
Contrast verification procedure: every color pair defined in the design system (Section 18 owns
the token values) is checked programmatically in a unit test co-located beside the token file
(web/styles/theme_test.ts, reading the committed custom properties in web/styles/theme.css)
using the WCAG contrast ratio formula against the AA thresholds (4.5:1 normal text, 3:1 large text and
UI components), run as part of the standard unit suite (22.2) rather than as a separate manual step,
since it is a pure computation over committed token values.
22.7 Performance testing #
Load scenarios: the two scenarios defined in Section 19.10 — normal traffic (sustained ~0.5 req/s
with realistic route-mix weighting: 40% permalink views, 25% designer (/), 20% render requests,
10% catalog, 5% API) and the permalink spike (ramp to ~7 req/s concentrated on one permalink and its
render URLs over 5 minutes) — codified as k6 scenarios under tests/load/ (Section 5.1's location
for load and performance scripts).
Tool: k6 for load/latency scenarios (chosen for scriptability in JS/TS, consistent with the project's language, and because it requires no JVM or additional runtime beyond what CI already provisions); Lighthouse CI for the page-level budgets in Section 19.1 (LCP/INP/CLS/page-weight), run against a production build served locally in the CI job.
Thresholds that fail the build: k6 scenario thresholds are set directly from Section 19.1's
budget table (e.g. http_req_duration{endpoint:design_resolve}: p(95)<60) and Section 19.8's render
back-pressure numbers; a threshold breach makes the k6 run itself exit non-zero, which fails the CI
job. Lighthouse CI thresholds are set from the same LCP/INP/CLS/page-weight numbers in Section 19.1
using Lighthouse CI's assert configuration with error severity (not warn) for every budget in
that table, so a regression fails the gate rather than merely appearing in a report.
22.8 Security testing #
Dependency audit: deno outdated plus an advisory check against resolved npm: specifiers
(Section 20.10), run in CI on every PR against the main branch and on a weekly cron independent of
code changes; a high/critical finding fails the PR-blocking job unless explicitly waived (Section
20.10's waiver process).
Header assertions: an integration test suite (tests/integration/security/headers_test.ts,
covering core/http/security-headers.ts) makes a real request to each of the two header profiles
(Section 20.4) and asserts every header listed there is
present with the exact specified value — not a substring match, an exact match, so a weakened CSP
directive is caught immediately.
CSP validation: in addition to the exact-match header assertion, a dedicated test splits each
directive into tokens and asserts that no bare 'unsafe-eval' token appears in any directive —
matched as a whole token, never by substring, because the required 'wasm-unsafe-eval' contains
unsafe-eval as a substring and a substring check would fail on a correct policy — that
unsafe-inline appears only in the style-src directive, never in script-src, and that
script-src contains 'wasm-unsafe-eval' and a per-response 'nonce-...' token — this is the specific regression this test guards against even if
someone edits the header string correctly elsewhere but reintroduces a bare inline-script allowance.
Because a string-level assertion cannot prove the policy actually permits the client-side workload it
is meant to allow, the E2E suite (Section 22.5, scenario 9) additionally exercises the CSP against a
real browser loading the WASM compositor and the nonce-carrying theme script.
Authorization matrix tests: every admin route (Section 10's admin route list) is tested four ways
— (1) no session cookie present: expect 401/redirect to /admin login; (2) an expired/invalidated
session: expect the same; (3) a session cookie for a valid admin but with a tampered/invalid CSRF
token on a state-changing request: expect 403 (SF-4003, Section 20.5); (4) a valid session for
each role strictly below the route's minimum role in Section 17.3, on both the GET and the
state-changing method: expect 403 (SF-4005, Section 20.5). This produces roughly 4 cases × the
admin route count (~15 routes) × up to 2 lower roles ≈ 100 test cases; the matrix is generated from
Section 17.3.1's permission-matrix table as data, so a new action added to that table without a
corresponding server-side check fails CI. It lives at tests/integration/security/authz_test.ts and
runs in the security CI job, which for that reason provisions the same postgres service as the
integration job — the matrix exercises real sessions and real roles, not stubs.
Fuzzing of format readers: the MUL/IDX reader (core/formats/mul.ts), the UOP reader
(core/formats/uop.ts), the ARGB1555 unpacker (core/hue/unpack.ts) and the hues.mul-format reader
(core/formats/hues.ts) are each fuzzed nightly (not per-PR, to keep PR CI fast) by a co-located
<module>_fuzz_test.ts beside each one, excluded by name from the per-PR unit job
using Deno's fuzzing-friendly property-test approach — a seeded random byte-mutator that takes each
committed fixture file (22.3's synthetic client fixtures) and flips/truncates/extends random byte
ranges across 10,000 iterations per module, asserting the reader either succeeds or throws a typed
decode error, and never panics with an unhandled exception, an infinite loop (bounded by a hard
iteration/time cap inside each reader itself, not just the fuzz harness), or an out-of-bounds memory
read (Deno's Uint8Array bounds-checks this at the runtime level, but the assertion additionally
checks no RangeError escapes as an unhandled rejection). A fuzzing failure opens a tracked issue
automatically (via the CI job's failure output) rather than blocking every PR, since fuzzing failures
need triage, not a hard merge block, but are treated as SEV3-equivalent findings requiring a fix within
the next release.
22.9 Pipeline testing #
Extraction against the synthetic fixture client: skinforge-cli extract is run in CI against the
committed tests/fixtures/synthetic-client/ directory (22.3), asserting each of Section 7's stages D1-D6
produces the expected classification/extraction outputs — e.g. D2 correctly identifies the fixture's
MUL/IDX pairs and hues-format file, D4 decodes the known sprite pixel patterns byte-correctly (reusing
the golden-image comparison method from 22.3), D5 classifies each synthetic sprite into its intended
slot via the fixture's deliberately unambiguous gump-id ranges.
Diffing: a second synthetic-client fixture variant (tests/fixtures/synthetic-client-v2/) with one
sprite changed, one added, and one removed is extracted and diffed against the first import run's
results, asserting the diff correctly reports one modified, one added, one removed candidate and
leaves every unchanged asset alone (no spurious re-flagging).
Publish/rollback: a full cycle — import v1, publish it, import v2, publish it, then roll back to v1 — is run as one integration test, asserting after rollback that the active build is v1's again, that v2's assets are not deleted (soft-retire only, per the soft-delete rule in Section 9.7), and that any design created while v2 was active still renders correctly from v2's archived assets (immutability, Section 13) even though v1 is now the publicly active build.
Idempotency tests that re-run a stage twice (required): the identical synthetic-client fixture is
imported once, then every stage D1–D6 is re-run against the same build_id, asserting the second
execution of each stage produces zero net row changes (Section 9.9's ON CONFLICT guarantees) and
that asset_variants.sha256 values are unchanged. A separate test imports the identical fixture as a
second build and asserts the diff (Section 9.3) reports every asset unchanged and every hue
unchanged, with new hues rows scoped to the new build_id as Section 6.13 requires — directly
testing the re-sync-after-patch code path described in Section 9 for the common case where a patch
changes nothing about the assets SkinForge tracks.
22.10 Test data and factories #
Fixture creation: all test fixtures are hand-authored and committed to the repository — the
synthetic client (22.3), the integration seed data (22.4), and the E2E seed data (22.5, which reuses
the same seed-test.ts script as integration tests to avoid drift between what integration tests and
E2E tests assume about the database state). Factory functions at tests/fixtures/factories.ts
provide typed builders for each table (buildDesign(), buildAsset(), etc.) with sensible defaults
and override parameters, used by both unit tests (for in-memory objects) and integration tests (for
rows to insert), so a test author never hand-writes a full row literal.
Seed script: tests/fixtures/seed-test.ts is the single source of truth for baseline test data,
invoked by the integration suite setup, the E2E suite setup, and available as a deno task db:seed:test for local development — never three separate, drifting seed implementations.
No real game files in tests: it is a hard rule, enforced by a CI check
(deno task test:no-real-assets grepping tests/fixtures/ for disallowed binary
signatures/extensions and failing if anything outside tests/fixtures/synthetic-client*/'s
deliberately tiny, hand-authored files appears) that no test — unit, integration, golden-image, E2E, or pipeline —
ever depends on real UO Outlands client files. This keeps the repository legally clean (Section 20.8's
"no raw game files are ever distributed" extends to the test suite) and keeps tests fast and
deterministic.
22.11 CI #
GitHub Actions, workflow file .github/workflows/ci.yml. Every job that boots the application or the
CLI carries the five variables Section 24.2 marks Required: yes plus SKINFORGE_METRICS_ENABLED,
because Section 24.3's validator calls Deno.exit(1) on the first missing one and a half-configured
job would hang on wait-on rather than fail with a readable message. GitHub Actions does not support
YAML anchors, so the block is written out per job deliberately rather than shared:
name: CI
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 3 * * *" # nightly: fuzzing (Section 22.8)
- cron: "0 4 * * 1" # weekly: dependency advisory re-check with no new commits (Section 20.10)
jobs:
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- run: deno lint
- run: deno fmt --check
- run: deno check main.ts cli/main.ts
unit:
runs-on: ubuntu-latest
needs: lint-and-typecheck
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
# Co-located *_test.ts beside every core/, cli/ and web/ module (22.2). The nightly fuzz
# suites are excluded by name so a PR never pays for 10k-iteration fuzzing (22.8).
- run: deno test --allow-read --allow-env --ignore="core/**/*_fuzz_test.ts" core/ cli/ web/
golden-image:
runs-on: ubuntu-latest
needs: lint-and-typecheck
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
# tests/golden/composite_test.ts renders each combination and byte-compares it against the
# committed PNGs in tests/golden/fixtures/ (22.3). Pointing this at the image directory
# instead would find no test modules and pass vacuously.
- run: deno test --allow-read --allow-write=/tmp/skinforge-golden-diffs tests/golden/
- uses: actions/upload-artifact@v4
if: failure()
with:
name: golden-image-diffs
path: /tmp/skinforge-golden-diffs/
integration:
runs-on: ubuntu-latest
needs: lint-and-typecheck
services:
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: skinforge_test
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
env:
SKINFORGE_ENV: test
SKINFORGE_DATABASE_URL: postgres://postgres:test@localhost:5432/skinforge_test
SKINFORGE_PUBLIC_BASE_URL: http://localhost:8000
SKINFORGE_ADMIN_SESSION_SECRET: dGVzdC1vbmx5LWNpLXNlY3JldC1ub3QtZm9yLXByb2R1Y3Rpb24tdXNlLWV2ZXItcGxlYXNl
SKINFORGE_CONTACT_EMAIL: ci@example.com
SKINFORGE_METRICS_ENABLED: "false"
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- run: deno task db:migrate
- run: deno task db:seed:test
- run: deno test --allow-net --allow-env --allow-read --ignore="tests/integration/pipeline,tests/integration/security" tests/integration/
pipeline:
runs-on: ubuntu-latest
needs: lint-and-typecheck
services:
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: skinforge_test
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
env:
SKINFORGE_ENV: test
SKINFORGE_DATABASE_URL: postgres://postgres:test@localhost:5432/skinforge_test
SKINFORGE_PUBLIC_BASE_URL: http://localhost:8000
SKINFORGE_ADMIN_SESSION_SECRET: dGVzdC1vbmx5LWNpLXNlY3JldC1ub3QtZm9yLXByb2R1Y3Rpb24tdXNlLWV2ZXItcGxlYXNl
SKINFORGE_CONTACT_EMAIL: ci@example.com
SKINFORGE_METRICS_ENABLED: "false"
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- run: deno task db:migrate
- run: deno test --allow-net --allow-env --allow-read --allow-write tests/integration/pipeline/
e2e:
runs-on: ubuntu-latest
needs: [unit, integration]
services:
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: skinforge_test
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
env:
SKINFORGE_ENV: test
SKINFORGE_DATABASE_URL: postgres://postgres:test@localhost:5432/skinforge_test
SKINFORGE_PUBLIC_BASE_URL: http://localhost:8000
SKINFORGE_ADMIN_SESSION_SECRET: dGVzdC1vbmx5LWNpLXNlY3JldC1ub3QtZm9yLXByb2R1Y3Rpb24tdXNlLWV2ZXItcGxlYXNl
SKINFORGE_CONTACT_EMAIL: ci@example.com
SKINFORGE_METRICS_ENABLED: "false"
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- run: deno task db:migrate && deno task db:seed:test
- run: npx playwright install --with-deps chromium
- run: deno task build
- run: deno task start:test & npx wait-on http://localhost:8000/health && npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
security:
runs-on: ubuntu-latest
needs: lint-and-typecheck
services:
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: skinforge_test
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
env:
SKINFORGE_ENV: test
SKINFORGE_DATABASE_URL: postgres://postgres:test@localhost:5432/skinforge_test
SKINFORGE_PUBLIC_BASE_URL: http://localhost:8000
SKINFORGE_ADMIN_SESSION_SECRET: dGVzdC1vbmx5LWNpLXNlY3JldC1ub3QtZm9yLXByb2R1Y3Rpb24tdXNlLWV2ZXItcGxlYXNl
SKINFORGE_CONTACT_EMAIL: ci@example.com
SKINFORGE_METRICS_ENABLED: "false"
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- run: deno install --frozen
- run: deno outdated --lock-only
# No event filter: 20.10 and 22.8 both require the advisory check on every PR and push, and
# the weekly cron above re-runs it when no commit has landed.
- run: deno task audit:npm # fails non-zero on any high/critical advisory (Section 20.10)
- run: deno task db:migrate && deno task db:seed:test
- run: deno test --allow-net --allow-env --allow-read tests/integration/security/
fuzz:
runs-on: ubuntu-latest
needs: lint-and-typecheck
if: github.event.schedule == '0 3 * * *'
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- run: deno test --allow-read core/formats/*_fuzz_test.ts core/hue/unpack_fuzz_test.ts
accessibility:
runs-on: ubuntu-latest
needs: [unit, integration]
services:
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: skinforge_test
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
env:
SKINFORGE_ENV: test
SKINFORGE_DATABASE_URL: postgres://postgres:test@localhost:5432/skinforge_test
SKINFORGE_PUBLIC_BASE_URL: http://localhost:8000
SKINFORGE_ADMIN_SESSION_SECRET: dGVzdC1vbmx5LWNpLXNlY3JldC1ub3QtZm9yLXByb2R1Y3Rpb24tdXNlLWV2ZXItcGxlYXNl
SKINFORGE_CONTACT_EMAIL: ci@example.com
SKINFORGE_METRICS_ENABLED: "false"
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- run: deno task db:migrate && deno task db:seed:test
- run: deno task build
- run: deno task start:test & npx wait-on http://localhost:8000/health && npx playwright test tests/e2e/a11y.spec.tsWhy those environment values specifically. SKINFORGE_ADMIN_SESSION_SECRET must satisfy Section
24.3's /^[A-Za-z0-9+/]{43,}={0,2}$/ regex: the literal above is 72 base64 characters decoding to 54
bytes, comfortably over the 32-byte floor, and contains no hyphen (a hyphen is not in the base64
alphabet, so a readable "test-secret-…" string fails validation and the app never starts).
SKINFORGE_METRICS_ENABLED: "false" is set explicitly because the variable defaults to true and
24.3's second superRefine then makes SKINFORGE_METRICS_TOKEN required — no CI job scrapes
/metrics, so disabling collection is the honest way to satisfy the validator rather than inventing a
token. SKINFORGE_ENV: test is what exempts SKINFORGE_PUBLIC_BASE_URL from the https refine, which
is why a http://localhost:8000 base URL is legal here and nowhere else. The pipeline job needs the
same set because cli/main.ts runs the identical loadCliConfig validator (24.3).
Required checks to merge: lint-and-typecheck, unit, golden-image, integration, pipeline,
security, accessibility are all required status checks on the main branch protection rule. e2e
is required on PRs targeting main but permitted to be skipped (with an explicit maintainer override)
on documentation-only changes, detected by a path filter.
Release gate: a separate release.yml workflow, triggered manually or on a version tag push, runs
the full suite above plus the performance job (k6 + Lighthouse CI, Section 22.7) and the nightly
fuzzing job (Section 22.8) on demand rather than waiting for the nightly schedule, and requires all of
them green plus the manual pre-launch security checklist (Section 20.12) and the manual QA checklist
(22.12) signed off before a release is tagged as deployable.
22.12 Definition of done and manual QA checklist #
Definition of done per milestone (milestones defined in Section 26): a milestone is done when
every acceptance criterion listed for it in Section 26 is met, every new code path has unit and/or
integration coverage per 22.1's pyramid expectations, no required CI check is failing, no new
deno lint or deno fmt exceptions were introduced, and any new route is added to the endpoint
coverage manifest (22.4).
Manual QA checklist before each release, performed by the operator against a staging deployment (Section 23.3) before promoting to production:
- Full design-and-share flow completed manually on a real mobile device (not just an emulated viewport) and a real desktop browser.
- At least one full admin workflow (candidate review through publish) completed manually.
- Manual keyboard-only script (22.6) passed, pass/fail recorded per step.
- Screen-reader spot checks (22.6) passed on at least one of the two reference combinations (VoiceOver/Safari or NVDA/Firefox), and both before a major release.
- Contrast verification (22.6) green in the unit suite — the automated axe scan does not evaluate it, and neither the axe scan nor this test can substitute for the keyboard and screen-reader scripts above.
- Visual spot-check of the OG image for a freshly created design, viewed via an actual social platform unfurl preview or an unfurl-preview debugging tool.
-
/legal,/about,/faq,/supportreviewed for accurate, current content including the disclaimer text (Section 10.4) and privacy statement (Section 20.7). - Release notes drafted for
/changelog. - Pre-launch security checklist (Section 20.12) re-confirmed if this release touches auth, headers, or the extraction sandbox.
- Backup restore drill freshness confirmed within 90 days (Section 23.6).
23. Deployment, Infrastructure & Operations #
23.1 Target environment #
A single Linux VPS running Docker Compose. Recommended concrete sizing: 4 vCPU, 8 GB RAM, 100 GB NVMe SSD, Ubuntu 24.04 LTS or later. This is sufficient for the capacity model in Section 19.10 with headroom for the render concurrency setting in Section 19.8 and Postgres running co-located.
Topology:
┌─────────────────────────┐
Internet ──HTTPS──▶ CDN ────▶ Caddy (TLS termination) │
│ :443, :80 │
└────────────┬─────────────┘
│ HTTP, localhost
┌────────────▼─────────────┐
│ app container (Deno) │
│ Fresh web + API + render │
│ :8000 │
└───┬──────────────────┬────┘
│ │
┌──────────▼───────┐ ┌────────▼────────┐
│ postgres:18 │ │ minio (optional) │
│ container │ │ S3-compatible │
└─────────────────────┘ └────────────────────┘The CDN (Cloudflare or any CDN meeting the caching contract in Section 19.2) sits in front of Caddy,
caching per the matrix in Section 19.3. Caddy terminates TLS and reverse-proxies to the app
container's internal HTTP port. Postgres and MinIO are separate containers on the same Docker network,
not exposed to the public internet — only Caddy's :443/:80 are published on the host. The
skinforge-cli process (Section 4) runs as a one-off container invocation or a host-installed Deno
binary against the same Postgres and storage, used interactively by staff — it is not a long-running
service.
23.2 docker-compose.yml, Dockerfile, Caddyfile #
The three deployment files live in docker/ exactly as Section 5.1's repository tree places them —
docker/docker-compose.yml, docker/Dockerfile, docker/Caddyfile — and every docker compose
command in this section is run from the repository root with COMPOSE_FILE=docker/docker-compose.yml
exported (23.4 step 3 sets it in the deploy user's profile) or with an explicit
-f docker/docker-compose.yml. Relative paths inside the file are therefore resolved against
docker/, which is why the build context and the host mounts below point one level up.
docker/docker-compose.yml:
name: skinforge
services:
app:
# Both keys are present deliberately: `image:` is what `docker compose pull` resolves during a
# normal release (23.5), and `build:` is the bootstrap path for the very first deploy, before any
# image has been published. Compose uses the pulled image when one exists for the tag.
image: ghcr.io/<owner>/skinforge:${SKINFORGE_IMAGE_TAG:-latest}
build:
context: ..
dockerfile: docker/Dockerfile
restart: unless-stopped
env_file: ../.env
environment:
SKINFORGE_PORT: "8000"
depends_on:
postgres:
condition: service_healthy
volumes:
- render_cache:/data/render-cache
- storage_fs:/data/storage
- import_work:/data/import-work
networks: [internal]
healthcheck:
test: ["CMD", "deno", "run", "--allow-net=localhost:8000", "--allow-env=SKINFORGE_PORT", "healthcheck.ts"]
interval: 15s
timeout: 5s
retries: 3
start_period: 20s
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
postgres:
image: postgres:18
restart: unless-stopped
environment:
POSTGRES_DB: skinforge
POSTGRES_USER: skinforge
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
secrets: [postgres_password]
command:
- postgres
- -c
- config_file=/etc/postgresql/postgresql.conf
volumes:
- postgres_data:/var/lib/postgresql/data
- ../db/backups:/backups
- ../db/wal-archive:/backups/wal
# archive_mode = on and archive_command writing to /backups/wal/ — this mount is what makes
# the 5-minute-RPO PITR window in 23.6 real rather than aspirational.
- ../db/postgresql.conf:/etc/postgresql/postgresql.conf:ro
networks: [internal]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U skinforge"]
interval: 10s
timeout: 5s
retries: 5
minio:
image: minio/minio:RELEASE.2026-08-01T00-00-00Z
restart: unless-stopped
profiles: ["s3-storage"]
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${SKINFORGE_S3_ACCESS_KEY_ID}
MINIO_ROOT_PASSWORD: ${SKINFORGE_S3_SECRET_ACCESS_KEY}
volumes:
- minio_data:/data
networks: [internal]
caddy:
image: caddy:2-alpine
restart: unless-stopped
# Without env_file the `{$SKINFORGE_PUBLIC_BASE_URL}` site address in the Caddyfile expands to an
# empty string and Caddy refuses to start, so this line is load-bearing, not decoration.
env_file: ../.env
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on: [app]
networks: [internal]
networks:
internal:
driver: bridge
volumes:
postgres_data:
render_cache:
storage_fs:
import_work:
minio_data:
caddy_data:
caddy_config:
secrets:
postgres_password:
file: ../secrets/postgres_password.txthealthcheck.ts (repository root, beside main.ts) — referenced by both the Compose healthcheck
above and the Dockerfile HEALTHCHECK below, and small enough to state in full so neither reference
dangles:
// healthcheck.ts — container liveness probe. Exits 0 when the process serves /health (Section 21.7),
// 1 otherwise. Deliberately dependency-free and dependency-blind: it must not fail because Postgres
// is down, or Docker would restart a healthy app during a database outage. Readiness (which does
// consider dependencies) is /health/ready, polled by the external monitor, never by Docker.
const port = Deno.env.get("SKINFORGE_PORT") ?? "8000";
try {
const res = await fetch(`http://localhost:${port}/health`, {
signal: AbortSignal.timeout(3000),
});
Deno.exit(res.ok ? 0 : 1);
} catch {
Deno.exit(1);
}It is run with --allow-net=localhost:8000 and --allow-env=SKINFORGE_PORT only; it reads no file
and writes nothing, so a compromised dependency cannot reach anything through the probe.
The minio service uses a Compose profile (s3-storage) so it starts only when
SKINFORGE_STORAGE_DRIVER=s3 and the operator explicitly runs docker compose --profile s3-storage up -d; the default fs driver deployment never starts it, keeping the default footprint smaller.
docker/Dockerfile (multi-stage, non-root user, Deno cache warmed at build time; built with the
repository root as context, per the build.context: .. above):
# syntax=docker/dockerfile:1
FROM denoland/deno:2.9 AS builder
WORKDIR /app
COPY deno.json deno.lock ./
COPY . .
RUN deno install --frozen
RUN deno task build
FROM denoland/deno:2.9 AS runtime
RUN addgroup --system skinforge && adduser --system --ingroup skinforge skinforge
WORKDIR /app
COPY --from=builder --chown=skinforge:skinforge /app /app
RUN mkdir -p /data/render-cache /data/storage /data/import-work \
&& chown -R skinforge:skinforge /data
USER skinforge
ENV SKINFORGE_RENDER_CACHE_DIR=/data/render-cache
ENV SKINFORGE_STORAGE_FS_ROOT=/data/storage
ENV SKINFORGE_IMPORT_WORKDIR=/data/import-work
EXPOSE 8000
HEALTHCHECK --interval=15s --timeout=5s --retries=3 \
CMD deno run --allow-net=localhost:8000 --allow-env=SKINFORGE_PORT healthcheck.ts
CMD ["run", \
"--allow-net=0.0.0.0:8000,postgres:5432,minio:9000,api.cdn.example:443", \
"--allow-read=/app,/data/storage,/data/render-cache,/data/import-work", \
"--allow-write=/data/render-cache,/data/storage,/data/import-work", \
"--allow-env", \
"--no-prompt", \
"main.ts"]postgres and minio are the Compose service DNS names defined above; api.cdn.example is a
placeholder the operator replaces with the host of their real SKINFORGE_CDN_PURGE_URL (Section
24.2). Image tags follow Section 4's major-line policy (2.9, never a pinned patch and never
latest); the same rule applies to minio/minio below.
This is the same permission set specified in Section 20.10, written differently for a different
reader: 20.10 uses ${SKINFORGE_*} variable names for readability, while an exec-form CMD is a JSON
array in which Docker performs no variable expansion, so the Dockerfile must carry the literal
container paths those variables resolve to. --allow-read covers /data/storage and
/data/render-cache because the fs driver reads stored renders and catalog objects back out on
every cache hit, not only writes them; --allow-write covers /data/import-work because the admin
ZIP-upload source mode (Section 17.5.1) has the web process stream an uploaded archive into it, and
with --no-prompt a missing write scope is a hard failure on the first byte rather than a prompt. The
container's CMD is the authoritative, minimal permission set, not merely documentation of it.
Caddyfile:
{
servers {
trusted_proxies static <CDN-edge-CIDRs>
}
}
{$SKINFORGE_PUBLIC_BASE_URL} {
encode zstd gzip
# Cache-Control for /render/*, /og/* and every HTML route is set by the app per response status
# (Sections 8.9, 19.3, 25.3) — several of them are status-dependent (a taken-down design's 410
# must never be cached the same way as a healthy render). Caddy must not override it.
@admin path /admin*
header @admin X-Frame-Options "DENY"
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
reverse_proxy app:8000 {
header_up X-Forwarded-For {http.request.header.X-Forwarded-For}
# CF-Connecting-IP is Cloudflare's header name; adjust or delete this line for another CDN.
header_up X-Real-IP {http.request.header.CF-Connecting-IP}
header_up X-Request-Start {time.now.unix_ms}
}
log {
output file /data/access.log {
roll_size 50MiB
roll_keep 5
roll_keep_for 720h
}
format json
}
}trusted_proxies must list the operator's actual CDN edge IP ranges; without it, Caddy's own
remote_host (not used above) would be the only IP the app ever saw, and forwarding the CDN's
already-present X-Forwarded-For chain unmodified — rather than overwriting it with Caddy's
immediate peer — combined with SKINFORGE_TRUSTED_PROXY_HOPS (Section 24.2) is what lets the app
recover the real client IP for per-IP rate limiting (Section 16.7, Section 20.1) instead of collapsing
every visitor behind the CDN into one bucket. Caddy's own access log at /data/access.log contains
full, untruncated client IPs and is operator-managed infrastructure logging, explicitly outside the
application-log rotation policy in Section 21.1 (which governs only the app's own structured JSON
logs); roll_keep_for 720h (30 days) bounds it for disk-space reasons only, not as a privacy
commitment.
The X-Real-IP line names CF-Connecting-IP, which is Cloudflare-specific. Decision D-10 permits any
CDN that honours Cache-Control and offers a purge API, so an operator on another provider replaces
that header name with their provider's equivalent or deletes the line outright — nothing in the
application reads X-Real-IP. The IP the app actually uses comes from the X-Forwarded-For chain
interpreted through SKINFORGE_TRUSTED_PROXY_HOPS (Section 20.7), which is provider-neutral.
Application-level security headers from Section 20.4 (the full CSP, Permissions-Policy,
Cross-Origin-* headers) are set by the app process itself, not Caddy, so the two profiles (public
vs. admin) stay driven by one source of truth in core/http/security-headers.ts; the Caddyfile only
adds HSTS and the access-log layer that is infrastructure-appropriate to set at the edge, plus the
X-Frame-Options legacy fallback on admin routes.
23.3 Environments #
| Environment | SKINFORGE_ENV |
Differs from production | Configuration source |
|---|---|---|---|
| Development | development |
Runs via deno task dev (Fresh's dev server with hot reload), local Postgres (Docker or native install), fs storage driver pointed at a local .data/ directory, SKINFORGE_LOG_LEVEL=debug, admin TOTP issuer suffixed (dev) to avoid confusing authenticator app entries with production |
.env (git-ignored, from .env.example, Section 23.4) |
| Staging | staging |
Full Docker Compose stack, separate Postgres database and storage bucket/root from production, a separate subdomain (e.g. staging.skinforge.example), real TOTP but a distinct admin user set, rate limits relaxed 5x for easier manual QA (Section 22.12) |
.env on the staging host, values distinct from production per variable |
| Production | production |
As specified throughout this section; SKINFORGE_LOG_LEVEL=info, full rate limits, real CDN in front, backups enabled (23.6) |
.env on the production host, secrets generated per Section 24.5, never copied from staging |
All three environments run the identical container images (built once per release, Section 23.5) —
environment differences are entirely configuration (Section 24), never a code branch on
SKINFORGE_ENV beyond cosmetic labeling (e.g. the TOTP issuer suffix above) and enabling
verbose logging.
23.4 First-time provisioning runbook #
Provision the VPS. Create a host matching Section 23.1's sizing. Create a non-root deploy user with Docker permissions (
usermod -aG docker <user>). Install Docker Engine and Docker Compose plugin.DNS. Point the production domain's
A/AAAArecords at the VPS IP (or at the CDN if the CDN provider requires a CNAME/proxy setup instead — follow the CDN provider's standard pattern). Allow time for propagation before step 4.Clone and configure.
git clonethe repository to the VPS. ExportCOMPOSE_FILE=docker/docker-compose.ymlin the deploy user's shell profile so every command below resolves the right file. Copy.env.example(defined fully in Section 24.4) to.envand fill every value; generate secrets per Section 24.5's commands. Createsecrets/postgres_password.txtwith a generated password (openssl rand -base64 32), and setSKINFORGE_IMAGE_TAGto the release tag being deployed. On a host that can reach the image registry,docker compose pullfetches the published image and no build happens locally; building on the host is the bootstrap fallback for the very first deploy, before CI has published an image for any tag.TLS. No manual step required — Caddy obtains and renews a Let's Encrypt certificate automatically for the domain in
SKINFORGE_PUBLIC_BASE_URLon first request, provided DNS (step 2) already resolves to this host and ports 80/443 are reachable from the internet.Start the data tier.
docker compose up -d postgres(andminiowith--profile s3-storageif using S3-compatible storage). Wait for the healthcheck to pass (docker compose ps).Run migrations.
docker compose run --rm app deno task db:migrate. Confirmschema_migrationsreflects every migration indb/migrations/.Create the first admin user.
docker compose run --rm app deno task cli -- admin create-user --email <staff-email> --role owner—--roleis mandatory (Section 17.1), and the first account must beowneror no one can reach the owner-only screens. The CLI prompts for a password (entered interactively, never passed as a CLI argument to avoid shell-history leakage) and prints a TOTP enrollment QR code / secret to the terminal for the operator to add to an authenticator app immediately.Storage bucket setup. For
fs(default): thestorage_fsvolume is created automatically by Compose; no further action. Fors3: create the bucket named inSKINFORGE_S3_BUCKETvia the provider's console ormc mbif using MinIO, and confirm the configured access key has read/write permission on it.Start the full stack.
docker compose up -d. ConfirmGET https://<domain>/healthreturns200andGET https://<domain>/health/readyshows every check"ok".Step 9a — enable backups, before any real data exists to lose. Install the host cron entries invoking
scripts/backup-postgres.sh(02:00 UTC) andscripts/backup-storage.sh(02:30 UTC), both of which encrypt withageand upload to the off-site bucket per 23.6. WAL archiving is already configured by thedb/postgresql.confmount in 23.2 (archive_mode = on, and anarchive_commandwriting to/backups/wal/); confirm segments are landing there. Verify one complete dump and at least one archived WAL segment have reached the off-site bucket before continuing — an unverified backup is not a backup, and the 5-minute RPO promised in 23.6 depends entirely on this step having actually been run.First asset import. Run the extractor (Section 20.6, step 1) against the operator's local client copy to produce a manifest in
SKINFORGE_IMPORT_WORKDIR(this step happens on whatever machine holds the client files, which may or may not be the VPS — the manifest and normalized output are then present on the server, or copied there, outside any web-served directory), then run the import command (Section 20.6, step 2) to ingest that manifest into the database. Follow Section 7's staged pipeline through D1-D6, then review candidates at/admin/candidates(Section 17) and publish the first build at/admin/builds.Smoke test. Load
/, create a test design, confirm the permalink and render URLs work, confirm the OG image generates. The test design does not need to be removed (designs are immutable per Section 13); note its short code for reuse as the release smoke-test permalink (23.5).
23.5 Release process #
Build: CI builds a single container image per release (Section 22.11's release.yml), tagged with
the git short SHA and, on a version tag push, additionally tagged with the semantic version (e.g.
2026.09.1). The same image is later promoted from staging to production — never rebuilt per
environment, eliminating "works in staging, different in production" drift.
Tag: version tags follow YYYY.MM.N (e.g. 2026.09.1 for the first release cut in September
2026), reflecting a small-team, non-strict-semver release cadence appropriate to a single-operator
product; N increments within the month.
Migrate: migrations (Section 6) are additive-first — a release that adds a column ships and
deploys before any release that requires the column to be non-null or that removes an old column,
across two separate releases, so that the previous release's running containers (during a rolling
restart) never encounter a schema shape they were not built against. deno task db:migrate runs as
a one-off docker compose run --rm app invocation before the app container is restarted with the new
image.
Deploy: set SKINFORGE_IMAGE_TAG in .env to the release tag, then
docker compose pull && docker compose up -d --no-deps app — the app service carries an image:
key alongside build: (23.2) precisely so this pull resolves a published image rather than failing on
a build-only service. Only the app service is replaced; postgres, minio, and caddy are untouched by a normal release. Docker Compose performs
a stop-then-start of the single app container (Compose does not natively do rolling replacement
with a single replica); the brief gap is covered by Caddy's automatic retry-on-connection-refused
behavior for a few seconds, which in practice is invisible to users given typical request rates
(Section 19.10) — this is the accepted trade-off of the single-VPS, single-replica v1 topology
(Section 23.12 covers the multi-replica path where true zero-downtime deploys become available).
Smoke test: immediately after deploy, an automated script (scripts/smoke-test.sh, also runnable
manually) hits /health, /health/ready, loads /, resolves the known smoke-test permalink from
provisioning (23.4 step 11) or a dedicated permanent smoke-test design, and confirms a 200 with the
expected content markers on each. A failure triggers immediate rollback.
Rollback: set SKINFORGE_IMAGE_TAG back to the previous release tag, docker compose pull, and
up -d --no-deps app again — since
migrations are additive-first, the previous image tag's code is guaranteed compatible with the current
(possibly newly migrated) schema. A rollback never reverses a migration; if a migration itself is the
defect, a forward-fix migration is written and released rather than a destructive down-migration
(down-migrations are not maintained in this project — db/migrations/ is forward-only, consistent
with the additive-first rule making down-migrations largely unnecessary).
Zero-downtime approach: as described under Deploy above, v1 accepts a brief (sub-few-second) connection gap rather than true zero-downtime, which is an explicit, documented trade-off for the single-replica topology; Section 23.12's first scaling step introduces a second app replica specifically to close this gap when traffic level justifies the added operational complexity.
23.6 Backups #
| What | Schedule | Retention | Encryption | Off-site copy |
|---|---|---|---|---|
Postgres (pg_dump custom format, scripts/backup-postgres.sh from the host cron installed in 23.4 step 9a) |
Daily at 02:00 UTC, plus continuous WAL archiving configured by the db/postgresql.conf mounted in 23.2 |
14 daily, 8 weekly, 6 monthly; WAL segments retained 7 days, giving the 5-minute-granularity, 7-day PITR window required by Section 6.34 | Encrypted at rest with age (public-key encryption, private key never stored on the VPS) before upload |
Yes — pushed to a separate cloud storage bucket immediately after each dump completes |
Object storage: catalog/ (the normalized per-variant .webp and .png art written by Stage D6 — Section 7.8, not regenerable without the operator's original client copy for that build; there is no mask artifact and no raw-buffer artifact in this prefix, only encoded images) and og/ (Open Graph images). renders/ and imports/ are excluded as regenerable/scratch — renders/ is a pure function of catalog/ plus the database |
Daily at 02:30 UTC (after the Postgres dump), via scripts/backup-storage.sh from the same host cron |
Same rotation as Postgres | Same age encryption |
Same off-site bucket, separate prefix |
Extraction work directory (SKINFORGE_IMPORT_WORKDIR) |
Not backed up | N/A — is scratch space, regenerable by re-running extraction against the operator's client files (which are the actual source of truth and are the operator's own responsibility to retain) | N/A | N/A |
Configuration (.env, secrets/) |
Manual, on every change, to the operator's own password manager / secret store — never to the same off-site bucket as data backups | Operator-managed | Operator-managed | Operator-managed |
RESTORE DRILL procedure, performed at minimum every 90 days (tracked as a checklist item in Section 22.12's release checklist and Section 20.12's pre-launch checklist):
- Provision a disposable VPS or local Docker environment separate from production.
- Download the most recent Postgres backup from off-site storage, decrypt with the
ageprivate key (kept only in the operator's secret store, never on any server). - Restore:
pg_restoreinto a freshpostgres:18container. - Restore the object-storage backup into a fresh
fsroot or MinIO instance. - Point a freshly built
appcontainer at the restored Postgres and storage, start it, and run the smoke test script (23.5). - Confirm a known pre-backup design's permalink resolves and renders correctly, confirm the admin catalog looks complete, confirm the migration table matches what is expected for that backup's age.
- Record the wall-clock time taken for steps 2-6 as the drill's measured RTO, and the backup's timestamp gap from "now" as the drill's measured RPO. Compare against the targets below.
- Tear down the disposable environment.
Expected RTO (Recovery Time Objective): 2 hours from decision-to-restore to a fully serving production replacement, dominated by VPS provisioning and backup download time on a typical broadband connection. RPO for Postgres: ≤ 5 minutes within the last 7 days via WAL replay (Section 6.34); 24 hours for object storage (daily backup).
23.7 Disaster recovery scenarios #
| Scenario | Detection | Steps | Expected recovery time |
|---|---|---|---|
| Lost VPS (hardware failure, provider outage) | /health alert (Section 21.5) fires and stays red; provider status page confirms |
Provision a replacement VPS (23.1 sizing), follow the provisioning runbook (23.4) steps 1-9 using the restore drill's restore steps (23.6) in place of a fresh empty database, update DNS if the IP changed | ~2-3 hours (RTO from 23.6 plus DNS propagation if the IP changed) |
| Corrupt database | Integrity errors in application logs (error level, Section 21.1), or /health/ready reporting the database check failing with a non-connectivity error |
Stop the app container to prevent further writes; assess whether point-in-time WAL recovery (to just before corruption) or the last-good daily dump is needed; restore per 23.6 into a fresh Postgres container; verify via the same checks as the restore drill before resuming traffic | 1-3 hours depending on how far back a clean recovery point is |
| Lost object storage | /health/ready storage check fails, or render/OG requests start returning SF-5003 (storage error) |
If using s3: contact the provider, most S3-compatible providers have their own durability guarantees making total loss rare; if it does occur, restore catalog/ and og/ from the off-site backup (23.6) and regenerate all renders/ content by re-running the render pipeline against every existing design (a bulk job iterating designs, safe and idempotent since renders are pure functions of design + build) |
2-4 hours, dominated by bulk render regeneration for the existing design set |
Lost catalog/ prefix with no backup |
verify (Section 7.9) fails for every asset |
Restore from the off-site catalog/ backup; if none exists, every design pinned to that build is permanently unrenderable and only a fresh import of the identical client build can repair it |
Not recoverable without a backup |
| Bad publish (a build published with broken/wrong assets) | Admin or user reports incorrect renders; admin.publish.purge_failed or a spike in render.failed after a build.published event |
Immediate rollback via /admin/builds (Section 9's rollback workflow) to the previous build; this is a metadata-only operation (flips which build is active) so it completes in seconds, not a restore |
Minutes |
| Compromised admin account | Unusual audit_log entries, an admin.login_succeeded from an unrecognized pattern, or a report from the legitimate account holder |
Immediately invalidate all sessions for that account: UPDATE admin_sessions SET revoked_at = now() WHERE admin_user_id = ... AND revoked_at IS NULL;; force a password reset and TOTP re-enrollment via CLI (skinforge-cli admin reset-credentials --email ... — the same two command names used in 23.4 step 7 and Section 17.1, admin create-user and admin reset-credentials, which invalidates the old TOTP secret and issues a new enrollment); review audit_log for that account's actions since the earliest suspicious activity and reverse any malicious publish/takedown actions. Rotating SKINFORGE_ADMIN_SESSION_SECRET (Section 24.5) does not invalidate sessions (session tokens are random and hash-referenced, not derived from that secret) — it invalidates outstanding pagination cursors and CSRF tokens, and is only relevant here if cursor/CSRF forgery specifically is suspected |
30-60 minutes for containment; audit review time varies |
23.8 Routine operations runbooks #
Re-import after a game patch (mechanics fully specified in Section 9; this is the operational
summary): run the extractor against the operator's freshly patched client copy, review flagged
candidates at /admin/candidates, publish the new build once review is complete. Typical cadence:
within a few days of an observed UO Outlands client patch.
Purge the render cache: docker compose run --rm app deno task cli -- cache purge-renders [--older-than 90d] — deletes object-storage entries under renders/ (never catalog/ or og/)
optionally filtered by age; safe at any time since every render is regenerable on demand
from the design + build data. Used to reclaim disk space (23.11's cost model assumes periodic purging
of renders older than the traffic long-tail).
Rebuild OG images: docker compose run --rm app deno task cli -- og rebuild [--build-id <id>] —
regenerates every og_images row for designs on the given build (or all builds if omitted); used
after a change to the OG layout template (Section 14) so previously generated images pick up the new
design.
Prune old designs: not a supported operation — designs are never auto-deleted; they are immutable
and kept indefinitely (Section 13.7). There is no zero-view or age-based pruning rule anywhere in this
system. The only removal path is the takedown process (Section 20.8), which marks a design taken-down
and serves 410 without deleting the row; it is a legal/moderation action, not a routine
storage-management task.
Rotate secrets: follow Section 24.5's per-secret rotation procedure; for each secret, generate a
new value, update .env, and restart the affected container(s) (docker compose up -d --no-deps app
for app-level secrets, or a full docker compose restart postgres for the database password, which
requires updating secrets/postgres_password.txt and briefly pausing the app during the restart).
Expand storage: for the fs driver, resize the VPS's block storage volume via the provider's
console/CLI, then grow the filesystem (resize2fs or provider-equivalent) — no application
downtime required. For s3, storage capacity is provider-managed and requires no operator action
beyond monitoring the cost implications (23.11).
Investigate a slow page: check the traffic and health dashboards (Section 21.4) for the affected
route's p95 latency and the database pool utilization; check skinforge_render_queue_depth if the
slow route involves a render; check recent build.published events for a correlated timing; consult
structured logs filtered by route and sorted by durationMs descending for the affected window.
Drain and restart: docker compose stop app allows in-flight requests to complete (Deno's default
graceful shutdown on SIGTERM, honored by the app's own shutdown handler which stops accepting new
connections but lets existing ones finish up to a 30-second grace period) before the container stops;
docker compose up -d --no-deps app starts a fresh instance. Used before any manual maintenance that
does not warrant full maintenance mode (23.9).
23.9 Maintenance mode #
There are two ways in, and they exist for two different situations. The boot-time kill switch is
SKINFORGE_MAINTENANCE_MODE=true plus a restart of the app container (a config change, not a code
deploy) — used when the app must come up already in maintenance, for instance ahead of a risky
migration or when the operator wants the state to survive a restart. The runtime toggle is the
audited switch on /admin/settings (Section 17.14), which writes the maintenance_mode row in
app_settings and takes effect within the cached settings accessor's refresh window (Section 24.1)
with no restart — used during an incident, when a restart is the last thing wanted. Either being on
produces the behavior below; the environment variable wins while it is true, so an operator cannot
accidentally clear a deliberate kill switch from the console. When enabled by either route:
- Every public HTML route returns a static
503maintenance page (styled consistently with the design system, Section 18) withRetry-After: 300and no attempt to reach the database. - Every public API route (
/api/v1/*excluding admin) returns503with errorSF-9003(MAINTENANCE_MODE) in the standard error envelope (Section 16). - Render and OG endpoints (
/render/...,/og/...) continue to serve from cache (CDN and object storage) without hitting the app process at all for already-cached content, since the CDN layer sits in front and maintenance mode only affects origin behavior — a visitor viewing an already- cached permalink during a maintenance window is unaffected for content the CDN already has. /healthcontinues to return200(the process is alive) but/health/readyreturns503with a"maintenance"status field, so external monitors correctly distinguish "intentionally paused" from "actually down" (avoids spurious SEV1 alerts per Section 21.5 during planned maintenance — the alert condition for site-down should be paired with a maintenance-mode check that suppresses the page during a deliberate window, configured as an alert silence tied to the same toggle)./admin/*remains fully functional regardless of maintenance mode, so the operator can complete the work that necessitated maintenance mode (e.g. a risky migration or a manual database repair) without needing to disable it first — and, in the runtime-toggle case, can switch it back off from the same screen that switched it on. Every toggle writes anaudit_logrow (Section 20.5).
23.10 Monitoring hookup and on-call expectations #
The operator is a single person; there is no on-call rotation. Alerts (Section 21.5) are configured to
notify the operator directly, delivered through whatever alerting channel the operator configures
against /metrics (Section 21.3) — commonly a self-hosted Prometheus Alertmanager routing to email, a
webhook, or a push notification the operator already has set up. This is entirely the operator's own
monitoring-stack configuration; SkinForge itself sends no outbound notification of any kind
(Section 17.12). The alert conditions and severities are fixed by Section 21.5 regardless of
delivery channel.
Expectations, calibrated to a one-person operation:
- SEV1 alerts are expected to be acknowledged and worked within a best-effort target of 1 hour during waking hours; there is no guaranteed overnight response, which is disclosed nowhere publicly (this is not a paid service with an SLA) but is stated here as the honest operational expectation.
- SEV2 alerts are worked within 24 hours.
- SEV3 alerts are triaged within a week, batched with the routine operations schedule (weekly dependency review, 23.11's cost review, etc.).
- The monthly dependency review (Section 20.10) and the 90-day restore drill (23.6) are the two recurring, calendar-driven tasks that do not wait for an alert to trigger — the operator schedules them proactively.
23.11 Cost model #
Monthly costs at three traffic levels, in USD, using representative 2026 pricing for a VPS provider, a CDN with a free/low tier, and optional managed extras. Assumptions stated per row.
| Traffic level | VPS | CDN | Object storage (if s3, beyond VPS disk) |
Backups off-site storage | Domain/TLS | Total (approx.) |
|---|---|---|---|---|---|---|
| Low (the "normal traffic" baseline, Section 19.10: ~5k visitors/day) | $24/mo (4 vCPU/8GB reference size, 23.1) | $0 (free tier covers this volume on any major CDN) | $0 (fs driver, fits on VPS disk; skip MinIO) | $2/mo (a few GB of encrypted dumps) | $1/mo (amortized annual domain cost); TLS is free via Caddy/Let's Encrypt | ~$27/mo |
| Medium (5x baseline, sustained community growth) | $24/mo (same size still sufficient per the capacity model, 19.10) | $0-5/mo (still within most free tiers; small overage possible) | $5/mo (moves to S3-compatible storage for easier backup/durability at this scale) | $3/mo | $1/mo | ~$33-38/mo |
| High (a sustained viral period, 20-30x baseline) | $48/mo (upgrade to 8 vCPU/16GB per the scaling steps in 23.12) | $10-20/mo (CDN overage at high image-serving volume) | $10/mo | $5/mo | $1/mo | ~$74-84/mo |
The architecture's cache-heavy, content-addressed design (Section 19.4) keeps CDN egress — usually the
dominant variable cost for an image-serving app — low relative to raw traffic volume, since the large
majority of repeat image requests are edge cache hits that many CDN providers do not bill as origin
bandwidth. Donation revenue (an optional donation link the product may display, SKINFORGE_DONATION_URL,
Section 24.2) is the intended offset for these costs; the spec makes no assumption about donation
income covering costs and treats the operator as fully responsible for hosting costs regardless.
23.12 Scaling steps in order #
| Step | Trigger metric | Action |
|---|---|---|
| 1. Vertical VPS upgrade | Sustained skinforge_db_pool_in_use near saturation, or render queue depth (19.8) regularly exceeding 100 during normal (non-spike) traffic |
Resize the VPS to 8 vCPU/16GB (provider-supported live resize where available); bump SKINFORGE_DB_POOL_SIZE and SKINFORGE_RENDER_MAX_CONCURRENCY proportionally |
| 2. Move to managed/external S3-compatible storage | Disk usage on the VPS approaching 70% capacity, or backup times (23.6) growing uncomfortably long | Switch SKINFORGE_STORAGE_DRIVER to s3, set SKINFORGE_S3_ENDPOINT, SKINFORGE_S3_REGION, SKINFORGE_S3_BUCKET and SKINFORGE_S3_PUBLIC_BASE_URL (Section 24.2) to point at a managed provider (Cloudflare R2, Backblaze B2) instead of self-hosted MinIO, migrate existing storage_fs contents with a one-off deno task cli -- storage migrate-to-s3 script |
| 3. Move Postgres to a managed database service | Database becomes the bottleneck independent of app-tier scaling (step 1 no longer helps), or the operator wants managed backups/HA beyond what 23.6 self-manages | Point SKINFORGE_DATABASE_URL at a managed Postgres 18 instance; retire the postgres Compose service; this is a configuration change plus a pg_dump/pg_restore migration, not a code change |
| 4. Add a second app replica behind Caddy | Sustained request rate approaching the point where a single Deno process's event loop plus render concurrency (19.8) becomes the ceiling, or true zero-downtime deploys (23.5) become a hard requirement | Add a second app service instance (or use Docker Compose's --scale app=2) with Caddy load-balancing across both; requires the in-process LRU caches (19.2) to be accepted as per-replica (already designed for this) and rate-limit buckets to move fully onto the Postgres-backed path (already the documented fallback in 19.2) |
| 5. Introduce a CDN-level image resizing/edge-compute tier | Egress costs (23.11) becoming the dominant cost driver at very high traffic | Offload scale/format negotiation (19.6) to CDN edge compute where the provider supports it, reducing origin render load further; this is an optimization on top of the existing content-addressed scheme, not an architecture change |
Each step is deliberately incremental and reversible, consistent with the scaling posture in Section 4.8, which keeps the canonical deployment target a single-VPS Docker Compose topology for as long as it serves the traffic level — these steps are triggered by measured metrics, never adopted speculatively.
24. Configuration & Environment Variables #
24.1 Configuration philosophy #
Environment variables configure the process at boot and are immutable at runtime. There is no
config file read at runtime, no layered override, and no code path that mutates a value in Section
24.2 after loadConfig() has returned — changing any of them means editing .env and restarting the
container.
That covers everything about how the process runs. It does not cover a handful of operational
behaviours the operator needs to change without a restart, which is why a small app_settings table
(Section 6) exists, is edited on /admin/settings (Section 17.14), and is read everywhere through one
cached accessor (getSetting(key), a 60-second in-process TTL, so a change is live within a minute
across the app without a per-request query). The split is exact:
| Kind | Where it lives | How it changes | Contents |
|---|---|---|---|
| Process configuration | Environment variables, Section 24.2 | Edit .env, restart the container |
Every variable in 24.2 — ports, credentials, driver selection, pool sizes, rate limits, timeouts, feature flags |
| Operator settings | app_settings rows (Section 6), one row per key |
Admin console, audited (audit_log, Section 20.5), effective within the accessor's 60-second TTL |
takedown_response_template, admin_ip_allowlist, donation_link_text, designs_like_this_threshold (default 2, Section 13.8), featured_designs, and maintenance_mode (the soft toggle in Section 23.9) |
Nothing appears in both columns. The one place they touch is maintenance mode: the environment
variable SKINFORGE_MAINTENANCE_MODE is a boot-time kill switch that wins while it is true, and the
maintenance_mode setting is the runtime toggle used during an incident (Section 23.9 owns the
resulting behaviour). Secrets are never operator settings — an app_settings row is never a
credential, and no value in that table is read before the boot validator has run.
Every variable is validated at process boot by a single Zod 4.x schema (core/config.ts); a missing
required variable or a value that fails its type/format check makes the process exit immediately with
a non-zero code and a readable error listing every failing variable at once (not just the first one
found), so an operator fixes every problem in one pass rather than one restart per typo. Boolean
variables accept true/false (exact strings) only; any other value is a boot-time validation
failure — a boolean is never coerced from truthiness. No secret is
ever committed to the repository (Section 20.10); .gitignore excludes .env* except
.env.example.
24.2 The canonical variable table #
Every name below is the canonical name; no other section may introduce a SKINFORGE_ variable that is
absent from this table. Consumed by lists the owning section(s) for the behavior the variable
controls. A variable with a Default is never Required; Required: yes means the boot validator
rejects an unset value.
| Variable | Type | Required | Default | Example | Consumed by | Secret |
|---|---|---|---|---|---|---|
SKINFORGE_ENV |
enum: development|staging|production|test |
yes | — | production |
23.3 | no |
SKINFORGE_PUBLIC_BASE_URL |
URL (https in staging/production) | yes | — | https://skinforge.example |
10, 13, 14, 23.2 | no |
SKINFORGE_PORT |
integer, 1-65535 | no | 8000 |
8000 |
23.1, 23.2 | no |
SKINFORGE_DATABASE_URL |
Postgres connection string | yes | — | postgres://skinforge:***@postgres:5432/skinforge |
6, 19.7 | yes |
SKINFORGE_DB_POOL_SIZE |
integer, 1-100 | no | 10 (the CLI process overrides this to 4 in code, Section 24.3's loadCliConfig) |
10 |
19.7 | no |
SKINFORGE_STORAGE_DRIVER |
enum: fs|s3 |
no | fs |
fs |
4.7, 6, 23.2, 23.12 | no |
SKINFORGE_STORAGE_FS_ROOT |
absolute path | conditional (fs driver) |
/data/storage |
/data/storage |
23.2 | no |
SKINFORGE_S3_ENDPOINT |
URL | conditional (s3 driver) |
— | https://<accountid>.r2.cloudflarestorage.com |
23.12 | no |
SKINFORGE_S3_REGION |
string | conditional (s3 driver) |
auto |
auto |
23.12 | no |
SKINFORGE_S3_BUCKET |
string | conditional (s3 driver) |
— | skinforge-prod |
23.12 | no |
SKINFORGE_S3_ACCESS_KEY_ID |
string | conditional (s3 driver) |
— | AKIA... |
23.12 | yes |
SKINFORGE_S3_SECRET_ACCESS_KEY |
string | conditional (s3 driver) |
— | *** |
23.12 | yes |
SKINFORGE_S3_PUBLIC_BASE_URL |
URL | conditional (s3 driver) |
— | https://cdn.skinforge.example |
19.4 | no |
SKINFORGE_ADMIN_SESSION_SECRET |
base64 string decoding to ≥ 32 bytes (≥ 44 characters) | yes | — | *** |
20.5 | yes |
SKINFORGE_ADMIN_SESSION_TTL_HOURS |
integer, 1-168 | no | 12 |
12 |
17.1 | no |
SKINFORGE_ADMIN_TOTP_ISSUER |
string (URL-encoded by the caller before being embedded in the otpauth:// URI, Section 17.2) |
no | SkinForge |
SkinForge |
20.5, 17 | no |
SKINFORGE_RENDER_CACHE_DIR |
absolute path | no | /data/render-cache |
/data/render-cache |
8, 19.2 | no |
SKINFORGE_RENDER_MAX_CONCURRENCY |
integer, 1-32 | no | 4 |
4 |
19.8 | no |
SKINFORGE_RENDER_TIMEOUT_MS |
integer, 100-60000 | no | 8000 |
8000 |
8.10, 19.8 | no |
SKINFORGE_OG_ENABLED |
boolean | no | true |
true |
14, 24.7 | no |
SKINFORGE_OG_TIMEOUT_MS |
integer, 100-60000 | no | 3000 |
3000 |
14.8 | no |
SKINFORGE_RATE_LIMIT_API_PER_MINUTE |
integer, 1-10000 | no | 120 |
120 |
20.1 | no |
SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE |
integer, 1-10000 | no | 60 |
60 |
19.8, 20.1 | no |
SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR |
integer, 1-1000 | no | 30 |
30 |
20.1 | no |
SKINFORGE_LOG_LEVEL |
enum: debug|info|warn|error |
no | info |
info |
21.1 | no |
SKINFORGE_METRICS_ENABLED |
boolean | no | true |
true |
21.3, 24.7 | no |
SKINFORGE_METRICS_TOKEN |
base64 string, ≥ 32 characters (≥ 24 bytes) | conditional (METRICS_ENABLED=true) |
— | *** |
21.3 | yes |
SKINFORGE_IMPORT_WORKDIR |
absolute path | no | /data/import-work |
/data/import-work |
7, 20.6 | no |
SKINFORGE_IMPORT_MAX_UPLOAD_MB |
integer, 1-10000 | no | 2048 |
2048 |
20.6 | no |
SKINFORGE_DONATION_URL |
URL | no | — (donation link hidden if unset) | https://ko-fi.com/example |
10 | no |
SKINFORGE_CONTACT_EMAIL |
email address | yes | — | contact@skinforge.example |
20.8, 21.9 | no |
SKINFORGE_CDN_PURGE_URL |
URL | conditional (CDN purge configured) | — | https://api.cdn.example/purge |
19.5 | no |
SKINFORGE_CDN_PURGE_TOKEN |
string | conditional (CDN purge configured) | — | *** |
19.5 | yes |
SKINFORGE_TRUSTED_PROXY_HOPS |
integer, 0-10 | no | 1 |
1 |
20.7 | no |
SKINFORGE_MAINTENANCE_MODE |
boolean | no | false |
false |
23.9, 24.7 | no |
SKINFORGE_JOB_RUNNER_ENABLED |
boolean | no | true |
true |
4.7, 24.7 | no |
SKINFORGE_IMAGE_TAG |
string (container image tag) | no | latest |
2026.09.1 |
23.2, 23.5 | no |
One variable is read by Docker Compose, not by the application: SKINFORGE_IMAGE_TAG substitutes
into the app service's image: key (Section 23.2) at docker compose up/pull time and is what
makes a release and a rollback a tag change rather than a rebuild (Section 23.5). It is listed here
because Section 24.2 is the canonical list of every SKINFORGE_ name in the system, but it is
deliberately absent from the boot schema in 24.3 — the application never reads it, and loadConfig()
ignores environment entries it does not declare rather than rejecting them.
Conditional-required semantics: a variable marked conditional becomes required exactly when the
condition named in its Required cell holds (e.g. every SKINFORGE_S3_* variable becomes required
only when SKINFORGE_STORAGE_DRIVER=s3); the boot validator (24.3) encodes this with Zod's
discriminated-union/.refine() pattern rather than making every variable unconditionally required,
so a fs-driver deployment's .env does not need placeholder S3 values.
SKINFORGE_TRUSTED_PROXY_HOPS: the number of trusted reverse-proxy hops (CDN + Caddy) between the
client and the app process, used to correctly parse the real originating IP from
X-Forwarded-For for rate limiting and truncated-IP logging (Section 20.7) without trusting a
client-forged header value beyond the configured hop count.
24.3 Boot-time validation module #
// core/config.ts
import { z } from "zod";
// Strict boolean parsing: the exact string "true" or "false" only. z.coerce.boolean() is never used
// anywhere in this schema — Boolean("false") === true in JavaScript, which would silently pin
// SKINFORGE_MAINTENANCE_MODE=false into permanent maintenance mode.
const strictBoolean = (defaultValue: "true" | "false") =>
z.enum(["true", "false"]).default(defaultValue).transform((v) => v === "true");
const absPath = z.string().min(1).refine((p) => p.startsWith("/"), "must be an absolute path");
const baseSchema = z.object({
SKINFORGE_ENV: z.enum(["development", "staging", "production", "test"]),
SKINFORGE_PUBLIC_BASE_URL: z.string().url(),
SKINFORGE_PORT: z.coerce.number().int().min(1).max(65535).default(8000),
SKINFORGE_DATABASE_URL: z.string().min(1),
SKINFORGE_DB_POOL_SIZE: z.coerce.number().int().min(1).max(100).default(10),
SKINFORGE_STORAGE_DRIVER: z.enum(["fs", "s3"]).default("fs"),
SKINFORGE_STORAGE_FS_ROOT: absPath.default("/data/storage"),
SKINFORGE_S3_ENDPOINT: z.string().url().optional(),
SKINFORGE_S3_REGION: z.string().default("auto"),
SKINFORGE_S3_BUCKET: z.string().optional(),
SKINFORGE_S3_ACCESS_KEY_ID: z.string().optional(),
SKINFORGE_S3_SECRET_ACCESS_KEY: z.string().optional(),
SKINFORGE_S3_PUBLIC_BASE_URL: z.string().url().optional(),
SKINFORGE_ADMIN_SESSION_SECRET: z.string().regex(
/^[A-Za-z0-9+/]{43,}={0,2}$/,
"must be base64 encoding at least 32 bytes",
),
SKINFORGE_ADMIN_SESSION_TTL_HOURS: z.coerce.number().int().min(1).max(168).default(12),
SKINFORGE_ADMIN_TOTP_ISSUER: z.string().default("SkinForge"),
SKINFORGE_RENDER_CACHE_DIR: absPath.default("/data/render-cache"),
SKINFORGE_RENDER_MAX_CONCURRENCY: z.coerce.number().int().min(1).max(32).default(4),
SKINFORGE_RENDER_TIMEOUT_MS: z.coerce.number().int().min(100).max(60000).default(8000),
SKINFORGE_OG_ENABLED: strictBoolean("true"),
SKINFORGE_OG_TIMEOUT_MS: z.coerce.number().int().min(100).max(60000).default(3000),
SKINFORGE_RATE_LIMIT_API_PER_MINUTE: z.coerce.number().int().min(1).max(10000).default(120),
SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE: z.coerce.number().int().min(1).max(10000).default(60),
SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR: z.coerce.number().int().min(1).max(1000).default(30),
SKINFORGE_LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
SKINFORGE_METRICS_ENABLED: strictBoolean("true"),
SKINFORGE_METRICS_TOKEN: z.string().min(32).optional(),
SKINFORGE_IMPORT_WORKDIR: absPath.default("/data/import-work"),
SKINFORGE_IMPORT_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(10000).default(2048),
SKINFORGE_DONATION_URL: z.string().url().optional(),
SKINFORGE_CONTACT_EMAIL: z.string().email(),
SKINFORGE_CDN_PURGE_URL: z.string().url().optional(),
SKINFORGE_CDN_PURGE_TOKEN: z.string().optional(),
SKINFORGE_TRUSTED_PROXY_HOPS: z.coerce.number().int().min(0).max(10).default(1),
SKINFORGE_MAINTENANCE_MODE: strictBoolean("false"),
SKINFORGE_JOB_RUNNER_ENABLED: strictBoolean("true"),
});
const configSchema = baseSchema
.superRefine((c, ctx) => {
if (
c.SKINFORGE_STORAGE_DRIVER === "s3" && !(
c.SKINFORGE_S3_ENDPOINT && c.SKINFORGE_S3_BUCKET &&
c.SKINFORGE_S3_ACCESS_KEY_ID && c.SKINFORGE_S3_SECRET_ACCESS_KEY &&
c.SKINFORGE_S3_PUBLIC_BASE_URL
)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["SKINFORGE_S3_ENDPOINT"],
message: "SKINFORGE_S3_* variables are required when SKINFORGE_STORAGE_DRIVER=s3",
});
}
})
.superRefine((c, ctx) => {
if (c.SKINFORGE_METRICS_ENABLED && !c.SKINFORGE_METRICS_TOKEN) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["SKINFORGE_METRICS_TOKEN"],
message: "SKINFORGE_METRICS_TOKEN is required when SKINFORGE_METRICS_ENABLED=true",
});
}
})
.refine(
(c) => c.SKINFORGE_ENV === "development" || c.SKINFORGE_ENV === "test" ||
c.SKINFORGE_PUBLIC_BASE_URL.startsWith("https://"),
{
message: "SKINFORGE_PUBLIC_BASE_URL must use https in staging and production",
path: ["SKINFORGE_PUBLIC_BASE_URL"],
},
);
export type Config = z.infer<typeof configSchema>;
export function loadConfig(env: Record<string, string | undefined> = Deno.env.toObject()): Config {
const result = configSchema.safeParse(env);
if (!result.success) {
const lines = result.error.issues.map((issue) =>
` - ${issue.path.join(".")}: ${issue.message}`
);
console.error(
`Configuration validation failed with ${lines.length} error(s):\n${lines.join("\n")}\n` +
`Fix the listed environment variables and restart.`,
);
Deno.exit(1);
}
return result.data;
}
// The CLI process shares every rule above but runs a smaller worker pool by default; it overrides
// SKINFORGE_DB_POOL_SIZE in code rather than via a second schema.
export function loadCliConfig(env: Record<string, string | undefined> = Deno.env.toObject()): Config {
const config = loadConfig(env);
return {
...config,
SKINFORGE_DB_POOL_SIZE: env.SKINFORGE_DB_POOL_SIZE
? config.SKINFORGE_DB_POOL_SIZE
: 4,
};
}Exact error output format (printed to stderr, process exits with code 1):
Configuration validation failed with 2 error(s):
- SKINFORGE_DATABASE_URL: Invalid input: expected string, received undefined
- SKINFORGE_ADMIN_SESSION_SECRET: must be base64 encoding at least 32 bytes
Fix the listed environment variables and restart.Message text after the variable name comes from Zod (Section 4) or from a superRefine/refine
custom message above; tests assert on the variable names and the exit code, never on Zod's own
wording, since upgrading Zod can change its built-in message text without changing behavior.
loadConfig() (or loadCliConfig() for the CLI process) is called exactly once, at the top of
main.ts and cli/main.ts, before any other module that depends on configuration is imported
side-effectfully; the resulting Config object is passed explicitly through the application's
dependency graph (constructor/factory parameters), never read again from Deno.env deeper in the
call stack — this makes every module's configuration dependency visible in its function signature and
keeps loadConfig the single point of truth.
24.4 Per-environment example files #
.env.example (development, committed to the repository):
SKINFORGE_ENV=development
SKINFORGE_PUBLIC_BASE_URL=http://localhost:8000
SKINFORGE_PORT=8000
SKINFORGE_DATABASE_URL=postgres://skinforge:devpassword@localhost:5432/skinforge_dev
SKINFORGE_DB_POOL_SIZE=5
SKINFORGE_STORAGE_DRIVER=fs
SKINFORGE_STORAGE_FS_ROOT=/tmp/skinforge-dev/storage
SKINFORGE_ADMIN_SESSION_SECRET=ZGV2LW9ubHktc2VjcmV0LW5vdC1mb3ItcHJvZHVjdGlvbi11c2UtcGxlYXNlLXJvdGF0ZS10aGlzLXZhbHVl
SKINFORGE_ADMIN_SESSION_TTL_HOURS=12
SKINFORGE_ADMIN_TOTP_ISSUER=SkinForge (dev)
SKINFORGE_RENDER_CACHE_DIR=/tmp/skinforge-dev/render-cache
SKINFORGE_RENDER_MAX_CONCURRENCY=2
SKINFORGE_RENDER_TIMEOUT_MS=8000
SKINFORGE_OG_ENABLED=true
SKINFORGE_OG_TIMEOUT_MS=3000
SKINFORGE_RATE_LIMIT_API_PER_MINUTE=1000
SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE=1000
SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR=1000
SKINFORGE_LOG_LEVEL=debug
SKINFORGE_METRICS_ENABLED=false
SKINFORGE_IMPORT_WORKDIR=/tmp/skinforge-dev/import-work
SKINFORGE_IMPORT_MAX_UPLOAD_MB=2048
SKINFORGE_CONTACT_EMAIL=dev@example.com
SKINFORGE_TRUSTED_PROXY_HOPS=0
SKINFORGE_MAINTENANCE_MODE=false
SKINFORGE_JOB_RUNNER_ENABLED=true.env.staging.example:
SKINFORGE_ENV=staging
SKINFORGE_PUBLIC_BASE_URL=https://staging.skinforge.example
SKINFORGE_PORT=8000
SKINFORGE_DATABASE_URL=postgres://skinforge:__GENERATED__@postgres:5432/skinforge_staging
SKINFORGE_DB_POOL_SIZE=10
SKINFORGE_STORAGE_DRIVER=fs
SKINFORGE_STORAGE_FS_ROOT=/data/storage
SKINFORGE_ADMIN_SESSION_SECRET=__GENERATED__
SKINFORGE_ADMIN_SESSION_TTL_HOURS=12
SKINFORGE_ADMIN_TOTP_ISSUER=SkinForge (staging)
SKINFORGE_RENDER_CACHE_DIR=/data/render-cache
SKINFORGE_RENDER_MAX_CONCURRENCY=4
SKINFORGE_RENDER_TIMEOUT_MS=8000
SKINFORGE_OG_ENABLED=true
SKINFORGE_OG_TIMEOUT_MS=3000
SKINFORGE_RATE_LIMIT_API_PER_MINUTE=600
SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE=300
SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR=150
SKINFORGE_LOG_LEVEL=info
SKINFORGE_METRICS_ENABLED=true
SKINFORGE_METRICS_TOKEN=__GENERATED__
SKINFORGE_IMPORT_WORKDIR=/data/import-work
SKINFORGE_IMPORT_MAX_UPLOAD_MB=2048
SKINFORGE_CONTACT_EMAIL=staff@skinforge.example
SKINFORGE_CDN_PURGE_URL=https://api.cdn.example/purge
SKINFORGE_CDN_PURGE_TOKEN=__GENERATED__
SKINFORGE_TRUSTED_PROXY_HOPS=1
SKINFORGE_MAINTENANCE_MODE=false
SKINFORGE_JOB_RUNNER_ENABLED=true
SKINFORGE_IMAGE_TAG=latest.env.production.example:
SKINFORGE_ENV=production
SKINFORGE_PUBLIC_BASE_URL=https://skinforge.example
SKINFORGE_PORT=8000
SKINFORGE_DATABASE_URL=postgres://skinforge:__GENERATED__@postgres:5432/skinforge
SKINFORGE_DB_POOL_SIZE=10
SKINFORGE_STORAGE_DRIVER=fs
SKINFORGE_STORAGE_FS_ROOT=/data/storage
SKINFORGE_ADMIN_SESSION_SECRET=__GENERATED__
SKINFORGE_ADMIN_SESSION_TTL_HOURS=12
SKINFORGE_ADMIN_TOTP_ISSUER=SkinForge
SKINFORGE_RENDER_CACHE_DIR=/data/render-cache
SKINFORGE_RENDER_MAX_CONCURRENCY=4
SKINFORGE_RENDER_TIMEOUT_MS=8000
SKINFORGE_OG_ENABLED=true
SKINFORGE_OG_TIMEOUT_MS=3000
SKINFORGE_RATE_LIMIT_API_PER_MINUTE=120
SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTE=60
SKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR=30
SKINFORGE_LOG_LEVEL=info
SKINFORGE_METRICS_ENABLED=true
SKINFORGE_METRICS_TOKEN=__GENERATED__
SKINFORGE_IMPORT_WORKDIR=/data/import-work
SKINFORGE_IMPORT_MAX_UPLOAD_MB=2048
SKINFORGE_DONATION_URL=https://ko-fi.com/example
SKINFORGE_CONTACT_EMAIL=contact@skinforge.example
SKINFORGE_CDN_PURGE_URL=https://api.cdn.example/purge
SKINFORGE_CDN_PURGE_TOKEN=__GENERATED__
SKINFORGE_TRUSTED_PROXY_HOPS=1
SKINFORGE_MAINTENANCE_MODE=false
SKINFORGE_JOB_RUNNER_ENABLED=true
SKINFORGE_IMAGE_TAG=2026.09.1Every __GENERATED__ placeholder must be replaced using the commands in Section 24.5 before the file
is used; committing a real .env.* file (as opposed to the .example templates above, which contain
no real secrets) is prevented by .gitignore.
24.5 Secret management #
| Secret | Generation command | Rotation procedure | Blast radius if leaked |
|---|---|---|---|
SKINFORGE_DATABASE_URL password component |
openssl rand -base64 32 |
Update the Postgres role password (ALTER ROLE skinforge WITH PASSWORD '...'), update .env and secrets/postgres_password.txt, restart postgres then app |
Full database read/write access |
SKINFORGE_ADMIN_SESSION_SECRET |
openssl rand -base64 48 |
Update .env, restart app. This invalidates every outstanding pagination cursor (Section 16.5.1) and every CSRF token (Section 20.5), both of which are derived from this secret. It does not invalidate sessions — session tokens are random and hash-referenced (Section 17.1), not derived from this secret; to invalidate sessions run UPDATE admin_sessions SET revoked_at = now() WHERE revoked_at IS NULL; |
Ability to forge pagination cursors and CSRF tokens |
SKINFORGE_METRICS_TOKEN |
openssl rand -base64 32 |
Update .env, restart app, update the scraping monitoring system's configured token |
Read access to internal metrics (traffic patterns, queue depths — not sensitive data, but useful for an attacker planning abuse) |
SKINFORGE_S3_SECRET_ACCESS_KEY |
Generated by the S3 provider's console/CLI, not locally | Rotate via the provider's console (issue a new key pair, update .env, restart app, then revoke the old key pair once confirmed working) |
Read/write access to object storage (rendered images are not sensitive, but write access could allow defacement) |
SKINFORGE_CDN_PURGE_TOKEN |
Generated by the CDN provider's console | Rotate via the provider's console, update .env, restart app |
Ability to trigger cache purges (denial-of-service-adjacent nuisance, not data exposure) |
Storage on the host: the .env file lives at the repository root on the VPS with filesystem
permissions 600, owned by the deploy user, never world- or group-readable.
secrets/postgres_password.txt (referenced by Compose's secrets: block, Section 23.2) has the same
permission discipline and is mounted into the postgres container via Docker's secrets mechanism
rather than passed as a plain environment variable, so it does not appear in docker inspect output
or process listings inside that container.
Rule against committing secrets: .gitignore excludes .env, .env.local, .env.staging,
.env.production, and secrets/* except a secrets/.gitkeep; only the three .example/template
files in Section 24.4 (which contain no real values) are committed. CI additionally runs a
secret-scanning step (part of the security job in Section 22.11's dependency audit stage) that fails
the build if a high-entropy string matching common secret patterns appears in a diff.
24.6 Configuration precedence, boot log line, and post-deploy verification #
Precedence: environment variables set in the container's runtime environment (via Compose's
env_file: ../.env directive, Section 23.2) are the only source of process configuration — there is
no layered override (no command-line flags overriding env vars, no separate "local overrides" file
beyond the developer's own .env in development). This single-source model is deliberate: Section
24.1's philosophy rules out multiple configuration sources so that "what value is actually in effect"
is always answerable by reading one file. The app_settings rows in 24.1 are not part of this
precedence chain and never shadow a variable: they are operational content with their own screen,
their own audit trail and their own cached accessor, and the two sets of keys are disjoint.
Boot log line: on successful validation, loadConfig()'s caller emits one app.started structured
log event (Section 21.2) containing a non-secret summary of effective configuration — every variable
in Section 24.2 marked Secret: no, with every Secret: yes variable replaced by a fixed
"[redacted]" marker (present/absent only, confirming it was set, never its value or even its
length):
{
"timestamp": "2026-09-03T12:00:01.234Z",
"level": "info",
"event": "app.started",
"version": "2026.09.1",
"env": "production",
"effectiveConfigSummary": {
"SKINFORGE_PORT": 8000,
"SKINFORGE_STORAGE_DRIVER": "fs",
"SKINFORGE_RENDER_MAX_CONCURRENCY": 4,
"SKINFORGE_RATE_LIMIT_API_PER_MINUTE": 120,
"SKINFORGE_LOG_LEVEL": "info",
"SKINFORGE_METRICS_ENABLED": true,
"SKINFORGE_MAINTENANCE_MODE": false,
"SKINFORGE_ADMIN_SESSION_SECRET": "[redacted]",
"SKINFORGE_DATABASE_URL": "[redacted]"
}
}Verifying configuration after deploy: the operator runs docker compose logs app --tail 50 | grep app.started immediately after a deploy and visually confirms the printed summary matches the intended
environment (correct env, correct SKINFORGE_STORAGE_DRIVER, correct rate limits per Section 23.3's
per-environment table) — this is included as a step in the smoke test script (Section 23.5).
Additionally, GET /health/ready (Section 21.7) confirms the runtime dependencies configuration resolved to
are actually reachable, which catches a syntactically valid but pointed-at-the-wrong-place
SKINFORGE_DATABASE_URL that boot-time schema validation alone cannot catch (schema validation checks
shape, not reachability).
24.7 Feature flags via environment #
Feature flags are plain environment variables, per Section 4.6, which rules out a separate runtime flag service. Each flag's effect:
| Flag | When false/disabled |
When true/enabled (default) |
|---|---|---|
SKINFORGE_OG_ENABLED |
/og/d/:code.png returns a static, generic placeholder OG image instead of a per-design generated one (Section 14); no satori/resvg work is performed |
Full per-design OG generation per Section 14 |
SKINFORGE_JOB_RUNNER_ENABLED |
The in-process job runner (Section 4.7, Section 19.8) does not claim or process jobs from the jobs table; jobs still enqueue normally but sit queued — used to pause background work during a maintenance operation without stopping the whole app |
Jobs are claimed and processed continuously |
SKINFORGE_MAINTENANCE_MODE |
Normal operation | Full maintenance-mode behavior per Section 23.9 |
SKINFORGE_METRICS_ENABLED |
GET /metrics returns 404; no metrics are collected in memory at all (not just hidden — the registry itself is a no-op), avoiding any overhead in environments that do not want it (e.g. a lightweight local development run) |
Full metrics collection and exposition per Section 21.3 |
Every flag defaults to the production-safe, fully-enabled behavior except SKINFORGE_MAINTENANCE_MODE
(defaults off, obviously) — the philosophy is that disabling a feature is always an explicit,
deliberate operator action, never an accidental default.
25. Error Catalog & Edge Case Register #
25.1 Error taxonomy #
Every error code has the shape SF- followed by exactly 4 digits, grouped into family ranges by
leading digit:
| Range | Family |
|---|---|
| 1000-1999 | Validation |
| 2000-2999 | Not found / gone |
| 3000-3999 | Rate limit |
| 4000-4999 | Auth |
| 5000-5999 | Render |
| 6000-6999 | Import |
| 9000-9999 | Internal |
The leading digit is the only part of a code with reserved meaning; the remaining three digits are
assigned sequentially within a family as errors are added, with no further sub-grouping implied by
digit position. A client integration may safely branch on the leading digit alone (for example,
treating any 4xxx code as an auth problem worth redirecting to /admin for) without maintaining its
own copy of the full registry, while still being able to display the exact registry message for any
code it does recognize.
The JSON error envelope shape ({"error": {"code", "message", "field", "details", "requestId"}}) is
defined in Section 16.4; this section owns only the registry of codes and messages, never the
envelope structure itself.
Section 25.2 is the sole registry of SF- codes in this document: no other section may use a code
with a meaning or HTTP status different from the one assigned to it below, and no other section may
mint a new code on its own — a section that needs a new condition covered points here for the number
to be added. SF-x999 values (e.g. SF-1999) are family upper bounds, not assignable codes; every
assignable code is listed in the registry below.
A code may also be reserved: listed in the registry, permanently unassignable, and used by no section. A reserved code is either a number that was minted for a mechanism this version does not specify, or one whose condition turned out to be covered by another code. Reserved rows stay in the table rather than being deleted, so the numeric sequence never implies that a missing number is free to reuse.
Codes are permanent once shipped: a code is never reassigned to a different meaning, and a retired error path's code is never reused for something unrelated — client integrations and support history may reference a code long after the situation that produced it has changed shape. When a new error condition is added, it receives the next unused number within its family's range rather than reusing a gap left by a removed one, so the numeric sequence within a family also serves as a rough chronological record of when each check was introduced.
25.2 The error registry #
| Code | HTTP status | Internal meaning | User-facing message | Retryable | Log event |
|---|---|---|---|---|---|
| SF-1000 | 400 | Generic schema validation failure | "That request isn't valid. Check the highlighted field and try again." | no | http.request (warn) |
| SF-1001 | 400 | Missing required field | "This field is required." | no | http.request (warn) |
| SF-1002 | 400 | Field exceeds max length | "That value is too long." | no | http.request (warn) |
| SF-1003 | 400 | Invalid body code (not m/f) |
"Choose a valid body type." | no | http.request (warn) |
| SF-1004 | 400 | Invalid hue index format | "That hue value isn't valid." | no | http.request (warn) |
| SF-1005 | 400 | Hue index outside 0–65535 | "That hue is out of range." | no | http.request (warn) |
| SF-1006 | 400 | Invalid asset key format | "That asset reference isn't valid." | no | http.request (warn) |
| SF-1007 | 400 | Invalid slot key | "That slot isn't recognized." | no | http.request (warn) |
| SF-1008 | 400 | Design JSON contains unknown top-level key | "That design data isn't valid." | no | http.request (warn) |
| SF-1009 | 400 | Design JSON slot appears more than once | "Each slot can only be set once." | no | http.request (warn) |
| SF-1010 | 400 | Malformed pagination cursor | "That page reference isn't valid; start from the first page." | no | http.request (warn) |
| SF-1012 | 400 | Invalid scale parameter (not 1/2/3) | "That image size isn't supported." | no | http.request (warn) |
| SF-1013 | 400 | Invalid format parameter (not webp/png) | "That image format isn't supported." | no | http.request (warn) |
| SF-1014 | 400 | Search query exceeds 200 characters | "Shorten your search." | no | http.request (warn) |
| SF-1015 | 415 | Unsupported request content type | "That request format isn't supported." | no | http.request (warn) |
| SF-1016 | 406 | No acceptable response representation for the request's Accept header — JSON API surfaces only; the image namespaces /render/* and /og/* never negotiate on Accept and can never return this code, because the extension in the path is authoritative (Sections 8.8, 19.6) |
"We can't return that format." | no | http.request (warn) |
| SF-1017 | 400 | Gender-restricted slot/asset mismatch (e.g. beard on female body) | "That item isn't available for the selected body." | no | http.request (warn) |
| SF-1019 | 422 | Well-formed but semantically invalid combination (asset does not belong to declared slot) | "That item doesn't belong in that slot." | no | http.request (warn) |
| SF-1020 | 400 | Search query has unterminated quote | "Check the quotes in your search." | no | http.request (warn) |
| SF-1021 | 400 | Query string has too many parameters (> 200) | "That link has too many parameters." | no | http.request (warn) |
| SF-2000 | 404 | Design short code not found, or the supplied code is malformed (wrong length/alphabet) — the two are deliberately indistinguishable so a client probing for valid codes cannot tell "malformed" from "not found" (Section 13.4.1) | "We couldn't find that design. The link may be mistyped." | no | design.resolved (info, cacheHit: false, found: false) |
| SF-2001 | 404 | Asset key not found | "That item isn't in our catalog." | no | http.request (warn) |
| SF-2002 | 404 | Hue index not found | "That hue isn't in our catalog." | no | http.request (warn) |
| SF-2004 | 404 | Slot key not found in registry | "That slot isn't recognized." | no | http.request (warn) |
| SF-2005 | 410 | Design removed via takedown | "This design is no longer available." | no | design.takedown_served (info) |
| SF-2006 | 404 | Route not found | "That page doesn't exist." | no | http.request (warn) |
| SF-2007 | 404 | Asset exists but has no row in the requested build (not merely retired — a retired asset still resolves normally from the build it belongs to) | "That item is no longer available in this build." | no | http.request (warn) |
| SF-2008 | 404 | Import run not found (admin) | "That import run doesn't exist." | no | http.request (warn) |
| SF-2009 | 404 | Build not found (admin) | "That build doesn't exist." | no | http.request (warn) |
| SF-2010 | 404 | Extraction candidate not found (admin) | "That candidate doesn't exist, it may already be resolved." | no | http.request (warn) |
| SF-3000 | 429 | Public API rate limit exceeded | "You're sending requests too quickly. Please wait a moment." | yes | rate_limit.exceeded (warn) |
| SF-3001 | 429 | Render rate limit exceeded | "Too many image requests right now. Please wait a moment." | yes | rate_limit.exceeded (warn) |
| SF-3002 | 429 | Design creation rate limit exceeded | "You've created a lot of designs recently. Please wait before creating another." | yes | rate_limit.exceeded (warn) |
| SF-3003 | 429 | Admin login rate limit exceeded — the single lockout code, covering password and TOTP failures alike, since Section 17.2.3 counts both in one per-account budget (5 combined failures in a rolling 15 minutes → 15-minute cooldown) | "Too many login attempts. Try again in 15 minutes." | yes | admin.login_failed (warn) |
| SF-3004 | — | Reserved (was a per-IP login limit; Section 17.2.3 rules out per-IP login lockout, because it would let an attacker lock a legitimate admin out from a shared address) | — | n/a | — |
| SF-3005 | — | Reserved (was a separate TOTP-attempt limit; TOTP failures count against the same SF-3003 budget) | — | n/a | — |
| SF-4000 | 401 | No admin session present | "Please sign in." | no | http.request (warn) |
| SF-4001 | 401 | Admin session expired or invalid | "Your session has expired. Please sign in again." | no | admin.session_revoked (info) |
| SF-4002 | 401 | Invalid email/password on login | "That email or password isn't correct." | no | admin.login_failed (warn) |
| SF-4003 | 403 | CSRF token missing or mismatched | "Your session looks out of date. Please refresh and try again." | no | http.request (warn) |
| SF-4004 | 401 | Invalid TOTP code. The code is distinct so logs and support can tell the second factor from the first, but the message deliberately matches SF-4002's: an attacker who reaches the TOTP step already knows the password was right, and a distinct message would confirm it. Section 17.2.1's login flow returns SF-4004 with this generic text | "That email or password isn't correct." | no | admin.login_failed (warn) |
| SF-4005 | 403 | Permission denied — session valid but the acting role lacks the permission this action requires (Section 17.3) | "You don't have permission to do that." | no | http.request (warn) |
| SF-4006 | — | Reserved (was a second, 401-status spelling of the lockout condition SF-3003 already covers at 429; one meaning, one status, so this number is retired rather than reused) | — | n/a | — |
| SF-4007 | 401 | Recovery code invalid or already used | "That recovery code isn't valid." | no | admin.login_failed (warn) |
| SF-4008 | — | Reserved (step-up re-authentication; no route in Section 17 requires a fresh session, so nothing raises it in v1) | — | n/a | — |
| SF-4010 | — | Reserved (TOTP replay detection; Section 17.2 does not store consumed codes in v1, so nothing raises it) | — | n/a | — |
| SF-5000 | 500 | Referenced sprite missing for the requested asset/body combination — a server-side data fault, logged and served from a never-cached placeholder, not a client error | "We couldn't render that combination." | no | render.failed (error) |
| SF-5001 | 500 | Encoder error (WASM PNG/WebP encode failed) | "We couldn't generate that image. Please try again." | yes | render.failed (error) |
| SF-5002 | 504 | Render timed out | "That image is taking too long to generate. Please try again." | yes | render.failed (error) |
| SF-5003 | 502 | Object storage read/write error during render | "We couldn't save or load that image right now." | yes | render.failed (error) |
| SF-5004 | 503 | Render queue full (back-pressure) | "We're generating a lot of images right now. Please try again shortly." | yes | render.queued (warn) |
| SF-5005 | 500 | Hue table missing or corrupted for a hue that should exist | "We couldn't apply that hue right now." | yes | render.failed (error) |
| SF-5006 | 500 | Composite canvas geometry mismatch (sprite offset outside canvas bounds) | "We couldn't render that combination." | no | render.failed (error) |
| SF-5007 | 500 | OG image layout generation failed | "We couldn't generate a preview image for this design." | yes | og.generated (error variant) |
| SF-5008 | 500 | Corrupt cache entry detected on read (render or OG object storage entry fails integrity check) | "We couldn't load that image. Please try again." | yes | render.failed (error) |
| SF-6000 | 422 | Unreadable source file during extraction | "This file couldn't be read; it may be corrupted." | no | import.candidate_flagged (warn) |
| SF-6001 | 422 | Unknown container format | "This file's format isn't recognized." | no | import.candidate_flagged (warn) |
| SF-6002 | 422 | Checksum mismatch on a previously recorded source file | "This file has changed unexpectedly since the last import." | no | import.candidate_flagged (warn) |
| SF-6003 | — (CLI/admin only; not an HTTP response — SF-6xxx codes are import-pipeline outcomes recorded on import_runs, never returned to a public HTTP client) |
Partial extraction (run stopped before completion, resumable) | "This import is incomplete and can be resumed." | yes | import.run_completed (warn, status: cancelled) |
| SF-6004 | 409 | Publish conflict (two staff publishing concurrently) | "Someone else just published a build. Please refresh and try again." | yes | build.published (warn, conflict variant) |
| SF-6005 | 422 | Decode timeout on a single file during extraction | "This file took too long to process and was skipped." | no | import.candidate_flagged (warn) |
| SF-6006 | 422 | File exceeds the per-file size cap | "This file is too large to process." | no | import.candidate_flagged (warn) |
| SF-6007 | 400 | Import work directory path escapes the configured root | "That file path isn't allowed." | no | http.request (error) |
| SF-6008 | 409 | Asset key collision across two different source sprites in the same build | "Two items in this import share the same identifier and need manual review." | no | import.candidate_flagged (warn) |
| SF-6009 | 422 | Build published with zero approved assets | "This build has no approved assets and cannot be published." | no | http.request (warn) |
| SF-6010 | 422 | Candidate assigned to a slot incompatible with its detected gender restriction | "This item can't be assigned to that slot." | no | http.request (warn) |
| SF-6012 | 409 | No published build exists yet (pre-launch, or every build has been retired) | "The catalog isn't available yet. Please check back soon." | yes | http.request (warn) |
| SF-9000 | 500 | Unhandled exception | "Something went wrong on our end. Please try again." | yes | http.request (error) |
| SF-9001 | 500 | Database statement timeout or connection failure | "We're having trouble reaching our database. Please try again shortly." | yes | http.request (error) |
| SF-9002 | 500 | CDN purge failed after all retries | (not user-facing; surfaces only in /admin/dashboard) |
n/a | admin.publish.purge_failed (error) |
| SF-9003 | 503 | Maintenance mode active | "SkinForge is temporarily down for maintenance. Please check back soon." | yes | http.request (info) |
| SF-9004 | 500 | Job runner exhausted retries for a critical job | (surfaces to admin dashboard, not public) | n/a | job.exhausted (error) |
| SF-9005 | 502 | Object storage unreachable (non-render path, e.g. admin asset browsing) | "We couldn't reach storage right now. Please try again." | yes | http.request (error) |
| SF-9006 | 500 | Configuration inconsistency detected at runtime (should be caught at boot; defensive fallback) | "Something went wrong on our end. Please try again." | yes | http.request (error) |
| SF-9007 | 500 | Short-code salt space exhausted — every salt increment for one canonical design collided (Section 13.4.2). A detected, handled condition, which is why it is not SF-9000 ("unhandled exception") | "Something went wrong on our end. Please try again." | no | design.salt_exhausted (error) |
The table above assigns 70 codes and reserves 5 more (SF-3004, SF-3005, SF-4006, SF-4008,
SF-4010), which are listed so their numbers are never reused and are raised by nothing. No section
is required to reach a code count; the registry is exactly as large as the set of conditions the rest
of this document actually distinguishes.
25.3 Error presentation across surfaces #
HTML pages: a validation or not-found error on a page-rendering route (e.g. /d/:code with an
unknown code) renders a full styled HTML error page (using the site's normal layout shell, Section 10)
with the user-facing message from the registry, the HTTP status set correctly, and no JSON in the
body. A 500-family error on an HTML route renders a generic "something went wrong" page — never a
stack trace, never the internal errorMessage, regardless of environment (development mode uses a
separate local-only debug overlay that never ships in the production build).
JSON API: every error returns the envelope from Section 16.4:
{"error": {"code": "SF-XXXX", "message": "<user-facing message>", "field": "...", "details": [], "requestId": "..."}}, with Content-Type: application/json and the HTTP status from the registry.
field is populated only for validation-family (1000-1999) errors where the error is attributable to
one input; omitted (not null, absent from the object) otherwise.
Image endpoints: /render/... and /og/... cannot return JSON — a client requesting an image
expects image bytes or a redirect, and many consumers (an <img> tag, a social-media unfurl bot) do
not parse JSON error bodies at all. Instead:
| Situation | Response |
|---|---|
| Missing sprite / invalid combination (SF-5000, SF-5006) | 500, Content-Type: image/png, body is a pre-generated static "image unavailable" placeholder PNG at the requested scale, Cache-Control: no-store (never cached — both are server-side data faults, logged, and a future retry after the underlying data is fixed should not be shadowed by a cached failure) |
| Render still generating (the single claim/poll path owned by Section 8.10; Section 19.8 owns the concurrency and timeout numbers) | 200, Content-Type: image/png, body is a lightweight "generating…" placeholder at the requested scale, Cache-Control: no-store, Retry-After: 1, X-SF-Render-Pending: 1 — this keeps a plain <img>/<picture> tag (Section 13.8, 19.6) working, since an <img> receiving a bodyless 202 renders the browser's broken-image icon and never retries. A JSON/API client that prefers to poll explicitly opts in by sending Accept: application/json, in which case a 202 Accepted, Retry-After: 1, empty-body response is returned instead |
| Render queue full (SF-5004) | 503, Content-Type: image/png, body is the same static placeholder, Retry-After: 2, Cache-Control: no-store |
| Rate limited (SF-3001) | 429, Content-Type: image/png, static placeholder, Retry-After per the limit window |
| Malformed request (bad scale/format/code) | 400, Content-Type: image/png, static placeholder |
| Takedown (SF-2005) | 410, Content-Type: image/png, a distinct static "removed" placeholder PNG (visually distinguishable from the generic "unavailable" placeholder so a crawler screenshot or manual check can tell the two apart), Cache-Control: public, max-age=86400 (this one is intentionally cacheable, since a takedown is permanent and re-requesting it repeatedly serves no purpose) |
| Internal error (SF-9xxx) | 500, Content-Type: image/png, generic placeholder, Cache-Control: no-store |
Every image-endpoint error additionally sets a custom response header X-SF-Error-Code: SF-XXXX so
that automated monitoring and browser devtools debugging can identify the error code without parsing
image bytes, even though the body itself is never JSON.
25.4 Edge case register #
| # | Situation | Expected behavior | Owning section |
|---|---|---|---|
| 1 | A design whose every slot references a since-retired asset | Renders normally using the archived build's art (designs.build_id pins the build); retirement never affects existing designs |
13, 9 |
| 2 | A hue that lost its colour table (corrupted/missing hues row data) |
The composite succeeds: the affected layer is drawn unhued (as-drawn pixels, exactly as hue 0 would render it) and the rest of the design renders normally, so one bad hue never blanks a whole design. SF-5005 is logged, never returned — the request is a 200 carrying a correct image with one wrong-coloured layer. The swatch is hidden from /hues and the picker until repaired |
25.2, 8 |
| 3 | A build published with zero assets | Rejected at publish time with SF-6009; publish workflow blocks the action in the admin UI before the request is even sent | 25.2, 17, 9 |
| 4 | Two staff publishing at once | The second publish attempt's transaction detects the conflict (optimistic check on the build's current status) and returns SF-6004; the admin UI prompts a refresh | 25.2, 17 |
| 5 | A permalink shared before its render finished | The HTML page renders immediately (design data exists on create); the image uses the async polling path (25.3) until the render completes, typically sub-second, so the visitor sees a brief loading state, never a broken image | 19.8, 12 |
| 6 | A crawler hitting an OG image for a design created one second ago | Synchronous render-on-first-request path (19.8) generates it within the render budget (19.1); if it somehow exceeds the crawler's timeout, the crawler retries and the second request hits the now-warm cache | 14, 19.8 |
| 7 | Clock skew on cursors | Cursors are opaque and HMAC-signed over the sort-key payload, which may include a timestamp — the signature is what makes forgery impossible, not the payload's content (20.2), so clock skew between client and server has no effect on cursor validity | 20.2, 16 |
| 8 | A client file that is a symlink (during extraction) | Symlinks are resolved and the resolved target is re-checked against the permitted read scope (20.6); a symlink escaping the scope is rejected, never silently followed outside the sandbox | 20.6, 7 |
| 9 | An asset key that URL-decodes to a path traversal string | Rejected by the asset-key validation pattern (20.2) before any filesystem or lookup logic runs; the pattern has no /, \, or % in its allowed character set, so a decoded traversal sequence cannot match it |
20.2, 20.1 |
| 10 | A design URL with 500 query parameters | Rejected with SF-1021 before parsing individual parameters, since the 200-parameter cap is checked first as a cheap guard against parameter-bombing | 25.2, 20.2 |
| 11 | A design created with a skin hue but zero optional slots (naked base body) | Valid; renders the body layer only, per Section 3's "one required body slot" rule | 3, 13 |
| 12 | Two different designs canonicalizing to the same short-code before salting | Handled by the collision/salt-increment rule in Section 13; the second design gets a salted re-hash and a distinct code | 13 |
| 13 | A request for /render/d/:code@4.webp (unsupported scale) |
Rejected with SF-1012 — only scales 1/2/3 are valid | 25.2, 8 |
| 14 | A catalog page requested for a slot key that does not exist | 404 with SF-2004 | 25.2, 15 |
| 15 | An admin publishing a build while an import for the next build is still running | Allowed — imports and publishes operate on independent game_builds rows; the running import does not lock the publish path |
9 |
| 16 | A design referencing a slot/asset pair where the asset belongs to a different slot than declared | Rejected at creation with SF-1019; never silently coerced to the asset's actual slot | 25.2, 20.2 |
| 17 | A visitor's browser has JavaScript disabled entirely | /d/:code fully renders and functions (19.9); / shows a functional but non-interactive fallback with a message directing to a JS-enabled browser for full editing, since live compositing requires client-side canvas work |
19.9, 11 |
| 18 | An extraction candidate that cannot be confidently classified into any slot | Flagged needs_operator_input and surfaced in /admin/candidates, never silently dropped or guessed |
7, 17 |
| 19 | A hue index request just above the discovered Outlands custom range's actual upper bound | 404 with SF-2002 — the numeric format may pass validation (0-65535) but existence is checked against the live hues table for the design's pinned build_id, not just the range |
25.2, 3 |
| 20 | An admin session cookie presented after the admin user was deleted via CLI | Session lookup joins to admin_users; a session for a deleted user is treated as invalid, returns SF-4001 |
20.5, 6 |
| 21 | A design create request during active maintenance mode | Returns SF-9003 (503) immediately, before touching the database | 23.9, 25.2 |
| 22 | A render request for an asset/hue combination that is individually valid but whose composite canvas position would place the sprite fully outside the 260×330 canvas | SF-5006; treated as a data integrity issue in the source asset's recorded offset, flagged for operator review, not served as a silently cropped or garbled image | 25.2, 8 |
| 23 | A visitor rapidly toggling many hues in the live designer (client-side) | No server impact — the client-side compositor (8.12) handles this entirely in-browser using cached sprite/hue data already fetched; no additional requests fire per toggle beyond the initial asset/hue data fetch | 8.12, 19.9 |
| 24 | A design JSON with hue: 0 explicitly set on a slot that has no hue-able variation |
Accepted; hue: 0 always means "as-drawn" regardless of whether the asset has meaningful hue variation (13) |
13 |
| 25 | An OG crawler bot identified by user-agent making requests far above the human rate limit | Bot requests are excluded from analytics (21.6) but not exempted from rate limiting — if a "bot" exceeds the render rate limit it still receives SF-3001, since OG image requests are typically one-per-crawl and should never approach the limit legitimately | 21.6, 20.1 |
| 26 | A takedown request submitted for a design that was already actioned | Idempotent — the second action is a no-op that still returns success, and the takedown_requests table records both requests for audit purposes |
20.8, 17 |
| 27 | An admin attempting to reverse a takedown | Supported via the admin console reversal workflow (17); the design's takedown flag is cleared, the permalink resumes serving normally, and the action is audit-logged | 20.8, 17 |
| 28 | A design created against a build that is later fully deleted (hypothetically) | Cannot happen — game_builds rows are never hard-deleted, only retired, consistent with the soft-delete convention (Section 3); a design's build_id foreign key is always resolvable |
3, 6 |
| 29 | Simultaneous requests for the same never-before-rendered design/scale/format triggering duplicate render work | The render job claim uses FOR UPDATE SKIP LOCKED (19.8); a second concurrent request for the identical cache key either joins the in-flight synchronous render (in-process de-duplication keyed by the render hash) or, if already queued, does not enqueue a duplicate job |
19.8, 8 |
| 30 | A visitor manually editing the query-string transient design state to reference a retired asset | The designer UI resolves it at load time against the live catalog; a retired asset is dropped from that slot with a visible "no longer available" indicator, never a silent substitution and never a crash | 13, 11 |
| 31 | An import run interrupted by a process crash mid-way | Recorded as partial (SF-6003) with the last completed stage recorded; re-running the import resumes from the next unprocessed file rather than restarting from D1, using the per-file checksum/fingerprint already recorded for completed items |
7, 9 |
| 32 | A hue table with fewer than the expected 32 entries for a group | Rejected at import validation time as malformed (SF-6000-family), never imported partially | 22.2, 7 |
| 33 | An asset with images at scale 1 and 3 but missing scale 2 | Import validation requires all three scales (1/2/3) to be generated before an asset can be approved (17); an incomplete asset cannot be published | 9, 8 |
| 34 | A public visitor requesting /api/v1/admin/* directly |
404, not 401/403 — the admin API is not under the public API namespace at all, it lives at /admin/api/* (Section 17.17), so an unauthenticated probe against the /api/v1/admin/* path gets the same "route not found" response as any nonexistent path, avoiding confirming the real namespace's existence |
16, 20.1 |
| 35 | A robots.txt request during maintenance mode |
Still served normally (static, cheap, does not hit the database) — maintenance mode does not need to hide crawling instructions | 23.9, 10 |
| 36 | A design's canonical JSON containing a slot hue value the asset does not actually support hueing for (Hueable: yes/no is per-slot per Section 3's table, but an individual asset could theoretically be marked non-hueable) | Rejected at creation with SF-1019-family validation against the asset's actual hueable flag, not just the slot's general hueable-ness | 25.2, 6 |
| 37 | A request with an Accept-Encoding that excludes both gzip and br |
Caddy/the app serve uncompressed; this is a graceful degradation, not an error | 23.2 |
| 38 | An admin TOTP enrollment interrupted before confirmation (browser closed mid-setup) | The pending TOTP secret is not activated until a valid code is confirmed; the admin user remains without TOTP (and therefore cannot complete login, per the mandatory-TOTP rule) until enrollment is retried from /admin |
17, 20.5 |
| 39 | A visitor bookmarking a /?... transient-state URL with a very old, now-retired combination of assets |
Same resolution as edge case 30 — retired assets in the transient state are dropped gracefully at load | 13, 11 |
| 40 | An attempt to create a design with body missing from the JSON |
Rejected with SF-1001 — body is always required per Section 3 | 25.2, 13 |
| 41 | A slot swatch strip request (/render/s/...) for a slot with zero approved assets |
Returns a valid but empty strip image (not an error) — an empty catalog for one slot is a legitimate transient state during early catalog population | 8, 15 |
| 42 | Two extraction candidates that decode to byte-identical sprite content but different source offsets | Both are kept as distinct candidates during review (provenance differs) but the importer's fingerprinting (sha256 of decoded pixels) flags them as likely duplicates for the reviewer's attention, never auto-merged silently | 7, 9 |
| 43 | A request for a render at scale 1 immediately followed by scale 3 for the same never-cached design | Each scale is an independent cache key (8.4) and independent render; requesting one does not pre-warm the other, though both share decoded sprite data from the in-process LRU (19.2) so the second render is cheaper than the first | 8, 19.2 |
| 44 | An admin CSV/bulk action on candidates where one row in the batch is invalid | The entire batch is validated up front; any invalid row aborts the whole batch with the specific row and reason reported, never a partial apply that leaves catalog state inconsistent | 17, 20.2 |
| 45 | A visitor on a browser that supports WebP but explicitly requests .png via a download link |
Served PNG exactly as requested — explicit extension always wins over content negotiation (19.6) | 19.6, 8 |
| 46 | A design creation request that is a byte-for-byte duplicate of an existing design's canonical JSON | Returns the existing design's short code rather than creating a duplicate row — the INSERT ... ON CONFLICT DO NOTHING RETURNING pattern (19.7) falls through to a SELECT on conflict, giving idempotent creation |
19.7, 13 |
| 47 | An admin audit log query spanning a very large date range | Paginated per the standard cursor rules (Section 16); no unbounded query is ever issued, even from the admin console | 16, 20.5 |
| 48 | A CDN edge serving a stale HTML page past its stale-while-revalidate window due to an extended origin outage |
The CDN's own stale-if-error behavior (provider-dependent, documented as the expected fallback) may extend serving stale content further; this is accepted as better than a hard error during an origin outage and is not a defect | 19.3, 19.5 |
| 49 | A request to /hues/:hueIndex for hue 0 (unhued) |
Valid; renders a dedicated "unhued / as-drawn" detail page rather than a 404, since 0 is a legitimate, documented hue value | 3, 15 |
| 50 | An operator running the extractor against a directory containing zero recognizable UO files | D2 (Identify) classifies everything as unknown; the run completes with zero candidates and a clear "no recognizable client files found" summary, never a crash or a misleading partial success |
7 |
| 51 | A design short code that collides with a reserved word used elsewhere in routing (e.g. resembles catalog) |
Structurally impossible — permalinks live under the /d/ prefix (13), which is disjoint from every other top-level route (10), so no collision can occur regardless of the code's characters |
13, 10 |
| 52 | A visitor navigating to /catalog/:slotKey/:assetKey for an asset that exists but belongs to a different slot than the one in the URL |
404 with SF-2001 — the lookup is scoped to the (slotKey, assetKey) pair, not the asset key alone, so a mismatched slot is treated as not found rather than redirecting |
25.2, 15 |
| 53 | A backup restore drill run against a database with in-flight (uncommitted) transactions at dump time | pg_dump in custom format with the default consistent-snapshot behavior excludes uncommitted data automatically; the restored database reflects only committed state as of the dump's snapshot start |
23.6 |
| 54 | An admin user's TOTP device is lost | Recovery codes (admin_recovery_codes, single-use) provide login access; if those are also exhausted or lost, only CLI-based credential reset (23.7's compromised-account runbook, reused for this non-adversarial case) restores access — there is no self-service TOTP bypass |
20.5, 23.7 |
| 55 | A public API client sends Content-Type: application/xml on a JSON endpoint |
Rejected with SF-1015 (415) before body parsing is attempted — Content-Type is checked as part of request validation |
25.2, 16 |
| 56 | A render request during a period where SKINFORGE_JOB_RUNNER_ENABLED=false and the request requires the async queue path (scale 3, high concurrency) |
The synchronous first-attempt path (19.8) still works since it does not depend on the job runner; only already-enqueued jobs stay in queued status until the flag is re-enabled — a degraded but non-broken state, surfaced via skinforge_job_queue_depth growing |
24.7, 19.8 |
| 57 | A design with a slot referencing an asset key that is syntactically valid but was never actually imported | 404 with SF-2001 at creation time — asset existence is checked against the live catalog, not just key-format validity | 25.2, 6 |
| 58 | An attempt to publish the same build twice in a row with no changes | Allowed as a no-op-equivalent republish (re-runs the invalidation steps in 19.5 harmlessly); not blocked, since an operator might legitimately want to force a cache purge this way | 9, 19.5 |
| 59 | A crawler requesting /sitemap.xml during a period of very high catalog churn (mid-import, pre-publish) |
The sitemap reflects only the currently published build's catalog, never in-progress import data, so it is always internally consistent even if generated mid-import | 10, 9 |
| 60 | A visitor's request carrying an X-Forwarded-For header with more hops than SKINFORGE_TRUSTED_PROXY_HOPS |
Only the configured number of trusted hops is consulted from the right; extra, further-left entries (potentially client-forged) are ignored for both rate-limiting and logging purposes | 24.2, 20.1 |
| 61 | A design permalink page requested with an If-None-Match conditional header matching the current ETag |
Returns 304 Not Modified with no body, consistent with standard HTTP caching semantics layered on top of the Cache-Control values in Section 19.3 |
19.3, 16 |
| 62 | An admin bulk-retiring every asset in a slot, leaving that slot with zero active assets | Allowed (the slot itself is never deleted, only its assets), and edge case 41's empty-swatch-strip behavior applies to public rendering of that slot going forward | 17, 41 (this table) |
The register above is exhaustive for v1: every situation the rest of this document raises as "what happens if…" resolves to one of these rows or to a code in 25.2.
25.5 Retry and idempotency rules #
| Operation | Client retry guidance | Server-side idempotency mechanism |
|---|---|---|
Design creation (POST /api/v1/designs) |
Safe to retry on network failure/timeout | INSERT ... ON CONFLICT (short_code) DO NOTHING RETURNING, falling back to a SELECT for an identical canonical JSON (edge case 46); a retried identical submission always resolves to the same short code, never a duplicate |
Render request (any GET /render/...) |
Safe to retry always | Pure function of content-addressed inputs (19.4); retrying is indistinguishable from a fresh request and always converges on the same bytes |
Admin publish (POST /admin/builds/:id/publish) |
Retry only after confirming the prior attempt's outcome via the build's status | Guarded by the optimistic conflict check (edge case 4); a blind retry after a conflict response is safe (it will either succeed or report the same conflict, never corrupt state) |
| Extraction/import run | Safe to re-run the identical source directory at any time | Per-file fingerprinting (sha256 + offset) makes a re-run a no-op for unchanged files (22.9's idempotency test); only genuinely changed/new files produce new candidates |
| CDN purge call (19.5) | Retried automatically by the server (3 attempts, exponential backoff) | Purge is inherently idempotent — purging an already-purged path has no additional effect |
| Job runner claim/execute | Jobs retry automatically per their configured max-attempts (job-type-specific, defined alongside each job's registration) | FOR UPDATE SKIP LOCKED claiming (19.7) guarantees exactly one worker executes a given job attempt at a time; a job handler that fails mid-execution is retried from the start of its handler, so every job handler is written to be safely re-executable from scratch (e.g. a render job re-checks whether its target cache key already exists before doing the work again) |
| Rate-limited request that received SF-3000/3001/3002 | Retry after the duration in the Retry-After header |
Token-bucket refill (Section 4.7's architecture decision, Section 16.7's buckets) is naturally idempotent to retries — no special handling needed beyond honoring Retry-After |
| Takedown action | Retry-safe (edge case 26) | Actioning an already-actioned design is a no-op that still reports success |
25.6 User-facing copy principles for errors #
- Plain language. No error codes, stack traces, or technical jargon in any user-facing message
(the registry's
messagecolumn in 25.2 is exactly what a visitor sees; thecodeis present in the JSON envelope for API consumers and support correspondence, never surfaced as the primary text on an HTML page). - State what happened, in one short sentence. "We couldn't find that design" rather than a description of the lookup mechanism that failed.
- State what to do next, when there is a useful next step. "Check the highlighted field and try again," "Please wait a moment," "The link may be mistyped" — every message in the registry that has a meaningful recovery action states it; messages for internal errors (9xxx family) instead offer a generic "please try again" since no more specific guidance would be honest.
- Never blame the user. Messages describe the situation neutrally ("That value is too long") rather than accusatorially ("You entered a value that is too long"); this is a deliberate, consistent tone choice applied across every message in 25.2.
- Never leak internals. No message references table names, internal error classes, stack frames,
file paths, or the specific validation library in use. The
requestIdfield exists precisely so a visitor can report a problem referencing something traceable without the message itself needing to expose anything sensitive — support correspondence can look up therequestIdagainst structured logs (Section 21.1) to get the full internal detail out of band. - Consistency across surfaces. The same
SF-XXXXcode always maps to the same underlyingmessagetext regardless of whether it is rendered on an HTML page, returned in a JSON envelope, or represented by an image-endpoint placeholder'sX-SF-Error-Codeheader — a support conversation referencing a code means the same thing everywhere. - No dead ends. Every error page and every inline validation message keeps a path back to something useful — the homepage, the catalog, or the previous valid state of a form — rather than stranding the visitor on a page with no way forward except the browser's back button. This applies to the styled HTML error pages in Section 25.3 specifically: each one reuses the standard site layout shell (navigation and footer intact) rather than rendering as a bare, unstyled error dump.
- Translation-ready, even though v1 ships English only. Every user-facing message in the registry
lives in
core/i18n/en.tsas a keyed string (Section 5.8), never inlined as a literal at the call site, so the message text itself — not the error-handling logic around it — is the only thing a future locale would need to change.
26. Milestones & Execution Plan #
26.1 Execution principles #
- Vertical slices. Every milestone produces a running application. At the end of any milestone
the app boots, serves at least one real route, and passes its own tests. No milestone leaves the
tree in a state where
deno task devfails to start. - Database-first within a slice. Inside a milestone, write the migration and the repository
module (
core/db/*) before the route or CLI command that uses it. Data shape is decided before the code that consumes it. - Tests written with the code. A task is not complete until its test exists and passes. Tests are not a separate later milestone; each milestone carries its own share of the pyramid defined in Section 22.
- No milestone depends on a later one. Dependencies only point backward. If a task feels like it needs something from a later milestone, the ordering is wrong and the plan is corrected, not the code.
- Small tasks. Every task in a milestone's task list is scoped to be completed, tested and committed in one sitting (under roughly two agent-hours). Larger units are split.
- Exit criteria are executable. Every milestone ends with checks that can literally be run — a shell command with an expected result, or a precisely described observable behaviour (HTTP status, response body shape, rendered pixel value, log line). "Works correctly" is never an exit criterion.
- Golden images are law. From M2 onward, any change to hue math, compositing or encoding must keep the golden-image tests in Section 22 green. A milestone that touches pixels is not done until pixel parity is proven, not asserted.
26.2 Milestone list #
26.2.1 M0 — Repository and toolchain skeleton #
- Goal: a bootable, empty Fresh application with linting, formatting, CI, and the CLI entry point wired up, and nothing else.
- Dependencies: none.
- Scope: repository root files (
deno.json,.gitignore,README.md),main.ts,dev.ts,web/routes/index.tsx,web/routes/_app.tsx,web/static/,cli/main.ts,core/(empty module folders per Section 5.1:core/db/,core/schema/,core/i18n/,core/hue/,core/composite/,core/codec/,core/storage/), CI workflow file,docs/adr/with a0001-initial-stack.mdrecord of the Section 4 stack decision. - Tasks:
- Initialize the Deno 2.x project with
deno.jsondeclaring the Fresh 2.x, Preact 10.x and Tailwind CSS 4.x dependencies at the version lines in Section 4. - Scaffold the Fresh 2.x app skeleton (
main.ts,dev.ts,fresh.config.ts,web/routes/_app.tsx). - Add
deno fmt,deno lint, and adeno task checkthat runs both plusdeno check. - Add
cli/main.tsas a Deno CLI entry point with a single workingskinforge-cli --versioncommand, sharing nothing yet fromcore/but importing it to prove the module boundary works. - Add a CI workflow that runs
deno task checkanddeno task test(empty test suite passes trivially) on every push. - Write
README.mdin the developer's own repository with setup instructions (this file is an output of the build, not part of this specification). - Record the stack choice as
docs/adr/0001-initial-stack.md.
- Initialize the Deno 2.x project with
- Exit criteria:
deno task devstarts the server andGET /returns HTTP 200.deno task checkexits 0 on a clean checkout.deno run -A cli/main.ts --versionprints a semantic version string.- CI workflow passes on the initial commit.
- Effort: 4 agent-hours. Risk: low.
26.2.2 M1 — Database schema and migrations #
- Goal: every table in Section 6 exists in a fresh PostgreSQL 18 database via ordered migrations, with seed data for reference tables.
- Dependencies: M0.
- Scope:
db/migrations/0001_init.sqlthrough the final numbered migration covering all tables listed in Section 6 (includingschema_migrations,game_builds,slots,hues,hue_groups,assets,asset_variants,designs,jobs,admin_users,audit_log, and the rest of the table list), the migration runner (cli/commands/db-migrate.ts),core/db/client.ts(postgres.js connection pool), anddb/seed/dev-fixtures.tsseeding the 19-row slot table from Section 3. - Tasks:
- Write
cli/commands/db-migrate.ts: readsdb/migrations/*.sqlin filename order, applies any not recorded inschema_migrations, wraps each file in a transaction. - Write migration 0001 creating
schema_migrations. - Write one migration per logical group of tables (catalog tables, design tables, admin/auth
tables, operational tables), each a plain
.sqlfile, matching column/type/index/constraint definitions in Section 6 exactly, including theUNIQUE (asset_id, body, build_id)constraint onasset_variantsand the(hue_index, build_id)uniqueness onhues(Section 6.11, Section 6.13) — every asset and hue variant is scoped to the build it was extracted from, never shared across builds. - Seed the
slotstable with the 19 rows from Section 3's slot registry. - Seed
hue_groupswith the fixed curated taxonomy rows (skin,hair,tattoo,clothing,event) from Section 6.14 — this is the 5-row curated grouping table, a different concept from the 375 raw 8-hue blocks read out ofhues.mulby the Stage D6 decoder in milestone M4; the decoder's file-format blocks are never written to this table directly. - Add
deno task db:migrateanddeno task db:seed. - Write an integration test that runs migrations against a disposable database and asserts every
table in Section 6's table list exists with the documented primary key, and that inserting two
asset_variantsrows for the same(asset_id, body)but differentbuild_idvalues succeeds while a duplicate(asset_id, body, build_id)insert is rejected by the constraint.
- Write
- Exit criteria:
deno task db:migrateagainst an empty PostgreSQL 18 database exits 0 and leavesschema_migrationswith one row per migration file.SELECT count(*) FROM slots;returns 19.- Running
deno task db:migratea second time is a no-op (idempotent, no errors). - The integration test in task 7 passes in CI against a PostgreSQL 18 service container, including
the build-scoped uniqueness assertions on
asset_variantsandhues.
- Effort: 10 agent-hours. Risk: low.
26.2.3 M2 — Core domain library: formats, hue math, compositing, encoding #
- Goal: the pure-TypeScript rendering core (hue application, compositing, encoding) works against a synthetic fixture client and is proven pixel-exact by golden-image tests, before any real game file has been touched.
- Dependencies: M1 (hue rows need a schema, even if fixture data is inserted directly; the
fixture hue table and fixture
asset_variantsrows carry a fixturebuild_idlike any other row, since both are build-scoped per Section 6.11/6.13). - Scope:
core/hue/unpack.ts,core/hue/apply.ts,core/composite/canvas.ts,core/composite/render-design.ts,core/codec/png.ts,core/codec/webp.ts,tests/fixtures/synthetic-client/(hand-built minimal sprite set and hue table standing in for real game data),tests/golden/(expected PNG outputs). - Tasks:
- Build a synthetic fixture: three hand-authored RGBA sprites (a body, a hair sprite, a torso sprite) and a small hue table with one full-hue entry and one partial-hue entry, committed as test fixtures.
- Implement full-hue and partial-hue pixel transforms per the algorithm owned by Section 8.3 — never the appendix — unit-tested against the hand-computed values in Appendix B (Section 28.2), which is a lookup card that reproduces Section 8.3's numbers exactly and is not an independent source.
- Implement the compositor: alpha-over blit of N layers onto a 260×330
Uint8ClampedArraycanvas in the z-order from Section 3, with nearest-neighbour integer upscaling for scales 2 and 3. - Implement WebP and PNG encoding via
@jsquash/webpand@jsquash/pngat the version lines in Section 4. - Write a golden-image test: render the synthetic fixture design at scales 1–3 in both formats, compare byte-for-byte (lossless WebP, PNG) against committed golden files.
- Benchmark a single composite-and-encode call; record the number for the performance budget in Section 19.
- Exit criteria:
deno test core/hue core/composite tests/goldenpasses, including the golden-image comparisons.- Rendering the fixture design twice produces byte-identical output (determinism check).
- Nearest-neighbour upscaling is verified by asserting that the scale-
nbitmap equals the scale-1 bitmap with every pixel replicated into an exactn×nblock, checked pixel-for-pixel — not merely that no unexpected colour values appear. - The benchmark in task 6 completes and its result is recorded in
docs/adr/for later comparison against the Section 19 budget.
- Effort: 16 agent-hours. Risk: medium — hue math correctness is foundational; errors here propagate to every later milestone.
26.2.4 M3 — Extraction CLI, stages D1–D3 #
- Goal:
skinforge-cli import discoverwalks a real or synthetic client directory and produces an inventory and a best-effort classification of every file, without extracting any art yet. - Dependencies: M0 (CLI skeleton), M1 (
source_filesandextraction_candidatestables). - Scope:
cli/commands/import-discover.ts,core/import/discover.ts,core/import/identify.ts,core/import/probe.ts,tests/fixtures/synthetic-client-dir/. - Tasks:
- Implement Stage D1 (inventory): recursive directory walk, per-file size, extension, first-256-byte
magic read, Shannon entropy calculation; write one
source_filesrow per file. - Implement Stage D2 (identify): MUL/IDX pair detection (matching
*.multo*idx.mul/*.idxby name convention and validating the 12-byte index record stride), UOPMYP\0magic detection,hues.muldetection by fixed record size,.def/.txtdetection by extension plus content sniffing. - Implement Stage D3 (probe unknown containers): compression sniffing (zlib/deflate magic bytes,
LZ4 frame magic), repeated-record-stride detection via byte-histogram autocorrelation, and a
--noteCLI flag letting the operator attach a free-text format note to any file, stored on thesource_filesrow. - Build a synthetic client directory fixture containing one valid MUL/IDX pair, one valid UOP stub, one hues.mul stub, and one genuinely unknown binary file, for use in CLI integration tests.
- Wire
skinforge-cli import discover --path <dir> --build <label>to create agame_buildsrow and populatesource_filesandextraction_candidates(the latter withstatus = 'pending'for every plausible sprite container). - Ensure the unknown file is classified
unknownand given anextraction_candidatesrow withstatus = 'needs_operator_input', and the run exits 0 (never a crash) per the degrade-gracefully rule in Section 7.
- Implement Stage D1 (inventory): recursive directory walk, per-file size, extension, first-256-byte
magic read, Shannon entropy calculation; write one
- Exit criteria:
skinforge-cli import discover --path tests/fixtures/synthetic-client-dir --build fixture-1exits 0.source_filescontains one row per fixture file with correctextensionandformat_guessvalues (mul,idx,uop,unknownas appropriate).- The MUL/IDX pair fixture is classified with
format_guess = 'mul_idx_pair'. - The unknown fixture file is present in
extraction_candidateswithstatus = 'needs_operator_input'and does not abort the run. - Re-running discovery on the same directory and build label is idempotent (no duplicate rows).
- Effort: 14 agent-hours. Risk: high — this is the milestone most exposed to the unknown real file layout; see the de-risking spike in 26.4.
26.2.5 M4 — Extraction stages D4–D6, normalization and storage #
- Goal: candidates identified in M3 are decoded into raw RGBA sprites, classified to slots/bodies, normalized to WebP/PNG at scales 1–3, and stored with full provenance.
- Dependencies: M2 (compositor/encoder reused for normalization), M3 (candidates exist).
- Scope:
core/import/extract.ts(gump decoder),core/import/hues.ts(hues.muldecoder and curated hue-group assignment),core/import/classify.ts,core/import/normalize.ts,core/storage/fs-driver.ts,core/storage/s3-driver.ts,core/storage/types.ts,cli/commands/import-extract.ts. - Tasks:
- Implement the gump run-length decoder (RLE rows of ARGB1555 with a lookup table) into raw RGBA, per the byte layout in Appendix A (Section 28) — Stage D4.
- Implement Stage D5 classification: map decoded gump ids to
slot_keyandbodyusing gump-id range tables andtiledata/body.defflags, writing low-confidence matches to a staff review queue (extraction_candidates.status = 'pending') instead of guessing. This stage maps sprites to slots and bodies only; it never writeshuesorhue_group_members— both are Stage D6 responsibilities (task 3), since no hue row exists until Stage D6 decodes one. - Implement Stage D6's hue decode: the
hues.muldecoder intohuesrows (the file format's 375 blocks of 8 fixed-size entries each, per Section 7.3.3 and Appendix A's Section 28.1.3), taggingsource = 'base'for everything decoded from this stage and stamping every row with the currentbuild_id(Section 6.13); then, in the same stage, assign each newly decoded hue to its curatedhue_groupsrow (Section 6.14) by writinghue_group_members.hue_groupsitself is the 5-row curated taxonomy seeded once in milestone M1 (Section 6.14) — a different concept from the file's raw 8-entry blocks — and this stage only links into it; it never inserts newhue_groupsrows. - Implement Stage D6's sprite normalization: trim transparent borders, generate WebP/PNG at scales
1–3 reusing
core/codec/png.tsandcore/codec/webp.tsfrom M2, computesha256and a perceptual hash (difference hash, 8×8), and writeassets,asset_variants,asset_imagesrows — stampingasset_variants.build_idwith the current build (Section 6.11) — with the source offset recorded for provenance. - Implement the storage driver interface (
core/storage/types.ts) and both thefsands3implementations, selected bySKINFORGE_STORAGE_DRIVER(Section 24). - Wire
skinforge-cli import extract --build <label>to run D4–D6 over allpendingcandidates for a build and report a summary (counts by outcome).
- Exit criteria:
- Running
skinforge-cli import extract --build fixture-1against the M3 synthetic fixture decodes the stub sprite(s) into at least oneassetsrow with non-nullasset_imagesat scales 1–3, and at least onehuesrow with a matchinghue_group_membersrow, both written by Stage D6 (task 3). - The perceptual hash of the same fixture computed twice is identical (determinism check).
- Switching
SKINFORGE_STORAGE_DRIVERfromfstos3against a local MinIO instance and re-running extraction produces the same row counts (driver parity check). - A deliberately ambiguous fixture sprite (added to the fixture set) lands in
extraction_candidateswithstatus = 'pending', not inassets.
- Running
- Effort: 20 agent-hours. Risk: high — decoder correctness against the real client format is unverified until real files are available; see 26.4.
26.2.6 M5 — Ingestion, builds, diffing and publish #
- Goal: staff can turn an extraction run into a published, versioned asset set, and re-run the whole pipeline against a patched client to diff and re-publish.
- Dependencies: M4.
- Scope:
core/ingest/diff.ts,core/ingest/publish.ts,cli/commands/import-diff.ts,cli/commands/import-publish.ts, additions tocore/db/repositories forimport_runs,import_run_items. - Tasks:
- Implement build diffing: compare the new build's extracted
assets(byasset_keyand content hash) against the currently published build, classifying each asadded,unchanged,changed, orretired-candidate. - Implement
import_runs/import_run_itemsbookkeeping so every ingestion is auditable. - Implement publish: set
game_builds.status = 'published'andgame_builds.published_at = now()for the reviewed build (demoting the previously published build'sstatustoarchived), and setretired_aton any previously published asset not present in the new build (soft delete, per Section 6's convention — never hard-deleted;assetsitself carries nopublished_atcolumn — an asset's live/retired state isretired_at IS NULLcombined with belonging to a build whosegame_builds.status = 'published'). - Implement rollback: republish a previous build's asset set without re-running extraction, by
setting that earlier build's
game_builds.statusback topublishedand replaying itsimport_run_items. - Wire
skinforge-cli import diff --build <label>(loads extraction output into the diff/review pipeline) andskinforge-cli import publish --build <label>(staff-confirmed promotion). - Write an integration test simulating two sequential builds (fixture v1 and a modified fixture
v2) proving the diff correctly reports one
changedand oneunchangedasset.
- Implement build diffing: compare the new build's extracted
- Exit criteria:
skinforge-cli import diff --build fixture-1thenskinforge-cli import publish --build fixture-1results ingame_builds.status = 'published'andgame_builds.published_atset for the fixture build.- Re-importing a second fixture build with one modified sprite reports exactly one
changeditem in the diff summary. - Publishing the second build sets
retired_aton any asset absent from it and leaves the design immutability guarantee intact (existingdesignsrows still resolve their referenced assets via their pinnedbuild_id, Section 6.18, Section 13.7). skinforge-cli import publish --build fixture-0 --rollback(an earlier label) restores the original asset set without re-running D1–D6.
- Effort: 14 agent-hours. Risk: medium.
26.2.7 M6 — Admin console: auth and candidate review queue #
- Goal: staff can log in with TOTP and review/approve extraction candidates through a web UI — the first admin screen and the authentication backbone for all later admin screens.
- Dependencies: M1 (
admin_users,admin_sessions,admin_recovery_codestables), M4 (candidates to review). - Scope:
web/routes/admin/index.tsx(login),web/routes/admin/dashboard.tsx,web/routes/admin/candidates.tsx,web/islands/admin/candidate-review.tsx,core/auth/argon2.ts,core/auth/totp.ts,core/auth/session.ts,cli/commands/admin-user.ts. - Tasks:
- Implement Argon2id password hashing and verification (
core/auth/argon2.ts). - Implement TOTP (RFC 6238) enrollment and verification (
core/auth/totp.ts), plus one-time recovery codes stored hashed inadmin_recovery_codes. - Implement server-side sessions in Postgres (
admin_sessions) with thesf_admincookie (HttpOnly; Secure; SameSite=Lax), TTL fromSKINFORGE_ADMIN_SESSION_TTL_HOURS. - Implement
skinforge-cli admin create-user --email <email> --role <owner|curator|viewer>(interactive password + TOTP secret provisioning, Section 17.1) — the only way to create an admin user; there is no in-console user self-registration or password-reset-by-email flow. - Build the login page and a session-required middleware for all
/admin/*routes except login. - Build the candidate review screen: list
extraction_candidateswithstatus = 'pending', preview the decoded sprite, allow approve (creates/updatesassets) or reject (status = 'rejected') with a required reason, all recorded inaudit_log.
- Implement Argon2id password hashing and verification (
- Exit criteria:
skinforge-cli admin create-user --email staff@example.test --role ownerfollowed by logging in at/adminwith the generated password and a valid TOTP code succeeds and setssf_admin.- An expired or tampered
sf_admincookie is rejected and redirects to/admin. - Approving a candidate on
/admin/candidatescreates anassetsrow and anaudit_logrow referencing the admin user. - Five consecutive failed TOTP attempts trigger the lockout behaviour defined in Section 20.
- Effort: 16 agent-hours. Risk: medium.
26.2.8 M7 — Remaining admin screens #
- Goal: every admin route in Section 10.1's site map and Section 17's screen specifications is implemented and functional.
- Dependencies: M6 (auth), M5 (builds/imports), M4 (assets/hues).
- Scope:
web/routes/admin/imports.tsx,web/routes/admin/imports/[id].tsx,web/routes/admin/assets.tsx,web/routes/admin/assets/[assetKey].tsx,web/routes/admin/hues.tsx,web/routes/admin/slots.tsx,web/routes/admin/tags.tsx,web/routes/admin/builds.tsx,web/routes/admin/designs.tsx,web/routes/admin/takedowns.tsx,web/routes/admin/audit.tsx,web/routes/admin/settings.tsx,web/routes/admin/users.tsx, corresponding islands underweb/islands/admin/. - Tasks:
- Build the imports list and detail screens (run status, diff summary, publish/rollback actions).
- Build the assets list and detail screens (metadata edit, tag assignment, retire/restore action).
- Build the hues browser (filter by group, preview swatch).
- Build the slots screen per Section 17.9:
owner-editable display name,zOrder, gender scope andvisibletoggle, with the z-order blast-radius warning, the live affected-design count, and the type-to-confirm slot-key field required before a change tozOrderor gender scope commits. The 19-row registry itself (which slots exist) stays fixed per Section 3.3/Appendix C; only the per-row fields Section 17.9 names are editable. - Build the tags management screen (create/rename/retire tags).
- Build the builds screen (list builds, current published build, diff history).
- Build the designs screen (search by short code, view canonical JSON, no edit — designs are immutable per Section 13).
- Build the takedown request screen implementing the workflow in Section 20.
- Build the audit log viewer (filter by actor, action, date range).
- Build the settings screen per Section 17.14: the read-only environment panel with secret masking
for every
SKINFORGE_*variable, the five editableapp_settingsrows, and the audited maintenance-mode toggle; and the users screen (list admin users, deactivate — creation remains CLI-only).
- Exit criteria:
- Every route listed in this sub-section returns HTTP 200 for an authenticated admin session and HTTP 303 (redirect to login) for an unauthenticated request.
- Retiring an asset from
/admin/assets/:assetKeysetsretired_atand the asset stops appearing in public catalog listings while still resolving in existing designs' renders. - Creating a takedown request and resolving it follows the state machine in Section 20 with no illegal transitions permitted by the UI.
- Editing a slot's
zOrderon/admin/slotsshows the live count of published designs the change would affect and refuses to commit until the operator types the slot key to confirm; the edit is recorded inaudit_logand is restricted to theownerrole per Section 17.3.1's permission matrix. - The settings screen never renders a raw secret value (verified by a test asserting masked output
for any env var name containing
SECRET,PASSWORD,TOKEN, orKEY), and toggling maintenance mode from the screen writes anapp_settingsrow and anaudit_logrow, taking effect for public requests without a redeploy.
- Effort: 22 agent-hours. Risk: low.
26.2.9 M8 — Public API and catalog pages #
- Goal: the public JSON API (Section 16) and the catalog browsing pages (Section 15) are live against published assets.
- Dependencies: M5 (published assets exist), M2 (rendering for catalog thumbnails).
- Scope:
web/routes/api/v1/**,web/routes/catalog/index.tsx,web/routes/catalog/[slotKey]/index.tsx,web/routes/catalog/[slotKey]/[assetKey].tsx,web/routes/hues/index.tsx,web/routes/hues/[hueIndex].tsx,core/api/envelope.ts,core/api/pagination.ts,core/api/rate-limit.ts,core/search/(query parsing and ranking, Section 15.6). - Tasks:
- Implement the success/error envelope helpers and error code table per Section 16 and Section 25.
- Implement cursor pagination helper per Section 16 (opaque cursor,
limit1..100 default 24). - Implement the Postgres-backed token bucket rate limiter per Section 16.7's bucket definitions,
wired to
SKINFORGE_RATE_LIMIT_API_PER_MINUTE,SKINFORGE_RATE_LIMIT_RENDER_PER_MINUTEandSKINFORGE_RATE_LIMIT_DESIGN_CREATE_PER_HOUR(Section 24); confirm the boot-time config validator (Section 24.3) rejects a malformed value for any of the three before this task is considered done, since a silently-defaulted limit is worse than a boot failure. - Implement every
/api/v1/*endpoint enumerated in Section 16 (slots, hues, hue-groups, assets, tags, search, designs read, design create, builds, stats). - Implement the
search_tsvcolumn per Section 15.6 as a trigger-maintained plaintsvectorcolumn (populated by anAFTER INSERT OR UPDATEtrigger onassetsand onasset_tags, never aGENERATED ALWAYS ASexpression, since PostgreSQL rejects a generated column whose expression contains a subquery), plus thepg_trgm-backed typo-tolerance fallback from Section 15.6. WireGET /search(Section 16.8.9) and the catalog'sqsearch parameter (Section 15.3) to it. - Build the catalog index, per-slot, and per-asset pages with server-side rendering and facet filters per Section 15.
- Build the hues browser pages.
- Add an integration test suite hitting every documented endpoint with a valid and an invalid
request, asserting envelope shape and status code, including a
search_tsvtest that inserts anassetsrow and confirms the trigger populates the column without any query-time computation.
- Exit criteria:
curl -s localhost:8000/api/v1/slots | jq '.data | length'returns 19.- A request exceeding the configured rate limit returns HTTP 429 with error code
SF-3000. /catalog/hairrenders at least one published hair asset thumbnail with correctsrcsetscales.- Every endpoint in Section 16 has at least one passing integration test for a success case and one for a documented error case.
GET /api/v1/search?q=<term>returns ranked results sourced from the trigger-maintainedsearch_tsvcolumn; the migration that creates it (Section 6, task 5 above) applies cleanly to a fresh database withdeno task db:migrate, proving the column is not a rejected generated expression.
- Effort: 18 agent-hours. Risk: low.
26.2.9a M8.5 — Observability, Operational Reporting & API Contract Hardening #
- Goal: every operational surface this document promises elsewhere but assigns to no milestone — structured logging, metrics, the health and version endpoints, the daily rollup job, the weekly operator report, the generated OpenAPI document, and the API's backward-compatibility contract fixtures — actually exists and is tested, closing the gap between what Sections 16, 21 and 24 describe and what M0–M8 build. Named M8.5 rather than renumbered into the main sequence so the M9–M12 labels below stay stable.
- Dependencies: M8 (the public API and catalog surface must exist before it can be instrumented, health-checked and contract-tested), M7 (the admin dashboard exists to host the weekly report panel per Section 17.4).
- Scope:
core/obs/logger.ts,core/obs/metrics.ts,web/routes/api/v1/health.ts,web/routes/api/v1/version.ts,core/ops/daily-rollup.ts,cli/commands/ops-rollup.ts,core/ops/weekly-report.ts,scripts/generate-openapi.ts,docs/api/openapi.json(generated, not hand-written),tests/contract/api-v1/(fixtures asserting the generated OpenAPI document matches the live envelope, pagination and error shapes). - Tasks:
- Implement structured logging per Section 21.1 as shared middleware wired into every route: one
JSON line per request in the documented field set, with the pitfall in Section 27.7 enforced by
test — no full canonical design JSON or full request body logged at
infolevel. - Implement metrics collection per Section 21.3 (the named counters and histograms, including
request duration and the render pipeline metrics), gated behind
SKINFORGE_METRICS_ENABLED/SKINFORGE_METRICS_TOKENper Section 24. - Implement
GET /api/v1/health(Section 16.8.17) andGET /api/v1/version(Section 16.8.18), backed by the deeper dependency checks in Section 21.7 (database reachability, storage driver reachability, job queue depth). - Implement the daily rollup job and the weekly operator report per Section 21.9, scheduled through the Postgres-backed job runner (Section 4's architecture), writing into the panel and storage location Sections 17.4 and 21.9 assign them.
- Implement the OpenAPI generator (
scripts/generate-openapi.ts) producing the machine-readable contract per Section 16.10 from the same Zod schemas the route handlers already validate against — never a hand-maintained duplicate document — and add adeno task openapi:generatethat fails CI when the checked-in document is stale relative to the schemas. - Write the API contract fixture suite per Section 16.12: golden request/response pairs for every endpoint in Section 16.8, asserted against both the live server and the generated OpenAPI document, so an accidental breaking change to envelope shape, pagination shape, or a field name is caught by CI rather than by a community bug report.
- Implement structured logging per Section 21.1 as shared middleware wired into every route: one
JSON line per request in the documented field set, with the pitfall in Section 27.7 enforced by
test — no full canonical design JSON or full request body logged at
- Exit criteria:
- Every request to any route emits exactly one structured log line matching Section 21.1's schema;
the logging test suite asserts no full canonical design JSON and no full request body appears at
infolevel. curl -s localhost:8000/api/v1/healthreturns the success envelope with each dependency check reported individually, and returns a non-2xx status when the database is unreachable (verified in a test that pointsSKINFORGE_DATABASE_URLat a closed port).curl -s localhost:8000/api/v1/versionreturns the running build's version string.- Running the daily rollup job against fixture data populates the aggregate counters it owns, and the resulting weekly report is viewable from the location Section 17.4 assigns it.
deno task openapi:generateproduces a document byte-identical to the one checked intodocs/api/openapi.jsonon a clean checkout (no drift), and CI fails the build if they differ.- The contract fixture suite in task 6 passes against the live server for every endpoint in Section 16.8, and deliberately renaming a response field on a throwaway branch makes the corresponding fixture fail — a canary test proving the suite actually catches drift, not merely that it runs.
- Every request to any route emits exactly one structured log line matching Section 21.1's schema;
the logging test suite asserts no full canonical design JSON and no full request body appears at
- Effort: 12 agent-hours. Risk: low.
26.2.10 M9 — Designer UI and live preview #
- Goal: the interactive design tool at
/lets a visitor build a full look with instant client-side preview (/designis a redirect to/, Section 10.1, and carries no UI of its own). - Dependencies: M8 (catalog API feeds the picker), M2 (client-side compositing reuses the same algorithm).
- Scope:
web/routes/index.tsx,web/routes/design.tsx(redirect handler only, Section 10.2),web/islands/designer/designer-root.tsx(the only mounted island, Section 11.2),web/components/designer/slot-rail.tsx,web/components/designer/preview-stage.tsx,web/components/designer/option-panel.tsx,web/components/designer/hue-picker.tsx,core/schema/design-state.ts,core/design/query-form.ts. - Tasks:
- Implement URL query-string state per Section 13.6's transient form (
?b=m&s=1002&hair=...) incore/design/query-form.ts(Section 13.6.3's reference parser), and the permanent-form schema from Section 13.2.5 for the Share action's submission. - Implement the Preact Signals state described in Section 11.3 (
designSig,uiSig, and their derivedcomputed()signals) created insideDesignerRootitself — there is no separate standalone signals module;DesignerRootis the only Fresh island in the designer tree (Section 11.2), andSlotRail,PreviewStage,OptionPanelandHuePickerare plain server-shaped components hydrated within its single island boundary, not independent islands. - Implement
PreviewStage's live composite canvas: fetches per-asset sprites and the hue table as JSON, composites on an HTML<canvas>client-side using the exact algorithm from Section 8.3, reusing shared logic withcore/composite/render-design.tsandcore/hue/apply.tswhere the runtime allows (browser-safe subset), per Section 12.3. - Implement
SlotRail(thumbnail-badged rail across all 19 slots, keyboard navigable, per Section 11.5) andOptionPanel's asset grid (Section 11.6). - Implement
HuePicker(swatch grid, keyboard navigable, per Section 11.6). - Implement undo/redo over
uiSig.value.history, per Section 11.7. - Implement the no-JS fallback exactly as Section 10.7 specifies: when
DesignerRootdoes not hydrate, the server renders a static, read-only<noscript>view (the same summary strip and composite image used on/d/:code) with a message directing the visitor to enable JavaScript or browse the catalog. There is no form-based slot/hue selector for the live designer — Section 10.7 makes the no-JS designer read-only by design; only the catalog's filters (Section 15.3) and the Share action degrade to plain forms. - Write a Playwright end-to-end test that builds a design by clicking through slots and hues and asserts the canvas pixel data matches a server-rendered reference at the same design state.
- Implement URL query-string state per Section 13.6's transient form (
- Exit criteria:
- Loading
/?b=m&s=1002renders bodymwith skin hue1002on the canvas within the performance budget in Section 19; loading/design?b=m&s=1002issues a 301 redirect to/with the query string intact (Section 10.5). - Changing a slot's asset or hue updates the canvas without a full page reload.
- The Playwright test in task 8 passes, proving client/server rendering parity for the interactive flow (extending the golden-image guarantee from Section 22).
- With JavaScript disabled, loading
/renders the read-only<noscript>fallback from Section 10.7 (summary strip, composite image, and the "enable JavaScript" message) instead of an empty page; no interactive slot/hue selection is expected or required without JavaScript.
- Loading
- Effort: 24 agent-hours. Risk: medium.
26.2.11 M10 — Permalinks, share flow and OG images #
- Goal: a design can be shared as a permanent, immutable link with a generated social preview image.
- Dependencies: M9 (a design exists to share), M2 (compositor reused for the embedded preview).
- Scope:
web/routes/d/[code].tsx,web/routes/og/d/[code].png.ts,core/design/short-code.ts,core/design/canonicalize.ts,core/og/layout.tsx,core/og/render.ts. - Tasks:
- Implement canonical JSON serialization (fixed key order
v,body,skinHue,slots; no whitespace; empty slots omitted) per Section 13.3. - Implement the short-code algorithm: SHA-256 of canonical JSON, leading 50 bits, Crockford Base32 (ambiguity-collapsing alphabet), 10-character lowercase code, with the salt-collision scheme from Section 13.
- Implement
POST /api/v1/designs(Section 16) to persist a design and return its code, called by the designer UI's Share action. - Build
/d/:code: server-rendered page showing the composite preview, metadata, and Open Graph tags pointing at the OG image route. - Implement the OG image pipeline:
satorilayout embedding a data-URL of the server-side composite, rasterized by@resvg/resvg-wasm, cached in object storage per Section 14's cache key scheme. - Implement crawler-friendly handling: OG image generation is synchronous on first request and cached thereafter, per Section 14.
- Write a test proving two different canonical JSON payloads that collide on the leading 50 bits receive different codes (salt path exercised with a forced collision fixture).
- Implement canonical JSON serialization (fixed key order
- Exit criteria:
- Sharing a design in the UI navigates to
/d/:codewhere<code>is a 10-character lowercase string. - Requesting the same design twice returns the same code (determinism).
curl -sI localhost:8000/og/d/:code.pngreturnsContent-Type: image/pngand aCache-Controlheader matching Section 19.- The forced-collision test in task 7 passes.
- Visiting
/d/:codefor a code whose design references a retired asset still renders correctly using archived art (immutability check per Section 9).
- Sharing a design in the UI navigates to
- Effort: 16 agent-hours. Risk: medium.
26.2.12 M11 — Performance, caching, CDN and hardening #
- Goal: the application meets the performance budgets in Section 19 and the security controls in Section 20 under realistic load.
- Dependencies: M8, M9, M10 (everything user-facing must exist to be hardened), M8.5 (the load test in task 5 reads the metrics this milestone adds, and the security review in task 6 checks the logging discipline M8.5 establishes).
- Scope: cache header middleware,
core/security/headers.ts, CDN purge integration (core/ops/cdn-purge.ts), load-test scripts undertests/load/. - Tasks:
- Apply the immutable-asset and HTML cache headers from Section 19 to every route.
- Implement security headers as shared middleware, per Section 20.4:
Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval' 'nonce-<per-response>'; img-src 'self' data: blob: <configured public storage origin>; connect-src 'self' <that origin>plus the rest of Section 20's header list (X-Content-Type-Options,Referrer-Policy, etc.); generate a fresh nonce per response and inject it into both the CSP header and the inline theme script'snonceattribute.wasm-unsafe-evalis required — without it the browser compositor (Section 8.12, Section 11, Section 12) cannot run. - Implement CDN purge calls on publish (
SKINFORGE_CDN_PURGE_URL/_TOKEN) per Section 19; the purge/caching layer must not force immutable caching on all image routes — the application setsCache-Controlper response and the proxy only fills it in when absent, and 4xx/5xx responses never receive a long TTL, so a taken-down image (Section 20.8) does not linger in the CDN. - Bound render concurrency using
SKINFORGE_RENDER_MAX_CONCURRENCYwith a queue and a documented rejection behaviour (HTTP 503 withRetry-After) beyond the bound. - Run a load test against the render endpoints (Section 8.8) and the catalog grid, tuning N+1 query paths flagged in Section 27.7.
- Run the security review checklist from Section 20 end to end and fix findings, including
verifying the boot-time config validator (Section 24.3) rejects every malformed value in
.env.exampleand that no boolean-typed variable (e.g.SKINFORGE_MAINTENANCE_MODE) can be coerced true by an arbitrary non-empty string — a strictz.enum(["true","false"]).transform(v => v === "true")parse, neverz.coerce.boolean(), since the latter turns the string"false"intotrueand would permanently pin maintenance mode on.
- Exit criteria:
- Lighthouse (or equivalent) performance score on
/meets the budget defined in Section 19. - Response headers on every page include the full security header set from Section 20, including a
CSP whose nonce differs on every response and whose
script-srcincludeswasm-unsafe-eval, verified by loading/and confirming the client-side compositor boots without a CSP violation. - The load test in task 5 sustains the target request rate from Section 19 without 5xx responses beyond the documented concurrency-limit 503s.
- The catalog grid issues a bounded, constant number of queries regardless of page size (no N+1).
- Starting the app with a deliberately malformed boolean env var (e.g.
SKINFORGE_MAINTENANCE_MODE=yes) fails boot-time validation with a readable error rather than silently coercing totrue, per task 6.
- Lighthouse (or equivalent) performance score on
- Effort: 18 agent-hours. Risk: medium.
26.2.13 M12 — Launch readiness #
- Goal: v1 is deployable, backed up, documented for operations, and accessible.
- Dependencies: all prior milestones.
- Scope:
docker-compose.yml,Caddyfile, backup scripts, runbooks in the developer's owndocs/runbooks/, accessibility fixes, seeded launch content. - Tasks:
- Write the Docker Compose topology from Section 23 (app, Postgres, optional MinIO, Caddy).
- Write backup and restore scripts and rehearse a full restore per Section 23.
- Write the runbooks required by Section 23 (deploy, rollback, re-import after a patch, incident response).
- Run both accessibility gates per Section 22.6: an automated audit (axe or equivalent) against every public page, and the manual keyboard-only script covering SC 2.1.4 (Character Key Shortcuts), SC 2.5.7 (Dragging Movements) and SC 2.4.11 (Focus Not Obscured) — criteria the automated tool does not evaluate. Fix all WCAG 2.2 AA violations found by either gate, per Section 18.
- Run the security checklist in Section 20 a final time.
- Seed the production database with an initial published build from a real client extraction (or the best available fixture if real files are still pending — see 26.4) and verify the full user journey end to end.
- Publish the unofficial fan-project disclaimer on every public page footer per Section 20.
- Run the acceptance test script in Section 27.10.
- Exit criteria:
docker compose upon a clean host brings up a working stack reachable over HTTPS via Caddy.- A rehearsed restore from backup succeeds and the restored instance serves a known permalink correctly.
- The launch accessibility gate passes: zero automated axe violations and a passing run of the manual keyboard-only script from Section 22.6, including its SC 2.1.4, SC 2.5.7 and SC 2.4.11 checks — an automated-only pass is not sufficient to close this milestone.
- The acceptance test script in Section 27.10 passes in full.
- Effort: 20 agent-hours. Risk: low, contingent on all prior milestones being closed.
Total estimated effort: 224 agent-hours across M0–M12 plus M8.5 (212 agent-hours across the 13 milestones M0–M12, plus M8.5's 12 agent-hours), excluding the de-risking spikes in 26.4.
26.3 Dependency graph and parallelization #
M0 ─▶ M1 ─┬─▶ M2 ─┬─▶ M9 ─▶ M10 ─┐
│ │ ├─▶ M11 ─▶ M12
├─▶ M3 ─▶ M4 ─▶ M5 ─▶ M6 ─▶ M7 ─┘
│ │ ┌─▶ M9
└─────────────────────┴─▶ M8 ─┬──────┘
└─▶ M8.5 ─▶ M11Read as: M0 gates everything. M1 gates M2 and M3. M2 (render core) is required by M4 (normalization reuses the encoder), M9 (client preview reuses the algorithm) and M10 (OG embeds a composite). M3→M4 →M5→M6→M7 is the asset/admin spine. M8 needs published assets from M5 and the render core from M2. M9 needs the catalog API from M8. M10 needs a working designer from M9. M8.5 branches from M8 (it instruments and contract-tests the surface M8 just built) and, together with M10, gates M11. M11 and M12 need the whole public surface plus M8.5's observability.
Parallelization plan for two or three agents:
- Agents A and B fork after M1. Agent A takes the asset spine: M3 → M4 → M5 → M6 → M7. Agent B takes the render core and public surface: M2 → M8 (blocked until Agent A's M5 publishes at least fixture assets, so Agent B can develop M8 against the M2 fixture data and switch to real data when M5 lands).
- A third agent (Agent C) can start M2's golden-image test infrastructure in parallel with M1, since the synthetic fixture in M2 does not depend on the real database schema beyond having somewhere to insert fixture hue rows — coordinate the schema dependency by having Agent C stub the fixture data as plain TypeScript objects until M1 lands, then swap to DB-backed fixtures.
- M8.5 runs in parallel with M9 → M10. Once M8 closes, whichever agent is free (Agent B, having handed the designer/permalink chain to Agent A or a fourth agent, or Agent A once M7 closes) can pick up M8.5 without waiting on M9 or M10 — it depends only on M8 and M7 (for the dashboard panel), not on the designer or permalink surfaces. Both M8.5 and M10 must close before M11 starts.
- M9 and M10 cannot start until both M2 and M8 are done — they are single-threaded from that point.
- M11 and M12 are single-threaded and must follow M9/M10 and M8.5 because they harden against the whole public and operational surface.
- Milestones that must NOT overlap: M5 and M6 (M6's review queue reads live
extraction_candidatesstate that M5's publish step mutates — running both at once risks reviewing already-published candidates); M9 and M10 for the same reason applied todesigns.
26.4 Critical path and risk register #
Critical path: M0 → M1 → M3 → M4 → M5 → M6 → M7 → M8 → M9 → M10 → M11 → M12. (M2 sits off the critical path in wall-clock terms if run in parallel per 26.3, but M8/M9/M10 cannot close without it, so treat it as effectively on the path for planning purposes. M8.5 branches from M8 and runs alongside M9 → M10; at 12 agent-hours it is shorter than that chain in every realistic staffing scenario, so it is not expected to extend the critical path — but M11 may not start until M8.5 closes, so if M8.5 is delayed it becomes the pacing item for M11.)
Risk 1 — the real client file format resists the readers in Section 7 (highest risk).
The exact on-disk layout is unknown until real files are inspected; UO Outlands may deviate from
stock UO in ways the format tables do not anticipate (custom UOP block ordering, non-standard hue
ranges, patched tiledata). De-risking spike, scheduled in the first week alongside M0: run
skinforge-cli discover (built early, even ahead of its formal M3 slot, as a throwaway script) against
a real Outlands client directory obtained from the operator, and manually inspect the first 50
classified files. Contingency if automated extraction stalls on a specific container format: keep the
staged pipeline's Stage D3 manual format note, add a manual asset upload path in the admin console
(staff uploads pre-extracted PNG/BMP sprites with hand-entered slot/body/hue metadata, bypassing D4–D6
for that subset) so the product can launch with a partial catalog while the automated reader for the
resistant format is finished post-launch.
Risk 2 — hue math or compositing has a subtle pixel-level bug that only shows up on real art. The synthetic fixture in M2 cannot cover every real sprite quirk (transparency edge cases, palette index reuse). De-risking spike: as soon as the first real sprites are extracted in M4, immediately run them through the golden-image harness from M2 and visually diff against a manually rendered reference image captured from the original game client by the operator. Contingency: expand the fixture corpus with the failing real sprite and fix the algorithm before proceeding past M4.
Risk 3 — render or catalog performance does not meet the Section 19 budget under concurrent load.
Pure-TypeScript/WASM rendering with no native image library is a deliberate simplicity trade-off that
could underperform. De-risking spike: run the M2 benchmark (task 6) early and extrapolate to the
expected concurrency from SKINFORGE_RENDER_MAX_CONCURRENCY; if the extrapolation exceeds the budget,
raise it before M11 rather than discovering it during hardening. Contingency: increase the in-process
render cache hit rate (pre-render common slot/hue/scale combinations during publish in M5) so most
public traffic never hits the live encoder.
26.5 Cutover checklist and definition of v1 complete #
Cutover checklist (run in order, immediately before flipping public DNS):
- All milestones M0–M12, plus M8.5, closed with their exit criteria passing in CI.
- Production database migrated and seeded from a real, staff-approved, published build (not the synthetic fixture).
- Backups configured and one full backup/restore cycle rehearsed successfully within the prior 7 days.
- TLS certificate issued and auto-renewal verified through Caddy.
SKINFORGE_ENV=productionand every required variable in Section 24 set with production values; no default/dev value remains for any secret-bearing variable.- Rate limits, CDN purge integration, and security headers verified live against the production domain.
- Accessibility audit re-run against the production build with zero AA violations.
- Fan-project disclaimer visible on every page footer, verified by a crawl of the sitemap.
- Donation link (if configured via
SKINFORGE_DONATION_URL) verified reachable; absent is also a valid state per Section 2. - The acceptance test script in Section 27.10 executed against the production URL, all checks pass.
Definition of "v1 complete": every route in Section 10.1's site map resolves; a visitor can design a look with no login, share it as a permalink with a working OG image, and browse the catalog; staff can run discovery and extraction against the operator's real client files, review candidates, and publish a build; the fan-project disclaimer is present everywhere; no user account system, elf/gargoyle body, animated preview, or paid tier exists anywhere in the shipped product, matching the scope boundaries in Section 2.
26.6 Post-v1 backlog (explicitly deferred, not part of this build) #
| Idea | Reason deferred |
|---|---|
| Elf and gargoyle player bodies | Out of scope per Section 2; UO Outlands has no such player bodies to spec against. |
| Animated or multi-directional (N/S/E/W) previews | Out of scope per Section 2; static paperdoll pose only. |
| User accounts, login, saved galleries | Out of scope per Section 2; permalinks fully replace the need. |
| Full clothing/armour/equipment catalog with stats | Out of scope per Section 2; SkinForge renders appearance only. |
| In-game integration or purchase flow | Out of scope per Section 2; SkinForge never touches a player account. |
| Native mobile apps | Out of scope per Section 2; responsive web only. |
| Advertising or subscription tiers | Out of scope per Section 2; free tool with an optional donation link. |
| Weapon, ring, bracelet, talisman layers | Excluded cosmetic layers per Section 3; not part of the visible skin. |
| Additional locales beyond English | Deferred because Section 5's repository layout already isolates strings in core/i18n/en.ts (Section 18.11), making it a pure data change later; not needed for launch. |
| Redis-backed job queue or message broker | Deferred because the Postgres-backed job table in Section 4's architecture handles v1 load; revisit only if throughput data after launch shows contention. |
| Public API keys, per-consumer quotas and paid tiers | Deferred. The v1 API is already open to third parties anonymously (Section 16.1, 16.11); what is deferred is per-consumer identity, which would be needed only to raise limits above the shared per-IP buckets in Section 16.7. |
| User-submitted custom hues or assets | Deferred because the asset pipeline is staff-curated by design in Section 1; community submission needs a moderation model not yet specified. |
27. Executor Instructions #
This section is written directly to the AI coding agent that will build Outlands SkinForge from this document. It assumes no prior conversation with the customer and no ability to ask one. Follow it literally.
27.1 How to read this document #
- This document has 28 top-level sections, each numbered
## <n>. <Title>. Each section OWNS one concern. Where a concept is used outside its owning section, it is referenced by number, never redefined. If any two sections appear to disagree, the OWNING section for that concern is correct and the other section is treated as informally paraphrasing it — this should not happen in a correctly drafted document, but if it does, resolve in favor of the owner. - Section ownership at a glance:
- Section 1 owns customization decisions and their defaults.
- Section 2 owns goals, non-goals, and success metrics.
- Section 3 owns the domain taxonomy (bodies, slots, layers, hues, glossary of game terms).
- Section 4 owns every dependency name and version line.
- Section 5 owns the repository layout and coding conventions.
- Section 6 owns the database schema.
- Section 7 owns asset discovery and extraction.
- Section 8 owns rendering (hue math, compositing, encoding, render URLs, cache keys).
- Section 9 owns ingestion, versioning, and patch re-sync.
- Section 10 owns public page routing and IA.
- Section 11 owns the designer UI's components and interaction model.
- Section 12 owns preview surfaces (paperdoll and per-slot static previews).
- Section 13 owns permalinks and the canonical design JSON.
- Section 14 owns Open Graph image generation.
- Section 15 owns catalog browse/search/filter.
- Section 16 owns the public HTTP API, including the envelope and pagination.
- Section 17 owns the admin console.
- Section 18 owns the design system and accessibility rules.
- Section 19 owns performance, caching, and CDN behaviour.
- Section 20 owns security, privacy, and legal posture.
- Section 21 owns observability.
- Section 22 owns the testing strategy and quality gates.
- Section 23 owns deployment and operations.
- Section 24 owns the environment variable table.
- Section 25 owns the error catalog.
- Section 26 (this document's execution plan) owns milestone sequencing.
- Section 27 (this section) owns how to execute the document.
- Section 28 owns reference appendices.
- A section number cited in prose (for example, "per Section 8.2") is the source of truth over any restatement of the same fact elsewhere. When implementing, open the cited section and follow it exactly, even if a summary elsewhere in the document seems to say something slightly different.
- Read the whole document once, start to finish, before writing any code. Re-read Section 26 (the milestone plan) before starting each milestone; re-read the specific owning sections listed in that milestone's scope before writing the code for it.
- Worked example of the cross-referencing rule: while building the catalog grid (milestone M8 in Section 26), the task list says to reuse "the pagination helper per Section 16." The correct action is to open Section 16, find its pagination sub-section, and implement exactly that cursor shape — not to design a new pagination scheme because the catalog "feels different" from a typical API listing. The same discipline applies to every other cross-reference in this document: treat the citation as an instruction to go read and comply, not as decorative context.
- When a section describes a table, schema, or contract with a fenced code block (SQL DDL, a TypeScript interface, a JSON example), that code block is normative. Field names, types, and nullability in the code block are exact requirements, not illustrative sketches.
- Numbers in this document (line counts, byte offsets, table row counts, timeouts, limits) are exact values to implement, not rough guidance. Where a table in Section 3 says 19 rows, the seeded table must contain exactly 19 rows — not "approximately 19."
27.1.1 Reading order for a first pass #
For an executor starting completely cold, read the 28 sections in this order rather than strictly numerically, because later foundational sections are needed to understand earlier feature sections: Section 2 (what is being built and why), Section 3 (the domain vocabulary used everywhere else), Section 4 (the stack), Section 5 (repository shape), Section 6 (data model), Section 24 (configuration — short, and referenced constantly), Section 25 (error catalog — short, and referenced constantly), then Sections 7 through 23 in numeric order, then this section, then Section 28 as a standing reference to return to while implementing. This reading order is a study aid only; the authoritative execution order for writing code is the milestone order in Section 26, which already accounts for dependency direction.
27.2 Start-up sequence #
Run these steps in order on a clean machine, before milestone M0 in Section 26 begins:
- Verify the toolchain:If any tool is missing or on a major line more than one behind the line in Section 4, install the current stable release for that major line.
deno --version # expect a 2.x line, per Section 4 psql --version # expect a 18.x line, per Section 4 (or docker for a disposable instance) docker --version - Install the current stable releases of every dependency listed in Section 4, using the Section 4 rule: confirm the resolved version's major line still matches Section 4's table, then let the lockfile record the exact patch version. Do not hand-pin patch versions in source.
- Create the repository skeleton exactly as laid out in Section 5 (folder tree, naming rules, module boundaries). Do not deviate from the folder names given there — every later section's file paths assume them.
- Start a PostgreSQL 18 instance (local install or
docker run postgres:18) and setSKINFORGE_DATABASE_URLper Section 24's format. - Run the first migration:This must create
deno task db:migrateschema_migrationsand every table in Section 6, per milestone M1 in Section 26. - Confirm the app boots:Expect HTTP 200. If it does not return 200, do not proceed to M1's remaining tasks — fix the boot failure first; a broken boot blocks every subsequent milestone's exit criteria.
deno task dev curl -sI http://localhost:8000/ - Run the empty test suite and lint/format checks:Both must exit 0 before starting M1's schema work.
deno task check deno task test - Verify object storage reachability before M4 needs it: if
SKINFORGE_STORAGE_DRIVER=fs(the default), confirmSKINFORGE_STORAGE_FS_ROOTexists and is writable; ifs3, confirm the bucket named bySKINFORGE_S3_BUCKETis reachable with the configured credentials using a simple put/get/delete of a throwaway key. Do this once at start-up time, not as a surprise discovered mid milestone M4. - Confirm the CLI and the web app share the same
core/library rather than diverging copies: rundeno check cli/main.tsanddeno check main.tsand confirm both succeed against the samedeno.jsonimport map. Divergence here is a sign the repository skeleton in Section 5 was not followed exactly, and must be fixed before any further work. - If working from a fresh clone rather than a brand-new repository (for example, resuming work started by a different agent or a previous session), run the entire start-up sequence again from step 1 rather than assuming a partially-configured environment is correct. A cheap re-verification is always less costly than debugging a stale environment assumption three milestones later.
27.3 The working loop per milestone #
For every milestone in Section 26, repeat this loop:
- Read the milestone. Read its goal, dependencies, scope, and task list in Section 26. Read every section referenced in its scope (for example, M2 requires re-reading Section 8 in full).
- Write the tests first for each task, following the test pyramid and golden-image rules in
Section 22. A task's test encodes its exit criteria from Section 26 wherever the exit criterion is
automatable; write it as an actual
deno testor Playwright spec, not a manual checklist. - Write the code to make the test pass, following the conventions in Section 5 (naming, module boundaries, lint rules) and the exact schemas/contracts owned by other sections (Section 6 for columns, Section 16 for API shapes, Section 8 for render behaviour, and so on).
- Run the quality gates from Section 22 (lint, type-check, unit tests, integration tests, and, for any milestone touching pixels, the golden-image suite) before considering the task done.
- Update the changelog. Append an entry to the developer's own
CHANGELOG.mddescribing the user-visible or operator-visible effect of the change, dated in UTC. - Commit with a Conventional Commit message (
feat:,fix:,chore:,test:,docs:,refactor:, scoped where useful, e.g.feat(render): implement partial hue transform). One logical change per commit; do not batch unrelated tasks into one commit. - Verify the milestone's exit criteria from Section 26 by literally running each check listed. Do not mark a milestone done from memory or inference — run the command or exercise the behaviour and observe the actual result.
- Move on to the next milestone per the dependency graph in Section 26.3. Do not start a milestone whose dependencies are not yet closed.
27.3.1 Worked example of one task through the loop #
To make the loop concrete, walk through one task from milestone M2 (Section 26.2.3): "Implement full-hue and partial-hue pixel transforms per the algorithm owned by Section 8.3."
- Read: re-open Section 8.3 (the hue algorithm — the only source of truth) and Appendix B (Section 28.2) for the worked numeric examples that reproduce it.
- Test first: write
core/hue/apply_test.ts, co-located beside the module it tests per Deno's own convention, with three cases — full hue on a pixel (recolored by its red-channel index regardless of greyness), partial hue on a grey pixel (all three unpacked channels exactly equal), and partial hue on a non-grey pixel that must pass through unchanged — using the exact input/output values from Appendix B. - Code: implement
applyHue(pixel, hueEntry, mode)incore/hue/apply.ts, andunpackArgb1555incore/hue/unpack.ts, against those tests. - Gates: run
deno test core/hue/apply_test.ts,deno fmt,deno lint. - Changelog: add a line noting the hue transform implementation.
- Commit:
feat(render): implement full and partial hue pixel transforms. - Exit criteria: this task is one input to M2's overall exit criteria (Section 26.2.3), which are verified once all of M2's tasks are complete, via the golden-image suite.
- Move on to the next task in M2's list (the compositor).
This is the granularity every task in Section 26 is expected to be executed at.
27.4 Decision protocol #
This document is the decision authority. When executing:
- If the document states a decision, follow it exactly. Do not substitute a personally preferred pattern, library, or convention.
- If something is genuinely not covered by any section — a situation the document's authors did not anticipate — resolve it by finding the nearest analogous decision already made in the document and extending it in the same style. For example, if a new admin list screen needs a default page size and none is stated for that specific screen, use the pagination default in Section 16 rather than inventing a new one.
- Record every such extension as a short entry in the developer's own
docs/adr/directory (one file per decision, numbered sequentially, stating the situation, the decision, and the section it extends). This creates a durable trail without modifying this specification. - Never stop work to ask a question. There is no one to answer it. A specification gap is resolved by the protocol above, not by pausing.
- Never invent a competing convention where one already exists (for example, do not introduce offset pagination anywhere because cursor pagination is already locked in Section 16; do not introduce a second error envelope shape because Section 16 already defines one).
- When two defaults from Section 1 interact in a way this document did not foresee, prefer the combination that keeps the public surface simplest for a first-time visitor and the admin surface safest for staff data integrity.
- Worked example: suppose, while building the admin tags screen (milestone M7), no section states
whether a tag name is case-sensitive. The nearest analogous decision is the
asset_keynatural-key convention in Section 6.10 and the lowercase-kebab-case naming rule in Section 5.3. Extend that pattern: store tag names lowercase, reject mixed case at the validation layer defined by the Zod schema module convention in Section 5 / Section 16.9, and record the extension indocs/adr/0004-tag-name-casing.md. - Worked example of the "keep going" rule: if, while implementing Stage D3 probing in milestone M3,
the operator's client directory contains a file type with no analogous decision anywhere in
Section 7 or Appendix A, do not halt the pipeline or the milestone. Classify the file as
unknownand give it anextraction_candidatesrow withstatus = 'needs_operator_input'per Section 7's degrade-gracefully rule, continue processing the rest of the directory, and surface it in the admin candidate queue (Section 17) for a human to resolve later. The milestone is still considered on track; an unresolved candidate is an expected, handled outcome, not a blocker.
27.5 When reality disagrees with the document #
The single area most likely to surprise the executor is the real UO Outlands client file layout, because Section 7 explicitly documents discovery as staged and adaptive rather than assuming a fixed layout. When the real files differ from what the format descriptions in Section 7 anticipate:
- Keep the staged pipeline. Do not replace Stage D1–D6 with a one-off script for the specific files encountered. The staged structure is what lets the pipeline degrade gracefully and lets staff intervene through the admin console per Section 7's contract.
- Extend the format readers, not the pipeline shape. If a UOP block chain uses an undocumented compression flag, add a case to the Stage D3/D4 reader that recognizes and handles it; do not fork a parallel extraction path.
- Record findings in the developer's own notes (
docs/format-notes.mdor similar, in the developer's repository, not in this specification) — the exact byte offsets, flags, or quirks discovered, so a future re-import after a game patch benefits from the same knowledge. - Preserve every interface that other sections depend on. Section 4's data flow, Section 6's table shapes, and Section 9's ingestion/publish contract must not change because one file format turned out to be unusual. The extraction layer absorbs the surprise; the rest of the system does not see it.
- If a container format genuinely cannot be parsed automatically (proprietary compression with no public documentation, for example), fall back to the manual-assist contingency defined in Section 26.4's Risk 1: a manual asset upload path in the admin console, so the product can still launch with a partial catalog while that one format's reader is finished later.
- If the hue range actually used by UO Outlands extends beyond the base 1..3000 space assumed in
Section 3, do not hard-code a new upper bound anywhere. The
huestable schema in Section 6 stores hue rows as data with asourcecolumn already anticipatingoutlands-sourced hues outside the base range; simply let the Stage D6 hue decoder in Section 7 write whatever rows it actually finds, tagged with the correctsourcevalue, and let downstream code read the range from the table rather than from a constant. - If gump id ranges used for Stage D5 classification do not cleanly map to the slot registry in
Section 3 for every sprite (some UO content historically reuses gump ids across contexts), leave
every ambiguous mapping's
extraction_candidates.statusat'pending'for staff review rather than guessing a slot. A wrong automatic classification that reaches the public catalog is worse than a slower manual review queue, because published assets are expected to remain stable per Section 9's retirement rules. - If real client files reveal additional cosmetic layers not in the 19-row slot registry in Section
3.3 (one required
bodyslot plus 18 choosable cosmetic slots,backpackincluded) — for example, a UO Outlands-specific cosmetic layer added after this document was written — do not silently add a slot. Record it as a candidate extension indocs/adr/, keep it out of the render pipeline and public UI until a deliberate decision is made, and flag it to the operator through the admin console's candidate queue. The 19-row slot registry in Section 3.3 is locked for this build; adding a slot is a scope change, not a routine extraction finding.
27.6 Non-negotiables #
The executor must never trade away any of the following, regardless of time pressure or a milestone running over its estimated effort:
- Pixel-exact rendering parity between the client-side canvas preview and the server-side composite, proven by the golden-image tests in Section 22. A visitor's shared permalink must always render exactly what they saw while designing.
- Permalink immutability. A design's canonical JSON and its rendered meaning never change after creation, per Section 13 and Section 9's retirement rules.
- No user accounts. No login, no password reset flow, no "my designs" list for public visitors, anywhere in the public surface.
- No raw game files served to users. End users receive only server-rendered PNG/WebP composites and pages; MUL/UOP/IDX files and any intermediate extraction artifact never leave the server.
- No third-party trackers. No third-party analytics scripts, no third-party cookies, no PII collection from public visitors, per Section 20.
- The fan-project disclaimer on every page. Every public page footer states the unofficial, fan-made nature of the project per Section 20.
- Accessibility conformance. WCAG 2.2 AA on every public page per Section 18, verified in milestone M12 by both the automated audit and the manual keyboard-only script from Section 22.6 — automated tooling alone does not exercise SC 2.1.4, SC 2.5.7 or SC 2.4.11, so an automated-only pass is not sufficient to satisfy this non-negotiable.
- Zero secrets in the repository. Every credential, token, and secret lives in an environment variable per Section 24, never committed, never logged in plaintext per Section 21.
Each non-negotiable exists because relaxing it silently breaks a promise made elsewhere in this document, often invisibly at first:
- Relaxing pixel-exact parity means a visitor's shared link can show a different image than what they designed, which breaks the entire premise of Section 13's permalink contract and is very difficult to detect without the golden-image discipline in Section 22 — by the time it is noticed, many permalinks may already be wrong.
- Relaxing permalink immutability breaks every previously shared link the moment a well-intentioned "fix" changes rendered output for existing codes; Section 9's retirement model exists specifically so catalog changes never do this.
- Adding accounts, even as an "optional" feature, reintroduces PII handling, session management for the public surface, and a moderation surface that Section 20's threat model does not cover; it is a scope change, not an enhancement, and is explicitly out of scope per Section 2.
- Serving raw game files, even briefly for debugging, risks distributing copyrighted client assets outside the fan-project's legal posture defined in Section 20.
- Any third-party tracker changes the privacy posture that Section 20 and the public disclaimer jointly promise visitors.
- Missing the disclaimer on even one page is a legal-posture gap, not a cosmetic omission — Section 20 treats it as a control, not a footer decoration.
- An accessibility regression on any public page fails the launch gate in milestone M12 regardless of how minor it looks, because Section 18's WCAG 2.2 AA target is a hard gate, not a goal to approach; relying on the automated audit alone would silently pass real regressions that only the manual keyboard-only script catches, so both gates are required, not just the one that is easy to automate.
- A single committed secret is a permanent compromise the moment the repository has any external collaborator, mirror, or backup; it cannot be un-leaked by a later commit removing it.
27.7 Common pitfalls and the correct approach #
| Pitfall | Correct approach |
|---|---|
| Smoothing pixel art when upscaling to scale 2 or 3 | Use nearest-neighbour integer upscaling only, per Section 8. Never use a canvas API or image library default that applies bilinear/bicubic filtering; explicitly disable smoothing (imageSmoothingEnabled = false on any 2D canvas context involved, and a manual nearest-neighbour loop in the WASM/TypeScript encoder path). |
| Forgetting partial hue | Partial hue remaps only pixels whose three unpacked channels are exactly equal (per the algorithm in Section 8.3 and the worked examples in Section 28.2, which reproduce it); applying full-hue logic to a partial-hue asset recolors pixels that must stay as-drawn. Always branch on the asset variant's hue_mode before transforming a sprite. |
| Mutating a design on remix | "Remix" (starting a new edit from an existing permalink) must copy the canonical JSON into a new transient editor state and never write back to the original designs row. The original code must keep resolving to its original JSON forever. |
| Breaking cursor stability in pagination | The opaque cursor per Section 16 must encode a stable sort key (creation order plus id tiebreaker), not an offset. Never derive the cursor from a value that can change between requests (such as a live count or a mutable updated_at). |
| Leaking untrusted asset names into HTML | Asset keys, tags, and any staff-entered free text (including Section 7's --note field) are untrusted input for HTML purposes even though they come from an internal pipeline, because they ultimately derive from arbitrary file names on the operator's disk. Always escape them through the framework's normal templating (Fresh/Preact JSX auto-escapes by default) and never interpolate them into a dangerouslySetInnerHTML or raw SQL string. |
| Blocking the event loop in the encoder | WASM encoding calls (@jsquash/webp, @jsquash/png) and the perceptual hash computation are CPU-bound; run them through the in-process job runner from Section 4's architecture (the Postgres jobs table with FOR UPDATE SKIP LOCKED) for batch extraction work, and keep the render-on-demand path's per-request work within the timeout budget in SKINFORGE_RENDER_TIMEOUT_MS (Section 24) so one slow render does not stall the whole Deno process. |
| Unbounded render concurrency | Always gate concurrent render/encode work behind SKINFORGE_RENDER_MAX_CONCURRENCY (Section 24) with an explicit queue and a documented HTTP 503 with Retry-After when the queue is full, per milestone M11 in Section 26. Never let an unbounded number of simultaneous encode calls run against the same process. |
| N+1 queries on the catalog grid | The catalog listing in Section 15 must fetch an asset page and its images/tags in a bounded number of queries (a join or a single batched IN (...) follow-up query), never one query per row. Verify this with the check in milestone M11's exit criteria. |
| Treating the discovery/extraction format tables as guaranteed-correct for the real client | Section 7 and Appendix A (Section 28.1) describe the best-known layout, not a verified fact about every UO Outlands client build. Follow the reality-disagreement protocol in Section 27.5 rather than assuming a decode failure is a bug in the reader. |
| Re-deriving the error envelope or pagination shape in a new endpoint | Every new API endpoint reuses the envelope and pagination helpers from Section 16; never hand-roll a slightly different JSON shape for a "special case" endpoint. |
| Validating a request shape twice, once by hand and once with the shared schema | The Zod schema module convention in Section 5 / Section 16.9 requires one schema module per domain object, imported by both the server handler and any island that needs client-side validation. Writing a second, slightly different manual check invites drift between client and server validation. |
| Assuming the designer's client-side compositor and the server's compositor can be two separate implementations that merely produce "similar" output | They must be the same algorithm applied to the same data, per the render contract in Section 8. A second, independently-written client compositor is exactly the failure mode the golden-image parity test in Section 22 exists to catch — do not treat that test as optional or as a "nice to have" once the UI looks right visually. |
| Hard-coding the hue range, slot count, or table names instead of reading them from Section 3 / Section 6 | Any of these written as a magic number (for example, looping for (let h = 1; h <= 3000; h++)) breaks the moment Stage D6 discovers Outlands-specific hues outside that range, per Section 7. Read counts and ranges from the seeded data or the schema, not from a literal in application code. |
Treating retired_at as a soft "hidden" flag that can later be reversed casually |
Retirement is part of the immutability contract in Section 9: a retired asset must keep rendering for any design created while it was active. Do not add a hard-delete path anywhere in the admin console, even as an "advanced" staff action, except through the legal takedown path in Section 20. |
| Skipping the no-JS fallback because "everyone has JavaScript enabled" | Section 11's designer UI and Section 27.10's acceptance script both require a working no-JS path. Search crawlers generating the Open Graph preview and accessibility tooling both exercise the server-rendered path; treat it as a first-class path, not a progressive-enhancement afterthought. |
Logging the full canonical design JSON or full request bodies at info level in production |
Section 21 owns the log schema; verbose payload logging at a default level bloats storage and can incidentally capture more of a request than intended. Follow the log schema and level conventions in Section 21 exactly rather than adding ad hoc debug logging that ships to production. |
27.8 Quality bar and self-review checklist #
Before declaring any milestone done, confirm every item:
- Every task in the milestone's task list (Section 26) has a corresponding commit.
- Every exit criterion for the milestone has been run and observed to pass, not assumed.
deno task check(lint, format, type-check) passes with zero warnings.- All tests required by Section 22 for this milestone's layer of the pyramid pass, including any golden-image tests if the milestone touches rendering.
- No new
TBD,TODO, placeholder string, or commented-out block was introduced. - No secret, token, or credential appears in source, in a commit message, or in a log statement.
- Every new table, column, endpoint, route, or environment variable matches the names already locked in Sections 6, 16, 10/11, and 24 respectively — no ad hoc renaming.
- The changelog entry and Conventional Commit message accurately describe the change.
- Any decision made under the protocol in Section 27.4 has a corresponding
docs/adr/entry. - The milestone leaves the app in a state where
deno task devboots and the previously passing exit criteria of all EARLIER milestones still pass (no regression).
27.9 Handover artifacts #
By the end of milestone M12 (Section 26), the executor must have produced, in the developer's own repository (not in this specification):
- A
README.mdcovering local setup, environment variables (pointing to Section 24's table), running migrations, running the CLI, and running tests. - The runbooks required by Section 23: deploy, rollback, backup/restore, and re-import after a game patch.
- Seeded fixtures under
tests/fixtures/sufficient for a new contributor to run the full test suite without access to the operator's real client files. - The first real import report: the output of
skinforge-cli importagainst the operator's actual client files, including the diff summary and any candidates left inpendingorneeds_operator_inputstate, handed to the operator for the first staff review pass. - The
docs/adr/directory containing every decision recorded under Section 27.4's protocol during the build. - A
CHANGELOG.mdwith one entry per milestone at minimum. - A short operator-facing guide (in the developer's own
docs/directory) explaining, in plain language and without assuming familiarity with this specification, how to trigger a re-import after a future UO Outlands client patch: which CLI commands to run, in what order, and how to use the admin candidate review queue to approve new or changed assets before they go live. This is the artifact the operator will actually use months after launch, when the details of this specification have faded from memory. - A list of every
docs/adr/entry produced during the build, summarized in one paragraph each, so the operator can see at a glance which decisions were extended beyond what this specification stated explicitly.
27.9.1 Why handover artifacts matter #
This specification is complete and self-contained, but it describes the system as designed, not as built. The handover artifacts in this sub-section are what let a human operator — who may never read this document — run, maintain, and extend the product safely. Treat producing them with the same rigor as passing a milestone's exit criteria; a milestone is not truly finished if its operational knowledge lives only in the executor's own reasoning during the build.
27.10 Final acceptance test script #
Run these checks in order against a fully deployed instance to confirm v1 is complete. Every check must pass.
curl -sI https://<public-base-url>/returns HTTP 200 and includes the security headers from Section 20.curl -s https://<public-base-url>/api/v1/slots | jq '.data | length'returns 19.- Load
/in a browser, select a body, a skin hue, and at least three cosmetic slots with hues, and confirm the canvas preview updates instantly for each change. Separately, confirm/designissues a 301 redirect to/(Section 10.1). - Click Share; confirm the browser navigates to
/d/:codewith a 10-character lowercase code and the page renders the same look shown in the designer. curl -sI https://<public-base-url>/og/d/:code.pngreturnsContent-Type: image/pngwith a long-livedCache-Controlheader per Section 19.- Reload
/d/:codewith JavaScript disabled; confirm the composite image and metadata still render server-side. - Visit
/catalog,/catalog/hair, and any/catalog/hair/:assetKeypage; confirm thumbnails load and pagination controls work using cursor parameters, not offsets. - Log in at
/adminwith a staff account and a valid TOTP code; confirm the dashboard loads. - From
/admin/candidates, approve one pending candidate, then runskinforge-cli import publish --build <label>, then confirm the asset appears at/catalog/:slotKeyon the next request. - From
/admin/imports, view the most recent import run's diff summary and confirm it lists added/changed/unchanged/retired counts consistent with the actual database state. - Attempt to access any
/admin/*route without a session cookie; confirm redirection to/admin. - Run the automated accessibility audit against the production URL and confirm zero violations, and separately run the manual keyboard-only script from Section 22.6 (including its SC 2.1.4, SC 2.5.7 and SC 2.4.11 checks) and confirm it passes — the automated tool alone does not cover WCAG 2.2 AA in full.
- Confirm the fan-project disclaimer text is present in the footer of
/,/catalog,/d/:code, and every other public page reachable from the sitemap. - Run the full automated test suite (
deno task test) and the Playwright suite against the deployed build (or a staging mirror) and confirm all tests pass. - Confirm
SKINFORGE_MAINTENANCE_MODEcorrectly takes the public site into a maintenance response when set, and back out of it when unset, per Section 23.9 (a container restart to pick up the changed environment variable is expected and is not a code redeploy).
A v1 build that passes all fifteen checks is complete per the definition in Section 26.5.
28. Appendices #
28.1 Appendix A — Binary format quick reference #
This appendix is a lookup card for the byte-level layouts used by the readers in Section 7. Section 7 owns the decoding algorithms and staged pipeline; this appendix restates only the layout facts needed while writing or debugging those readers.
28.1.1 MUL/IDX index record #
A classic UO "index" file (*.idx or an *idx.mul companion to a data .mul file) is a flat array of
fixed 12-byte records, one per entry, addressed by entry number:
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 4 bytes, int32le |
lookup |
Byte offset of the entry's data in the companion .mul file, or -1 (0xFFFFFFFF) if the entry is absent. |
| 4 | 4 bytes, int32le |
length |
Byte length of the entry's data, or -1 if absent. |
| 8 | 4 bytes, int32le |
extra |
Format-specific auxiliary value (for gump art: packed width/height; for some formats: unused, read as 0). |
Reading entry n means seeking to byte n * 12 in the index file, reading the 12-byte record, and,
if lookup != -1 and length != -1, reading length bytes starting at lookup in the companion data
file.
28.1.2 Gump art run-length encoding #
Gump art entries (used for sprites: bodies, hair, clothing, and every other visual slot in Section 3)
are stored as run-length encoded rows of ARGB1555 pixels:
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 4 bytes, uint32le |
width |
Sprite width in pixels (also derivable from the index extra field). |
| 4 | 4 bytes, uint32le |
height |
Sprite height in pixels. |
| 8 | height * 4 bytes |
rowOffsets |
One uint32le per row: the byte offset (from the start of the pixel data, i.e. after the row-offset table) where that row's run-length data begins. |
| 8 + height*4 | variable | rowData |
For each row, a sequence of runs. Each run is a uint16le colorOrCount pair: first a uint16le pixel color (ARGB1555, see 28.1.4) then a uint16le run length in pixels; a run with color 0 and any length denotes transparent pixels to skip. A run with length 0 ends the row. |
Decoding proceeds row by row using rowOffsets to seek, emitting runLength pixels of color
(transparent if color == 0) until the terminating zero-length run, producing a full width x height
RGBA buffer (color 0 maps to alpha 0, all other colors map to alpha 255 after ARGB1555
expansion).
The unit and origin of rowOffsets are not verified against a real Outlands file — community
implementations commonly disagree, some treating each entry as a byte offset from the start of the
pixel data (as tabulated above), others as a 32-bit-word offset from the start of the rowOffsets
table itself; getting it wrong decodes every sprite to garbage, the same failure mode 28.1.3 already
guards against for the hues.mul spacer. Per Section 27.5's reality-disagreement protocol, the Stage
D4 reader MUST decode row 0 under both hypotheses (bytes-after-table, and dwords-from-table-start) and
select whichever yields a run sequence that terminates within width pixels, rather than assuming the
byte-offset reading shown above is correct without checking.
28.1.3 hues.mul layout #
hues.mul is a flat array of fixed-size hue blocks, no index file. "Hue block" is this appendix's
name for the file-format grouping described here — eight raw hue entries sharing one 708-byte record —
which is a different concept from the curated hue_groups taxonomy table (Section 6.14, five rows:
skin, hair, tattoo, clothing, event) that the catalog and admin console use for browsing. A
Stage D6 step (Section 7, milestone M4 in Section 26.2.5), the same stage that decodes this block into
hues rows, assigns each decoded hue to one or more curated hue_groups rows via hue_group_members;
the raw block below plays no further part once decoding is done.
| Offset (within a block) | Size | Field | Meaning |
|---|---|---|---|
| 0 | 8 entries × 88 bytes = 704 bytes | entries[8] |
Eight fixed-size hue entries, see below. |
| 704 | 4 bytes | unused |
Historically a spacer; read and discard. |
Each 88-byte hue entry within the block:
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 32 × 2 bytes = 64 bytes | colorTable[32] |
32 ARGB1555 colors forming the hue's gradient/ramp, low to high luminance. |
| 64 | 2 bytes, uint16le |
tableStart |
Index into colorTable where "normal" application begins (implementation detail retained for parity with the original client; SkinForge's own hue math in Section 8.3 uses the full 32-entry table directly and does not need this field beyond provenance). |
| 66 | 2 bytes, uint16le |
tableEnd |
Companion end index to tableStart. |
| 68 | 20 bytes | name |
ASCII hue name, null-padded, not guaranteed to be present or meaningful for custom Outlands hues. |
Total file size is blockCount * 708 bytes; blockCount is 375 in the stock client and MUST be
re-derived from the actual file size (fileSize / 708) rather than hard-coded, per Section 27.5's
reality-disagreement protocol, since UO Outlands may extend this file.
Whether the 4-byte spacer precedes or follows the eight entries within a block is not verified against
a real Outlands file — every widely-circulated community description disagrees on this point, and
getting it wrong shifts every offset in the table above by 4 bytes, decoding all ~3,000 hues to
garbage. Per Section 27.5's reality-disagreement protocol, the Stage D2/D4 reader MUST validate by
decoding hue index 1 under both hypotheses (spacer-first, entries at offset 4..707; or spacer-last as
tabulated above, entries at offset 0..703) and selecting whichever hypothesis produces a printable-ASCII
name field, rather than assuming the layout shown here is correct without checking.
28.1.4 ARGB1555 packing #
A 16-bit ARGB1555 value packs one alpha bit and 5 bits each of red, green, blue:
bit: 15 14-10 9-5 4-0
field: A R G BUnpacking to 8-bit-per-channel RGBA — this is the one unpackArgb1555 implementation used everywhere
in this specification, owned by Section 8.3.1, reproduced here verbatim:
function unpackArgb1555(value: number): { r: number; g: number; b: number; a: number } {
const r5 = (value >> 10) & 0x1f;
const g5 = (value >> 5) & 0x1f;
const b5 = value & 0x1f;
// 5-bit to 8-bit: replicate the top 3 bits into the low 3 bits for even spread across 0..255
const scale = (v5: number) => (v5 << 3) | (v5 >> 2);
return {
r: scale(r5),
g: scale(g5),
b: scale(b5),
a: value === 0x0000 ? 0 : 255,
};
}Sprite transparency comes from the run-length color == 0 sentinel in 28.1.2, never from bit 15: the
packed value 0x0000 is the transparency convention, and bit 15 is not consulted at all — a sprite
pixel is transparent exactly when its packed source color is zero, opaque otherwise. Packing the
reverse direction truncates each 8-bit channel to its top 5 bits and sets the source color to 0x0000
when the source alpha is zero, to any non-zero packed value otherwise.
28.1.5 UOP MythicPackage layout #
UOP files use a chained-block container format:
File header (fixed, at offset 0):
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 4 bytes | magic |
ASCII MYP\0. |
| 4 | 4 bytes, uint32le |
version |
Format version. |
| 8 | 4 bytes, uint32le |
formatTimestamp |
Build timestamp, format-defined. |
| 12 | 8 bytes, int64le |
firstBlockOffset |
Byte offset of the first block header. |
| 20 | 4 bytes, uint32le |
blockSize |
Number of entries per block ("max entries per block"). |
| 24 | 4 bytes, uint32le |
entryCount |
Total entry count across all blocks. |
Block header (at firstBlockOffset, then chained):
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 4 bytes, uint32le |
entriesInBlock |
Number of entries in this block. |
| 4 | 8 bytes, int64le |
nextBlockOffset |
Offset of the next block, or 0 if this is the last block. |
Entry record (repeated entriesInBlock times immediately after the block header):
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 8 bytes, uint64le |
dataOffset |
Byte offset of this entry's compressed/raw data. |
| 8 | 4 bytes, uint32le |
headerLength |
Length of a per-entry data header to skip before the payload. |
| 12 | 4 bytes, uint32le |
compressedSize |
Size of the stored payload. |
| 16 | 4 bytes, uint32le |
decompressedSize |
Size after decompression (equal to compressedSize if uncompressed). |
| 20 | 8 bytes, uint64le |
pathHash |
64-bit hash of the entry's original virtual file path (order-independent identifier; matched against a known hash table for the expected virtual paths, since UOP files do not store plain filenames). |
| 28 | 4 bytes, uint32le |
headerHash |
Adler32 or format-defined checksum of the header; used for integrity verification. |
| 32 | 2 bytes, uint16le |
compressionFlag |
0 = uncompressed, 1 = zlib-compressed (per Section 7's Stage D3 compression sniffing, treat any other value as unknown/needs_operator_input rather than assuming zlib). |
To read an entry: seek to dataOffset, skip headerLength bytes, read compressedSize bytes, and if
compressionFlag == 1, zlib-inflate to decompressedSize bytes.
28.2 Appendix B — Hue math worked examples #
These examples use the hue algorithm owned by Section 8.3 — the only source of truth for the unit
tests and golden images built in milestone M2 (Section 26.2.3) — and reproduce its numbers exactly;
this appendix is a lookup card, never an independent restatement. Each example shows one input pixel
(as a packed 16-bit ARGB1555 value), one hue table entry, and the resulting output pixel, with every
intermediate value shown so the transform is checkable by hand using unpackArgb1555 (28.1.4).
28.2.1 Example 1 — full hue on a mid-tone pixel #
Input packed pixel: c = 0xC104. Unpacking per 28.1.4:
r5 = (c >> 10) & 0x1F = 0b10000 = 16→r8 = (16 << 3) | (16 >> 2) = 128 | 4 = 132.g5 = (c >> 5) & 0x1F = 0b01000 = 8→g8 = (8 << 3) | (8 >> 2) = 64 | 2 = 66.b5 = c & 0x1F = 0b00100 = 4→b8 = (4 << 3) | (4 >> 2) = 32 | 1 = 33.a = c === 0x0000 ? 0 : 255 = 255(cis non-zero).
Unpacked source pixel: r=132, g=66, b=33, a=255.
Full hue mode replaces every non-transparent pixel's color with the hue table color at the index taken directly from the source pixel's own 5-bit red channel, ignoring every other channel and ignoring whether the pixel is grey (this is what makes "full hue" produce flat, uniformly recolored art regardless of the source sprite's original palette):
index = r8 >> 3 = 132 >> 3 = 16. (This always recovers the original 5-bit value: the low 3 bits the unpacking step added back in never push the shifted result into the next bucket, soindex = r8 >> 3 = r5for everyr5in0..31.)- Look up
colorTable[16] = { r: 200, g: 40, b: 40 }(the hue table entry for this hue, per Section 8.3.2). - Output pixel:
r=200, g=40, b=40, a=255— alpha is carried over unchanged from the unpacked source pixel; full hue replaces only the color channels.
28.2.2 Example 2 — partial hue on a grey pixel #
Input packed pixel: c = 0x4210 (r5 = g5 = b5 = 0b10000 = 16 — chosen so all three 5-bit channels
are identical). Unpacking gives r8 = g8 = b8 = 132 (the same (16 << 3) | (16 >> 2) expansion
computed in 28.2.1) and a = 255 (c is non-zero).
Partial hue mode recolors a pixel only when its three unpacked 8-bit channels are exactly equal
(r8 === g8 && g8 === b8, no tolerance window), using the same red-channel index rule as full hue for
those pixels, and leaves every non-grey pixel completely unchanged:
- Exact-equality check:
r8 === g8 === b8 === 132→ true → the pixel qualifies for recoloring. index = r8 >> 3 = 16(same derivation as 28.2.1).- Look up
colorTable[16] = { r: 200, g: 40, b: 40 }. - Output pixel:
r=200, g=40, b=40, a=255.
28.2.3 Example 3 — partial hue on a non-grey pixel (pass-through) #
Same input pixel as Example 1: c = 0xC104, unpacking to r8=132, g8=66, b8=33, a=255 — not grey.
- Exact-equality check:
132 === 66is false → the pixel does NOT qualify for recoloring. - Output pixel: unchanged,
r=132, g=66, b=33, a=255.
This is the behaviour the pitfall table in Section 27.7 refers to when it warns against applying
full-hue logic where partial-hue is required: Examples 1 and 3 hue the identical source pixel
differently only because the asset's hue_mode differs, never because the pixel itself changes — full
hue (28.2.1) recolors it via the red-channel index regardless of greyness, partial hue (28.2.3) passes
it through unchanged because it fails the exact-equality test.
28.2.4 Hue index 0 (unhued) #
Hue index 0 is a sentinel meaning "apply no hue transform at all" — the sprite's own decoded RGBA
values pass through unchanged, regardless of full or partial mode. This is the "as-drawn" case
referenced in Section 13's canonical design JSON rules.
28.3 Appendix C — Slot registry lookup card #
Consistent with the taxonomy owned by Section 3. Reproduced here as a flat reference card. 19 rows
total: one required body slot (always part of every design) plus 18 choosable cosmetic slots,
backpack included — 17 of those 18 are slots a design may leave entirely empty, and the 18th,
backpack, is choosable like any other slot but always renders (a default asset key,
backpack.default, is used when the visitor has not chosen one — Section 13.2.1), since a paperdoll
never shows an empty back.
| z | slot_key | Display name | uo_layer | Hueable | Gender |
|---|---|---|---|---|---|
| 10 | body |
Body | — | yes | both (required) |
| 20 | tattoo_body |
Body Tattoo | — | yes | both |
| 30 | footwear |
Footwear | 3 | yes | both |
| 40 | legs_inner |
Pants | 4 / 24 | yes | both |
| 50 | torso_inner |
Shirt | 5 / 13 | yes | both |
| 60 | torso_middle |
Chest Piece | 17 | yes | both |
| 70 | arms |
Arms | 19 | yes | both |
| 80 | gloves |
Gloves | 7 | yes | both |
| 90 | waist |
Belt / Sash | 12 | yes | both |
| 100 | legs_outer |
Skirt / Kilt | 23 | yes | both |
| 110 | torso_outer |
Robe / Outer Torso | 22 | yes | both |
| 120 | neck |
Neck | 10 | yes | both |
| 130 | hair |
Hair | 11 | yes | both (different style sets) |
| 140 | facial_hair |
Beard | 16 | yes | male |
| 150 | face |
Face Art | 15 | yes | both |
| 160 | earrings |
Earrings | 18 | yes | both |
| 170 | head |
Hat / Helm | 6 | yes | both |
| 180 | cloak |
Cloak | 20 | yes | both |
| 190 | backpack |
Backpack | 21 | yes | both (always visible) |
Excluded layers, never modeled as slots: one-handed weapon (1), two-handed weapon (2), ring (8), bracelet (14), talisman (9), and any stat-bearing equipment catalog beyond visible cosmetics.
Body codes: m (Male, paperdoll base gump 12 / 0x000C), f (Female, paperdoll base gump 13 /
0x000D). These are the only two designable bodies.
28.4 Appendix D — Canonical design JSON reference #
Consistent with the format owned by Section 13. One compact example, with slots already in the
required ascending z-order (torso_outer=110 before hair=130, per Appendix C):
{"v":1,"body":"m","skinHue":1002,"slots":[{"slot":"torso_outer","asset":"robe.plain","hue":0},{"slot":"hair","asset":"hair.long-wavy","hue":1102}]}Field reference:
| Field | Type | Notes |
|---|---|---|
v |
integer | Schema version, currently 1. |
body |
"m" | "f" |
Required. |
skinHue |
integer | Hue index applied to the body slot; 0 means as-drawn. |
slots |
array | Sorted by the z-order in Appendix C; entries for empty slots are omitted entirely. One entry per occupied slot, out of the 18 choosable slots (the 17 optional slots plus backpack) — never an entry for body itself. |
slots[].slot |
string | One of the 18 choosable slot_key values in Appendix C. |
slots[].asset |
string | An asset_key natural key, e.g. hair.long-wavy. |
slots[].hue |
integer | 0 means as-drawn. |
Canonicalization for hashing (Section 13.3): keys emitted in the fixed order v, body,
skinHue, slots — not alphabetically sorted — and within each slot entry, the fixed order slot,
asset, hue; no insignificant whitespace; UTF-8 encoding; exactly as shown in the compact example
above (which is already in canonical form). A fixed emission order is what makes the form
deterministic; a generic "sort object keys" step would not reproduce the example, since an
alphabetical sort of v, body, skinHue, slots is body, skinHue, slots, v.
Query-string (transient, pre-share) form of the same design:
?b=m&s=1002&hair=hair.long-wavy:1102&torso_outer=robe.plain:0One query parameter per non-empty slot, named after the slot_key, valued assetKey:hue (order is
not significant in this transient form, unlike the permanent canonical form above); b is the body
code and s is the skin hue. This form is never persisted as-is (Section 13.6); pressing Share
converts it to canonical JSON, computes the short code, and the client replaces the address bar with
/d/:code via history.pushState (Section 11.9) — there is no full-page redirect.
28.5 Appendix E — Glossary #
Alphabetical. Covers both the UO Outlands game domain and this system's own vocabulary.
| Term | Meaning |
|---|---|
| Admin console | The staff-only web interface for reviewing and publishing assets, owned by Section 17. |
| ARGB1555 | A 16-bit pixel format: 1 alpha bit, 5 bits each red/green/blue. See Appendix A.4. |
| Asset | A single designable visual item (a hair style, a shirt) with one asset_key, owned by Section 6. |
| Asset key | The stable, human-readable natural key for an asset, e.g. hair.long-wavy. |
| Asset variant | A per-body sprite record for an asset (geometry, offsets, hash), scoped to the build it was extracted from — one row per (asset, body, build) triple. |
| Body | One of the two designable player bodies, body code m (Male) or f (Female). |
| Build | One versioned extraction run tied to a specific state of the operator's client files, table game_builds. |
| Canonical JSON | The fixed-key-order, whitespace-free JSON form of a design used for hashing, owned by Section 13. |
| Catalog | The public browsing surface for assets and hues, owned by Section 15. |
| Classification | Stage D5 of extraction: mapping decoded sprites to slots and bodies. |
| Compositing | Layering multiple hued sprites onto one canvas in z-order, owned by Section 8. |
| Compression sniffing | Detecting zlib/deflate/LZ4 signatures in an unknown container, Stage D3. |
| Cursor pagination | The only pagination model used in this system's API; opaque cursor, no offsets. |
| Design | One saved, immutable combination of body, skin hue, and slot choices, table designs. |
| Discovery | Stage D1–D3 of extraction: inventorying and identifying files before extracting them. |
| Entropy (file) | A statistical measure used in Stage D1 to help distinguish compressed/unknown data from structured data. |
| Extraction | Stage D4–D6: decoding identified files into usable sprites and hue tables. |
| Extraction candidate | A file or record flagged by discovery as possibly containing designable art, awaiting classification or review. |
| Fan-project disclaimer | The persistent, mandatory statement that SkinForge is unofficial and unaffiliated with the game's operators. |
| Full hue | A hue mode that recolors every non-transparent pixel using the hue table index taken from the pixel's own 5-bit red channel, ignoring whether the pixel is grey. |
| Golden-image test | A test that compares rendered output byte-for-byte against a committed reference image. |
| Gump | Classic UO term for a 2D art asset, including paperdoll sprites, decoded via run-length encoding. |
| Gump id | The numeric identifier of a gump art entry within the client's art files. |
| Hue | A recoloring table applied to a sprite; index 0 means unhued. |
| Hue block | This appendix's name for the file-format grouping in hues.mul: 8 raw hue entries sharing one 708-byte record. A different concept from "hue group" below; see Appendix A.3 (28.1.3). |
| Hue group | A curated taxonomy row (table hue_groups, Section 6.14) used to organize hues for browsing and picking — 5 fixed rows (skin, hair, tattoo, clothing, event), unrelated to the raw 8-entry file blocks decoded from hues.mul (see "Hue block" above). |
| Hueable | Whether a given slot accepts a hue selection, per the registry in Section 3 / Appendix C. |
| IDX file | The index file paired with a .mul data file, fixed 12-byte records. See Appendix A.1. |
| Immutability (design) | The guarantee that a permalink's rendered meaning never changes after creation. |
| Ingestion | Turning an extraction run into diffed, reviewable, publishable catalog data, owned by Section 9. |
| Job runner | The in-process, Postgres-backed background work system with no external broker. |
| Layer (UO) | The classic UO equipment slot numbering system; SkinForge's uo_layer column references it where applicable. |
| MUL file | A classic UO flat binary data file, typically paired with an .idx index. |
| Natural key | A stable, human-readable identifier (e.g. asset_key) used in URLs and JSON instead of a numeric id. |
| Normalization (Stage D6) | Trimming, re-encoding, and hashing extracted sprites into final stored assets; also the stage that decodes hues.mul into hues rows and assigns each to its curated hue_groups row via hue_group_members (Section 7.7). |
| Object storage | The pluggable file storage layer (fs or s3 driver), owned by Section 4's architecture. |
| OG image | The Open Graph social preview image auto-generated per permalink, owned by Section 14. |
| Paperdoll | The static, front-facing full-body character illustration UO Outlands renders in-game; SkinForge's one supported pose. |
| Partial hue | A hue mode that recolors only pixels whose three unpacked RGB channels are exactly equal (no tolerance window) and leaves every other pixel unchanged. |
| Perceptual hash | A hash sensitive to visual similarity rather than exact bytes, used to detect near-duplicate sprites during normalization. |
| Permalink | The permanent, immutable share URL for one design, /d/:code. |
| Provenance | The recorded source file, offset, and build for every extracted asset. |
| Publish | Promoting a reviewed build's assets to live, publicly visible status. |
| Rate limit | A Postgres-backed token-bucket throttle applied to API and render endpoints. |
| Render URL | The content-addressed URL scheme for composite and per-asset images, owned by Section 8. |
| Retirement | Soft-deleting a catalog row (retired_at) while it remains renderable for old designs. |
| Rollback (publish) | Republishing a previous build's asset set without re-running extraction. |
| Short code | The 10-character Crockford Base32 identifier derived from a design's canonical JSON hash. |
| Skin hue | A hue tagged into the skin hue group, applied to the body slot. |
| Slot | One designable position in the composite (e.g. hair, footwear), 19 total including body and backpack. |
| Slot key | The canonical string identifier for a slot, e.g. torso_outer. |
| Source file | One file recorded during discovery, with its detected format and metadata. |
| Takedown request | The formal legal/removal request workflow, owned by Section 20. |
| Tiledata | Classic UO metadata file describing item/art flags, used to help classify sprites in Stage D5. |
| TOTP | Time-based One-Time Password, RFC 6238, used for mandatory admin second-factor authentication. |
| UOP file | MythicPackage container format, chained blocks of compressed entries. See Appendix A.5. |
| Z-order | The bottom-to-top stacking order of slots in the composite, fixed in Section 3 / Appendix C. |
28.6 Appendix F — External references #
| Reference | What it is for |
|---|---|
| WCAG 2.2 (W3C Recommendation) | The accessibility conformance standard targeted at AA level, owned by Section 18. |
| Open Graph protocol (ogp.me) | The metadata standard behind the social preview images in Section 14. |
| Crockford Base32 (Douglas Crockford's specification) | The alphabet and encoding used for permalink short codes in Section 13. |
| ARGB1555 / 16-bit color packing (general graphics reference) | The pixel format used throughout the classic UO art files, detailed in Appendix A.4. |
| WebP image format (Google, via the WebP container specification) | The default render output format, encoded via the codec named in Section 4. |
| Argon2 (RFC 9106) | The password hashing algorithm used for admin authentication in Section 17. |
| TOTP (RFC 6238) | The one-time-password algorithm used for mandatory admin second-factor authentication. |
| Conventional Commits (conventionalcommits.org) | The commit message convention required by Section 27.3. |
| The ClassicUO project (open-source UO client reimplementation) | A public reference implementation useful for cross-checking MUL/UOP/gump decoding behaviour during Section 7's development. |
| Community UO file-format documentation (various long-standing UO emulation community wikis and forums) | General public reference material on MUL/UOP/hues.mul layouts, useful background for Section 7 and Appendix A; treat any single community source as informative, not authoritative, and verify against real extracted files per Section 27.5. |
| PostgreSQL 18 official documentation | Reference for the database engine named in Section 4. |
| Deno 2.x and Fresh 2.x official documentation | Reference for the runtime and framework named in Section 4. |
28.7 Appendix G — One-page system summary #
What it is: a free, no-login web tool where UO Outlands players design a human male or female character's cosmetic appearance (skin hue plus 18 choosable hueable slots, backpack included) and share it as a permanent link with an auto-generated social preview image. A staff-only pipeline extracts the underlying art and hue data from the operator's own UO Outlands client files; nothing is scraped from or downloaded by end users.
The stack (full detail in Section 4): Deno 2.x runtime, Fresh 2.x web framework with Preact 10.x islands, PostgreSQL 18 via postgres.js, pure-TypeScript-plus-WASM rendering (no native image libraries), Docker Compose deployment behind Caddy, optional S3-compatible object storage.
The five surfaces:
- The public designer (Sections 10–13): build a look, see it live, share it.
- The catalog (Section 15): browse every published asset and hue independently of the designer.
- The public API (Section 16): the JSON contract every page and island consumes.
- The admin console (Section 17): staff review, approval, and publish workflow.
- The extraction pipeline (Sections 7 and 9): discovery, decoding, ingestion, and re-sync after patches, run entirely by staff via the CLI.
Data flow, end to end: operator's client files → discovery (Stage D1–D3) → extraction (Stage D4–D6) → staff review in the admin console → publish → public catalog and designer read published assets → a visitor's chosen combination becomes a design → the design is hashed into a short code and persisted as a permalink → the permalink page and its Open Graph image are rendered server-side from the same composite algorithm the designer used client-side, guaranteeing pixel parity.
The ten decisions that matter most:
- Exactly two bodies (Male
m, Femalef), one static paperdoll pose, no animation. - A 19-row slot registry — one required
bodyslot plus 18 choosable cosmetic slots,backpackincluded (uniquely among them,backpackalways renders a default asset when left unset, since a paperdoll never shows an empty back) — with a fixed z-order, owned by Section 3. - No user accounts anywhere; permalinks are the entire persistence model for end users.
- Pure TypeScript plus WASM rendering, avoiding native image libraries and headless browsers.
- Cursor-only pagination and one consistent success/error envelope across the entire public API.
- Asset discovery is staged and adaptive (Stages D1–D6), never assuming a fixed, known file layout.
- Every catalog row is soft-deleted (
retired_at) so permalinks never break, and designs themselves are fully immutable and never auto-deleted — the only removal path is the legal takedown process (Section 20.8). - Admin authentication requires TOTP for every staff account; there is no public account system to compare it against.
- No third-party trackers, no cookies for public visitors, no PII — aggregate counters only.
- The fan-project disclaimer is a persistent, non-negotiable control, not a cosmetic footer note.
Generated at GenerateSpecs.com — one idea in, one buildable spec out.
Licensed under CC BY 4.0. Use it for anything — just credit GenerateSpecs.com.