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:
Schwitzz
2026-10-24 18:31:20 -05:00
parent 53f4c5b4c7
commit 1c9b531996
95 changed files with 10006 additions and 16 deletions
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@pubscript/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch --import tsx src/index.ts",
"start": "node --import tsx src/index.ts",
"seed": "tsx src/seed.ts",
"build": "tsc --noEmit",
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fastify/cookie": "^11.0.1",
"@fastify/cors": "^11.0.1",
"@fastify/static": "^8.0.3",
"@fastify/websocket": "^11.0.1",
"@pubscript/core": "workspace:*",
"argon2": "^0.41.1",
"better-sqlite3": "^11.7.0",
"fastify": "^5.2.1",
"tsx": "^4.19.2",
"ulid": "^2.3.0",
"ws": "^8.18.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.2",
"@types/ws": "^8.5.12",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { Db } from "./db.js";
import type { User } from "@pubscript/core";
declare module "fastify" {
interface FastifyInstance {
db: Db;
}
interface FastifyRequest {
user: User | null;
}
}
+181
View File
@@ -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 };
});
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Environment-driven server configuration.
*
* All values are read from `process.env` at import time with sensible
* defaults so the server boots with zero configuration for local dev.
*/
function env(key: string, fallback: string): string {
const value = process.env[key];
return value === undefined || value === "" ? fallback : value;
}
function envInt(key: string, fallback: number): number {
const raw = process.env[key];
if (raw === undefined || raw === "") return fallback;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : fallback;
}
export interface ServerConfig {
/** Path to the SQLite database file. */
dbPath: string;
/** Port the HTTP server listens on. */
port: number;
/** Origin allowed for CORS. */
origin: string;
/** Email of the bootstrap admin user. */
adminEmail: string;
/** Session token TTL in seconds. */
tokenTtl: number;
/** Version string reported by /api/version. */
version: string;
}
export const config: ServerConfig = {
dbPath: env("PUBSCRIPT_DB", "./data/pubscript.sqlite"),
port: envInt("PORT", 8080),
origin: env("PUBSCRIPT_ORIGIN", "http://localhost:5173"),
adminEmail: env("PUBSCRIPT_ADMIN_EMAIL", "admin@pubscript.local"),
tokenTtl: envInt("PUBSCRIPT_TOKEN_TTL", 60 * 60 * 24 * 30),
version: env("PUBSCRIPT_VERSION", "0.1.0"),
};
+77
View File
@@ -0,0 +1,77 @@
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { ulid } from "ulid";
import { config } from "./config.js";
export type Db = Database.Database;
/**
* Open (or create) the SQLite database and bring it up to the latest
* migration. Migrations are plain `.sql` files in `src/migrations/`, applied
* in filename order and recorded in `schema_migrations` so each runs once.
*/
export function openDb(dbPath: string = config.dbPath): Db {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
migrate(db);
return db;
}
function migrate(db: Db): void {
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)
`);
const applied = new Set(
(db.prepare("SELECT name FROM schema_migrations").all() as Array<{ name: string }>).map((r) => r.name),
);
const dir = path.resolve(import.meta.dirname, "migrations");
const files = fs
.readdirSync(dir)
.filter((f) => f.endsWith(".sql"))
.sort();
for (const file of files) {
if (applied.has(file)) continue;
const sql = fs.readFileSync(path.join(dir, file), "utf8");
const apply = db.transaction(() => {
db.exec(sql);
db.prepare("INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)").run(
file,
new Date().toISOString(),
);
});
apply();
}
}
/** Bootstrap the first admin from env on first boot (idempotent). */
export function ensureAdmin(db: Db, hashPassword: (pw: string) => Promise<string>): Promise<void> {
const email = process.env.PUBSCRIPT_ADMIN_EMAIL;
const password = process.env.PUBSCRIPT_ADMIN_PASSWORD;
if (!email || !password) return Promise.resolve();
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email) as { id: string } | undefined;
if (existing) return Promise.resolve();
return hashPassword(password).then((passHash) => {
const id = ulid();
const now = new Date().toISOString();
db.prepare(
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)",
).run(id, email, passHash, email.split("@")[0] ?? "admin", now);
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.bootstrap', ?)").run(
ulid(),
now,
id,
JSON.stringify({ email }),
);
});
}
+75
View File
@@ -0,0 +1,75 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import cookie from "@fastify/cookie";
import websocket from "@fastify/websocket";
import { ZodError } from "zod";
import { config } from "./config.js";
import { openDb, ensureAdmin } from "./db.js";
import { hashPassword, registerAuthHook, registerAuthRoutes } from "./auth.js";
import { registerRoutes } from "./routes.js";
import { registerWs } from "./ws.js";
/** A Fastify error carrying a status code and a serializable payload. */
export interface HttpError extends Error {
statusCode: number;
payload: object;
}
declare module "fastify" {
interface FastifyInstance {
/** Create a throwable HTTP error: `throw app.httpErrors.create(404, { error: "not_found" })`. */
httpErrors: { create(status: number, payload: object): HttpError };
}
}
function makeHttpError(status: number, payload: object): HttpError {
const e = new Error("http_error") as HttpError;
e.statusCode = status;
e.payload = payload;
return e;
}
export async function buildApp() {
const app = Fastify({ logger: true });
await app.register(cors, { origin: true, credentials: true });
await app.register(cookie);
await app.register(websocket);
const db = openDb();
app.decorate("db", db);
app.decorate("httpErrors", { create: makeHttpError });
await ensureAdmin(db, hashPassword);
// Serialize thrown errors into the API's `{ error: "code" }` shape.
app.setErrorHandler((err, _req, reply) => {
if (err instanceof ZodError) {
return reply.code(400).send({ error: "invalid_input", issues: err.issues });
}
const status = (err as HttpError).statusCode ?? 500;
const payload = (err as HttpError).payload ?? { error: "internal_error" };
return reply.code(status).send(payload);
});
app.get("/api/health", async () => {
let dbUp = false;
try {
dbUp = db.prepare("SELECT 1").get() != null;
} catch {
dbUp = false;
}
return { ok: dbUp, db: dbUp ? "up" : "down", version: config.version, uptime: process.uptime() };
});
registerAuthHook(app);
registerAuthRoutes(app);
registerRoutes(app);
registerWs(app);
return app;
}
export async function start() {
const app = await buildApp();
await app.listen({ port: config.port, host: "0.0.0.0" });
return app;
}
+268
View File
@@ -0,0 +1,268 @@
import type {
AISession,
AISessionRole,
Board,
Book,
Chapter,
Character,
Entity,
Note,
Project,
ProjectMeta,
User,
WorldItem,
} from "@pubscript/core";
// ---- Row shapes (mirror the SQLite schema) ----
export interface UserRow {
id: string;
email: string;
pass_hash: string;
display_name: string;
role: "admin" | "author";
disabled: number;
created_at: string;
}
export interface ShareRow {
project_id: string;
user_id: string;
perm: "read" | "edit";
}
export interface ProjectRow {
id: string;
owner_id: string;
title: string;
settings: string;
cover: string;
created_at: string;
updated_at: string;
}
export interface BookRow {
id: string;
project_id: string;
title: string;
ord: number;
}
export interface ChapterRow {
id: string;
book_id: string;
project_id: string;
title: string;
doc: string;
rev: number;
ord: number;
updated_at: string;
}
export interface EntityRow {
id: string;
project_id: string;
kind: string;
name: string;
fields: string;
created_at: string;
updated_at: string;
}
export interface BoardRow {
id: string;
project_id: string;
nodes: string;
edges: string;
updated_at: string;
}
export interface AISessionRow {
id: string;
project_id: string;
role: string;
model: string;
settings: string;
messages: string;
suggestions: string;
updated_at: string;
}
// ---- Row -> core types ----
export function toUser(r: UserRow): User {
return {
id: r.id,
email: r.email,
displayName: r.display_name,
role: r.role,
createdAt: r.created_at,
};
}
export function toProject(r: ProjectRow, shares: ShareRow[]): Project {
return {
id: r.id,
projectId: r.id,
title: r.title,
ownerId: r.owner_id,
sharedWith: shares.map((s) => ({ userId: s.user_id, perm: s.perm })),
settings: JSON.parse(r.settings),
cover: JSON.parse(r.cover),
createdAt: r.created_at,
updatedAt: r.updated_at,
};
}
export function toProjectMeta(r: ProjectRow, shares: ShareRow[], user: User): ProjectMeta {
const owned = r.owner_id === user.id;
const mine = shares.find((s) => s.user_id === user.id);
return {
id: r.id,
projectId: r.id,
title: r.title,
ownerId: r.owner_id,
cover: JSON.parse(r.cover),
createdAt: r.created_at,
updatedAt: r.updated_at,
perm: owned ? "edit" : (mine?.perm ?? "read"),
owned,
};
}
export function toBook(r: BookRow, chapters: ChapterRow[]): Book {
return {
id: r.id,
projectId: r.project_id,
title: r.title,
order: r.ord,
chapters: chapters
.slice()
.sort((a, b) => a.ord - b.ord)
.map(toChapter),
};
}
export function toChapter(r: ChapterRow): Chapter {
return {
id: r.id,
bookId: r.book_id,
projectId: r.project_id,
title: r.title,
doc: JSON.parse(r.doc),
rev: r.rev,
order: r.ord,
meta: {
wordCount: wordCount(JSON.parse(r.doc)),
scenes: [],
updatedAt: r.updated_at,
},
};
}
export function toBoard(r: BoardRow): Board {
return {
id: r.id,
projectId: r.project_id,
nodes: JSON.parse(r.nodes),
edges: JSON.parse(r.edges),
};
}
export function toAISession(r: AISessionRow): AISession {
return {
id: r.id,
projectId: r.project_id,
role: r.role as AISessionRole,
model: r.model,
settings: JSON.parse(r.settings),
messages: JSON.parse(r.messages),
suggestions: JSON.parse(r.suggestions),
};
}
export function toEntity(r: EntityRow): Entity {
const f = JSON.parse(r.fields);
if (r.kind === "character") {
return {
id: r.id,
projectId: r.project_id,
name: r.name,
role: f.role ?? "",
bio: f.bio ?? "",
appearance: f.appearance ?? "",
voice: f.voice ?? "",
relationships: f.relationships ?? "",
arc: f.arc ?? "",
status: f.status ?? "active",
tags: f.tags ?? [],
} satisfies Character;
}
if (r.kind === "world") {
return {
id: r.id,
projectId: r.project_id,
kind: f.kind ?? "location",
name: r.name,
fields: f.fields ?? {},
} satisfies WorldItem;
}
return {
id: r.id,
projectId: r.project_id,
body: f.body ?? "",
pinned: !!f.pinned,
tags: f.tags ?? [],
linkedTo: f.linkedTo ?? [],
} satisfies Note;
}
// ---- core types -> row (for writes) ----
export function entityToRow(e: Entity): Omit<EntityRow, "created_at" | "updated_at"> {
if ("fields" in e) {
// WorldItem
return {
id: e.id,
project_id: e.projectId,
kind: "world",
name: e.name,
fields: JSON.stringify({ kind: e.kind, fields: e.fields }),
};
}
if ("body" in e) {
// Note
return {
id: e.id,
project_id: e.projectId,
kind: "note",
name: e.body.slice(0, 80),
fields: JSON.stringify({ body: e.body, pinned: e.pinned, tags: e.tags, linkedTo: e.linkedTo }),
};
}
// Character
const c = e as Character;
return {
id: c.id,
project_id: c.projectId,
kind: "character",
name: c.name,
fields: JSON.stringify({
role: c.role,
bio: c.bio,
appearance: c.appearance,
voice: c.voice,
relationships: c.relationships,
arc: c.arc,
status: c.status,
tags: c.tags,
}),
};
}
// ---- helpers ----
/** Count words in a ProseMirror doc (text nodes only). */
export function wordCount(doc: unknown): number {
let count = 0;
const walk = (node: any): void => {
if (node && typeof node === "object") {
if (node.type === "text") {
const t = String(node.text ?? "");
count += t.split(/\s+/).filter(Boolean).length;
}
if (Array.isArray(node.content)) for (const c of node.content) walk(c);
}
};
walk(doc);
return count;
}
+101
View File
@@ -0,0 +1,101 @@
-- 001_initial.sql — the full PubScript schema (CHUNK #2 step 2).
-- Applied in filename order; tracked in `schema_migrations`.
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
pass_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'author' CHECK (role IN ('admin', 'author')),
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
settings TEXT NOT NULL DEFAULT '{}',
cover TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id);
CREATE TABLE IF NOT EXISTS project_shares (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
perm TEXT NOT NULL CHECK (perm IN ('read', 'edit')),
PRIMARY KEY (project_id, user_id)
);
CREATE TABLE IF NOT EXISTS books (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
ord INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_books_project ON books(project_id);
CREATE TABLE IF NOT EXISTS chapters (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
doc TEXT NOT NULL DEFAULT '{"type":"doc","content":[]}',
rev INTEGER NOT NULL DEFAULT 0,
ord INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chapters_project ON chapters(project_id);
CREATE INDEX IF NOT EXISTS idx_chapters_book ON chapters(book_id);
CREATE TABLE IF NOT EXISTS entities (
id TEXT NOT NULL,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
name TEXT NOT NULL,
fields TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (project_id, kind, id)
);
CREATE INDEX IF NOT EXISTS idx_entities_project_kind ON entities(project_id, kind);
CREATE TABLE IF NOT EXISTS boards (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
nodes TEXT NOT NULL DEFAULT '[]',
edges TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_boards_project ON boards(project_id);
CREATE TABLE IF NOT EXISTS ai_sessions (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
role TEXT NOT NULL,
model TEXT NOT NULL,
settings TEXT NOT NULL DEFAULT '{}',
messages TEXT NOT NULL DEFAULT '[]',
suggestions TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ai_sessions_project ON ai_sessions(project_id);
CREATE TABLE IF NOT EXISTS audit (
id TEXT PRIMARY KEY,
at TEXT NOT NULL,
user_id TEXT,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit(at);
+584
View File
@@ -0,0 +1,584 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { ulid } from "ulid";
import {
requireAuth,
requireAdmin,
canAccessProject,
} from "./auth.js";
import {
toProject,
toProjectMeta,
toBook,
toChapter,
toBoard,
toAISession,
toEntity,
entityToRow,
wordCount,
type ProjectRow,
type ShareRow,
type BookRow,
type ChapterRow,
type EntityRow,
type BoardRow,
type AISessionRow,
} from "./mapping.js";
// ---- input schemas ----
const projectCreate = z.object({ title: z.string().min(1).max(200) });
const projectPatch = z.object({
title: z.string().min(1).max(200).optional(),
settings: z.record(z.unknown()).optional(),
cover: z.object({ color: z.string(), emoji: z.string().optional() }).optional(),
});
const shareAdd = z.object({ userId: z.string(), perm: z.enum(["read", "edit"]) });
const bookCreate = z.object({ title: z.string().min(1).max(200) });
const bookPatch = z.object({
title: z.string().min(1).max(200).optional(),
order: z.number().int().optional(),
});
const chapterCreate = z.object({ title: z.string().min(1).max(200) });
const chapterPatch = z.object({
title: z.string().min(1).max(200).optional(),
doc: z.object({ type: z.literal("doc"), content: z.array(z.any()) }).optional(),
});
const autosaveBody = z.object({
rev: z.number().int(),
doc: z.object({ type: z.literal("doc"), content: z.array(z.any()) }),
wordCount: z.number().int().optional(),
});
const entityCreate = z.object({
id: z.string().optional(),
kind: z.enum(["character", "world", "note"]),
name: z.string().optional(),
body: z.string().optional(),
fields: z.record(z.string()).optional(),
role: z.string().optional(),
bio: z.string().optional(),
appearance: z.string().optional(),
voice: z.string().optional(),
relationships: z.string().optional(),
arc: z.string().optional(),
status: z.enum(["active", "archived"]).optional(),
tags: z.array(z.string()).optional(),
pinned: z.boolean().optional(),
linkedTo: z.array(z.string()).optional(),
});
const boardCreate = z.object({
id: z.string().optional(),
nodes: z.array(z.any()).default([]),
edges: z.array(z.any()).default([]),
});
const boardPatch = z.object({
nodes: z.array(z.any()).optional(),
edges: z.array(z.any()).optional(),
});
const aiSessionCreate = z.object({
id: z.string().optional(),
role: z.string().min(1),
model: z.string().min(1),
settings: z.record(z.unknown()).default({}),
messages: z.array(z.any()).optional(),
suggestions: z.array(z.any()).optional(),
});
const aiMessageAppend = z.object({ role: z.string(), content: z.string() });
const aiSuggestionAdd = z.object({ kind: z.string(), text: z.string() });
const userPatch = z.object({
role: z.enum(["admin", "author"]).optional(),
disabled: z.boolean().optional(),
displayName: z.string().min(1).max(80).optional(),
});
// ---- helpers ----
function requireProjectAccess(app: FastifyInstance, req: any, projectId: string, needWrite = false): void {
requireAuth(req);
if (!canAccessProject(app.db, req.user, projectId, needWrite)) {
throw app.httpErrors.create(403, { error: "forbidden" });
}
}
function now(): string {
return new Date().toISOString();
}
function sharesFor(db: any, projectId: string): ShareRow[] {
return db.prepare("SELECT project_id, user_id, perm FROM project_shares WHERE project_id = ?").all(projectId) as ShareRow[];
}
function projectRow(db: any, id: string): ProjectRow | undefined {
return db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as ProjectRow | undefined;
}
export function registerRoutes(app: FastifyInstance): void {
const db = app.db;
// ---------- Projects ----------
app.get("/api/projects", async (req) => {
requireAuth(req);
const u = req.user!;
const owned = db.prepare("SELECT * FROM projects WHERE owner_id = ?").all(u.id) as ProjectRow[];
const shared = db
.prepare("SELECT p.* FROM projects p JOIN project_shares s ON s.project_id = p.id WHERE s.user_id = ?")
.all(u.id) as ProjectRow[];
const byId = new Map<string, ProjectRow>();
for (const r of [...owned, ...shared]) byId.set(r.id, r);
return { projects: [...byId.values()].map((r) => toProjectMeta(r, sharesFor(db, r.id), u)) };
});
app.post("/api/projects", async (req) => {
requireAuth(req);
const { title } = projectCreate.parse(req.body);
const id = ulid();
const t = now();
db.prepare(
"INSERT INTO projects (id, owner_id, title, settings, cover, created_at, updated_at) VALUES (?, ?, ?, '{}', '{}', ?, ?)",
).run(id, req.user!.id, title, t, t);
const row = projectRow(db, id)!;
return { project: toProject(row, []) };
});
app.get("/api/projects/:id", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id);
const row = projectRow(db, id);
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
return { project: toProject(row, sharesFor(db, id)) };
});
app.patch("/api/projects/:id", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id, true);
const body = projectPatch.parse(req.body);
const row = projectRow(db, id);
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
const title = body.title ?? row.title;
const settings = body.settings ? JSON.stringify(body.settings) : row.settings;
const cover = body.cover ? JSON.stringify(body.cover) : row.cover;
const t = now();
db.prepare("UPDATE projects SET title = ?, settings = ?, cover = ?, updated_at = ? WHERE id = ?").run(
title,
settings,
cover,
t,
id,
);
return { project: toProject({ ...row, title, settings, cover, updated_at: t }, sharesFor(db, id)) };
});
app.delete("/api/projects/:id", async (req) => {
const { id } = req.params as { id: string };
requireAuth(req);
const row = projectRow(db, id);
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
if (row.owner_id !== req.user!.id) throw app.httpErrors.create(403, { error: "forbidden" });
db.prepare("DELETE FROM projects WHERE id = ?").run(id);
return { ok: true };
});
app.post("/api/projects/:id/shares", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id, true);
const { userId, perm } = shareAdd.parse(req.body);
db.prepare("INSERT OR REPLACE INTO project_shares (project_id, user_id, perm) VALUES (?, ?, ?)").run(id, userId, perm);
return { ok: true };
});
app.delete("/api/projects/:id/shares/:userId", async (req) => {
const { id, userId } = req.params as { id: string; userId: string };
requireProjectAccess(app, req, id, true);
db.prepare("DELETE FROM project_shares WHERE project_id = ? AND user_id = ?").run(id, userId);
return { ok: true };
});
// ---------- Books ----------
app.get("/api/projects/:id/books", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id);
const books = db.prepare("SELECT * FROM books WHERE project_id = ? ORDER BY ord").all(id) as BookRow[];
return {
books: books.map((b) => {
const chapters = db.prepare("SELECT * FROM chapters WHERE book_id = ?").all(b.id) as ChapterRow[];
return toBook(b, chapters);
}),
};
});
app.post("/api/projects/:id/books", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id, true);
const { title } = bookCreate.parse(req.body);
const bid = ulid();
const ord = (db.prepare("SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM books WHERE project_id = ?").get(id) as { n: number }).n;
db.prepare("INSERT INTO books (id, project_id, title, ord) VALUES (?, ?, ?, ?)").run(bid, id, title, ord);
const row = db.prepare("SELECT * FROM books WHERE id = ?").get(bid) as BookRow;
return { book: toBook(row, []) };
});
app.patch("/api/books/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM books WHERE id = ?").get(id) as BookRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const body = bookPatch.parse(req.body);
const title = body.title ?? row.title;
const ord = body.order ?? row.ord;
db.prepare("UPDATE books SET title = ?, ord = ? WHERE id = ?").run(title, ord, id);
const chapters = db.prepare("SELECT * FROM chapters WHERE book_id = ?").all(id) as ChapterRow[];
return { book: toBook({ ...row, title, ord }, chapters) };
});
app.delete("/api/books/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM books WHERE id = ?").get(id) as BookRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
db.prepare("DELETE FROM books WHERE id = ?").run(id);
return { ok: true };
});
// ---------- Chapters ----------
app.get("/api/books/:id/chapters", async (req) => {
const { id } = req.params as { id: string };
const book = db.prepare("SELECT * FROM books WHERE id = ?").get(id) as BookRow | undefined;
if (!book) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, book.project_id);
const chapters = db.prepare("SELECT * FROM chapters WHERE book_id = ? ORDER BY ord").all(id) as ChapterRow[];
return { chapters: chapters.map(toChapter) };
});
app.post("/api/books/:id/chapters", async (req) => {
const { id } = req.params as { id: string };
const book = db.prepare("SELECT * FROM books WHERE id = ?").get(id) as BookRow | undefined;
if (!book) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, book.project_id, true);
const { title } = chapterCreate.parse(req.body);
const cid = ulid();
const ord = (db.prepare("SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM chapters WHERE book_id = ?").get(id) as { n: number }).n;
const t = now();
db.prepare(
"INSERT INTO chapters (id, book_id, project_id, title, doc, rev, ord, updated_at) VALUES (?, ?, ?, ?, '{\"type\":\"doc\",\"content\":[]}', 0, ?, ?)",
).run(cid, id, book.project_id, title, ord, t);
const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(cid) as ChapterRow;
return { chapter: toChapter(row) };
});
app.get("/api/chapters/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id);
return { chapter: toChapter(row) };
});
app.patch("/api/chapters/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const body = chapterPatch.parse(req.body);
const title = body.title ?? row.title;
const doc = body.doc ? JSON.stringify(body.doc) : row.doc;
const t = now();
db.prepare("UPDATE chapters SET title = ?, doc = ?, rev = rev + 1, updated_at = ? WHERE id = ?").run(title, doc, t, id);
const updated = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow;
return { chapter: toChapter(updated) };
});
// Optimistic autosave (CHUNK #2 acceptance criterion).
app.post("/api/chapters/:id/autosave", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const { rev, doc, wordCount: wc } = autosaveBody.parse(req.body);
if (rev === row.rev) {
const t = now();
const count = wc ?? wordCount(doc);
db.prepare("UPDATE chapters SET doc = ?, rev = rev + 1, updated_at = ? WHERE id = ?").run(JSON.stringify(doc), t, id);
return { ok: true, rev: row.rev + 1, wordCount: count };
}
// Stale client — return the server's current state so it can reconcile.
throw app.httpErrors.create(409, {
error: "conflict",
serverRev: row.rev,
serverDoc: JSON.parse(row.doc),
});
});
app.delete("/api/chapters/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
db.prepare("DELETE FROM chapters WHERE id = ?").run(id);
return { ok: true };
});
// ---------- Entities (characters / world / notes) ----------
app.get("/api/projects/:id/entities", async (req) => {
const { id } = req.params as { id: string };
const { kind } = req.query as { kind?: string };
requireProjectAccess(app, req, id);
let rows: EntityRow[];
if (kind) {
rows = db.prepare("SELECT * FROM entities WHERE project_id = ? AND kind = ?").all(id, kind) as EntityRow[];
} else {
rows = db.prepare("SELECT * FROM entities WHERE project_id = ?").all(id) as EntityRow[];
}
return { entities: rows.map(toEntity) };
});
app.post("/api/projects/:id/entities", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id, true);
const body = entityCreate.parse(req.body);
const eid = body.id ?? ulid();
const t = now();
// Build the core Entity from the input, then map to a row.
let entity: any;
if (body.kind === "character") {
entity = {
id: eid, projectId: id, name: body.name ?? "", role: body.role ?? "", bio: body.bio ?? "",
appearance: body.appearance ?? "", voice: body.voice ?? "", relationships: body.relationships ?? "",
arc: body.arc ?? "", status: body.status ?? "active", tags: body.tags ?? [],
};
} else if (body.kind === "world") {
entity = { id: eid, projectId: id, kind: body.fields?.kind ?? "location", name: body.name ?? "", fields: body.fields ?? {} };
} else {
entity = { id: eid, projectId: id, body: body.body ?? "", pinned: body.pinned ?? false, tags: body.tags ?? [], linkedTo: body.linkedTo ?? [] };
}
const row = entityToRow(entity);
db.prepare(
`INSERT INTO entities (id, project_id, kind, name, fields, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id, kind, id) DO UPDATE SET
name = excluded.name, fields = excluded.fields, updated_at = excluded.updated_at`,
).run(eid, id, row.kind, row.name, row.fields, t, t);
const stored = db.prepare("SELECT * FROM entities WHERE project_id = ? AND id = ?").get(id, eid) as EntityRow;
return { entity: toEntity(stored) };
});
app.get("/api/projects/:projectId/entities/:kind/:id", async (req) => {
const { projectId, kind, id } = req.params as { projectId: string; kind: string; id: string };
requireProjectAccess(app, req, projectId);
const row = db.prepare("SELECT * FROM entities WHERE project_id = ? AND kind = ? AND id = ?").get(projectId, kind, id) as EntityRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
return { entity: toEntity(row) };
});
app.patch("/api/projects/:projectId/entities/:kind/:id", async (req) => {
const { projectId, kind, id } = req.params as { projectId: string; kind: string; id: string };
requireProjectAccess(app, req, projectId, true);
const existing = db.prepare("SELECT * FROM entities WHERE project_id = ? AND kind = ? AND id = ?").get(projectId, kind, id) as EntityRow | undefined;
if (!existing) throw app.httpErrors.create(404, { error: "not_found" });
const body = entityCreate.parse(req.body);
let entity: any;
if (kind === "character") {
entity = {
id, projectId, name: body.name ?? existing.name, role: body.role ?? "", bio: body.bio ?? "",
appearance: body.appearance ?? "", voice: body.voice ?? "", relationships: body.relationships ?? "",
arc: body.arc ?? "", status: body.status ?? "active", tags: body.tags ?? [],
};
} else if (kind === "world") {
entity = { id, projectId, kind: body.fields?.kind ?? "location", name: body.name ?? existing.name, fields: body.fields ?? {} };
} else {
entity = { id, projectId, body: body.body ?? "", pinned: body.pinned ?? false, tags: body.tags ?? [], linkedTo: body.linkedTo ?? [] };
}
const row = entityToRow(entity);
const t = now();
db.prepare(
"UPDATE entities SET name = ?, fields = ?, updated_at = ? WHERE project_id = ? AND kind = ? AND id = ?",
).run(row.name, row.fields, t, projectId, kind, id);
const stored = db.prepare("SELECT * FROM entities WHERE project_id = ? AND kind = ? AND id = ?").get(projectId, kind, id) as EntityRow;
return { entity: toEntity(stored) };
});
app.delete("/api/projects/:projectId/entities/:kind/:id", async (req) => {
const { projectId, kind, id } = req.params as { projectId: string; kind: string; id: string };
requireProjectAccess(app, req, projectId, true);
const res = db.prepare("DELETE FROM entities WHERE project_id = ? AND kind = ? AND id = ?").run(projectId, kind, id);
if (res.changes === 0) throw app.httpErrors.create(404, { error: "not_found" });
return { ok: true };
});
// ---------- Boards ----------
app.get("/api/projects/:id/boards", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id);
const boards = db.prepare("SELECT * FROM boards WHERE project_id = ?").all(id) as BoardRow[];
return { boards: boards.map(toBoard) };
});
app.post("/api/projects/:id/boards", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id, true);
const body = boardCreate.parse(req.body);
const bid = body.id ?? ulid();
const t = now();
db.prepare(`INSERT INTO boards (id, project_id, nodes, edges, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
project_id = excluded.project_id, nodes = excluded.nodes, edges = excluded.edges, updated_at = excluded.updated_at`).run(
bid, id, JSON.stringify(body.nodes), JSON.stringify(body.edges), t,
);
const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(bid) as BoardRow;
return { board: toBoard(row) };
});
app.get("/api/boards/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(id) as BoardRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id);
return { board: toBoard(row) };
});
app.patch("/api/boards/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(id) as BoardRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const body = boardPatch.parse(req.body);
const nodes = body.nodes ? JSON.stringify(body.nodes) : row.nodes;
const edges = body.edges ? JSON.stringify(body.edges) : row.edges;
const t = now();
db.prepare("UPDATE boards SET nodes = ?, edges = ?, updated_at = ? WHERE id = ?").run(nodes, edges, t, id);
const updated = db.prepare("SELECT * FROM boards WHERE id = ?").get(id) as BoardRow;
return { board: toBoard(updated) };
});
app.delete("/api/boards/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(id) as BoardRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
db.prepare("DELETE FROM boards WHERE id = ?").run(id);
return { ok: true };
});
// ---------- AI sessions ----------
app.get("/api/projects/:id/ai/sessions", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id);
const rows = db.prepare("SELECT * FROM ai_sessions WHERE project_id = ?").all(id) as AISessionRow[];
return { sessions: rows.map(toAISession) };
});
app.post("/api/projects/:id/ai/sessions", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id, true);
const body = aiSessionCreate.parse(req.body);
const sid = body.id ?? ulid();
const t = now();
db.prepare(
`INSERT INTO ai_sessions (id, project_id, role, model, settings, messages, suggestions, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
project_id = excluded.project_id, role = excluded.role, model = excluded.model,
settings = excluded.settings, messages = excluded.messages, suggestions = excluded.suggestions,
updated_at = excluded.updated_at`,
).run(sid, id, body.role, body.model, JSON.stringify(body.settings),
JSON.stringify(body.messages ?? []), JSON.stringify(body.suggestions ?? []), t);
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(sid) as AISessionRow;
return { session: toAISession(row) };
});
app.get("/api/ai/sessions/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id);
return { session: toAISession(row) };
});
app.patch("/api/ai/sessions/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const body = z.object({ settings: z.record(z.unknown()).optional(), model: z.string().optional() }).parse(req.body);
const settings = body.settings ? JSON.stringify(body.settings) : row.settings;
const model = body.model ?? row.model;
const t = now();
db.prepare("UPDATE ai_sessions SET settings = ?, model = ?, updated_at = ? WHERE id = ?").run(settings, model, t, id);
const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
return { session: toAISession(updated) };
});
app.delete("/api/ai/sessions/:id", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
db.prepare("DELETE FROM ai_sessions WHERE id = ?").run(id);
return { ok: true };
});
app.post("/api/ai/sessions/:id/messages", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const { role, content } = aiMessageAppend.parse(req.body);
const messages = JSON.parse(row.messages) as any[];
messages.push({ role, content, at: now() });
const t = now();
db.prepare("UPDATE ai_sessions SET messages = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(messages), t, id);
const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
return { session: toAISession(updated) };
});
app.post("/api/ai/sessions/:id/suggestions", async (req) => {
const { id } = req.params as { id: string };
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const { kind, text } = aiSuggestionAdd.parse(req.body);
const suggestions = JSON.parse(row.suggestions) as any[];
const sug = { id: ulid(), kind, text, at: now() };
suggestions.push(sug);
const t = now();
db.prepare("UPDATE ai_sessions SET suggestions = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(suggestions), t, id);
const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
return { session: toAISession(updated) };
});
app.delete("/api/ai/sessions/:id/suggestions/:sid", async (req) => {
const { id, sid } = req.params as { id: string; sid: string };
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true);
const suggestions = (JSON.parse(row.suggestions) as any[]).filter((s) => s.id !== sid);
const t = now();
db.prepare("UPDATE ai_sessions SET suggestions = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(suggestions), t, id);
const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
return { session: toAISession(updated) };
});
// ---------- Admin ----------
app.get("/api/admin/users", async (req) => {
requireAdmin(req);
const rows = db.prepare("SELECT id, email, display_name, role, disabled, created_at FROM users").all() as any[];
return { users: rows };
});
app.patch("/api/admin/users/:id", async (req) => {
requireAdmin(req);
const { id } = req.params as { id: string };
const body = userPatch.parse(req.body);
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as any;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
const role = body.role ?? row.role;
const disabled = body.disabled ? 1 : row.disabled;
const displayName = body.displayName ?? row.display_name;
db.prepare("UPDATE users SET role = ?, disabled = ?, display_name = ? WHERE id = ?").run(role, disabled, displayName, id);
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.user_update', ?)").run(
ulid(), now(), req.user!.id, JSON.stringify({ target: id, role, disabled: !!disabled }),
);
return { user: { id, email: row.email, displayName, role, disabled: !!disabled, createdAt: row.created_at } };
});
app.get("/api/admin/audit", async (req) => {
requireAdmin(req);
const limit = Math.min(Number((req.query as any).limit ?? 100), 500);
const rows = db.prepare("SELECT id, at, user_id, action, detail FROM audit ORDER BY at DESC LIMIT ?").all(limit) as any[];
return { audit: rows.map((r) => ({ ...r, detail: JSON.parse(r.detail) })) };
});
}
+275
View File
@@ -0,0 +1,275 @@
/**
* Seed script for PubScript.
*
* Creates a self-contained demo project — "The Glass Harbor" — so every later
* chunk has something concrete to show: a book with three chapters of real
* prose, four linked characters, one world location, and a six-node story
* board. Idempotent: re-running it leaves the existing demo untouched.
*
* Usage:
* pnpm --filter server seed
*
* The demo is owned by the bootstrap admin user (created by `ensureAdmin` on
* first boot, or by `PUBSCRIPT_ADMIN_EMAIL` / `PUBSCRIPT_ADMIN_PASSWORD`).
*/
import { ulid } from "ulid";
import { openDb } from "./db.js";
import { ensureAdmin } from "./db.js";
import { hashPassword } from "./auth.js";
import { entityToRow } from "./mapping.js";
import type { Character, WorldItem, Board } from "@pubscript/core";
const DEMO_TITLE = "The Glass Harbor";
/** A single ProseMirror paragraph. */
function para(text: string) {
return { type: "paragraph", content: [{ type: "text", text }] };
}
/** Build a ProseMirror doc from an array of paragraphs. */
function doc(paras: string[]) {
return { type: "doc", content: paras.map(para) };
}
/** Insert a chapter with real prose, returning its id. */
function addChapter(db: any, bookId: string, projectId: string, title: string, paras: string[]): string {
const id = ulid();
const t = new Date().toISOString();
db.prepare(
"INSERT INTO chapters (id, book_id, project_id, title, doc, rev, ord, updated_at) " +
"VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
).run(id, bookId, projectId, title, JSON.stringify(doc(paras)), paras.length, t);
return id;
}
/** Upsert a character entity. */
function addCharacter(db: any, projectId: string, c: Character): void {
const row = entityToRow(c);
const t = new Date().toISOString();
db.prepare(
`INSERT INTO entities (id, project_id, kind, name, fields, created_at, updated_at) ` +
"VALUES (?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT(project_id, kind, id) DO UPDATE SET " +
"name = excluded.name, fields = excluded.fields, updated_at = excluded.updated_at",
).run(row.id, projectId, row.kind, row.name, row.fields, t, t);
}
/** Upsert a world location entity. */
function addWorld(db: any, projectId: string, w: WorldItem): void {
const row = entityToRow(w);
const t = new Date().toISOString();
db.prepare(
`INSERT INTO entities (id, project_id, kind, name, fields, created_at, updated_at) ` +
"VALUES (?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT(project_id, kind, id) DO UPDATE SET " +
"name = excluded.name, fields = excluded.fields, updated_at = excluded.updated_at",
).run(row.id, projectId, row.kind, row.name, row.fields, t, t);
}
/** Upsert the story board with six nodes. */
function addBoard(db: any, projectId: string, board: Board): void {
const t = new Date().toISOString();
db.prepare(
`INSERT INTO boards (id, project_id, nodes, edges, updated_at) VALUES (?, ?, ?, ?, ?) ` +
"ON CONFLICT(id) DO UPDATE SET " +
"project_id = excluded.project_id, nodes = excluded.nodes, " +
"edges = excluded.edges, updated_at = excluded.updated_at",
).run(board.id, projectId, JSON.stringify(board.nodes), JSON.stringify(board.edges), t);
}
async function main(): Promise<void> {
const db = openDb();
// Ensure an admin owner exists (idempotent).
await ensureAdmin(db, hashPassword);
const owner = db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").get() as { id: string };
if (!owner) throw new Error("seed: no admin user to own the demo project");
// Idempotent: skip if the demo already exists.
const existing = db.prepare("SELECT id FROM projects WHERE title = ?").get(DEMO_TITLE) as
| { id: string }
| undefined;
if (existing) {
console.log(`seed: "${DEMO_TITLE}" already exists (id ${existing.id}); leaving it untouched.`);
db.close();
return;
}
const t = new Date().toISOString();
const projectId = ulid();
// Project.
db.prepare(
"INSERT INTO projects (id, owner_id, title, settings, cover, created_at, updated_at) VALUES (?, ?, ?, '{}', ?, ?, ?)",
).run(projectId, owner.id, DEMO_TITLE, JSON.stringify({ color: "#0f3d3e", emoji: "🌊" }), t, t);
// Book.
const bookId = ulid();
db.prepare("INSERT INTO books (id, project_id, title, ord) VALUES (?, ?, ?, 0)").run(bookId, projectId, DEMO_TITLE);
// Three chapters of real prose.
addChapter(
db,
bookId,
projectId,
"Chapter One — The Arrival",
[
"The ferry that carried Mara Vell to the Glass Harbor left the mainland before dawn, and by the time it came to rest the sea had turned the color of a bruised plum.",
"She pressed her palm against the cold window and watched the lighthouse appear out of the mist — a thin white line standing where the land gave up to the water.",
"Three years since she had last seen it, and yet it seemed to have shrunk, or perhaps she had simply grown too large for the memory of it.",
"The harbor master waited for her on the quay, wrapped in oilskin against a wind that did not so much blow as insist. He did not wave. He never waved.",
'"You came," he said, as if to say he had not expected her, and had been wrong about it all morning.',
'"The light is failing," he added. "We have had three nights without it. The fish are turning away."',
],
);
addChapter(
db,
bookId,
projectId,
"Chapter Two — The Storm",
[
"The storm arrived on the second night, and it came the way everything at the Glass Harbor came — without apology and without warning.",
"Mara climbed the one hundred and fourteen steps to the lamp, each one slick with salt spray, and found that the mechanism had seized with the same stubborn rust that had seized everything else on this rock.",
"She worked through the small hours with a wrench and her own two hands, coaxing the gears until at last the great beam swung free and threw its light out across the black water.",
"Below her, in the harbor, the boats threw their lines and danced on them, and she thought of all the sailors who had been lost here when the light was dead.",
'"Four ships in ten years," said a voice behind her. It was Old Fen, the hermit, half-frozen and grinning as if the storm were a joke only he understood.',
'"They did not die because the light failed," Fen said. "They died because they forgot that someone had to keep it."',
],
);
addChapter(
db,
bookId,
projectId,
"Chapter Three — The Glass in the Dark",
[
"In the morning the sea lay flat and exhausted, strewn with the kelp and driftwood the storm had disgorged.",
"Mara walked the shoreline and found, half-buried in the wrack, a shard of glass the size of her palm — but the glass was warm, and it held inside it a light that did not belong to the rising sun.",
"This was the secret the Glass Harbor had kept for a hundred years: the light of the lighthouse was not made, it was mined — drawn up from the deep in shards of living glass that only a keeper knew how to kindle.",
"She understood then why the old keeper had never waved, and why he had never left. Some duties are not chosen. They are inherited, like the tide.",
"When Tomas Reed returned from his rounds that evening, he found Mara at the lamp, her hands on the warm glass, and he stopped in the doorway with the old grief on his face.",
'"I can teach you," he said. "If you want it. No one has ever wanted it before."',
"Mara looked out at the water, at the fragile line of the quay, at the little lights of the town that had welcomed her without asking why she had come.",
'"I want it," she said, and the light answered for her.',
],
);
// Four linked characters.
addCharacter(db, projectId, {
id: "char-mara-vell",
projectId,
name: "Mara Vell",
role: "The New Keeper",
bio: "A cartographer who returned to the Glass Harbor she fled three years ago, carrying a grief she has never named. Precise, stubborn, and quietly afraid of being needed.",
appearance: "Late thirties, salt-short hair, salt-calloused hands, a scar through one eyebrow from a rigging line she did not remember cutting herself on.",
voice: "Dry, understated, speaks like someone counting the cost of every word.",
relationships: [
{ targetId: "char-tomas-reed", kind: "family", note: "Uncle; the keeper before her." },
{ targetId: "char-old-fen", kind: "mentor", note: "Taught her to swim in waters too cold for children." },
],
arc: "From someone running from duty to someone who chooses to keep the light.",
status: "active",
tags: ["protagonist", "keeper"],
});
addCharacter(db, projectId, {
id: "char-tomas-reed",
projectId,
name: "Tomas Reed",
role: "The Harbor Master",
bio: "The keeper of the quay and the keeper of the light before Mara. Carries the old grief of a storm he could not save. Does not wave because he believes grief is a full-time job.",
appearance: "Sixties, weathered like driftwood, one leg that aches before a storm.",
voice: "Few words. Speaks in the short sentences of a man who has learned words are expensive.",
relationships: [
{ targetId: "char-mara-vell", kind: "family", note: "Niece; the keeper he is training." },
{ targetId: "char-old-fen", kind: "ally", note: "Oldest friend and only confidant." },
],
arc: "From guarding a secret alone to trusting someone to inherit it.",
status: "active",
tags: ["mentor", "keeper"],
});
addCharacter(db, projectId, {
id: "char-iris-kade",
projectId,
name: "Iris Kade",
role: "The Smuggler",
bio: "Runs the supply boat between the harbor and the mainland, carrying glass as carefully as she carries secrets. Witty, generous, and impossible to trust with a straight face.",
appearance: "Forties, sun-leathered, a silver ring through each ear, always barefoot even in winter.",
voice: "Fast, warm, teasing. Lies beautifully and enjoys it.",
relationships: [
{ targetId: "char-mara-vell", kind: "ally", note: "Funds the lighthouse quietly; becomes Mara's ally on the mainland." },
{ targetId: "char-tomas-reed", kind: "rival", note: "Clashes over how to keep the harbor alive." },
],
arc: "From a self-interested survivor to someone with a place to belong.",
status: "active",
tags: ["ally", "rival"],
});
addCharacter(db, projectId, {
id: "char-old-fen",
projectId,
name: "Old Fen",
role: "The Hermit",
bio: "Lives in a cottage where the land gives up to the water. Has seen four ships lost and never once said how he knew it was coming. Everyone fears him and loves him in the same breath.",
appearance: "Ageless, thin, eyes the pale gray of the sea before a storm.",
voice: "Speaks in riddles that turn out to be warnings. Grins at the wrong moments.",
relationships: [
{ targetId: "char-mara-vell", kind: "mentor", note: "The child he taught to swim; the keeper he is guiding." },
{ targetId: "char-tomas-reed", kind: "ally", note: "Oldest friend; the only one who understands him." },
],
arc: "From keeper of impossible knowledge to the one who finally tells the truth.",
status: "active",
tags: ["mysterious", "hermit"],
});
// One world location.
addWorld(db, projectId, {
id: "world-glass-harbor",
projectId,
kind: "location",
name: "The Glass Harbor",
fields: {
kind: "location",
description:
"A fishing village on a wind-battered rock at the edge of the mainland shelf, lit by a lighthouse whose beam is fed by shards of living glass drawn from the deep.",
population: "Under two hundred",
economy: "Fishing, and the quiet, illegal trade in harbor-glass that keeps the light alive.",
notable:
"The Lighthouse (one hundred and fourteen steps to the lamp) · The Quay (where Tomas waits and Iris unloads her cargo) · The Wrack Line (where the sea deposits what the storm has taken).",
},
});
// Story board with six nodes.
addBoard(db, projectId, {
id: "board-story",
projectId,
nodes: [
{ id: "n1", type: "note", position: { x: 80, y: 120 }, data: { text: "Mara arrives at the harbor" } },
{ id: "n2", type: "note", position: { x: 320, y: 120 }, data: { text: "The light is failing" } },
{ id: "n3", type: "note", position: { x: 560, y: 120 }, data: { text: "The storm arrives" } },
{ id: "n4", type: "note", position: { x: 800, y: 120 }, data: { text: "Mara saves the light" } },
{ id: "n5", type: "note", position: { x: 320, y: 320 }, data: { text: "The secret of the glass" } },
{ id: "n6", type: "note", position: { x: 560, y: 320 }, data: { text: "Mara chooses the duty" } },
],
edges: [
{ id: "e1", source: "n1", target: "n2", type: "leads-to" },
{ id: "e2", source: "n2", target: "n3", type: "leads-to" },
{ id: "e3", source: "n3", target: "n4", type: "leads-to" },
{ id: "e4", source: "n4", target: "n5", type: "causes" },
{ id: "e5", source: "n5", target: "n6", type: "leads-to" },
],
});
console.log(`seed: created demo project "${DEMO_TITLE}" (id ${projectId})`);
console.log(`seed: 1 book, 3 chapters, 4 characters, 1 world location, 1 board`);
console.log(`seed: owner: ${owner.id}`);
console.log(`seed: login with PUBSCRIPT_ADMIN_EMAIL=${process.env.PUBSCRIPT_ADMIN_EMAIL ?? "admin@pubscript.local"}`);
db.close();
}
main().catch((err) => {
console.error("seed failed:", err);
process.exit(1);
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect, afterEach } from "vitest";
import { buildApp } from "./index.js";
afterEach(async () => {
// Each test boots its own server; close it to free the DB handle.
// (No shared server state is asserted across tests.)
});
describe("server config", () => {
it("exposes sensible defaults", async () => {
const { config } = await import("./config.js");
expect(config.port).toBe(8080);
expect(config.version).toBeTruthy();
expect(typeof config.dbPath).toBe("string");
});
});
describe("health endpoint", () => {
it("reports ok and db up", async () => {
const app = await buildApp();
try {
const res = await app.inject({ method: "GET", url: "/api/health" });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.ok).toBe(true);
expect(body.db).toBe("up");
expect(body.version).toBeTruthy();
} finally {
await app.close();
}
});
});
+66
View File
@@ -0,0 +1,66 @@
import type { FastifyInstance } from "fastify";
import { WebSocket } from "ws";
import { canAccessProject, bearerToken, userFromToken } from "./auth.js";
import { toUser } from "./mapping.js";
interface Client {
ws: WebSocket;
projectId: string;
userId: string;
}
/**
* Register the `/api/ws` endpoint. Clients connect with `?projectId=X` and an
* auth token (bearer header or cookie). The server validates access, then
* relays `autosave` and `presence` events to the other clients in the same
* project (never back to the sender).
*/
export function registerWs(app: FastifyInstance): void {
const rooms = new Map<string, Set<Client>>();
app.get("/api/ws", { websocket: true }, (socket: WebSocket, req) => {
const db = app.db;
const query = req.query as { projectId?: string };
const projectId = query.projectId;
// Authenticate (bearer header or cookie).
const cookieToken = (req.headers.cookie ?? "")
.split("; ")
.find((c) => c.startsWith("ps_session="))
?.split("=")[1] ?? null;
const token = bearerToken(req) ?? cookieToken;
const row = token ? userFromToken(db, token) : null;
if (!row || !projectId || !canAccessProject(db, toUser(row), projectId)) {
socket.close(4401);
return;
}
const client: Client = { ws: socket, projectId, userId: row.id };
const room = rooms.get(projectId) ?? new Set<Client>();
room.add(client);
rooms.set(projectId, room);
socket.on("message", (data: WebSocket.RawData) => {
let msg: any;
try {
msg = JSON.parse(String(data));
} catch {
return;
}
if (msg?.type !== "autosave" && msg?.type !== "presence") return;
const payload = JSON.stringify({ ...msg, userId: client.userId, at: new Date().toISOString() });
for (const other of room) {
if (other !== client && other.ws.readyState === WebSocket.OPEN) {
other.ws.send(payload);
}
}
});
const cleanup = (): void => {
room.delete(client);
if (room.size === 0) rooms.delete(projectId);
};
socket.on("close", cleanup);
socket.on("error", cleanup);
});
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
},
});