iPhoneDoctor
iPhoneDoctor is two surfaces against one Supabase: a public site fast enough for a customer holding a broken phone, and an admin app shaped around the operator who needs to change a price while the shop is open.
The thesis
iPhoneDoctor is a real Apple-repair business in Bucharest at iphonedoctor.ro. The repo iron-kasa ships two surfaces: a public site at apps/public (Vercel project iron-kasa) and an in-house admin app at apps/nagakiba (Vercel project nagakiba). Two Vercel projects against one Supabase project. The split is deployment ownership, not data topology.
The operator is the business owner. They change working hours, vacation dates, catalog prices, images, and page content without touching code. The admin app is shaped around the objects this specific business actually manages — services, devices, variants, prices, hours — and not around the generic vocabulary of a CMS vendor.
The customer arrives in calm urgency: broken phone in hand, limited patience, a quiet suspicion of repair shops generally. The public site is fast, type-led, restrained. The brand brief reads Apple-adjacent premium, not Apple parody.
The case study from here is organized as seams between the two surfaces. Each seam follows a four-move pattern — what the seam is, what it enables for the operator, how it's built, where it strains. This is a Phase A snapshot. Engineering-done is not shipping-done; the final cutover to iphonedoctor.ro is blocked on two client-side acts that the engineering team does not control.
- Commits over 19 months
- 579
- SQL migrations
- 29
- Tests passing, 0 audit vulns
- 107
- SquirrelScan, 57 crawled pages
- 79
The split decision
Seam
The deployment boundary: two separate Vercel projects (both in fra1) sharing one Supabase backend and five shared workspace packages.
What the operator gets
The admin app can be redeployed, rolled back, or held at a previous version without affecting the public site. A bad CMS deploy does not take down the storefront. Operator-facing chrome — auth UX, the catalog editor, CMS forms — iterates on its own cadence without coordinating with public releases. Per docs/deployment.md verbatim: "The repo still uses one Supabase project; this split changes deployment ownership, not backend topology."
Mechanism
Workspace monorepo via npm workspaces. Five packages/*: supabase, chat, catalog, site-settings, ui. Three Supabase client variants in packages/supabase/src/clients.ts, all marked import 'server-only';: createSupabaseReadonlyClient() for anon reads, createSupabaseServiceRoleClient() for admin writes and contact-form persistence, and createSupabaseTokenClient(accessToken) for admin session verification.
The hosting decision lives at docs/hosting-recommendation.md (2026-03-13): Vercel chosen over Netlify and Cloudflare Pages, captured as a deliberate client-facing recommendation. The doc itself is the artifact — a move from implicit engineering preference to documented client recommendation. Daily AGE-encrypted backups run to Backblaze B2 via .github/workflows/backup.yml at 03:17 UTC; the AGE private key lives in a password manager, not in CI.
Where it strains
database.types.ts exists in three places — one per app plus the shared package. Generation source is implicit; nothing currently enforces synchronization between the three copies. Two package-lock.json files at root (Vercel's per-project requirements force per-app lockfiles). Both are real maintenance surfaces, not bugs — they are the cost of the split, named explicitly rather than smoothed over.
- @iron-kasa/supabaseEnv parsing, DB types, three client helpers (readonly / service-role / token). All marked server-only.
- @iron-kasa/chatServer-utils, broadcast helpers, status-batching, shared chat types. The heaviest user is the admin chat inbox.
- @iron-kasa/catalogCatalog domain types and shared utilities used on both the public read path and the admin write path.
- @iron-kasa/site-settingsTyped site-settings model: working hours, vacation mode, announcement modal, CMS content shapes.
- @iron-kasa/uiPrimitives reused by both apps. Chat UI primitives (ChatInput, MessageBubble, MessageList, TickIcon) are the heaviest user.
The auth seam
Seam
Between public-unauthenticated paths and admin-authenticated paths. The boundary lives at requireCatalogAdmin(request) — and its route wrapper withCatalogAdmin — called at the top of every admin write route.
What the operator gets
A clean login boundary that fails fast with auditable error messages. Three distinct failure modes the operator can act on: 401 (no bearer token in the request), 403 (token is valid but the user is not in an active admin_memberships row), 500 (auth verification itself failed). The operator sees actionable copy instead of a generic "unauthorized."
Mechanism
apps/nagakiba/lib/catalog/admin-auth.ts houses requireCatalogAdmin(request). The pipeline: parse Authorization: Bearer <token> with a Zod regex, hand the token to a token-context Supabase client to validate the JWT via auth.getUser(), then use the service-role client to check admin_memberships for is_active = true. Success returns {userId, role: 'owner' | 'editor'}; failure throws CatalogAdminAuthError with the specific status code. RUNBOOK.md documents each error message verbatim, so a support session can match a reported message against a known auth failure mode.
Where it strains
The verbatim error messages reveal shape but not state. The 403 reads "admin membership is required" — not "user X is not an admin." Deliberate design choice, less information leak to an attacker, but it means debugging an operator who cannot log in requires server-side log inspection rather than a glance at the response body.
The catalog seam
Seam
Between admin writes (publish a product, archive a service, change a price, replace an image) and public reads (the customer-facing catalog page).
What the operator gets
A publish-readiness gate that fails closed with actionable copy. When the operator clicks "publish" on a product missing an active image, the gate rejects with MISSING_ACTIVE_IMAGE — not "publish failed." The operator knows exactly what to fix. Engineering-done is not shipping-done: a product that has data but is missing a required field is structurally not ready to go live, and the gate enforces that distinction at the moment of publishing.
Mechanism
apps/nagakiba/lib/catalog/publish-readiness.ts returns {isPublishable: boolean, blockers: [{code, message}]}. Blocker codes: PRODUCT_ARCHIVED, MISSING_ACTIVE_IMAGE, MISSING_ACTIVE_SERVICE, MISSING_ACTIVE_PRICE. Each carries a human-readable message for the admin UI. The publish route layers publish-readiness on top of requireCatalogAdmin from the prior seam — auth first, then readiness.
Public reads go through apps/public/lib/products/server.ts. The runtime selector reads CATALOG_DATA_SOURCE env and switches the entire catalog read path with a single env-var flip: supabase by default, constants as the fallback. Single-env-var, zero-data-loss incident rollback. The kill switch lives in the public app, not in Supabase.
Where it strains
The pricing_type field has a graceful fallback in the publish route (defaults to 'fixed' when missing) — a small data-integrity gap named as known debt in the codebase rather than silently fixed. The dual-code kill-switch path means every catalog-shape change has to land in both the Supabase reader and the constants reader, or the fallback drifts. The cost is real; the insurance is also real.
The CMS preview seam
Seam
Between admin draft content and the public render of that content — without weakening production CSP for normal customer traffic.
What the operator gets
An iframe in the admin app that frames the public site rendering the operator's unpublished draft. Edit a page, click "preview," see exactly what the customer will see. No staging environment to maintain; no separate preview subdomain to manage. The preview affordance and the production surface share one codebase.
Mechanism
apps/public/proxy.ts's buildPublicCsp(enableUpgradeInsecureRequests, allowAdminPreviewFraming) toggles frame-ancestors based on the request signal. createProxyResponse recognizes the preview request (cookie / query param ?cms_preview=1) and emits a per-response CSP that allows the admin origin to frame the public site. Production CSP for normal customer traffic remains strict (frame-ancestors 'none'). The preview-allowing CSP is scoped to the single response, not deployment-wide; there is no environment in which production customer requests serve the frame-allowing CSP.
Where it strains
The trick only holds as long as ADMIN_ORIGIN is correctly set per environment. The default is http://localhost:3001 — misconfigured production would silently break live preview, a manual workflow not exercised by automated tests. A first-class env-validation check for ADMIN_ORIGIN against the expected production origin would close this, and is not in place.
The chat seam
Seam
The asymmetric customer ↔ admin seam. The customer reaches the operator over Supabase Realtime Broadcast; the operator replies through the admin chat console in nagakiba. The word asymmetric matters: at snapshot, the admin side is shipped and the customer-side HTTP routes are mid-port.
What the operator gets
An inbox in the admin app. Conversations come in, the operator replies, the conversation closes. The substrate is solid — Broadcast channels, hardened schema, shared @iron-kasa/chat server-utils, shared UI primitives. The customer-facing surface — the chat widget on the public site — is the asymmetric side: the architectural foundation is in place, the HTTP routes are mid-extraction at the survey snapshot. Engineering-done is not shipping-done.
Mechanism
Migration 20260226120000_chat_schema.sql landed the schema with anon SELECT policies using USING (true), which exposed every conversation to anonymous Supabase clients. Migration 20260227120000_chat_drop_anon_policies.sql revoked anon SELECT one day later. The migration comment reads verbatim: "Customer reads go through service-role API routes; customer realtime switches to Supabase Broadcast (no RLS needed)." Follow-up hardening: 20260228130000_chat_origin_url_sanitize.sql and 20260305143000_chat_closed_conversation_insert_guard.sql.
The shared substrate carries the working pieces: @iron-kasa/chat for server-utils, broadcast helpers, status-batching; @iron-kasa/ui for the chat primitives (ChatInput, MessageBubble, MessageList, TickIcon). The admin inbox at apps/nagakiba/app/admin/chat/... uses them and works. The customer-facing HTTP routes are not in apps/public/app/api/v1/ at snapshot — that directory contains contact/ and site-settings/, no chat/. README and docs/handoff.md claim apps/public "owns /api/v1/chat/*"; at commit fcf32d4, that claim documents intent, not state.
Where it strains
The case study cannot claim "customer chat shipped." It claims architecture-and-Broadcast-layer shipped, customer HTTP routes mid-extraction. The 24-hour security retrofit on the schema is a credit, not a debit — caught and closed inside a day — but it is named explicitly because hiding it would be more telling than naming it.
The brand seam
Seam
Between two creative directions that still co-exist in the repo at snapshot: .impeccable.md (active, Apple-adjacent premium, light-mode-primary) and CLAUDE.md (the older brief, light-cyberpunk, dark-first). The rebrand from the second to the first landed mid-build. Both files are still present.
What the operator gets
A customer-facing surface that does not compete with the offline shop's signal — it carries it. The brief reads Apple-adjacent premium, not Apple parody. Calm, premium, exacting. Light-mode-primary. Typography leads, with color and motion in supporting roles. The operator side inherits the same restraint but allows monospace in admin contexts (the brief explicitly permits it for diagnostics and structured data).
Mechanism
PR #16 codex/apple-premium-distill-rebrand merged 2026-04-03, merge commit 42185e1. Three backup/pre-rollback-2026-04-06* branches preserved as evidence of willingness-to-roll-back during a high-risk rebrand. None were used; the rebrand stuck. The preservation is the discipline.
.impeccable.md at the repo root (4.1 KB, last touched 2026-04-04) is the active brief — design principles, 14-token color palette (notable that accent and neutral-900 are the same hue #111111 by design), typography (SF Pro Text/Display ideal, Plus Jakarta Sans the web-licensable fallback), and an explicit anti-direction list: "No cyberpunk cues: no scanlines, glitch effects, data-stream motifs, HUD framing, or neon highlights." CLAUDE.md at the root (2.7 KB, last touched 2026-03-20) preserves the prior cyberpunk brief verbatim.
Romanian-diacritic correctness as brand discipline: three migrations dedicated to it — 20260304113000_catalog_mufa_name_diacritics.sql, 20260329100000_fix_despre_noi_diacritics.sql, 20260329100100_fix_services_page_diacritics.sql — plus branch codex/normalize-romanian-diacritics merged 2026-03-29 (9e91f36). The kind of detail the brief means when it says "Let precision do the branding."
Where it strains
Both briefs still present in the repo at snapshot. A contributor — especially a Claude Code session that loads CLAUDE.md automatically — who does not also read .impeccable.md will implement against the wrong direction. The mitigation is not reconciliation; the mitigation is making the archaeology explicit (rename CLAUDE.md to mark its archive status, or fold it in with a pointer to .impeccable.md). Neither has landed. The hazard is named, not closed.
The handover seam
Seam
Between engineering-done and shipping-done. Phase A (transition domain under noindex) verified. Phase B (cutover to iphonedoctor.ro) blocked on two client-side acts.
What the operator gets, eventually
The customer-brand domain iphonedoctor.ro serving the new site, with a 301 redirect chain from the legacy URL space, and an owner account on admin@iphonedoctor.ro that can sign into the admin app. Today: the operator already has the admin app at nagakiba.vercel.app (always noindex); the customer-facing site sits on the transition domain (also noindex per the launch-checklist policy) waiting on the client to act.
Mechanism
Phase A is enumerated in docs/launch-checklist.md with every checkbox [x]: environment variables, GA4, security headers, backups, rollback paths all verified before the staging cutover. Phase B blockers live in docs/handover-to-do.md: client provisioning of admin@iphonedoctor.ro (needed for owner-account creation in admin_memberships), and client granting access to legacy iphonedoctor.ro DNS and hosting (needed for the 301 redirect chain from the legacy URL space). Phase A operates on the transition domain with noindex enforced via the X-Robots-Tag header; customer eyes do not land there. 44 days quiet on the repo between fcf32d4 (2026-04-10) and the survey date (2026-05-24) — consistent with a launch-handover pause, not stalled engineering.
Where it strains
Phase B latency is outside the engineering team's control. The case study quotes the state today, not a state the project hopes to be in. Engineering-done is not shipping-done.
Honest edges
The per-seam "Where it strains" notes are the honest edge for each seam. This section is the cross-cutting list — debt and deferrals that do not fit cleanly into one seam.
database.types.ts exists in three places (one per app plus the shared packages/supabase). Generation source is implicit; nothing currently enforces synchronization between the three copies.
Two package-lock.json files at root, one per app. Vercel project requirements force per-app lockfiles; the duplication is a real maintenance surface and a known onboarding gotcha.
CLAUDE.md vs .impeccable.md co-existence (covered in the brand seam). Brand-onboarding hazard for any contributor — human or agent — that loads the older brief first.
MacBook (/macbooks) and iMac (/imacs) routes are intentionally launch- non-critical per docs/architecture.md — they ship as static category pages, not full CMS-driven catalogs, because the business inputs are not finalized. Captured as scope, not incompleteness.
No release tags. git tag is empty. CI gates are green; release-cut discipline is the next step, not a different project.
PR numbering is sparse. Numbers visible in the merge log: #1, #2, #4, #5, #6, #8, #10, #11, #12, #13, #16. Absent: #3, #7, #9, #14, #15. From the local clone alone the absence cannot be classified (closed-no-merge, squash without PR reference, never landed).
Sentry production DSN configured and source maps uploaded; that events are actually flowing in production is not verifiable from the repo alone. Needs a live check.
AUDIT-TO-DO.md is a ~43 KB log of the live SquirrelScan campaign through March-April 2026. Inventoried; key scores quoted; full per-rule findings not transcribed.
COVERAGE.md (387 tests, dated 2026-03-10) and issues.md (107 tests) report different counts — different snapshots from different branches at different times. The case study quotes the lower, more recent number.
This is a Phase A snapshot, not a launch announcement. The numbers and the seams are what the project can defend today.