Implement CHUNK #2: server layer, core schemas/adapters, contract tests, seed
Complete the server-backed storage layer for PubScript so the app can run against a real Fastify + SQLite backend instead of only local IndexedDB. Server (@pubscript/server): - db.ts: better-sqlite3 schema + migrations (users, shares, projects, books, chapters, entities, boards, ai_sessions, audit); WAL mode; idempotent admin bootstrap via PUBSCRIPT_ADMIN_EMAIL/PASSWORD. - auth.ts: argon2 hashing, JWT-ish session tokens, guards, auth routes. - routes.ts: REST CRUD for projects/books/chapters/entities/boards/ai_sessions with multi-tenant isolation and optimistic concurrency (baseRev). - ws.ts: Fastify websocket hub for autosave + presence events. - mapping.ts: row <-> core type conversion. Core (@pubscript/core): - Zod domain schemas (projects, books, chapters, entities, boards, ai_sessions) exported from index. - storage-local.ts (IndexedDB/Dexie) and storage-server.ts (REST) adapters implementing the StorageAdapter contract. Tests & tooling: - storage.contract.test.ts: parameterized over both adapters; wired as `pnpm contract` and added to CI. - seed.ts (`pnpm --filter server seed`): creates demo project "The Glass Harbor" — 3 chapters of prose, 4 linked characters, 1 world location, 6-node board. - Dockerfile + compose wiring for the server runtime. Acceptance: seed project opens with 3 chapters, 4 linked characters, 1 board.
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import argon2 from "argon2";
|
||||
import { ulid } from "ulid";
|
||||
import { z } from "zod";
|
||||
import type { User } from "@pubscript/core";
|
||||
import type { Db } from "./db.js";
|
||||
import { toUser, type UserRow } from "./mapping.js";
|
||||
|
||||
const SESSION_COOKIE = "ps_session";
|
||||
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
|
||||
|
||||
// ---- password hashing ----
|
||||
export const hashPassword = (pw: string): Promise<string> => argon2.hash(pw, { type: argon2.argon2id });
|
||||
export const verifyPassword = (hash: string, pw: string): Promise<boolean> => argon2.verify(hash, pw);
|
||||
|
||||
// ---- session tokens (store only the SHA-256 hash) ----
|
||||
export function newToken(): string {
|
||||
return crypto.randomBytes(32).toString("hex");
|
||||
}
|
||||
function tokenHash(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function createSession(db: Db, userId: string): string {
|
||||
const token = newToken();
|
||||
const now = Date.now();
|
||||
db.prepare("INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)").run(
|
||||
tokenHash(token),
|
||||
userId,
|
||||
new Date(now + SESSION_TTL_MS).toISOString(),
|
||||
new Date(now).toISOString(),
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
export function destroySession(db: Db, token: string): void {
|
||||
db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash(token));
|
||||
}
|
||||
|
||||
/** Resolve a presented token to a live, non-disabled user (or null). */
|
||||
export function userFromToken(db: Db, token: string | null | undefined): UserRow | null {
|
||||
if (!token) return null;
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT u.id, u.email, u.pass_hash, u.display_name, u.role, u.disabled, u.created_at
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ? AND s.expires_at > ? AND u.disabled = 0`,
|
||||
)
|
||||
.get(tokenHash(token), new Date().toISOString()) as UserRow | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
// ---- tiny in-memory fixed-window rate limiter ----
|
||||
export function makeRateLimiter(windowMs: number, max: number): (key: string) => boolean {
|
||||
const hits = new Map<string, { count: number; resetAt: number }>();
|
||||
return (key: string): boolean => {
|
||||
const now = Date.now();
|
||||
const e = hits.get(key);
|
||||
if (!e || now > e.resetAt) {
|
||||
hits.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return true;
|
||||
}
|
||||
e.count += 1;
|
||||
return e.count <= max;
|
||||
};
|
||||
}
|
||||
|
||||
// ---- request hook: populate req.user from bearer header or cookie ----
|
||||
export function bearerToken(req: FastifyRequest): string | null {
|
||||
const h = req.headers.authorization;
|
||||
if (h?.startsWith("Bearer ")) return h.slice(7);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerAuthHook(app: FastifyInstance): void {
|
||||
app.addHook("onRequest", async (req) => {
|
||||
const db = app.db;
|
||||
const token = bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
|
||||
const row = userFromToken(db, token);
|
||||
req.user = row ? toUser(row) : null;
|
||||
});
|
||||
}
|
||||
|
||||
export function setSessionCookie(reply: FastifyReply, token: string): void {
|
||||
reply.setCookie(SESSION_COOKIE, token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: SESSION_TTL_MS / 1000,
|
||||
});
|
||||
}
|
||||
export function clearSessionCookie(reply: FastifyReply): void {
|
||||
reply.clearCookie(SESSION_COOKIE, { path: "/" });
|
||||
}
|
||||
|
||||
// ---- guards ----
|
||||
export function requireAuth(req: FastifyRequest): void {
|
||||
if (!req.user) throw req.server.httpErrors.create(401, { error: "unauthorized" });
|
||||
}
|
||||
export function requireAdmin(req: FastifyRequest): void {
|
||||
requireAuth(req);
|
||||
if (req.user!.role !== "admin") throw req.server.httpErrors.create(403, { error: "forbidden" });
|
||||
}
|
||||
export function canAccessProject(db: Db, user: User, projectId: string, needWrite = false): boolean {
|
||||
const proj = db.prepare("SELECT owner_id FROM projects WHERE id = ?").get(projectId) as
|
||||
| { owner_id: string }
|
||||
| undefined;
|
||||
if (!proj) return false;
|
||||
if (proj.owner_id === user.id) return true;
|
||||
const share = db
|
||||
.prepare("SELECT perm FROM project_shares WHERE project_id = ? AND user_id = ?")
|
||||
.get(projectId, user.id) as { perm: "read" | "edit" } | undefined;
|
||||
if (!share) return false;
|
||||
return needWrite ? share.perm === "edit" : true;
|
||||
}
|
||||
|
||||
// ---- auth routes ----
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
displayName: z.string().min(1).max(80),
|
||||
});
|
||||
const loginSchema = z.object({ email: z.string().email(), password: z.string() });
|
||||
|
||||
export function registerAuthRoutes(app: FastifyInstance): void {
|
||||
const authLimiter = makeRateLimiter(60_000, 10); // 10 auth attempts / min / IP
|
||||
|
||||
app.post("/api/auth/register", async (req, reply) => {
|
||||
if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" });
|
||||
const body = registerSchema.parse(req.body);
|
||||
const db = app.db;
|
||||
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
|
||||
if (existing) throw app.httpErrors.create(409, { error: "email_taken" });
|
||||
|
||||
const isFirst = (db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n === 0;
|
||||
const role = isFirst ? "admin" : "author";
|
||||
const id = ulid();
|
||||
const now = new Date().toISOString();
|
||||
const passHash = await hashPassword(body.password);
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)",
|
||||
).run(id, body.email, passHash, body.displayName, role, now);
|
||||
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.register', ?)").run(
|
||||
ulid(),
|
||||
now,
|
||||
id,
|
||||
JSON.stringify({ email: body.email, first: isFirst }),
|
||||
);
|
||||
|
||||
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
|
||||
const token = createSession(db, id);
|
||||
setSessionCookie(reply, token);
|
||||
return { token, user: toUser(row) };
|
||||
});
|
||||
|
||||
app.post("/api/auth/login", async (req, reply) => {
|
||||
if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" });
|
||||
const body = loginSchema.parse(req.body);
|
||||
const db = app.db;
|
||||
const row = db.prepare("SELECT * FROM users WHERE email = ?").get(body.email) as UserRow | undefined;
|
||||
if (!row || row.disabled || !(await verifyPassword(row.pass_hash, body.password))) {
|
||||
throw app.httpErrors.create(401, { error: "bad_credentials" });
|
||||
}
|
||||
const token = createSession(db, row.id);
|
||||
setSessionCookie(reply, token);
|
||||
return { token, user: toUser(row) };
|
||||
});
|
||||
|
||||
app.post("/api/auth/logout", async (req, reply) => {
|
||||
const token = bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
|
||||
if (token) destroySession(app.db, token);
|
||||
clearSessionCookie(reply);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get("/api/auth/me", async (req) => {
|
||||
requireAuth(req);
|
||||
return { user: req.user };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user