Changelog

Every release these kits have shipped, with the date it shipped. The most recent release is at the top of each list.

46
dated releases
216
individual changes
3
kits
57 days
26 Aug 2026 – 21 Oct 2026

Why this page exists

Starter kits rot. Dependencies get CVEs, framework majors land, and a kit that stopped shipping is a kit you will be maintaining yourself in six months. The free alternative most people compare against,CMSaasStarter, has never published a tagged release at all, and its most recent commit is dated 21 March 2026 — check that link if you want to confirm it. Nothing here is a claim about what that project is doing; it is the reason a buyer should ask any vendor for a dated changelog, and then read this one.

Multi-tenant SvelteKit Starter

15 releases · 71 changes · latest v0.2.15 on12 Oct 2026

  1. v0.2.15

    15changes

    Added

    • **Your sessions** — a `/app/sessions` screen where a signed-in user can see every device they are signed in on, end an individual session, or sign out everywhere else at once. Reachable from the app top bar. - `src/lib/server/services/sessions.ts` — `listOwnSessions()`, `revokeOwnSession()`, `revokeOtherSessions()`. - **Every function takes the session token, never a user id.** The user id is re-derived server-side from that token, and the `DELETE` is scoped to it, so a forged or misrouted session id cannot reach another account's rows. There is no client-supplied identity anywhere on the page to get wrong. - Revoking the session you are currently using is refused with a pointer to Sign out, rather than completing a request against a session it just killed. - Each revocation writes an append-only `session.revoked` (or `session.revoked_others`, with the count) row. Sessions belong to a user, not an organization, so these entries carry no `orgId` — which keeps them out of every organization's audit page while still leaving the record an incident review needs.
    • `rateLimitedToFail()` in `lib/server/http.ts` — the 429 wording now lives beside `errorToFail()` instead of being written out inside the `/login` action, so every surface that consults a `RateLimiter` answers identically.

    Fixed

    • **The scrypt work factor was below the standard its own comment cited.** `auth.ts` used `N = 16384, r = 8, p = 1` under the comment "OWASP-recommended scrypt parameters for interactive logins". OWASP's Password Storage Cheat Sheet gives a minimum CPU/memory cost of 2^17 at block size 8 and parallelism 1, and lists `2^17/p=1`, `2^16/p=2`, `2^15/p=3`, `2^14/p=5` and `2^13/p=10` as configurations of similar minimal defense. `2^14` is only paired with `p=5`, so the shipped value was neither the minimum nor any listed configuration, and the comment made a false claim about a named standard. The default is now `N = 2^17, r = 8, p = 1`. **`SCRYPT_MAXMEM` was added alongside it and is not optional**: scrypt needs `128 * N * r` bytes (128 MiB at this value) while Node defaults `maxmem` to 32 MiB and throws `ERR_CRYPTO_INVALID_SCRYPT_PARAMS` rather than allocating. Existing hashes are unaffected — `format()` encodes `scheme$N$r$p$salt$hash` and `verifyPassword()` reads the parameters back out of the stored value, so a row written under the old parameters keeps verifying and is only re-issued at the new cost when its owner next sets a password. `tests/scrypt-upgrade.test.ts` proves that path, including all five OWASP-listed configurations. The suite runs the production parameters rather than a weakened test-only value. That costs wall-clock — roughly 80s instead of 15s — and buys a suite that exercises what ships.
    • **The invite page was an unlimited credential oracle.** `/invite/[token]`'s `loginAccept` and `signupAccept` actions verify passwords and create accounts with **no rate limiting at all**; the limiter existed only inside the `/login` action. Anyone holding a valid invite link had an unlimited password-guessing oracle against every account in the install, with none of the failed-attempt accounting the limiter exists to enforce. Both actions now check the key before spending any scrypt work, penalise failures, reset on success, and answer the shared 429.
    • **`loginAccept` reported an explainable failure as an Internal Error.** It called `tryAccept()` outside any try/catch, so signing in on an expired, revoked or already-used link threw out of the action while the two sibling actions on the same page handled it — and the user was left holding a session cookie. It now returns the same 400 with the real message.
    • **`cleanupExpiredSessions()` never deleted anything.** It matched `eq(expiresAtMs, Date.now())`, so it removed only sessions whose expiry was _exactly_ the current millisecond. Expired sessions accumulated without bound. The predicate is now `lt`, matching what `getUserBySessionToken()` already treated as expired.
    • **`errorToFail()` contradicted its own documented contract.** The docstring promised a generic 500 for "anything unmapped or unexpected", but the implementation reached that branch only for values with no `code` at all — every other `{ code, message }` became a 400 carrying its raw message. better-sqlite3 attaches both to every `SqliteError`, so an internal fault was reported to the browser as a user-fixable 400 with schema table and column names in the body. It now maps an explicit allow-list of this kit's machine codes and answers a logged, generic 500 for anything else, so a new code that nobody registered fails closed.
    • **`verifyPassword()` threw on a corrupt stored hash.** A value truncated after the salt left `hashB64` undefined and `Buffer.from(undefined, 'base64')` raised a `TypeError`; neither caller wraps the call in a try/catch, so one bad `password_hash` row turned every login attempt for that account into an unhandled 500 _and_ skipped the limiter's failure accounting. The field count and the scrypt parameters are now validated before use, so a malformed row is a failed verification — and a row claiming an enormous work factor is refused rather than made to allocate gigabytes on every login.
    • **`createOrg()` was not atomic.** The organization row, the owner's membership and the audit entry were three independent statements; a failure between them — most easily the membership INSERT tripping the `users` foreign key — left an organization with no owner, which no read path can reach and nothing collects. They now run in one transaction.

    Fixed

    • **The CSV export had no formula-injection guard.** `csvEscape()` implemented RFC 4180 quoting only. Spreadsheets treat a value beginning `=`, `+`, `-` or `@` as a **formula**, not text, so an exported cell could become a live `=HYPERLINK(...)` in the compliance officer's spreadsheet. Nothing in today's columns can start with one — `metadata_json` is JSON-wrapped, `actor_user_id` and `target_id` are UUIDs, `action` and `target_type` are server constants, `created_at` is an ISO timestamp — so this was unreachable in practice. It is fixed anyway, because the guarantee was resting on an argument about the current schema rather than on a property of the code: the first column a buyer adds from user input would have inherited the hole, and nothing would have failed. `neutralizeFormula()` now prefixes such values with an apostrophe, which keeps the text and round-trips on re-import. **Order matters and the wrong order is the natural one.** The guard runs on the _raw_ value, before quoting. Quoting first would leave the field starting with `"`, which matches no trigger character, so the canonical payload `=HYPERLINK("…")` — which necessarily contains a quote — would pass through untouched. `tests/audit-export.test.ts` pins exactly that payload.
    • **`csvEscape()` did not quote on a bare carriage return.** A lone `\r` splits rows in some CSV parsers, so a metadata value containing one produced a record count that did not match the row count. `\r` now joins `\n` in the trigger set.
    • **The audit exports had no pagination ceiling.** `limit` and `offset` were passed straight through, so one request could ask for the entire append-only log — unbounded memory on exactly the table designed to grow forever. `MAX_EXPORT_ROWS` (10 000) and `clampExportPage()` now floor `limit` at 1 and `offset` at 0 and cap the page, applied to both `exportAuditCsv()` and `exportAuditJson()`.
    • **`exportAuditJson()` aborted the whole export on one corrupt metadata row.** `JSON.parse()` threw on anything unparseable. The audit log is append-only with no UPDATE path, so such a row can never be repaired — throwing denied the buyer every other event in the file, including the ones they needed. A row that will not parse is now reported as its raw string.

    Changed

    • Test count 357 → 475. Coverage is 100% on statements, branches, functions and lines, measured with `npm run test:coverage`.
    • `npm run format:check` now passes. It did not before this pass: 24 files were unformatted, including five documentation files.
  2. v0.2.14

    0changes
  3. v0.2.13

    0changes
  4. v0.2.12

    1change

    Fixed

    • README version claim. The `**Version**` line, the version badge and the machine-readable `version:` field all still read `0.2.11`'s predecessor after the previous release, so the published proof repo stated a version that did not match its own release tag. All three forms now track `package.json`.
  5. v0.2.11

    2changes

    Added

    • `src/lib/server/mcp/` — an MCP server exposing the service layer to a coding agent over stdio. Four read tools: `list_orgs`, `list_members`, `list_pending_invites` and `list_audit_log`, all backed by the same services the app uses, all taking a `Db` (better-sqlite3 + Drizzle). - **Read-only by design.** These tools are called by a language model, and a model can be steered into calling them by text it has just read — a prompt injection hidden in an audit-log string could otherwise reach a mutating call. Reads cannot destroy state, so reads are the first surface exposed. - **No SDK.** MCP over stdio is JSON-RPC 2.0. Implementing it keeps this feature dependency-free and shows the protocol rather than hiding it. - **Both protocol shapes.** The 2026-07-28 revision dropped the mandatory `initialize` handshake for a stateless core carrying `protocolVersion` in `_meta`; older clients still open with `initialize`. The server accepts both and names the versions it implements when asked for one it does not. - **Tool failures are results, not protocol errors** (`isError: true` with a readable message), so a model can correct its call and retry. - `npm run mcp` starts it. Credentials come from whatever the app already uses — no new environment variables. - Lives inside `src/lib/server/`, so it counts toward the coverage threshold. The module is at 100% statement, branch, function and line coverage; `cli.ts` is excluded with the reason recorded in `vitest.config.ts`, and the transport is covered in-process. - The stdio transport is serialised deliberately: replies go out in the order requests were read, so a client never gets two responses swapped.
    • `tests/mcp.test.ts` — protocol, tool and transport coverage. Test count 326 → 357.
  6. v0.2.10

    5changes

    Added

    • `tests/csrf-cookie.test.ts` — real client-bundle exposure guard. Walks every module SvelteKit also runs in the browser (`.svelte`, `+page.ts`, `+layout.ts`) and fails if any of them imports `$lib/server/*` auth code or reads the session cookie from `document.cookie`. This replaces a test that was _named_ for this guard while asserting only `maxAge`.

    Fixed

    • A test whose name claimed a security property it did not check. `never exposes the session token

    Changed

    • The `sameSite` pin is renamed `pins sameSite to 'lax' so the documented threat model stays true`. The previous name claimed the test "documents the limits" of `lax`; it never did. It pins the _premise_ of the documented threat model (change the cookie option and the analysis stops describing the code, so fail and revisit). The two documented limits themselves are browser behaviour and are not unit-tested, and the test's docblock now says so.

    Notes

    • 323 tests across 14 suites, coverage 98.84 / 95.62 / 100 / 99.17. `svelte-check` clean.
    • The exposure guard is falsifiable: adding a `.svelte` file that imports `$lib/server/auth` makes it fail with the offending path in the message.
  7. v0.2.8

    3changes

    Added

    • **Dinh Fire Lamp theme** applied across the app UI and docs — Bạc Ngà (ivory) light default with an optional Rừng Đêm (forest-night) dark palette, tokenized per the shared design spec (Georgia serif headings, 18px radii, 60-30-10).

    Fixed

    • Light-mode `--faint` text color corrected to the spec value (`#97897a`; a dark-mode value had been used, dimming secondary text too far in light mode).

    Docs

    • Screenshot gallery refreshed — every capture re-taken against the themed live demo (owner dashboard, invite flow, seat updates, RBAC denial, audit trail; 298 tests).
  8. v0.2.7

    1change

    Docs

    • Suite tables completed to match the shipped 13 test files: `AGENTS.md`, `docs/testing.md`, and README status now list every suite (added `billing-edge-cases`, `rbac-boundary`, `service-integration`, `edge-cases`) with measured per-file counts (298 tests total).
  9. v0.3.0

    1change

    Tests

    • 298 tests (was 240): +58 new tests covering e2e billing flows (seat upgrades/downgrades, adapter failures, boundary conditions), RBAC boundary cases (cross-org isolation, rank escalation, hierarchy enforcement), full multi-step service-integration flows, and auth edge cases. Suite grown to 13 files.
  10. v0.2.4

    4changes

    Added

    • **Docker support**: `Dockerfile` (multi-stage Node 24 Alpine build) and `docker-compose.yml` with persistent SQLite volume for container deployments.
    • **Deployment docs**: `docs/deployment.md` — comprehensive deployment guide covering Docker, VPS, Cloudflare Pages, Vercel, Fly.io, and Postgres swap path.
    • **Usage-based billing guide**: `docs/ai-billing.md` — adapter pattern for AI token usage tracking, metered billing, and usage-based pricing.
    • **`.dockerignore`**: Build context exclusions for cleaner Docker builds.
  11. v0.2.3

    7changes

    Added

    • **Password strength validation**: `checkPasswordStrength()` now enforces uppercase, lowercase, and digit requirements (not just minimum length). Returns `{ ok, reasons }` for UI feedback.
    • **Session management**: `listSessions()`, `destroyAllSessions(exceptToken?)`, `cleanupExpiredSessions()` for security features like "log out everywhere" and session listing.
    • **Configurable session TTL**: `SESSION_TTL_DAYS` environment variable (default 30) for compliance scenarios requiring shorter sessions.
    • **Audit log export**: `exportAuditCsv()` and `exportAuditJson()` for compliance buyers who need to export their audit trail. CSV includes proper escaping; JSON includes ISO timestamps and parsed metadata.
    • **Billing guard**: `assertSeatAvailable()` now blocks `past_due` and `canceled` subscriptions from adding new seats.

    Fixed

    • **`createUser` error handling**: catch block now only catches unique constraint violations (`SQLITE_CONSTRAINT`, `SQLITE_CONSTRAINT_UNIQUE`, Postgres `23505`) instead of silently swallowing all DB errors as `email_taken`.

    Tests

    • 240 tests (was 204): +36 new tests covering password strength, session management, billing guard, error mapping with actual Error subclasses, audit export, and audit pagination edge cases.
  12. v0.2.2

    3changes

    Added

    • **Source documentation (TSDoc)**: every exported symbol on the public API surface now carries a doc comment — parameter/return/throws contracts, the RBAC hierarchy (`mayActOn`/`mayGrant`), the typed error codes, schema tables, and the swappable `BillingAdapter`/`RateLimiter` seams. Comments only — no behavior, signature, or formatting change; all 204 tests still pass.
    • **Generated public API reference**: `docs/api/` — a TypeDoc-generated reference for the full `src/lib/server` surface (auth, rbac, db, services, billing, http, ratelimit). Ships with the kit; regenerated from source so it cannot drift.
    • **Docs gate (CI + release pipeline)**: `npm run docs:api:check` in CI and a `gen-api-docs` check in the release pipeline now fail if any exported symbol goes undocumented or if `docs/api` falls out of sync with the source.
  13. v0.2.1

    7changes

    Added

    • **Audit suite**: `tests/audit.test.ts` — append-only writer (no update/delete path), metadata round-trip, null handling, newest-first pagination, history surviving member removal
    • **Billing suite**: `tests/billing.test.ts` — per-org seat counting, `assertSeatAvailable` (at-limit + no-subscription), mock adapter contract + `MOCK_PLAN_SEATS` env override
    • **Auth edge cases**: expired sessions rejected at read time; display-name fallback to email prefix
    • **Org/invite edges**: unique URL-safe slug generation, member-only org reads, multi-owner transfer guard, invite default TTL + recipient-email normalization, raw token never audited, member-revoke forbidden
    • **Documentation**: `docs/testing.md` — testing philosophy, suite layout, patterns, gotchas

    Changed

    • Test suite grown from 52 to **204 tests across 9 suites**
    • README/AGENTS/CLAUDE test counts and suite tables updated
  14. v0.2.0

    5changes

    Added

    • **Rate limiting**: Sliding-window failed-attempt limiter on login/signup - `RateLimiter` seam (`src/lib/server/ratelimit.ts`) - Configurable via `AUTH_FAILED_ATTEMPTS` (default: 5) and `AUTH_WINDOW_MS` (default: 900000ms/15min) - 429 response with approximate retry window - Pre-check before scrypt hashing (attacker pays the cost, not you) - Verified over HTTP: 400→400→429→recovery
    • 8 new unit tests for sliding-window semantics including eviction cap

    Fixed

    • **Redirect swallowing bug**: Form actions that redirect from inside try/catch now propagate SvelteKit's redirect instead of returning a false 500 after side effects have committed - Fixed at `errorToFail()` choke point - 4 regression tests added in `tests/http.test.ts` - Full flow proven over HTTP end-to-end

    Changed

    • Test count increased from 40 to 52
    • Rate limiter documented in `docs/architecture.md` with multi-instance guidance
  15. v0.1.0

    17changes

    Added

    • **Authentication**: Email+password with scrypt hashing, DB-backed revocable sessions, hashed session tokens
    • **Organizations**: Create, slug, owner bootstrap
    • **Invites**: Single-use hashed tokens, 7-day expiry, revoke, atomic claim
    • **RBAC**: `owner > admin > member` hierarchy with capability matrix enforced server-side on every action
    • **Billing**: `BillingAdapter` interface + deterministic `MockBillingAdapter` (seat limits enforced at join time)
    • **Audit log**: Append-only by construction (no UPDATE/DELETE path exists)
    • **Testing**: 40 passing tests covering auth, matrix, hierarchy, invite lifecycle, seat limits
    • **CI**: GitHub Actions workflow (install → test → check → build)
    • **Documentation**: Architecture, RBAC, billing, license docs
    • **Screenshots**: S1–S8 captured from running app
    • **Demo GIFs**: G1 (invite flow), G2 (RBAC denial)

    Design Principles

    • Server-side enforcement everywhere (UI hides controls, but every load/action re-checks)
    • Services are framework-free (import nothing from `@sveltejs/kit`)
    • Every mutating service call re-derives authority
    • Errors carry machine codes (`AuthError`, `RbacError`, etc.)
    • One error mapper (`errorToFail()`)
    • No ORM lock-in at service boundaries

