Sub-skills
Auth in depth
For your agentCopy this and give it to the AI you're building with.
Run `cubek skill`, then `cubek skill auth`, read all of both and follow them exactly (no CLI: download https://cubek.dev/SKILL.md and https://cubek.dev/skills/auth.md verbatim with curl; don't summarize them). Run `cubek check` after every edit until it is clean.
Sign-up, sign-in, sessions, sign-out, guarded routes, roles, API tokens and rate limits. Accounts are app code: copy this, do not invent a scheme.
Schema
// schema.ts
import { table, t } from "cubek"
export const users = table("users", {
id: t.id(),
email: t.string().unique({ nocase: true }),
passwordHash: t.string(),
role: t.enum(["member", "admin"]).default("member"),
createdAt: t.timestamp().default("now"),
}).index("createdAt")
export const sessions = table("sessions", {
id: t.id(),
tokenHash: t.string().unique(),
userId: t.ref(users),
expiresAt: t.timestamp(),
}).index("userId")
export const apiTokens = table("apiTokens", { id: t.id(), tokenHash: t.string().unique(), userId: t.ref(users), name: t.string() })Sessions
The cookie holds a random token; the database holds only its SHA-256, so a leaked database does not leak sessions.
// src/lib/auth.ts
import { crypto, db, encoding, type Context, type Row } from "cubek"
import type { users } from "../../schema"
export type User = Row<typeof users>
const DAYS = 30
export async function currentUser(c: Context): Promise<User | null> {
const token = c.cookie("sid")
if (token === null) return null
const s = await db.sessions.findOne({ where: { tokenHash: crypto.hash("sha256", token) } })
if (s === null || s.expiresAt < new Date().toISOString()) return null
return await db.users.get(s.userId)
}
export async function startSession(c: Context, userId: string): Promise<void> {
const token = encoding.hex.encode(crypto.randomBytes(32))
const expiresAt = new Date(Date.now() + DAYS * 86400000).toISOString()
await db.sessions.insert({ tokenHash: crypto.hash("sha256", token), userId, expiresAt })
c.setCookie("sid", token, { httpOnly: true, sameSite: "lax", path: "/", maxAge: DAYS * 86400 })
}
export async function endSession(c: Context): Promise<void> {
const token = c.cookie("sid")
if (token !== null) await db.sessions.deleteWhere({ tokenHash: crypto.hash("sha256", token) })
c.setCookie("sid", "", { maxAge: 0, path: "/" })
}
// API clients: `Authorization: Bearer <token>`, created once and shown once.
export async function tokenUser(c: Context): Promise<string | null> {
const h = c.header("authorization")
if (h === null || !h.startsWith("Bearer ")) return null
const t = await db.apiTokens.findOne({ where: { tokenHash: crypto.hash("sha256", h.slice(7)) } })
return t === null ? null : t.userId
}Routes
// src/app.tsx
import { app, crypto, db, rateLimit, validate } from "cubek"
import { currentUser, endSession, startSession } from "./lib/auth"
const credentials = validate.object({
email: validate.string({ email: true, max: 254 }),
password: validate.string({ min: 8, max: 200 }),
})
// Brute force: per IP on the auth forms.
app.use("/auth/*", rateLimit({ limit: 10, window: "1m" }))
app.post("/auth/signup", async (c) => {
const p = credentials.safeParse(await c.body())
if (!p.success) return c.text(p.error, 400)
if ((await db.users.findOne({ where: { email: p.data.email } })) !== null) return c.text("that email has an account", 409)
const user = await db.users.insert({ email: p.data.email, passwordHash: crypto.password.hash(p.data.password) })
await startSession(c, user.id)
return c.redirect("/", 303)
})
app.post("/auth/signin", async (c) => {
const p = credentials.safeParse(await c.body())
if (!p.success) return c.text("wrong email or password", 401)
const user = await db.users.findOne({ where: { email: p.data.email } })
if (user === null || !crypto.password.verify(p.data.password, user.passwordHash)) return c.text("wrong email or password", 401)
await startSession(c, user.id)
return c.redirect("/", 303)
})
app.post("/auth/signout", async (c) => {
await endSession(c)
return c.redirect("/", 303)
})
// Guard: a middleware for a prefix (it runs for every path under it).
app.use("/admin/*", async (c, next) => {
const u = await currentUser(c)
if (u === null) return c.redirect("/signin", 303)
if (u.role !== "admin") return c.text("forbidden", 403)
await next()
})
app.get("/admin/users", async (c) => c.json(await db.users.find({ orderBy: { createdAt: "desc" }, limit: 100 })))
// Per-user limit: `key` returns the user id (null falls back to the IP).
const perUser = rateLimit({ limit: 30, window: "1h", key: async (c) => { const u = await currentUser(c); return u === null ? null : u.id } })
app.post("/api/generate", perUser, async (c) => {
const u = await currentUser(c)
return u === null ? c.json({ error: "sign in" }, 401) : c.json({ ok: true })
})- Roles are data: more roles in the enum (
["member", "moderator", "admin"]), a guard per prefix as above. Grant the first admin with SQL (a signed-up account):cubek sql --dev "UPDATE users SET role = 'admin' WHERE email = 'me@example.com'" --writelocally, the same without--devon production; later admins from an admin-only route. - Realtime channels are private: give
realtime.token(channel)only to viewers allowed on it (frontend.md). - A guard on
/admin/*also covers/admin/login: keep sign-in pages outside the guarded prefix, or guard route by route. - Handlers call
currentUser(c)again (noc.set); it is one indexed read. - Every live query and RPC checks the user itself: anyone can call them.
- Emails are unique ignoring case (
nocase); still compare the same way. - Password reset: a random token (store its hash, 1 h expiry), sent as a
link with
c.origin()by your email provider's API (fetch+ a secret, tasks.md; in dev,log.infothe link); the link's POST sets the new hash and deletes the user's sessions (deleteWhere({ userId })). - CSRF:
sameSite: "lax"cookies plus POST for every write cover forms; never change state in a GET. - After
c.setCookie(or deleting withmaxAge: 0),c.cookiein the same request sees the new value: sign in, then render as signed in. - Exact quotas (per day, per plan) instead of windows:
kv.incr(key, 1, { ttl: "1d" })and compare.
Shared accounts (households, teams)
Data belongs to a group: a members row links a user to it with a role,
and every query is scoped by the viewer's group id.
// schema.ts
export const households = table("households", { id: t.id(), name: t.string(), inviteCode: t.string().unique() })
export const members = table("members", {
id: t.id(),
householdId: t.ref(households),
userId: t.ref(users),
role: t.enum(["owner", "member"]).default("member"),
}).unique("householdId", "userId").index("userId")
export const expenses = table("expenses", { id: t.id(), householdId: t.ref(households), cents: t.number() }).index("householdId")// src/lib/households.ts
import { crypto, db, encoding, type Context, type Row } from "cubek"
import type { members } from "../../schema"
import { currentUser } from "./auth"
export type Member = Row<typeof members>
export async function membership(c: Context): Promise<Member | null> {
const u = await currentUser(c)
return u === null ? null : await db.members.findOne({ where: { userId: u.id } })
}
export async function createHousehold(userId: string, name: string): Promise<string> {
const h = await db.households.insert({ name, inviteCode: encoding.hex.encode(crypto.randomBytes(6)) })
await db.members.insert({ householdId: h.id, userId, role: "owner" })
return h.id
}
export async function join(userId: string, code: string): Promise<boolean> {
const h = await db.households.findOne({ where: { inviteCode: code } })
if (h === null) return false
const m = await db.members.findOne({ where: { householdId: h.id, userId } })
if (m === null) await db.members.insert({ householdId: h.id, userId })
return true
}- Every read and write filters by
m.householdId(find({ where: { householdId: m.householdId } })); aget(id)checksrow.householdId === m.householdId(404 otherwise), live queries too. - Owner-only actions check
m.role === "owner"; a newinviteCoderevokes the old one. - Several groups per user: the id in the path (
/h/:hid/...) and that membership (findOne({ where: { householdId: hid, userId } })).