Shipyard
A production-grade multi-tenant SaaS starter. Tenant isolation enforced by a repository chokepoint, permission-based RBAC, an append-only audit log.
The opinionated spine you would let pay you on day one. Token-bucket rate limits and a billing scaffold with a real state machine, pinned by tests.
trialing -> active active -> past_due past_due -> active * -> canceled
Six isolated test suites prove the guarantees in roughly one second.
Why this matters
Every B2B SaaS needs the same spine before it can ship product. The pieces are well understood and each one is independently easy to get subtly wrong: a query missing its tenant predicate, a role check living in the client, a webhook reactivating a cancelled plan. Shipyard is the version where the design decisions are made once, written down, and pinned by tests, so the guarantee of tenant isolation holds on commit one.
Why this exists
I have started the same B2B SaaS three times. Each time I spent the first fortnight rebuilding the same unglamorous spine before I could touch the actual product. Tenant isolation, sessions, a role model, an audit trail, rate limits, and a billing scaffold I would not be ashamed to demo.
None of it is hard in isolation. The danger is that it is all easy to get subtly wrong, and the failures are the kind you find in production. A query that forgot its tenant predicate. A role check that lived in the client. A webhook that reactivated a cancelled plan because nobody validated the transition.
So I built the spine once, with the isolation and authorisation guarantees pinned down by tests rather than by good intentions, and made it run with no external services so it installs and proves itself on any machine. Shipyard is opinionated about the hard parts and deliberately empty where your product lives.
The one test that has to pass
Before any feature, this is the test I write first on every B2B product. If it fails, nothing else matters.
it("an update cannot reach across tenants", () => {
const { repo } = freshRepo();
const auth = new AuthService(repo);
const a = auth.signup({ email: "a@acme.test", password: "pw-pw-pw-pw", organisationName: "Acme" });
const b = auth.signup({ email: "b@globex.test", password: "pw-pw-pw-pw", organisationName: "Globex" });
const acmeMembership = repo.selectScoped(a.organisationId, "memberships")[0] as { userId: string };
// Globex tries to demote an Acme member. The scoped update matches nothing.
const changed = repo.updateScoped(
b.organisationId,
"memberships",
{ role: "viewer" },
{ userId: acmeMembership.userId },
);
expect(changed).toBe(0);
});Globex holds Acme’s real userId, names the right table, and asks for a write it is allowed to make inside its own tenant. The update still touches zero rows. That single property is what Shipyard exists to give you on commit one.
What you get
Everything below is in the repo today, with tests that prove it.
One chokepoint for tenant data
Every tenant-scoped read and write goes through Repository in src/db/repository.ts. It stamps the tenant id onto inserts and ANDs it into reads and updates. A caller cannot construct a query that reaches another tenant, even with a foreign id in the payload.
Sessions you can leak
Opaque 32-byte random tokens. Only a SHA-256 hash is stored, the plaintext is the httpOnly cookie. A database dump does not hand out live sessions. scrypt for passwords, with cost parameters embedded in the hash so you can raise them without a migration.
Permission-based RBAC
Routes assert a permission (billing:manage), not a role. Roles are bundles of permissions in src/lib/rbac.ts. A forgotten check fails closed by throwing ForbiddenError, not by silently allowing. Four roles, ten permissions, every mapping tested.
Append-only audit log
Actor, tenant, action, JSON metadata, millisecond timestamp. Written through the scoped repository so an entry cannot be misattributed. No update or delete path is exposed. Routes that read it are gated by audit:read.
Token-bucket rate limiter
Two numbers per key: (tokens, lastRefill). Injectable clock for deterministic tests. Pluggable BucketStore so the same algorithm ports to Redis behind several instances. Tighter budget on auth than on the general API.
Billing scaffold that means it
Plan catalogue with per-metric budgets, a real subscription state machine that rejects illegal transitions, usage metering that returns 402 instead of overrunning, and a Stripe-shaped adapter whose HMAC-SHA256 webhook signature check is the real scheme.
node:sqlite in dev, Postgres in production
No compiled addon, no service to install. pnpm install is fast and pnpm test runs anywhere. The repository sits behind a typed interface so the same shape reimplements against Postgres, with RLS as defence in depth on top.
The guarantees are tests
tests/tenant-isolation, tests/rbac, tests/audit, tests/rate-limit, tests/billing, tests/stripe-webhook. Each suite gets a fresh in-memory database so there is no shared state to leak. 29 tests in roughly 470ms on an M3 Pro.
One request context
resolveContext reads the session, looks up the user, pins the active tenant, resolves the role from membership in that tenant. A user pointing a session at a tenant they have no membership in resolves with no role and is refused.
Empty where your product lives
Opinionated about the hard parts, deliberately empty everywhere else. A minimal settings dashboard proves the wiring, then gets out of the way. Bring your own UI kit, your own routes, your own data model on top.
Tech stack
How a request flows
Edge middleware is a cheap gate. The authoritative decision is server-side where the database lives.
Try it in a minute
No services to install. node:sqlite is built in from Node 24, scrypt and HMAC are in the standard library.
git clone https://github.com/sarmakska/shipyard.git cd shipyard pnpm install SHIPYARD_DB_PATH=shipyard.db pnpm seed # two demo organisations pnpm test # the guarantees, proved SHIPYARD_DB_PATH=shipyard.db pnpm dev # http://localhost:3000/login
# Sign in as the Acme owner # owner@acme.test / password-acme-123 # Open /app/settings: members, billing, usage, audit trail. # Switch to a Globex account and confirm none of Acme is visible.
Who this is for
What people actually clone this to do.
A B2B SaaS on day one
Organisations, members, roles, an audit trail and a plan picker exist before you write your first product route. You start where most B2B starters end.
A serious internal tool
Multi-team internal tooling needs the same spine as a public SaaS: tenant separation, audit, role checks. The minimal dashboard is enough to run an internal product on while you build the rest.
A teaching reference
About 230 lines of repository code, a 30-line RBAC guard, a 60-line limiter. Small enough to read top to bottom and reason about. The wiki explains every design decision in prose.
A migration target
You have a single-tenant app and you need to add organisations. Lift the repository pattern, the RBAC table and the audit writer across. The rest of your code does not need to know.
Honest limits
SQLite is for dev and tests, not production. The repository is built for the Postgres swap. The SQLite layer exists so the project installs and proves itself with nothing running.
The Stripe adapter is a seam, not a client. The webhook signature verification is real and tested. The customer and subscription calls throw with a pointer to the wiki until you pnpm add stripe and fill them in.
Single-instance rate limiting out of the box. The default bucket store is in-memory. Behind several instances the effective limit multiplies until you wire the Redis store. The interface is there for exactly that.
Not a single-tenant skeleton. If you are not building multi-tenant, the tenancy machinery is pure overhead. Start elsewhere.
No UI framework opinion. There is a minimal settings dashboard to prove the wiring. Bring your own design system.
Clone it. Read it. Ship on it.
Email-token invitations, a Postgres repository alongside the SQLite one, and a Redis BucketStore are on the roadmap. Pull requests welcome on the spine, not on the missing UI kit.