01-projects/printables-product/audit-2026-09-02

Scribble Works — Architecture & Code Quality Audit

2026-09-02·audit·status: reference
scribble-worksprintables-productauditcloudflarecode-qualityengineering

Auditor: zero-context senior staff engineer (no prior history with this repo). Method: read-only clone of RayDataCo/scribble-works @ e58085b, ~21,900 lines of first-party source across functions/, src/, scripts/, workers/, migrations/. Ran npm run build (green, 32 pages, 942 ms) and all ten npm run test:* scripts (green, ~1,000 assertions). Nothing was edited, committed, or deployed.


Scores at a glance

# Area Score
1 Separation of concerns / duplication across the API functions 3 / 5
2 Data layer (D1 schema, migrations, KV races, retention, indexes) 3 / 5
3 Error handling & observability 2 / 5
4 Test strategy 3 / 5
5 Build / render pipeline 4 / 5
6 Client code quality 3 / 5
7 Config & secrets hygiene 3 / 5
8 Maintainability & onboarding cost 3 / 5

Weighted read: a solid 3. Above the median for a five-week-old solo product, well above anything that deserves the phrase "vibe-coded", and held back by four specific, nameable gaps rather than by diffuse sloppiness.


1. Separation of concerns and duplication — 3/5

There is a shared layer, and it was built deliberately rather than retrofitted. src/lib/customize/art-rail.js:1-5 says so out loud: it was factored out the day the second image endpoint arrived, with the reason stated ("the copy that drifts is the one that stops sending cf-aig-collect-log: false"). functions/_lib/session.js:1-4 exists purely so Functions code never reaches four dots into src/. src/lib/auth/{guard,limits,session,store,tokens,state}.js is a properly layered auth module with one file per concern and one file (store.js) holding every D1 statement so "what can touch the accounts database" is one wc -l away (store.js:1-2).

What holds the score at 3 is that the two oldest endpoints never got pulled into it, so the same primitives now exist four and five times over:

Primitive Copies
takeSlot (KV read-modify-write counter) functions/api/customize.js:110, src/lib/customize/art-rail.js:38, src/lib/auth/limits.js:19, plus increment() at functions/api/downloads.js:74
json() / NO_STORE customize.js:78-79, shopper.js:54-55, customize-art.js:96-97, customize-icons.js:86-87, downloads.js:40, art/[id].js:14, workers/feedback-intake/src/index.js:21 — and a canonical one already exists at src/lib/auth/session.js:37-38
sha256Hex customize.js:94, shopper.js:70, art-rail.js:28 (as hashHex), src/lib/auth/tokens.js:17
utcDay / utcMonth customize.js:99-100, shopper.js:75-76, art-rail.js:32-33
isEnabled / messagesUrl / callModel customize.js:83-92,135-164 and shopper.js:59-68,108-135, near-verbatim; art-rail.js:69-97 is the better third version
timingSafeEqual downloads.js:133, src/lib/auth/tokens.js:44, workers/feedback-intake/src/twilio.js:45

limits.js:18 even carries the comment "Read-modify-write, same shape (and the same accepted slip) as customize.js" — the duplication is known and consciously carried, which is better than accidental, but it is still four places to fix when the counter semantics change. The five endpoints have three different gate vocabularies (unchanged(code), fallback(note), out({code})) for what is structurally the same decision tree.

Positive: the handleX(request, env, deps = {}) + thin onRequestPost shape is used consistently across all five (customize.js:191/301, shopper.js:140/218, state.js:21/72, me.js:10/25, art/[id].js:17/41). That single convention is what makes the whole test suite possible.

2. Data layer — 3/5

Schema and migrations are genuinely good. Three numbered migrations, IF NOT EXISTS throughout, every table indexed on its actual access path (0002_feedback.sql:29-30, 0003_accounts.sql:55,68-69,80), a CHECK constraint that forces future identity providers to be a deliberate migration (0003_accounts.sql:49), and a privacy split that is architecturally enforced rather than promised: feedback holds only sender_hash, feedback_contacts holds the raw address keyed by that hash, so deleting one contact row anonymises every message that person ever sent (0002_feedback.sql:6-12). Magic-link single-use is enforced by the write, not a read-then-write — used_at IS NULL is in the WHERE clause, so two concurrent confirms cannot both succeed (src/lib/auth/store.js:34-43). Tokens are never stored raw and every hash is peppered with SESSION_SECRET (tokens.js:5-7,22-26).

Two real defects.

