Changelog

What shipped, when. One PR per milestone.

  • M67

    Enterprise pack — CAPTCHA, passkeys, per-org 2FA, verified-domain auto-join, SSO

    featureJul 7, 2026

    M67 adds the five sign-in-security features enterprise buyers ask for first. Every one is opt-in and inert until configured — the kit ships and deploys cold with no new required env, and each feature stays dark until an operator sets an env or an org admin turns it on. Nothing here changes behavior for an existing install until you enable it. Two migrations (0075, 0076) carry the schema; no new cron or background job was added (the worker inventory is unchanged at 22 crons + 10 jobs).

    • Bot protection (Cloudflare Turnstile) — set TURNSTILE_SECRET_KEY + NEXT_PUBLIC_TURNSTILE_SITE_KEY and the BetterAuth captcha plugin gates /sign-up/email, /sign-in/email, and /request-password-reset (fail-closed once the secret is set). With either env unset there is no widget and no verification — the auth forms behave exactly as before. The secret is server-only; the site key is public.

    • Passkeys / WebAuthn — phishing-resistant, per-user, web-only sign-in. Gated by SAASKIT_PASSKEYS_ENABLED (loads only when exactly "1"; the kit ships it enabled). Users manage credentials under Account → Passkeys; the login page offers a passkey affordance. rpID/origin derive from BETTER_AUTH_URL. Desktop/Tauri passkeys are deferred (the webview origin differs) — follow-up #295.

    • Per-org two-factor enforcement — an org admin can require every member to have 2FA from Settings → Security. BetterAuth has no built-in per-org policy, so this is a custom gate stored on organization_security_policy.require_two_factor: a member without 2FA is redirected to /account/two-factor by the org shell (reads) and hard- blocked at the server-action mutation chokepoint (writes). An org that never sets the toggle is unenforced.

    • Verified-domain auto-join — an org proves it owns an email domain via a DNS-TXT record (<prefix>=<token>, prefix from SAASKIT_DOMAIN_VERIFICATION_TXT_PREFIX), and thereafter a new user who verifies an email on that domain auto-joins the org (subject to the org's member quota). Claiming, verifying, and the auto-join are all audited. Until a domain is claimed and verified, nothing auto-joins.

    • Single sign-on (SAML + OIDC), with optional require-SSO — gated by SAASKIT_SSO_ENABLED (ON by default, =0 removes the plugin and every /api/auth/sso/* endpoint). It is inert until an org admin registers an IdP for a domain it has verified (the DNS-TXT proof above is the prerequisite). OIDC discovers endpoints from the issuer's well-known document; SAML uses the plugin's pure-JS schema validator (no native/Java toolchain). Turning on require-SSO blocks every non-SSO session vector (password, magic-link, social, passkey) for that domain — a self- identifying operator impersonation session is still allowed. Note the documented residual: require-SSO gates session creation, so an already-signed-in user keeps their existing session until it expires. Removing the last provider while require-SSO is on is a known self-lock — follow-up #296.

    Operators get a read-only org security panel at /admin/orgs/<orgId> showing each org's 2FA/require-SSO posture, verified domain, member-2FA coverage, and registered providers (protocol + issuer + domain only — provider secrets and certs never leave the server). See the admin runbook's Enterprise pack section for the enable-and-register walkthroughs.

  • M62

    Revenue mechanics — free trials, dunning reminders, cancellation survey, coupons

    featureJul 4, 2026

    M62 adds four revenue-lifecycle mechanics, each behind its own flag so an operator opts in per deployment. None of them move money on their own — trials are zero-revenue, dunning only sends reminders, and coupon math is applied at checkout under the existing renewal and grace machinery.

    • Card-less free trials (SAASKIT_TRIAL_DAYS) — an org can start ONE free trial that grants a chosen plan's entitlements and credits without collecting a card. The trial is a provider-less subscription that expires through the same renewal cron every paid plan rides, so there is no separate expiry path to trust. A trial banner shows days remaining, and a reminder email goes out two days before it ends. One trial per org, ever. (These are kit-level trials; native card-collected trials on the Polar hosted checkout are a follow-up.)

    • Multi-step dunning reminders (SAASKIT_DUNNING_REMINDER_DAYS) — when a recurring payment goes past-due, the customer gets a chain of reminder emails (default at +3 and +7 days; the last one carries final-warning copy) plus an in-app past-due banner with a pay CTA. If the payment recovers, the remaining reminders cancel themselves automatically. Set the value to empty to turn reminders off entirely.

    • Cancellation survey — cancelling a subscription now shows a short retention step and then asks for a reason (and an optional comment). The response is saved and visible to operators, so churn reasons are captured instead of lost. For recurring Polar subscriptions the cancellation is passed through to Polar; if that call fails, the customer is handed off to the billing portal rather than left stuck.

    • Coupons (SAASKIT_BILLING_COUPONS) — operators can create and manage discount coupons from the admin console. On flat PayPal and Sepay checkouts the discount is applied with exact integer VND-safe math and VAT is charged on the discounted amount; on Polar the discount is applied natively. Customers enter a code at checkout and see the discounted price before paying. Each coupon is redeemable once per organization on the kit-managed paths.

    Everything above is off by default. Turn on only the mechanics you want, per deployment.

  • M63

    Customer support portal + Zalo OA channel

    featureJul 4, 2026

    M56 shipped the operator support inbox but the customer side was write-only — a "Contact support" ticket vanished the moment it was created. M63 completes the loop: a customer can now read and continue their own threads, and the support inbox gains its first external channel, Zalo Official Account, live for the VN market.

    • Customer support portal (/{orgSlug}/support) — a member sees their own tickets in a list, opens a thread, and replies with optional attachments. The read model is bound to both the requester and the org in the repo, so a member never sees a co-member's ticket and never sees a ticket from another org. Operator internal notes (a new visibility flag) are filtered out at the repo layer — a UI bug cannot leak one. New threads and operator replies refresh the customer's view live over the existing realtime transport, and the support bell notification now deep-links straight to the thread instead of dead-ending.
    • Attachment downloads — support attachments (uploaded in-app or arriving by email) are now served through GET /api/support/attachments/[id], authorized for either the ticket's requester (public messages only) or an operator, and streamed via a short-lived presigned URL.
    • Zalo OA channel (Part B) — inbound Zalo messages land in the operator inbox as first-class conversations (one thread per Zalo user), and operators reply through the Zalo OA Customer Service API. Inbound is text-only with media fetched by a background job behind an SSRF host-allowlist; sends are deduped, and a failed send surfaces the same durable "Send failed — Retry" affordance M61 introduced for email replies. The channel ships inert until provisioned — with no OA credentials the webhook returns 503 and nothing sends, so non-VN deploys are byte-identical.
    • OA token management — the OA's strictly single-use refresh token is rotated by a 15-minute cron with an atomic compare-and-swap so a concurrent refresh can never brick the chain, and a failed refresh raises a deduplicated zalo_token_refresh_failed operator alert. Operators provision and monitor the OA from a write-only card at /admin/inbox/zalo (tokens are never echoed back).

    Honest scope: Zalo threads are operator-facing only (a Zalo user is not an app account, so they never appear in the customer portal), replies are always operator-initiated (nothing auto-sends), and the operator inbox stays English. No chatbot, no ZNS/broadcast, no Messenger, no SLA timers — those remain future work. Zalo's charged 48 h–7 d send window and its 7-day hard cutoff are shown to the operator before they send; see the admin runbook for the full operational detail.

  • M65

    Ops hygiene — maintenance mode, retention pruning, async data export, incidents

    breakingJul 4, 2026

    M65 is an operator-reliability sweep: an env-gated maintenance window, two retention prune crons that stop operational-log tables growing forever, an asynchronous data export that no longer streams a giant ZIP on the request thread, a zero-downtime webhook-signing-key rotation, and an operator-curated public incident history. Two of these change externally observable behavior — read the breaking notes below before you upgrade an existing install. One migration (0072) carries every schema addition.

    • Maintenance mode (SAASKIT_MAINTENANCE_MODE) — setting this to 1 makes the proxy return a fully-headed 503 (Retry-After: 300, no-store, per-request CSP, correlation x-request-id) for most routes — an HTML page for browser navigations, a { "error": "maintenance_mode" } body for /api/*. It is env-only and read at request time, but the process must restart to pick up the flag (no runtime toggle). An allowlist stays reachable so the platform doesn't cycle the app and operators keep working: /status, /api/ready, /api/health, /admin, /.well-known/, and /api/csp-report; everything else 503s. Note the deliberate escape hatches — proxy-excluded routes (api/auth, api/webhooks, api/og, _next) keep serving (billing webhooks must not be dropped mid-window), and /share/<token> public links bypass the gate — so a window is not a hard total blackout. See the admin runbook for the flip procedure and consequences.

    • Retention prune crons — two always-on daily maintenance crons cap unbounded growth: webhooks/prune deletes webhook_out_event rows older than SAASKIT_WEBHOOK_RETENTION_DAYS (default 90d; delivery children cascade, endpoints untouched), and notifications/prune deletes notification rows older than SAASKIT_NOTIFICATION_RETENTION_DAYS (default 180d, read-state-blind). Both delete in 10k-row chunks up to a 10M-row-per-run ceiling and warn if retention is falling behind.

    • Asynchronous data export (BREAKING for API clients)POST /api/account/export no longer streams a 200 ZIP. It now records the request, enqueues a worker build via the transactional outbox, and returns 202 { requestId, status }. The worker builds the ZIP (size-capped by SAASKIT_EXPORT_MAX_BYTES, default 128 MiB), uploads it to private storage, marks the row ready for a SAASKIT_EXPORT_DOWNLOAD_TTL_HOURS window (default 48h), and notifies the user. The ZIP is then fetched from the new GET /api/account/export/<requestId>/download route, which 302-redirects to a 300-second presigned URL (uniform 404 for unknown/foreign/unready, 410 once expired). Any client that expected the old 200 ZIP from the request must adapt: poll for ready, then follow the download route.

    • Zero-downtime webhook-key rotation — the outbound-webhook AES master key (WEBHOOKS_OUT_SECRET) now rotates without dropping deliveries. Set WEBHOOKS_OUT_SECRET_OLD to the previous key so the decrypt path falls back to it while an idempotent re-encrypt script rewrites every stored secret, then unset it. Full 5-step runbook in docs/webhooks-out/README.md.

    • Operator incident history — operators post + resolve incidents from /admin/health; the public /status page renders an active-incident banner and the last 10 resolved from curated fields only (title / publicBody / severity / timestamps) — it never touches alert internals. Posting is audited and fail-closed.

  • M61

    Reliability sweep — stuck-state re-drive + idempotent retries

    fixJul 3, 2026

    A post-M59 audit found six reliability defects that share one shape: a committed intermediate state with no re-drive path, or a retry that is not idempotent. An operator approval could crash mid-apply and strand in approved with nothing to detect it; an announcement fan-out could die mid-broadcast and sit in sending forever; an approved-but-uncaptured PayPal order could authorize money and never settle; a failed support reply had no durable "this never sent" marker. M61 closes each gap with a detector (so a stuck state is never silent) and a re-drive lever — automatic where replay is provably idempotent, operator-driven where it is not. No new feature surface; one migration (0071) carries every schema addition.

    • Announcements re-drive automatically (#239) — the announcements/redrive-stale cron (every 5 min) reclaims a broadcast stranded in sending past SAASKIT_ANNOUNCEMENT_REDRIVE_AFTER_MINUTES (default 15) via an atomic CAS and re-enqueues the fan-out. This one is fully automatic because the fan-out is already replay-idempotent (it subtracts recipients that already have a notification), so a re-drive never double-delivers. A live broadcast heartbeats its claim per chunk, so a long run is never reclaimed mid-flight; the operator stop lever is cancel.

    • Operator approvals: detect automatically, re-drive by hand (#246, #248) — a new ops/approval-stuck-check cron (every 15 min) fires an approval_stuck operator alert for any approval stranded in approved past SAASKIT_APPROVAL_STUCK_ALERT_MINUTES (default 30, always-on). But re-applying it is deliberately operator-driven, not an unattended auto-replay, for two code-verified reasons: the apply-cores need a live operator request context (headers(), the ALS user id, the approver's cookie session — a worker has none of these), and the money cores are not replay-idempotent (a credit adjust appends a ledger entry, a comp creates a subscription). A blind worker replay would crash half the action types and could double-move money. Instead an operator uses a verify-first Retry apply in /admin/billing/approvals: check the target state first (the crash may have already landed the apply), then retry — gated by the same staleness window (a fresh row may still be applying), a Separation-of-Duties re-check against the retrying operator, and a CAS claim so only one dispatcher wins. The raw apply error is now recorded on the row.

    • PayPal capture backstop (#230) — Orders v2 does not auto-capture, so a customer who closes the tab between approval and capture leaves an order APPROVED but never captured (money authorized, nothing settled). A delayed billing/paypal-capture-backstop job — outbox-enqueued on every CHECKOUT.ORDER.APPROVED, default 10 min later (SAASKIT_PAYPAL_CAPTURE_BACKSTOP_DELAY_MS) — re-checks the order and captures it if still APPROVED, under a deterministic PayPal-Request-Id so a retry replays the same capture instead of issuing a second one. The worker process now runs the billing boot guard too.

    • Durable support-reply retry (#264) — a failed operator email reply now stamps a durable send_failed_at and shows an inline Retry in the /admin/inbox thread. The retry re-sends the SAME row under the Resend idempotency key minted at the first attempt, with a byte-identical payload rebuild, so a retry within Resend's 24h window dedupes at the provider even if the first send actually landed — and a Resend 409 is read as "already delivered", not re-sent.

    • Dispute ingest two-key payment lookup (#271) — one PayPal payment can be referenced by either its order id or its capture id, and a dispute webhook may carry either. Ingest now resolves the payment by both keys (capture id first, order id fallback), and every PayPal paid writer stores the order id (0071 backfills history). An unmatched reference still fails loud (a FAILED, PayPal-redelivered webhook_event) — a money signal is surfaced, never swallowed.

    • Deprovision revokes operator-authored org keys (#249) — one-click operator deprovision now also revokes the org-scoped API keys the operator created (attributed via metadata.createdByUserId), closing a live backdoor a role-revoke left open. Keys created before the attribution convention can't be found by query and need a manual review — called out in the runbook.

    Honest scope. The approval re-drive is human-in-the-loop by design — the runbook documents the verify-first procedure and why unattended replay is unsafe here. The PayPal capture and dispute-ingest wire contracts are validated by a live sandbox round-trip (hard pre-prod gate), not by CI, which runs these paths in mock mode.

  • M64

    Email deliverability + SEO quick-wins

    featureJul 3, 2026

    M64 is a batch of deliverability and discoverability quick-wins — the small, standards-shaped things that make outbound email land in the inbox and public pages surface correctly to crawlers, feed readers, and security researchers.

    • One-click unsubscribe (RFC 8058) — every preference-gated notification email now carries List-Unsubscribe + List-Unsubscribe-Post headers, so Gmail and Yahoo show a native one-click "Unsubscribe" button. The link is a stateless, signed HMAC token (180-day expiry, no new secret — it signs with your existing auth secret); the POST flips the user's email preference for that category and audits it. Transactional mail — sign-in, password reset, GDPR, support replies — is deliberately exempt, so a click can never opt a user out of account-critical email.
    • hreflang + locale-aware canonicals — English and Vietnamese pages now emit reciprocal hreflang alternates (en, vi, x-default) and a per-locale canonical, so search engines index the right language for the right audience instead of treating the two locales as duplicate content.
    • Sitemap locale alternates — each locale URL is its own sitemap entry with reciprocal en / vi / x-default links, matching the on-page hreflang.
    • RSS 2.0 feeds — the blog (/feed.xml) and changelog (/changelog.xml) are now subscribable, advertised via <link rel="alternate"> on their index pages so feed readers auto-discover them.
    • security.txt — an RFC 9116 /.well-known/security.txt publishes a contact URI (set SAASKIT_SECURITY_CONTACT) with a freshly computed Expires on every request, giving security researchers a standard way to report vulnerabilities.
    • x-request-id echoed on every response — the proxy now returns the request id on all responses (2xx/3xx/4xx), so a customer or operator can quote it and support can correlate it to logs.
    • Branded email footer — notification emails now close with a "Sent by <your kit name>" line (from NEXT_PUBLIC_KIT_NAME), falling back to saas-kit when the kit name is unset.
  • M58

    Money-ledger unification

    featureJul 2, 2026

    Money movement used to be stitched from three tables at read time — payment (money-in), refund_request (money-out), and the M57 dispute case — and every operator and finance surface re-derived the stitch differently. There was no single record that said "this much money moved, this direction, this provider, for this org, at this time, because of this event." M58 introduces one: an append-only, org-scoped money_ledger that is the derived reporting authority for revenue and treasury. The source tables stay the transactional truth; the ledger is the single read authority.

    • One append-only row per money eventcharge, refund, chargeback, chargeback_hold, chargeback_reversal, comp, each carrying a direction (in/out) and an is_revenue flag, native amount_minor + currency, source links (payment / refund_request / dispute / einvoice), and a deterministic entry_key UNIQUE constraint. Idempotency is the load-bearing invariant: a redelivered webhook, a re-run backfill, or a double-fulfillment can never double-count. Migration 0068 backfills the full payment / refund_request history.
    • Written at the repo seam, atomically — the ledger row appends on the same transaction that mutates the source table (paymentsRepo.record for charge/comp, all three refund_request completion writers, the dispute engine's hold/reversal), so the two commit together. No provider behaviour changes and no new webhook subscriptions — the ledger is derived from events the kit already ingests.
    • Operator revenue re-pointed — M26 revenueSeries now reads the ledger instead of the ad-hoc payment FULL OUTER JOIN refund_request. As part of this, comp (paid entitlement, zero revenue — M28) is now correctly excluded from revenue; the old join would have counted a non-zero comp payment as revenue. No real report changes (comp never created payment rows in practice), but the correction is now structural rather than incidental.
    • Treasury holds are now visible — a chargeback provisionally debits cash at open and reverses on a win. That intra-dispute hold/reversal, invisible before, is now recorded as chargeback_hold / chargeback_reversal treasury rows (is_revenue false, they net to zero on a won dispute). An operator can finally see cash currently held pending a dispute.
    • Per-org drill-down — a read-only /admin/orgs/[orgId]/ledger tab shows the money-event stream that explains the numbers, gated on billing.reconcile (finance / admin / superadmin — not read-only / refund-approver / support, who may not see dispute treasury data).

    Honest scope — VAT is linked and reconciled, not migrated.

    • VAT is linked, not re-sourced. Charge rows carry the Net / VAT / tax_rate_code breakdown copied from the linked e-invoice, and a new integration test reconciles ledger charge gross against e-invoice gross so drift is caught. But M31's legal VAT report stays sourced from the e-invoice — the e-invoice remains the filing authority (issued_at + adjusted credit-note semantics the ledger deliberately does not model). This is permanent by design, not a pending migration.
    • FX stays read-time. The ledger stores native amount_minor + currency; VND conversion remains an M30 read-time layer. No per-event FX snapshot.
    • Single-entry, not double-entry. Direction carries the sign; there is no offsetting account or trial balance.
    • Deferred: the customer-facing "money movements" view on the receipt / VAT surface is deferred to a follow-up (#272). Operator credit adjustments (M27) stay out of the ledger — they move the prepaid credits balance, not provider cash.
  • M56

    Operator support inbox + ticketing core

    featureJul 1, 2026

    The operator console gains a unified support inbox at /admin/inbox — the first milestone of a support-inbox epic. One model (a conversation IS the ticket) gathers customer messages into a single place to see "what has a customer said, and have we replied", with a channel discriminator so future channels add an adapter, not a schema rewrite.

    • In-app support, live day-one (F3) — a logged-in customer opens a ticket from the "Contact support" entry point in the app shell; an authenticated server action creates the conversation + first message with org + user context already stitched, audits SUPPORT_CONVERSATION_OPENED (id-only metadata), and nudges the operator inbox live over the existing realtime transport. Zero third-party — it works the moment M56 merges.
    • Email-to-ticket, behind provisioning (F4)POST /api/webhooks/email-inbound ingests inbound customer email into the same model: Svix-verified, idempotent on the provider email_id, threaded via Message-ID with an anti-hijack guard (join only when the root Message-ID and the sender match). Resend Inbound is GA but metadata-only, so the route does a 2-phase fetch (Received Emails + Attachments API) for body + attachments, reusing RESEND_API_KEY. Inbound is text-only (no HTML stored or rendered → no sanitizer dep, no inbound-HTML XSS). The route ships inert (503) until SAASKIT_INBOUND_EMAIL_SECRET + the receiving-domain MX + the Resend webhook are provisioned (docs/operator-support-inbox.md).
    • /admin/inbox console (F5) — a self-gated cross-tenant list + conversation detail + reply composer. Reply routes back to the originating channel (in-app → the bell; email → a threaded email reply with a minted Message-ID). Assign + status changes are gated, rate-limited, and audited.
    • Operator-ac inbox statement — a new BetterAuth-native inbox: ["read","reply","assign"] grant. read is broad (read_only + support + admin + superadmin) so analytics operators can see the backlog; reply/assign is the support tier and up. finance and refund_approver get nothing on the inbox.

    Honest scope: lifecycle is status + assignee + read-only tags only — tag-editing, SLA timers, CSAT, macros/canned-responses, and auto-routing are deferred to a future milestone, as are all external channels (Zalo OA is next for the VN market, then FB Messenger) and chatbot / AI-deflection. The in-app channel is live on merge; email-to-ticket is a documented runbook step away.

  • M57

    Dispute & chargeback automation

    featureJul 1, 2026

    M55 shipped a dispute console, but it was bookkeeping only — an operator recorded accept/contest and stored evidence in S3, and nothing ever reached the payment provider. An operator could believe a chargeback was handled while the provider's response deadline silently passed. M57 closes the loop: a dispute is now a first-class domain aggregate with a provider-driven lifecycle, and PayPal disputes integrate with the Customer Disputes API for both ingest and evidence submission.

    • Dispute is a first-class aggregate — no longer a refund_request with source='dispute'. It has its own dispute row, a single Stripe-aligned status (inquiry → needs_response → under_review → won | lost | accepted), an evidence_due_by deadline, an evidence-submission count, and structured evidence categories. The console list surfaces the status, a deadline-urgency indicator, and whether evidence has been assembled.
    • PayPal Disputes API automationCUSTOMER.DISPUTE.CREATED/UPDATED/RESOLVED webhooks auto-ingest the dispute and its deadline (no manual seeding). Operators assemble evidence by category and explicitly submit it to PayPal (re-submittable until the deadline) or accept the claim — both reach PayPal over the raw Disputes REST API (the repo's PayPal SDK has no DisputesController), reusing the existing OAuth token.
    • Deadline alerts — an opt-in cron (SAASKIT_DISPUTE_DEADLINE_ALERT_HOURS) fires a dispute_deadline_approaching operator alert for PayPal disputes whose response window is closing, plus a dispute_opened alert on ingest.
    • The M17 untangle (money vs. entitlement) — M17 conflated a dispute with a refund and revoked entitlement the moment a chargeback opened, which is wrong (no money has settled yet and the merchant may win). M57 separates the two: money-out is recorded exactly once, at lost, as a refund_request(source='chargeback') linked from the dispute; entitlement is policy-driven — alert on open, revoke on lost by default (mirrors Stripe), with an opt-in SAASKIT_DISPUTE_SUSPEND_ON_OPEN for stricter operators. Accept-claim concedes the dispute (status → accepted, terminal) but records no money-out and does not itself revoke — the money-out + entitlement revoke then follow the provider's actual refund via the M17 reconcile path.

    Honest scope — automation is PayPal-only.

    • Polar is Merchant of Record: it emits no dispute webhook and contests chargebacks itself, so there is no merchant evidence path. When Polar loses it issues a refund, and the M17 reconcile records a dispute(status='lost'). The console shows an explanatory banner — there is nothing to submit.
    • Sepay (VietQR bank transfer) has no card chargebacks; disputes are recorded and transitioned manually, with no provider round-trip.
    • The live PayPal wire contract is not certified by CI. CI runs the handlers in mock mode, which cannot verify the real evidence_type strings, the multipart file→evidence association, or the accept-claim body. Those are validated by a runbook-documented PayPal sandbox round-trip that is a hard pre-prod gate, not a CI check.
    • Deferred: PayPal send-message / make-offer / escalate / appeal (only provide-evidence + accept-claim ship); auto-submit (submission is always an explicit operator action); and money-ledger unification → M58, which will make money-movement a first-class ledger that payment/refund/dispute all reference and re-point analytics / FX / VAT at it. Folding that into M57 would couple disputes to a billing-wide analytics rewrite — out of scope by design, not by omission.
  • M54

    Operator observability & incident ops

    featureJun 30, 2026

    Operator-side observability and incident-response tooling lands: a /admin/health cockpit, an operational-symptom alerting engine, alert acknowledgement, and a disaster-recovery / rollback runbook with a restore-check drill.

    • Operator health cockpit (/admin/health) — a self-gated (observability:read) console that surfaces scheduler/cron freshness, an outbound-webhook rollup with top-failing endpoints, DB latency, and the list of currently-firing operational alerts. Secret-free projections only (no signing secrets, no customer PII). A dead-man's-switch notice is rendered inline.
    • Operational-symptom alerting — four opt-in alert kinds (scheduler_stale, job_failed, webhook_endpoint_down, error_rate_threshold) evaluated on a schedule, with atomic race-free dedup (upsertObservation), flap damping via a re-arm cooldown, and a system-actor fan-out to the notification bell + email. Every kind is uniformly opt-in (enable flag + threshold) — a fresh deploy fires nothing until an operator opts each in.
    • Alert acknowledgementobservability:acknowledge-gated, rate-limited, audited (ADMIN_OPS_ALERT_ACKNOWLEDGED); an ack survives a flap inside the cooldown.
    • DR & rollback runbook (docs/dr-runbook.md) — a disaster-recovery + rollback runbook for the DigitalOcean-only deploy: backup reality (Managed PG PITR, Spaces, Valkey), the point-in-time restore procedure, a one-command App Platform rollback recipe, and an external dead-man's-switch step (the only mitigation for a total scheduler outage, which is invisible in-product).
    • Restore-check drill (scripts/restore-check.mjs) — a read-only probe that proves a forked backup is restorable: asserts connectivity, that the drizzle migration journal matches the codebase, that sentinel core tables exist, and a smoke query — failing loud on drift or a corrupt restore. Run monthly + pre-major-release.

    Honest scope: the DR runbook is discipline + recipes (not automated DR); the in-product evaluator is itself a scheduled job, so a total scheduler outage is invisible in-product and requires the external dead-man's-switch documented in the runbook.

  • M55

    Operator customer-care surface

    featureJun 30, 2026

    The operator console gains the support / customer-care tools the GOVERN + OPERATE loops were missing: support staff can now raise refund/credit requests through the existing 4-eyes machinery, a dispute / chargeback console lands, and operators can re-send a payment receipt.

    • Support raises refund & credit requests (F1) — the support operator role now holds billing:["refund.create"] + credits:["adjust"] (the REQUESTER side only). It does not gain refund.approve or approvals.approve, so the M17 4-eyes split is preserved: support requests, a distinct finance/admin operator approves.
    • Dispute / chargeback console (/admin/billing/disputes, F2) — a self-gated (billing:dispute.manage, held by finance / admin / superadmin) cross-tenant list + detail of source='dispute' refund requests. Operators record an accept or contest disposition (audited ADMIN_DISPUTE_ACCEPTED / ADMIN_DISPUTE_CONTESTED, alerted via the new dispute_accepted / dispute_contested kinds), attach free-text and file evidence (presigned S3 upload, keys namespaced to the dispute's own org), and download stored evidence.
    • Resend payment receipt (F3) — an org-scoped, communications:["resend"]-gated control on the org-detail payment rows re-sends a paid payment's receipt to the customer's email on file (never the operator's), rate-limited and audited (ADMIN_RECEIPT_RESENT).

    Honest scope: the dispute console is bookkeeping + evidence storage only. There is no PayPal/Polar Disputes API client in the kit, so contest evidence is not auto-submitted to the provider — an operator must still file it in the provider's dashboard before the deadline (the console banner and the operator runbook say so). Provider-evidence submission is deferred to a follow-up. The operator-triggered email-verification resend was likewise deferred (BetterAuth exposes no clean operator-to-customer verification-token path) — receipt resend ships; verification resend is tracked as a follow-up.

  • M29

    Operator console hardening

    featureJun 18, 2026

    Four follow-ups from the M26–M28 operator-console trilogy are now closed:

    • Polar provider-side cancel on force-cancel — when an operator force-cancels a Polar recurring subscription the kit now also calls the Polar cancel API so the customer is not billed at the next renewal. PayPal and Sepay are one-time providers (no auto-billing), so for them the local cancel is already complete and no provider call is made. If the Polar API call fails the local cancel is still authoritative and the operator sees a clear warning to verify in the Polar dashboard.
    • Admin/operator actions visible in the per-org audit tab (#200) — actions such as comp, extend, force-cancel, credit adjust, ban, and internal notes are now surfaced in the /admin/orgs/[orgId]/audit view alongside the org's own activity. Previously those rows (which carry no organization_id) only appeared in the global /admin/audit log. This is a query-side widening — no schema change.
    • Daily metric snapshots → MRR/ARR/ARPA deltas + subscriber churn (#194) — a new metric_snapshot table (migration 0056) and a daily Inngest cron (billing/metric-snapshot, 01:00 UTC) capture point-in-time run-rate metrics. The operator analytics dashboard now shows period-over-period MRR/ARR/ARPA deltas, net subscriber change, and an approximate gross churn rate derived from the two most recent snapshots. Deltas and churn display as "n/a" until at least two daily snapshots exist (≥ 24 h post-deploy).
    • Cached operator analytics aggregates for faster dashboard loads (#195) — the eight aggregate queries that back the operator analytics dashboard are now wrapped with unstable_cache (no app-wide Cache Components flag change). The cache is busted automatically after each daily snapshot write. Aggregate staleness is bounded to 5 minutes between snapshot runs.

    Deferred: #196 (real FX feed to replace the static VND rate table) remains a standalone follow-up — MRR/ARR figures continue to use the static rate table until that issue ships.

  • M20

    Cursor pagination for /api/v2 list endpoints

    featureJun 11, 2026

    The /api/v2 list endpoints now return a cursor-pagination envelope:

    • Additive (non-breaking)GET /api/v2/orgs/{slug}/members and GET /api/v2/orgs/{slug}/api-keys gain a meta block (next_cursor / has_more / limit) alongside the existing data array, and accept an optional opaque ?cursor= token to page forward. api-keys also gains a ?limit= param (clamped 1..100, default 50) — it previously had none. Existing callers that only read data are unaffected. The typed SDK (@saas-kit/api-client) now exposes the cursor param and the meta envelope.
    • Behavior change — neither route previously declared an ORDER BY, so the default result order was undefined. Both now sort deterministically: members oldest-first (member.id ascending, UUIDv7 time-ordered) and api-keys newest-first (created_at descending). Consumers that relied on the previous, implicit ordering should review this — the order callers observe today will change.
  • M15

    Tenant-mode foundation + workspace admin UX

    featureMay 29, 2026

    A configurable multi-tenant foundation plus the full workspace-admin surface, shipped across seven sub-PRs:

    • Tenant mode — pick personal-default (private workspace per signup, team surfaces revealed on the first invite), solo (single-user; team surfaces 404), or team-required (every signup runs the team-creation wizard). One env var, SAASKIT_TENANT_MODE, plus an optional SAASKIT_TENANT_LABEL to rename the tenant noun. See docs/extending/tenant-mode.md.
    • Members — filterable, paginated member table with search, role/2FA/last-seen filters, bulk remove, and owner-only ownership transfer (typed-confirm).
    • Invitations — email invites, CSV bulk import, resend/cancel, and shareable invite links (/join/[slug]) with usage caps.
    • Roles — per-org custom-role editor: resource-grouped permissions, role templates, rename, and delete-when-empty. Built-in roles stay read-only.
    • Audit log — a tenant-scoped explorer with full-text metadata search (Postgres tsvector + GIN), faceted filters, a drill-down drawer, and streaming CSV export.
    • Platform-admin parity — super-admins inspect any org's Members / Invitations / Roles / Audit at /admin/orgs/[orgId]/* using the same UI in a read-only posture.
    • Migration 0037 ships the audit metadata_tsv index as a two-step CREATE INDEX CONCURRENTLY runbook to keep the deploy lock-free.

    Adopting personal-default on an existing install? Run the one-shot backfill: BACKFILL_PERSONAL_WORKSPACES=1 pnpm --filter @saas-kit/db backfill:personal.

  • v1.2.3

    Patch hardening

    fixMay 22, 2026
    • Fixed next-intl proxy handling for rewrites and top-level non-i18n routes
    • Cleaned up pre-existing TypeScript errors around proxy, typed routes, theme provider props, and implementation casts
    • Added permanent-failure operational follow-ups for outbound webhooks
    • Translated the signup divider copy in both English and Vietnamese message files
  • M12

    Follow-ups + polish

    featureMay 21, 2026
    • docs/extending/{ai,search,mcp}.md wiring guides for downstream products
    • Webhook permanent-failure subscriber, audit action, notification type, and permanent_failed_at migration
    • Vietnamese app-shell translations for settings and dashboard surfaces
    • Sentry v11 re-check recorded; kit stays on the current 10.x line until v11 ships
    • cacheComponents decision locked: wait for Next 17 or per-route opt-in signal
    • /api/csp-report endpoint added; CSP enforcement remains deferred until a real observation window
  • M11

    Completeness pass

    featureMay 19, 2026
    • @saas-kit/storage with attachment schema, presigned uploads, and S3-compatible config
    • @saas-kit/jobs with Inngest mount, tenant-aware job wrapper, and cron migrations
    • Org RBAC roles and permissions with settings UI
    • Outbound webhooks package with signing, endpoint UI, retry delivery, and event history
    • /api/v2/*, OpenAPI JSON, Scalar docs, and generated typed SDK
    • i18n routing with en default and vi scaffold
    • Public /status page and security/CSP Report-Only baseline
  • M10

    Desktop foundation

    featureMay 18, 2026
    • Tauri v2 desktop app scaffold with Vite, React, Tailwind, and shared UI
    • @saas-kit/desktop-bridge for typed IPC, auth token storage, deep links, and update hooks
    • /api/v1/* surface for desktop handoff, entitlements, consent, and client version checks
    • Desktop release, signing, setup, customization, and threat-model docs
    • Desktop E2E scaffolds and smoke scripts for local/release validation
  • M9

    Polish + docs

    featureMay 14, 2026
    • v1.0 polish pass across app-shell, marketing, API, and docs
    • Deployment matrix docs for Vercel, Cloudflare Pages, Docker, and DigitalOcean
    • Environment-variable reference and setup documentation
    • E2E, visual, and Lighthouse gates hardened for release confidence
    • v1.0.0 tag cut after the milestone suite
  • M8

    Tier 2 + GDPR

    featureMay 13, 2026
    • Data export request and archive shape in @saas-kit/gdpr
    • Account deletion flow with 30-day grace window and restore path
    • Cookie consent defaults, geo helpers, and consent-log repository
    • Storage attachment groundwork with S3-compatible provider docs
    • GDPR runbook and operator-facing retention guidance
  • M7

    Marketing + content

    featureMay 10, 2026
    • New @saas-kit/content package with velite collection schemas + makeReaders factory
    • Fumadocs at /docs with built-in Orama search + sidebar
    • Polished landing + refactored pricing + blog + changelog + 3 legal placeholders
    • Sitemap, robots, dynamic OG via next/og
    • Lighthouse CI gate ≥ 90 on /, /pricing, /blog, /docs
    • CSS-only hero entrance (no motion runtime dep)
    • Server-component MDX rendering via velite + react/jsx-runtime
  • M6

    Audit + admin

    featureMay 9, 2026
    • audit_log schema + transactional logAudit() in new @saas-kit/audit package
    • Closed-enum action catalog covering all M1–M5 mutating sites
    • Daily prune cron (365-day retention)
    • BetterAuth admin() plugin: roles, ban, signed 1-hour impersonation
    • /admin/... console: orgs / users / audit-log with filters
    • Twin-gated server actions (kit requireAdmin + plugin's internal admin check)
  • M5

    Email + notifications + observability

    featureMay 8, 2026
    • Resend client with SPF/DKIM/DMARC docs
    • Seven React Email templates
    • Pino logger with request_id / user_id / org_id
    • Sentry stub (env-gated, server + client)
    • PostHog stub (env-gated, browser + Node)
    • In-app notification table + bell UI with ETag-cached polling
    • Preferences page toggles per channel × category
  • M3

    UI library + theme

    featureMay 7, 2026
    • 20 shadcn primitives + 7 composites + 7 layouts in @saas-kit/ui
    • Warm-gray OKLCH tokens; 4/8/12 radii; 32px density
    • Storybook stories for every primitive + composite + layout
    • Playwright snapshot regression on /components demo page
  • M4

    Billing — 3 providers

    featureMay 7, 2026
    • Polar.sh recurring (USD/EUR) via @polar-sh/better-auth plugin
    • PayPal one-time (Orders API v2) with webhook signature verification
    • Sepay PG one-time (VND) via sepay-pg-node
    • Unified subscription + payment schema
    • Daily cron for expiry email (T-7d) + status flip (T+0)
    • Per-org referenceId pattern keeps subscriptions strictly tenant-scoped
  • M2

    Multi-tenancy

    featureMay 6, 2026
    • /[orgSlug]/... routing with proxy-driven membership resolution
    • AsyncLocalStorage context + withOrg() repo helper
    • @saas-kit/eslint-rules/no-raw-tenant-query enforced in CI
    • OrgSwitcher + Members + Invitations UI
    • next-safe-action v8 wired
    • Cross-tenant leak suite gates M2 DoD
  • M1

    DB + Auth foundation

    featureMay 1, 2026
    • Drizzle ORM + UUIDv7 (client-generated)
    • BetterAuth: email + pw, Google, GitHub, magic link, 2FA TOTP
    • /login, /signup, /forgot-password, /reset-password, /verify-email, /magic-link, /two-factor
    • /account/{profile,email,password,two-factor,sessions,danger} user settings
    • In-process LRU rate limit on /api/auth/* (Upstash swap in M8)
    • Email transport via Mailpit (Resend swap in M5)
  • M0

    Bootstrap

    featureApr 30, 2026
    • Turborepo 2 + pnpm 10 workspace
    • Docker Compose: Postgres 18, Mailpit, MinIO
    • Node 22 LTS pinned via engines + packageManager
    • CI runs lint + typecheck on every PR
    • Next 16 + React 19 + App Router boots