A small, honest, Linear-style multi-tenant team issue tracker. It exists to demonstrate the architecture that actually matters in B2B SaaS — not a toy CRUD app:
- Isolated organizations (tenants) — every domain row is scoped by
org_id. - Role-based access control enforced server-side through one choke point.
- Invitations with single-use, expiring, email-bound tokens.
- A real-time kanban board that updates across clients over SSE.
- An append-only audit log of every mutation.
- Seat-based billing with an honest, clearly-labelled simulated upgrade.
Built with Next.js 16 (App Router, Server Actions), better-auth, Drizzle ORM + postgres-js, Postgres 17, Tailwind v4, TypeScript strict, and Zod on every mutation.
These are real captures produced by the Playwright E2E run (
e2e/teamboard.spec.ts), which drives the whole product against a freshly built server.
flowchart LR
subgraph Browser["Browser (per org)"]
UI["React client components<br/>kanban · modals · toasts"]
ES["EventSource<br/>/api/orgs/:id/stream"]
end
subgraph Next["Next.js 16 server"]
SA["Server Actions<br/>auth → requireRole → zod → mutate → audit → broadcast"]
RH["Route handlers<br/>auth · state · SSE stream"]
BUS["In-process event bus<br/>Map<org_id, listeners>"]
end
DB[("Postgres 17<br/>org-scoped tables")]
UI -- "mutations (RPC)" --> SA
UI -- "GET board state" --> RH
ES -- "subscribe" --> RH
SA -- "read / write" --> DB
RH -- "read" --> DB
SA -- "broadcast(org_id)" --> BUS
BUS -- "event" --> RH
RH -- "SSE event" --> ES
ES -- "refetch state" --> UI
Every mutation is a Server Action that follows the same pipeline:
authenticate → requireRole(orgId, minRole) → validate (zod) → mutate (Drizzle)
→ write audit row → broadcast(org_id) over the bus
The browser never mutates the database directly and never sends a role it wants to use.
Organizations are modelled in first-party domain tables (not the better-auth
organization plugin) so isolation is explicit and easy to audit. Every
tenant-scoped table — members, invitations, issues, comments,
audit_log — carries an org_id foreign key, and every read is filtered by
it. A membership on org A can never surface data from org B, because the query
that lists org B's issues is where org_id = B and the user simply has no
membership row there.
Roles are ordered owner > admin > member. Authorization lives in a single
helper, called at the top of every mutating action and every data route. The
role is always read fresh from the database, scoped to the org in the request:
export async function requireRole(orgId: string, minRole: Role): Promise<Actor> {
const user = await requireUser(); // throws if not signed in
const rows = await db
.select({ id: members.id, role: members.role })
.from(members)
.where(and(eq(members.orgId, orgId), eq(members.userId, user.id)))
.limit(1);
const member = rows[0];
const access = resolveAccess(
member ? [{ orgId, role: member.role }] : [],
orgId,
minRole,
);
if (!access.ok) {
throw new ForbiddenError(/* not a member / insufficient role */);
}
return { user, member: member! };
}The comparison itself is a pure function (resolveAccess / hasMinRole),
so tenant isolation and role ordering are unit-tested with no database:
// An owner of org-a is only a member on org-b — no privilege bleed.
resolveAccess([{ orgId: "org-a", role: "owner" }], "org-b", "admin"); // { ok: false }Members can only work the board. Owners and admins additionally manage members, invitations, billing, and can see the audit log. Owner-only guards protect against demoting the last owner and against admins escalating themselves.
The board is kept live with Server-Sent Events:
- Each mutating action calls
broadcast({ type, orgId })on an in-process event bus — aMap<org_id, Set<listener>>. GET /api/orgs/:id/streamauthorizes the caller withrequireRole, then subscribes to that org's channel and streams events (plus a heartbeat).- The board's
EventSourcereceives an event and refetchesGET /api/orgs/:id/state. A live-connection indicator reflects theEventSourcestate (connecting / live / reconnecting).
This is intentionally single-instance (bus state is in one process's
memory). The multi-instance path is a drop-in swap: replace the bus with
Redis pub/sub — PUBLISH org:<id> on broadcast, SUBSCRIBE per
connection — and nothing else changes because the event shape stays the same.
There is no payment processor, real or faked. The org has a plan
(free = 3 seats, pro = unlimited). The billing page shows seat usage vs the
limit, and inviting past the free limit is blocked with an upgrade prompt.
For the demo, an admin can "simulate upgrade" — a button that flips the plan locally. The UI says so in plain language.
How a real Stripe integration would plug in (no code change to the domain model — only the source of the plan changes):
- "Upgrade" starts a Stripe Checkout Session for a seat-based subscription.
- Stripe calls a webhook route. It verifies the signature, then handles
checkout.session.completed,customer.subscription.updated, andcustomer.subscription.deleted. - The handler is idempotent (keyed on the Stripe event id, stored so a
redelivered event is a no-op) and maps subscription status → plan:
active/trialing→pro, otherwise →free. - It writes the org's
planexactly likesimulateSetPlandoes today — the seat gate, the board, and the audit log need no changes.
docker compose up --buildThis starts Postgres 17 (schema applied from drizzle/0000_*.sql on first
boot) and the app on http://localhost:3000. Set a real BETTER_AUTH_SECRET
in your environment for anything beyond a local demo.
# 1. Start Postgres on port 5437 (127.0.0.1, never localhost — IPv6 gotcha)
docker run -d --name teamboard-pg \
-e POSTGRES_USER=teamboard -e POSTGRES_PASSWORD=teamboard -e POSTGRES_DB=teamboard \
-p 5437:5432 postgres:17-alpine
# 2. Configure env
cp .env.example .env
# 3. Install deps (Node 24 required — see below) and apply the schema
npm install
npm run db:push # drizzle-kit push --force
# 4. Run the dev server
npm run dev # http://localhost:3000Node 24 is required. The lockfile is npm-11 format;
npm cifails on Node 22. CI and the Docker image both pinnode:24.
| Variable | Required | Example | Notes |
|---|---|---|---|
DATABASE_URL |
yes | postgres://teamboard:teamboard@127.0.0.1:5437/teamboard |
Use 127.0.0.1, not localhost (resolves to IPv6 on some hosts). |
BETTER_AUTH_SECRET |
yes | a 32+ char random string | Signs session cookies. Generate a strong value in production. |
BETTER_AUTH_URL |
yes | http://localhost:3000 |
Public base URL; better-auth trusts this origin. |
npm run lint # eslint (0 warnings allowed)
npm run typecheck # tsc --noEmit
npm test # vitest — 31 unit tests, no DB required
npm run test:e2e # playwright — full two-user live-update flow (builds first)- Unit tests cover the pure logic: RBAC role ordering + cross-org denial, the token-bucket rate limiter (with an injected clock), invitation token validation/expiry, and seat-limit math. All logic that can be pure is pure, so these need no database.
- E2E (
e2e/teamboard.spec.ts) signs up an owner → creates an org → creates issues → invites a second user → the second context signs up and accepts the invite → both load the board → the owner moves an issue → the second client sees it update live over SSE → simulates a Pro upgrade. It writes the three screenshots above intodocs/.
src/
db/ schema.ts (auth + domain tables), index.ts (127.0.0.1 DSN)
lib/
auth.ts better-auth server config
auth-client.ts better-auth browser client
rbac.ts requireRole / requireUser (server enforcement)
rbac-core.ts pure role logic (unit-tested)
actions.ts ALL server actions (auth→role→zod→mutate→audit→broadcast)
queries.ts org-scoped read helpers
bus.ts in-process SSE event bus
ratelimit.ts token-bucket limiter
invites.ts invite token + expiry logic (pure)
seats.ts seat-limit logic (pure)
app/
page.tsx landing
sign-in, sign-up auth
dashboard org list
org/[id]/ board, members, audit, billing (+ shell layout)
invite/[token] invitation acceptance
api/auth/[...all] better-auth handler
api/orgs/[id]/state board state (JSON)
api/orgs/[id]/stream SSE stream
api/orgs/[id]/issues/[id] issue detail + comments
components/ UI (board, members panel, billing panel, modal, toasts, …)
tests/ vitest suites
e2e/ playwright spec
drizzle/ generated SQL migration (mounted by compose on first boot)
- Single instance. The SSE bus and the rate limiter keep state in one process's memory. Horizontal scaling needs Redis pub/sub (bus) and a shared store (rate limiter). The code is structured so these are drop-in swaps.
- Billing is simulated. No Stripe, no charges. The plan is flipped locally and the UI labels it as such.
- Cookie sessions. Sessions are cookie-based via better-auth; there is no refresh-token rotation or device management.
- No email delivery. Invitations produce a link you copy and share rather than sending an email.
- Redis pub/sub for multi-instance real-time and shared rate limiting.
- Real Stripe subscriptions via the idempotent webhook described above.
- Optimistic drag-and-drop ordering within a column.
- Email delivery for invitations; SSO / OAuth providers.
- Per-issue activity feed and @mentions in comments.
MIT © 2026 Aminyx