(a) The KV limiters are not just lossy — they are bypassable, and they are the cost ceiling. takeSlot is get → compare → put with no atomicity (customize.js:110-115). The code comments frame this as an acceptable undercount ("a lost increment under a race is an acceptable undercount", downloads.js:26-28). That framing is right for a download counter and wrong for cz:month:<YYYY-MM>, which is the only thing standing between the account and an unbounded model bill: N requests fired in parallel all read the same value and all take a slot. art-rail.js:139-145 adds a serialize() helper for exactly this reason — but it only serialises within one request, so it fixes the two-icons-in-parallel case and does nothing about two browsers. The daily 3/browser gate has the same shape. A Durable Object counter, or a D1 UPDATE … SET n = n + 1 WHERE n < ?, closes it for the ceiling keys.

(b) Retention is stamped and never enforced. retain_until is written on every row in both customizations (src/lib/ugc/log.js:104) and feedback (workers/feedback-intake/src/store.js:59), the migrations document 90- and 180-day policies (0001_customizations.sql:7, 0002_feedback.sql:13-14), the README repeats it (workers/feedback-intake/README.md:97) — and there is no purge job anywhere in the repo. No [triggers] crons in either wrangler config, no DELETE FROM statement in any file, no scheduled handler. Rows and R2 photos accumulate forever. Since /privacy is a published page, this is a stated-policy-vs-code gap, not only a housekeeping one. There is also no index on customizations.retain_until for the purge scan when it is written, and nothing ever reaps expired sessions / magic_links rows.

3. Error handling and observability — 2/5

This is the weakest area and the one that will cost the most in production.

The fail-closed design is correct and well-executed: every generative gate degrades to a useful answer instead of a 5xx (customize.js:50-52, shopper.js:31-33), and auth deliberately inverts that posture to fail closed (store.js:5-8). A parent never sees a stack trace. Good.

But a 4xx from the model is indistinguishable from a timeout, and both are indistinguishable from a bad gateway token. callModel builds a precise error string — gateway 401: authentication_error, timeout after 12000ms — at customize.js:157-160 / shopper.js:128-131, assigns it to priorError, feeds it into the re-ask prompt, and then throws it away. Both handlers end at return unchanged('model_unavailable') (customize.js:297) / return fallback('model unavailable') (shopper.js:214). So an expired CF_AIG_TOKEN, a 429, a malformed request body, and a slow model all produce byte-identical 200 responses. The site will look like it is working while the entire generative rail is dead, and nothing anywhere will say so.

Supporting evidence:

4. Test strategy — 3/5

What is there is real, and better than the norm. All ten suites run green from a clean clone:

taxonomy ✓  playsets ✓  slots 19 ✓  shopper 86 ✓  customize 363 ✓
ugc 52 ✓  maze 53 ✓  downloads 21 ✓  feedback ✓  auth 255 ✓

They test behaviour through the real handler, not mocks of it — scripts/test-customize.mjs (1,591 lines) imports handleCustomize from functions/api/customize.js and drives it with injected fetch/now/timings/manifests. That is only possible because of the deps = {} convention, and it is the single best architectural decision in the repo. Gate ordering is asserted, not assumed (src/lib/auth/guard.js:6-11). scripts/test-validate-taxonomy.mjs and test-validate-playsets.mjs go further and test the validators by building dirty trees and asserting the build goes red.

What is missing:

5. Build / render pipeline — 4/5

The strongest area. prebuild (package.json:7) chains eleven steps — build feedback catalog, validate taxonomy, validate playsets, author-maze --check, validate slots, build shopper catalog, build slot manifests, export thumbnails, sync previews, sync game HTML, check R2 objects — and any failure kills the build. validate-taxonomy.mjs enforces a founder ruling structurally: the build fails if pack: / pack_page: reappear in a game schema (README.md:8-10).

Generated files (src/data/slot-manifests.json 98 KB, slot-manifests.js, shopper-catalog.json) are committed rather than gitignored, which is the classic drift setup — but it is handled correctly: they carry a // GENERATED by … — do not edit header, they are regenerated at every prebuild, and the .js twin exists for a real reason (the Workers runtime has no filesystem, so functions/api/customize.js:70 must import the manifests). I verified there is no drift today: npm run build regenerated everything and git status --porcelain came back empty.

Held to 4, not 5, by: nothing enforces that check (no CI, so a hand-edited manifest merges silently), and the deploy config is not reproducible — see §7.

6. Client code quality — 3/5

