How Shipyard works
One repository chokepoint for tenant data. One context resolver. One permission guard. One audit writer. One bucket per key. One state machine. The spine is small enough to read in an hour and pinned down by 29 tests.
Tenant isolation,
by construction.
Not by good intentions.
Every tenant-scoped read and write goes through one Repository. It injects organisationId = @organisationId into every WHERE clause and stamps it onto every insert. The caller cannot remove or override it.
The active tenant comes from the session, resolved server-side. A user pointing a session at a tenant they have no membership in resolves with no role and is refused. Authorisation runs on the server, every time.
The whole spine fits in roughly the same code footprint as a typical OpenAPI client. pnpm test runs 29 tests in about 470ms on an M3 Pro, with a fresh in-memory database per suite.
The chokepoint pattern
One narrow path to tenant data. Three properties hold for every scoped operation.
// src/db/schema.ts
export const TENANT_SCOPED_TABLES = new Set([
"memberships",
"audit_log",
"subscriptions",
"usage_counters",
]);
// src/db/repository.ts
// 1. The tenant id is injected, never trusted.
const scoped = { ...(row as Row), organisationId };
// Smuggled organisationId in the payload is overwritten by the spread.
// 2. The tenant predicate cannot be removed.
const conditions = ["organisationId = @organisationId"];
for (const [key, value] of Object.entries(where)) {
if (key === "organisationId") continue; // never overridable
conditions.push(`"${key}" = @${key}`);
}
// 3. Values are bound, never interpolated.
// Every value is a named parameter to a prepared statement.// src/lib/context.ts - where the active tenant comes from const session = auth.resolveSession(token); const user = auth.userById(session.userId); const role = auth.roleOf(user.id, session.organisationId); if (!role) throw new TenantResolutionError(); // fail closed
Four roles, ten permissions
Routes assert a permission, not a role. Roles are bundles of permissions in one file.
| Permission | owner | admin | member | viewer |
|---|---|---|---|---|
| org:read | yes | yes | yes | yes |
| org:manage | yes | yes | no | no |
| members:read | yes | yes | yes | yes |
| members:invite | yes | yes | no | no |
| members:remove | yes | yes | no | no |
| members:set_role | yes | yes | no | no |
| billing:read | yes | yes | yes | yes |
| billing:manage | yes | no | no | no |
| audit:read | yes | yes | no | no |
| usage:write | yes | yes | yes | no |
// src/lib/rbac.ts
export function requirePermission(role: Role, permission: Permission): void {
if (!roleHasPermission(role, permission)) {
throw new ForbiddenError(permission); // mapped to 403
}
}
// In a route, one line:
withGuard({ permission: "members:invite" }, handler, req)
// In a service:
guard(ctx, "members:set_role")Who did what, where, when
| Field | Type | Meaning |
|---|---|---|
| id | text, pk | Random identifier for the entry |
| organisationId | text, scoped | Tenant the action happened in. Never overridable |
| actorUserId | text or null | Who performed the action. null for system actions (webhooks) |
| action | text | Dotted name, for example members.invite, billing.webhook |
| metadata | text (JSON) | Context for the action. Encoded on the way in, decoded on the way out |
| createdAt | integer | Millisecond timestamp |
// src/lib/audit.ts - the single writer
recordAudit(repo, {
organisationId: ctx.organisationId,
actorUserId: ctx.user.id,
action: "members.invite",
metadata: { email, role },
});
// Actions recorded out of the box:
// auth.signup new user and organisation created
// members.invite a user invited to a tenant
// members.set_role a member's role changed
// billing.subscribe a plan subscribed
// billing.webhook a provider event applied (actor null)
// billing.cancel a subscription canceled
// Reads go through selectScoped so a request for tenant B
// can only ever return tenant B's entries.From cookie to scoped row
Edge middleware is a cheap gate. The authoritative authorisation decision is server-side, every time.
Each piece, deep-dived
The repository chokepoint
Application-level isolation has to fail loudly in a unit test on any database. Row-level security is good, but it fails silently when misconfigured until production. A single narrow path to tenant data is the only thing you can test on commit one.
src/db/repository.ts exposes two families. insertScoped, selectScoped, selectOneScoped, updateScoped require an organisationId and refuse global tables. insertGlobal, selectGlobal, updateGlobal, deleteGlobal refuse tenant-scoped tables. assertScoped throws TenantScopeError if you pick the wrong family. About 230 lines, readable top to bottom.
The tenant predicate cannot be removed
A caller who can drop the organisationId from a WHERE clause has the whole system. The guarantee has to survive a caller who knows another tenant's ids and tries to slip them through the filter.
On read and update, the repository writes the predicate first and skips any organisationId key in the caller's where. The SQL it builds is: WHERE organisationId = @organisationId AND "userId" = @where_userId. The @organisationId binding comes from the first method argument, which is resolved from the session, not from the request body.
The tenant id is injected, never trusted
On insert, a payload from the client may carry an organisationId field. If the repository accepted it, a tenant could plant rows in another tenant's table.
const scoped = { ...(row as Row), organisationId }. The spread overwrites any smuggled id, so the row lands under the scope the caller passed as the first argument, not whatever was in the body. Asserted by the smuggled-id case in tests/tenant-isolation.test.ts.
Permission-based RBAC
Asserting roles at the call site (if role === "admin") rots. Every new capability forces you to revisit every role comparison and the meaning drifts. Permissions named at the call site keep the route readable and let the role table grow without touching call sites.
src/lib/rbac.ts declares the PERMISSIONS list (org:read, org:manage, members:read, members:invite, members:remove, members:set_role, billing:read, billing:manage, audit:read, usage:write) and ROLE_PERMISSIONS maps each role to its bundle. requirePermission(role, permission) throws ForbiddenError (mapped to 403) when a role lacks the permission. A forgotten check fails closed.
Append-only audit log
An incident review always asks who did what, in which tenant, and when. The answer has to be trustworthy, which means immutable, attributable, and tenant-scoped like everything else.
audit_log holds (id, organisationId, actorUserId, action, metadata JSON, createdAt). recordAudit is the single writer, called inside privileged operations. Reads go through selectScoped so a request for tenant B can only ever return tenant B's entries. No update or delete path is exposed. Convention for action names is domain.verb (members.invite, billing.subscribe, billing.webhook, billing.cancel).
Token-bucket rate limiter
A fixed window double-rates at the boundary. A sliding-window log needs a timestamp list per key. A bucket is two numbers and ports unchanged to a Redis Lua script. Smooth throttling with controlled bursts is what an API wants.
src/lib/rate-limit.ts. State per key is (tokens, lastRefill). refill computes elapsed time, adds elapsedSeconds * refillPerSecond, caps at capacity. consume refills, then takes one token if available. Injectable now() so tests use a hand-advanced clock and skip real sleeps. BucketStore interface so Redis swaps in. auth is tighter (capacity 5, refill 0.2/s) than api (capacity 60, refill 10/s). Blocked requests surface retryAfterMs as Retry-After.
Subscription state machine
A webhook that arrives out of order, replayed, or for a different subscription must not be allowed to reactivate a cancelled plan or mutate a tenant's record from another tenant's event.
src/lib/billing/service.ts validates every transition against an allow list. The terminal state is canceled. trialing → active, trialing → past_due, trialing → canceled, active → past_due, active → canceled, past_due → active, past_due → canceled. Illegal moves throw BillingError. Webhook events are also checked against the stored providerSubscriptionId so an event for a different subscription cannot mutate this tenant.
Stripe-shaped webhook verification
Bundling a payment SDK into a starter is the wrong choice. Doing webhook signature verification with hand-rolled string compare is also the wrong choice. The signature scheme is the one piece you genuinely cannot fake.
provider-stripe.ts implements the real Stripe signing scheme: HMAC-SHA256 over `${timestamp}.${payload}` with the webhook secret, compared via timingSafeEqual. A tampered or unsigned payload is rejected before any state changes. The customer and subscription methods throw with a pointer to the wiki until you pnpm add stripe and fill them in. The provider is selected by env in the billing route, so no app code changes when you flip BILLING_PROVIDER=stripe.
Two numbers, per key
// src/lib/rate-limit.ts
private refill(bucket: Bucket): Bucket {
const now = this.now();
const elapsedSeconds = (now - bucket.lastRefill) / 1000;
const refilled = Math.min(
this.config.capacity,
bucket.tokens + elapsedSeconds * this.config.refillPerSecond,
);
return { tokens: refilled, lastRefill: now };
}
export const RATE_LIMITS = {
auth: { capacity: 5, refillPerSecond: 0.2 }, // tightest
api: { capacity: 60, refillPerSecond: 10 },
};
// Applied per (organisationId, routeGroup).
// Unauthenticated routes (signup, login) key by IP instead.
// MemoryBucketStore by default; the BucketStore interface ports
// the same algorithm to a Redis Lua script unchanged.Failure modes you should expect
What you can measure
Apple M3 Pro, Node v25.9.0. Real numbers, not estimates.
What I will add
Email-token invitations
Invite by email with a signed token, accept on first sign-in. Today only direct membership creation is exposed.
Postgres repository
A second implementation of Repository against Postgres, alongside the SQLite one. Same interface, same tests, RLS as defence in depth.
Redis BucketStore
Atomic refill-and-take in a small Lua script so the limiter is correct multi-instance. The algorithm does not change, only where (tokens, lastRefill) lives.
Organisation switching
Move the active organisationId on the session without a new login. Server-side state, cookie unchanged.
Per-tenant feature flags
Flags keyed by (organisationId, flag). Same scoped repository, same audit log entry on toggle.
What I will not add
An ORM, a bundled payment SDK, or a UI kit. Those are decisions your product should make and bolting them on would undo the reason the spine is small enough to trust.
Ready to read the source?
Clone, pnpm install, pnpm test, pnpm dev. No services to install, no API keys to set, no checkout to do before the guarantees prove themselves.
All open-source projects