Multi-tenant SvelteKit Starter product page · GitHub releases

SvelteKit + Supabase Starter

15 releases · 62 changes · latest v0.4.2 on21 Oct 2026

  1. v0.4.2

    1change

    Fixed

    • **A fresh install would have returned `42501 permission denied` on every table from 2026-10-30.** Supabase is ending the automatic Data API grants for tables newly created in the `public` schema — the new default applies to **existing** projects as well as new ones on that date — so a table is reachable by PostgREST only if an explicit `GRANT` exists. Without one, every request for the table fails with `42501 permission denied for table <name>`, even though the table exists, its RLS policies are correct and the keys are valid. This kit creates its tables only from `supabase/migrations/`, which `supabase db push`, `supabase db reset` and every preview branch all run, so all three install paths were affected. `0001_initial_schema.sql` now grants `organizations`, `memberships`, `invites` and `audit_log`, and `0002_users_profile.sql` grants `users`, each in the file that creates the table: | Role | Privileges | | --------------- | -------------------------------- | | `anon` | `SELECT` | | `authenticated` | `SELECT, INSERT, UPDATE, DELETE` | | `service_role` | `SELECT, INSERT, UPDATE, DELETE` | The matrix mirrors Supabase's documented default for new tables, so behaviour after the change matches behaviour before it — no application code changed. `0002` creates `public.users` with `IF NOT EXISTS`, and `GRANT` is idempotent, so the same statements are correct both on a clean install and on a project where the table already existed. `service_role` is included deliberately: bypassing RLS is not bypassing the privilege system, and this kit's admin writes run as `service_role`. `audit_log` remains append-only — that guarantee is the absence of an UPDATE/DELETE _policy_, not the absence of a privilege, so granting the vendor matrix does not weaken it. **Tables created before 2026-10-30 keep the grants they already hold.** This affects newly created tables only, which is why the grants belong to the migration and not to a one-time dashboard step. Existing installations need no action. `tests/schema-contract.test.ts` now enforces the grants statically, in the same test that checks the queried relations exist: it parses every migration and fails naming the file, table, role and missing privilege if a created table is not granted in that same file, and fails if a write privilege is ever granted to `anon`. Deleting any single `GRANT` line turns the suite red. Documented in README.md ("Supabase setup", "Schema verification"), AGENTS.md ("Database (Supabase)") and `docs/deployment.md` (§2).
  2. v0.4.1

    0changes
  3. v0.4.0

    16changes

    Added

    • Audit export hardening: `MAX_EXPORT_ROWS` (10 000) and `clampExportPage()` (floors `limit` at 1, caps it at the ceiling, floors `offset` at 0), an `offset` parameter on `listAuditEntries`/`exportAuditCsv`/`exportAuditJson`, and a spreadsheet-formula guard that prefixes a leading `=`, `+`, `-`, `@`, TAB or CR with an apostrophe. The guard runs on the **raw** value, before CSV quoting: quoting first leaves the field starting with a double quote, which matches no formula trigger, so the canonical `=HYPERLINK("…")` payload — which necessarily contains a quote — would pass through un-neutralised. A test pins that exact payload and fails if the two steps are swapped. In this schema the guard is defence-in-depth rather than a live hole: `action`/`targetType`/`targetId` are app-written constants and `actorUserId` is a UUID, so no cell can begin with `=` today. It is there for a buyer who adds a free-form field.
    • Audit-log CSV and JSON export at `GET /app/org/[id]/audit/export` — 401 unauthenticated, 403 non-privileged, 404 non-member, with `Content-Disposition`, `Cache-Control: no-store` and an `audit.exported` entry recording format and row count (never the payload).
    • `countAudit()`, `exportAuditCsv()` and `exportAuditJson()` in `src/lib/server/audit.ts`. CSV escaping round-trips commas, quotes and newlines; values are left verbatim rather than prefixed, because prefixing mutates them and the export would no longer mirror the database.
    • `src/lib/server/billing/service.ts` and `src/lib/server/billing/index.ts`.
    • `.range(from, to)` support in the in-memory fake Supabase client, so paging is exercised rather than assumed.
    • SvelteKit virtual-module stubs (`tests/helpers/stubs/`) and aliases in `vitest.config.ts`, so tests can import real route modules and assert real HTTP statuses instead of re-implementing route logic.

    Fixed

    • **The "single seat-enforcement point" was two.** `billing/service.ts::assertSeatAvailable` was introduced as the one place seat limits are enforced, but it delegated the decision to `BillingAdapter.assertSeatAvailable`, so the policy still lived in two places — and the two had drifted: the adapter compared its constructor's `seatLimit` against a _caller-supplied_ member count while the service compared `state.planSeats` against the _real_ membership count, and only the service treated `status: 'none'` as blocking. `assertSeatAvailable` is removed from the `BillingAdapter` interface and from `MockBillingAdapter`; the adapter is now a pure reporting seam (`getSubscriptionState`, `getPlan`, `getSeatCount`) and `billing/service.ts` owns the whole decision. A reporting seam cannot drift from a policy. A real merchant-of-record adapter is still consulted — `getSubscriptionState()` is where it calls the provider — so a provider outage throws from there and propagates unchanged, and because the check runs before the invite claim, a failed check still never burns an invite. `tests/billing.test.ts`, `tests/billing-edge-cases.test.ts`, `tests/smoke.test.ts` and `tests/smoke-extended.test.ts` were converted to assert what the adapter _reports_; the seat decisions they used to make are covered in `tests/billing-service.test.ts` (2-of-3 allowed, 3-of-3 blocked, none/past_due/canceled → `subscription_required`, provider error propagates).
    • **Org route actions returned HTTP 200 for RBAC violations.** Every action in `src/routes/app/org/[id]/+page.server.ts` mapped a caught domain error through a local `toFail(e)` that returned a bare `{ error: e.message }`. A SvelteKit form action returning a plain object is **200 OK**, so a member POSTing a forged "promote the owner" request — the exact scenario the README claimed answers 403 — received `200 OK` with an error string in the body. The write was always blocked server-side, but the response said nothing about it: no client, proxy, monitoring dashboard or security test could distinguish a denial from a success. Added `src/lib/server/http.ts::errorToFail` (`forbidden`, `hierarchy_violation`, `no_membership` → 403; `not_found` → 404; other known domain errors → 400; unknown → generic 500) and replaced all six call sites. `errorToFail` re-throws anything carrying `location`, so SvelteKit's `redirect()` is never swallowed — otherwise a successful action whose side effects had already committed would be reported as a 500. `tests/http.test.ts` pins the mapping; `tests/http-status.test.ts` drives the real route actions and asserts `result.status`.
    • **`listMembers()` joined a table the schema never created.** It embeds `users!inner(id, email, name)`, but `0001_initial_schema.sql` creates only `organizations`, `memberships`, `invites` and `audit_log`; real users live in `auth.users`. On a clean-room install the embed could not resolve, so `listMembers()` threw and **the whole org dashboard failed to load** — the page that lists members, seats and invite controls. The test suite never caught it because `tests/helpers/fake-supabase.ts` provisions a `users` table the deployed schema never had: the fake was more permissive than reality. Added `supabase/migrations/0002_users_profile.sql` (additive, idempotent) creating the `public.users` profile mirror, syncing it from `auth.users` by trigger, and adding the `memberships.user_id → users(id)` foreign key PostgREST needs to plan the embed. Added `tests/schema-contract.test.ts`, which cross-checks every relation the server queries against the migrations — including the foreign key, since a table without a relationship still breaks the embed with a different error and a table-existence-only check would pass while the feature stayed broken.
    • **Unbounded slug-collision retry in `createOrg`.** A unique-constraint collision on the org slug recursed into `createOrg` with no attempt budget, so a slug space that could not produce a free value turned one request into an endless loop. Retries are now capped at 5 and an exhausted budget throws the typed `OrgError('slug_taken')` — a code that was declared and documented in the type union but previously had no code path that could produce it.
    • **`billing/index.ts` re-reads `MOCK_PLAN_SEATS` per call.** A module-level singleton would freeze whatever the environment held at import time, silently breaking the documented env override.

    Changed

    • **Tenant-scoped reads now run through the RLS-enforcing client.** Every call site previously used `createServerClient()` (service role, bypasses RLS); `createUserClient()` had **zero** call sites and `locals.accessToken`, set in `hooks.server.ts`, was never read. The RLS policies shipped but never executed at runtime. Listing orgs, loading an org, listing members, listing pending invites, the audit page, the audit count and the audit export now use `tenant.ts::requireTenantClient(locals.accessToken)`. Writes deliberately stay on the service role, for reasons documented in `tenant.ts`: `audit_insert` is `WITH CHECK (true)` (a user-scoped write would let any authenticated user forge audit rows in any org); `memberships_update`/`memberships_delete` require `owner` while `MATRIX` grants `members.role.set`/`members.remove` to `admin`; and `peekInvite` runs for signed-out visitors. `tests/tenant-client.test.ts` pins the split.
    • **Seat enforcement is single-sourced.** Seat counting and the limit decision lived inline in `services/invites.ts` alongside the adapter's own guard. Added `src/lib/server/billing/service.ts` (`countActiveSeats`, `assertSeatAvailable`) plus a `billing/index.ts` barrel; `acceptInvite` now calls the service, which also covers the `none` subscription state the mock adapter never reports.
    • Docs reconciled with measured behaviour (Rule 7): the README's "real HTTP 403" claim now states what is enforced and how it is proven, and the RLS sections state the read/write split instead of implying blanket database-level RLS.

    Known issues

    • **A clean-room install of the previously published source is broken on the org dashboard**, because it ships `0001` without the `users` relation that `listMembers()` embeds. Applying `0002_users_profile.sql` fixes it. Verified against a real Postgres 16: both migrations apply cleanly, `0002` is idempotent on re-application, the trigger populates and re-syncs `public.users`, and the new RLS policies were exercised with a non-owner role (a user saw themselves and same-org peers, not a user in another org; a self-update was allowed while a change of `id` was rejected by the `WITH CHECK`). Not verified against a live Supabase project: there is no Supabase Auth issuing JWTs in that harness, so PostgREST-level embed behaviour is unproven.
    • `memberships_update` / `memberships_delete` require `owner` while `MATRIX` grants `members.role.set` / `members.remove` to `admin`. Documented rather than resolved — resolving it is a product decision, and closing it by weakening a policy was out of scope.
  4. v0.3.4

    0changes
  5. v0.3.3

    0changes
  6. v0.3.2

    1change

    Fixed

    • README version claim. The `**Version**` line, the version badge and the machine-readable `version:` field all still read `0.3.1`'s predecessor after the previous release, so the published proof repo stated a version that did not match its own release tag. All three forms now track `package.json`.
  7. v0.3.1

    2changes

    Added

    • `src/lib/server/mcp/` — an MCP server exposing the service layer to a coding agent over stdio. Four read tools: `list_orgs`, `list_members`, `list_pending_invites` and `list_audit_log`, all backed by the same services the app uses, all taking a `SupabaseClient`. - **Read-only by design.** These tools are called by a language model, and a model can be steered into calling them by text it has just read — a prompt injection hidden in an audit-log string could otherwise reach a mutating call. Reads cannot destroy state, so reads are the first surface exposed. - **No SDK.** MCP over stdio is JSON-RPC 2.0. Implementing it keeps this feature dependency-free and shows the protocol rather than hiding it. - **Both protocol shapes.** The 2026-07-28 revision dropped the mandatory `initialize` handshake for a stateless core carrying `protocolVersion` in `_meta`; older clients still open with `initialize`. The server accepts both and names the versions it implements when asked for one it does not. - **Tool failures are results, not protocol errors** (`isError: true` with a readable message), so a model can correct its call and retry. - `npm run mcp` starts it. Credentials come from whatever the app already uses — no new environment variables. - Lives inside `src/lib/server/`, so it counts toward the coverage threshold. The module is at 100% statement, branch, function and line coverage; `cli.ts` is excluded with the reason recorded in `vitest.config.ts`, and the transport is covered in-process. - The stdio transport is serialised deliberately: replies go out in the order requests were read, so a client never gets two responses swapped.
    • `tests/mcp.test.ts` — protocol, tool and transport coverage. Test count 359 → 390.
  8. v0.3.0

    6changes

    Added

    • **Shipped application UI** (`src/routes`): signup, sign-in, organizations, org workspace (members, invites, role changes, ownership transfer, leave), invite-link accept flow, and an audit-log view — all themed, wired to the existing services, and enforcing the RBAC matrix server-side on every action.
    • **Session cookie auth** (`src/hooks.server.ts` + `createAnonClient`/`signIn`/`setSessionCookie`/`clearSessionCookie` in `src/lib/server/supabase/client.ts`): the `session` cookie holds the Supabase refresh token; `hooks.server.ts` resolves `locals.user`/`locals.accessToken` per request with automatic rotation; sign-out revokes sessions server-side ("log out everywhere", `destroyAllSessions`).
    • **Audit reader** — `listAuditEntries` in `src/lib/server/audit.ts`: companion to the append-only writer, newest-first with a limit (used by the audit-log route). 4 new tests.
    • `/api/health` route: liveness + Supabase reachability check for deployments.

    Fixed

    • Made `npm run check` green for the first time (kit previously failed svelte-check): orgs join-row typing, `listUserSessions` typing gap on the installed Supabase client, `vite.config.ts` `defineConfig` import, `ThrowingAdapter`/`GenericThrowingAdapter` override signatures in `tests/billing-edge-cases.test.ts` (now 314 tests total, ≥ 95 % coverage maintained).

    Docs

    • Screenshot gallery refreshed — every capture re-taken from the themed live demo (org dashboard, landing, RBAC denial GIF, the 314-test run).
  9. v0.2.5

    1change

    Docs

    • Suite tables completed to match the shipped 12 test files: `AGENTS.md`, `docs/testing.md`, and README status now list every suite (added `auth`, `billing-edge-cases`, `rbac-boundary`, `service-integration`, `edge-cases`, `smoke-extended`) with per-file coverage notes (310 tests total).
  10. v0.2.4

    1change

    Tests

    • 310 tests (was 208): +102 new tests covering e2e billing flows (seat upgrades/downgrades, adapter failures, boundary conditions), RBAC boundary cases (cross-org isolation, rank escalation, hierarchy enforcement), full multi-step service-integration flows, and auth edge cases.
  11. v0.2.3

    4changes

    Added

    • **Docker support**: `Dockerfile` (multi-stage Node 24 Alpine build) and `docker-compose.yml` with Supabase credentials for container deployments.
    • **Deployment docs**: `docs/deployment.md` — comprehensive deployment guide covering Docker, VPS, Cloudflare Pages, Vercel, and Fly.io.
    • **Usage-based billing guide**: `docs/ai-billing.md` — adapter pattern for AI token usage tracking, metered billing, and usage-based pricing.
    • **`.dockerignore`**: Build context exclusions for cleaner Docker builds.
  12. v0.2.2

    6changes

    Added

    • **Auth module** (`src/lib/server/auth.ts`): `AuthError`, `checkPasswordStrength()`, `createUser()`, `listSessions()`, `destroyAllSessions()`, `normalizeEmail()`, `MIN_PASSWORD_LENGTH`, `SESSION_COOKIE`.
    • **Password strength validation**: enforces uppercase, lowercase, and digit requirements (not just minimum length). Returns `{ ok, reasons }` for UI feedback.
    • **Billing guard**: `assertSeatAvailable()` now blocks `past_due` and `canceled` subscriptions from adding new seats.
    • **Billing adapter types**: `SubscriptionStatus`, `SubscriptionState`, `getSubscriptionState()` on the `BillingAdapter` interface.

    Fixed

    • **`createUser` error handling**: catch block now only catches Postgres unique constraint violations (`23505`) instead of silently swallowing all DB errors as `email_taken`.

    Tests

    • 208 tests (was 198): +10 new tests covering billing guard (past_due/canceled/active/trialing) and audit listing (offset, ordering).
  13. v0.2.1

    3changes

    Added

    • **Source documentation (TSDoc)**: every exported symbol on the public API surface now carries a doc comment — parameter/return/throws contracts, the RBAC hierarchy (`mayActOn`/`mayGrant`), typed error codes, and the service-role vs user-scoped (`RLS`) Supabase client split. Comments only — no behavior, signature, or formatting change; all 198 tests still pass.
    • **Generated public API reference**: `docs/api/` — a TypeDoc-generated reference for the full `src/lib/server` surface (rbac, audit, services, billing, supabase/client). Ships with the kit; regenerated from source so it cannot drift.
    • **Docs gate (CI + release pipeline)**: `npm run docs:api:check` in CI and a `gen-api-docs` check in the release pipeline now fail if any exported symbol goes undocumented or if `docs/api` falls out of sync with the source.
  14. v0.2.0

    5changes

    Added

    • **Service-level test suite** — 51 new tests across `tests/orgs-members.test.ts`, `tests/invites-seats.test.ts`, `tests/billing.test.ts`, `tests/audit.test.ts`, backed by an in-memory fake Supabase client (`tests/helpers/fake-supabase.ts`). No database or network needed; suite 24 → **198 tests**.
    • **Documentation**: `docs/architecture.md`, `docs/rbac.md`, `docs/billing.md`, `docs/testing.md`

    Fixed

    • **Seat limits now enforced at invite acceptance** — `acceptInvite` previously documented seat enforcement ("join time") but never called the billing adapter. It now counts the org's memberships and runs `billing.assertSeatAvailable(orgId, count)` before the single-use claim, so a full org never burns an invite it cannot honor.
    • **`createBillingAdapter` guards invalid `MOCK_PLAN_SEATS`** — a non-numeric value previously produced a `NaN` seat limit that silently allowed unlimited joins; it now falls back to the default (3).

    Changed

    • README/AGENTS/CLAUDE testing sections updated to the real suite layout and count
  15. v0.1.0

    16changes

    Added

    • **Authentication**: Supabase Auth (email+password, magic links, OAuth)
    • **Organizations**: Create, slug, owner bootstrap with Supabase RLS for tenant isolation
    • **Invites**: Single-use hashed tokens, 7-day expiry, revoke, atomic claim
    • **RBAC**: `owner > admin > member` hierarchy with capability matrix enforced server-side on every action
    • **Billing**: `BillingAdapter` interface + deterministic `MockBillingAdapter` (seat limits enforced at join time)
    • **Audit log**: Append-only by construction (no UPDATE/DELETE path exists)
    • **RLS policies**: Defense-in-depth tenant isolation at the database level
    • **Testing**: Comprehensive test suite covering auth, RBAC, invites, billing
    • **CI**: GitHub Actions workflow (install → test → check → build)
    • **Documentation**: Architecture, RBAC, billing, versioning docs

    Design Principles

    • Server-side enforcement everywhere (UI hides controls, but every load/action re-checks)
    • Services are framework-free (import nothing from `@sveltejs/kit`)
    • Every mutating service call re-derives authority
    • Errors carry machine codes (`AuthError`, `RbacError`, etc.)
    • One error mapper (`errorToFail()`)
    • RLS provides defense-in-depth alongside application-level RBAC