src/scripts/customize.js is 804 lines of which lines 104-804 are one function, installCustomize(). Seven hundred lines, one scope, no state machine — the flow (idle → submitting → words landed → art drawing → art landed/failed → applied) is tracked by ad-hoc locals and hidden/classList toggles, with three parallel copy tables (ART_COPY, ICON_COPY, LOADING_COPY, WORKING_COPY) and three abort budgets (ABORT_MS, ART_ABORT_MS, ICON_ABORT_MS) threaded through by hand. It works, the comments are excellent, and it is the file most likely to break on the next feature. src/scripts/shopper.js (595) and playset.js (477) have the same shape. Splitting the sheet, the Stage-3 art poller and the apply/highlight pass into three modules with an explicit state variable is a mechanical, low-risk refactor.

Better than expected:

7. Config and secrets hygiene — 3/5

Right: no secrets on disk anywhere; BYOK posture is correct and asserted (the function sends only cf-aig-authorization, never x-api-key — art-rail.js:92-97); cf-aig-collect-log: false and cf-aig-skip-cache: true on every gateway call; __Host- cookie prefix with Secure; HttpOnly; SameSite=Lax (tokens.js:12,139-141); CSRF second layer via Sec-Fetch-Site/Origin with a request carrying neither refused (tokens.js:145-161); the next redirect validated at request time and persisted, never read from the callback query (tokens.js:113-122); enumeration-safe sign-in with exactly one success body (functions/api/auth/request.js:1-33). This is careful work.

Three defaults [[2026-09-06-fail-open-vs-fail-closed-defaults|fail open]], and the repo cannot tell you whether production is safe.

  1. The generative session gate is off unless an env var is set. guard.js:20: requiresAccount = String(env?.GENERATIVE_REQUIRES_ACCOUNT).toLowerCase() === 'true' — unset means anonymous callers pass. guard.js:23 likewise defaults GENERATIVE_ENABLED to on. The control the founder asked for on 2026-09-02 exists in code and defaults to disabled.
  2. AUTH_ALLOWLIST unset means everyone (request.js:47 treats an unset list as "everyone" and guards only the preview-echo path against that combination). Turnstile is also optional-by-presence (limits.js:57-58), and checkSignInLimits returns {ok:true} when the KV binding is missing (limits.js:38) — labelled as a deliberate guess, but it means sign-in email sending has no enforced brake in a mis-bound environment.
  3. The feedback Worker's SMS door is unauthenticated when TWILIO_TOKEN is unset. twilio.js:51: if (!token) return { ok: true, checked: false }. With workers_dev = true (workers/feedback-intake/wrangler.toml:16) and no rate limiting, that is a public POST /sms that writes arbitrary rows to D1, appends to Ray's KV inbox, posts to Discord, and fetches attacker-supplied MediaUrl values from inside the Worker (index.js:69-90) with no size cap and no host allow-list. The comment calls it "fine for a preview Worker" — the gap is that nothing prevents that preview posture from being what production runs.

Compounding all three: wrangler.toml is 15 KB of entirely commented-out configuration (wrangler.toml:1-8 explains why — a live pages_build_output_dir would switch wrangler to config-driven mode and break direct-upload deploys). The reasoning is sound, but the consequence is that every binding, var and secret for the Pages project lives only in the Cloudflare dashboard. There is no way to review production configuration from the repo, no diff when it changes, and no way for a reviewer to answer "is GENERATIVE_REQUIRES_ACCOUNT set in production?" Excellent runbooks are not the same thing as reproducible config.

Also absent: public/_headers. The site now sets a session cookie and has no CSP, no Referrer-Policy, no X-Content-Type-Options at the document level, no HSTS.

8. Maintainability and onboarding cost — 3/5

The commenting is the best thing about this codebase and I want to say so plainly. Nearly every file opens with what it is, what it answers, what it deliberately does not do, and which ruling decided that. state.js:6 — "PUT is a full replace, not a merge: send the whole tray or lose the rest of it." art-rail.js:4-5 — why the file exists at all. 0003_accounts.sql:24-31 — why households.email keeps its UNIQUE constraint and when it gets dropped. shopper.js:52 records that the spec's 5s/3s timeouts were wrong because Sonnet took 4.9-6.9s in a live smoke. This is institutional memory written where it will be found, and it materially lowers the cost of the next change.

