Follow the evolution of Boilerplate-Stack with all our updates, new features, and improvements.
v1.36Minor
Guest checkout and account claiming, stronger authentication, versioned documentation, and clearer product terminology
Consumers can now buy one-time digital products without creating an account and securely claim the purchase afterward. This release also separates catalogue products from delivered licence entitlements, adds MFA, passkeys, step-up authentication, organization MFA policy and real device management, versions the documentation with every validated release, and fixes TOTP verification, personal MFA enforcement, mobile transactional emails, and device OS labels.
One-time catalogue products are now distinct from licence entitlements. Customer choices use pricingConfig.products, ProductConfig, Product, licenseType and the one_time billing model, while persisted entitlement contracts deliberately retain the licenses table and RPCs, database enums, Stripe type: 'license' metadata, admin licence surfaces, access-source values and pending-checkout discriminants. Existing generated configurations using billingModel: 'license' are normalized with a one-time development warning; no database migration is required
B2C guest checkout lets consumers purchase one-time digital products without creating an account. It is off by default behind the server-only GUEST_CHECKOUT_ENABLED flag. /pricing remains the only product-selection surface, and /login offers a localized Continue without an account path only for a valid pending one-time checkout; direct login visits, subscriptions and credit packs never expose it
The localized guest page shows the exact product and locale-selected currency, collects the payer email, reuses legal consent, requires a separate unchecked withdrawal waiver for immediate digital delivery, and includes Turnstile. The public endpoint enforces Origin checks, strict rate limits, body limits and Zod validation; disabled checkout returns 404 before parsing, and forged requests carrying account_id are rejected
Anonymous checkout performs zero database writes and creates only a Stripe Checkout session with customer_email, customer_creation: 'always' and a validated checkout_actor: 'guest' marker. After confirmed payment, the webhook idempotently provisions an ownerless guest Account and a hashed single-use 72-hour claim token, then creates the normal payment and licence records. Unexpected guest account metadata is logged as critical and grants nothing
The paid buyer receives a localized guest_purchase_claim email only after the licence is applied. Transactional emails can override their CTA URL and label, owner-email resolution treats ownerless guest Accounts as expected, and the durable service-role email queue retains the single-use token when immediate delivery fails
The authenticated claim endpoint is CSRF-protected and strictly rate-limited. It binds the signed-in user's verified email to the payer email, gives malformed and unknown tokens the same response, consumes each claim atomically once, transfers payment and licence rows into the user's existing primary Account, and moves credits only through the ledger RPCs. The emptied guest Account remains for its audit trail
The responsive claim page is a Server Component with a deliberate POST action, so GET and prefetch never mutate state or probe token validity. Logged-out buyers return through login with the claim path and token preserved; authenticated buyers receive localized typed errors. Guest and claim routes were verified in both themes and languages down to 320px
The bounded cleanup-guest-accounts job deletes only guest Accounts proven to have neither a payment nor a licence after the grace period. Paid expired purchases are never deleted; they are reported as unclaimed_expired_purchases so operators can detect delivery failures
GDPR export now includes payer-scoped guest purchase claims without token_hash, and Article 17 erasure explicitly removes claim rows so payer emails cannot be orphaned. The separate withdrawal waiver is enforced server-side with z.literal(true), with a configurable withdrawal period recording the Article 16(m) basis for immediate digital delivery
Guest-checkout contracts make billing QA deterministic. Coverage locks the no-write Stripe boundary, metadata round-trip, lazy webhook provisioning, single-use email-bound claims, credit transfer, safe cleanup and withdrawal waiver. Browser/API tests cover consent, waiver, subscription, supplied account id, malformed-token and no-oracle boundaries, while all QA profiles set GUEST_CHECKOUT_ENABLED explicitly
Feature-off tests exposed and fixed both guest handlers checking the flag after Zod parsing. Price-binding tests now mock the billing model they exercise, related job and persona mocks follow the catalogue rename, and the development launcher no longer passes a second conflicting Next.js port
Served documentation now includes a searchable guest-checkout page and the complete flow, security, notification, job, compliance, licensing, API, domain-map and test invariants. Existing payment/setup pages, the static documentation generator and generated AI-rule mirrors were updated to the new catalogue terminology
Signed-in MFA challenge, recovery-code and step-up clients now send JSON with the content type required by the protected API wrapper. Requests are no longer rejected with HTTP 415 before Supabase can verify the code, and the invalid-code message is reserved for an actually rejected TOTP or recovery code
Transactional emails now use a fluid shared 320-600px shell with Gmail and iOS text-scaling guards. Long localized CTA labels retain a compact 46px target and wrap at a normal line height, while the shared brand, dark-mode palette, spacing and Outlook-compatible button remain consistent across every email family
Personal MFA is enforced after every sign-in method. Every verified factor now requires aal2 after OAuth, magic-link or typed-OTP sign-in; the shared post-auth finalizer routes to the challenge before the requested destination, including /checkout, and page middleware repeats the control for direct requests. Only users without a factor who must enroll for an organization may reach the account page at aal1
Documentation now has immutable release URLs such as /docs/1.35, a non-indexed /docs/next preview, an accessible version selector, same-page switching, and clear preview and legacy notices. Navigation, search, breadcrumbs, pagers, canonical metadata, structured data, onboarding and sitemap entries resolve inside the selected version, and historical MDX links stay within it
Dated changelog releases are tied to validated documentation snapshots. pnpm run docs:version:create -- <version> snapshots content/docs/current, updates the registry and refuses overwrites; docs:versions:check enforces the snapshot in the normal guardrail audit, while unversioned documentation URLs temporarily alias the latest validated release
Two-factor authentication is enabled by default with QR/manual TOTP enrollment and ten single-use recovery codes shown once. Codes use unambiguous Crockford base32 and store only a SHA-256 hash. Consuming a recovery code removes every factor, signs the user out everywhere and returns them to enrollment; fallback codes exist only after successful factor verification and are cleared with the last factor
MFA management endpoints are Zod-validated, feature-gated to 404 and protected by the project security wrapper. Enrollment is strictly rate-limited, while factor removal and recovery-code regeneration require named step-up authorization
Passkey registration, management and passwordless login are code-complete behind NEXT_PUBLIC_PASSKEYS_ENABLED, off by default while Supabase's API remains experimental. A contract test pins the upstream shape; the initialization wizard preserves both auth flags and emits the required WebAuthn settings. Passkeys are not treated as MFA or step-up factors until Supabase's emitted AMR is observed and explicitly allow-listed
Destructive, credential and billing actions now require a factor verified within the last five minutes. Per-action policies live in appConfig.security.stepUp.actions, routes declare stepUp on the security wrapper, and existing requireRecentAuth call sites retain their behavior while enrolled users can answer the challenge in place
Workspace owners can require MFA for every member with a configurable seven-day enrollment grace period. Settings show enrolled coverage and affected-member warnings; protected pages route members to challenge or enrollment as appropriate. Transient policy-read failures do not lock out the workspace, while sensitive API checks remain fail-closed
The account page now lists real sessions with device, browser, OS, IP, activity and MFA state, and can revoke them remotely. Revocation invalidates the refresh token immediately; operators can lower jwt_expiry to reduce the remaining access-token window. Every factor change sends a localized out-of-band security email containing no code, factor id or device secret
User-agent OS detection now checks iPhone before Mac OS and Android before Linux, so iOS and Android sessions receive accurate labels in the device list. Historical access-log rows retain their previous labels
v1.35Minor
Centralized ZDR LLM routing with resilient failover, affiliate commission integrity, route-scoped rate limits, and executable quality guardrails
Every production chat and embedding request now crosses a single server-only LlmClient boundary with zero-data-retention payloads, a reviewed model catalogue, a shared circuit breaker, and one cross-provider fallback that never runs after the first streamed token. Affiliate attribution moves to an HMAC-signed click timestamp and payment-bound commission eligibility, external service calls gain bounded cancellation-aware timeouts, and rate-limit buckets are now scoped per HTTP method and canonical route. The repository's quality audits are executable again and enforced by CI on every push, and two high-severity transitive advisories were closed with narrowly scoped overrides rather than forced fixes.
Centralized ZDR LLM routing. Every production chat and embedding request now crosses one server-only LlmClient boundary. Operators select direct OpenAI, Anthropic, and Google APIs or OpenRouter with AI_LLM_TRANSPORT; OpenRouter requests always require ZDR-capable endpoints, deny provider data collection, require parameter support, and allow gateway provider fallback. Direct OpenAI requests set store: false and the SDK ZDR hint, while the operator documentation makes clear that contractual direct-provider ZDR remains an account/DPA control
The selectable text catalogue was reviewed on 2026-08-18 against official provider catalogues and OpenRouter's live model API. It now uses GPT-5.6 Sol/Terra/Luna, Claude Opus 5/Sonnet 5/Haiku 4.5, and current Gemini 3.6/3.5/3.1/2.5 models; retired GPT-4, older Claude, shut-down Gemini 2.0, and image-generation endpoints were removed from chat selection. Claude Fable 5 is deliberately excluded because Anthropic documents mandatory 30-day retention and no ZDR eligibility. Context limits, direct and OpenRouter prices, cache thresholds, defaults, model slugs, and sampling compatibility are configuration-owned; GPT-5.6, Claude Opus 5/Sonnet 5, Gemini 3.6 Flash, and Gemini 3.5 Flash-Lite calls omit deprecated or unsupported temperature parameters
LLM routes now share a circuit breaker backed by the existing Upstash Redis connection in production and a deterministic process-local backend in development and tests. Five transient failures in 30 seconds open a route for 30 seconds; one atomic, leased half-open probe may close or reopen it. Redis failures are rate-limited, redacted, and fail open so the observability store cannot cause a global AI outage
Direct providers receive one cancellation-aware jittered retry; OpenRouter receives none at the application layer because it already performs provider-level routing. A transient failure before output may use one reviewed cross-provider fallback, including when the primary circuit is open. Fallback never crosses an agent allow-list, never runs after the first streamed token, and never switches embedding models, because incompatible vector spaces would corrupt RAG similarity
Success and error audit rows now record the requested and resolved model, vendor, route, upstream provider and request ID, circuit state and store, bounded attempt outcome/category/duration, and whether fallback occurred. Prompts, responses, provider bodies, identities, secrets, and raw errors are excluded. Credits remain one per provider-reported input/output token and are debited once against the model that actually served the answer; chat history and the final SSE metadata likewise identify the resolved model. Bounded attempt summaries reuse the existing ai_requests.metadata JSONB column, so the circuit breaker needs no additional migration
Deterministic QA now validates the canonical model slugs, mandatory ZDR payloads for chat and embeddings, direct retry limits, automatic OpenAI-to-Anthropic model fallback, open and half-open circuit transitions, atomic single probes, Redis fail-open behavior, fallback audit persistence, agent allow-lists, and the no-fallback-after-partial-output rule. The private browser journey performs a provider failure and confirms that the same user submission continues on the configured fallback with exactly one debit and no false error row
External service calls are bounded and recoverable. Brevo and Mailjet HTTP requests now use the configuration-owned EMAIL_REQUEST_TIMEOUT_MS deadline (5000 ms by default, bounded to 1000-30000 ms). The timeout aborts the underlying network request instead of only racing it, so supported contact, newsletter, and billing flows can enter their existing durable email-queue fallback when a provider stops responding
Claude Code, Codex, Gemini, OpenCode, Cursor, and the generated rule mirrors now apply the same reliability contract to every external service integration: bounded cancellation-aware timeouts, transient-only capped backoff, idempotency before retrying writes, and a database-backed queue or transactional outbox when a side effect must survive request or process failure. The rule also requires atomic claims, stale recovery, bounded attempts, terminal operational visibility, redacted errors, and focused timeout/retry/replay tests
Affiliate cookies are now consumed against the verified billing Account before Stripe Checkout is created, with an onboarding fallback for legacy flows. Applications persist affirmative terms evidence, approval creates the first link atomically, and affiliates can create bounded same-origin links after re-accepting the current terms
The affiliate cookie now carries an HMAC-authenticated click timestamp, so Starter and Partner attribution windows begin at the real click instead of checkout, and unsigned cookies cannot manufacture a fresh window. This intentionally invalidates legacy plain-code cookies on deployment: visitors with no persisted attribution must revisit an affiliate URL, while existing database attributions remain valid. A subscription that first converts inside that window remains commission-eligible for its configured 12/24-invoice cap; the same expired attribution still rejects a new subscription, license, or credit-pack conversion
IP ceilings are transactionally locked, self-attribution covers membership overlap, and anti-fraud hashes are no longer readable through the authenticated Data API. Positive subscription invoice order comes from canonical payment rows under a subscription lock; zero-value, proration, refunded, failed, and disputed invoices do not consume commission eligibility. Conversion replays are conflict-checked, hold periods are snapshotted, and refunds or lost disputes reverse only the affected invoice
Commission statistics remain separated by original billing currency. Payout configuration is manual-only; Stripe Connect is intentionally excluded. Terms now link to a versioned document and store its immutable id; outdated acceptance disables links, attributions, and new commissions. Late attribution reconciles earlier positive payments, raw clicks have a retention purge, and admin mutations journal atomically. The accidentally deleted migration history was restored, the affiliate migration is mirrored in the full schema, and a separate forward migration reconciles the earlier schema/migration drift
Quality guardrails are executable again. The dependency audit no longer executes the Playwright TypeScript configuration while building its graph, so path aliases cannot prevent Knip from starting. Its root and Remotion workspaces are now described explicitly, implicit tool dependencies are documented narrowly, and genuinely unused dependencies and aliases were removed. Dependency Cruiser's five pricing/email cycles were eliminated by moving shared currency and notification-copy primitives into dependency-free modules
The translation audit now checks catalog parity and translation validity, which matches the project's custom translator; the unsupported source-usage heuristic that falsely reported thousands of unused keys was removed. CI now runs the translation, dependency, and circular-import audits on every push and pull request
Every explicit API 500 response must now have a preceding structured error log; the audit fails globally instead of warning for legacy routes. The dependency vulnerability gate was raised to low, patched esbuild is enforced across the workspace, and the stale Remotion ESLint chain was upgraded, leaving the audit at zero known vulnerabilities. The boilerplate version is sourced from package.json by the initialization wizard and aligned with the latest documented release, 1.35.0
Route-scoped API limits. Rate-limit keys now include a versioned HTTP method and canonical route scope before the caller identity and trailing IP. Unrelated endpoints and methods no longer consume one another's quota, query strings never affect the bucket, and every dynamic handler declares a stable route template so varying UUIDs, slugs, or codes cannot bypass throttling. The API-pattern audit now rejects future dynamic routes without that declaration. Inline AI, Stripe, and dual-auth job-run limiters use the same key builder; the job runner's admin branch now authenticates before applying its user and IP budget
Platform-admin reads now use a dedicated 120/min/route tier and ordinary mutations 30/min/route. Access, billing, role, cron, reversal, and destructive operations explicitly retain the strict 5/min/route tier. All limits, including per-recipient magic-link and OTP budgets, are centralized in config/rate-limits.ts; the v2 Redis namespace prevents collisions while old minute-window keys expire naturally
The AI coding blueprint is aligned with current Supabase contracts. Claude, Codex, Gemini, OpenCode, Cursor, and generated mirrors now route database work through the Account-centric ownership model, explicit Data API grants plus RLS, migration and full-schema parity, safe SECURITY DEFINER privileges, current Supabase client boundaries, and scoped read-only-by-default MCP guidance. The published AI Blueprint no longer teaches retired helpers, unscoped remote access, or missing CLI workflows
Frontend instructions now use one project-wide DESIGN.md contract implemented by app/globals.css, components.json, local primitives and patterns, DM Sans and JetBrains Mono, semantic oklch() tokens, Tailwind CSS 4, and the existing Motion provider. The retired pre-v4 .impeccable.md handoff was removed and the reviewed Impeccable bundle was upgraded from 4.0.4 to 4.1.1, with its absent CLI, self-update, and hook installation paths disabled for this repository. Every frontend change now begins mobile-first and loads Impeccable plus only the React, shadcn, Tailwind, motion, accessibility, and performance guidance the task actually needs. Rules and skills remain canonical under .claude/, with synchronized multi-tool mirrors and automated drift validation
Playwright could not start its CI web server. playwright.config.ts launched the app with pnpm run dev -- --hostname 127.0.0.1, and the pinned pnpm forwards -- verbatim, so Next received --hostname as its project directory. Every CI browser suite (test:e2e:api, test:e2e:auth, test:e2e:oauth, test:a11y, test:responsive) failed at server start. The config now invokes the CLI directly and derives host and port from the validated QA base URL, so the server started is always the one Playwright polls
The local QA gate reported failures it could not explain. currentStep was only assigned inside the Phase 6 block, so every earlier stage reported as initialization, and the Supabase CLI's output was captured and discarded. The gate now names each stage, and QA_LOCAL_GATE_DIAGNOSTICS=true adds the failing stage's reason plus bounded, redacted stderr and stdout tails (supabase status output is never forwarded)
The gate also preflights the migration set before start and reset. The CLI silently skips any file that does not match its timestamp pattern, plus any <timestamp>_init.sql — a skipped migration produces no output and only surfaces later as a seed-time relation "public.app_settings" does not exist. classifyMigrationFileNames mirrors that filter and fails before any image is pulled; its message needs no diagnostics flag because it is built from repository file names only
Two high-severity transitive advisories were closed with scoped overrides, without a new release-age or trust-policy exclusion and without a forced audit fix. GHSA-2v37-7h3g-55p8 (nanoid) was re-scoped from <3.3.17 to <3.3.18, so the existing postcss>nanoid: ^3.3.17 floor silently stopped clearing it — the override looked correct while the lockfile stayed on the vulnerable 3.3.17. Raising the floor to ^3.3.18 fixes it; nanoid resolves to exactly one version and all 52 paths run through postcss, so the existing scope still covers the whole tree, and 3.3.18 clears the seven-day quarantine on its own
GHSA-jmr9-qjv8-65gv (extract-zip) had no upgrade path at all: the advisory names >=2.0.2 as patched, but that version was never published — 2.0.1 from 2020 is still latest and the package is effectively abandoned. It reached us solely through @lhci/cli > lighthouse > puppeteer-core, and every link in that chain is pinned exactly, so nothing upstream could move on its own. @puppeteer/[email protected] replaced extract-zip with modern-tar, so the new scoped override puppeteer-core>@puppeteer/browsers: ^3.1.0 deletes the vulnerable package outright rather than pinning around it. ^3.1.0 rather than the newer ^3.2.0 is deliberate: 3.2.0 would have needed a minimumReleaseAgeExclude entry, while 3.1.0 drops extract-zip just the same and clears quarantine unaided
Crossing that major was verified rather than assumed. 3.x still exports every symbol puppeteer-core@24 imports, and although it is ESM-only where 2.13.2 was dual, Lighthouse 12 is ESM and resolves puppeteer-core's ESM build, so the consuming path never requires it; the CJS interop path was confirmed working anyway on the pinned Node baseline. The net effect is a 22-package reduction, with [email protected] and [email protected] version-unchanged, so the hard Lighthouse assertion floors are unaffected. Signatures were re-verified across 1658 packages; typecheck, 640 unit tests, guardrails, lint, and a full production build all pass
Referral rewards are now bound to a real, finalized payment on the referred Account, so refunds and disputes reverse exactly the reward they paid for and nothing else. Platform administrators can enable maintenance mode from the admin dashboard without a redeploy, behind a CSRF-protected, audited RPC. A disposable account-aware QA stack now gates the repository end to end — private, organization, platform-admin and non-functional coverage — and exposed a long list of real defects along the way. Stripe webhooks move to API version 2026-07-29.dahlia, and the admin Payments tab, which had never rendered a single row since it shipped, finally queries columns that exist.
Paid referral rewards now require a positive, finalized local payment belonging to the referred Account. The referral stores that payment in qualifying_payment_id; subscription rewards additionally require persisted Stripe subscription evidence. Full refunds and lost disputes reverse only the reward qualified by the affected payment, so an unrelated purchase can no longer trigger a clawback. Account-scoped advisory locks close the webhook/attribution race, and zero-value, fully refunded, missing, or cross-Account payments are rejected
Checkout routes consume the referral cookie only after billing authorization and before returning the Stripe URL, using the exact verified billing Account. The awaited onboarding fallback resolves an owned workspace in B2B mode or personal Account in B2C mode and may reconcile a payment that committed first. Manual code application cannot reuse a historical payment. Cache invalidation is targeted with the referrer Account id returned by the new qualification/reversal RPCs
The referral migration is mirrored in supabase/schema.sql and generated database types. A 31-assertion pgTAP suite covers payment validation, exact reversal, payment-before-attribution reconciliation, subscription-only evidence, RPC privileges, and indexes; the focused feature/browser gate covers member/admin referral mutations plus responsive and accessibility states
Audited platform maintenance mode. Platform administrators can now enable or disable maintenance from /admin-dashboard/settings without changing an environment variable or redeploying. The strict-boolean, CSRF-protected, rate-limited, recent-authenticated admin mutation delegates to an authenticated-only SECURITY DEFINER RPC that repeats the is_admin check and atomically records the change in admin_logs
When maintenance is enabled, localized website pages and /docs temporarily redirect to the new localized /[locale]/maintenance screen with no-store, Retry-After, and noindex protections. Administrator and authentication-recovery routes remain reachable, while APIs, Stripe webhooks, background jobs, health probes, static assets, and the offline fallback continue operating. Non-GET page requests receive a typed 503 MAINTENANCE_MODE response. The scalar status lookup uses a 60-second shared Next.js server cache with a cache-safe Supabase service client, runs alongside session resolution, and deliberately fails open with structured logging if Supabase is unavailable. Immediate invalidation is intentionally omitted; the admin Settings UI states that activation and restoration can lag until the cache refreshes
The maintenance-mode migration defaults the flag to false, exposes only a narrow public boolean read RPC, and is mirrored in supabase/schema.sql and generated database types. The operator guide at Production → Maintenance Mode documents rollout, recovery boundaries, security controls, request behavior, and verification
Disposable account-aware QA automation. The repository now pins the Supabase CLI and carries a loopback-only local stack, an idempotent QA seed, and transactional pgTAP suites for schema, RLS/grants, and signup/account invariants. pnpm run qa:local:gate guards the exact local project, resets and tests it, provisions member/admin/owner/platform-admin actors through Supabase Admin Auth, runs passwordless auth, role, cross-tenant, and authenticated dashboard accessibility checks, then removes browser storage state and the disposable database volume
The public documentation now includes a dedicated Quality Assurance → QA Automations section explaining the disposable gate lifecycle, focused command profiles, account/persona isolation, provider boundaries, production kill switches, route-drift manifests, diagnostics, and the workflow for adding QA coverage
Phase 6 non-functional coverage is complete through pnpm run qa:local:phase6. The production-only disposable profile builds and trace-checks Next.js, generates the Serwist worker, scans 14 public routes in both themes plus docs/offline and all 58 owned private/organization/admin destinations, exercises five viewports, compares eight committed visual baselines, and runs a 36-case Chromium/Firefox/WebKit matrix. It also proves keyboard/zoom/reduced-motion behavior, consent and Global Privacy Control script boundaries, manifest/service-worker/offline/push states, API/document runtime-cache isolation, public and authenticated performance plus PostgreSQL query budgets, 15 median-gated Lighthouse audits, and a rate-bounded health load smoke. The gate exposed and fixed accessibility labels/contrast/scroll regions, invalid analytics tab semantics, consent-script withdrawal, offline navigation fallback, unsafe default runtime caching of dynamic API/HTML/RSC responses, local WebKit CSP handling, stale admin audit selection, transient session-button contrast, a hero LCP hidden until Motion hydration, streamed-redirect test races, and unbounded load generation
Phase 2 private coverage has started with a focused pnpm run test:e2e:private command. The authenticated member suite now exercises the localized private-dashboard overview and credit-pack chooser, and validates the complete GDPR CSV export contract: download/security headers, all 29 documented sections in order, a secret-safe API-key projection, and exclusion of foreign-tenant identities
The private suite also drives the real chat/SSE route and Supabase accounting against a secret-free OpenAI-compatible provider bound to loopback. It proves the 402 pre-flight gate, exact provider-token debits, durable user/assistant history and AI-output marking, provider failure/retry without false success or duplicate prompts, abrupt socket interruption followed by a clean retry, same-tick duplicate-submit rejection with one session/request/debit, and cancellation without billing. Stalled upstream streams now abort at the config-owned aiConfig.streamTimeoutMs ceiling and enter the observable zero-token error path. The client preserves interrupted assistant turns as localized retryable errors, while synchronous single-flight guards prevent duplicate sessions and streams before React state catches up. The implementation also fixes the defects exposed by this proof: provider exceptions converted by BaseAgent can no longer fall through as successful zero-token requests, PostgREST chat-message bulk inserts now give both rows an identical column set so ai_generated NOT NULL cannot roll back history, and the private dashboard's active-account cookie now selects only a validated membership while keeping every account-scoped field aligned. Hybrid access resolution preserves that validated account order across subscription and license sources, while provider-quota alerts redact secrets before email delivery. pnpm run qa:local:private runs this focused disposable profile; only the local gate injects its exact deterministic-provider marker, so generic private/persona commands exclude every provider-backed spec instead of risking a developer's real provider credentials
The same fail-closed provider profile now covers the complete document/RAG lifecycle. Uploads require an exact MIME/extension pair plus magic bytes, keep the 10MB file limit separate from bounded multipart overhead, acknowledge asynchronous work with 202 Accepted and Location, and explicitly finish the browser response before polling. Embeddings use a validated server-only OPENAI_BASE_URL, a 60-second request ceiling, bounded multi-input batches, and strict provider response index/dimension checks. Provider-reported tokens are persisted as a scalar list field and drive the UI credit refresh instead of chunk count. Deletion removes the private Storage object before the document row and relies on the database cascade for chunks. The deterministic browser contract proves foreign-account denial, one exact token debit, ready/detail state, and complete DB/chunk/object cleanup; generic private/persona commands exclude this provider-backed spec as well
Local Phase 2 private coverage is now complete. A feature-enabled, fail-closed profile exercises personal referral code creation and sharing, attribution, qualification and atomic rewards, then the affiliate application, pending/approved states, link attribution, conversion recording and localized metrics. The provider-free account-settings contract persists profile and consent changes and proves deletion scheduling plus cancellation; existing session and GDPR export contracts remain part of the same gate. All five private pages receive WCAG 2.2 A/AA scans and horizontal-overflow checks at 320, 768 and 1280 pixels. The tests exposed and fixed stale affiliate state after application, a keyboard-inaccessible conversions scroller, a duplicate public main landmark, and a missing accessible loading status on documents. The responsive matrix restarts the application to obtain a fresh production-equivalent rate-limit window instead of sleeping or weakening the limit
Phase 3 organization-dashboard coverage is complete through pnpm run qa:local:org. The disposable owner/admin/member matrix exercises all nine route contracts, invitation resend/revoke, role changes, manager removal boundaries, account-isolated analytics and billing, one-time API-key creation/revocation, settings normalization, owner-only deletion, plus WCAG 2.2 A/AA and 320/768/1280px overflow checks on all eight rendered destinations before repeating authenticated API and dynamic-resource contracts. The implementation now derives the current manager membership without another query, keeps billing and destructive controls owner-only, preserves admin removal rights only for ordinary members, refreshes Server Component state without hard reloads, correctly labels completed payments, and names keyboard/icon controls for assistive technology
Phase 4 platform-admin coverage is complete through pnpm run qa:local:admin. The disposable matrix renders all 43 admin list/new/detail/edit pages with worker-owned IDs, exercises all 36 admin API families across positive CRUD/actions, malformed input and unknown-resource states, and proves every page/API denial for member, workspace-admin, and workspace-owner personas while logs/referrals/affiliates are enabled hermetically. User actions now cover recently reauthenticated setup delivery, profile/privilege/disable changes, atomic credit ledger writes, guarded deletion, and audit rows; subscription domain contracts align Stripe calls, the local mirror, and audit events without contacting Stripe in the local gate. The suite also proves error-log secret redaction and salted IP hashing. Defects found by the gate and security review were fixed: admin credit RPCs remain bound to the Supabase client, malformed media bodies return 400, and deleting a user cannot implicitly remove a workspace or externally billed Account. Payment-reference classification distinguishes real Stripe identifiers from internal free_init_*, sub_init_*, sub_trial_*, and end_trial_* idempotency sentinels, so free users remain safely deletable
Phase 7 browser-driven admin CRUD coverage has started through pnpm run qa:local:phase7. The current localized Playwright journeys create, persist, toggle/edit, and delete worker-owned jobs, job handlers, blog categories, and localized tags through the real forms, menus, confirmation dialogs, CSRF-protected routes, and exact database cleanup checks. Taxonomy journeys also prove in-use deletion denial. The focused profile preserves the strict admin rate limiter and assigns independent journeys reserved QA client IPs instead of weakening the quota. The same spec is wired into the focused admin and default disposable gates; CMS/blog pages, media, and cron remain tracked in QATests.md
Playwright's authenticated projects remain opt-in and fail closed. Persona runs require QA_PERSONA_SETUP=true plus a Zod-validated account-centric manifest; setup verifies every Auth identity, profile, membership role/role_slug, workspace, and billing-access source before minting a magic link. Privileged projects disable trace, screenshots, and video. A shared diagnostic fixture records only bounded canonical route/count tuples for browser errors and same-origin failures, so console payloads, token URLs, cookies, headers, bodies, and stacks never enter reports. Request failures are associated with their main-frame navigation generation: only resources from a superseded page are ignored, while current-page failures remain blocking. CI adds the disposable local database/persona gate and repeats secret scanning after browser execution
The local gate now captures the application's own rendered magic-link message through a fail-closed, temp-only extension of EMAIL_PROVIDER=noop. Two secret-safe Playwright journeys sign in preflighted personas with the delivered OTP and with the exact delivered callback URL; the latter is exchanged outside Playwright instrumentation before validated cookies are installed. The recorder requires loopback app and Supabase origins plus non-indexable development mode, writes bounded Zod-validated messages atomically, and ignores snapshotted credentials on retry. Privileged runs use no persistent reporter, avoid secret-bearing Playwright input steps, bind the app to loopback, and remove exact temporary email/output directories on completion or handled interruption before the post-gate secret scan. Auth email inputs now pass through sanitizeEmail() after Zod validation, while addresses that sanitization would destructively rewrite are rejected instead of targeting another identity
Logout now has its own isolated persona session so it cannot invalidate a concurrently running ACL project. The browser exercises the real account dropdown and CSRF-protected /api/auth/logout, then proves the auth cookie is gone, /api/user/sessions is unauthorized, and a fresh context replaying the pre-logout storage state is redirected to login. Session setup generates this fifth state only after the complete four-persona preflight, keeps all artifacts disabled, and the local gate removes it in finally
Session revocation is now a server-confirmed Auth operation rather than an audit-only API followed by an unchecked browser call. DELETE /api/user/sessions executes signOut({ scope: 'others' }), reports Auth failures, preserves the current session, and records the audit outcome separately. Ordinary /api/auth/logout explicitly uses scope: 'local' instead of Supabase's global default and no longer reports or logs success before Auth succeeds; account and invitation UIs rely on that single server mutation instead of issuing a second global sign-out. Two additional independent owner states prove the other session is rejected while the current one remains authenticated. The page title now says session activity because its list is recent access-log history, not a live auth.sessions inventory, and access logging reuses the centralized Cloudflare trust gate while sanitizing stored header strings
The follow-up hardening makes the proof and data boundary explicit. The logout test now verifies a second session survives scope: 'local'; revocation proves immediate application denial through getUser() without claiming that an already-issued JWT is invalid against Supabase's Data API before expiry. Persona provisioning and revocation are rejected for hosted staging and accepted only on the loopback target. Session reads and revocation moved from the route into core/auth, independent reads run in parallel, and user_access_logs is now a service-authored audit trail: authenticated INSERT grants and policies were removed in a mirrored migration. The unused client-selectable /api/user/log-activity endpoint and its duplicate onboarding call were removed; real mutations such as /api/user/onboarding log their own fixed event server-side, so clients cannot forge audit event types through a service-role proxy. Finally, the local email recorder is fail-closed in both Webpack and Turbopack production execution; a post-build assertion rejects compiled or traced recorder code and external temporary-host entries
Stripe webhooks now use API version 2026-07-29.dahlia. The Stripe SDK is upgraded to 22.4.0 and both the application client and the staging readiness probe derive the API version from the SDK. Invoice webhook handlers consume Dahlia's invoice.parent.subscription_details.subscription and Invoice Payments shapes instead of the removed top-level invoice.subscription and invoice.payment_intent fields, preserving subscription payment records and monthly credit refills
Webhook signature verification accepts an optional STRIPE_WEBHOOK_SECRET_PREVIOUS during Stripe's dual-endpoint cutover procedure. The readiness probe recognizes query-suffixed replacement endpoints, requires an explicit Dahlia version pin, checks the required event list, and sends a signed inert event to confirm that the deployed route accepts the configured active secret
The dependency audit also exposed GHSA-2v37-7h3g-55p8 in the PostCSS branch's [email protected]. A narrow postcss>nanoid override raises it to patched 3.3.17 without broadening the repository's release-age or trust-policy exclusions
The admin Payments tab had never shown a single row. Reported as "I paid a license and there's no line in Payments" — the license was fine, the tab was querying columns that do not exist. The user detail page selected amount_cents and credits_amount from payments, while the table has amount (integer, cents) and metadata jsonb; neither string appears anywhere in supabase/schema.sql, and the only references in the entire repo were those three lines. PostgREST rejects the whole projection on an unknown column, so the read returned null, the call destructured only { data } and discarded the error, payments fell to [], and the tab rendered its designed "no payments yet" empty state. Not license-specific and not new — that tab has been empty for every user and every payment type since it shipped. The sibling read in org-dashboard/billing had always used amount correctly, so the two pages disagreed about the schema with no gate able to notice
A failed query that falls into a designed empty state is indistinguishable from real emptiness, and that is what bought this bug its lifetime: an empty Payments tab is exactly what a user who never paid should see, so nothing looked wrong until someone knew for certain that a payment existed. Same shape as anti-pattern D9 — a silent fallback whose output is plausible enough that nobody reads it as failure. The query error now goes to logError (fire-and-forget, category admin, event user_detail_payments_query_failed), so a rejected projection surfaces at /admin-dashboard/logs rather than presenting as absence of data
Credits were the same mistake twice: there is no credits_amount column either, and the two writers do not agree on a key — credit packs write metadata.credits_amount while licenses write metadata.credits_included (core/billing/mutations.ts). A new getPaymentCredits() helper reads both and coerces, so a license granting credits.oneTime now shows its grant instead of a dash. A second defect had been masked by the first: license is a payment_type enum value and applyLicensePurchase writes it, but getPaymentTypeLabel() had no entry for it, so the badge would have rendered the raw slug the moment rows appeared. admin.userDetail.paymentTypeLicense was added to all four catalogues
v1.33Major
Security sweep, design-system rebuild, EU AI Act compliance, and a pnpm-only supply chain
The largest release to date: a DOMPurify sanitizer-bypass fix and a pnpm-only supply-chain policy, an external attack-surface audit that closed eleven findings (including a completely unaudited auth callback), a rebuilt dashboard shell and design system across ~84 pages, EU AI Act Article 50 disclosure and output marking, GDPR export and consent-trail gap closure, and a long list of accessibility, i18n and invitation-flow defects — including workspace invitations, which had never once been acceptable.
Fixed a DOMPurify sanitizer bypass (GHSA-55q2-fjhq-7xh7): removing an IN_PLACE hook leaves a detached subtree executable. DOMPurify backs HtmlRenderer, the shortcode renderer and markdownToHtml, so this was a live XSS path rather than a transitive footnote. dompurify is now overridden to 3.4.13, scoped to its sole consumer (isomorphic-dompurify>dompurify) rather than applied graph-wide, with an exact-version exception to the project's seven-day release quarantine on the same "urgent signed CVE fix" precedent as the fast-uri and js-yaml entries — waiting the quarantine out would have meant knowingly serving the vulnerable sanitizer for three more days. The guardrail exclusion list was updated in the same edit and can be removed after 2026-08-10; the override stays as the version floor
New pnpm-only dependency supply-chain policy. The root app and Remotion demo are now one pnpm workspace with a single lockfile, an integrity-pinned pnpm 11.20.0 and a Node 24.18.1 LTS baseline shared by version files, package engines, CI and Docker; npm, npx, pnpx, pnpm dlx, Bun, Yarn and alternate lockfiles are forbidden. Lifecycle scripts are disabled by default with a reviewed allowBuilds list, and resolution applies a seven-day release quarantine, strict publication-time lookup, exotic-transitive-source blocking, registry integrity revalidation and a no-downgrade trust policy. Version-scoped overrides resolve ws to 8.21.0, fast-uri to 3.1.5 and js-yaml to 3.15.1 / 4.3.1. Codex, Claude Code and Gemini share a fail-closed command hook; CI uses SHA-pinned actions and runs frozen install, registry-signature verification and a moderate-or-higher audit before code checks
An external attack-surface audit found /[locale]/callback running unauthenticated with no wrapper and no rate limit — while audit:api-patterns reported "coverage OK" the whole time, because a Next.js route handler is defined by the filename route.ts, not by living under /api, and the scanner walked only app/api. Unthrottled it accepted unbounded verifyOtp and exchangeCodeForSession calls, the cheapest way to exhaust the project's Supabase Auth budget and lock out real sign-ins. The scan root is now app/ (coverage 84 → 87 route files) and the callback is limited at the standard tier. A gate that cannot see part of the surface it claims to cover converts an omission into a green check
Magic-link had no per-email budget: rate limiting was per IP, but the target of this abuse is somebody else's inbox, and rotating source addresses against one victim gave unlimited sends — while generateLink also creates the auth.users row for an address that has never signed up, so the same loop mass-registers arbitrary addresses and burns the sending domain's reputation. Now 3 per 15 minutes keyed on the normalised address, charged after the prelaunch gate and before generateLink. Separately, the OAuth duplicate-account merge was keyed on email equality alone and rewrote an existing user's profile from data supplied by whoever completed the flow; it now requires email_verified from the user metadata or the identity claim
A cached CSP nonce is a published CSP nonce. With CDN_PUBLIC_CACHE_ENABLED the edge stored the HTML and its Content-Security-Policy header together, so every visitor inside the TTL received the same nonce and could read it out of the markup — at which point script-src 'nonce-…' 'strict-dynamic' grants precisely what 'unsafe-inline' grants, on exactly the CMS and blog pages where an injection would matter. The middleware now withholds the cacheable Cache-Control on a nonce-bearing response unless CDN_PUBLIC_CACHE_ALLOW_SHARED_NONCE=true states the trade explicitly, which makes edge HTML caching opt-in-again rather than silently on
The SSRF guard matched IPv6 as text, so it only recognised the spellings it was written for: ::ffff:7f00:1 is 127.0.0.1 and the dotted-form regex did not match it, so an admin-configured webhook could be pointed straight at the loopback interface or the cloud metadata endpoint. The same held for the NAT64 (64:ff9b::/96) and 6to4 (2002::/16) transition prefixes, and ff00::/8 multicast was not checked at all. Addresses are now parsed into numeric hextets and any embedded IPv4 is resolved and judged as IPv4 — numbers collapse every spelling of an address to one value where text matching cannot. Covered by 44 new test cases
The rate limiter now runs before the request body is ingested on public routes — a throttled attacker previously still made the server read up to 10 MB per request and only paid the 429 afterwards. In-memory rate limiting no longer fails open in production: without a shared store every serverless instance keeps its own counter map and an attacker is load-balanced across all of them, which does not weaken the limits but removes them invisibly, so missing Upstash credentials now throw at boot with ALLOW_INMEMORY_RATE_LIMIT=true as an explicit single-instance opt-out. Also removed cdnjs.cloudflare.com from script-src (it remains in img-src), added frame-ancestors 'none' plus COOP/CORP on /api/*, and gave Turnstile verification a 5s timeout and client-IP forwarding
/api/health bought a full COUNT(*) for every anonymous request: the probe used { head: true, count: 'exact' } on profiles, and .limit(1) does not bound a count — unauthenticated and unthrottled, one curl equalled one table scan. The compounding failure is that this is the endpoint the orchestrator polls, so saturating it made the probe answer degraded/503 and got healthy containers restarted, an outage loop anyone could trigger from outside. It now selects one row with no count, costing an index lookup regardless of table size, and carries a relaxed limiter sized so real probes are never throttled
The Stripe billing portal was reachable by any workspace member. createPortalSession authorized on RLS alone, which blocks a foreign accountId but not a plain member of a workspace they belong to — and the portal can cancel the subscription and change the payment method. Its accountId arrives from the client-writable current_account_id cookie, and a Server Action is an HTTP endpoint, so the conditional UI around it was never a control. It now applies the same getBillingManagerRoles() gate the API route always had. Behaviour change: in b2b mode a workspace admin can no longer open the portal from /my-account. All seven current_account_id writers now share one name and options source; one had been dropping the secure and sameSite flags
Workspace invitations could never be accepted. invitations.token is minted as 64 hexadecimal characters while POST /api/invitations/accept validated it as z.string().uuid(), so every accept was rejected at Zod before reaching the domain layer. It hid unusually well: the email sent, the accept page rendered the workspace name and inviter correctly, and only the final button failed — returning "Invalid input", which reads like the user typed something wrong rather than like a server that cannot parse its own token. config/workspace.ts now owns tokenBytes plus a derived pattern that both the generator and the schema read, mirrored into scripts/init-project.js in the same change since the init script overwrites that file
Two more ways an invitation could be permanently unacceptable. inviteMember stored the typed address raw, so a trailing space from a paste could never match the invitee's real address — producing "This invitation was sent to a different email address" with no way to tell why. Addresses are now normalised through the existing sanitizeEmail() on write and read, so invitations already stored with stray case or whitespace start working without being re-sent. The comparison itself had a hole: undefined !== undefined is false, i.e. "matches", i.e. accepted. A missing address is now a mismatch, never a match
Every invitee who followed a link while signed out landed on the dashboard instead of the invitation: the page built a locale-prefixed redirectTo while sanitizeRedirectTo matches locale-less paths, and /invite/accept was not in the allowlist at all. Two independent misses, one silent outcome — the allowlist rewrites a miss to /private-dashboard rather than failing, and that destination is plausible enough that nobody read it as a bug. Since a new invitee is signed out by definition, this hit the normal path rather than an edge case. Accepting with the wrong account is now diagnosable too: the mismatch is detected server-side before the Accept button renders, naming the signed-in address in full and the invited one masked (invitation links get forwarded, so the page must not print a third party's address), with a sign-out-and-switch action
A failed AI turn rendered a bare 0 where the answer belonged. /api/ai/stream commits 200 as soon as streaming begins, so a provider failure can only be reported in-band — but the client destructured { content, tokens } from each frame and dropped everything else, so the server's own explanation was discarded and the turn simply ended empty; the UI already had an error branch with a retry button and it had never been reached. The visible artefact came from the second defect: the turn ended with tokensUsed: 0 and the token footer was gated on {message.tokensUsed && …}, and React renders 0 as a text node where false and null render nothing. Errors now surface at the moment they arrive, localized through a new mapStreamError option, and a stream ending with neither a terminator nor an error frame no longer leaves the bubble typing forever
"Manage organization" appeared for every user. In B2B mode each user is provisioned a workspace at signup and made its owner, so a plain member of somebody else's org also owns an empty shell of their own — filtering the dropdown on role alone therefore showed the entry to literally every user, which conveys the same information as showing it to nobody. The derivation existed in five places with three different rules, so a workspace admin saw the entry on /private-dashboard but not on /admin-dashboard. core/accounts/owned-workspaces.ts is now the single rule: owner/admin of a workspace account that carries a subscription or has more than one member. Two call sites deliberately keep their own rule and are commented so they are not "unified" later
Every entry in the account dropdown pushed the same /org-dashboard/admin URL while the org context resolved currentWorkspace = ownedWorkspaces[0], so a user managing two organizations could not reach the second one at all. The dropdown now records the choice in the existing current_account_id cookie and the org context honours it — as a hint only: the id must match an entry in the list the server derived from the caller's own memberships, and falls back otherwise, so tampering can only ever select a workspace the user already manages
A silently killed Docker build looked like a TypeScript failure — the log stopped dead after "Running TypeScript ..." with no type error and no BuildKit failure line, which together mean a SIGKILL: no exit code to report and no chance to print. The builder stage now caps the heap via a NODE_BUILD_MEMORY_MB build argument (default 4096), plumbed through docker-compose.yml so it can be overridden from Coolify without editing the Dockerfile. Node sizes its default old-space from the host's total RAM and does not read the container's cgroup limit, so capping it makes V8 collect instead of growing until the kernel intervenes, and converts a genuinely insufficient budget into a legible heap-out-of-memory stack rather than silence. This is insurance, not a confirmed diagnosis — the leading remaining candidate is a dropped SSH channel, since Coolify reports exit code 255
Two CI gates failed on conditions unrelated to the code. The rate-limit boot guard fired during next build, which evaluates every route module to collect page data in a process where NODE_ENV is already production while nothing is listening, and a build runner legitimately holds no Upstash credentials; the guard now also reads NEXT_PHASE, which Next sets to phase-production-build during the build only, so next start and serverless runtimes both leave it armed and a real server still fails its cold start. And audit:guardrails crashed instead of reporting, because it read an optional nested workspace manifest unconditionally and died on an uncaught ENOENT before a single check ran — the manifest list now carries a required flag and a missing root manifest is reported rather than thrown
Node 24 Docker install compatibility: upgraded @lingual/i18n-check to 0.9.5, which supports Node.js 20 and newer and no longer pulls the Node-22-only i18next-parser, fixing ERR_PNPM_UNSUPPORTED_ENGINE during frozen Docker installs. Declared highlight.js, serwist and undici as exact root dependencies so pnpm's strict module isolation exposes the packages directly imported by the Markdown renderer, service worker and job runner during Turbopack builds. Serwist now uses configurator mode instead of its Webpack plugin, so Next.js builds with the default Turbopack compiler and @serwist/cli bundles and injects the service worker afterwards
GDPR erasure aborted for every OAuth user with a profile picture. process-account-deletions unconditionally removed from an avatars Storage bucket whenever profiles.avatar_url was set — and there is no avatars bucket anywhere in the schema, the init script or any migration. Since avatar_url is populated exclusively from OAuth metadata it is always an external provider URL, so Storage answered "Bucket not found", the processor threw, and the request was persisted as failed with retry_count incremented: the Article 17 erasure never completed and the row stuck after max_deletion_retries. Magic-link-only users were unaffected. The path is now null unless the URL actually points into the bucket, and the processor records skipped with the reason
The dashboard shell was rebuilt so separation is expressed by elevation and surface rather than borders, with the same chrome on private and org. The enabling change is a token, not a stylesheet sweep: --background was 0.995 against --card at 1.0, a contrast ratio of 1.020, so the only thing holding a card apart from the page was its border; it is now 0.94 light / 0.115 dark, and 0.94 is a floor rather than a preference — text-destructive on the low-credits label clears by only 4.52 there and fails at 0.93. <main> became a floating panel, full-bleed on mobile, and the shell is fixed inset-0 rather than an in-flow h-dvh box, which had been drawing a second scrollbar beside the panel's. The bottom tab bar was removed and its components deleted with it rather than left unwired
One app bar now owns the drawer trigger, notifications, theme toggle and account dropdown on both the private and org dashboards. Private could not previously have one for a structural reason: the mobile-open state lived inside the sidebar, so the trigger was forced into the same component as the drawer it opened; hoisting that state into a shell is the whole change. Two things follow — the notification bell had two mounts for one control and now renders once at every width, and the account dropdown moved into the bar, collapsing a fork where private rendered its own hand-rolled dropdown with a separate sign-out path and its own Supabase client while marketing, admin and org shared one component
Design-system migration across ~84 pages. Colour is now a single parameter: --brand-h, --brand-c, --brand-c-subtle and --neutral-c are the only authored colour values, and every brand, neutral, sidebar and chart token derives from them, so rotating one line rebrands the product — raw palette usage went from 53 files to 2, both documented exceptions. Semantic states are deliberately not brand-derived and all sit at lightness 0.55, because a token used as text has a strictly harder contrast target than the same token used as a fill. New container, elevation, motion and z-index scales, a fluid clamp() type scale, and fixed font variables that had pointed at --font-geist-* values defined nowhere, so font-sans fell back to the browser default. New pattern library (PageHeader, StatCard, EmptyState, ErrorState) with 60 PageHeader call-sites and zero hand-rolled page titles left in the dashboards
Navigation is manifest-driven: config/navigation.ts holds the private, org and admin manifests plus pure resolve and active-match helpers, replacing 2,049 near-duplicate sidebar lines, with icons carried as names rather than components because gates resolve on the server and React cannot serialize a function across that boundary. 31 tests pin gate and label semantics. components/navigation/sidebar-nav-link.tsx is now the single link implementation behind all three sidebars — the private and org copies had both gained a collapsed-rail aria-label while admin never did, so the whole of admin's collapsed navigation was anonymous to assistive tech. Motion is installed and mounted only in the marketing layout (LazyMotion + MotionConfig reducedMotion="user"), ~4.6 kB baseline and zero bytes on the dashboards
New /register route sharing magic-link and OTP mechanics with /login through a variant prop that selects copy and framing only, deliberately not added to the redirect allowlist since it is the origin of an auth flow and never a post-auth destination. input-otp replaces the hand-rolled code field on both, bringing paste distribution, Backspace-to-previous-slot, arrow-key navigation and one-time-code autocomplete. The shadcn chart primitive replaces the admin analytics bars, which were hand-positioned divs with a 2% height floor to fake visibility, no Y axis, X labels that did not line up, and a hover-only tooltip unreachable by keyboard or touch; Recharts resolves into exactly one 343 kB chunk, absent from every page's initial set
New StatusPill pattern across 13 render sites, eight of which had conveyed status by colour and text alone, with cancelled and failed sharing a glyph separated only by colour. The finding behind it revises the house style: the tinted form documented as canonical fails for small text — bg-success/10 with text-success measures 4.19:1 and info 4.40:1 against a 4.5:1 requirement, because tinting a surface with the token's own hue moves the surface toward the text and spends exactly the headroom the token was authored with. StatusPill therefore keeps the tone on the icon, where 3:1 applies, and renders the label in text-foreground
~50 hardcoded English accessible labels replaced. aria-label, sr-only, alt and title are never rendered for sighted users, and axe checks that a label exists rather than what language it is in, so every gate stayed green while a French screen-reader user heard "Toggle theme", "Send message", "Close menu". The WYSIWYG editor alone held 21 toolbar labels behind an optional translations prop whose sole call site passed nothing. A new UiStringsProvider resolves a curated set of chrome strings server-side and hands them to deep client primitives via context, since DialogContent has 41 call sites and prop-threading was not viable; it throws when unmounted rather than falling back to English. A new permanent gate scans components and locale routes for literals in all four attributes, and was proven to fail before being trusted. Also fixed: icon-only sidebar controls with no accessible name at all, /login and /register having no <h1>, and the auth layout having no <main> for the skip link to reach
The public EU AI Act transparency notice rendered raw i18n keys in every locale since it shipped — its <h1> read legal.aiTransparency.title and every <h2> was a dot-path. Every gate was green, and the reason is worth internalising: key-parity compares the catalogues to each other, so a key uniformly absent from all of them is perfectly consistent; axe accepts a dot-path as a valid heading; tsc accepts it because the translator returns the key on a miss; and no test visited the route. 40 keys of real French and English compliance copy now describe the mechanisms the codebase actually implements, verified at runtime with zero raw-key hits
EU AI Act Article 50 compliance. Article 50 became applicable on 2 August 2026 and was explicitly excluded from the Digital Omnibus deferral, and the boilerplate shipped a chat assistant with neither an AI-interaction disclosure nor output marking. New config/ai-act.ts is the single source of truth for risk tier, the eleven Annex III categories and per-provider disclosures, with any declared Annex III use case escalating the tier regardless of configuration. Art. 50(1) disclosure renders at two placements per chat surface (empty state and composer, so it survives scrolling); Art. 50(2) marking is written durably to chat_messages.ai_generated and ai_provider unconditionally — a message is AI-generated whether or not the operator currently surfaces the marking — plus X-AI-* response headers carrying model and provider only. New public /ai-transparency notice, a Terms section on AI features, and five operator documents: AI Act guide, Article 30 ROPA, Article 35 DPIA scaffold, breach runbook, and subprocessor/DPA register
GDPR gaps closed alongside the AI Act work. The Article 15 export had zero coverage of the referral and affiliate programs — referral codes, referrals, affiliate applications, affiliates, attributions and conversions all hold the subject's personal data and none of it was exportable; seven sections added, 28 total, with a separate affiliate-side pass because two of those tables key on affiliate_id rather than account_id. Article 7(1) consent was not demonstrable either: user_consents is upserted, so every change overwrote the previous decision and only current state was provable. A new append-only user_consent_events table now carries the trail, with own-row SELECT and no INSERT/UPDATE/DELETE policies (an audit log the subject can write to is not evidence), seeded from current state, and both tables gained policy_version — consent against a superseded notice is not consent to the current one
Admin alert emails delivered raw i18n keys — job-failure notices and LLM credit alerts arrived as billingNotif.undefined.subject with a body of dot-paths, and the actual alert was discarded before sending. sendAdminNotification() queued a pre-rendered payload under email_type = 'notification', but that type means "billing payload carrying a kind discriminator" to the worker, which fed it to the billing template; the translator returns the key verbatim when it cannot resolve one, so the keys were mailed as content. New email_type = 'admin' with a dedicated worker branch that forwards the pre-rendered body untouched, a migration that reclassifies already-queued rows, and a runtime guard that fails a mismatched payload loudly instead of quietly delivering placeholder copy. One payload shape per email_type is now a stated invariant. Alerts sent while the bug was live are recoverable from pending_emails.payload
CMS per-locale publication leaked across languages: the publication check was built on the display fallback chain, so a row with no en-US key inherited fr-FR: true — the page went live in English serving French content, was emitted in the sitemap, and was advertised as an English hreflang alternate. Publication and the SEO directives (canonical, noindex, nofollow) now resolve through accessors that stop at same-language siblings and never fall back to the default locale, while display fields keep the permissive chain. hreflang advertises only live translations, the sitemap emits hreflang and no longer contradicts the pages it lists, and it revalidates hourly instead of being frozen at build time — a DB read is not a dynamic API to Next, so pages published after a deploy never appeared. Reserved slugs unified into one list, and the language switcher now shows unavailable locales muted and routes them to that locale's home or blog index instead of a 404
Next.js 16.2.9 → 16.3.0 with the bundle analyzer and ESLint config moved in lockstep, closing five advisories: SSRF in rewrites via an attacker-controlled destination hostname, unauthenticated disclosure of internal Server Function endpoints, cache confusion of response bodies on invalid UTF-8 request bodies, unbounded Server Action payloads in the Edge runtime, and Image-Optimization DoS via SVG. npm audit went from 12 vulnerabilities to 0 across the full tree, --omit=dev and the audit gate, with sharp, undici, postcss and others patched in range. A scoped override keeps @lhci/cli current instead of accepting npm's suggested --force fix, which downgrades it from 0.15.1 to 0.6.1; isomorphic-dompurify is deliberately held at 2.x and documented as pinned-on-purpose
a11y and responsive coverage extended from 5 routes to 12 (30 → 68 tests), scanning /register, /blog, /privacy, /legal, /changelog, /ai-transparency and /privacy-choices in light and dark and at 320/768/1280. The "30/30 green" gate reported across ten phases of design-system work had only ever measured five marketing routes, which is how the AI Act key defect, a contrast failure and a whole class of unnamed sidebar controls all shipped through it. Three defects surfaced and fixed: a skip link pointing at a target that existed on no auth route, a hardcoded € in the analytics cost formatter shown to en-US admins beside $ figures on the same page, and a UTC off-by-one labelling chart bars with the previous day in negative-offset timezones. Opt-in authenticated Playwright coverage was also added — the fixture mints a token_hash through the admin API and drives the browser to the app's own callback so the real verifyOtp runs, and stays inert unless E2E_TEST_EMAIL is set
vitest had been running a vendored dependency's test suite: the exclude list used bare 'node_modules', which as a glob matches only the top-level directory, so the runner walked into a nested workspace's node_modules and ran the zod library's own tests as if they were this project's. The reported 173 files / 2,006 tests were in fact 14 files / 260 tests, and the three "pre-existing failing test files" waved off in several past entries were zod's, failing on an unmet peer dependency. No project test was lost; the number simply now measures the project. Dead components were removed in the same pass — the entire components/dashboard/ directory and an unused chat-interface copy, all verified at zero importers including dynamic-import forms
Refreshed every vendored AI design skill against upstream. Impeccable v4.0.4 folded ~20 standalone skills into subcommands (45 skills → 26), so /typeset, /shape, /critique and /polish no longer resolve standalone, /arrange became /impeccable layout, and /normalize is retired with no successor. v4 also ships ~40 executable scripts and declares Bash allowed-tools including a hook-configuration writer, so the design-skills rule now documents that execution surface with a standing instruction to re-review it on every version bump. Added the official /shadcn skill alongside tailwind-v4-shadcn (they do not overlap — one covers components, CLI and registry, the other Tailwind v4 setup and its failure modes), updated ui-ux-pro-max to v2.11.0 (84 styles, 192 palettes, 74 font pairings, 22 stacks), and added Saaspo as the shared SaaS design-research reference. emil-design-eng was deliberately not updated: its entire upstream delta removes Radix guidance in favour of Base UI, and this stack is shadcn/ui on Radix
Adds inline loading feedback to OAuth sign-in, fixes the license checkout flow end-to-end (user_id now reaches the Stripe webhook so purchases are no longer silently dropped), makes the my-account billing card billing-model-aware, pins the Next.js release-train packages, and lands the full code-review v2 remediation across security, architecture and performance.
OAuth sign-in buttons on the login page now show an inline spinner and a localized "Connecting to {provider}…" label, and disable all providers while one is mid-redirect (components/auth/oauth-buttons.tsx). signInWithOAuth makes a network round-trip to resolve the provider authorization URL before the browser navigates away, so the buttons previously gave no feedback during that gap and looked unresponsive. Added aria-busy on the active button and a guard against double-clicks / racing two providers; on error the state resets so the user can retry, on success the spinner stays up since the page is already navigating
License checkout sessions now carry user_id in their Stripe metadata (createLicenseCheckoutSession in core/billing/checkout.ts). The checkout.session.completed handler guards on missing account_id/user_id, so every license purchase was silently dropped at the webhook ("Checkout session missing required metadata") — the license was never created and credits never granted. This affected both the dashboard LicensePricingTable and the landing-page flow. user_id now also populates licenses.purchased_by and the payments audit row (both previously null)
Landing-page license purchases now complete end-to-end. checkout-client.tsx gained a type: 'license' branch that POSTs to /api/billing/license-checkout (previously licenses fell through to the !priceId guard and threw "Payment configuration not available for this plan" — licenses carry a productId, not a Stripe priceId)
landing-licenses.tsx now sets the bsk_pending_checkout httpOnly cookie (parity with the subscription flow, so cross-tab magic-link logins keep the selection) and uses an allowlisted locale-less redirectTo=/checkout. The prior locale-prefixed, query-bearing redirectTo was rejected by sanitizeRedirectTo and bounced logged-out buyers to /private-dashboard before they reached checkout
The /my-account billing card is now billing-model-aware: in license mode it shows "License" / "Manage your license and access" / "No active license" copy instead of the subscription-centric strings, and the license-access badge renders the localized product name (from pricingConfig.licenses) instead of the raw product id. The subscription and hybrid modes keep the subscription wording
Pinned the Next.js release-train packages to ^16.2.9 (from ^16.2.6): next, eslint-config-next, and @next/bundle-analyzer. next already resolved to 16.2.9 (latest 16.2.x), so this only raises the declared floor so a fresh install can't drop to 16.2.6–16.2.8; @next/bundle-analyzer was the one real upgrade. All three bumped in lockstep to avoid ESLint-config / analyzer skew against the framework. Does not clear the build-time-only postcss advisory (GHSA-qx2v-qp2m-jg93), which only a future Next release can resolve
CRITICAL — fixed the stale admin_logs INSERT policy in supabase/schema.sql's bottom migrations-mirror block (ARCH-1): the block re-created the pre-SEC-M4 is_admin WITH CHECK policy, so any fresh supabase db reset (dev, CI, downstream forks) silently re-opened the audit-log forgery hole patched by 20260610090002. The mirror now re-creates the service-role-only policy. Incrementally-migrated environments were never affected
OAuth account-merge is now atomic (SEC-4): a new merge_oauth_duplicate_user SECURITY DEFINER RPC (service-role-only EXECUTE + auth.role() guard) deletes the duplicate user's personal account/profile and backfills OAuth display data in one transaction. auth.admin.deleteUser runs only after commit; if it fails, the flow still completes as "account linked" (orphan auth row logged) instead of re-creating rows for the merged-away user
CSRF cookie sameSite relaxed from 'strict' to 'lax' at all three set sites (SEC-3) — 'strict' dropped the cookie on cross-site top-level navigations (email links), desyncing the double-submit pair into spurious 403s. The token is non-secret; Origin/Referer validation and the double-submit comparison are unchanged. Added an architecture-invariants test (SEC-5) pinning the load-bearing UNIQUE(referred_account_id) constraints and the self-referral CHECK so a refactor can't silently drop the anti-farming guarantee
Consolidated the licensing domain (ARCH-2/3): the unified access check's batch license read and the admin licenses list now go through new core/licenses/queries.ts readers; deleted 9 dead license functions plus unused types. Removed the unwired core/ export cluster across accounts/affiliates/roles/chat (ARCH-4); live chat readers dropped their misleading Uncached suffix. Role checks consolidated onto hasRole() and the post-auth redirect swap unified behind getPostEventRedirect() (ARCH-5/6); invitation expiry now derives from workspaceConfig and resendInvitation() moved to core with the Zod validation it previously lacked (ARCH-7/8/11)
Rewrote the generate-analytics job (PERF-1/2): the daily AI usage summary now calls the get_admin_ai_period_stats aggregate RPC instead of downloading every ai_requests row of the day and reducing in JS, and its three independent reads run in Promise.all. check-low-credits-alerts moved its balance filter into the DB at the widest configured threshold (PERF-3); cleanup-push-subscriptions runs its three independent deletes concurrently (PERF-4)
v1.31Patch
Full-codebase review remediation — security, architecture, performance, and database
A broad remediation pass from a full-codebase review: recent-auth gates and Zod validation on destructive admin routes, service-role-only audit-log writes, an extended SSRF blocklist and SVG removal from the public bucket, a restored dual-column admin RLS check, redundant-index cleanup and new hot-path composites, ~100k-row admin-analytics aggregations moved into SQL RPCs, duplicate-auth elimination on chat/referral routes, and a sweep of architecture and i18n fixes.
Added Zod validation (discriminated union, UUID licenseId, bounded days) and the requireRecentAuth() gate to the admin license revoke/extend route — revoking a license claws back credits and paid access, so a stale hijacked admin session can no longer do it in a single POST. Added requireRecentAuth() to the referral reversal and referral-code deactivation routes too, closing the drift with the structurally identical affiliate conversion-reverse route
Restricted admin_logs INSERT to service_role (20260610090002): the old is_admin WITH CHECK let any admin forge audit rows attributing actions to another admin via the Data API. All legitimate writes already went through supabaseAdmin; SELECT stays admin-readable
Stopped leaking raw Postgres/RPC error text from the four admin cron routes — clients now get a generic error plus a code while the full message stays in error_logs. Newsletter subscribe/unsubscribe no longer return the email provider's raw error string on 500, and the unsubscribe provider-failure path is now logged
Extended the SSRF private-IP blocklist (lib/security/ssrf.ts) with the CGNAT shared range 100.64.0.0/10 (RFC 6598), 192.0.0.0/24 (RFC 6890), and 198.18.0.0/15 (RFC 2544) — admin-configured webhook URLs can no longer reach those ranges
Removed image/svg+xml from the public media bucket (20260610090006) and the app-side allowlist, deleting the bypassable regex-based SVG content filter with it. SVGs can carry scripts and the bucket is public; use PNG/WebP or serve trusted SVGs from /public
Fixed user_is_account_admin dropping the role_slug branch (20260610090000): the May 29 search_path-pinning migration re-created the function with only the legacy role check, silently denying member management to custom-role admins whose membership only sets role_slug. Restored the dual-column check everywhere, including the later CREATE OR REPLACE that wins on a fresh db reset
Dropped redundant/overlapping indexes (20260610090005, 20260610090008) to cut write amplification, and added composite hot-path indexes via standalone CONCURRENTLY migrations: api_keys(account_id, created_at DESC), documents(account_id, created_at DESC), and referrals(ip_hash, created_at DESC) backing the apply_referral_code IP rate-limit count
Replaced the admin analytics JS aggregation (~100,000 ai_requests rows per /admin-dashboard/analytics render) with 8 SQL aggregate RPCs (20260610090001): get_admin_ai_period_stats, get_admin_active_user_count, get_admin_credit_flow, get_admin_daily_stats, get_admin_top_users, get_admin_model_stats, get_admin_agent_stats, get_admin_org_stats — all SECURITY DEFINER, service-role-only EXECUTE, auth.role() body-guarded
Killed the double auth.getUser() + duplicate memberships fetch on 8 chat-session and referral routes: new checkPermissionForUser(userId, accountId, permission) reuses the user apiSecurity.* already authenticated — ~60–120 ms saved per request
Bounded the unbounded list queries (listDocumentsForAccount capped at 200 with metadata dropped from the list projection, api_keys capped at 100), parallelized the admin dashboard layout's independent awaits, and moved isomorphic-dompurify (~150 KB) out of the CMS page form's client chunk into a dynamic ssr:false preview component. Raised the Lighthouse CI performance floor from 0.7 to 0.85
Replaced 7 inline dual-column owner checks with the shared hasRole() helper (my-account, admin layout, subscriptions/organizations/licenses/org-detail pages). This surfaced and fixed two latent bugs: the licenses page checked role_slug only and the org-detail page checked role only — both queries now select both columns
Extracted the inline service-role orchestration from the three heaviest admin pages into core modules: users → core/organizations/admin-users.ts, organizations → core/organizations/admin-organizations.ts, subscriptions → core/billing/admin-subscriptions.ts. Behavior-preserving; pages are now thin Server Component shells
Fixed locale-less number/date formatting on the affiliates dashboard KPIs + conversions table, ReferralStatsRow, and landing-licenses.tsx (now via getIntlLocale(locale)). Wired the previously-unwired createInAppNotification producer: the check-low-credits-alerts job now creates a bell notification for the account owner alongside the push
Updated the canonical AI rules and user-facing docs to match the new code reality (recent-auth action list, analytics RPC family, SVG ban, checkPermissionForUser() guidance, Lighthouse 70% → 85% target) and synced the .codex / .gemini / .opencode rule mirrors
Closes an in-tenant privilege-escalation path on memberships (a non-owner admin could mint an owner via direct Data API insert), scopes the GDPR data export's api_keys section to owners/admins, and adds an edge-safe auth wrapper so missing or expired Supabase sessions are treated as anonymous instead of throwing. Plus an init-script fix so npm run init no longer drops the CDN config block.
Closed an in-tenant privilege-escalation path on memberships: the owner-mint guard (lock_membership_sensitive_columns) was bound BEFORE UPDATE only, while the 'Admins can insert memberships' RLS policy checks account-admin without constraining the inserted role — so a non-owner workspace admin could POST /rest/v1/memberships with role='owner' for a colluding user and escalate them to owner (billing + deletion powers). The trigger is now BEFORE INSERT OR UPDATE and rejects owner inserts/updates from Data API callers
Hotfixed the guard's role scope: the first cut used auth.role() is distinct from 'service_role', which caught the SECURITY DEFINER signup trigger handle_new_user_account() (it runs on the GoTrue auth-admin connection where auth.role() is NULL) and aborted signup with 'Database error saving new user'. Re-scoped to auth.role() in ('authenticated','anon') so the escalation is still blocked for direct PostgREST writes while internal triggers and service_role bootstrap are exempt
Scoped the GDPR data export's api_keys section to accounts where the requester is owner/admin (via getOrgManagerRoles() + hasRole()), matching the management-UI gate (requireApiKeyManager) so a plain member can no longer export key names/prefixes/scopes of keys they cannot otherwise see
Added lib/auth/safe-get-user.ts (safeGetUser) — an edge-safe wrapper that treats a missing/expired/invalid Supabase session as anonymous instead of letting @supabase/ssr throw AuthApiError: Invalid Refresh Token (which surfaced as an unhandled 500 / log noise). Wired into every server-side auth.getUser() site (get-user.ts, proxy.ts, api-security.ts, permissions/check.ts, verify-admin.ts, stripe/actions.ts, ai/stream). Fails closed — protected routes still redirect, authenticated routes still return 401
Fixed scripts/init-project.js regenerating config/app.ts without the cdn block and the isPublicCacheablePath() / PUBLIC_CACHEABLE_PATHS exports. The CDN feature (1.29) added these to the committed config, the wizard prompt, and .env.example, but the generateAppConfig() template was never updated — so running npm run init overwrote config/app.ts minus 57 lines, breaking proxy.ts
Mirrored the cdn block, PUBLIC_CACHEABLE_PATHS, and isPublicCacheablePath() into the generateAppConfig() template so future inits emit a complete config, and added anti-pattern A15: any field/helper added to a config file that init-project.js regenerates MUST be mirrored into the matching generate*() template in the same change — treated as one edit unit, like the migration↔schema.sql mirror (A1)
v1.29Minor
Security, jobs, and database performance hardening, dynamic-slug rendering, optional CDN caching, and SEO/localization
A broad hardening pass across security, background jobs and database performance: tighter membership and account-deletion policies, centralized webhook SSRF validation, service-role-only Stripe sync, aggregate RPCs for dashboard counters, and pinned SECURITY DEFINER search paths. Plus a production blog/CMS dynamic-slug rendering fix, an opt-in CDN edge-caching layer, and SEO/localization improvements (server-rendered html lang, normalized site URLs, better locale negotiation).
Hardened memberships updates with WITH CHECK plus a trigger that blocks non-service-role account/user reassignment and owner self-escalation through either role or role_slug
Refactored /api/admin/jobs/** into thin routes backed by core/jobs/*, centralized webhook SSRF validation in lib/security/ssrf.ts, and documented the shared create-time/runtime DNS checks
Reworked sync-stripe to batch existing subscription state updates through a service-role-only RPC, while keeping missing-row creation inside the billing domain flow
Added aggregate RPC usage for admin/org AI statistics and documented the no-raw-row-download pattern for dashboard counters
Memoized CMS unstable_cache() wrappers per canonical key and documented the pattern for future dynamic-key caches
Tightened GDPR export failures so internal section messages are logged server-side and the client receives a generic EXPORT_FAILED response
Closed an in-tenant privilege-escalation path on account_deletion_requests: the owner-cancel UPDATE policy now carries a WITH CHECK, and a BEFORE UPDATE column-lock trigger restricts non-service-role owners to a status → 'cancelled' transition so a member or owner can no longer flip cascade_members (or any other field) via the Data API to force member-account deletion
Pinned SET search_path = public on the SECURITY DEFINER membership helpers (user_belongs_to_account, user_is_account_admin, user_has_permission) and the lock_membership_sensitive_columns / lock_notification_columns triggers
Added a matching WITH CHECK to the storage.objects 'Admins can update media' policy so an admin cannot move a media object out of the media bucket
Narrowed the pending_emails.email_type CHECK to the worker-supported set (contact, newsletter, notification) so unsupported types fail loudly at insert instead of being silently permanent-failed
Consolidated the admin jobs-URL SSRF validation in core/admin/settings.ts onto the canonical validatePublicWebhookUrl resolver (removed a drifting regex/DNS copy)
Replaced the admin overview revenue JS-reduce with a get_admin_revenue_sum aggregate RPC and wrapped getPlatformStats in unstable_cache (60s, per-locale)
Dropped redundant single-column indexes covered by composites (credit_tx_account_idx, subscriptions_account_idx) and added composites for the revenue (payments(status, created_at desc)) and job-run history (job_runs(job_id, started_at desc)) hot paths
Moved the documents/[id] detail route to a thin dispatch over a new core/documents reader, memoized the per-post blog cache wrapper, dropped a redundant getUser() round-trip on onboarding (now via SecurityContext.user_metadata), parallelized the email-provider per-list loops, removed ~20 stale as any casts on now-typed tables, and gave in-app notification helpers a core/notifications/ home
Fixed a production DYNAMIC_SERVER_USAGE 500 on blog posts / CMS pages published after the last build: blog/[slug] and [slug] are now force-dynamic (data still cached via unstable_cache) so on-demand ISR no longer conflicts with the root layout's per-request CSP-nonce headers() read
Added an opt-in CDN edge-caching layer (CDN_PUBLIC_CACHE_ENABLED, CDN_PUBLIC_S_MAXAGE, CDN_PUBLIC_SWR): when enabled, the middleware advertises a cacheable Cache-Control on anonymous GET requests to public content pages only (no auth cookie, no Set-Cookie), keeping the strict per-request CSP nonce intact while authenticated/dashboard/API responses stay private, no-store. Off by default; documented in caching.md, .env.example, and the production caching docs
Server-rendered the actual route locale on <html lang> (fr-FR, fr-CH, en-US, en-CA) instead of patching a default language after hydration, improving crawler and accessibility-tool accuracy
Centralized site URL normalization for canonical URLs, hreflang alternates, OpenGraph/JSON-LD, sitemap, and robots metadata so trailing slashes in NEXT_PUBLIC_APP_URL no longer produce duplicate slashes
Improved locale negotiation for unprefixed requests so Accept-Language can redirect to exact configured regional locales such as en-CA
v1.28Patch
Private sidebar credits widget sync, explicit Supabase Data API grants, and refreshed Supabase agent skills
The private dashboard sidebar credits widget now stays in sync with server-authoritative props after server actions like End Trial; a new migration makes Supabase Data API grants explicit for fresh projects and database resets, with documentation refreshed; local Supabase agent skills and AI-tool entrypoints are updated to invoke both the product-wide Supabase skill and the Postgres best-practices skill.
Private dashboard sidebar credits widget now updates immediately after server-side credit changes (notably the End Trial confirmation) without requiring a full page reload
Root cause fix: useState(initialCreditsBalance)'s lazy initializer only ran on mount, so router.refresh() re-rendered the layout with a new server-authoritative prop while the sidebar kept stale local state — added a useEffect that re-syncs creditsBalance whenever the initialCreditsBalance prop changes, preserving the existing 'credits-updated' listener for optimistic chat decrements
Org sidebar already read workspace.creditsBalance directly from props, so no change was needed there
New migration 20260527225001_explicit_data_api_grants.sql makes Supabase Data API access explicit for fresh projects and database resets: default public-schema privileges are revoked and intended anon/authenticated/service_role table grants are re-applied, with the full-schema mirrors carrying the same grant surface
Documented the Data API grant model in the Supabase setup docs, onboarding guide, README deployment notes, and AI database rules so future public tables, views, sequences and RPCs ship with grants/revokes alongside RLS policies
Updated local Supabase agent skills from supabase/agent-skills at 4e69c80: added the general supabase skill and refreshed supabase-postgres-best-practices to the upstream 1.1.1 references/ layout
Updated AI-tool entrypoints and generated skill/rule mirrors so future Supabase work invokes both the product-wide Supabase skill and the Postgres best-practices skill where relevant
v1.27Minor
Dashboard loading states, protected-layout async reads, and hardened job handlers
Route-segment loading-state guidance for platform admin, organization admin and private dashboards keeps immediate skeleton/spinner feedback while Server Components resolve; the protected dashboard async pattern is documented; background job handlers are hardened with safer GDPR deletion ordering and Stripe subscription mirror recovery.
Added route-segment loading-state guidance for platform admin, organization admin and private dashboard pages so future dashboard work keeps immediate skeleton + spinner feedback while Server Component data resolves
Documented the protected dashboard async pattern: start dashboard-context and translation promises together, await context for redirects, then await translations for rendering without moving user/account data into unstable_cache()
Synced generated AI-rule mirrors for the new dashboard loading and async-read guidance
Hardened background job handlers: GDPR deletion now checks Supabase mutation errors, keeps retry identifiers until destructive work succeeds, and deletes account rows last; Stripe sync creates missing subscription mirrors when create webhooks were missed
v1.26Minor
Privacy rights, regional compliance surfaces, and documentation
New customer-facing /[locale]/privacy-choices page and a My Account privacy/data-rights section surface do-not-sell/share, targeted-advertising opt-out, GDPR, Swiss FADP, US state privacy, Canada PIPEDA and CASL rights; cookie consent now honors browser Global Privacy Control signals; canonical security rules, AI-rule mirrors and product docs refreshed.
New /[locale]/privacy-choices page and footer link surfacing do-not-sell/share, targeted-advertising opt-out, GDPR, Swiss FADP, US state privacy, Canada PIPEDA and CASL rights
New My Account privacy/data-rights section with data export entry point, cookie consent management, regional rights summary, privacy policy access, privacy contact and deletion context
Privacy Choices cross-linked from the existing Terms, Privacy Policy and Legal Notice pages so users can move between all legal/privacy surfaces
/privacy-choices added to the sitemap and reserved CMS slug list so the route is discoverable and cannot be shadowed by dynamic CMS pages
Localized legal/footer/cookie/account copy added across en-US, en-CA, fr-FR and fr-CH
Cookie consent now honors browser Global Privacy Control signals — marketing/sharing/targeted-ad consent stays disabled even when 'Accept all' is clicked
Cookie settings UI surfaces the active GPC state and disables marketing consent while that signal is present
Canonical security rules updated to preserve /privacy-choices and Global Privacy Control behavior in future changes
Generated AI-rule mirrors synced for Codex, Gemini and OpenCode
Product docs updated for GDPR/privacy, cookie consent, analytics, My Account, production onboarding, README and the changelog
v1.25Minor
Marketing auth hydration, dashboard request deduplication, and docs
Public marketing pages stay anonymous-first and cacheable while authenticated UI hydrates client-side via /api/auth/me; private and org dashboards now share a request-cached context with aggregate SQL RPCs and composite indexes; canonical caching/auth rules and product docs refreshed.
Public marketing layout (app/[locale]/(frontend)/layout.tsx) no longer reads getUser() / Supabase session data server-side for navbar personalization — stays anonymous-first and cacheable
New GET /api/auth/me endpoint for client-side marketing-shell auth hydration: apiSecurity.public({ rateLimit: 'relaxed' }), server-side getUser(), minimal sanitized auth/profile/workspace payloads, and Cache-Control: private, no-store
New MarketingAuthProvider and MarketingCommandPalette; TransparentNavbar and AccountDropdown now consume hydrated auth context client-side — connected users clicking the anonymous login link are still routed to their dashboard by the existing auth-route guard
New core/accounts/dashboard-context.ts with React.cache() request-level helpers: getPrivateDashboardContext() for /private-dashboard/* and getOrgDashboardContext() for /org-dashboard/*
Private dashboard layout/pages (private-dashboard, chat, documents, referrals, affiliates) and org dashboard layout/pages (overview/admin, analytics, billing, API keys, members, roles, member detail, settings) now reuse shared auth/account/workspace context instead of repeating getUser(), profile, membership, billing-access and subscription reads
Protected dashboards remain dynamic and request-scoped; no user-specific data was moved to unstable_cache()
New dashboard aggregate RPCs for private overview counts, org analytics and org member usage summaries, plus composite indexes for ai_requests, chat_sessions and user_access_logs dashboard hot paths — org analytics/member pages now aggregate in SQL instead of pulling raw usage rows into Server Components
Credit purchase and end-trial client components lazy-loaded in dashboard sidebars/actions so modal/payment UI code is no longer part of the initial dashboard client bundle
Canonical caching rules updated to document marketing auth hydration, /api/auth/me rate-limit / Cache-Control expectations, and dashboard context deduplication
Product docs under content/docs refreshed for caching/performance, authentication, API reference and admin dashboards
v1.24Minor
Route-thinning, CI + Guardrails, Post-1.23 Security & Docs v2
Follow-on to 1.23: API route-thinning pass moves multi-step logic into core/*, a new GitHub Actions CI workflow with guardrail scripts and zero-warning lint gate, TRUST_CLOUDFLARE_IP / recent-auth / RLS-attribution security hardening, the multi-page public/docs/v2/ documentation rebuild, and refreshed AI-tool rules and entrypoints.
New TRUST_CLOUDFLARE_IP env gate so rate limiting no longer trusts spoofable CF-Connecting-IP — the header is only honored when explicitly enabled
Stripe webhook raw-body reads now enforce size while streaming the body instead of buffering the full arrayBuffer() first
/api/jobs/run cleanly separates the bearer-token cron path from the admin-session path with equivalent rate-limit, CSRF and body-size controls
Newsletter unsubscribe now targets only the authenticated user by default — no more anonymous arbitrary-email unsubscribes
Recent-auth + immutable admin_logs extended to subscription cancellation/reactivation, log purges, user credit grants and organization mutations
Org settings, invitation revocation, onboarding profile writes and /my-account profile updates moved behind validated server/API boundaries; account-scoped chat/document RLS tightened to block forged user_id attribution
API route-thinning pass: GDPR export, AI stream orchestration, CMS pages/media/blocks, admin settings/users/organizations/subscriptions and account API keys now delegate multi-step logic to core/gdpr, core/ai, core/cms, core/admin, core/organizations, core/billing and core/accounts — no app/api/**/route.ts remains over 250 lines
High-risk route splits completed: Stripe webhook dispatch in core/billing/stripe-webhook.ts, checkout session creation in core/billing/checkout.ts, job-run auth/execution in core/jobs/run-request.ts and bounded body / security-response helpers in lib/http/request-body.ts — routes are HTTP/security glue only
New .github/workflows/ci.yml runs on pull requests and pushes to main: typecheck, lint:ci, lint:strict, project guardrails, API-pattern coverage, API route size, RLS schema/policy coverage, Supabase type coverage, unit tests, secret scan, production build, accessibility and responsive checks
New/expanded guardrail scripts: project anti-patterns, API security-wrapper coverage, API route size, RLS coverage across schema.sql + cms-schema.sql, Supabase schema/type coverage, staging readiness probe and critical API load smoke test
lint:strict is now a zero-warning CI gate; historical warning visibility lives in npm run lint:debt (4,787 warnings measured during this pass)
New static multi-page documentation under public/docs/v2/ with shared navigation, responsive styling, grouped icon sidebar, per-page subsection menus, reused public/docs/screens/ assets, richer visual callouts and a global search-index.json covering setup, configuration, architecture, Supabase, auth/B2B, Stripe pricing, AI/RAG, operations, content, frontend, security, deployment, API reference and AI-tooling
public/docs/index.html reworked for first-project onboarding: clearer quick-start path, new-project configuration checklist, scenario-based environment guidance, explicit config/pricing.ts / Stripe Price ID setup, B2B checkout-before-onboarding and workspace auto-bootstrap notes, updated AI Blueprint skill counts, and removed stale local asset references
Canonical AI rules and /docs now document the admin-dashboard / billing maintainability split (core/admin/*, focused core/billing/* modules, affiliate/referral reversal hooks in risk-events.ts), the TRUST_CLOUDFLARE_IP env contract (also added to init-generated .env.local), authenticated unsubscribe behavior, the hardened jobs API, recent-auth admin requirements, stricter RLS/profile-write boundaries, the GDPR/US/Canada/Switzerland privacy expansion (fr-CH locale, CHF pricing, Swiss legal copy, CMS legal seeds), and the zero-warning lint gate / lint:debt / RLS coverage / staging probe / load smoke env contracts — generated AI-rule mirrors refreshed via scripts/sync-ai-rules.sh
Added .impeccable.md as the project design-context handoff; new CI/guardrail scripts documented across AI-tool entrypoints, canonical rules, README.md and product testing docs
v1.23Minor
Consolidated Audit Remediation, Billing, Docs, Email & AI Skills
Operators: apply 4 new migrations to the live DB — planfix DB hardening, subscriptions index (outside-tx), deletion processor status, and CMS locale key normalization (fr/en → fr-FR/en-US); mirrored in supabase/schema.sql and supabase/cms-schema.sql
Analytics + live-chat are now privacy-by-default: GTM, gtag/Google Ads, Meta Pixel, X Pixel and Crisp only load/fire after the matching analytics/marketing consent; purchase/signup events guarded at the application layer
Logged-in cookie consent now goes through POST /api/user/consents with apiSecurity.authenticated() + CSRF + Zod + rate limiting; browser components no longer upsert user_consents directly
CMS WYSIWYG image uploads now send the double-submit CSRF token and use media-appropriate rate limits; RAG document uploads keep auth + CSRF + body-size enforcement on the interactive standard limit
planFix.md remediation: 63/64 findings fixed — fail-closed auth.role() guard on referral/affiliate SECURITY DEFINER RPCs, API-key hash reads revoked from anon/authenticated roles, app_settings UPDATE RLS gained matching WITH CHECK
Admin verification + escalation tightened: 60s recent-auth cookie, per-email OTP rate-limit bucket, is_disabled rejection in verify-OTP and magic-link callback, recent-auth gates for admin_email and toggle_admin, audit logs for destructive actions
GDPR data export expanded — profile, memberships, chat sessions/messages, AI requests, consents, payments, subscriptions, licenses, invitations, access logs, deletion requests, notification prefs, pending emails, document metadata and API-key metadata (no secrets or hashes)
process-account-deletions records processor status for Stripe, newsletter/email, Supabase Storage, DB cascade and Supabase Auth; failures redacted, increment retry_count, mark the request failed and block false completion; Storage document/avatar cleanup runs before row deletion
New legal env contract: LEGAL_COMPANY_NAME, LEGAL_ADDRESS, LEGAL_REGISTRATION_NUMBER — npm run init, .env.example, config/app.ts and /docs all wired up; example placeholders intentionally block public/indexable production builds
npm run init now ends with a Pricing & Stripe guide — config/pricing.ts as the source of truth, price-ID mapping, billing model selection, webhook setup and payment methods
Ending a Stripe trial early synchronizes the local subscription mirror before responding (org-dashboard removes trial banners immediately); trial conversion credits subtract the configured trial-period grant from the plan allotment instead of adding the full plan amount on top
License-expiration warning now uses the documented two-step owner lookup instead of the invalid profiles!owner_user_id embed — fixes the live zero-email regression
Contact/newsletter attack-pattern detection wired; unwired CSRF helper code removed; CSRF Origin/Referer checks use appConfig.url; HTML iframe allowlisting now happens inside DOMPurify
Pricing tables now render included credits, project limits and trial credits from config/pricing.ts / trial env values instead of duplicated static feature value translations
CMS/page/blog/changelog locale lookup falls back from configured locales to legacy language-only keys; admin writes normalize payloads back to BCP-47 keys; seeds, sitemap and npm run init now use configured locales instead of fr / en
AI-tool docs synced across .claude/.codex/.gemini/.opencode/.agents — 22 files per mirror; coding-agent entrypoints now include a task-routing matrix, reviewer/skill selection and change-type verification pointers; scripts/sync-ai-rules.sh regenerates .agents/skills/ from .claude/skills/
Thin-route + core/ extractions cover AI streaming/accounting, changelog, chat mutations, billing subscribe-free, admin orgs/users and CMS validation; dead core/usage/ and core/memberships/ removed
CURRENCY_CODES is now the canonical currency tuple (Currency and SUPPORTED_CURRENCIES derive from it); agent model ids, referral salt, workspace signup bonus, RAG threshold, low-credit alert, locale/currency fallbacks and org-manager roles now use config helpers
Transactional email builders share lib/email/layout.ts: monochrome shell, table-based layout, dark-mode CSS, bulletproof CTA fallback and escaped text helpers; per-template gradients removed; covered by __tests__/email/templates-render.test.ts
/api/ai/stream body-size enforcement reads the cloned body as a stream instead of buffering the full arrayBuffer() first
RAG processing, Stripe sync, GDPR deletion processors, export reads, role reorders, affiliate revalidation and log purges parallelized or batched; select('*') replaced by explicit projections across the flagged hot paths
Next.js upgraded to 16.2.6; npm run audit:bundle now runs next build --webpack because @next/bundle-analyzer does not emit reports under Turbopack (reports in .next/analyze/)
/ui-ux-pro-max refreshed to v2.5.0: new data files (app-interface.csv, google-fonts.csv, design.csv, draft.csv), rewritten SKILL.md, web-stack lookup consolidated via --domain ux / --domain style; --stack nextjs / vue / shadcn deprecated
v1.22Minor
IPv6 Rate-Limit Fix & X Pixel
IPv6 rate-limit keys collapsed to the /64 prefix (bypass closed), a consent-gated X (Twitter) Pixel, and a /docs correctness + responsiveness pass.
IPv6 rate-limit bypass closed — keys collapsed to the /64 network prefix (RFC 4291) at a single chokepoint in createRateLimiter(); IPv4 unaffected
normalizeRateLimitIdentifier() never throws and leaves IPv4-mapped forms intact; getClientIP() still returns the full /128 for contact and IP-hash paths
New unit test __tests__/security/rate-limit-ip.test.ts: ::-expansion, prefix-collapse, IPv4 pass-through and malformed input
X (Twitter) Pixel via components/analytics/x-pixel.tsx — rendered only when consent.marketing is granted, same gate as the Meta Pixel
xEvents.purchase / xEvents.signUp fire from the existing trackPurchase / trackSignUp paths; no-op when conversion-event env vars are unset
Three optional NEXT_PUBLIC_X_PIXEL_* env vars, Zod-validated; static.ads-twitter.com added to the CSP allowlist
Standalone /docs page: removed two stale version sections, added a Changelog link to the single source of truth
Docs mobile table clipping fixed (13 tables wrapped), anchor scroll-offset, image lazy-loading, de-inlined a repeated pre style
v1.21Patch
Stripe Checkout Payment Methods
PayPal explicitly enabled for one-time payments via a single config source; subscriptions stay card-only (Stripe geo-restriction).
PayPal now appears on Stripe Checkout for one-time payments — payment_method_types explicitly requested instead of relying on Stripe auto-selection
New pricingConfig.checkoutPaymentMethods in config/pricing.ts, keyed per Stripe mode (subscription: card, payment: card + paypal)
CheckoutPaymentMethod literal union in lib/pricing/types.ts — a typo fails npm run build before deploy
Subscriptions stay card-only by default: Stripe restricts PayPal subscriptions to EEA/UK/CH merchants
Card-based wallets (Apple Pay / Google Pay) unaffected — they ride on the card entry
npm run init reminds operators to activate the methods in Stripe Dashboard for both test and live
Docs updated: .claude/rules/billing.md and mirrors, domain-map config-source registry
v1.20Minor
Magic-Link UX Overhaul
6-digit code fallback, cross-tab auto-redirect, resend cooldown and copy refresh to cut magic-link drop-off.
6-digit OTP code surfaced in the magic-link email alongside the link; length configurable via OTP_LENGTH env (default 6, clamped 6-10)
New endpoint POST /api/auth/verify-otp — Zod-validated, apiSecurity.public() + strict rate limit, generic error (no enumeration)
Success screen shows the destination email, numeric OTP input (autoComplete one-time-code) and a Wrong email? back button
Cross-tab auto-redirect: success screen polls getUser() every 4s (paused when tab hidden); clicking the link in another tab redirects the original
Resend cooldown (30s countdown) and spam-folder reminder
Copy refresh: Continue with email, subtitle mentions link + code, email subject Your sign-in code
9 new auth.login.* and 3 email.magicLink.* keys in both locales (parity test green)
Init script prompts for OTP_LENGTH (validated); operator caveat: it must match the Supabase project Email OTP length setting
v1.19Minor
Prelaunch Sign-In Gate & Dynamic OAuth Catalog
Server-side admin allowlist gating sign-in during prelaunch, plus a configurable multi-provider OAuth button catalog.
Prelaunch sign-in now gated server-side by PRELAUNCH_ALLOWED_EMAILS (server-only, comma-separated, case-insensitive; empty = locked)
Three enforcement points: magic-link 403 before generateLink, callback sign-out, OAuth sign-out + admin.deleteUser cleanup
Generic restriction message — no email-enumeration vector
Dynamic OAuth catalog: NEXT_PUBLIC_OAUTH_PROVIDERS renders one branded button per id, validated against all 19 Supabase-supported providers
New lib/auth/oauth-providers.tsx (inlined brand SVGs, no new dependency) + components/auth/oauth-buttons.tsx
login-form.tsx: hardcoded Google button removed; divider only shows when at least one provider is configured
i18n: auth.login.continueWithProvider with {provider} placeholder + auth.login.prelaunchRestricted, both locales (parity green)
Init script hydrates both env vars on re-runs; public/docs/index.html OAuth + prelaunch sections rewritten
v1.18Minor
Affiliation Program & Provider-Agnostic Email
Account-centric partner program with cash commissions (Stripe Connect deferred) and a pluggable email abstraction supporting Brevo, Mailjet and noop.
AFFILIATES_ENABLED feature gate returns 404 everywhere when off — surface invisible until you flip it
Provider-agnostic email abstraction (EmailProvider interface + factory + adapters); Brevo, Mailjet and noop ship in the box
EMAIL_PROVIDER env var with auto-fallback to noop when credentials missing (dev/CI friendly)
Newsletter forms (waitlist, landing-newsletter, newsletter-form) now actually call /api/newsletter/subscribe with Turnstile token — pre-existing 400 bug fixed
v1.17Patch
pg_cron Headers Fix & Cron Admin Dashboard
Critical pg_net positional-args fix (secret leak + 401 storm) plus a new admin surface for monitoring and managing cron.job entries.
CRITICAL: pg_net positional-args fix — JOBS_SECRET_KEY was being sent as URL query string, leaking into access logs every cron tick. Rotate the secret after upgrading.
Cron jobs were silently 401'ing — net.http_post now called with named arguments, robust across pg_net versions
Cron Admin Dashboard at /admin-dashboard/jobs/cron — list, toggle, unschedule, purge-orphans
Bearer tokens in cron.job.command redacted at the SQL layer — never cross the API/UI boundary
Recent-auth gate on destructive cron actions (unschedule, purge-orphans); audit trail on every mutation