Developer playground
Capability inventory
Every capability, its state in THIS deployment, and the tests that cover it. Generated from one typed registry the application itself reads, so a claim here cannot drift from the code without a test failing.
Three facts, never collapsed into one
Code existing is not the same as a provider being configured, and neither is the same as someone watching the real path succeed on the deployed site. A capability only reads Live when all three are true and the registry names what was watched.
Built, configured, and watched working on the deployed site.
Built and configured here, but its real path has not been watched in production yet.
Built, but the provider it needs is not configured here, so it runs its documented plain path.
The interface is real; the substance behind it is incomplete or generated.
Designed in the knowledge base, not built.
This deployment
0 of 117 watched working in production
Fallback is not failure: every seam has a working plain path, and the environment matrix says exactly what each variable turns on and what happens without it.
Foundation7
| Capability | Technology | State | Tests | Demonstrates |
|---|---|---|---|---|
App framework src/app | Next.js 15 (App Router) | Ready | — | File-system routing, layouts, and server-first rendering. Watched on another site 2026-09-04 on capability-sandbox.vercel.app, until 2026-12-03: Fetched /, /shop, /play, /leaderboard, /newsletter and the blog on capability-sandbox.vercel.app; all 200. |
Type safety tsconfig.json | TypeScript (strict) | Ready | — | Compile-time guarantees, including noUncheckedIndexedAccess. |
Server Components src/app/page.tsx | React 19 RSC | Ready | — | Components that render on the server and ship zero JS to the browser. |
src/lib/validation.ts | Zod | Ready | 1 unit | One schema that validates on the client and re-validates on the server. |
src/lib/seo/structured-data.ts, src/components/seo/json-ld.tsx, src/lib/site.ts | schema.org builders in src/lib/seo, one <script type="application/ld+json"> component | Ready | 1 unit · 1 browser | Every page names its publisher: an Organization, or the configured LocalBusiness type with address, phone, hours, and service area when SITE_BUSINESS is filled in. Posts carry Article, products carry Product with an Offer and stock state, admin pages carry WebPage, all with breadcrumbs. The serializer escapes anything that could close the script element, and a test proves it. Watched on another site 2026-09-04 on capability-sandbox.vercel.app, until 2026-12-03: Read the Organization and WebSite JSON-LD blocks out of the deployed homepage HTML. |
src/lib/capabilities/packs.ts, src/lib/capabilities/pack-status.ts, src/app/packs | Registry ids grouped into forkable recipes, with readiness computed per deployment | Ready | 2 unit · 1 browser | Nobody forks one capability; they fork a shop, or a brochure site, or a community. A pack names that grouping and computes what it would cost: how many of its capabilities are built, how many have been watched working, and every environment variable it would want. The ids are checked against the registry by a test, so renaming a capability breaks the build rather than quietly emptying a pack. |
Journey navigation src/lib/navigation.ts, src/lib/settings-nav.ts, src/app/admin/admin-nav.tsx | One tagged array read by the header spine, the drawer, the footer, and the account menu | Ready | 1 unit · 1 browser | Four different people use this site — a visitor, a builder, someone with an account, and whoever runs it — and a flat list of eight links served none of them. Navigation is tagged by journey and filtered by who is asking, so the drawer groups under headings that say who each part is for. Admin settings split the same way: six routes instead of one scroll past a delete-account dialog. |
Design system8
| Capability | Technology | State | Tests | Demonstrates |
|---|---|---|---|---|
Styling src/app/globals.css | Tailwind CSS v4 | Ready | — | CSS-first configuration with design tokens declared in one file. |
Design tokens src/app/globals.css | CSS custom properties + OKLCH | Ready | — | Perceptually uniform color that stays balanced across themes. |
src/components/ui | Radix UI + CVA | Ready | — | Twenty-two accessible primitives wrapped in a typed variant API we own outright: dialog and sheet, dropdown menu, select menu, tooltip, popover, alert, avatar, progress, breadcrumb, pagination, tabs, switch, and the basics. Every one has a Lab demo. |
src/app/globals.css, tests/token-purity.test.ts, e2e/flavors.spec.ts | One token block per identity; data-flavor on <html> | Ready | 1 browser | Ember and Slate beside the default, each light and dark, selected from Settings. A whole visual identity from one block of custom properties — the proof that no component carries a color of its own. Token purity is a unit test; contrast under every flavor is a browser test. |
Dark / light mode src/components/layout/theme-toggle.tsx | next-themes | Ready | 1 browser | System-aware theming with no flash of the wrong theme on first paint. |
Responsive layout src/app/globals.css, src/components/layout/site-header.tsx | CSS Grid + Flexbox | Ready | 1 browser | One layout that reflows from 320px to ultrawide without separate templates — checked by measuring rather than by looking. Every element on seven pages, at three widths, must sit inside its box; a wide table scrolling in its own container is fine, and a control clipped away by a card is a bug, because it cannot be tapped. |
Image delivery src/components/shop/product-image.tsx, next.config.ts | next/image with the storage host allowed by derivation | Fallback needs R2_PUBLIC_BASE_URL | 1 browser | Product images and avatars are resized to the rendered width, served as AVIF/WebP, lazy below the fold, and reserve their box before loading. The remote host comes from R2_PUBLIC_BASE_URL in next.config.ts. |
src/lib/i18n/rules.ts, src/app/i18n | Two locales, direction from the locale, Intl for numbers and dates, no library | Partial | 1 unit · 1 browser | A deliberately small slice: English and Arabic. Two, because one is not internationalization and ten is a translation-management problem rather than an engineering one — and Arabic specifically, because right-to-left is where a layout that claims to support i18n falls over. The direction comes from the locale and the layout uses logical properties, so the browser mirrors it rather than a stylesheet doing it by hand. The locale is a path segment, not a cookie, so a link shows the same page to whoever opens it. Numbers and dates name their numbering system rather than inheriting whatever the runtime's ICU build defaults to. The rest of the site is NOT translated, and the page says so. |
Interactive UI11
| Capability | Technology | State | Tests | Demonstrates |
|---|---|---|---|---|
src/experiments/modals | Radix Dialog | Ready | — | Focus trapping, Escape handling, and focus restoration on close. |
src/experiments/toasts | Sonner | Ready | — | Non-blocking feedback, including promise-driven pending states. |
src/experiments/data-table | React state + useMemo | Ready | — | Derived state: filtering computed from inputs rather than stored twice. |
src/lib/notifications, src/components/layout/notification-bell.tsx | Notification table + a polling bell | Ready | 2 unit · 1 browser | Written in the same transaction as the event they announce; mark-read scoped to the reader in the WHERE clause; the badge polls every 30s only while the tab is visible. |
Live updates src/lib/realtime/rules.ts, src/app/api/events/route.ts, src/components/layout/notification-bell.tsx | Server-sent events (rung two of the realtime ladder), polling kept as the plain transport | Ready | 1 unit · 1 browser | The bell holds one EventSource on /api/events; the server checks the database every three seconds and writes an event only when this user's count changed, ends the stream before the platform would, and the browser reconnects. Scoped to the session, 401 for guests. NEXT_PUBLIC_REALTIME_TRANSPORT=poll falls back to the 30-second poll. WebSockets stay designed for when users must see each other. |
Command palette | TBD | Designed | — | Keyboard-first navigation across the whole app. |
Drag and drop | TBD | Designed | — | Pointer-based reordering with keyboard-accessible fallbacks. |
src/lib/storage, src/components/upload, src/app/api/uploads, src/app/api/inquiries/attachments | Presigned URLs — Cloudflare R2, or a local transport | Fallback needs R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_PUBLIC_BASE_URL | 3 unit · 2 browser | Presign, direct PUT with progress (or drag-and-drop), then verification of the real bytes by magic number. Keys are random; SVG is refused. The server probes its own credentials before promising a URL. In the APPLICATION, product images are public and a quote photo is not: publicUrl refuses the inquiry prefix outright, and the only door is an admin-checked route redirecting to a signature good for two minutes. THAT GUARANTEE DOES NOT SURVIVE THE BUCKET IT IS STORED IN, and on 2026-09-04 it did not: the R2 bucket had its Public Development URL enabled and no custom domain, so every prefix — inquiries/ included — was served to anyone at pub-*.r2.dev with no authorization at all. Privacy here is a key-naming convention enforced by application code, and an object store that serves the whole bucket does not read application code. privatePrefixExposure() asks the bucket and the admin inbox reports the answer, which is detection; the prevention is the private-object-bucket capability — with R2_PRIVATE_BUCKET set, every private prefix lives in a second bucket the public URL cannot reach. Until that variable is set, treat the private prefix as private-by-obscurity — which this project says elsewhere, correctly, is never access control — and /api/health reports storage as degraded for exactly that reason. Watched on another site 2026-09-05 on capability-sandbox.vercel.app (a559bef), until 2026-12-04: On capability-sandbox.vercel.app (deployment a559bef) a quote-form photo went presign, direct PUT to R2 from the browser, byte verification at completion (Ready), attachment to the submitted inquiry, and admin display through the attachment route's signed redirect. |
Private object bucket src/lib/storage/rules.ts, src/lib/storage/storage.ts, scripts/migrate-private-objects.mts | A second R2 bucket with public access off, chosen per key prefix | Fallback needs R2_PRIVATE_BUCKET | 2 unit | Privacy that survives the bucket. The public bucket serves any key to anyone at its r2.dev URL, so an application rule like 'publicUrl refuses inquiries/' protects nothing the bucket does not. With R2_PRIVATE_BUCKET set, bucketForKey routes every private prefix — inquiries/, and staging/, so no unvalidated upload ever touches the public bucket — to a bucket the public URL cannot reach. Presigned reads keep working because each request is signed against whichever bucket holds the key; promotion copies across buckets with the server's own credentials; the health probe checks both buckets. scripts/migrate-private-objects.mts moves what was already in the public bucket, verified by size, idempotently. Watched on another site 2026-09-05 on capability-sandbox.vercel.app (a559bef), until 2026-12-04: On capability-sandbox.vercel.app (deployment a559bef) submitted a quote request with a 64x64 PNG: the browser PUT went to capability-sandbox-private (CORS held), completion promoted staging/inquiries to inquiries/jpc1x4f-LLijUPbj.png inside the private bucket and removed the staged copy, /admin/inquiries served it through a 120-second signed GET against the private bucket, the public bucket still held only avatars/, the public r2.dev URL answered 404 for the key, and /api/health reported storage ok for both buckets. |
src/lib/views/rules.ts, src/components/shop/product-row.tsx, src/components/shop/view-switch.tsx, src/app/shop/page.tsx | A view parameter in the URL, one product query feeding two layouts, and a browser memory that only offers | Ready | 2 unit · 1 browser | The same catalog as cards for browsing or a list for scanning, from one query and one action slot: the row and the card take identical data and the identical quick-add control, so a view can never disagree with another about stock or price. The view is a URL parameter — a link, a bookmark, and the back button all mean what they say — and the category chips carry it. Where the choice lives is explicit: the URL wins, the browser remembers the last click and offers it on a plain visit, and nothing follows the account (decision 059). |
src/lib/wishlist/rules.ts, src/components/shop/wishlist-button.tsx, src/app/wishlist | WishlistItem rows for members; localStorage for guests; one heart component for both | Ready | 1 unit · 1 browser | The same heart saves to the account when signed in and to the browser when not. The wishlist page shows either, resolves browser-held ids to live products on the server, and moves a guest's list to the account on one explicit click after sign-in. |
Data30
| Capability | Technology | State | Tests | Demonstrates |
|---|---|---|---|---|
prisma/schema.prisma | Prisma 7 + Postgres (Neon) | Ready | — | Schema-as-code, typed queries, migrations, native enums and arrays. |
Driver adapters src/lib/db.ts | @prisma/adapter-pg | Ready | — | The SQLite-to-Postgres move changed two lines here — the pattern's promise, kept. |
| Server Actions + Prisma | Ready | 1 browser | Create, edit, and delete that survive a restart, with no API layer. | |
Transactions src/experiments/crud/actions.ts | Prisma $transaction | Ready | — | A change and its audit entry commit together, or not at all. |
| Prisma + Server Actions | Ready | 1 browser | Records that outlive the rows they describe, via a nullable relation. | |
Cache invalidation src/experiments/crud/actions.ts | revalidatePath | Ready | — | Why a successful write can still leave a stale page on screen. |
Database seeding prisma/seed.mts | Prisma seed script | Ready | — | An idempotent seed that upserts, so re-running it is safe. |
Sample datasets src/lib/sample-data.ts | Seeded generator | Partial | — | Realistic in-memory data still backing the charts and the data table. |
src/app/shop, prisma/schema.prisma | Prisma models + Server Components | Ready | 1 browser | Money as integer cents, products deactivated rather than deleted, category filter in the URL. |
src/lib/commerce/cart.ts, src/lib/commerce/cart-rules.ts | Guest cookie + account rows, merged at sign-in | Ready | 2 unit · 1 browser | A hashed guest token, quantities clamped against live stock on the server, and a pure, unit-tested merge rule. |
src/app/shop/actions.ts | Conditional UPDATE inside a transaction | Ready | 1 browser | Stock reserved with one atomic decrement, prices snapshotted onto the order, guest receipts via an httpOnly cookie. |
src/lib/reviews/rules.ts, src/components/shop/product-reviews.tsx, src/app/admin/reviews | Review rows, one per member per product; verified from paid orders; a pure rating summary; admin moderation | Ready | 1 unit · 1 browser | A rating and plain text from a member, marked as a verified purchase only when the server finds a paid order for the product. A flag gates writing on a purchase. The product page shows a one-decimal average and a histogram-backed summary, structured data carries aggregateRating, and an admin can hide or restore any review. |
src/lib/commerce/pricing.ts, src/app/admin/discounts, src/app/cart/cart-pricing.tsx | One pure pricing function; DiscountCode rows with atomic use counting; shipping methods and tax rates as data in code | Ready | 1 unit · 1 browser | Subtotal → discount → shipping → tax → total, in integer cents, rendered identically on the cart, the checkout, the order, the receipt, and Stripe's page. Codes are checked when applied, on every view, and at checkout where a conditional update counts the use. Shipping methods and regional tax rates are lists a fork edits in one file. |
src/lib/commerce/variant-rules.ts, src/app/admin/products/variants-dialog.tsx, src/app/shop/add-to-cart-button.tsx | ProductVariant rows with own stock and optional price; cart lines unique by (product, variant) | Ready | 1 unit · 1 browser | A size, a color, a switch type: each with its own stock and price. With any variant visible the shopper must choose one; the order reserves that variant's stock with the same conditional update the product used and carries its name in the line. Cancel and refund return stock to the variant. Admins edit variants per product. |
Checkout provider seam (mock or Stripe) src/lib/commerce/checkout.ts, src/lib/commerce/stripe.ts, src/lib/commerce/stripe-rules.ts, src/lib/commerce/settle.ts, src/lib/commerce/refunds.ts, src/lib/commerce/refund-rules.ts, src/app/api/webhooks/stripe | Mock by default; Stripe hosted Checkout over fetch when keys are set, with a signed webhook, refunds, and reconciliation | Fallback needs STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET | 2 unit · 2 browser | Payment execution behind the same one-entry-point pattern as mail and AI. The mock confirms instantly so the whole shop runs with no account. With Stripe keys, the browser goes to hosted Checkout and the order settles only when a webhook signed by Stripe says so — or when the nightly sweep asks Stripe about a session the webhook never reported. A refund is an attempt row carrying the provider's own status — the order reads refunded only when the provider says succeeded, by its synchronous answer, its signed refund events, or the nightly sweep asking — and stock returns on the goods coming back, not on the money (decision 053). Watched on another site 2026-09-06 on capability-sandbox.vercel.app (f25d089), until 2026-12-05: Stripe test mode on capability-sandbox.vercel.app (deployment f25d089): order #2 paid with 4242 through hosted Checkout and settled by the signed checkout.session.completed event; the same event replayed from the Stripe dashboard answered outcome already and changed nothing; a 4000 0000 0000 0002 decline left orders #3 and #4 pending with stock reserved; refunds of #2 (unshipped), #5 (shipped) and #6 (via a return) each reached Stripe once and refund.updated came back answered already. |
content/blog, scripts/sync-content.mjs, src/lib/content/posts.ts | next-mdx-remote + a deploy-time sync | Ready | 1 unit · 1 browser | Posts are files in the repo, validated and synced into Postgres at deploy; drafts are excluded in the query, not the template. |
src/lib/pages, src/app/admin/pages, src/app/[slug] | Page table, Markdown through rehype-sanitize, signed preview links | Ready | 1 unit · 1 browser | A non-developer writes Markdown in the admin area and it is served at /<slug>. Drafts are 404 until published or opened through a day-long signed link. The sanitizer, not the author, decides what HTML exists; a reserved-slug test stops a page shadowing a route. |
src/lib/forum/rules.ts, src/app/forum, src/components/comments | ForumCategory and Thread rows; replies are Comment rows on a thread instead of a post | Ready | 1 unit · 1 browser | Categories, threads, pinning, locking, and pagination — with every reply reusing the comment system, so plain text, reports, the flag threshold, and one admin queue cover the forum without a second implementation. Sorting is pinned first then last activity, updated by every reply. |
src/lib/comments, src/app/comments/actions.ts, src/components/comments, src/app/admin/comments | Comment and CommentReport rows, plain text rendered as text, a flag threshold, an admin queue | Ready | 1 unit · 1 browser | Members comment under posts and reply one level deep; bodies are stored as typed and rendered as text with links recognized at render time. Own edits for fifteen minutes, soft deletes, per-user rate limits, a verified-email requirement behind a flag. Three reports flag a comment and notify admins; the queue hides, restores, or hides everything by an author and disables them. |
src/lib/game, src/app/play | SaveGame rows with JSON state and an optimistic-lock version, a pure reducer run on the server | Ready | 1 unit · 1 browser | Three slots per player. The browser sends words and the version it saw; the server runs the engine and writes only where the version still matches, so a stale tab cannot undo a move. A guest plays the same engine in the browser with localStorage — both variants, one reducer. |
src/lib/game/rules.ts, src/app/leaderboard | Score rows, best per player per board, computed from state on the server | Ready | 1 unit · 1 browser | No browser ever sends a score. Each move's new state is scored server-side and replaces the player's best only when higher. Standard competition ranking with ties; display names, never emails. |
src/app/admin/media, src/components/upload/attachment-field.tsx, src/app/globals.css | The upload flow with an admin-only purpose, a list, a Markdown snippet, delete | Fallback needs R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_PUBLIC_BASE_URL | 1 browser | An admin uploads images once and pastes a snippet into any page. Several images on one line render as a gallery grid; a quote with an attribution line renders as a testimonial card — conventions in CSS, not a page builder. The sanitizer admits same-origin image paths alongside https; a test keeps protocol-relative hosts out. |
src/lib/inquiries, src/app/quote, src/app/admin/inbox, src/lib/storage/guest-cookie.ts | Inquiry table, guest-owned presigned photos, an admin inbox, two templated mails | Fallback needs RESEND_API_KEY | 1 unit · 1 browser | A visitor describes a job and attaches up to six photos that go straight to storage; the request is stored before it is mailed, then acknowledged to the sender and announced to the owner. A guest's photo is bound to an httpOnly cookie, claimable only by their own submission, and swept if nothing claims it in a day. Contact messages land in the same inbox. |
src/lib/search, prisma/migrations | Postgres tsvector + GIN across four tables, no service | Ready | 1 unit · 1 browser | Weighted tsvector columns kept current by a trigger per table — posts, products, pages and threads. Generated columns came first and could not stay: Prisma cannot model one, read the generation expression as a default, and emitted a DROP DEFAULT that the next unrelated migration died on. A plain column a trigger maintains is invisible to that diff. A bound-parameter raw query reads them; highlights are rendered as text, never HTML. Threads are indexed by title alone, so a search cannot surface the body of a reply moderation has hidden. |
src/lib/pages/conflict.ts, src/lib/pages/draft.ts, src/app/admin/pages/actions.ts, src/app/admin/pages/page-editor.tsx | A compare-and-swap on updatedAt, a field-by-field conflict panel, and a versioned localStorage draft with a previous-valid fallback | Ready | 2 unit · 1 browser | Two tabs used to save over each other in silence; a closed tab lost its words. A save now carries the page's updatedAt as a token and writes with that token in its WHERE clause, so a stale form is refused with the other person's version field by field and a choice: take theirs, or keep mine and save again with the fresh token — explicit resolution, not a merge. While you type, the browser keeps a draft in a strict versioned envelope beside the previous valid one, so a corrupt draft falls back to the one before rather than to nothing; nothing in a draft is ever executed, and a save clears it (decision 058). |
src/app/admin/pages/actions.ts, src/app/api/cron/publish, src/lib/pages/rules.ts | A revision row per save, and publishing as a status rather than a date | Ready | 1 unit · 1 browser | A CMS without history is one where a bad paste is permanent. Every save keeps what it painted over, so the newest revision is the state before the current one and restoring is a copy forward rather than a special case — and the restore is itself undoable, because the mistake being fixed may be the restore. Scheduling is a STATUS, not a published page with a future date: with a date, every read path has to remember to compare it, and the one that forgets publishes the embargo early. One cron route moves them. |
src/lib/commerce/fulfillment.ts, src/lib/commerce/refunds.ts, src/app/admin/orders/fulfillment-actions.ts, src/app/orders/return-actions.ts | A transition table, a second status column, and a return as a row with its own history | Ready | 2 unit · 1 browser | Payment and fulfillment are separate facts and separate columns: an order can be paid and unshipped, shipped and refunded, or delivered and then returned, and collapsing both into one status forces a choice between lying about the money and lying about the parcel. The legal moves are a table rather than a pile of conditionals, so the rule that nothing ships before it is paid for is stated once — and the panel computes its buttons from the same table, so it never offers a move the server would refuse. Updates are conditional on the current status in the WHERE clause, so two admins clicking at once produce one shipment and one honest refusal. A return is a row, not a flag, because it is a conversation with dates that matter when someone disputes it, and the refund is an attempt row rather than a move — the return reads refunded only when the provider says the money went back, and the goods arriving is what returns the stock, whatever the money did (decision 053). Watched on another site 2026-09-06 on capability-sandbox.vercel.app (f25d089), until 2026-12-05: On capability-sandbox.vercel.app (deployment f25d089), with real Stripe test refunds: order #5 was shipped then refunded and its stock stayed out until the parcel was marked returned (195 → 196 on the Flask Sticker Sheet); order #6 was delivered, returned by the customer, approved, received (stock 196 → 197 while the order still read paid), then refunded through the return, after which the order read refunded and stock did not move again. |
src/lib/commerce/dispute-rules.ts, src/lib/commerce/disputes.ts, src/app/api/webhooks/stripe/route.ts, src/app/admin/orders/[id]/page.tsx | Stripe dispute webhooks onto a Dispute row with an evidence deadline | Fallback needs STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET | 1 unit | Money can move against you after the sale. A cardholder tells their bank the charge was wrong; the bank takes the money back and asks for evidence; weeks later it decides. That arrives here through the same signed webhook the payments use, as one row per dispute keyed by the provider's id, updated with a conditional write so retries and out-of-order deliveries leave one row saying the latest thing. The admin's order page shows what the bank wants and by when, because missing the deadline is losing by default. And the refund rule asks the dispute before the provider: a refund while the bank holds the money would return it twice, and after a lost dispute there is nothing left to refund (decision 065). |
src/lib/commerce/address.ts, src/app/checkout/checkout-form.tsx, src/app/shop/actions.ts | A validated JSON snapshot on the order, written once at checkout | Ready | 1 unit · 1 browser | An order that ships needs somewhere to go, and the somewhere must be the one the customer gave at checkout — not whatever their profile says a month later when the dispute arrives. So the address is a snapshot on the order like the prices and the line names: asked for only when the shipping method is not a pickup, validated loosely (one required line, a city, a postal code, a two-letter country; region free text because half the world has no state), and shown on the order, the admin's page, and the receipt. It is also what a tax service would quote from (decision 065). |
src/lib/commerce/tax.ts, src/lib/commerce/pricing.ts, docs/knowledge-base/tax-providers.md | One TaxProvider interface; a rate table in code behind it; Stripe Tax designed | Ready | 1 unit · 1 browser | Tax was a table in code, which is right for a shop selling into three states and wrong the day it sells into forty. The seam is the same shape as mail, AI, and payment: one interface, one plain implementation that needs nothing, and a place a fork plugs a service into. The order stores which provider quoted it. An unknown TAX_PROVIDER falls back to the table and says so in the quote, so a typo in an environment variable never silently zeroes the tax. Stripe Tax — calculation per checkout, transaction on settle, reversal on refund — is designed in the knowledge base with its costs stated (decision 065). |
APIs8
| Capability | Technology | State | Tests | Demonstrates |
|---|---|---|---|---|
src/lib/api-keys, src/app/api/v1, src/app/settings/api-keys.tsx, src/lib/game/service.ts | ApiKey rows (hash + display prefix), scopes in code, per-key rate limit, /api/v1 | Ready | 1 unit · 1 browser | A member mints a key in Settings, sees it once, and a program sends it as a bearer token. Every endpoint names the scope it needs; wrong scope is 403, anything dead is 401, and each key has its own rate bucket. The game is fully playable by API through the same service the console uses, version lock included. |
src/app/api | Next.js Route Handlers | Ready | — | JSON endpoints with validation and correct HTTP status codes. |
| Server-side fetch proxy | Ready | — | Calling a third-party API from the server so keys and origins stay hidden. | |
| React state machine | Ready | — | Every one of idle / loading / error / empty / success rendered explicitly. | |
Rate limiting src/lib/rate-limit.ts | Postgres fixed windows (atomic upsert), or in-memory | Ready | 1 unit · 1 browser | Keyed per caller, so an abuser is throttled and not the site. Durable by default: one atomic upsert per hit holds the limit across serverless instances; RATE_LIMIT_STORE=memory keeps the per-process limiter, which is also the fallback if the database call fails. |
Bot protection src/lib/bot-check, src/components/forms/bot-check.tsx | Honeypot always; Cloudflare Turnstile when configured | Fallback needs NEXT_PUBLIC_TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY | 1 unit · 1 browser | Sign-up, forgot-password, and contact carry a hidden field a person never fills and a script always does. With Turnstile keys set, an invisible challenge is verified server-side too; the CSP admits the widget by derivation. One generic message for every failure. |
src/lib/api-keys/endpoints.ts, src/app/api-docs | A typed endpoint list rendered as a page and checked by a test | Ready | 1 unit · 1 browser | Documentation drifts because it lives where the code does not. This is the registry's trick applied to the API: one typed array, rendered as the docs page, with a test asserting every route file it names exists — so a renamed route fails the build rather than quietly becoming a lie. No OpenAPI document: three endpoints do not need a specification format, and generating one from the same array is a small change the day a client needs it. Watched on another site 2026-09-04 on capability-sandbox.vercel.app (3dab6d6), until 2026-12-03: Loaded /api-docs on capability-sandbox.vercel.app after the 3dab6d6 deploy and read the generated endpoint list and scopes back out of the rendered page. |
Outbound webhooks docs/knowledge-base/outbound-webhooks.md | Designed: signed deliveries over the existing outbox pattern | Designed | — | Designed and deliberately not built. Not for difficulty — an outbound webhook is a POST with an HMAC header, and the retry machinery already exists. The reason is that an untested outbound webhook is a liability: it makes a request this site chooses, to a URL someone else controls, carrying data about real people. Retrying forever into a dead endpoint looks like an attack; a mistyped URL leaks a customer's address; a URL resolving to a private range turns the feature into a proxy for a cloud metadata endpoint. The schema, the signing scheme, the three traps, and the tests it would need before the registry may call it built are in the knowledge base. |
Auth22
| Capability | Technology | State | Tests | Demonstrates |
|---|---|---|---|---|
src/lib/orgs, src/app/orgs, src/experiments/crud | Organization, Membership (owner/admin/member), OrgInvite rows; pure role rules; projects shared by pointer | Ready | 1 unit · 1 browser | A group of accounts with three roles, where the membership row is the authorization and every action reads it fresh. The last owner cannot leave or be demoted; nobody changes their own role. Existing accounts join at once and are notified; others get a week-long hashed invite for their address only. A project shared with an organization is editable by its admins. |
src/lib/auth.ts | Auth.js v5 (beta) | Ready | 1 browser | Credentials sign-in with an httpOnly, encrypted session cookie. |
Password hashing src/lib/password.ts | node:crypto scrypt | Ready | 1 unit | Per-user salt, a memory-hard KDF, and constant-time comparison. |
src/middleware.ts | Middleware + page checks | Ready | 2 browser | Edge-safe config, plus a second check the page makes for itself. |
src/experiments/crud/actions.ts | Server Actions + Prisma | Ready | 1 browser | Authorization on every mutation, not just hidden buttons in the UI. |
Enumeration resistance src/lib/password.ts | Dummy-hash comparison | Ready | 1 unit · 1 browser | Equal work for unknown and known emails, so timing reveals nothing. |
Open-redirect protection src/app/(auth)/actions.ts | Same-site path allowlist | Ready | — | Why an attacker-supplied callbackUrl is a phishing vector. |
Transactional email src/lib/mail.ts, src/lib/mail-template.ts, src/lib/mail-specs.ts | Resend (or console transport) | Fallback needs RESEND_API_KEY | 1 unit | One spec renders both an HTML email and its plain-text alternative; rich by default, MAIL_FORMAT=text for plain only. Two transports; real delivery is a key in .env. Previews at /admin/mail. |
Email verification src/lib/tokens.ts | Single-use hashed tokens | Fallback needs RESEND_API_KEY | 1 unit · 1 browser | Sign-up sends a 24h link; recorded but not enforced at sign-in — by design here. |
| Single-use hashed tokens | Fallback needs RESEND_API_KEY | 1 browser | A 30-minute link that burns on use; the change and the burn commit together. | |
Sign-up enumeration fix src/app/(auth)/actions.ts | Neutral response + email | Ready | 1 browser | New and duplicate addresses get identical screens; the truth goes to the inbox. |
src/lib/account/rules.ts, src/app/api/account/export, src/app/settings/actions.ts | Export route + deletion action under the admin-count lock | Ready | 1 unit · 1 browser | A person can download everything the site holds about them as one JSON file, and delete their account: typed-email confirmation, the password when there is one, the last-admin invariant, orders kept as records without a link, an audit entry that outlives the account, uploads removed from storage. |
OAuth providers src/lib/auth.ts, src/lib/oauth-rules.ts, src/app/(auth)/provider-buttons.tsx | Auth.js — Google and GitHub, present only when configured | Fallback needs AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET | 1 unit · 1 browser | Sign in with Google or GitHub beside email + password. A provider identity is linked to an existing account by email only when the provider verified that email; otherwise the person is told to sign in with their password first. Accounts created this way have no password until they set one. |
| localStorage, applied to <html> as data attributes | Ready | 1 browser | Density and reduce-motion are stored per browser and applied to the document on every page: compact shrinks the base spacing token every component is built on. Account details are the database’s; these are the browser’s, on purpose. | |
Account disabling src/lib/admin-rules.ts | disabledAt column | Ready | 1 unit · 1 browser | Sign-in refused without leaking that the account is disabled; last-admin and self-lockout rules unit-tested. |
src/lib/identity/sessions.ts, src/lib/auth.ts, src/app/settings/security | A row per sign-in, keyed by an id inside the token, plus a valid-from stamp on the account | Fallback needs IDENTITY_PROFILE | 1 unit · 1 browser | A JSON Web Token is self-contained, which is exactly why revoking one used to be impossible. This does not abandon JWTs: it gives each one an id and a row, and lets the row decide whether the token still counts. Ending one session signs out one device; the account-level stamp ends everything at once, including tokens whose row was never written. Sign out everywhere signs out this browser too, because a button for 'someone else has my session' should not quietly hand you a fresh one. |
src/lib/identity/totp.ts, src/lib/identity/secret-box.ts, src/lib/identity/pending.ts, src/app/(auth)/totp-step.tsx | RFC 6238 TOTP written by hand, secret encrypted at rest with AES-256-GCM | Ready | 1 unit · 1 browser | Forty lines of HMAC and a base32 encoder, checked against the RFC's own published test vectors so two independent authenticator apps agree with it. One step of clock drift is forgiven; a spent step is refused, so a shoulder-surfed code is dead. The secret is encrypted with a key derived from AUTH_SECRET, because a secret this system can read in plaintext is one a database read hands over whole. Sign-in splits into two requests carried by a signed httpOnly cookie rather than a password in a hidden field, and the credentials provider refuses the password-only path outright for an enrolled account. |
src/lib/identity/recovery.ts | Ten single-use codes, hashed with the same scrypt as passwords | Ready | 1 unit · 1 browser | A second factor that cannot be recovered from is a way to lose an account, so enrolling hands over ten codes at the same moment. They are hashed, because a code this system can read is a code an attacker who reaches the database can read. Spent codes are marked rather than deleted, so 'three of ten used' is answerable and a replay is refused. The alphabet leaves out the characters people confuse on a printout. |
src/lib/identity/rules.ts, src/lib/identity/account.ts, src/app/settings/security-actions.ts | A lastReauthAt stamp on the account, checked by a named list of sensitive actions | Fallback needs IDENTITY_PROFILE | 1 unit · 1 browser | A session lasts a week; a laptop is left unlocked for five minutes. Deleting an account, changing an address, or turning off a second factor asks for the password again first, and the list of what counts as sensitive is written down rather than inferred. An account that signs in only through a provider has nothing to re-prove, so the gate passes rather than locking someone out of their own settings forever. |
src/app/settings/security-actions.ts, src/app/(auth)/verify-email/page.tsx | A confirmation token carrying the wanted address, with both mailboxes told | Ready | 1 browser | Nothing on the account moves until the NEW address confirms a link, so a typo costs nothing and the old address keeps working. The old mailbox is told a change was requested, because that is the mailbox someone still controls if the request was not theirs. Confirming ends every session, since an address change is the one moment where 'was this really you' has to be answered by signing in again. A taken address gets the same answer as a free one, so the form is not an account-existence oracle. |
Verified-email enforcement src/lib/identity/rules.ts, src/lib/auth.ts | One policy read from the environment, applied at sign-in | Fallback needs IDENTITY_PROFILE | 1 unit · 1 browser | The sandbox profile records verification and does not enforce it, because there is no inbox at example.com. The strict profile refuses sign-in until the address is confirmed, and says so plainly rather than with a generic failure — reaching that message required the right password, so it reveals nothing. An account created through Google or GitHub arrives verified by the provider and is never caught by it. |
Passkeys docs/knowledge-base/passkeys.md | WebAuthn — needs @simplewebauthn, not installed | Designed | — | Designed and deliberately not built. TOTP is forty lines checkable against published vectors; WebAuthn is CBOR parsing, COSE keys, attestation formats, and signature verification where a subtle mistake looks exactly like success and is an account takeover. Hand-rolling it would be unsafe to deploy, so it waits on a reviewed library rather than being half-built. The schema, the flow, and the trap to avoid are written up in the knowledge base. |
AI5
| Capability | Technology | State | Tests | Demonstrates |
|---|---|---|---|---|
src/components/ai/chat-widget.tsx, src/lib/ai/widget-rules.ts | A launcher and panel over every public page, streaming from the same /api/chat route | Fallback needs ANTHROPIC_API_KEY | 2 unit · 1 browser | Open, minimize, close — the state remembered per browser, the conversation deliberately not stored. Before anyone types it says where the words go, what is kept, and that it cannot see your account; when the real model is configured it asks for an account rather than refusing a typed question. A Stop button aborts mid-answer and the abort reaches the server, so the spend stops with it. Focus moves in on open and back to the launcher on close. |
src/lib/ai/provider.ts, src/lib/ai/grounding.ts | Vercel AI SDK + Anthropic (or scripted mock) | Fallback needs ANTHROPIC_API_KEY | 1 unit · 1 browser | Token streaming, server-held keys, and a mock so it all runs without one. The system prompt is assembled from the capability registry, so the model answers from the same records the Stack page reads instead of from the shape of the question — and is told never to call something live when it is only ready. |
LLM cost controls src/app/api/chat/route.ts, src/components/ai/chat-widget.tsx | Zod caps + auth gate + abort | Fallback needs ANTHROPIC_API_KEY | 1 unit · 1 browser | Message caps, sign-in required when it costs money, and a Stop button that aborts the fetch — the signal reaches the route, the route hands it to the provider, and generation ends rather than running to completion into a closed tab. |
AI spend cap src/lib/ai/budget.ts | Daily token budget in Postgres | Fallback needs ANTHROPIC_API_KEY | 1 unit | A ceiling that survives restarts, because an in-memory cap is not a cap. |
Structured extraction | Zod-constrained generation | Designed | — | Forcing a model to return data that matches a schema. |
Visualization2
Operations24
| Capability | Technology | State | Tests | Demonstrates |
|---|---|---|---|---|
src/lib/notifications/notify.ts, src/lib/notifications/email-rules.ts, src/app/api/cron/digests, src/app/settings/email-preferences.tsx | Account preferences, MailOutbox rows written with the notification, a digest cron, purpose-scoped signed stop links | Fallback needs RESEND_API_KEY | 1 browser | The important few notifications go out at once — queued in the same transaction as the notification, sent by the outbox drainer — and the rest arrive as a daily or weekly digest whose window and marker move together. Preferences live on the account so every device agrees; every email carries a signed link that turns both off. |
src/lib/newsletter, src/app/newsletter, src/app/admin/newsletter, src/app/api/cron/outbox | Subscriber, NewsletterIssue, and MailOutbox rows; signed unsubscribe links; a batch drainer with backoff | Fallback needs RESEND_API_KEY | 2 unit · 1 browser | A visitor confirms by a link only their inbox received; the screen never says whether an address is listed. Issues are Markdown through the pages sanitizer, tested on the admin first. Sending writes one outbox row per recipient in a transaction and drains in batches with retries, so a provider outage delays and never loses. Every issue carries a signed one-click unsubscribe and a List-Unsubscribe header. |
src/lib/flags, src/app/admin/flags, src/app/api/flags, src/components/layout/announcement-banner.tsx | Definitions in code, FeatureFlag overrides, FLAG_OVERRIDES env pins, FNV-1a buckets | Ready | 1 unit · 1 browser | A switch the code consults, decided per person: code default, then an admin's override with a percentage, an audience, and a text payload, then an environment pin that wins over both. Rollouts are stable per subject. The announcement banner and the quote form consult theirs; /api/flags serves client code. Every flip is audited. Watched on another site 2026-09-04 on capability-sandbox.vercel.app, until 2026-12-03: GET /api/flags on the deployed site returned the registry's flags with their decided values. |
Environment config .env.example | .env + NEXT_PUBLIC convention | Ready | — | The boundary between server-only secrets and public build values. |
Security headers src/middleware.ts | Next.js middleware | Ready | — | Clickjacking, MIME-sniffing, and referrer-leak mitigations. Watched on another site 2026-09-04 on capability-sandbox.vercel.app, until 2026-12-03: Read the response headers from the deployed site and confirmed the policy is present on a real request. |
Content-Security-Policy src/lib/csp.ts | Per-request nonce + strict-dynamic | Ready | 2 unit | Injected scripts cannot run, because they carry no valid nonce. |
Deployment package.json, prisma.config.ts | Vercel + Neon branches | Ready | — | Git-push deploys; migrations over the direct connection, runtime over the pooled one; a separate Neon branch for development. Watched on another site 2026-09-04 on capability-sandbox.vercel.app, until 2026-12-03: Pushed to main and watched Vercel build and serve the new commit at capability-sandbox.vercel.app, migrations applied by vercel-build. |
Accessibility testing e2e/a11y.spec.ts, e2e/keyboard.spec.ts, e2e/reflow.spec.ts, e2e/containment.spec.ts | axe-core at WCAG 2.2 AA, plus measured checks for what axe cannot see | Ready | 3 browser | Forty pages — public, signed-in, admin, and at phone width — checked on every run, with the open drawer and the open assistant measured too rather than only the closed page. axe covers the mechanical half; the rest is measured. Keyboard journeys complete real tasks with no pointer and assert where focus lands, including that it returns to what opened an overlay. Reflow is checked at 320px, at 200% text, and under a reader's own spacing. Forced colors and reduced motion are emulated and verified. Target size implements the criterion's spacing exception rather than waiving the rule. |
Performance budget e2e/perf-budget.spec.ts, lighthouserc.json | Byte budgets in Playwright; Lighthouse CI in the pipeline | Ready | 1 browser | JavaScript and HTML bytes per first visit are asserted on every run because bytes are deterministic anywhere; Lighthouse scores and Core Web Vitals are asserted in CI where the machine is consistent. |
src/lib/analytics, src/app/api/analytics/view, src/components/analytics, src/app/admin/analytics | First-party page views (cookieless); Vercel Web Analytics beside it when enabled | Fallback needs NEXT_PUBLIC_VERCEL_ANALYTICS | 1 unit · 1 browser | One beacon per navigation counts a view per (day, path) and a distinct visitor per day from a salted hash that cannot be linked across days. No cookie, no ip stored, crawlers and private pages excluded, Global Privacy Control honored. /admin/analytics charts real rows. |
src/instrumentation.ts, src/lib/errors, src/app/admin/errors | instrumentation.ts + ErrorEvent table; Sentry forward when SENTRY_DSN is set | Fallback needs SENTRY_DSN | 1 unit · 1 browser | Every uncaught server error — render, route, action, middleware — and a visitor's blank page are captured, grouped by fingerprint on /admin/errors, and kept 30 days. The same event goes to Sentry over plain HTTP when a DSN exists; no SDK. |
SEO surface src/app/robots.ts, src/app/sitemap.ts, src/app/manifest.ts, src/lib/og-image.tsx | Next metadata routes + next/og | Ready | 1 browser | robots.txt, a database-driven sitemap.xml, a web manifest, favicons, and a per-post and per-product OpenGraph image — all generated from code, no binary assets to keep in sync. Watched on another site 2026-09-04 on capability-sandbox.vercel.app, until 2026-12-03: Fetched robots.txt, sitemap.xml and the web manifest from the deployed site and read the expected entries. |
src/app/admin | Role-gated route group + Server Actions | Ready | 1 browser | Users, audit log, and AI spend behind a role check that re-reads the database rather than trusting the session claim. |
src/lib/jobs/rules.ts, src/lib/jobs/ledger.ts, src/lib/jobs/handlers.ts, src/app/api/cron/publish, src/experiments/durable-jobs | A Job table with leases, backoff, and recorded due/started/finished times; handlers registered by kind | Ready | 2 unit · 1 browser | Work that must happen after a moment — a page publishing at nine, a retry after a provider timeout — is a row written in the same transaction as the promise, claimed by a worker under a lease with one conditional update, retried with growing backoff, and stopped after its attempt limit so poison waits for a person. A worker that dies leaves an expired lease the next run reclaims, which is why every handler is idempotent. Due, started, and finished are recorded on the row, so lateness against the schedule's written contract is a number on /admin/jobs rather than a suspicion; the Lab's bench lets a visitor watch all of it without a provider in the loop (decision 057). |
Scheduled jobs vercel.json, src/lib/maintenance, src/app/api/cron/maintenance | Vercel Cron + a guarded route handler | Ready | 1 unit · 1 browser | A nightly sweep of expired tokens, abandoned uploads, stale guest carts, and unpaid orders that never reached a payment provider — an order holding a Stripe session is cancelled only by Stripe's answer, never by the clock, and the ones still waiting are counted into the audit row. Idempotent, bearer-guarded, audited. |
Automated tests tests/ | Vitest | Ready | — | Every rule where a mistake is a security problem, run in under a second — and, increasingly, guard tests that read the source and fail when a rule stops being followed, because the defects that survive review are the ones a behavioral test cannot see. Deliberately no count here: a number in a file that claims to be the source of truth goes stale on the next commit and makes a liar of it. `npm test` prints the real one. |
Mutation checking docs/testing.md | Deliberate defect injection | Ready | — | Proving the tests fail when the code breaks, not just that they pass. |
End-to-end tests e2e/ | Playwright | Ready | 3 browser | Real journeys against a production build and a throwaway database, because a rule that is correct and not connected is worth nothing. Run them through `npm run test:e2e`, never `npx playwright test`: the runner points at a separate database, raises the rate limits one loopback address would trip, and blanks the mail and model keys so nothing sends real email or spends real money. |
Continuous integration .github/workflows/ci.yml | GitHub Actions | Ready | — | Both suites on every push — and its first two runs each found a real bug. |
Visual baselines e2e/visual.spec.ts, e2e/visual.spec.ts-snapshots, scripts/visual-baselines.mjs | Playwright screenshot comparison across widths, themes, and flavors | Ready | 1 browser | Thirty baselines: the whole component gallery at three widths under two themes and three flavors, plus every page whose layout is its own. Viewport rather than full page, because a three-megabyte diff of a table is one nobody opens. A baseline belongs to the machine that rendered it, so a run on a platform that has none skips and names the missing platform rather than passing on a comparison it never made. The committed set is LINUX, because Linux is what CI compares on — baselines captured on Windows were invisible to the runner, so this suite skipped every CI run for three days and said so in a line nobody reads: 209 passed, 2 skipped. `npm run test:visual:update` captures inside the pinned Playwright image, so a Windows machine still produces renderings the pipeline can use. Watched on another site 2026-09-04 on capability-sandbox.vercel.app, until 2026-12-03: CI run 33922827486 on ubuntu-latest compared all thirty Linux baselines and passed both visual tests — checkmarks, not skips. The first run in this project's history in which the visual gate actually compared anything. |
src/lib/health/rules.ts, src/lib/health/check.ts, src/app/api/health, src/app/admin/health | A public JSON endpoint and an admin page, over the same probes | Ready | 1 unit · 1 browser | Each probe answers a question someone asks at 3am, by doing the thing rather than reading configuration: SELECT 1, count the queue, ask storage to sign a request with our own credentials. Every probe has a deadline, because a check that hangs is worse than one that fails. The classification is the point — a critical failure is an outage, an unreachable provider is degraded, and an unconfigured one is a CHOICE reported as such — except where the platform makes absence a fault: on production, unconfigured storage and a missing cron secret report failing, because nothing can fall back there. A monitor that pages someone for a deliberate decision is a monitor that gets muted; 503 only for a real outage. Watched on another site 2026-09-04 on capability-sandbox.vercel.app (3dab6d6), until 2026-12-03: Fetched /api/health on capability-sandbox.vercel.app after the 3dab6d6 deploy: 200, status healthy, database ok in 73ms, and every provider classified — mail, AI, storage and cron configured, Stripe and the strict identity profile reported as absent rather than failing. |
src/app/admin/jobs | The mail outbox, with a page for what gave up | Ready | 1 unit · 1 browser | Background work that fails silently is background work that does not exist. The outbox already retried with backoff and gave up after five attempts; this is where 'gave up' stops being invisible. Three verbs, because a person needs exactly three: retry, discard, or drain the whole queue now. Editing a queued message is deliberately absent — a row is a record of what was sent, and letting an admin rewrite it after the fact would make the outbox a worse audit log than none. |
Runbook and restore drill docs/runbook.md, scripts/reconcile-stripe.mts | A written procedure, with a table saying which parts have been rehearsed | Partial | — | What to do when something is wrong, written before it is — and a table at the top saying which sections have actually been followed and which are still plans. The restore section is honestly marked NOT rehearsed: it is written from Neon's documented point-in-time branching, and the fifteen-minute quarterly drill at the end is what would turn it into a procedure. Grading a runbook more generously than the code would be exactly the kind of claim the capability registry exists to stop. |
Payment reconciliation scripts/reconcile-stripe.mts, src/lib/commerce/reconcile-rules.ts, docs/knowledge-base/stripe-vetting.md | A read-only script comparing orders against Stripe's Payment Intents | Fallback needs STRIPE_SECRET_KEY | 1 unit | The tool nobody builds until the morning they need it. Three questions in order of how much they should worry you: paid here and unknown to Stripe, paid at Stripe and not here (a missed webhook, recoverable by replaying it), and amounts that disagree. It NEVER writes — a reconciliation tool that repairs things turns a reporting bug into a data-loss bug, and the repair is a decision a person should make while looking at the report. Like the other operational scripts it ignores .env, because a tool that talks to a payment provider must be pointed somewhere explicitly. |
Known limitations
Things that would need addressing before any of this faced real users.
- Most provider paths have never run for realStripe, Resend, Cloudflare R2, Anthropic, Turnstile and OAuth all have complete code and a working fallback, but without credentials in this deployment none of them has been watched succeeding. That is what the Fallback count above measures, and it is the honest gap between 'built' and 'proven'.
- The identity profile here is the loose oneIDENTITY_PROFILE defaults to the sandbox posture: verification is recorded and not enforced, sessions are not tracked, and destructive actions do not ask for the password again. Every one of those is a switch, and the strict profile turns them all on — but this deployment is running the loose set, which is what /settings/security says on the page.
- Passkeys are designed and not builtThe one part of the identity work that is missing. WebAuthn needs CBOR parsing, COSE keys, attestation formats, and signature verification, where a subtle mistake looks exactly like success and is an account takeover — so it waits on a reviewed library rather than being hand-rolled. docs/knowledge-base/passkeys.md has the schema and the flow.
- The CSP still allows inline stylesScripts are locked down with a per-request nonce and strict-dynamic, so an injected script cannot run. But style-src still needs 'unsafe-inline' because Next injects critical CSS, next/font emits inline @font-face, and Recharts styles SVG elements directly. Styles cannot execute code, though CSS can exfiltrate through attribute selectors.
- The database credential is a single secretDATABASE_URL is a real credential living only in .env and the host's environment. Leaking it means leaking the data; there is no second factor in front of the database.
- No component testsThe unit suite covers pure rules and the browser suite covers journeys, but React components are tested only indirectly. Form state-restoration behavior has regressed once already.