SvelteKit + Supabase Starter product page · GitHub releases

SvelteKit + Postgres Starter

16 releases · 83 changes · latest v0.1.16 on16 Oct 2026

  1. v0.1.16

    3changes

    Added

    • **Regression coverage for the instruction itself** in `tests/rls-isolation.test.ts` (3 tests). The existing isolation suite could not catch this: it connects as a deliberate non-superuser non-owner role, which is right for testing the policies and is exactly why nothing ever exercised the owner path. The new checks assert that this file `FORCE`s RLS, that its apply instructions ask for `BYPASSRLS` and never rely on the owner, and that the three helpers are owned by a role exempt from RLS. Verified to fail on the defect before being verified to pass.

    Fixed

    • **🔴 `HOW TO APPLY` told buyers to apply the RLS file as the table owner, which could not work.** The 0.1.15 fix made the three `SECURITY DEFINER` helpers correct, and correctly explained that a `SECURITY DEFINER` function only breaks the policy recursion if the role owning it is exempt from RLS. But it named the wrong exempt role as the remedy: this same file runs `ALTER TABLE ... FORCE ROW LEVEL SECURITY` on all four tables, and `FORCE` removes precisely the exemption a table owner holds by ownership. Following the instruction as a plain table owner therefore reproduced the very failure the file warns about — `ERROR: stack depth limit exceeded` on the first request that sets the GUC. Measured on PostgreSQL 16 to confirm rather than assume — same table, same owner, one row, a `USING (false)` policy, owner with `rolsuper = false` and `rolbypassrls = false`: | State | Owner `SELECT count(*)` | |---|---:| | `ENABLE ROW LEVEL SECURITY` only | **1** — owner bypasses RLS | | `ENABLE` + `FORCE` | **0** — owner is subject to the policy | `HOW TO APPLY` now asks for a role with `BYPASSRLS` (or a superuser), the only exemptions that survive `FORCE`, and says explicitly that ownership is not enough. `docs/deployment.md` is corrected to match. **This corrects an instruction introduced in 0.1.15; the 0.1.15 entry above is left as the historical record of what that release said.**

    Changed

    • Test count 451 -> 454.
  2. v0.1.15

    6changes

    Added

    • **`tests/rls-isolation.test.ts` — the two-account isolation check, 11 tests.** RLS is opt-in, so nothing in the kit exercised it at runtime; the documentation was the only thing a buyer had to trust. This suite runs the shipped `rls/0010_rls_policies.sql` against a live Postgres as a genuine non-superuser, non-owner role (`TEST_DATABASE_URL` connects as a superuser, which bypasses RLS unconditionally and would have made every check vacuous). It asserts: the check role is not a superuser and owns nothing; isolation holds across the **open-transaction window** with account A's transaction deliberately held open; A's data is invisible to B across all four gated tables; the policies are fail-closed; and — the part that makes the rest falsifiable — **the session-scoped form does leak** on a reused pooled backend while the transaction-scoped form does not, on the same pool shape, in the same order. Without that last pair, a check that set nothing at all would also "pass".
    • Test suite: **440 → 451 tests across 27 files**, against the real Postgres test database. Coverage unchanged at 100% statements / 99.26% branches / 100% functions / 100% lines.

    Fixed

    • **🔴 The RLS policies could not execute. Infinite policy recursion.** `memberships_select` is gated by `app_is_org_member(org_id)`, and `app_is_org_member` reads the `memberships` table. As shipped, the helper was a plain `SECURITY INVOKER` function, so evaluating the policy re-entered the policy. The first request that set the GUC failed with: ``` ERROR: stack depth limit exceeded ``` Every tenant-scoped read errored out. The irony is exact: the feature that was meant to be defense-in-depth was the thing breaking the reads it was meant to protect — and it only ever surfaced for a buyer who actually applied the file, because no shipped code path, test, or demo in this kit enables RLS. Fixed by making the three helpers that read `memberships` (`app_is_org_member`, `app_org_role`, `app_has_min_role`) `SECURITY DEFINER` with `SET search_path = public, pg_temp`, so they run as an owner exempt from the policy being evaluated. The pinned `search_path` is standard companion hardening, not decoration: without it a `SECURITY DEFINER` function resolves unqualified names through the caller's `search_path`, which is itself a privilege-escalation path. `app_current_user()` reads only the GUC and so is deliberately left invoker-scoped. **`HOW TO APPLY` is reordered because of this.** The file must now be applied **as the table owner** (or a role with `BYPASSRLS`), *not* through the low-privilege app role. A `SECURITY DEFINER` function only escapes the recursion if the role that owns it is exempt from RLS; applying the file as the app role leaves the helpers owned by a role that is subject to the very policies they evaluate, and `stack depth limit exceeded` returns.
    • **🔴 The documented way to set the GUC was SQL Postgres rejects.** All three copies of the wiring example shipped `SET LOCAL app.current_user_id = ${...}`. `SET` and `SET LOCAL` are utility commands, not functions, so they accept **no bind parameters** — a driver binding the user id into them gets a syntax error, not a session. The parameterized form is `set_config('app.current_user_id', $1, true)`. Corrected in `rls/0010_rls_policies.sql`, `docs/architecture.md` and `docs/deployment.md`.
    • **🔴 The docs omitted the pooling hazard that makes the scope argument load-bearing.** A session-scoped value survives a connection going back to the pool, so the next request drawing that backend can inherit the *previous* tenant's identity — a cross-tenant leak that is invisible with one connection and rare under light load. `docs/deployment.md` now documents the three rules that close it (transaction scoping, `RESET ALL` / `DISCARD ALL` on release, never session-scoping under a pool) and the `SET LOCAL`-outside-a-transaction no-op that surfaces as "the list is mysteriously empty". `tests/pool-options.test.ts` already documented this exact hazard for `SET search_path` — a session setting that leaks across a pool. The awareness existed; it had never reached the RLS docs, which is where it mattered.

    Not changed

    • **The SvelteKit + Supabase sibling kit is unaffected, and was checked rather than assumed.** Its RLS is entirely `auth.uid()` — 13 policy references, zero `current_setting` or `set_config` — so no custom GUC is ever set and PostgREST owns the per-request scope. The pooling hazard above does not apply to it.
  3. v0.1.14

    22changes

    Added

    • **Audit-log export** — `countAudit()`, `exportAuditCsv()` and `exportAuditJson()` in `src/lib/server/db/audit.ts`, closing the gap with the SQLite sibling kit. All three accept `Db | Tx` so an export can join a caller's transaction. CSV values containing a comma, quote or newline round-trip byte-for-byte through any RFC 4180 parser, and any value a spreadsheet would evaluate as a **formula** (leading `=`, `+`, `-`, `@`, tab or CR) is prefixed with an apostrophe first — invite metadata carries an inviter-supplied email address, so that content is attacker-influenceable. `limit`/`offset` are clamped (`clampExportPage`, `MAX_EXPORT_ROWS = 10_000`) so a request parameter cannot ask Postgres for the whole append-only log. JSON export parses metadata back into real objects and degrades an unparseable row to its raw string rather than aborting the export.
    • **`POST /app/org/[id]/audit/export`** — the export as a user-reachable action on the org's Audit tab, admin/owner only via `audit.view`. It is **POST, not GET**, because the export writes an `audit.exported` row recording who pulled the log and when — an export missing from the log is exactly the gap this closes. POST keeps it behind SvelteKit's `csrf.checkOrigin`; the response is `no-store`.
    • **`/app/sessions`** — see every active session for your account, revoke one, or sign out everywhere else. The acting user id is derived from the httpOnly session cookie server-side and is never read from the form; the `DELETE` is additionally scoped by `sessions.user_id`, so a crafted POST reaches no other account. Revocations write `session.revoked` / `session.revoked_all` audit rows (with a null `org_id`, since they belong to the account, not to one organization).
    • `cleanupExpiredSessions(db)` — prunes expired session rows and returns the count. `getUserBySessionToken()` treats an expired session as unauthenticated but leaves the row, so without a sweeper the `sessions` table grows without bound.
    • `poolOptionsFromEnv()` and the `idleTimeout`/`connectTimeout` options on `openDb()`.

    Fixed

    • **`createOrg` is now atomic.** It wrote three rows — the organization, the owner membership and the audit entry — with no transaction around them. A failure between any two leaves an organization that exists but belongs to nobody: no membership means `getOrgForUser()` returns `null` for every user, so it is invisible in the UI, undeletable through the app, and — because the audit log is append-only — uncollectable forever. No rare fault is needed to hit this; a process that ends between two `await`s is enough. All three writes now run in one `db.transaction`. postgres.js is async-native, so an async callback is the normal shape and the awaits genuinely stay inside the transaction; the `better-sqlite3` caveat that an `await` inside the callback can let the driver COMMIT early does not apply here. `tests/org-atomicity.test.ts` induces a real failure between the statements — a syntactically valid, non-existent `userId`, which commits the org row and then fails the membership FK — and asserts no orphan row, no membership and no audit entry survive. Reverting the transaction makes it fail. - The slug-collision retry moved to `ON CONFLICT DO NOTHING`, because a try/catch retry loop **cannot** work inside a transaction: Postgres aborts the whole transaction on any failed statement, so every statement after the first unique violation would fail with "current transaction is aborted". A collision now returns zero rows instead of raising, which keeps the transaction usable.
    • **The test suite no longer corrupts itself when two suites share its database.** `createTestDb()` truncates six tracked tables before every test, and `fileParallelism: false` serialises files only _within one vitest process_ — it does nothing about a second one. A second `npm test` (another terminal, a CI matrix shard, two checkouts on a laptop) interleaved its truncations with the first, and the result read exactly like a broken schema. Measured on this kit before the fix: two concurrent runs produced 28 failures and 19 `memberships_user_id_users_id_fk` violations, with nothing in the output mentioning concurrency. A real FK defect is deterministic; a pile of them whose count moves with interleaving is not. `tests/helpers/test-db-guard.ts` now takes a **session-scoped** advisory lock (`TEST_DB_LOCK_KEY`) on a **reserved** connection for the whole run, acquired in `globalSetup` and released in teardown. Reserved because `pg_advisory_lock` belongs to the physical session, not to the JS client: taken on a pooled connection and returned, it either leaks and blocks every later run forever, or is dropped mid-suite when the pool hands that session to someone else. A try-lock rather than a blocking lock, because the blocking form would park the second run silently for the length of the first and look like a hung suite. A concurrent run now aborts **before any test is collected**, with one sentence: > another test suite is using this database — wait for it to finish, or point > TEST_DATABASE_URL somewhere else. > `TEST_DB_LOCK_KEY` is deliberately neither `MIGRATION_LOCK_KEY` nor > `MIGRATION_LOCK_KEY ± 1`: reusing the migration key would make a test run and a > `db:migrate` exclude each other with an error that names neither, and the ±1 > case collides with `tests/migration-lock-control.test.ts`, which takes > `MIGRATION_LOCK_KEY + 1` as its negative control — that suite blocks on the key > and times out. `tests/test-db-guard.test.ts` pins both constraints and asserts > the lock is genuinely held, by a session other than the test's own.

    Fixed

    • `PG_IDLE_TIMEOUT` and `PG_CONNECT_TIMEOUT` are documented in `README.md`, `AGENTS.md`, `.env.example` and `docs/deployment.md` but were **never read by any line of code** — the driver was constructed with `max` alone. An operator tuning them for a constrained instance watched the settings sit in `.env` doing nothing. Both now reach postgres.js.
    • `openDb(url, { schema })` documents that it requires `max: 1` — `SET search_path` is a session setting, and on a pool of N connections it lands on whichever connection served it while later queries may draw a different one — but nothing enforced it, so a schema-scoped handle silently leaked into `public`. The pool is now pinned to 1 whenever a schema is requested.
    • `createOrg()` used a 3-byte (24-bit) random slug suffix with no collision handling, so a clash on the `organizations_slug_uq` unique index escaped as a raw driver error with no machine code — `errorToFail()` cannot classify it, so a retryable naming collision surfaced to the user as an HTTP 500. The suffix is now 4 bytes (32 bits) and the insert is retried on SQLSTATE 23505 only, up to five times, falling back to a typed `OrgError('not_found')`. Every other error propagates on the first attempt: a retry loop that swallowed a connection failure would turn a real outage into a mysterious hang.

    Changed

    • Test suite: **422 → 440 tests across 26 files**, against the real Postgres test container.
    • `npm test` now requires exclusive use of `TEST_DATABASE_URL`; a concurrent run fails fast rather than producing misleading failures. Documented in `README.md`, `AGENTS.md` and `docs/testing.md`.

    Changed

    • Test suite: **325 → 422 tests across 24 files**, against the real Postgres test container.
    • Coverage: **100% statements / 99.26% branches / 100% functions / 100% lines** (was 99.44 / 98.34 / 100 / 99.69). The ≥ 95% floor is unchanged and still enforced in `vitest.config.ts`. The two branches still open are the `org?.name ?? ''` fallbacks in `peekInvite()`/`acceptInvite()`: an invite cannot outlive its organization (the FK is `ON DELETE CASCADE`), so they are reachable only under the opt-in RLS policies, where `organizations` SELECT is gated by membership and an anonymous invitee legitimately sees no row.
    • `npm test` now takes roughly 3–4 minutes instead of ~1: the suite runs real scrypt at the OWASP work factor (128 MiB and ~0.55 s per hash) as well as real Postgres. Documented in `README.md` and `docs/testing.md` rather than papered over.
    • The slug suffix is 8 hex characters, not 6 — `tests/orgs-members.test.ts` pinned the old width and is updated with it.

    Security

    • **scrypt work factor raised to N = 2^17, r = 8, p = 1.** The previous N = 2^14, p = 1 was described in the source as "OWASP-recommended" while being a pairing OWASP's Password Storage Cheat Sheet does not list — its five configurations of "similar minimal level of defense" are N=2^17/p=1, 2^16/p=2, 2^15/p=3, 2^14/p=5 and 2^13/p=10. The comment now names a configuration the standard actually specifies. Because `format()` encodes the parameters inside the hash and `verifyPassword()` reads them back out, **existing password hashes keep working** and are upgraded at each user's next sign-in; `tests/auth.test.ts` pins that path. `maxmem` is now set explicitly (256 MiB): Node defaults it to 32 MiB, and at N = 2^17 scrypt needs 128 MiB, so without it every login would fail with `error:030000AC`. The parameters are deliberately not env-configurable — a work factor is not a performance dial.
    • **The single-owner invariant is now enforced under concurrency.** `leaveOrg()` and `transferOwnership()` read the owner count and then write in two separate round trips. Two owners of the same org leaving at the same instant both read "another owner exists", both proceed, and the organization is left with **no owner** — a state no RBAC path can recover from, because every path keys off an owner. On SQLite the whole process is serialized behind one mutex so the window does not exist; on a Postgres pool it is an ordinary race. Both now run inside a transaction whose first statement is `SELECT … FOR UPDATE` on the organization row — the same gate `acceptInvite()` already used. `transferOwnership()` additionally re-reads the actor's live role inside that transaction instead of trusting the caller-supplied `actorRole`: without it, a second transfer launched in the same instant still passes `ownership.transfer` on a stale role and manufactures the two-owner inconsistency the function exists to reject. `tests/members-concurrency.test.ts` starts two genuinely simultaneous operations and asserts on the committed result.
    • `listSessions()` and `listMembers()` now have a deterministic `ORDER BY`. Postgres makes no ordering guarantee without one, so both were free to reorder themselves between two identical requests. `createdAtMs` alone is not sufficient — two members who accept invites in the same millisecond would still swap — so the membership id breaks the tie. The missing `ORDER BY` on `listSessions` was also a divergence from the SQLite sibling, where rowid order happened to hide it.

    Documentation

    • Reconciled with measured state: test counts, coverage figures, the new export and session surfaces, the scrypt parameters, and the fact that `PG_IDLE_TIMEOUT` / `PG_CONNECT_TIMEOUT` are now actually wired.
    • `README.md` now states explicitly that **no migration in `drizzle/` applies `rls/0010_rls_policies.sql`** — until a buyer runs the documented `psql` command the app is protected by the service layer alone, which is a supported and tested configuration. The RLS claim was already consistently "opt-in" everywhere else (`README.md`, `AGENTS.md`, `docs/architecture.md`, `docs/deployment.md`); this closes the last ambiguity rather than correcting an overstatement.
    • The screenshot caption claiming a "current 325-test mark" is now labelled as a historical capture.
  4. v0.1.13

    0changes
  5. v0.1.12

    0changes
  6. v0.1.11

    1change

    Fixed

    • README version claim. The `**Version**` line, the version badge and the machine-readable `version:` field all still read `0.1.10`'s predecessor after the previous release, so the published proof repo stated a version that did not match its own release tag. All three forms now track `package.json`.
  7. v0.1.10

    2changes

    Added

    • `src/lib/server/mcp/` — an MCP server exposing the service layer to a coding agent over stdio. Four read tools: `list_orgs`, `list_members`, `list_pending_invites` and `list_audit_log`, all backed by the same services the app uses, all taking a `Db` (postgres.js + Drizzle). - **Read-only by design.** These tools are called by a language model, and a model can be steered into calling them by text it has just read — a prompt injection hidden in an audit-log string could otherwise reach a mutating call. Reads cannot destroy state, so reads are the first surface exposed. - **No SDK.** MCP over stdio is JSON-RPC 2.0. Implementing it keeps this feature dependency-free and shows the protocol rather than hiding it. - **Both protocol shapes.** The 2026-07-28 revision dropped the mandatory `initialize` handshake for a stateless core carrying `protocolVersion` in `_meta`; older clients still open with `initialize`. The server accepts both and names the versions it implements when asked for one it does not. - **Tool failures are results, not protocol errors** (`isError: true` with a readable message), so a model can correct its call and retry. - `npm run mcp` starts it. Credentials come from whatever the app already uses — no new environment variables. - Lives inside `src/lib/server/`, so it counts toward the coverage threshold. The module is at 100% statement, branch, function and line coverage; `cli.ts` is excluded with the reason recorded in `vitest.config.ts`, and the transport is covered in-process. - The stdio transport is serialised deliberately: replies go out in the order requests were read, so a client never gets two responses swapped.
    • `tests/mcp.test.ts` — protocol, tool and transport coverage. Test count 294 → 325.
  8. v0.1.9

    3changes

    Fixed

    • **The boot-time migration race is closed with a Postgres advisory lock.** `openDb()` and `scripts/migrate.ts` now take `pg_advisory_lock(8240173)` around the migrator, so replicas booting at the same moment serialise instead of both deciding the same migration is pending and one failing on `already exists`. - The key is a single exported constant, `MIGRATION_LOCK_KEY`, imported by both paths. Two separate keys would not exclude each other, leaving the CLI and a booting replica free to run at once — the exact race the lock exists to close. - The lock is held on a **dedicated connection**, not one drawn from the main pool. Reserving from the pool would hand back its only connection under `max: 1` (the test configuration) and the migration would wait forever for a connection that is itself holding the lock. - The DDL runs on the pooled client while the lock is held on its own connection. An advisory lock is a mutual-exclusion gate between migrators, not a per-connection guard on the statements, so this closes the window without tying the DDL to the lock session. - This is not redundant with the migrator's own transaction. Drizzle's `migrate()` does wrap each run in `session.transaction(...)`, which is what makes an interrupted run roll back cleanly — but two _concurrent_ runs both read the applied-migrations table before either commits, and the transaction does not help there. - `src/lib/server/db/index.ts` — `MIGRATION_LOCK_KEY` + `migrateUnderLock()`; the `@remarks` on `openDb()` no longer says migrations are unsafe under multiple replicas, because that is no longer true. - `scripts/migrate.ts` — takes the same key, so `npm run db:migrate` excludes a booting replica.

    Tests

    • **273 tests, coverage 98.58% statements / 95.53% branches / 98.57% functions / 99.61% lines.** Two new suites, run against a live Postgres: - `tests/migration-lock.test.ts` — asserts a second connection genuinely cannot take the lock while the first holds it, and that release hands the lock over. A test that cannot fail proves nothing, so the blocking is measured, not assumed. - `tests/migration-lock-control.test.ts` — negative control: a _different_ key is not blocked by ours. This is what makes the assertion above meaningful, since it separates "the lock blocked it" from "the connection was merely busy".

    Credit

    • The advisory-lock suggestion came from a reader comment on the dev.to boot-migrations post.
  9. v0.1.7

    2changes

    Tests

    • Unchanged: 262 tests, coverage 98.57% statements / 95.53% branches / 98.55% functions / 99.61% lines. No code behavior changed in this release.

    Docs

    • **The boot-time migrator's concurrency limit is now documented.** `openDb()` runs the Drizzle migrator on every connection open, which is safe for local dev, a single instance, or a preview deploy — but several instances booting concurrently against the same Postgres database can each decide the same migrations are pending and race, producing `column already exists` or a partially applied migration. This was nowhere stated, so buyers running 2+ replicas would have hit it undocumented. - `src/lib/server/db/index.ts` — a `@remarks` block on `openDb()` next to the `migrate()` call: the boot call is a safety net, and under multiple replicas you keep `npm run db:migrate` as the only writer and treat the boot call as a no-op. - `docs/deployment.md` §3 — a warning subsection with a deployment table (what to do per deployment shape) and the reason both mechanisms exist: the explicit pre-deploy step is the reviewed, ordered event; the boot-time call is the guarantee that no instance serves traffic against an unmigrated schema.
  10. v0.1.6

    7changes

    Added

    • `Tx` type export (`Parameters<Parameters<Db['transaction']>[0]>[0]`); `audit()`, `countActiveSeats()`, and `assertSeatAvailable()` accept `Db | Tx` so they can participate in a caller's transaction.
    • 3 concurrency tests against a real Postgres (`tests/edge-cases.test.ts`): last-seat race (exactly one winner, loser gets `seat_limit`, invite unclaimed), duplicate-member race (`already_member`, losing invite unclaimed), and FK-violation rollback (claim unwinds). Suite total: 262 tests; coverage 98.57% statements / 95.53% branches / 98.55% functions / 99.61% lines.

    Fixed

    • **Seat limits can no longer be oversold by concurrent invite acceptance.** `acceptInvite()` now runs the whole flow — per-org `SELECT … FOR UPDATE` row lock → seat check → single-use claim → membership insert → audit write — inside one `db.transaction()`. Previously the seat count was read outside any transaction, so two different invites accepted at the same instant could both observe "one seat free" and both commit a membership. Concurrent accepts for one org now serialize on the organization row; the conditional `UPDATE … WHERE accepted_at_ms IS NULL … RETURNING` claim remains the authoritative single-use gate. No `40001` retry loop is needed because the lock is always the transaction's first statement, so accepts queue instead of deadlocking.
    • Concurrent acceptance of two _different_ invites by the same user now returns the domain error `already_member` (Postgres `23505` on `memberships_org_user_uq` is translated) instead of a 500, and the losing invite is left unclaimed by the rollback so it still works later.
    • Any failure inside the transaction (e.g. a foreign-key violation on the membership insert) now rolls back the invite claim — the link stays open instead of being burned.

    Docs

    • `docs/architecture.md` concurrency notes rewritten: the old "claim+count could be wrapped in one transaction" limit is gone; the new limits (external billing call inside the lock; Postgres-only — D1 `batch()` and PostgREST have no interactive transactions) are documented instead.
    • README/AGENTS.md/docs/testing.md updated to v0.1.6 · 262 tests; TypeDoc reference regenerated (`audit` signature, new `Tx` alias). Screenshot S6 still shows the pre-0.1.6 run and is regenerated by the standing screenshot pipeline at release.
  11. v0.1.5

    3changes

    Added

    • **Dinh Fire Lamp theme** applied across the app UI and docs — Bạc Ngà (ivory) light default with an optional Rừng Đêm (forest-night) dark palette, tokenized per the shared design spec (Georgia serif headings, 18px radii, 60-30-10).

    Fixed

    • Light-mode `--faint` text color corrected to the spec value (`#97897a`; a dark-mode value had been used, dimming secondary text too far in light mode).

    Docs

    • Screenshot gallery refreshed — every capture re-taken against the themed live demo (org dashboard, RBAC denial GIF, audit; 259 tests).
  12. v0.1.4

    1change

    Docs

    • Suite tables completed to match the shipped 13 test files: `AGENTS.md`, `docs/testing.md`, and README status now list every suite (added `billing-edge-cases`, `rbac-boundary`, `service-integration`, `edge-cases`) with per-file coverage notes (259 tests total).
  13. v0.1.3

    1change

    Tests

    • 259 tests (was 206): +53 new tests against a real Postgres test database covering e2e billing flows (seat upgrades/downgrades, adapter failures, boundary conditions), RBAC boundary cases (cross-org isolation, rank escalation, hierarchy enforcement), full multi-step service-integration flows, and auth edge cases.
  14. v0.1.2

    2changes

    Added

    • **Usage-based billing guide**: `docs/ai-billing.md` — adapter pattern for AI token usage tracking, metered billing, and usage-based pricing.
    • **`.dockerignore`**: Build context exclusions for cleaner Docker builds.
  15. v0.1.1

    9changes

    Added

    • **Password strength validation**: `checkPasswordStrength()` enforces uppercase, lowercase, and digit requirements (not just minimum length). Returns `{ ok, reasons }` for UI feedback.
    • **Session management**: `listSessions()`, `destroyAllSessions(exceptToken?)` for security features like "log out everywhere".
    • **Billing guard**: `assertSeatAvailable()` now blocks `past_due` and `canceled` subscriptions from adding new seats.

    Fixed

    • **`createUser` error handling**: catch block now handles `DrizzleQueryError.cause.code` for Postgres unique constraint violations (`23505`) instead of silently swallowing all DB errors as `email_taken`.

    Tests

    • 206 tests (was 194): +12 new tests covering billing guard (past_due/canceled/active/trialing), audit pagination (offset, limit=0, ordering), and error mapping with actual Error subclasses.

    Planned

    • Real merchant-of-record billing adapter (Lemon Squeezy / Paddle) behind the existing `BillingAdapter` seam
    • JSONB metadata columns on audit_log / memberships (upgrade path documented in `docs/deployment.md`)
    • GIN full-text search over the audit log (tsvector, documented in `docs/deployment.md`)
    • Drizzle read/write split configuration for read replicas (documented in `docs/deployment.md`)
  16. v0.1.0

    21changes

    Added

    • **Authentication**: Email+password with scrypt hashing, DB-backed revocable sessions, hashed session tokens (raw token never persisted)
    • **Rate limiting**: Sliding-window failed-attempt limiter on login/signup — `RateLimiter` seam, `AUTH_FAILED_ATTEMPTS`/`AUTH_WINDOW_MS` env, 429 with approximate retry window, cheap pre-check before scrypt
    • **Organizations**: Create, unique URL-safe slug, owner bootstrap, member-only reads
    • **Invites**: Single-use hashed tokens, 7-day expiry, revoke, atomic conditional-UPDATE claim, optional recipient-email note
    • **RBAC**: `owner > admin > member` hierarchy with capability matrix, enforced server-side on every load/action; hierarchy rules (act downward, grant strictly below, no self-modification, single-owner invariant)
    • **Billing**: `BillingAdapter` interface + deterministic `MockBillingAdapter` (seat limits enforced at join time via `assertSeatAvailable`)
    • **Audit log**: Append-only by construction — no UPDATE/DELETE path exists anywhere
    • **Postgres port**: Drizzle Postgres schema (`uuid` primary keys, bigint epoch-ms timestamps, Postgres indexes) with the `postgres.js` driver; service layer untouched from the SQLite starter (swap the driver, keep the services)
    • **Migrations**: Checked-in Drizzle SQL migration (`drizzle/0000_init.sql`), applied automatically at boot via `migrate()`; `npm run db:migrate` for on-demand application
    • **Connection pooling**: Pool sized by `PG_MAX_CONNECTIONS`; PgBouncer/Supavisor guidance in `docs/deployment.md`
    • **RLS (opt-in)**: `rls/0010_rls_policies.sql` — Row-Level Security policies for all tenant tables with `FORCE RLS`, per-request `app.current_user_id` GUC pattern, documented as fail-closed defense-in-depth
    • **Local Postgres**: `docker-compose.yml` with Postgres 16 (dev :5433 + test :5434 databases)
    • **Testing**: 194 tests across 9 suites against a real Postgres test database (per-worker isolated schema + per-test truncation), `fileParallelism: false`
    • **Documentation**: Architecture, RBAC, billing, testing, deployment (providers, pooling, RLS, optional upgrades), versioning
    • **Repository hygiene**: `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, `.github` templates (CI with Postgres 16 service container, release pipeline), EULA (`LICENSE`)

    Design Principles

    • Server-side enforcement everywhere (UI hides controls, but every load/action re-checks)
    • Services are framework-free (import nothing from `@sveltejs/kit`)
    • Every mutating service call re-derives authority
    • Errors carry machine codes (`AuthError`, `RbacError`, etc.); one error mapper (`errorToFail()`)
    • No ORM lock-in at service boundaries — same service layer runs on SQLite or Postgres
    • Tenancy enforced in the app layer by default; RLS shipped as opt-in hardening

SvelteKit + Postgres Starter product page · GitHub releases

How to check this page

These numbers are not maintained by hand. The build reads theCHANGELOG.md of each kit in the repository and renders what it finds, so a release cannot appear here without an entry behind it, and an edited entry changes this page. AKeep a Changelog file is the source of truth, not this rendering of it.

If a kit here is stale, that is a fact you can see, and it is the same kind of fact as a competitor's last release date. That is the point: you should be able to tell without asking.

Get in touch

Questions about the product, team licenses, or anything else? We'll aim to respond within 48 hours.

Max 2000 characters

Stored in our own database — no third party. Deleted on request.

Not buying yet? Get told when something new lands.

Every guide, comparison and changelog entry published on this site is written about a real implementation decision — multi-tenancy, seat billing, audit logs, RLS, deploys. Leave an address and you get a message when a new one is published. Nothing else, and no schedule to keep up with.

Stored in our own database — no third party, no newsletter service. Deleted on request.

Currently VerdantStack publishes by hand, so this is occasional rather than scheduled. That is why no cadence is promised above.