Naming is consistent (test- / smoke- / shoot- / validate- / build- / sync- prefixes across 45 scripts), files are mostly under 500 lines, and I found no dead first-party modules — every src/lib and src/scripts file has at least one importer. Seven scripts/*.mjs are not wired into package.json (all one-off screenshot/spike tools: shoot-home, shoot-shopper, shoot-account, shoot-shop-round, font-contact-sheet, serve-shopper-mock, set-preview-accounts-config), plus scripts/engine-spike/ and a stray root-level ghost.jpg.

What a new engineer actually pays:


The 10 highest-leverage improvements, ranked by risk × effort⁻¹

# File(s) Change
1 workers/feedback-intake/src/twilio.js:51, wrangler.toml:16 Make verifyTwilio fail closed on a missing token (allow-list an explicit TWILIO_ALLOW_UNSIGNED=true for preview only), set workers_dev = false, and cap/allow-list the MediaUrl fetch in index.js:69-90.
2 repo root git rm --cached node_modules and add it to .gitignore — an absolute symlink into /Users/ray/Projects/ is committed and breaks the repo for everyone else.
3 new .github/workflows/ci.yml, package.json Add "test": "npm run test:taxonomy && … && npm run test:auth" plus a PR workflow that runs npm ci && npm test && npm run build && git diff --exit-code — one file turns ~1,000 existing assertions and the generated-file drift check into an actual gate.
4 functions/api/customize.js:297, shopper.js:214, customize-art.js, customize-icons.js Stop discarding priorError: console.warn the failure class (status / timeout / validation) before returning, and add a non-PII reason field beside code so a 401 from the gateway is distinguishable from a slow model.
5 new scripts/purge-retained.mjs + [triggers] crons in workers/feedback-intake/wrangler.toml Implement the retention that three files already promise: DELETE FROM customizations/feedback WHERE retain_until < ? plus the matching R2 deletes, on a daily cron. Add an index on retain_until.
6 src/lib/auth/guard.js:20, functions/api/auth/request.js:47 Invert the fail-open defaults — GENERATIVE_REQUIRES_ACCOUNT and AUTH_ALLOWLIST should be safe when unset, with an explicit opt-out var for local/preview.
7 new src/lib/http/ (or extend functions/_lib/) Collapse the duplicated primitives — one json()/NO_STORE, one sha256Hex, one utcDay/utcMonth, one takeSlot, one callModel — and have customize.js and shopper.js import them from the layer art-rail.js already established.
8 functions/api/customize.js:251,263, art-rail.js:38 Move the monthly ceiling counter off KV read-modify-write onto a Durable Object or a D1 UPDATE … WHERE n < ?; concurrent requests currently all take the same slot, so the spend cap is bypassable by parallelism.
9 new public/_headers; new ARCHITECTURE.md Add CSP / Referrer-Policy / X-Content-Type-Options / HSTS for a site that now sets a session cookie; and lift the durable half of IMPLEMENTATION-NOTES.md into a 15-minute onboarding doc.
10 src/scripts/customize.js:104-804 Split the 700-line installCustomize() into sheet / art-poller / apply modules behind one explicit state variable, and back it with a jsdom or Playwright test — the largest untested surface in the repo.

(Ranking note: #1-#3 are the highest risk-per-unit-effort — each is under an hour and each closes a whole failure class. #4-#6 are half-day fixes on live exposure. #7-#10 are multi-day and pay back over the next quarter rather than this week.)


Verdict — "is it vibe-coded or holding up to best practices?"

It is not vibe-coded. Vibe-coded software has no failure posture, no test seam, and no explanation of itself, and this repo has all three: every generative endpoint has a deliberately chosen and documented degradation path, auth deliberately inverts that posture to fail closed, every handler is written as handle(request, env, deps) specifically so ~1,000 real assertions can drive it, the D1 schema enforces its own privacy story (hashed senders in one table, raw addresses in another, so one delete anonymises a person's whole history), and the comments record why — including the times a spec was wrong and got corrected by a live smoke. That is engineering judgment, and it is a level most five-week-old solo products never reach. What it is instead is under-operationalised: the discipline lives in a person's head and in comments rather than in the machinery. There is no CI, so none of those tests gate a merge; production configuration exists only in a Cloudflare dashboard because wrangler.toml is entirely commented out, so no reviewer can verify that the security switches are on; three controls default to open when their env var is unset, one of which leaves an unauthenticated public endpoint writing to the database; a 4xx from the model is indistinguishable from a timeout because the precise error is built and then thrown away, so the generative rail can be completely dead while the site looks fine; retention is stamped on every row and enforced by nothing; and a committed node_modules symlink into /Users/ray/Projects/ means a second engineer cannot install dependencies on day one. The gap here is not craft — it is that a codebase this careful is still being run like a one-person workshop, and the ten fixes above are mostly configuration and plumbing, not rewrites. Fix the top three and this is a 4.