From afbc5e52541c921cc44d5e663ad382b9908db9da Mon Sep 17 00:00:00 2001 From: nholtware Date: Thu, 5 Nov 2026 13:33:51 -0600 Subject: [PATCH] feat: passport-local authentication, story ownership Co-Authored-By: Claude Sonnet 5.5 --- migrations/0003_auth.sql | 26 +++++ package-lock.json | 194 +++++++++++++++++++++++++++++++++ package.json | 6 + src/app.ts | 18 +++ src/lib/auth.ts | 113 +++++++++++++++++++ src/lib/config.ts | 13 +++ src/lib/passwords.ts | 29 +++++ src/lib/runtime-config.test.ts | 10 +- src/lib/session-store.ts | 70 ++++++++++++ src/lib/story-store.ts | 37 +++++-- src/lib/user-store.ts | 89 +++++++++++++++ src/routes/auth.test.ts | 150 +++++++++++++++++++++++++ src/routes/auth.ts | 102 +++++++++++++++++ src/routes/stories.test.ts | 106 +++++++++++++----- src/routes/stories.ts | 103 +++++++++++------ src/runtime-config.schema.ts | 2 + 16 files changed, 995 insertions(+), 73 deletions(-) create mode 100644 migrations/0003_auth.sql create mode 100644 src/lib/auth.ts create mode 100644 src/lib/passwords.ts create mode 100644 src/lib/session-store.ts create mode 100644 src/lib/user-store.ts create mode 100644 src/routes/auth.test.ts create mode 100644 src/routes/auth.ts diff --git a/migrations/0003_auth.sql b/migrations/0003_auth.sql new file mode 100644 index 0000000..ef82bce --- /dev/null +++ b/migrations/0003_auth.sql @@ -0,0 +1,26 @@ +-- Up Migration +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX users_username_lower_idx ON users (lower(username)); + +-- NULL owner = legacy story from before accounts existed: readable if +-- published, but editable by nobody. +ALTER TABLE stories ADD COLUMN owner_id UUID REFERENCES users(id) ON DELETE CASCADE; +CREATE INDEX stories_owner_idx ON stories (owner_id, updated_at DESC); + +CREATE TABLE sessions ( + sid TEXT PRIMARY KEY, + sess JSONB NOT NULL, + expire TIMESTAMPTZ NOT NULL +); +CREATE INDEX sessions_expire_idx ON sessions (expire); + +-- Down Migration +DROP TABLE sessions; +DROP INDEX stories_owner_idx; +ALTER TABLE stories DROP COLUMN owner_id; +DROP TABLE users; diff --git a/package-lock.json b/package-lock.json index 8e08495..60601da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,11 @@ "version": "1.0.0", "dependencies": { "express": "^5.2.0", + "express-session": "^1.19.0", "ioredis": "^5.4.0", "node-pg-migrate": "^8.0.0", + "passport": "^0.7.0", + "passport-local": "^1.0.0", "pg": "^8.13.0", "pino": "^9.5.0", "pino-http": "^10.3.0", @@ -22,7 +25,10 @@ "@commitlint/cli": "^19.5.0", "@commitlint/config-conventional": "^19.5.0", "@types/express": "^5.0.0", + "@types/express-session": "^1.19.0", "@types/node": "^22.0.0", + "@types/passport": "^1.0.17", + "@types/passport-local": "^1.0.38", "@types/pg": "^8.11.0", "@types/supertest": "^6.0.0", "eslint": "^9.13.0", @@ -1583,6 +1589,16 @@ "@types/send": "*" } }, + "node_modules/@types/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-GbypG0bog68UbOq2tSAp7SclvCUm3ha1uDi58OPRGK1NfRvCIu7Gz0M7fTGtpNG1T9a29GpuurQj9zEcT/lMXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -1614,6 +1630,39 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-local": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", + "integrity": "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/pg": { "version": "8.23.1", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", @@ -3303,6 +3352,50 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4677,6 +4770,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4795,6 +4897,43 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", @@ -4857,6 +4996,11 @@ "node": ">= 14.16" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pg": { "version": "8.23.1", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", @@ -5189,6 +5333,15 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", @@ -5347,6 +5500,26 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -5955,6 +6128,18 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -6015,6 +6200,15 @@ "punycode": "^2.1.0" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index ffb4c71..9b5e4f1 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,11 @@ }, "dependencies": { "express": "^5.2.0", + "express-session": "^1.19.0", "ioredis": "^5.4.0", "node-pg-migrate": "^8.0.0", + "passport": "^0.7.0", + "passport-local": "^1.0.0", "pg": "^8.13.0", "pino": "^9.5.0", "pino-http": "^10.3.0", @@ -32,7 +35,10 @@ "@commitlint/cli": "^19.5.0", "@commitlint/config-conventional": "^19.5.0", "@types/express": "^5.0.0", + "@types/express-session": "^1.19.0", "@types/node": "^22.0.0", + "@types/passport": "^1.0.17", + "@types/passport-local": "^1.0.38", "@types/pg": "^8.11.0", "@types/supertest": "^6.0.0", "eslint": "^9.13.0", diff --git a/src/app.ts b/src/app.ts index 0e573c9..d7f3e90 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,6 +14,13 @@ import { adminRouter } from "./routes/admin.js"; import { healthRouter } from "./routes/health.js"; import { logDemoRouter } from "./routes/log-demo.js"; import { rootRouter } from "./routes/root.js"; +import { authRouter } from "./routes/auth.js"; +import { createAuth } from "./lib/auth.js"; +import { + memoryUserStore, + pgUserStore, + type UserStore, +} from "./lib/user-store.js"; import { storiesRouter } from "./routes/stories.js"; import { memoryStore, pgStore, type StoryStore } from "./lib/story-store.js"; @@ -25,6 +32,7 @@ export interface Deps { flags?: Flags; runtimeConfig?: RuntimeConfig; store?: StoryStore; + users?: UserStore; } export function createApp({ @@ -35,6 +43,7 @@ export function createApp({ flags, runtimeConfig = staticRuntimeConfig(), store = db ? pgStore(db) : memoryStore(), + users = db ? pgUserStore(db) : memoryUserStore(), }: Deps) { const app = express(); app.disable("x-powered-by"); @@ -63,6 +72,15 @@ export function createApp({ app.use(healthRouter({ db, cache })); app.use(rootRouter({ config, db, cache, flags, runtimeConfig })); + const auth = createAuth({ config, users, db }); + app.use(auth.middleware); + app.use( + authRouter({ + auth, + users, + registrationOpen: () => runtimeConfig.get().registrationOpen, + }), + ); app.use(storiesRouter({ store })); app.use(adminRouter({ runtimeConfig })); app.use(logDemoRouter()); diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..34a42e2 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,113 @@ +import type { Request, RequestHandler } from "express"; +import session from "express-session"; +import { Passport } from "passport"; +import { Strategy as LocalStrategy } from "passport-local"; +import type { Config } from "./config.js"; +import type { Db } from "./db.js"; +import { hashPassword, verifyPassword } from "./passwords.js"; +import { PgSessionStore } from "./session-store.js"; +import type { User, UserStore } from "./user-store.js"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface User { + id: string; + username: string; + } + } +} + +export const SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000; + +// Verified against when the username doesn't exist, so response time doesn't +// reveal which usernames are registered. +const dummyHash = await hashPassword("not-a-real-password"); + +export interface Auth { + /** session + passport.initialize + passport.session, in order. */ + middleware: RequestHandler[]; + authenticate: RequestHandler; +} + +export function createAuth(opts: { + config: Config; + users: UserStore; + db?: Db; +}): Auth { + const { config, users, db } = opts; + const passport = new Passport(); + + passport.use( + new LocalStrategy(async (username, password, done) => { + try { + const user = await users.findByUsername(username); + const ok = await verifyPassword( + password, + user?.passwordHash ?? dummyHash, + ); + done( + null, + ok && user ? { id: user.id, username: user.username } : false, + ); + } catch (e) { + done(e); + } + }), + ); + passport.serializeUser((u, done) => done(null, (u as User).id)); + passport.deserializeUser(async (id: string, done) => { + try { + // false (not an error) drops a session whose user no longer exists. + done(null, (await users.findById(id)) ?? false); + } catch (e) { + done(e); + } + }); + + const secret = + config.SESSION_SECRET ?? "dev-only-secret-not-for-production-use"; + return { + middleware: [ + session({ + name: "adventure.sid", + secret, + resave: false, + saveUninitialized: false, + store: db ? new PgSessionStore(db, SESSION_TTL_MS) : undefined, + cookie: { + httpOnly: true, + sameSite: "lax", + secure: config.COOKIE_SECURE, + maxAge: SESSION_TTL_MS, + }, + }), + passport.initialize(), + passport.session(), + ], + authenticate: (req, res, next) => + passport.authenticate("local", (err: unknown, user?: User | false) => { + if (err) return next(err); + if (!user) { + return void res + .status(401) + .json({ error: "invalid username or password" }); + } + // req.login regenerates the session id (fixation defence). + req.login(user, (e) => { + if (e) return next(e); + res.json({ user: { id: user.id, username: user.username } }); + }); + })(req, res, next), + }; +} + +export const requireAuth: RequestHandler = (req, res, next) => { + if (!req.isAuthenticated()) { + res.status(401).json({ error: "authentication required" }); + return; + } + next(); +}; + +export const currentUser = (req: Request): User | undefined => req.user; diff --git a/src/lib/config.ts b/src/lib/config.ts index 5864e83..fd7f77a 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -10,6 +10,14 @@ const schema = z GIT_SHA: z.string().default("dev"), DATABASE_URL: z.string().url().optional(), REDIS_URL: z.string().url().optional(), + // Session cookie signing. Required once there is a database (several + // pods must agree). Created per namespace by kubectl, never in git. + SESSION_SECRET: z.string().min(32).optional(), + // Set true once the site is served over HTTPS. + COOKIE_SECURE: z + .enum(["true", "false"]) + .default("false") + .transform((v) => v === "true"), // Feature flags (Unleash). Unset UNLEASH_URL = every flag reads false. UNLEASH_URL: z.string().url().optional(), UNLEASH_API_TOKEN: z.string().min(1).optional(), @@ -18,6 +26,11 @@ const schema = z // Unset = schema defaults, no reload. RUNTIME_CONFIG_PATH: z.string().min(1).optional(), }) + .refine((c) => !c.DATABASE_URL || c.SESSION_SECRET, { + message: + "SESSION_SECRET (>= 32 chars) is required when DATABASE_URL is set", + path: ["SESSION_SECRET"], + }) .refine((c) => !c.UNLEASH_URL || c.UNLEASH_API_TOKEN, { message: "UNLEASH_API_TOKEN is required when UNLEASH_URL is set", path: ["UNLEASH_API_TOKEN"], diff --git a/src/lib/passwords.ts b/src/lib/passwords.ts new file mode 100644 index 0000000..d4e7c7e --- /dev/null +++ b/src/lib/passwords.ts @@ -0,0 +1,29 @@ +import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; + +// scrypt via node:crypto: no native dependency. Format: scrypt$$ +const KEYLEN = 64; + +function derive(password: string, salt: Buffer): Promise { + return new Promise((resolve, reject) => + scrypt(password, salt, KEYLEN, (err, key) => + err ? reject(err) : resolve(key), + ), + ); +} + +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16); + const key = await derive(password, salt); + return `scrypt$${salt.toString("hex")}$${key.toString("hex")}`; +} + +export async function verifyPassword( + password: string, + stored: string, +): Promise { + const [scheme, saltHex, hashHex] = stored.split("$"); + if (scheme !== "scrypt" || !saltHex || !hashHex) return false; + const expected = Buffer.from(hashHex, "hex"); + const actual = await derive(password, Buffer.from(saltHex, "hex")); + return expected.length === actual.length && timingSafeEqual(expected, actual); +} diff --git a/src/lib/runtime-config.test.ts b/src/lib/runtime-config.test.ts index afdeffe..416b2fc 100644 --- a/src/lib/runtime-config.test.ts +++ b/src/lib/runtime-config.test.ts @@ -52,6 +52,7 @@ describe("runtime config", () => { expect(rc.get()).toEqual({ greeting: "hello", maxItems: 50, + registrationOpen: true, featureHints: [], }); expect(rc.snapshot().source).toBe("defaults"); @@ -73,6 +74,7 @@ describe("runtime config", () => { expect(rc.get()).toEqual({ greeting: "hi", maxItems: 50, + registrationOpen: true, featureHints: [], }); }); @@ -90,6 +92,7 @@ describe("runtime config", () => { expect(rc.get()).toEqual({ greeting: "two", maxItems: 5, + registrationOpen: true, featureHints: ["a", "b"], }); expect(seen.at(-1)?.sort()).toEqual(["featureHints", "greeting"]); @@ -165,7 +168,12 @@ describe("runtime config", () => { expect(admin.status).toBe(200); expect(admin.body).toMatchObject({ source: "file", - values: { greeting: "howdy", maxItems: 50, featureHints: [] }, + values: { + greeting: "howdy", + maxItems: 50, + registrationOpen: true, + featureHints: [], + }, }); expect(Date.parse(admin.body.loadedAt)).not.toBeNaN(); diff --git a/src/lib/session-store.ts b/src/lib/session-store.ts new file mode 100644 index 0000000..2a195c3 --- /dev/null +++ b/src/lib/session-store.ts @@ -0,0 +1,70 @@ +import session from "express-session"; +import type { Db } from "./db.js"; + +/** Postgres-backed sessions so every pod sees the same login. */ +export class PgSessionStore extends session.Store { + private readonly timer: NodeJS.Timeout; + + constructor( + private readonly db: Db, + private readonly ttlMs: number, + ) { + super(); + // Expired rows are ignored on read; sweep them occasionally. + this.timer = setInterval(() => { + void this.db + .query("DELETE FROM sessions WHERE expire < now()") + .catch(() => {}); + }, 10 * 60_000); + this.timer.unref(); + } + + private expiry(sess: session.SessionData): Date { + const exp = sess.cookie?.expires; + return exp ? new Date(exp) : new Date(Date.now() + this.ttlMs); + } + + get( + sid: string, + cb: (err?: unknown, sess?: session.SessionData | null) => void, + ) { + this.db + .query<{ sess: session.SessionData }>( + "SELECT sess FROM sessions WHERE sid = $1 AND expire > now()", + [sid], + ) + .then((r) => cb(null, r.rows[0]?.sess ?? null), cb); + } + + set(sid: string, sess: session.SessionData, cb?: (err?: unknown) => void) { + this.db + .query( + `INSERT INTO sessions (sid, sess, expire) VALUES ($1, $2, $3) + ON CONFLICT (sid) DO UPDATE SET sess = EXCLUDED.sess, expire = EXCLUDED.expire`, + [sid, JSON.stringify(sess), this.expiry(sess)], + ) + .then( + () => cb?.(), + (e) => cb?.(e), + ); + } + + touch(sid: string, sess: session.SessionData, cb?: (err?: unknown) => void) { + this.db + .query("UPDATE sessions SET expire = $2 WHERE sid = $1", [ + sid, + this.expiry(sess), + ]) + .then( + () => cb?.(), + (e) => cb?.(e), + ); + } + + destroy(sid: string, cb?: (err?: unknown) => void) { + this.db.query("DELETE FROM sessions WHERE sid = $1", [sid]).then( + () => cb?.(), + (e) => cb?.(e), + ); + } +} diff --git a/src/lib/story-store.ts b/src/lib/story-store.ts index a3e176a..4c47af9 100644 --- a/src/lib/story-store.ts +++ b/src/lib/story-store.ts @@ -4,6 +4,7 @@ import type { StoryDoc, StoryInput } from "./story.js"; export interface Story { id: string; + ownerId: string | null; title: string; description: string; doc: StoryDoc; @@ -14,9 +15,12 @@ export interface Story { export type StorySummary = Omit & { nodeCount: number }; export interface StoryStore { - list(opts: { publishedOnly: boolean }): Promise; + list(opts: { + publishedOnly?: boolean; + ownerId?: string; + }): Promise; get(id: string): Promise; - create(input: StoryInput): Promise; + create(ownerId: string, input: StoryInput): Promise; update(id: string, input: StoryInput): Promise; setPublished(id: string, published: boolean): Promise; remove(id: string): Promise; @@ -33,19 +37,21 @@ const summarize = ({ doc, ...rest }: Story): StorySummary => ({ export function memoryStore(): StoryStore { const rows = new Map(); return { - async list({ publishedOnly }) { + async list({ publishedOnly, ownerId }) { return [...rows.values()] .filter((s) => !publishedOnly || s.published) + .filter((s) => !ownerId || s.ownerId === ownerId) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) .map(summarize); }, async get(id) { return rows.get(id) ?? null; }, - async create(input) { + async create(ownerId, input) { const now = new Date().toISOString(); const s: Story = { id: randomUUID(), + ownerId, ...input, published: false, createdAt: now, @@ -76,6 +82,7 @@ export function memoryStore(): StoryStore { interface Row { id: string; + owner_id: string | null; title: string; description: string; doc: StoryDoc; @@ -83,9 +90,11 @@ interface Row { created_at: Date; updated_at: Date; } -const COLS = "id, title, description, doc, published, created_at, updated_at"; +const COLS = + "id, owner_id, title, description, doc, published, created_at, updated_at"; const toStory = (r: Row): Story => ({ id: r.id, + ownerId: r.owner_id, title: r.title, description: r.description, doc: r.doc, @@ -96,9 +105,17 @@ const toStory = (r: Row): Story => ({ export function pgStore(db: Db): StoryStore { return { - async list({ publishedOnly }) { + async list({ publishedOnly, ownerId }) { + const where: string[] = []; + const params: unknown[] = []; + if (publishedOnly) where.push("published"); + if (ownerId) { + params.push(ownerId); + where.push(`owner_id = $${params.length}`); + } const r = await db.query( - `SELECT ${COLS} FROM stories ${publishedOnly ? "WHERE published" : ""} ORDER BY updated_at DESC LIMIT 500`, + `SELECT ${COLS} FROM stories ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY updated_at DESC LIMIT 500`, + params, ); return r.rows.map((row) => summarize(toStory(row))); }, @@ -109,10 +126,10 @@ export function pgStore(db: Db): StoryStore { ); return r.rows[0] ? toStory(r.rows[0]) : null; }, - async create(i) { + async create(ownerId, i) { const r = await db.query( - `INSERT INTO stories (title, description, doc) VALUES ($1, $2, $3) RETURNING ${COLS}`, - [i.title, i.description, JSON.stringify(i.doc)], + `INSERT INTO stories (owner_id, title, description, doc) VALUES ($1, $2, $3, $4) RETURNING ${COLS}`, + [ownerId, i.title, i.description, JSON.stringify(i.doc)], ); return toStory(r.rows[0]!); }, diff --git a/src/lib/user-store.ts b/src/lib/user-store.ts new file mode 100644 index 0000000..14245d2 --- /dev/null +++ b/src/lib/user-store.ts @@ -0,0 +1,89 @@ +import { randomUUID } from "node:crypto"; +import type { Db } from "./db.js"; + +export interface User { + id: string; + username: string; +} +export interface UserWithHash extends User { + passwordHash: string; +} + +export class UsernameTakenError extends Error {} + +export interface UserStore { + /** Throws UsernameTakenError (case-insensitive). */ + create(username: string, passwordHash: string): Promise; + findByUsername(username: string): Promise; + findById(id: string): Promise; +} + +export function memoryUserStore(): UserStore { + const rows = new Map(); + const byName = (n: string) => + [...rows.values()].find( + (u) => u.username.toLowerCase() === n.toLowerCase(), + ); + return { + async create(username, passwordHash) { + if (byName(username)) throw new UsernameTakenError(); + const u = { id: randomUUID(), username, passwordHash }; + rows.set(u.id, u); + return { id: u.id, username }; + }, + async findByUsername(n) { + return byName(n) ?? null; + }, + async findById(id) { + const u = rows.get(id); + return u ? { id: u.id, username: u.username } : null; + }, + }; +} + +interface Row { + id: string; + username: string; + password_hash: string; +} + +export function pgUserStore(db: Db): UserStore { + return { + async create(username, passwordHash) { + try { + const r = await db.query( + "INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id, username, password_hash", + [username, passwordHash], + ); + return { id: r.rows[0]!.id, username: r.rows[0]!.username }; + } catch (e) { + if ((e as { code?: string }).code === "23505") { + throw new UsernameTakenError(); + } + throw e; + } + }, + async findByUsername(username) { + const r = await db.query( + "SELECT id, username, password_hash FROM users WHERE lower(username) = lower($1)", + [username], + ); + const row = r.rows[0]; + return row + ? { + id: row.id, + username: row.username, + passwordHash: row.password_hash, + } + : null; + }, + async findById(id) { + const r = await db.query( + "SELECT id, username, password_hash FROM users WHERE id = $1", + [id], + ); + const row = r.rows[0]; + return row ? { id: row.id, username: row.username } : null; + }, + }; +} diff --git a/src/routes/auth.test.ts b/src/routes/auth.test.ts new file mode 100644 index 0000000..9d4b091 --- /dev/null +++ b/src/routes/auth.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import request from "supertest"; +import pino from "pino"; +import { createApp } from "../app.js"; +import { parseConfig } from "../lib/config.js"; +import { hashPassword, verifyPassword } from "../lib/passwords.js"; +import { staticRuntimeConfig } from "../lib/runtime-config.js"; + +const config = parseConfig({ SERVICE_NAME: "t", GIT_SHA: "x" }); +const mk = (extra = {}) => + createApp({ config, logger: pino({ level: "silent" }), ...extra }); +const creds = { username: "alice", password: "correct horse" }; + +describe("passwords", () => { + it("verifies the right password and rejects wrong, malformed and empty hashes", async () => { + const h = await hashPassword("hunter2hunter2"); + expect(h).toMatch(/^scrypt\$[0-9a-f]+\$[0-9a-f]+$/); + expect(await verifyPassword("hunter2hunter2", h)).toBe(true); + expect(await verifyPassword("wrong", h)).toBe(false); + expect(await verifyPassword("x", "garbage")).toBe(false); + expect(await hashPassword("same")).not.toBe(await hashPassword("same")); + }); +}); + +describe("auth API", () => { + it("registers, logs in the new user, and reports /auth/me", async () => { + const agent = request.agent(mk()); + expect((await agent.get("/auth/me")).body).toEqual({ user: null }); + const r = await agent.post("/auth/register").send(creds); + expect(r.status).toBe(201); + expect(r.body.user.username).toBe("alice"); + expect(JSON.stringify(r.body)).not.toMatch(/hash|password/i); + expect((await agent.get("/auth/me")).body.user.username).toBe("alice"); + const cookie = r.headers["set-cookie"]![0]!; + expect(cookie).toMatch(/HttpOnly/i); + expect(cookie).toMatch(/SameSite=Lax/i); + }); + + it("does not create a session for anonymous visitors", async () => { + const r = await request(mk()).get("/auth/me"); + expect(r.headers["set-cookie"]).toBeUndefined(); + }); + + it("rejects bad usernames and short passwords", async () => { + const app = mk(); + for (const body of [ + { username: "a", password: "correct horse" }, + { username: "has space", password: "correct horse" }, + { username: "alice", password: "short" }, + {}, + ]) { + expect( + (await request(app).post("/auth/register").send(body)).status, + ).toBe(400); + } + }); + + it("rejects a duplicate username case-insensitively", async () => { + const app = mk(); + await request(app).post("/auth/register").send(creds); + const r = await request(app) + .post("/auth/register") + .send({ ...creds, username: "ALICE" }); + expect(r.status).toBe(409); + }); + + it("logs in with the right password only, with one message for both failures", async () => { + const app = mk(); + await request(app).post("/auth/register").send(creds); + const ok = await request(app).post("/auth/login").send(creds); + expect(ok.status).toBe(200); + expect(ok.headers["set-cookie"]).toBeDefined(); + const wrongPw = await request(app) + .post("/auth/login") + .send({ ...creds, password: "nope nope nope" }); + const noUser = await request(app) + .post("/auth/login") + .send({ username: "ghost", password: "whatever1" }); + expect(wrongPw.status).toBe(401); + expect(noUser.status).toBe(401); + expect(wrongPw.body).toEqual(noUser.body); + expect((await request(app).post("/auth/login").send({})).status).toBe(401); + }); + + it("logout ends the session", async () => { + const agent = request.agent(mk()); + await agent.post("/auth/register").send(creds); + expect((await agent.post("/auth/logout")).status).toBe(204); + expect((await agent.get("/auth/me")).body).toEqual({ user: null }); + }); + + it("rotates the session id on login", async () => { + const app = mk(); + await request(app).post("/auth/register").send(creds); + const sid = (r: request.Response) => + /adventure\.sid=([^;]+)/.exec(r.headers["set-cookie"]![0]!)![1]; + const a = await request(app).post("/auth/login").send(creds); + const b = await request(app).post("/auth/login").send(creds); + expect(sid(a)).not.toBe(sid(b)); + }); + + it("locks out after repeated failures for that user", async () => { + const app = mk(); + await request(app).post("/auth/register").send(creds); + const bad = { ...creds, password: "wrong wrong" }; + for (let i = 0; i < 10; i++) { + expect((await request(app).post("/auth/login").send(bad)).status).toBe( + 401, + ); + } + expect((await request(app).post("/auth/login").send(bad)).status).toBe(429); + // even the right password is refused during lockout + expect((await request(app).post("/auth/login").send(creds)).status).toBe( + 429, + ); + // other usernames are unaffected + expect( + ( + await request(app) + .post("/auth/login") + .send({ username: "bob", password: "whatever1" }) + ).status, + ).toBe(401); + }); + + it("closes registration via runtime config", async () => { + const rc = staticRuntimeConfig(); + const closed = { + ...rc, + get: () => ({ ...rc.get(), registrationOpen: false }), + }; + const r = await request(mk({ runtimeConfig: closed })) + .post("/auth/register") + .send(creds); + expect(r.status).toBe(403); + }); +}); + +describe("config", () => { + it("requires SESSION_SECRET once DATABASE_URL is set", () => { + const base = { SERVICE_NAME: "t", DATABASE_URL: "postgres://u:p@h/db" }; + expect(() => parseConfig(base)).toThrow(/SESSION_SECRET/); + expect(() => parseConfig({ ...base, SESSION_SECRET: "short" })).toThrow( + /SESSION_SECRET/, + ); + expect( + parseConfig({ ...base, SESSION_SECRET: "x".repeat(32) }).COOKIE_SECURE, + ).toBe(false); + }); +}); diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..01d5148 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,102 @@ +import express, { Router, type RequestHandler } from "express"; +import { z } from "zod"; +import type { Auth } from "../lib/auth.js"; +import { hashPassword } from "../lib/passwords.js"; +import { UsernameTakenError, type UserStore } from "../lib/user-store.js"; + +const credentials = z.object({ + username: z + .string() + .regex(/^[A-Za-z0-9_-]{3,32}$/, "3-32 characters: letters, digits, _ or -"), + password: z.string().min(8, "at least 8 characters").max(128), +}); + +/** Failed logins per (ip, username): 10 per 15 min, per pod. Cheap brute-force brake. */ +function loginLimiter(): RequestHandler { + const WINDOW = 15 * 60_000; + const MAX = 10; + const fails = new Map(); + return (req, res, next) => { + const key = `${req.ip}|${String(req.body?.username ?? "").toLowerCase()}`; + const now = Date.now(); + const e = fails.get(key); + if (e && e.reset > now && e.n >= MAX) { + res.status(429).json({ error: "too many attempts; try again later" }); + return; + } + res.on("finish", () => { + if (res.statusCode === 401) { + const cur = fails.get(key); + if (!cur || cur.reset <= Date.now()) { + fails.set(key, { n: 1, reset: Date.now() + WINDOW }); + } else cur.n++; + } else if (res.statusCode === 200) fails.delete(key); + if (fails.size > 10_000) fails.clear(); + }); + next(); + }; +} + +export function authRouter(deps: { + auth: Auth; + users: UserStore; + registrationOpen: () => boolean; +}): Router { + const router = Router(); + const json = express.json({ limit: "10kb" }); + const publicUser = (u: { id: string; username: string }) => ({ + id: u.id, + username: u.username, + }); + + router.get("/auth/me", (req, res) => { + res.json({ user: req.user ? publicUser(req.user) : null }); + }); + + router.post("/auth/register", json, async (req, res, next) => { + try { + if (!deps.registrationOpen()) { + return void res.status(403).json({ error: "registration is closed" }); + } + const parsed = credentials.safeParse(req.body); + if (!parsed.success) { + return void res.status(400).json({ + error: parsed.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join("; "), + }); + } + const user = await deps.users.create( + parsed.data.username, + await hashPassword(parsed.data.password), + ); + // req.login regenerates the session id (fixation defence). + req.login(user, (err) => { + if (err) return next(err); + res.status(201).json({ user: publicUser(user) }); + }); + } catch (e) { + if (e instanceof UsernameTakenError) { + return void res.status(409).json({ error: "username is taken" }); + } + next(e); + } + }); + + router.post("/auth/login", json, loginLimiter(), (req, res, next) => { + deps.auth.authenticate(req, res, next); + }); + + router.post("/auth/logout", (req, res, next) => { + req.logout((err) => { + if (err) return next(err); + req.session.destroy((e) => { + if (e) return next(e); + res.clearCookie("adventure.sid"); + res.status(204).end(); + }); + }); + }); + + return router; +} diff --git a/src/routes/stories.test.ts b/src/routes/stories.test.ts index 5d5dd5f..24a1f12 100644 --- a/src/routes/stories.test.ts +++ b/src/routes/stories.test.ts @@ -10,6 +10,16 @@ const mk = () => logger: pino({ level: "silent" }), }); +/** A logged-in agent against `app` (a fresh app if none given). */ +async function login(app = mk(), name = "alice") { + const agent = request.agent(app); + const r = await agent + .post("/auth/register") + .send({ username: name, password: "correct horse" }); + expect(r.status).toBe(201); + return agent; +} + const good = { title: "Cave", description: "d", @@ -32,63 +42,61 @@ const good = { describe("stories API", () => { it("creates, reads, lists drafts only with mine=1", async () => { - const app = mk(); - const c = await request(app).post("/stories").send(good); + const agent = await login(); + const c = await agent.post("/stories").send(good); expect(c.status).toBe(201); const id = c.body.story.id; expect(c.body.issues).toEqual([]); - expect((await request(app).get(`/stories/${id}`)).body.story.title).toBe( - "Cave", - ); - expect((await request(app).get("/stories")).body.stories).toHaveLength(0); - const mine = await request(app).get("/stories?mine=1"); + const got = await agent.get(`/stories/${id}`); + expect(got.body.story.title).toBe("Cave"); + expect(got.body.ownedByMe).toBe(true); + expect((await agent.get("/stories")).body.stories).toHaveLength(0); + const mine = await agent.get("/stories?mine=1"); expect(mine.body.stories[0]).toMatchObject({ id, nodeCount: 3 }); expect(mine.body.stories[0].doc).toBeUndefined(); }); it("publishes a valid story and lists it publicly", async () => { - const app = mk(); - const id = (await request(app).post("/stories").send(good)).body.story.id; - const p = await request(app).post(`/stories/${id}/publish`); + const agent = await login(); + const id = (await agent.post("/stories").send(good)).body.story.id; + const p = await agent.post(`/stories/${id}/publish`); expect(p.status).toBe(200); expect(p.body.story.published).toBe(true); - expect((await request(app).get("/stories")).body.stories).toHaveLength(1); - await request(app).post(`/stories/${id}/unpublish`); - expect((await request(app).get("/stories")).body.stories).toHaveLength(0); + expect((await agent.get("/stories")).body.stories).toHaveLength(1); + await agent.post(`/stories/${id}/unpublish`); + expect((await agent.get("/stories")).body.stories).toHaveLength(0); }); it("refuses to publish a story with errors", async () => { - const app = mk(); + const agent = await login(); const bad = structuredClone(good); bad.doc.nodes.a.choices[0]!.to = "nope"; - const id = (await request(app).post("/stories").send(bad)).body.story.id; - const p = await request(app).post(`/stories/${id}/publish`); + const id = (await agent.post("/stories").send(bad)).body.story.id; + const p = await agent.post(`/stories/${id}/publish`); expect(p.status).toBe(422); expect(p.body.issues[0].message).toMatch(/missing node "nope"/); }); it("validates input shape", async () => { - const app = mk(); - const r = await request(app) + const agent = await login(); + const r = await agent .post("/stories") .send({ title: "", doc: { start: "A!", nodes: {} } }); expect(r.status).toBe(400); }); it("updates and deletes; 404s for unknown or malformed ids", async () => { - const app = mk(); - const id = (await request(app).post("/stories").send(good)).body.story.id; - const u = await request(app) - .put(`/stories/${id}`) - .send({ ...good, title: "New" }); + const agent = await login(); + const id = (await agent.post("/stories").send(good)).body.story.id; + const u = await agent.put(`/stories/${id}`).send({ ...good, title: "New" }); expect(u.body.story.title).toBe("New"); - expect((await request(app).delete(`/stories/${id}`)).status).toBe(204); - expect((await request(app).get(`/stories/${id}`)).status).toBe(404); - expect((await request(app).get("/stories/not-a-uuid")).status).toBe(404); + expect((await agent.delete(`/stories/${id}`)).status).toBe(204); + expect((await agent.get(`/stories/${id}`)).status).toBe(404); + expect((await agent.get("/stories/not-a-uuid")).status).toBe(404); }); it("reports dead ends, unreachable nodes and missing endings", async () => { - const app = mk(); + const agent = await login(); const doc = { title: "t", doc: { @@ -100,10 +108,52 @@ describe("stories API", () => { }, }, }; - const r = await request(app).post("/stories").send(doc); + const r = await agent.post("/stories").send(doc); const msgs = r.body.issues.map((i: { message: string }) => i.message); expect(msgs).toContain("dead end: no choices and not marked as an ending"); expect(msgs).toContain("unreachable from the start node"); expect(msgs).toContain("no ending is reachable from the start node"); }); }); + +describe("story authorization", () => { + it("requires login to write or to list your own stories", async () => { + const app = mk(); + expect((await request(app).post("/stories").send(good)).status).toBe(401); + expect((await request(app).get("/stories?mine=1")).status).toBe(401); + const id = (await (await login(app)).post("/stories").send(good)).body.story + .id; + for (const r of [ + request(app).put(`/stories/${id}`).send(good), + request(app).post(`/stories/${id}/publish`), + request(app).post(`/stories/${id}/unpublish`), + request(app).delete(`/stories/${id}`), + ]) { + expect((await r).status).toBe(401); + } + }); + + it("hides other users' drafts (404) and forbids editing published ones (403)", async () => { + const app = mk(); + const alice = await login(app, "alice"); + const bob = await login(app, "bob"); + const id = (await alice.post("/stories").send(good)).body.story.id; + + // draft: invisible to bob, and to anonymous visitors + expect((await bob.get(`/stories/${id}`)).status).toBe(404); + expect((await request(app).get(`/stories/${id}`)).status).toBe(404); + expect((await bob.put(`/stories/${id}`).send(good)).status).toBe(404); + expect((await bob.get("/stories?mine=1")).body.stories).toEqual([]); + + // published: bob can read and play, but not change it + await alice.post(`/stories/${id}/publish`); + const seen = await bob.get(`/stories/${id}`); + expect(seen.status).toBe(200); + expect(seen.body.ownedByMe).toBe(false); + expect((await request(app).get(`/stories/${id}`)).status).toBe(200); + expect((await bob.put(`/stories/${id}`).send(good)).status).toBe(403); + expect((await bob.post(`/stories/${id}/unpublish`)).status).toBe(403); + expect((await bob.delete(`/stories/${id}`)).status).toBe(403); + expect((await alice.get(`/stories/${id}`)).body.story.published).toBe(true); + }); +}); diff --git a/src/routes/stories.ts b/src/routes/stories.ts index daf8bb2..a9d1f10 100644 --- a/src/routes/stories.ts +++ b/src/routes/stories.ts @@ -1,6 +1,10 @@ -import express, { Router } from "express"; +import express, { Router, type Request, type Response } from "express"; +import { requireAuth } from "../lib/auth.js"; import { analyze, hasErrors, storyInputSchema } from "../lib/story.js"; -import { isUuid, type StoryStore } from "../lib/story-store.js"; +import { isUuid, type Story, type StoryStore } from "../lib/story-store.js"; + +const notFound = (res: Response) => + void res.status(404).json({ error: "story not found" }); export function storiesRouter({ store }: { store: StoryStore }): Router { const router = Router(); @@ -8,20 +12,47 @@ export function storiesRouter({ store }: { store: StoryStore }): Router { // Param guard: non-UUID ids are a 404, never a Postgres cast error. router.param("id", (req, res, next, id) => { - if (!isUuid(String(id))) { - res.status(404).json({ error: "story not found" }); - return; - } + if (!isUuid(String(id))) return notFound(res); next(); }); - // ?mine=1 is the builder's view: drafts included. No auth (homelab). + /** + * Load a story the caller may see (published, or their own draft). Returns + * null after replying 404. `owner: true` additionally requires ownership: + * visible-but-not-yours is 403, hidden drafts stay 404. + */ + async function load( + req: Request, + res: Response, + owner: boolean, + ): Promise { + const story = await store.get(String(req.params.id)); + const mine = !!story && !!req.user && story.ownerId === req.user.id; + if (!story || (!story.published && !mine)) { + notFound(res); + return null; + } + if (owner && !mine) { + res.status(403).json({ error: "you do not own this story" }); + return null; + } + return story; + } + + // ?mine=1 is the builder's view: the caller's own stories, drafts included. router.get("/stories", async (req, res, next) => { try { - const stories = await store.list({ - publishedOnly: req.query.mine !== "1", - }); - res.json({ stories }); + if (req.query.mine === "1") { + if (!req.user) { + return void res + .status(401) + .json({ error: "authentication required" }); + } + return void res.json({ + stories: await store.list({ ownerId: req.user.id }), + }); + } + res.json({ stories: await store.list({ publishedOnly: true }) }); } catch (e) { next(e); } @@ -29,16 +60,19 @@ export function storiesRouter({ store }: { store: StoryStore }): Router { router.get("/stories/:id", async (req, res, next) => { try { - const story = await store.get(req.params.id); - if (!story) - return void res.status(404).json({ error: "story not found" }); - res.json({ story, issues: analyze(story.doc) }); + const story = await load(req, res, false); + if (!story) return; + res.json({ + story, + issues: analyze(story.doc), + ownedByMe: !!req.user && story.ownerId === req.user.id, + }); } catch (e) { next(e); } }); - router.post("/stories", json, async (req, res, next) => { + router.post("/stories", requireAuth, json, async (req, res, next) => { try { const parsed = storyInputSchema.safeParse(req.body); if (!parsed.success) { @@ -46,14 +80,14 @@ export function storiesRouter({ store }: { store: StoryStore }): Router { .status(400) .json({ error: "invalid story", details: parsed.error.issues }); } - const story = await store.create(parsed.data); + const story = await store.create(req.user!.id, parsed.data); res.status(201).json({ story, issues: analyze(story.doc) }); } catch (e) { next(e); } }); - router.put("/stories/:id", json, async (req, res, next) => { + router.put("/stories/:id", requireAuth, json, async (req, res, next) => { try { const parsed = storyInputSchema.safeParse(req.body); if (!parsed.success) { @@ -61,20 +95,20 @@ export function storiesRouter({ store }: { store: StoryStore }): Router { .status(400) .json({ error: "invalid story", details: parsed.error.issues }); } - const story = await store.update(req.params.id, parsed.data); - if (!story) - return void res.status(404).json({ error: "story not found" }); + const cur = await load(req, res, true); + if (!cur) return; + const story = await store.update(cur.id, parsed.data); + if (!story) return notFound(res); res.json({ story, issues: analyze(story.doc) }); } catch (e) { next(e); } }); - router.post("/stories/:id/publish", async (req, res, next) => { + router.post("/stories/:id/publish", requireAuth, async (req, res, next) => { try { - const story = await store.get(req.params.id); - if (!story) - return void res.status(404).json({ error: "story not found" }); + const story = await load(req, res, true); + if (!story) return; const issues = analyze(story.doc); if (hasErrors(issues)) { return void res.status(422).json({ @@ -82,28 +116,29 @@ export function storiesRouter({ store }: { store: StoryStore }): Router { issues, }); } - const updated = await store.setPublished(story.id, true); - res.json({ story: updated, issues }); + res.json({ story: await store.setPublished(story.id, true), issues }); } catch (e) { next(e); } }); - router.post("/stories/:id/unpublish", async (req, res, next) => { + router.post("/stories/:id/unpublish", requireAuth, async (req, res, next) => { try { - const story = await store.setPublished(req.params.id, false); - if (!story) - return void res.status(404).json({ error: "story not found" }); + const cur = await load(req, res, true); + if (!cur) return; + const story = await store.setPublished(cur.id, false); + if (!story) return notFound(res); res.json({ story, issues: analyze(story.doc) }); } catch (e) { next(e); } }); - router.delete("/stories/:id", async (req, res, next) => { + router.delete("/stories/:id", requireAuth, async (req, res, next) => { try { - const ok = await store.remove(req.params.id); - if (!ok) return void res.status(404).json({ error: "story not found" }); + const cur = await load(req, res, true); + if (!cur) return; + await store.remove(cur.id); res.status(204).end(); } catch (e) { next(e); diff --git a/src/runtime-config.schema.ts b/src/runtime-config.schema.ts index da16d66..07190c4 100644 --- a/src/runtime-config.schema.ts +++ b/src/runtime-config.schema.ts @@ -16,6 +16,8 @@ import { z } from "zod"; export const RuntimeConfigSchema = z.object({ greeting: z.string().min(1).default("hello"), maxItems: z.number().int().positive().default(50), + // Close signups by committing registrationOpen: false to the ConfigMap. + registrationOpen: z.boolean().default(true), featureHints: z.array(z.string()).default([]), });