10 Commits

Author SHA1 Message Date
241d3e799e Use less ambiguous funcion 2026-01-10 08:55:00 -06:00
49dc0e3fe0 Mark several unused vars as such 2026-01-10 08:54:51 -06:00
c7b8cd33da Clean up imports 2026-01-10 08:54:34 -06:00
6c0895de07 Fix formatting 2026-01-10 08:51:20 -06:00
17ea6ba02d Consider block stmts without braces to be errors 2026-01-09 11:44:09 -06:00
661def8a5c Refmt 2026-01-04 15:24:29 -06:00
74d75d08dd Add Session class to provide getUser() on call.session
Wraps SessionData and user into a Session class that handlers can use
via call.session.getUser() instead of accessing services directly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 15:22:27 -06:00
ad6d405206 Add session data to Call type
- AuthService.validateRequest now returns AuthResult with both user and session
- Call type includes session: SessionData | null
- Handlers can access session metadata (createdAt, authMethod, etc.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 09:50:05 -06:00
e9ccf6d757 Add PostgreSQL database layer with Kysely and migrations
- Add database.ts with connection pool, Kysely query builder, and migration runner
- Create migrations for users and sessions tables (0001, 0002)
- Implement PostgresAuthStore to replace InMemoryAuthStore
- Wire up database service in services/index.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 09:43:20 -06:00
34ec5be7ec Pull in kysely and pg deps 2026-01-03 17:20:49 -06:00
20 changed files with 704 additions and 55 deletions

View File

@@ -3,6 +3,7 @@ import express, {
type Response as ExpressResponse, type Response as ExpressResponse,
} from "express"; } from "express";
import { match } from "path-to-regexp"; import { match } from "path-to-regexp";
import { Session } from "./auth";
import { cli } from "./cli"; import { cli } from "./cli";
import { contentTypes } from "./content-types"; import { contentTypes } from "./content-types";
import { httpCodes } from "./http-codes"; import { httpCodes } from "./http-codes";
@@ -36,7 +37,7 @@ const processedRoutes: { [K in Method]: ProcessedRoute[] } = {
DELETE: [], DELETE: [],
}; };
function isPromise<T>(value: T | Promise<T>): value is Promise<T> { function _isPromise<T>(value: T | Promise<T>): value is Promise<T> {
return typeof (value as any)?.then === "function"; return typeof (value as any)?.then === "function";
} }
@@ -59,7 +60,7 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => {
console.log("request.originalUrl", request.originalUrl); console.log("request.originalUrl", request.originalUrl);
// Authenticate the request // Authenticate the request
const user = await services.auth.validateRequest(request); const auth = await services.auth.validateRequest(request);
const req: Call = { const req: Call = {
pattern: route.path, pattern: route.path,
@@ -67,7 +68,8 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => {
method, method,
parameters: { one: 1, two: 2 }, parameters: { one: 1, two: 2 },
request, request,
user, user: auth.user,
session: new Session(auth.session, auth.user),
}; };
try { try {

View File

@@ -7,7 +7,14 @@
// Import authRoutes directly from "./auth/routes" instead. // Import authRoutes directly from "./auth/routes" instead.
export { hashPassword, verifyPassword } from "./password"; export { hashPassword, verifyPassword } from "./password";
export { AuthService } from "./service"; export { type AuthResult, AuthService } from "./service";
export { type AuthStore, InMemoryAuthStore } from "./store"; export { type AuthStore, InMemoryAuthStore } from "./store";
export { generateToken, hashToken, SESSION_COOKIE_NAME } from "./token"; export { generateToken, hashToken, SESSION_COOKIE_NAME } from "./token";
export * from "./types"; export {
type AuthMethod,
Session,
type SessionData,
type TokenId,
type TokenType,
tokenLifetimes,
} from "./types";

View File

@@ -28,8 +28,11 @@ function scryptAsync(
): Promise<Buffer> { ): Promise<Buffer> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
scrypt(password, salt, keylen, options, (err, derivedKey) => { scrypt(password, salt, keylen, options, (err, derivedKey) => {
if (err) reject(err); if (err) {
else resolve(derivedKey); reject(err);
} else {
resolve(derivedKey);
}
}); });
}); });
} }

