feat: passport-local authentication, story ownership
All checks were successful
/ checks (push) Successful in 38s
/ deploy (push) Successful in 11s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nholtware 2026-11-05 13:33:51 -06:00
parent 0ae3f6109b
commit afbc5e5254
16 changed files with 995 additions and 73 deletions

26
migrations/0003_auth.sql Normal file
View file

@ -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;

194
package-lock.json generated
View file

@ -9,8 +9,11 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"express": "^5.2.0", "express": "^5.2.0",
"express-session": "^1.19.0",
"ioredis": "^5.4.0", "ioredis": "^5.4.0",
"node-pg-migrate": "^8.0.0", "node-pg-migrate": "^8.0.0",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"pg": "^8.13.0", "pg": "^8.13.0",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-http": "^10.3.0", "pino-http": "^10.3.0",
@ -22,7 +25,10 @@
"@commitlint/cli": "^19.5.0", "@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0", "@commitlint/config-conventional": "^19.5.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/express-session": "^1.19.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/passport": "^1.0.17",
"@types/passport-local": "^1.0.38",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"eslint": "^9.13.0", "eslint": "^9.13.0",
@ -1583,6 +1589,16 @@
"@types/send": "*" "@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": { "node_modules/@types/http-errors": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@ -1614,6 +1630,39 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/pg": {
"version": "8.23.1", "version": "8.23.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
@ -3303,6 +3352,50 @@
"url": "https://opencollective.com/express" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -4677,6 +4770,15 @@
"url": "https://opencollective.com/express" "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": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -4795,6 +4897,43 @@
"node": ">= 0.8" "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": { "node_modules/path-exists": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
@ -4857,6 +4996,11 @@
"node": ">= 14.16" "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": { "node_modules/pg": {
"version": "8.23.1", "version": "8.23.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
@ -5189,6 +5333,15 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT" "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": { "node_modules/range-parser": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
@ -5347,6 +5500,26 @@
"node": ">= 18" "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": { "node_modules/safe-stable-stringify": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", "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" "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": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@ -6015,6 +6200,15 @@
"punycode": "^2.1.0" "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": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View file

@ -19,8 +19,11 @@
}, },
"dependencies": { "dependencies": {
"express": "^5.2.0", "express": "^5.2.0",
"express-session": "^1.19.0",
"ioredis": "^5.4.0", "ioredis": "^5.4.0",
"node-pg-migrate": "^8.0.0", "node-pg-migrate": "^8.0.0",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"pg": "^8.13.0", "pg": "^8.13.0",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-http": "^10.3.0", "pino-http": "^10.3.0",
@ -32,7 +35,10 @@
"@commitlint/cli": "^19.5.0", "@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0", "@commitlint/config-conventional": "^19.5.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/express-session": "^1.19.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/passport": "^1.0.17",
"@types/passport-local": "^1.0.38",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"eslint": "^9.13.0", "eslint": "^9.13.0",

View file

@ -14,6 +14,13 @@ import { adminRouter } from "./routes/admin.js";
import { healthRouter } from "./routes/health.js"; import { healthRouter } from "./routes/health.js";
import { logDemoRouter } from "./routes/log-demo.js"; import { logDemoRouter } from "./routes/log-demo.js";
import { rootRouter } from "./routes/root.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 { storiesRouter } from "./routes/stories.js";
import { memoryStore, pgStore, type StoryStore } from "./lib/story-store.js"; import { memoryStore, pgStore, type StoryStore } from "./lib/story-store.js";
@ -25,6 +32,7 @@ export interface Deps {
flags?: Flags; flags?: Flags;
runtimeConfig?: RuntimeConfig; runtimeConfig?: RuntimeConfig;
store?: StoryStore; store?: StoryStore;
users?: UserStore;
} }
export function createApp({ export function createApp({
@ -35,6 +43,7 @@ export function createApp({
flags, flags,
runtimeConfig = staticRuntimeConfig(), runtimeConfig = staticRuntimeConfig(),
store = db ? pgStore(db) : memoryStore(), store = db ? pgStore(db) : memoryStore(),
users = db ? pgUserStore(db) : memoryUserStore(),
}: Deps) { }: Deps) {
const app = express(); const app = express();
app.disable("x-powered-by"); app.disable("x-powered-by");
@ -63,6 +72,15 @@ export function createApp({
app.use(healthRouter({ db, cache })); app.use(healthRouter({ db, cache }));
app.use(rootRouter({ config, db, cache, flags, runtimeConfig })); 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(storiesRouter({ store }));
app.use(adminRouter({ runtimeConfig })); app.use(adminRouter({ runtimeConfig }));
app.use(logDemoRouter()); app.use(logDemoRouter());

113
src/lib/auth.ts Normal file
View file

@ -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;

View file

@ -10,6 +10,14 @@ const schema = z
GIT_SHA: z.string().default("dev"), GIT_SHA: z.string().default("dev"),
DATABASE_URL: z.string().url().optional(), DATABASE_URL: z.string().url().optional(),
REDIS_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. // Feature flags (Unleash). Unset UNLEASH_URL = every flag reads false.
UNLEASH_URL: z.string().url().optional(), UNLEASH_URL: z.string().url().optional(),
UNLEASH_API_TOKEN: z.string().min(1).optional(), UNLEASH_API_TOKEN: z.string().min(1).optional(),
@ -18,6 +26,11 @@ const schema = z
// Unset = schema defaults, no reload. // Unset = schema defaults, no reload.
RUNTIME_CONFIG_PATH: z.string().min(1).optional(), 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, { .refine((c) => !c.UNLEASH_URL || c.UNLEASH_API_TOKEN, {
message: "UNLEASH_API_TOKEN is required when UNLEASH_URL is set", message: "UNLEASH_API_TOKEN is required when UNLEASH_URL is set",
path: ["UNLEASH_API_TOKEN"], path: ["UNLEASH_API_TOKEN"],

29
src/lib/passwords.ts Normal file
View file

@ -0,0 +1,29 @@
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
// scrypt via node:crypto: no native dependency. Format: scrypt$<saltHex>$<hashHex>
const KEYLEN = 64;
function derive(password: string, salt: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) =>
scrypt(password, salt, KEYLEN, (err, key) =>
err ? reject(err) : resolve(key),
),
);
}
export async function hashPassword(password: string): Promise<string> {
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<boolean> {
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);
}

View file

@ -52,6 +52,7 @@ describe("runtime config", () => {
expect(rc.get()).toEqual({ expect(rc.get()).toEqual({
greeting: "hello", greeting: "hello",
maxItems: 50, maxItems: 50,
registrationOpen: true,
featureHints: [], featureHints: [],
}); });
expect(rc.snapshot().source).toBe("defaults"); expect(rc.snapshot().source).toBe("defaults");
@ -73,6 +74,7 @@ describe("runtime config", () => {
expect(rc.get()).toEqual({ expect(rc.get()).toEqual({
greeting: "hi", greeting: "hi",
maxItems: 50, maxItems: 50,
registrationOpen: true,
featureHints: [], featureHints: [],
}); });
}); });
@ -90,6 +92,7 @@ describe("runtime config", () => {
expect(rc.get()).toEqual({ expect(rc.get()).toEqual({
greeting: "two", greeting: "two",
maxItems: 5, maxItems: 5,
registrationOpen: true,
featureHints: ["a", "b"], featureHints: ["a", "b"],
}); });
expect(seen.at(-1)?.sort()).toEqual(["featureHints", "greeting"]); expect(seen.at(-1)?.sort()).toEqual(["featureHints", "greeting"]);
@ -165,7 +168,12 @@ describe("runtime config", () => {
expect(admin.status).toBe(200); expect(admin.status).toBe(200);
expect(admin.body).toMatchObject({ expect(admin.body).toMatchObject({
source: "file", source: "file",
values: { greeting: "howdy", maxItems: 50, featureHints: [] }, values: {
greeting: "howdy",
maxItems: 50,
registrationOpen: true,
featureHints: [],
},
}); });
expect(Date.parse(admin.body.loadedAt)).not.toBeNaN(); expect(Date.parse(admin.body.loadedAt)).not.toBeNaN();

70
src/lib/session-store.ts Normal file
View file

@ -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),
);
}
}

View file

@ -4,6 +4,7 @@ import type { StoryDoc, StoryInput } from "./story.js";
export interface Story { export interface Story {
id: string; id: string;
ownerId: string | null;
title: string; title: string;
description: string; description: string;
doc: StoryDoc; doc: StoryDoc;
@ -14,9 +15,12 @@ export interface Story {
export type StorySummary = Omit<Story, "doc"> & { nodeCount: number }; export type StorySummary = Omit<Story, "doc"> & { nodeCount: number };
export interface StoryStore { export interface StoryStore {
list(opts: { publishedOnly: boolean }): Promise<StorySummary[]>; list(opts: {
publishedOnly?: boolean;
ownerId?: string;
}): Promise<StorySummary[]>;
get(id: string): Promise<Story | null>; get(id: string): Promise<Story | null>;
create(input: StoryInput): Promise<Story>; create(ownerId: string, input: StoryInput): Promise<Story>;
update(id: string, input: StoryInput): Promise<Story | null>; update(id: string, input: StoryInput): Promise<Story | null>;
setPublished(id: string, published: boolean): Promise<Story | null>; setPublished(id: string, published: boolean): Promise<Story | null>;
remove(id: string): Promise<boolean>; remove(id: string): Promise<boolean>;
@ -33,19 +37,21 @@ const summarize = ({ doc, ...rest }: Story): StorySummary => ({
export function memoryStore(): StoryStore { export function memoryStore(): StoryStore {
const rows = new Map<string, Story>(); const rows = new Map<string, Story>();
return { return {
async list({ publishedOnly }) { async list({ publishedOnly, ownerId }) {
return [...rows.values()] return [...rows.values()]
.filter((s) => !publishedOnly || s.published) .filter((s) => !publishedOnly || s.published)
.filter((s) => !ownerId || s.ownerId === ownerId)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.map(summarize); .map(summarize);
}, },
async get(id) { async get(id) {
return rows.get(id) ?? null; return rows.get(id) ?? null;
}, },
async create(input) { async create(ownerId, input) {
const now = new Date().toISOString(); const now = new Date().toISOString();
const s: Story = { const s: Story = {
id: randomUUID(), id: randomUUID(),
ownerId,
...input, ...input,
published: false, published: false,
createdAt: now, createdAt: now,
@ -76,6 +82,7 @@ export function memoryStore(): StoryStore {
interface Row { interface Row {
id: string; id: string;
owner_id: string | null;
title: string; title: string;
description: string; description: string;
doc: StoryDoc; doc: StoryDoc;
@ -83,9 +90,11 @@ interface Row {
created_at: Date; created_at: Date;
updated_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 => ({ const toStory = (r: Row): Story => ({
id: r.id, id: r.id,
ownerId: r.owner_id,
title: r.title, title: r.title,
description: r.description, description: r.description,
doc: r.doc, doc: r.doc,
@ -96,9 +105,17 @@ const toStory = (r: Row): Story => ({
export function pgStore(db: Db): StoryStore { export function pgStore(db: Db): StoryStore {
return { 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<Row>( const r = await db.query<Row>(
`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))); 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; return r.rows[0] ? toStory(r.rows[0]) : null;
}, },
async create(i) { async create(ownerId, i) {
const r = await db.query<Row>( const r = await db.query<Row>(
`INSERT INTO stories (title, description, doc) VALUES ($1, $2, $3) RETURNING ${COLS}`, `INSERT INTO stories (owner_id, title, description, doc) VALUES ($1, $2, $3, $4) RETURNING ${COLS}`,
[i.title, i.description, JSON.stringify(i.doc)], [ownerId, i.title, i.description, JSON.stringify(i.doc)],
); );
return toStory(r.rows[0]!); return toStory(r.rows[0]!);
}, },

89
src/lib/user-store.ts Normal file
View file

@ -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<User>;
findByUsername(username: string): Promise<UserWithHash | null>;
findById(id: string): Promise<User | null>;
}
export function memoryUserStore(): UserStore {
const rows = new Map<string, UserWithHash>();
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<Row>(
"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<Row>(
"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<Row>(
"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;
},
};
}

150
src/routes/auth.test.ts Normal file
View file

@ -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);
});
});

102
src/routes/auth.ts Normal file
View file

@ -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<string, { n: number; reset: number }>();
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;
}

View file

@ -10,6 +10,16 @@ const mk = () =>
logger: pino({ level: "silent" }), 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 = { const good = {
title: "Cave", title: "Cave",
description: "d", description: "d",
@ -32,63 +42,61 @@ const good = {
describe("stories API", () => { describe("stories API", () => {
it("creates, reads, lists drafts only with mine=1", async () => { it("creates, reads, lists drafts only with mine=1", async () => {
const app = mk(); const agent = await login();
const c = await request(app).post("/stories").send(good); const c = await agent.post("/stories").send(good);
expect(c.status).toBe(201); expect(c.status).toBe(201);
const id = c.body.story.id; const id = c.body.story.id;
expect(c.body.issues).toEqual([]); expect(c.body.issues).toEqual([]);
expect((await request(app).get(`/stories/${id}`)).body.story.title).toBe( const got = await agent.get(`/stories/${id}`);
"Cave", expect(got.body.story.title).toBe("Cave");
); expect(got.body.ownedByMe).toBe(true);
expect((await request(app).get("/stories")).body.stories).toHaveLength(0); expect((await agent.get("/stories")).body.stories).toHaveLength(0);
const mine = await request(app).get("/stories?mine=1"); const mine = await agent.get("/stories?mine=1");
expect(mine.body.stories[0]).toMatchObject({ id, nodeCount: 3 }); expect(mine.body.stories[0]).toMatchObject({ id, nodeCount: 3 });
expect(mine.body.stories[0].doc).toBeUndefined(); expect(mine.body.stories[0].doc).toBeUndefined();
}); });
it("publishes a valid story and lists it publicly", async () => { it("publishes a valid story and lists it publicly", async () => {
const app = mk(); const agent = await login();
const id = (await request(app).post("/stories").send(good)).body.story.id; const id = (await agent.post("/stories").send(good)).body.story.id;
const p = await request(app).post(`/stories/${id}/publish`); const p = await agent.post(`/stories/${id}/publish`);
expect(p.status).toBe(200); expect(p.status).toBe(200);
expect(p.body.story.published).toBe(true); expect(p.body.story.published).toBe(true);
expect((await request(app).get("/stories")).body.stories).toHaveLength(1); expect((await agent.get("/stories")).body.stories).toHaveLength(1);
await request(app).post(`/stories/${id}/unpublish`); await agent.post(`/stories/${id}/unpublish`);
expect((await request(app).get("/stories")).body.stories).toHaveLength(0); expect((await agent.get("/stories")).body.stories).toHaveLength(0);
}); });
it("refuses to publish a story with errors", async () => { it("refuses to publish a story with errors", async () => {
const app = mk(); const agent = await login();
const bad = structuredClone(good); const bad = structuredClone(good);
bad.doc.nodes.a.choices[0]!.to = "nope"; bad.doc.nodes.a.choices[0]!.to = "nope";
const id = (await request(app).post("/stories").send(bad)).body.story.id; const id = (await agent.post("/stories").send(bad)).body.story.id;
const p = await request(app).post(`/stories/${id}/publish`); const p = await agent.post(`/stories/${id}/publish`);
expect(p.status).toBe(422); expect(p.status).toBe(422);
expect(p.body.issues[0].message).toMatch(/missing node "nope"/); expect(p.body.issues[0].message).toMatch(/missing node "nope"/);
}); });
it("validates input shape", async () => { it("validates input shape", async () => {
const app = mk(); const agent = await login();
const r = await request(app) const r = await agent
.post("/stories") .post("/stories")
.send({ title: "", doc: { start: "A!", nodes: {} } }); .send({ title: "", doc: { start: "A!", nodes: {} } });
expect(r.status).toBe(400); expect(r.status).toBe(400);
}); });
it("updates and deletes; 404s for unknown or malformed ids", async () => { it("updates and deletes; 404s for unknown or malformed ids", async () => {
const app = mk(); const agent = await login();
const id = (await request(app).post("/stories").send(good)).body.story.id; const id = (await agent.post("/stories").send(good)).body.story.id;
const u = await request(app) const u = await agent.put(`/stories/${id}`).send({ ...good, title: "New" });
.put(`/stories/${id}`)
.send({ ...good, title: "New" });
expect(u.body.story.title).toBe("New"); expect(u.body.story.title).toBe("New");
expect((await request(app).delete(`/stories/${id}`)).status).toBe(204); expect((await agent.delete(`/stories/${id}`)).status).toBe(204);
expect((await request(app).get(`/stories/${id}`)).status).toBe(404); expect((await agent.get(`/stories/${id}`)).status).toBe(404);
expect((await request(app).get("/stories/not-a-uuid")).status).toBe(404); expect((await agent.get("/stories/not-a-uuid")).status).toBe(404);
}); });
it("reports dead ends, unreachable nodes and missing endings", async () => { it("reports dead ends, unreachable nodes and missing endings", async () => {
const app = mk(); const agent = await login();
const doc = { const doc = {
title: "t", title: "t",
doc: { 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); 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("dead end: no choices and not marked as an ending");
expect(msgs).toContain("unreachable from the start node"); expect(msgs).toContain("unreachable from the start node");
expect(msgs).toContain("no ending is reachable 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);
});
});

View file

@ -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 { 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 { export function storiesRouter({ store }: { store: StoryStore }): Router {
const router = 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. // Param guard: non-UUID ids are a 404, never a Postgres cast error.
router.param("id", (req, res, next, id) => { router.param("id", (req, res, next, id) => {
if (!isUuid(String(id))) { if (!isUuid(String(id))) return notFound(res);
res.status(404).json({ error: "story not found" });
return;
}
next(); 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<Story | null> {
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) => { router.get("/stories", async (req, res, next) => {
try { try {
const stories = await store.list({ if (req.query.mine === "1") {
publishedOnly: req.query.mine !== "1", if (!req.user) {
}); return void res
res.json({ stories }); .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) { } catch (e) {
next(e); next(e);
} }
@ -29,16 +60,19 @@ export function storiesRouter({ store }: { store: StoryStore }): Router {
router.get("/stories/:id", async (req, res, next) => { router.get("/stories/:id", async (req, res, next) => {
try { try {
const story = await store.get(req.params.id); const story = await load(req, res, false);
if (!story) if (!story) return;
return void res.status(404).json({ error: "story not found" }); res.json({
res.json({ story, issues: analyze(story.doc) }); story,
issues: analyze(story.doc),
ownedByMe: !!req.user && story.ownerId === req.user.id,
});
} catch (e) { } catch (e) {
next(e); next(e);
} }
}); });
router.post("/stories", json, async (req, res, next) => { router.post("/stories", requireAuth, json, async (req, res, next) => {
try { try {
const parsed = storyInputSchema.safeParse(req.body); const parsed = storyInputSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
@ -46,14 +80,14 @@ export function storiesRouter({ store }: { store: StoryStore }): Router {
.status(400) .status(400)
.json({ error: "invalid story", details: parsed.error.issues }); .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) }); res.status(201).json({ story, issues: analyze(story.doc) });
} catch (e) { } catch (e) {
next(e); next(e);
} }
}); });
router.put("/stories/:id", json, async (req, res, next) => { router.put("/stories/:id", requireAuth, json, async (req, res, next) => {
try { try {
const parsed = storyInputSchema.safeParse(req.body); const parsed = storyInputSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
@ -61,20 +95,20 @@ export function storiesRouter({ store }: { store: StoryStore }): Router {
.status(400) .status(400)
.json({ error: "invalid story", details: parsed.error.issues }); .json({ error: "invalid story", details: parsed.error.issues });
} }
const story = await store.update(req.params.id, parsed.data); const cur = await load(req, res, true);
if (!story) if (!cur) return;
return void res.status(404).json({ error: "story not found" }); const story = await store.update(cur.id, parsed.data);
if (!story) return notFound(res);
res.json({ story, issues: analyze(story.doc) }); res.json({ story, issues: analyze(story.doc) });
} catch (e) { } catch (e) {
next(e); next(e);
} }
}); });
router.post("/stories/:id/publish", async (req, res, next) => { router.post("/stories/:id/publish", requireAuth, async (req, res, next) => {
try { try {
const story = await store.get(req.params.id); const story = await load(req, res, true);
if (!story) if (!story) return;
return void res.status(404).json({ error: "story not found" });
const issues = analyze(story.doc); const issues = analyze(story.doc);
if (hasErrors(issues)) { if (hasErrors(issues)) {
return void res.status(422).json({ return void res.status(422).json({
@ -82,28 +116,29 @@ export function storiesRouter({ store }: { store: StoryStore }): Router {
issues, issues,
}); });
} }
const updated = await store.setPublished(story.id, true); res.json({ story: await store.setPublished(story.id, true), issues });
res.json({ story: updated, issues });
} catch (e) { } catch (e) {
next(e); next(e);
} }
}); });
router.post("/stories/:id/unpublish", async (req, res, next) => { router.post("/stories/:id/unpublish", requireAuth, async (req, res, next) => {
try { try {
const story = await store.setPublished(req.params.id, false); const cur = await load(req, res, true);
if (!story) if (!cur) return;
return void res.status(404).json({ error: "story not found" }); const story = await store.setPublished(cur.id, false);
if (!story) return notFound(res);
res.json({ story, issues: analyze(story.doc) }); res.json({ story, issues: analyze(story.doc) });
} catch (e) { } catch (e) {
next(e); next(e);
} }
}); });
router.delete("/stories/:id", async (req, res, next) => { router.delete("/stories/:id", requireAuth, async (req, res, next) => {
try { try {
const ok = await store.remove(req.params.id); const cur = await load(req, res, true);
if (!ok) return void res.status(404).json({ error: "story not found" }); if (!cur) return;
await store.remove(cur.id);
res.status(204).end(); res.status(204).end();
} catch (e) { } catch (e) {
next(e); next(e);

View file

@ -16,6 +16,8 @@ import { z } from "zod";
export const RuntimeConfigSchema = z.object({ export const RuntimeConfigSchema = z.object({
greeting: z.string().min(1).default("hello"), greeting: z.string().min(1).default("hello"),
maxItems: z.number().int().positive().default(50), 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([]), featureHints: z.array(z.string()).default([]),
}); });