Project Setup
The shift from user-owned to org-owned
In the auth course, notes belonged to a user. In this course, notes belong to an organization, and users access them through their membership in that organization.
This is how most real apps work: Slack channels belong to a workspace, GitHub repos belong to an organization, Google Docs belong to a shared drive. The user does not own the resource directly — they access it through a group.
Create the project
mkdir authz-course
cd authz-course
npm init -y
npm install @hectoday/http zod srvx better-sqlite3 bcryptjs
npm install -D typescript @types/node @types/better-sqlite3 @types/bcryptjs tsx Same stack as the web security course: Hectoday HTTP, Zod, SQLite, bcrypt.
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "./src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src"]
} The database schema
The key addition is two new tables: organizations and memberships. Notes now have an org_id instead of a user_id.
Create src/db.ts:
// src/db.ts
import Database from "better-sqlite3";
import bcrypt from "bcryptjs";
const db = new Database("app.db");
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS memberships (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
org_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (org_id) REFERENCES organizations(id),
UNIQUE(user_id, org_id)
);
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
created_by TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (org_id) REFERENCES organizations(id),
FOREIGN KEY (created_by) REFERENCES users(id)
);
`);
export default db; Notice what changed from the auth course:
- Users no longer have a
rolefield. Roles are defined per-membership, not per-user. - Organizations are a new entity. They have an ID and a name.
- Memberships connect users to organizations with a role. The
UNIQUE(user_id, org_id)constraint means a user can only have one role per organization. - Notes have
org_id(which organization they belong to) andcreated_by(who created them). A note belongs to the org, not to the user.
Seed data
Add seed data to src/db.ts:
// Seed data
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get("[email protected]");
if (!existing) {
// Users
db.prepare("INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)").run(
"user-alice",
"[email protected]",
"Alice",
bcrypt.hashSync("password123", 10),
);
db.prepare("INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)").run(
"user-bob",
"[email protected]",
"Bob",
bcrypt.hashSync("password123", 10),
);
db.prepare("INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)").run(
"user-carol",
"[email protected]",
"Carol",
bcrypt.hashSync("password123", 10),
);
// Organizations
db.prepare("INSERT INTO organizations (id, name) VALUES (?, ?)").run("org-acme", "Acme Corp");
db.prepare("INSERT INTO organizations (id, name) VALUES (?, ?)").run("org-globex", "Globex Inc");
// Memberships
db.prepare("INSERT INTO memberships (id, user_id, org_id, role) VALUES (?, ?, ?, ?)").run(
"mem-1",
"user-alice",
"org-acme",
"owner",
);
db.prepare("INSERT INTO memberships (id, user_id, org_id, role) VALUES (?, ?, ?, ?)").run(
"mem-2",
"user-bob",
"org-acme",
"editor",
);
db.prepare("INSERT INTO memberships (id, user_id, org_id, role) VALUES (?, ?, ?, ?)").run(
"mem-3",
"user-carol",
"org-acme",
"viewer",
);
db.prepare("INSERT INTO memberships (id, user_id, org_id, role) VALUES (?, ?, ?, ?)").run(
"mem-4",
"user-alice",
"org-globex",
"viewer",
);
// Notes (belong to orgs, not users)
db.prepare("INSERT INTO notes (id, org_id, created_by, title, body) VALUES (?, ?, ?, ?, ?)").run(
"note-1",
"org-acme",
"user-alice",
"Q4 Plan",
"Our goals for Q4.",
);
db.prepare("INSERT INTO notes (id, org_id, created_by, title, body) VALUES (?, ?, ?, ?, ?)").run(
"note-2",
"org-acme",
"user-bob",
"Meeting Notes",
"Action items from standup.",
);
db.prepare("INSERT INTO notes (id, org_id, created_by, title, body) VALUES (?, ?, ?, ?, ?)").run(
"note-3",
"org-globex",
"user-alice",
"Globex Draft",
"Initial proposal.",
);
} The seed data creates a realistic scenario:
- Alice is an owner of Acme Corp and a viewer of Globex Inc
- Bob is an editor at Acme Corp (no Globex membership)
- Carol is a viewer at Acme Corp (no Globex membership)
- Notes belong to organizations, not users
This gives us plenty of cases to test: Can Bob edit Acme notes? (Yes.) Can Bob delete them? (Not yet — we decide this with permissions.) Can Carol view Acme notes? (Yes.) Can Carol edit them? (No.) Can Bob see Globex notes? (No — he is not a member.)
Auth helpers
Same session and cookie patterns as the other courses:
// src/sessions.ts
const store = new Map<string, { userId: string; activeOrgId: string | null; createdAt: number }>();
const MAX_AGE = 24 * 60 * 60 * 1000;
export function createSession(userId: string): string {
const id = crypto.randomUUID();
store.set(id, { userId, activeOrgId: null, createdAt: Date.now() });
return id;
}
export function getSession(id: string) {
const s = store.get(id);
if (!s) return undefined;
if (Date.now() - s.createdAt > MAX_AGE) {
store.delete(id);
return undefined;
}
return s;
}
export function setActiveOrg(sessionId: string, orgId: string): void {
const session = store.get(sessionId);
if (session) session.activeOrgId = orgId;
} Notice the session now includes activeOrgId. This tracks which organization the user is currently working in. We will use this in Section 4 for organization switching.
// src/cookies.ts
export function parseCookies(h: string | null): Record<string, string> {
if (!h) return {};
const c: Record<string, string> = {};
for (const p of h.split(";")) {
const [n, ...r] = p.trim().split("=");
if (n) c[n] = r.join("=");
}
return c;
}
export function getSessionId(req: Request) {
return parseCookies(req.headers.get("cookie"))["session"];
}
export function sessionCookie(id: string) {
return `session=${id}; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400`;
} // src/auth.ts
import db from "./db.js";
import { getSessionId } from "./cookies.js";
import { getSession } from "./sessions.js";
export interface AuthUser {
id: string;
email: string;
name: string;
sessionId: string;
activeOrgId: string | null;
}
export function authenticate(request: Request): AuthUser | Response {
const sessionId = getSessionId(request);
if (!sessionId) return Response.json({ error: "Unauthorized" }, { status: 401 });
const session = getSession(sessionId);
if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 });
const user = db
.prepare("SELECT id, email, name FROM users WHERE id = ?")
.get(session.userId) as any;
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
return {
id: user.id,
email: user.email,
name: user.name,
sessionId,
activeOrgId: session.activeOrgId,
};
} The AuthUser type now includes sessionId (so we can update the session, like setting the active org) and activeOrgId (which org the user is currently working in).
App shell, login, and server
// src/routes/auth.ts
import * as z from "zod/v4";
import { route, group } from "@hectoday/http";
import bcrypt from "bcryptjs";
import db from "../db.js";
import { createSession } from "../sessions.js";
import { sessionCookie } from "../cookies.js";
const LoginBody = z.object({ email: z.email(), password: z.string().min(1) });
export const authRoutes = group([
route.post("/login", {
request: { body: LoginBody },
resolve: async (c) => {
if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
const { email, password } = c.input.body;
const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email) as any;
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
return Response.json({ error: "Invalid credentials" }, { status: 401 });
}
const sessionId = createSession(user.id);
return Response.json(
{ user: { id: user.id, email: user.email, name: user.name } },
{ headers: { "set-cookie": sessionCookie(sessionId) } },
);
},
}),
]); // src/app.ts
import { setup, route } from "@hectoday/http";
import { authRoutes } from "./routes/auth.js";
export const app = setup({
routes: [route.get("/health", { resolve: () => Response.json({ status: "ok" }) }), ...authRoutes],
}); // src/server.ts
import { serve } from "srvx";
import { app } from "./app.js";
serve({ fetch: app.fetch, port: 3000 }); Add "type": "module" and "scripts": { "dev": "tsx watch src/server.ts" } to package.json.
Verify it works
npm run dev
curl -c cookies.txt -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password123"}' In the next lesson, we add role-based access to the notes.
Exercises
Exercise 1: Look at the seed data. What role does Alice have in Acme? In Globex? What would happen if Alice tried to create a note in Globex with an editor role check? (She would fail — she is a viewer there.)
Exercise 2: Try inserting a membership with the same user_id and org_id as an existing one. The UNIQUE(user_id, org_id) constraint should reject it. This ensures a user has exactly one role per organization.
Why do notes have an org_id instead of a user_id?