JoistSaaS Skeleton
What it does for you
A zero-dependency multi-tenant SaaS starter with roles, invites, plan limits, API keys and an audit log, where every route is tested against cross-tenant access.

Screenshots

In more detail
Joist is a starter kit for a subscription web app where each customer company gets its own private workspace, and one company can never see another company's data. It has teams and roles, invites, plans with limits, API keys, and an activity log. Most SaaS data leaks are not clever hacks. They are one missing WHERE org_id = ? on one route, and I wanted a skeleton where that mistake is hard to make.
Honest limits
No real email delivery, no real payments, and nobody clicked through every UI flow by hand. The API flows are covered by tests, including a live test that spawns the real server on a spare port. The UI was checked by headless screenshots. This is a tested skeleton, not a production SaaS.
How I built it
Like 61, this was on Codex's list and Claude Code picked it up on the last day when Codex stopped taking rows. It's a zero-dependency Node 24 server on node:http and built-in node:sqlite with versioned migrations, plain ES modules and CSS, no build step.
I wrote the spec and constraints. Claude Code wrote the pure core (roles, plans, proration, keys, metering, invites), the tenant repository, the guard pipeline, and the UI, all in small files. The API connection was still dropping long responses, so every file stayed under about 4 KB and the tests are split into 13 test-*.mjs parts. Small files kept the session alive and made review easier, to be honest.
The billing side is a clearly labeled stub: Free, Pro, and Team plans, limits that answer 402 with an upgrade prompt, a fake checkout, prorated previews, trials, and invoice records. The Stripe seam is documented in server/stripe-seam.js and never called.
What I learned
- Make the safe path the only path. The tenant repository throws unless it gets a real org id, binds that id into every statement, and refuses
org_idin caller data. A spy database records every SQL statement and proves each one carries the org. - Generate the security tests from the route table. We got 4 roles times 25 org routes for permissions, plus every org route aimed at another org for isolation. A new route gets attacked automatically.
- Non-members get 404, not 403. A 403 confirms the org exists. A 404 tells an outsider nothing.
- Metering order is a design choice. Scope and plan checks run before the quota, so refused API calls never burn it. A test caught my own wrong expectation there.
- Proration is display math. Rounding each side to whole cents gives 1666, not 1667, on a mid-period downgrade, and the real payment provider stays the source of truth.
What carried forward
Joist reuses tested modules from two projects built earlier the same day, imported by relative path through one lib/reuse.js file. From project 80 (Trellis): scrypt hashing, token hashing, CSRF compare, cookies, login backoff, and validation. From project 75 (Gatehouse): the audit hash chain, security headers, and CSV export. It was built right after the security review of 75, so those lessons were applied from the start. The strict CSP from 75 also shaped the UI: it blocked inline style widths on the usage bars, so we sized them through the CSSOM with transforms, which also avoids janky width animations.
If I took it further
I'd move it onto real Postgres and add row-level security as a second wall behind the repository. Then I'd wire the Stripe seam with a verified webhook as the only place a plan can change.
#062 Built with Claude Code September 24, 2026 578 automated checks