View File

@@ -4,7 +4,7 @@
// password reset, and email verification. // password reset, and email verification.
import type { Request as ExpressRequest } from "express"; import type { Request as ExpressRequest } from "express";
import { AnonymousUser, type MaybeUser, type User, type UserId } from "../user"; import { AnonymousUser, type User, type UserId } from "../user";
import { hashPassword, verifyPassword } from "./password"; import { hashPassword, verifyPassword } from "./password";
import type { AuthStore } from "./store"; import type { AuthStore } from "./store";
import { import {
@@ -12,7 +12,7 @@ import {
parseAuthorizationHeader, parseAuthorizationHeader,
SESSION_COOKIE_NAME, SESSION_COOKIE_NAME,
} from "./token"; } from "./token";
import { type TokenId, tokenLifetimes } from "./types"; import { type SessionData, type TokenId, tokenLifetimes } from "./types";
type LoginResult = type LoginResult =
| { success: true; token: string; user: User } | { success: true; token: string; user: User }
@@ -24,6 +24,11 @@ type RegisterResult =
type SimpleResult = { success: true } | { success: false; error: string }; type SimpleResult = { success: true } | { success: false; error: string };
// Result of validating a request/token - contains both user and session
export type AuthResult =
| { authenticated: true; user: User; session: SessionData }
| { authenticated: false; user: typeof AnonymousUser; session: null };
export class AuthService { export class AuthService {
constructor(private store: AuthStore) {} constructor(private store: AuthStore) {}
@@ -68,7 +73,7 @@ export class AuthService {
// === Session Validation === // === Session Validation ===
async validateRequest(request: ExpressRequest): Promise<MaybeUser> { async validateRequest(request: ExpressRequest): Promise<AuthResult> {
// Try cookie first (for web requests) // Try cookie first (for web requests)
let token = this.extractCookieToken(request); let token = this.extractCookieToken(request);
@@ -78,38 +83,40 @@ export class AuthService {
} }
if (!token) { if (!token) {
return AnonymousUser; return { authenticated: false, user: AnonymousUser, session: null };
} }
return this.validateToken(token); return this.validateToken(token);
} }
async validateToken(token: string): Promise<MaybeUser> { async validateToken(token: string): Promise<AuthResult> {
const tokenId = hashToken(token) as TokenId; const tokenId = hashToken(token) as TokenId;
const session = await this.store.getSession(tokenId); const session = await this.store.getSession(tokenId);
if (!session) { if (!session) {
return AnonymousUser; return { authenticated: false, user: AnonymousUser, session: null };
} }
if (session.tokenType !== "session") { if (session.tokenType !== "session") {
return AnonymousUser; return { authenticated: false, user: AnonymousUser, session: null };
} }
const user = await this.store.getUserById(session.userId as UserId); const user = await this.store.getUserById(session.userId as UserId);
if (!user || !user.isActive()) { if (!user || !user.isActive()) {
return AnonymousUser; return { authenticated: false, user: AnonymousUser, session: null };
} }
// Update last used (fire and forget) // Update last used (fire and forget)
this.store.updateLastUsed(tokenId).catch(() => {}); this.store.updateLastUsed(tokenId).catch(() => {});
return user; return { authenticated: true, user, session };
} }
private extractCookieToken(request: ExpressRequest): string | null { private extractCookieToken(request: ExpressRequest): string | null {
const cookies = request.get("Cookie"); const cookies = request.get("Cookie");
if (!cookies) return null; if (!cookies) {
return null;
}
for (const cookie of cookies.split(";")) { for (const cookie of cookies.split(";")) {
const [name, ...valueParts] = cookie.trim().split("="); const [name, ...valueParts] = cookie.trim().split("=");
@@ -240,7 +247,9 @@ export class AuthService {
extractToken(request: ExpressRequest): string | null { extractToken(request: ExpressRequest): string | null {
// Try Authorization header first // Try Authorization header first
const token = parseAuthorizationHeader(request.get("Authorization")); const token = parseAuthorizationHeader(request.get("Authorization"));
if (token) return token; if (token) {
return token;
}
// Try cookie // Try cookie
return this.extractCookieToken(request); return this.extractCookieToken(request);

View File

@@ -75,7 +75,9 @@ export class InMemoryAuthStore implements AuthStore {
async getSession(tokenId: TokenId): Promise<SessionData | null> { async getSession(tokenId: TokenId): Promise<SessionData | null> {
const session = this.sessions.get(tokenId); const session = this.sessions.get(tokenId);
if (!session) return null; if (!session) {
return null;
}
// Check expiration // Check expiration
if (new Date() > session.expiresAt) { if (new Date() > session.expiresAt) {
@@ -110,7 +112,9 @@ export class InMemoryAuthStore implements AuthStore {
async getUserByEmail(email: string): Promise<User | null> { async getUserByEmail(email: string): Promise<User | null> {
const userId = this.usersByEmail.get(email.toLowerCase()); const userId = this.usersByEmail.get(email.toLowerCase());
if (!userId) return null; if (!userId) {
return null;
}
return this.users.get(userId) ?? null; return this.users.get(userId) ?? null;
} }

View File

@@ -19,7 +19,9 @@ function hashToken(token: string): string {
// Parse token from Authorization header // Parse token from Authorization header
function parseAuthorizationHeader(header: string | undefined): string | null { function parseAuthorizationHeader(header: string | undefined): string | null {
if (!header) return null; if (!header) {
return null;
}
const parts = header.split(" "); const parts = header.split(" ");
if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") { if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") {

View File

@@ -62,3 +62,35 @@ export const tokenLifetimes: Record<TokenType, number> = {
password_reset: 1 * 60 * 60 * 1000, // 1 hour password_reset: 1 * 60 * 60 * 1000, // 1 hour
email_verify: 24 * 60 * 60 * 1000, // 24 hours email_verify: 24 * 60 * 60 * 1000, // 24 hours
}; };
// Import here to avoid circular dependency at module load time
import { AnonymousUser, type MaybeUser } from "../user";
// Session wrapper class providing a consistent interface for handlers.
// Always present on Call (never null), but may represent an anonymous session.
export class Session {
constructor(
private readonly data: SessionData | null,
private readonly user: MaybeUser,
) {}
getUser(): MaybeUser {
return this.user;
}
getData(): SessionData | null {
return this.data;
}
isAuthenticated(): boolean {
return this.user !== AnonymousUser;
}
get tokenId(): string | undefined {
return this.data?.tokenId;
}
get userId(): string | undefined {
return this.data?.userId;
}
}

View File

@@ -17,7 +17,10 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true "recommended": true,
"style": {
"useBlockStatements": "error"
}
} }
}, },
"javascript": { "javascript": {

View File

@@ -30,7 +30,7 @@ function parseListenAddress(listen: string | undefined): {
if (lastColon === -1) { if (lastColon === -1) {
// Just a port number // Just a port number
const port = parseInt(listen, 10); const port = parseInt(listen, 10);
if (isNaN(port)) { if (Number.isNaN(port)) {
throw new Error(`Invalid listen address: ${listen}`); throw new Error(`Invalid listen address: ${listen}`);
} }
return { host: defaultHost, port }; return { host: defaultHost, port };
@@ -39,7 +39,7 @@ function parseListenAddress(listen: string | undefined): {
const host = listen.slice(0, lastColon); const host = listen.slice(0, lastColon);
const port = parseInt(listen.slice(lastColon + 1), 10); const port = parseInt(listen.slice(lastColon + 1), 10);
if (isNaN(port)) { if (Number.isNaN(port)) {
throw new Error(`Invalid port in listen address: ${listen}`); throw new Error(`Invalid port in listen address: ${listen}`);
} }

View File

@@ -1,5 +1,3 @@
import { Extensible } from "./interfaces";
export type ContentType = string; export type ContentType = string;
// tx claude https://claude.ai/share/344fc7bd-5321-4763-af2f-b82275e9f865 // tx claude https://claude.ai/share/344fc7bd-5321-4763-af2f-b82275e9f865

397
express/database.ts Normal file
View File

@@ -0,0 +1,397 @@
// database.ts
// PostgreSQL database access with Kysely query builder and simple migrations
import * as fs from "node:fs";
import * as path from "node:path";
import {
type Generated,
Kysely,
PostgresDialect,
type Selectable,
sql,
} from "kysely";
import { Pool } from "pg";
import type {
AuthStore,
CreateSessionData,
CreateUserData,
} from "./auth/store";
import { generateToken, hashToken } from "./auth/token";
import type { SessionData, TokenId } from "./auth/types";
import { User, type UserId } from "./user";
// Connection configuration
const connectionConfig = {
host: "localhost",
port: 5432,
user: "diachron",
password: "diachron",
database: "diachron",
};
// Database schema types for Kysely
// Generated<T> marks columns with database defaults (optional on insert)
interface UsersTable {
id: string;
email: string;
password_hash: string;
display_name: string | null;
status: Generated<string>;
roles: Generated<string[]>;
permissions: Generated<string[]>;
email_verified: Generated<boolean>;
created_at: Generated<Date>;
updated_at: Generated<Date>;
}
interface SessionsTable {
token_id: string;
user_id: string;
token_type: string;
auth_method: string;
created_at: Generated<Date>;
expires_at: Date;
last_used_at: Date | null;
user_agent: string | null;
ip_address: string | null;
is_used: Generated<boolean | null>;
}
interface Database {
users: UsersTable;
sessions: SessionsTable;
}
// Create the connection pool
const pool = new Pool(connectionConfig);
// Create the Kysely instance
const db = new Kysely<Database>({
dialect: new PostgresDialect({ pool }),
});
// Raw pool access for when you need it
const rawPool = pool;
// Execute raw SQL (for when Kysely doesn't fit)
async function raw<T = unknown>(
query: string,
params: unknown[] = [],
): Promise<T[]> {
const result = await pool.query(query, params);
return result.rows as T[];
}
// ============================================================================
// Migrations
// ============================================================================
// Migration file naming convention:
// NNNN_description.sql
// e.g., 0001_initial.sql, 0002_add_users.sql
//
// Migrations directory: express/migrations/
const MIGRATIONS_DIR = path.join(__dirname, "migrations");
const MIGRATIONS_TABLE = "_migrations";
interface MigrationRecord {
id: number;
name: string;
applied_at: Date;
}
// Ensure migrations table exists
async function ensureMigrationsTable(): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
}
// Get list of applied migrations
async function getAppliedMigrations(): Promise<string[]> {
const result = await pool.query<MigrationRecord>(
`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY name`,
);
return result.rows.map((r) => r.name);
}
// Get pending migration files
function getMigrationFiles(): string[] {
if (!fs.existsSync(MIGRATIONS_DIR)) {
return [];
}
return fs
.readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith(".sql"))
.filter((f) => /^\d{4}_/.test(f))
.sort();
}
// Run a single migration
async function runMigration(filename: string): Promise<void> {
const filepath = path.join(MIGRATIONS_DIR, filename);
const content = fs.readFileSync(filepath, "utf-8");
// Run migration in a transaction
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(content);
await client.query(
`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES ($1)`,
[filename],
);
await client.query("COMMIT");
console.log(`Applied migration: ${filename}`);
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
// Run all pending migrations
async function migrate(): Promise<void> {
await ensureMigrationsTable();
const applied = new Set(await getAppliedMigrations());
const files = getMigrationFiles();
const pending = files.filter((f) => !applied.has(f));
if (pending.length === 0) {
console.log("No pending migrations");
return;
}
console.log(`Running ${pending.length} migration(s)...`);
for (const file of pending) {
await runMigration(file);
}
console.log("Migrations complete");
}
// List migration status
async function migrationStatus(): Promise<{
applied: string[];
pending: string[];
}> {
await ensureMigrationsTable();
const applied = new Set(await getAppliedMigrations());
const files = getMigrationFiles();
return {
applied: files.filter((f) => applied.has(f)),
pending: files.filter((f) => !applied.has(f)),
};
}
// ============================================================================
// PostgresAuthStore - Database-backed authentication storage
// ============================================================================
class PostgresAuthStore implements AuthStore {
// Session operations
async createSession(
data: CreateSessionData,
): Promise<{ token: string; session: SessionData }> {
const token = generateToken();
const tokenId = hashToken(token);
const row = await db
.insertInto("sessions")
.values({
token_id: tokenId,
user_id: data.userId,
token_type: data.tokenType,
auth_method: data.authMethod,
expires_at: data.expiresAt,
user_agent: data.userAgent ?? null,
ip_address: data.ipAddress ?? null,
})
.returningAll()
.executeTakeFirstOrThrow();
const session: SessionData = {
tokenId: row.token_id,
userId: row.user_id,
tokenType: row.token_type as SessionData["tokenType"],
authMethod: row.auth_method as SessionData["authMethod"],
createdAt: row.created_at,
expiresAt: row.expires_at,
lastUsedAt: row.last_used_at ?? undefined,
userAgent: row.user_agent ?? undefined,
ipAddress: row.ip_address ?? undefined,
isUsed: row.is_used ?? undefined,
};
return { token, session };
}
async getSession(tokenId: TokenId): Promise<SessionData | null> {
const row = await db
.selectFrom("sessions")
.selectAll()
.where("token_id", "=", tokenId)
.where("expires_at", ">", new Date())
.executeTakeFirst();
if (!row) {
return null;
}
return {
tokenId: row.token_id,
userId: row.user_id,
tokenType: row.token_type as SessionData["tokenType"],
authMethod: row.auth_method as SessionData["authMethod"],
createdAt: row.created_at,
expiresAt: row.expires_at,
lastUsedAt: row.last_used_at ?? undefined,
userAgent: row.user_agent ?? undefined,
ipAddress: row.ip_address ?? undefined,
isUsed: row.is_used ?? undefined,
};
}
async updateLastUsed(tokenId: TokenId): Promise<void> {
await db
.updateTable("sessions")
.set({ last_used_at: new Date() })
.where("token_id", "=", tokenId)
.execute();
}
async deleteSession(tokenId: TokenId): Promise<void> {
await db
.deleteFrom("sessions")
.where("token_id", "=", tokenId)
.execute();
}
async deleteUserSessions(userId: UserId): Promise<number> {
const result = await db
.deleteFrom("sessions")
.where("user_id", "=", userId)
.executeTakeFirst();
return Number(result.numDeletedRows);
}
// User operations
async getUserByEmail(email: string): Promise<User | null> {
const row = await db
.selectFrom("users")
.selectAll()
.where(sql`LOWER(email)`, "=", email.toLowerCase())
.executeTakeFirst();
if (!row) {
return null;
}
return this.rowToUser(row);
}
async getUserById(userId: UserId): Promise<User | null> {
const row = await db
.selectFrom("users")
.selectAll()
.where("id", "=", userId)
.executeTakeFirst();
if (!row) {
return null;
}
return this.rowToUser(row);
}
async createUser(data: CreateUserData): Promise<User> {
const id = crypto.randomUUID();
const now = new Date();
const row = await db
.insertInto("users")
.values({
id,
email: data.email,
password_hash: data.passwordHash,
display_name: data.displayName ?? null,
status: "pending",
roles: [],
permissions: [],
email_verified: false,
created_at: now,
updated_at: now,
})
.returningAll()
.executeTakeFirstOrThrow();
return this.rowToUser(row);
}
async getUserPasswordHash(userId: UserId): Promise<string | null> {
const row = await db
.selectFrom("users")
.select("password_hash")
.where("id", "=", userId)
.executeTakeFirst();
return row?.password_hash ?? null;
}
async setUserPassword(userId: UserId, passwordHash: string): Promise<void> {
await db
.updateTable("users")
.set({ password_hash: passwordHash, updated_at: new Date() })
.where("id", "=", userId)
.execute();
}
async updateUserEmailVerified(userId: UserId): Promise<void> {
await db
.updateTable("users")
.set({
email_verified: true,
status: "active",
updated_at: new Date(),
})
.where("id", "=", userId)
.execute();
}
// Helper to convert database row to User object
private rowToUser(row: Selectable<UsersTable>): User {
return new User({
id: row.id,
email: row.email,
displayName: row.display_name ?? undefined,
status: row.status as "active" | "suspended" | "pending",
roles: row.roles,
permissions: row.permissions,
createdAt: row.created_at,
updatedAt: row.updated_at,
});
}
}
// ============================================================================
// Exports
// ============================================================================
export {
db,
raw,
rawPool,
pool,
migrate,
migrationStatus,
connectionConfig,
PostgresAuthStore,
type Database,
};

View File

@@ -1,5 +1,3 @@
import { Extensible } from "./interfaces";
export type HttpCode = { export type HttpCode = {
code: number; code: number;
name: string; name: string;

View File

@@ -15,8 +15,8 @@ type Message = {
text: AtLeastOne<string>; text: AtLeastOne<string>;
}; };
const m1: Message = { timestamp: 123, source: "logging", text: ["foo"] }; const _m1: Message = { timestamp: 123, source: "logging", text: ["foo"] };
const m2: Message = { const _m2: Message = {
timestamp: 321, timestamp: 321,
source: "diagnostic", source: "diagnostic",
text: ["ok", "whatever"], text: ["ok", "whatever"],

View File

@@ -0,0 +1,21 @@
-- 0001_users.sql
-- Create users table for authentication
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT,
status TEXT NOT NULL DEFAULT 'pending',
roles TEXT[] NOT NULL DEFAULT '{}',
permissions TEXT[] NOT NULL DEFAULT '{}',
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index for email lookups (login)
CREATE INDEX users_email_idx ON users (LOWER(email));
-- Index for status filtering
CREATE INDEX users_status_idx ON users (status);

View File

@@ -0,0 +1,24 @@
-- 0002_sessions.sql
-- Create sessions table for auth tokens
CREATE TABLE sessions (
token_id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_type TEXT NOT NULL,
auth_method TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ,
user_agent TEXT,
ip_address TEXT,
is_used BOOLEAN DEFAULT FALSE
);
-- Index for user session lookups (logout all, etc.)
CREATE INDEX sessions_user_id_idx ON sessions (user_id);
-- Index for expiration cleanup
CREATE INDEX sessions_expires_at_idx ON sessions (expires_at);
-- Index for token type filtering
CREATE INDEX sessions_token_type_idx ON sessions (token_type);

View File

@@ -18,9 +18,11 @@
"@types/nunjucks": "^3.2.6", "@types/nunjucks": "^3.2.6",
"@vercel/ncc": "^0.38.4", "@vercel/ncc": "^0.38.4",
"express": "^5.1.0", "express": "^5.1.0",
"kysely": "^0.28.9",
"nodemon": "^3.1.11", "nodemon": "^3.1.11",
"nunjucks": "^3.2.4", "nunjucks": "^3.2.4",
"path-to-regexp": "^8.3.0", "path-to-regexp": "^8.3.0",
"pg": "^8.16.3",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"ts-luxon": "^6.2.0", "ts-luxon": "^6.2.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@@ -46,6 +48,7 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.3.10", "@biomejs/biome": "2.3.10",
"@types/express": "^5.0.5" "@types/express": "^5.0.5",
"@types/pg": "^8.16.0"
} }
} }

131
express/pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
express: express:
specifier: ^5.1.0 specifier: ^5.1.0
version: 5.1.0 version: 5.1.0
kysely:
specifier: ^0.28.9
version: 0.28.9
nodemon: nodemon:
specifier: ^3.1.11 specifier: ^3.1.11
version: 3.1.11 version: 3.1.11
@@ -32,6 +35,9 @@ importers:
path-to-regexp: path-to-regexp:
specifier: ^8.3.0 specifier: ^8.3.0
version: 8.3.0 version: 8.3.0
pg:
specifier: ^8.16.3
version: 8.16.3
prettier: prettier:
specifier: ^3.6.2 specifier: ^3.6.2
version: 3.6.2 version: 3.6.2
@@ -57,6 +63,9 @@ importers:
'@types/express': '@types/express':
specifier: ^5.0.5 specifier: ^5.0.5
version: 5.0.5 version: 5.0.5
'@types/pg':
specifier: ^8.16.0
version: 8.16.0
packages: packages:
@@ -380,6 +389,9 @@ packages:
'@types/nunjucks@3.2.6': '@types/nunjucks@3.2.6':
resolution: {integrity: sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==} resolution: {integrity: sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==}
'@types/pg@8.16.0':
resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==}
'@types/qs@6.14.0': '@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
@@ -645,6 +657,10 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
kysely@0.28.9:
resolution: {integrity: sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA==}
engines: {node: '>=20.0.0'}
make-error@1.3.6: make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
@@ -715,6 +731,40 @@ packages:
path-to-regexp@8.3.0: path-to-regexp@8.3.0:
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
pg-cloudflare@1.2.7:
resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==}
pg-connection-string@2.9.1:
resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-pool@3.10.1:
resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.10.3:
resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg@8.16.3:
resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -722,6 +772,22 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
postgres-bytea@1.0.1:
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
engines: {node: '>=0.10.0'}
postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
prettier@3.6.2: prettier@3.6.2:
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -799,6 +865,10 @@ packages:
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
engines: {node: '>=10'} engines: {node: '>=10'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
statuses@2.0.1: statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -875,6 +945,10 @@ packages:
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
yn@3.1.1: yn@3.1.1:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1118,6 +1192,12 @@ snapshots:
'@types/nunjucks@3.2.6': {} '@types/nunjucks@3.2.6': {}
'@types/pg@8.16.0':
dependencies:
'@types/node': 24.10.1
pg-protocol: 1.10.3
pg-types: 2.2.0
'@types/qs@6.14.0': {} '@types/qs@6.14.0': {}
'@types/range-parser@1.2.7': {} '@types/range-parser@1.2.7': {}
@@ -1421,6 +1501,8 @@ snapshots:
jsesc@3.1.0: {} jsesc@3.1.0: {}
kysely@0.28.9: {}
make-error@1.3.6: {} make-error@1.3.6: {}
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
@@ -1480,10 +1562,55 @@ snapshots:
path-to-regexp@8.3.0: {} path-to-regexp@8.3.0: {}
pg-cloudflare@1.2.7:
optional: true
pg-connection-string@2.9.1: {}
pg-int8@1.0.1: {}
pg-pool@3.10.1(pg@8.16.3):
dependencies:
pg: 8.16.3
pg-protocol@1.10.3: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.1
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg@8.16.3:
dependencies:
pg-connection-string: 2.9.1
pg-pool: 3.10.1(pg@8.16.3)
pg-protocol: 1.10.3
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.2.7
pgpass@1.0.5:
dependencies:
split2: 4.2.0
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.1: {} picomatch@2.3.1: {}
postgres-array@2.0.0: {}
postgres-bytea@1.0.1: {}
postgres-date@1.0.7: {}
postgres-interval@1.2.0:
dependencies:
xtend: 4.0.2
prettier@3.6.2: {} prettier@3.6.2: {}
proxy-addr@2.0.7: proxy-addr@2.0.7:
@@ -1587,6 +1714,8 @@ snapshots:
dependencies: dependencies:
semver: 7.7.3 semver: 7.7.3
split2@4.2.0: {}
statuses@2.0.1: {} statuses@2.0.1: {}
statuses@2.0.2: {} statuses@2.0.2: {}
@@ -1650,6 +1779,8 @@ snapshots:
wrappy@1.0.2: {} wrappy@1.0.2: {}
xtend@4.0.2: {}
yn@3.1.1: {} yn@3.1.1: {}
zod@4.1.12: {} zod@4.1.12: {}

View File

@@ -5,9 +5,9 @@ import { DateTime } from "ts-luxon";
import { authRoutes } from "./auth/routes"; import { authRoutes } from "./auth/routes";
import { contentTypes } from "./content-types"; import { contentTypes } from "./content-types";
import { multiHandler } from "./handlers"; import { multiHandler } from "./handlers";
import { HttpCode, httpCodes } from "./http-codes"; import { httpCodes } from "./http-codes";
import { services } from "./services"; import { services } from "./services";
import { type Call, ProcessedRoute, type Result, type Route } from "./types"; import type { Call, Result, Route } from "./types";
// FIXME: Obviously put this somewhere else // FIXME: Obviously put this somewhere else
const okText = (result: string): Result => { const okText = (result: string): Result => {
@@ -41,7 +41,7 @@ const routes: Route[] = [
{ {
path: "/list", path: "/list",
methods: ["GET"], methods: ["GET"],
handler: async (call: Call): Promise<Result> => { handler: async (_call: Call): Promise<Result> => {
const code = httpCodes.success.OK; const code = httpCodes.success.OK;
const lr = (rr: Route[]) => { const lr = (rr: Route[]) => {
const ret = rr.map((r: Route) => { const ret = rr.map((r: Route) => {
@@ -51,19 +51,35 @@ const routes: Route[] = [
return ret; return ret;
}; };
const listing = lr(routes).join(", "); const rrr = lr(routes);
const template = `
<html>
<head></head>
<body>
<ul>
{% for route in rrr %}
<li><a href="{{ route }}">{{ route }}</a></li>
{% endfor %}
</ul>
</body>
</html>
`;
const result = nunjucks.renderString(template, { rrr });
const _listing = lr(routes).join(", ");
return { return {
code, code,
result: listing + "\n", result,
contentType: contentTypes.text.plain, contentType: contentTypes.text.html,
}; };
}, },
}, },
{ {
path: "/whoami", path: "/whoami",
methods: ["GET"], methods: ["GET"],
handler: async (_call: Call): Promise<Result> => { handler: async (call: Call): Promise<Result> => {
const me = services.session.getUser(); const me = call.session.getUser();
const template = ` const template = `
<html> <html>
<head></head> <head></head>

View File

@@ -1,15 +1,15 @@
// services.ts // services.ts
import { AuthService, InMemoryAuthStore } from "../auth"; import { AuthService } from "../auth";
import { config } from "../config"; import { db, migrate, migrationStatus, PostgresAuthStore } from "../database";
import { getLogs, log } from "../logging"; import { getLogs, log } from "../logging";
import { AnonymousUser, anonymousUser, type User } from "../user"; import { anonymousUser, type User } from "../user";
//const database = Client({ const database = {
db,
//}) migrate,
migrationStatus,
const database = {}; };
const logging = { const logging = {
log, log,
@@ -34,8 +34,8 @@ const session = {
}, },
}; };
// Initialize auth with in-memory store // Initialize auth with PostgreSQL store
const authStore = new InMemoryAuthStore(); const authStore = new PostgresAuthStore();
const auth = new AuthService(authStore); const auth = new AuthService(authStore);
// Keep this asciibetically sorted // Keep this asciibetically sorted

View File

@@ -2,14 +2,12 @@
// FIXME: split this up into types used by app developers and types internal // FIXME: split this up into types used by app developers and types internal
// to the framework. // to the framework.
import { import type { Request as ExpressRequest } from "express";
type Request as ExpressRequest,
Response as ExpressResponse,
} from "express";
import type { MatchFunction } from "path-to-regexp"; import type { MatchFunction } from "path-to-regexp";
import { z } from "zod"; import { z } from "zod";
import { type ContentType, contentTypes } from "./content-types"; import type { Session } from "./auth/types";
import { type HttpCode, httpCodes } from "./http-codes"; import type { ContentType } from "./content-types";
import type { HttpCode } from "./http-codes";
import { import {
AnonymousUser, AnonymousUser,
type MaybeUser, type MaybeUser,
@@ -39,6 +37,7 @@ export type Call = {
parameters: object; parameters: object;
request: ExpressRequest; request: ExpressRequest;
user: MaybeUser; user: MaybeUser;
session: Session;
}; };
export type InternalHandler = (req: ExpressRequest) => Promise<Result>; export type InternalHandler = (req: ExpressRequest) => Promise<Result>;