Rename express/ to backend/ and update references

Update paths in sync.sh, master/main.go, and CLAUDE.md to reflect
the directory rename.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 17:54:44 -05:00
parent 6e669d025a
commit db1f2151de
80 changed files with 5 additions and 5 deletions

View File

@@ -0,0 +1,20 @@
// index.ts
//
// Barrel export for auth module.
//
// NOTE: authRoutes is NOT exported here to avoid circular dependency:
// services.ts → auth/index.ts → auth/routes.ts → services.ts
// Import authRoutes directly from "./auth/routes" instead.
export { hashPassword, verifyPassword } from "./password";
export { type AuthResult, AuthService } from "./service";
export { type AuthStore, InMemoryAuthStore } from "./store";
export { generateToken, hashToken, SESSION_COOKIE_NAME } from "./token";
export {
type AuthMethod,
Session,
type SessionData,
type TokenId,
type TokenType,
tokenLifetimes,
} from "./types";

View File

@@ -0,0 +1,80 @@
// Tests for auth/password.ts
// Pure unit tests - no database needed
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { hashPassword, verifyPassword } from "./password";
describe("password", () => {
describe("hashPassword", () => {
it("returns a scrypt formatted hash", async () => {
const hash = await hashPassword("testpassword");
assert.ok(hash.startsWith("$scrypt$"));
});
it("includes all scrypt parameters", async () => {
const hash = await hashPassword("testpassword");
const parts = hash.split("$");
// Format: $scrypt$N$r$p$salt$hash
assert.equal(parts.length, 7);
assert.equal(parts[0], "");
assert.equal(parts[1], "scrypt");
// N, r, p should be numbers
assert.ok(!Number.isNaN(parseInt(parts[2], 10)));
assert.ok(!Number.isNaN(parseInt(parts[3], 10)));
assert.ok(!Number.isNaN(parseInt(parts[4], 10)));
});
it("generates different hashes for same password (different salt)", async () => {
const hash1 = await hashPassword("testpassword");
const hash2 = await hashPassword("testpassword");
assert.notEqual(hash1, hash2);
});
});
describe("verifyPassword", () => {
it("returns true for correct password", async () => {
const hash = await hashPassword("correctpassword");
const result = await verifyPassword("correctpassword", hash);
assert.equal(result, true);
});
it("returns false for incorrect password", async () => {
const hash = await hashPassword("correctpassword");
const result = await verifyPassword("wrongpassword", hash);
assert.equal(result, false);
});
it("throws for invalid hash format", async () => {
await assert.rejects(
verifyPassword("password", "invalid-hash"),
/Invalid password hash format/,
);
});
it("throws for non-scrypt hash", async () => {
await assert.rejects(
verifyPassword("password", "$bcrypt$10$salt$hash"),
/Invalid password hash format/,
);
});
it("works with empty password", async () => {
const hash = await hashPassword("");
const result = await verifyPassword("", hash);
assert.equal(result, true);
});
it("works with unicode password", async () => {
const hash = await hashPassword("p@$$w0rd\u{1F511}");
const result = await verifyPassword("p@$$w0rd\u{1F511}", hash);
assert.equal(result, true);
});
it("is case sensitive", async () => {
const hash = await hashPassword("Password");
const result = await verifyPassword("password", hash);
assert.equal(result, false);
});
});
});

View File

@@ -0,0 +1,70 @@
// password.ts
//
// Password hashing using Node.js scrypt (no external dependencies).
// Format: $scrypt$N$r$p$salt$hash (all base64)
import {
randomBytes,
type ScryptOptions,
scrypt,
timingSafeEqual,
} from "node:crypto";
// Configuration
const SALT_LENGTH = 32;
const KEY_LENGTH = 64;
const SCRYPT_PARAMS: ScryptOptions = {
N: 16384, // CPU/memory cost parameter (2^14)
r: 8, // Block size
p: 1, // Parallelization
};
// Promisified scrypt with options support
function scryptAsync(
password: string,
salt: Buffer,
keylen: number,
options: ScryptOptions,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, keylen, options, (err, derivedKey) => {
if (err) {
reject(err);
} else {
resolve(derivedKey);
}
});
});
}
async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(SALT_LENGTH);
const hash = await scryptAsync(password, salt, KEY_LENGTH, SCRYPT_PARAMS);
const { N, r, p } = SCRYPT_PARAMS;
return `$scrypt$${N}$${r}$${p}$${salt.toString("base64")}$${hash.toString("base64")}`;
}
async function verifyPassword(
password: string,
stored: string,
): Promise<boolean> {
const parts = stored.split("$");
if (parts[1] !== "scrypt" || parts.length !== 7) {
throw new Error("Invalid password hash format");
}
const [, , nStr, rStr, pStr, saltB64, hashB64] = parts;
const salt = Buffer.from(saltB64, "base64");
const storedHash = Buffer.from(hashB64, "base64");
const computedHash = await scryptAsync(password, salt, storedHash.length, {
N: parseInt(nStr, 10),
r: parseInt(rStr, 10),
p: parseInt(pStr, 10),
});
return timingSafeEqual(storedHash, computedHash);
}
export { hashPassword, verifyPassword };

View File

@@ -0,0 +1,231 @@
// routes.ts
//
// Authentication route handlers.
import { z } from "zod";
import { contentTypes } from "../content-types";
import { httpCodes } from "../http-codes";
import { request } from "../request";
import type { Call, Result, Route } from "../types";
import {
forgotPasswordInputParser,
loginInputParser,
registerInputParser,
resetPasswordInputParser,
} from "./types";
// Helper for JSON responses
const jsonResponse = (
code: (typeof httpCodes.success)[keyof typeof httpCodes.success],
data: object,
): Result => ({
code,
contentType: contentTypes.application.json,
result: JSON.stringify(data),
});
const errorResponse = (
code: (typeof httpCodes.clientErrors)[keyof typeof httpCodes.clientErrors],
error: string,
): Result => ({
code,
contentType: contentTypes.application.json,
result: JSON.stringify({ error }),
});
// POST /auth/login
const loginHandler = async (call: Call): Promise<Result> => {
try {
const body = call.request.body;
const { email, password } = loginInputParser.parse(body);
const result = await request.auth.login(email, password, "cookie", {
userAgent: call.request.get("User-Agent"),
ipAddress: call.request.ip,
});
if (!result.success) {
return errorResponse(
httpCodes.clientErrors.Unauthorized,
result.error,
);
}
return jsonResponse(httpCodes.success.OK, {
token: result.token,
user: {
id: result.user.id,
email: result.user.email,
displayName: result.user.displayName,
},
});
} catch (error) {
if (error instanceof z.ZodError) {
return errorResponse(
httpCodes.clientErrors.BadRequest,
"Invalid input",
);
}
throw error;
}
};
// POST /auth/logout
const logoutHandler = async (call: Call): Promise<Result> => {
const token = request.auth.extractToken(call.request);
if (token) {
await request.auth.logout(token);
}
return jsonResponse(httpCodes.success.OK, { message: "Logged out" });
};
// POST /auth/register
const registerHandler = async (call: Call): Promise<Result> => {
try {
const body = call.request.body;
const { email, password, displayName } =
registerInputParser.parse(body);
const result = await request.auth.register(
email,
password,
displayName,
);
if (!result.success) {
return errorResponse(httpCodes.clientErrors.Conflict, result.error);
}
// TODO: Send verification email with result.verificationToken
// For now, log it for development
console.log(
`[AUTH] Verification token for ${email}: ${result.verificationToken}`,
);
return jsonResponse(httpCodes.success.Created, {
message:
"Registration successful. Please check your email to verify your account.",
user: {
id: result.user.id,
email: result.user.email,
},
});
} catch (error) {
if (error instanceof z.ZodError) {
return errorResponse(
httpCodes.clientErrors.BadRequest,
"Invalid input",
);
}
throw error;
}
};
// POST /auth/forgot-password
const forgotPasswordHandler = async (call: Call): Promise<Result> => {
try {
const body = call.request.body;
const { email } = forgotPasswordInputParser.parse(body);
const result = await request.auth.createPasswordResetToken(email);
// Always return success (don't reveal if email exists)
if (result) {
// TODO: Send password reset email
console.log(
`[AUTH] Password reset token for ${email}: ${result.token}`,
);
}
return jsonResponse(httpCodes.success.OK, {
message:
"If an account exists with that email, a password reset link has been sent.",
});
} catch (error) {
if (error instanceof z.ZodError) {
return errorResponse(
httpCodes.clientErrors.BadRequest,
"Invalid input",
);
}
throw error;
}
};
// POST /auth/reset-password
const resetPasswordHandler = async (call: Call): Promise<Result> => {
try {
const body = call.request.body;
const { token, password } = resetPasswordInputParser.parse(body);
const result = await request.auth.resetPassword(token, password);
if (!result.success) {
return errorResponse(
httpCodes.clientErrors.BadRequest,
result.error,
);
}
return jsonResponse(httpCodes.success.OK, {
message:
"Password has been reset. You can now log in with your new password.",
});
} catch (error) {
if (error instanceof z.ZodError) {
return errorResponse(
httpCodes.clientErrors.BadRequest,
"Invalid input",
);
}
throw error;
}
};
// GET /auth/verify-email?token=xxx
const verifyEmailHandler = async (call: Call): Promise<Result> => {
const url = new URL(call.path, "http://localhost");
const token = url.searchParams.get("token");
if (!token) {
return errorResponse(
httpCodes.clientErrors.BadRequest,
"Missing token",
);
}
const result = await request.auth.verifyEmail(token);
if (!result.success) {
return errorResponse(httpCodes.clientErrors.BadRequest, result.error);
}
return jsonResponse(httpCodes.success.OK, {
message: "Email verified successfully. You can now log in.",
});
};
// Export routes
const authRoutes: Route[] = [
{ path: "/auth/login", methods: ["POST"], handler: loginHandler },
{ path: "/auth/logout", methods: ["POST"], handler: logoutHandler },
{ path: "/auth/register", methods: ["POST"], handler: registerHandler },
{
path: "/auth/forgot-password",
methods: ["POST"],
handler: forgotPasswordHandler,
},
{
path: "/auth/reset-password",
methods: ["POST"],
handler: resetPasswordHandler,
},
{
path: "/auth/verify-email",
methods: ["GET"],
handler: verifyEmailHandler,
},
];
export { authRoutes };

View File

@@ -0,0 +1,419 @@
// Tests for auth/service.ts
// Uses InMemoryAuthStore - no database needed
import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";
import { AuthService } from "./service";
import { InMemoryAuthStore } from "./store";
describe("AuthService", () => {
let store: InMemoryAuthStore;
let service: AuthService;
beforeEach(() => {
store = new InMemoryAuthStore();
service = new AuthService(store);
});
describe("register", () => {
it("creates a new user", async () => {
const result = await service.register(
"test@example.com",
"password123",
"Test User",
);
assert.equal(result.success, true);
if (result.success) {
assert.equal(result.user.email, "test@example.com");
assert.equal(result.user.displayName, "Test User");
assert.ok(result.verificationToken.length > 0);
}
});
it("fails when email already registered", async () => {
await service.register("test@example.com", "password123");
const result = await service.register(
"test@example.com",
"password456",
);
assert.equal(result.success, false);
if (!result.success) {
assert.equal(result.error, "Email already registered");
}
});
it("creates user without displayName", async () => {
const result = await service.register(
"test@example.com",
"password123",
);
assert.equal(result.success, true);
if (result.success) {
assert.equal(result.user.displayName, undefined);
}
});
});
describe("login", () => {
beforeEach(async () => {
// Create and verify a user
const result = await service.register(
"test@example.com",
"password123",
"Test User",
);
if (result.success) {
// Verify email to activate user
await service.verifyEmail(result.verificationToken);
}
});
it("succeeds with correct credentials", async () => {
const result = await service.login(
"test@example.com",
"password123",
"cookie",
);
assert.equal(result.success, true);
if (result.success) {
assert.ok(result.token.length > 0);
assert.equal(result.user.email, "test@example.com");
}
});
it("fails with wrong password", async () => {
const result = await service.login(
"test@example.com",
"wrongpassword",
"cookie",
);
assert.equal(result.success, false);
if (!result.success) {
assert.equal(result.error, "Invalid credentials");
}
});
it("fails with unknown email", async () => {
const result = await service.login(
"unknown@example.com",
"password123",
"cookie",
);
assert.equal(result.success, false);
if (!result.success) {
assert.equal(result.error, "Invalid credentials");
}
});
it("fails for inactive user", async () => {
// Create a user but don't verify email (stays pending)
await service.register("pending@example.com", "password123");
const result = await service.login(
"pending@example.com",
"password123",
"cookie",
);
assert.equal(result.success, false);
if (!result.success) {
assert.equal(result.error, "Account is not active");
}
});
it("stores metadata", async () => {
const result = await service.login(
"test@example.com",
"password123",
"cookie",
{ userAgent: "TestAgent", ipAddress: "192.168.1.1" },
);
assert.equal(result.success, true);
});
});
describe("validateToken", () => {
let token: string;
beforeEach(async () => {
const regResult = await service.register(
"test@example.com",
"password123",
);
if (regResult.success) {
await service.verifyEmail(regResult.verificationToken);
}
const loginResult = await service.login(
"test@example.com",
"password123",
"cookie",
);
if (loginResult.success) {
token = loginResult.token;
}
});
it("returns authenticated for valid token", async () => {
const result = await service.validateToken(token);
assert.equal(result.authenticated, true);
if (result.authenticated) {
assert.equal(result.user.email, "test@example.com");
assert.notEqual(result.session, null);
}
});
it("returns unauthenticated for invalid token", async () => {
const result = await service.validateToken("invalid-token");
assert.equal(result.authenticated, false);
assert.equal(result.user.isAnonymous(), true);
assert.equal(result.session, null);
});
});
describe("logout", () => {
it("invalidates the session", async () => {
const regResult = await service.register(
"test@example.com",
"password123",
);
if (regResult.success) {
await service.verifyEmail(regResult.verificationToken);
}
const loginResult = await service.login(
"test@example.com",
"password123",
"cookie",
);
assert.equal(loginResult.success, true);
if (!loginResult.success) return;
const token = loginResult.token;
// Token should be valid before logout
const beforeLogout = await service.validateToken(token);
assert.equal(beforeLogout.authenticated, true);
// Logout
await service.logout(token);
// Token should be invalid after logout
const afterLogout = await service.validateToken(token);
assert.equal(afterLogout.authenticated, false);
});
});
describe("logoutAllSessions", () => {
it("invalidates all user sessions", async () => {
const regResult = await service.register(
"test@example.com",
"password123",
);
if (regResult.success) {
await service.verifyEmail(regResult.verificationToken);
}
// Create multiple sessions
const login1 = await service.login(
"test@example.com",
"password123",
"cookie",
);
const login2 = await service.login(
"test@example.com",
"password123",
"bearer",
);
assert.equal(login1.success, true);
assert.equal(login2.success, true);
if (!login1.success || !login2.success) return;
// Both should be valid
const before1 = await service.validateToken(login1.token);
const before2 = await service.validateToken(login2.token);
assert.equal(before1.authenticated, true);
assert.equal(before2.authenticated, true);
// Logout all
const user = await store.getUserByEmail("test@example.com");
const count = await service.logoutAllSessions(user!.id);
assert.equal(count, 2);
// Both should be invalid
const after1 = await service.validateToken(login1.token);
const after2 = await service.validateToken(login2.token);
assert.equal(after1.authenticated, false);
assert.equal(after2.authenticated, false);
});
});
describe("verifyEmail", () => {
it("activates user with valid token", async () => {
const regResult = await service.register(
"test@example.com",
"password123",
);
assert.equal(regResult.success, true);
if (!regResult.success) return;
const result = await service.verifyEmail(
regResult.verificationToken,
);
assert.equal(result.success, true);
// User should now be active and can login
const loginResult = await service.login(
"test@example.com",
"password123",
"cookie",
);
assert.equal(loginResult.success, true);
});
it("fails with invalid token", async () => {
const result = await service.verifyEmail("invalid-token");
assert.equal(result.success, false);
if (!result.success) {
assert.equal(
result.error,
"Invalid or expired verification token",
);
}
});
it("fails when token already used", async () => {
const regResult = await service.register(
"test@example.com",
"password123",
);
assert.equal(regResult.success, true);
if (!regResult.success) return;
// First verification succeeds
const result1 = await service.verifyEmail(
regResult.verificationToken,
);
assert.equal(result1.success, true);
// Second verification fails (token deleted)
const result2 = await service.verifyEmail(
regResult.verificationToken,
);
assert.equal(result2.success, false);
});
});
describe("createPasswordResetToken", () => {
it("returns token for existing user", async () => {
const regResult = await service.register(
"test@example.com",
"password123",
);
assert.equal(regResult.success, true);
const result =
await service.createPasswordResetToken("test@example.com");
assert.notEqual(result, null);
assert.ok(result!.token.length > 0);
});
it("returns null for unknown email", async () => {
const result = await service.createPasswordResetToken(
"unknown@example.com",
);
assert.equal(result, null);
});
});
describe("resetPassword", () => {
it("changes password with valid token", async () => {
const regResult = await service.register(
"test@example.com",
"oldpassword",
);
if (regResult.success) {
await service.verifyEmail(regResult.verificationToken);
}
const resetToken =
await service.createPasswordResetToken("test@example.com");
assert.notEqual(resetToken, null);
const result = await service.resetPassword(
resetToken!.token,
"newpassword",
);
assert.equal(result.success, true);
// Old password should no longer work
const loginOld = await service.login(
"test@example.com",
"oldpassword",
"cookie",
);
assert.equal(loginOld.success, false);
// New password should work
const loginNew = await service.login(
"test@example.com",
"newpassword",
"cookie",
);
assert.equal(loginNew.success, true);
});
it("fails with invalid token", async () => {
const result = await service.resetPassword(
"invalid-token",
"newpassword",
);
assert.equal(result.success, false);
if (!result.success) {
assert.equal(result.error, "Invalid or expired reset token");
}
});
it("invalidates all existing sessions", async () => {
const regResult = await service.register(
"test@example.com",
"password123",
);
if (regResult.success) {
await service.verifyEmail(regResult.verificationToken);
}
// Create a session
const loginResult = await service.login(
"test@example.com",
"password123",
"cookie",
);
assert.equal(loginResult.success, true);
if (!loginResult.success) return;
const sessionToken = loginResult.token;
// Reset password
const resetToken =
await service.createPasswordResetToken("test@example.com");
await service.resetPassword(resetToken!.token, "newpassword");
// Old session should be invalid
const validateResult = await service.validateToken(sessionToken);
assert.equal(validateResult.authenticated, false);
});
});
});

View File

@@ -0,0 +1,262 @@
// service.ts
//
// Core authentication service providing login, logout, registration,
// password reset, and email verification.
import type { Request as ExpressRequest } from "express";
import {
type AnonymousUser,
anonymousUser,
type User,
type UserId,
} from "../user";
import { hashPassword, verifyPassword } from "./password";
import type { AuthStore } from "./store";
import {
hashToken,
parseAuthorizationHeader,
SESSION_COOKIE_NAME,
} from "./token";
import { type SessionData, type TokenId, tokenLifetimes } from "./types";
type LoginResult =
| { success: true; token: string; user: User }
| { success: false; error: string };
type RegisterResult =
| { success: true; user: User; verificationToken: string }
| { 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: AnonymousUser; session: null };
export class AuthService {
constructor(private store: AuthStore) {}
// === Login ===
async login(
email: string,
password: string,
authMethod: "cookie" | "bearer",
metadata?: { userAgent?: string; ipAddress?: string },
): Promise<LoginResult> {
const user = await this.store.getUserByEmail(email);
if (!user) {
return { success: false, error: "Invalid credentials" };
}
if (!user.isActive()) {
return { success: false, error: "Account is not active" };
}
const passwordHash = await this.store.getUserPasswordHash(user.id);
if (!passwordHash) {
return { success: false, error: "Invalid credentials" };
}
const valid = await verifyPassword(password, passwordHash);
if (!valid) {
return { success: false, error: "Invalid credentials" };
}
const { token } = await this.store.createSession({
userId: user.id,
tokenType: "session",
authMethod,
expiresAt: new Date(Date.now() + tokenLifetimes.session),
userAgent: metadata?.userAgent,
ipAddress: metadata?.ipAddress,
});
return { success: true, token, user };
}
// === Session Validation ===
async validateRequest(request: ExpressRequest): Promise<AuthResult> {
// Try cookie first (for web requests)
let token = this.extractCookieToken(request);
// Fall back to Authorization header (for API requests)
if (!token) {
token = parseAuthorizationHeader(request.get("Authorization"));
}
if (!token) {
return { authenticated: false, user: anonymousUser, session: null };
}
return this.validateToken(token);
}
async validateToken(token: string): Promise<AuthResult> {
const tokenId = hashToken(token) as TokenId;
const session = await this.store.getSession(tokenId);
if (!session) {
return { authenticated: false, user: anonymousUser, session: null };
}
if (session.tokenType !== "session") {
return { authenticated: false, user: anonymousUser, session: null };
}
const user = await this.store.getUserById(session.userId as UserId);
if (!user || !user.isActive()) {
return { authenticated: false, user: anonymousUser, session: null };
}
// Update last used (fire and forget)
this.store.updateLastUsed(tokenId).catch(() => {});
return { authenticated: true, user, session };
}
private extractCookieToken(request: ExpressRequest): string | null {
const cookies = request.get("Cookie");
if (!cookies) {
return null;
}
for (const cookie of cookies.split(";")) {
const [name, ...valueParts] = cookie.trim().split("=");
if (name === SESSION_COOKIE_NAME) {
return valueParts.join("="); // Handle = in token value
}
}
return null;
}
// === Logout ===
async logout(token: string): Promise<void> {
const tokenId = hashToken(token) as TokenId;
await this.store.deleteSession(tokenId);
}
async logoutAllSessions(userId: UserId): Promise<number> {
return this.store.deleteUserSessions(userId);
}
// === Registration ===
async register(
email: string,
password: string,
displayName?: string,
): Promise<RegisterResult> {
const existing = await this.store.getUserByEmail(email);
if (existing) {
return { success: false, error: "Email already registered" };
}
const passwordHash = await hashPassword(password);
const user = await this.store.createUser({
email,
passwordHash,
displayName,
});
// Create email verification token
const { token: verificationToken } = await this.store.createSession({
userId: user.id,
tokenType: "email_verify",
authMethod: "bearer",
expiresAt: new Date(Date.now() + tokenLifetimes.email_verify),
});
return { success: true, user, verificationToken };
}
// === Email Verification ===
async verifyEmail(token: string): Promise<SimpleResult> {
const tokenId = hashToken(token) as TokenId;
const session = await this.store.getSession(tokenId);
if (!session || session.tokenType !== "email_verify") {
return {
success: false,
error: "Invalid or expired verification token",
};
}
if (session.isUsed) {
return { success: false, error: "Token already used" };
}
await this.store.updateUserEmailVerified(session.userId as UserId);
await this.store.deleteSession(tokenId);
return { success: true };
}
// === Password Reset ===
async createPasswordResetToken(
email: string,
): Promise<{ token: string } | null> {
const user = await this.store.getUserByEmail(email);
if (!user) {
// Don't reveal whether email exists
return null;
}
const { token } = await this.store.createSession({
userId: user.id,
tokenType: "password_reset",
authMethod: "bearer",
expiresAt: new Date(Date.now() + tokenLifetimes.password_reset),
});
return { token };
}
async resetPassword(
token: string,
newPassword: string,
): Promise<SimpleResult> {
const tokenId = hashToken(token) as TokenId;
const session = await this.store.getSession(tokenId);
if (!session || session.tokenType !== "password_reset") {
return { success: false, error: "Invalid or expired reset token" };
}
if (session.isUsed) {
return { success: false, error: "Token already used" };
}
const passwordHash = await hashPassword(newPassword);
await this.store.setUserPassword(
session.userId as UserId,
passwordHash,
);
// Invalidate all existing sessions (security: password changed)
await this.store.deleteUserSessions(session.userId as UserId);
// Delete the reset token
await this.store.deleteSession(tokenId);
return { success: true };
}
// === Token Extraction Helper (for routes) ===
extractToken(request: ExpressRequest): string | null {
// Try Authorization header first
const token = parseAuthorizationHeader(request.get("Authorization"));
if (token) {
return token;
}
// Try cookie
return this.extractCookieToken(request);
}
}

View File

@@ -0,0 +1,321 @@
// Tests for auth/store.ts (InMemoryAuthStore)
// Pure unit tests - no database needed
import assert from "node:assert/strict";
import { after, before, beforeEach, describe, it } from "node:test";
import type { UserId } from "../user";
import { InMemoryAuthStore } from "./store";
import { hashToken } from "./token";
import type { TokenId } from "./types";
describe("InMemoryAuthStore", () => {
let store: InMemoryAuthStore;
beforeEach(() => {
store = new InMemoryAuthStore();
});
describe("createUser", () => {
it("creates a user with pending status", async () => {
const user = await store.createUser({
email: "test@example.com",
passwordHash: "hash123",
displayName: "Test User",
});
assert.equal(user.email, "test@example.com");
assert.equal(user.displayName, "Test User");
assert.equal(user.status, "pending");
});
it("creates a user without displayName", async () => {
const user = await store.createUser({
email: "test@example.com",
passwordHash: "hash123",
});
assert.equal(user.email, "test@example.com");
assert.equal(user.displayName, undefined);
});
it("generates a unique id", async () => {
const user1 = await store.createUser({
email: "test1@example.com",
passwordHash: "hash123",
});
const user2 = await store.createUser({
email: "test2@example.com",
passwordHash: "hash456",
});
assert.notEqual(user1.id, user2.id);
});
});
describe("getUserByEmail", () => {
it("returns user when found", async () => {
await store.createUser({
email: "test@example.com",
passwordHash: "hash123",
});
const user = await store.getUserByEmail("test@example.com");
assert.notEqual(user, null);
assert.equal(user!.email, "test@example.com");
});
it("is case-insensitive", async () => {
await store.createUser({
email: "Test@Example.COM",
passwordHash: "hash123",
});
const user = await store.getUserByEmail("test@example.com");
assert.notEqual(user, null);
});
it("returns null when not found", async () => {
const user = await store.getUserByEmail("notfound@example.com");
assert.equal(user, null);
});
});
describe("getUserById", () => {
it("returns user when found", async () => {
const created = await store.createUser({
email: "test@example.com",
passwordHash: "hash123",
});
const user = await store.getUserById(created.id);
assert.notEqual(user, null);
assert.equal(user!.id, created.id);
});
it("returns null when not found", async () => {
const user = await store.getUserById("nonexistent" as UserId);
assert.equal(user, null);
});
});
describe("getUserPasswordHash", () => {
it("returns hash when found", async () => {
const user = await store.createUser({
email: "test@example.com",
passwordHash: "hash123",
});
const hash = await store.getUserPasswordHash(user.id);
assert.equal(hash, "hash123");
});
it("returns null when not found", async () => {
const hash = await store.getUserPasswordHash(
"nonexistent" as UserId,
);
assert.equal(hash, null);
});
});
describe("setUserPassword", () => {
it("updates password hash", async () => {
const user = await store.createUser({
email: "test@example.com",
passwordHash: "oldhash",
});
await store.setUserPassword(user.id, "newhash");
const hash = await store.getUserPasswordHash(user.id);
assert.equal(hash, "newhash");
});
});
describe("updateUserEmailVerified", () => {
it("sets user status to active", async () => {
const created = await store.createUser({
email: "test@example.com",
passwordHash: "hash123",
});
assert.equal(created.status, "pending");
await store.updateUserEmailVerified(created.id);
const user = await store.getUserById(created.id);
assert.equal(user!.status, "active");
});
});
describe("createSession", () => {
it("creates a session with token", async () => {
const user = await store.createUser({
email: "test@example.com",
passwordHash: "hash123",
});
const { token, session } = await store.createSession({
userId: user.id,
tokenType: "session",
authMethod: "cookie",
expiresAt: new Date(Date.now() + 3600000),
});
assert.ok(token.length > 0);
assert.equal(session.userId, user.id);
assert.equal(session.tokenType, "session");
assert.equal(session.authMethod, "cookie");
});
it("stores metadata", async () => {
const user = await store.createUser({
email: "test@example.com",
passwordHash: "hash123",
});
const { session } = await store.createSession({
userId: user.id,
tokenType: "session",
authMethod: "cookie",
expiresAt: new Date(Date.now() + 3600000),
userAgent: "Mozilla/5.0",
ipAddress: "127.0.0.1",
});
assert.equal(session.userAgent, "Mozilla/5.0");
assert.equal(session.ipAddress, "127.0.0.1");
});
});
describe("getSession", () => {
it("returns session when found and not expired", async () => {
const user = await store.createUser({
email: "test@example.com",
passwordHash: "hash123",
});
const { token } = await store.createSession({
userId: user.id,
tokenType: "session",
authMethod: "cookie",
expiresAt: new Date(Date.now() + 3600000), // 1 hour from now
});
const tokenId = hashToken(token) as TokenId;
const session = await store.getSession(tokenId);
assert.notEqual(session, null);
assert.equal(session!.userId, user.id);
});
it("returns null for expired session", async () => {
const user = await store.createUser({
email: "test@example.com",
passwordHash: "hash123",
});
const { token } = await store.createSession({
userId: user.id,
tokenType: "session",
authMethod: "cookie",
expiresAt: new Date(Date.now() - 1000), // Expired 1 second ago
});
const tokenId = hashToken(token) as TokenId;
const session = await store.getSession(tokenId);
assert.equal(session, null);
});
it("returns null for nonexistent session", async () => {
const session = await store.getSession("nonexistent" as TokenId);
assert.equal(session, null);
});
});
describe("deleteSession", () => {
it("removes the session", async () => {
const user = await store.createUser({
email: "test@example.com",
passwordHash: "hash123",
});
const { token } = await store.createSession({
userId: user.id,
tokenType: "session",
authMethod: "cookie",
expiresAt: new Date(Date.now() + 3600000),
});
const tokenId = hashToken(token) as TokenId;
await store.deleteSession(tokenId);
const session = await store.getSession(tokenId);
assert.equal(session, null);
});
});
describe("deleteUserSessions", () => {
it("removes all sessions for user", async () => {
const user = await store.createUser({
email: "test@example.com",
passwordHash: "hash123",
});
const { token: token1 } = await store.createSession({
userId: user.id,
tokenType: "session",
authMethod: "cookie",
expiresAt: new Date(Date.now() + 3600000),
});
const { token: token2 } = await store.createSession({
userId: user.id,
tokenType: "session",
authMethod: "bearer",
expiresAt: new Date(Date.now() + 3600000),
});
const count = await store.deleteUserSessions(user.id);
assert.equal(count, 2);
const session1 = await store.getSession(
hashToken(token1) as TokenId,
);
const session2 = await store.getSession(
hashToken(token2) as TokenId,
);
assert.equal(session1, null);
assert.equal(session2, null);
});
it("returns 0 when user has no sessions", async () => {
const count = await store.deleteUserSessions(
"nonexistent" as UserId,
);
assert.equal(count, 0);
});
});
describe("updateLastUsed", () => {
it("updates lastUsedAt timestamp", async () => {
const user = await store.createUser({
email: "test@example.com",
passwordHash: "hash123",
});
const { token } = await store.createSession({
userId: user.id,
tokenType: "session",
authMethod: "cookie",
expiresAt: new Date(Date.now() + 3600000),
});
const tokenId = hashToken(token) as TokenId;
const beforeUpdate = await store.getSession(tokenId);
assert.equal(beforeUpdate!.lastUsedAt, undefined);
await store.updateLastUsed(tokenId);
const afterUpdate = await store.getSession(tokenId);
assert.ok(afterUpdate!.lastUsedAt instanceof Date);
});
});
});

View File

@@ -0,0 +1,164 @@
// store.ts
//
// Authentication storage interface and in-memory implementation.
// The interface allows easy migration to PostgreSQL later.
import { AuthenticatedUser, type User, type UserId } from "../user";
import { generateToken, hashToken } from "./token";
import type { AuthMethod, SessionData, TokenId, TokenType } from "./types";
// Data for creating a new session (tokenId generated internally)
export type CreateSessionData = {
userId: string;
tokenType: TokenType;
authMethod: AuthMethod;
expiresAt: Date;
userAgent?: string;
ipAddress?: string;
};
// Data for creating a new user
export type CreateUserData = {
email: string;
passwordHash: string;
displayName?: string;
};
// Abstract interface for auth storage - implement for PostgreSQL later
export interface AuthStore {
// Session operations
createSession(
data: CreateSessionData,
): Promise<{ token: string; session: SessionData }>;
getSession(tokenId: TokenId): Promise<SessionData | null>;
updateLastUsed(tokenId: TokenId): Promise<void>;
deleteSession(tokenId: TokenId): Promise<void>;
deleteUserSessions(userId: UserId): Promise<number>;
// User operations
getUserByEmail(email: string): Promise<User | null>;
getUserById(userId: UserId): Promise<User | null>;
createUser(data: CreateUserData): Promise<User>;
getUserPasswordHash(userId: UserId): Promise<string | null>;
setUserPassword(userId: UserId, passwordHash: string): Promise<void>;
updateUserEmailVerified(userId: UserId): Promise<void>;
}
// In-memory implementation for development
export class InMemoryAuthStore implements AuthStore {
private sessions: Map<string, SessionData> = new Map();
private users: Map<string, User> = new Map();
private usersByEmail: Map<string, string> = new Map();
private passwordHashes: Map<string, string> = new Map();
private emailVerified: Map<string, boolean> = new Map();
async createSession(
data: CreateSessionData,
): Promise<{ token: string; session: SessionData }> {
const token = generateToken();
const tokenId = hashToken(token);
const session: SessionData = {
tokenId,
userId: data.userId,
tokenType: data.tokenType,
authMethod: data.authMethod,
createdAt: new Date(),
expiresAt: data.expiresAt,
userAgent: data.userAgent,
ipAddress: data.ipAddress,
};
this.sessions.set(tokenId, session);
return { token, session };
}
async getSession(tokenId: TokenId): Promise<SessionData | null> {
const session = this.sessions.get(tokenId);
if (!session) {
return null;
}
// Check expiration
if (new Date() > session.expiresAt) {
this.sessions.delete(tokenId);
return null;
}
return session;
}
async updateLastUsed(tokenId: TokenId): Promise<void> {
const session = this.sessions.get(tokenId);
if (session) {
session.lastUsedAt = new Date();
}
}
async deleteSession(tokenId: TokenId): Promise<void> {
this.sessions.delete(tokenId);
}
async deleteUserSessions(userId: UserId): Promise<number> {
let count = 0;
for (const [tokenId, session] of this.sessions) {
if (session.userId === userId) {
this.sessions.delete(tokenId);
count++;
}
}
return count;
}
async getUserByEmail(email: string): Promise<User | null> {
const userId = this.usersByEmail.get(email.toLowerCase());
if (!userId) {
return null;
}
return this.users.get(userId) ?? null;
}
async getUserById(userId: UserId): Promise<User | null> {
return this.users.get(userId) ?? null;
}
async createUser(data: CreateUserData): Promise<User> {
const user = AuthenticatedUser.create(data.email, {
displayName: data.displayName,
status: "pending", // Pending until email verified
});
this.users.set(user.id, user);
this.usersByEmail.set(data.email.toLowerCase(), user.id);
this.passwordHashes.set(user.id, data.passwordHash);
this.emailVerified.set(user.id, false);
return user;
}
async getUserPasswordHash(userId: UserId): Promise<string | null> {
return this.passwordHashes.get(userId) ?? null;
}
async setUserPassword(userId: UserId, passwordHash: string): Promise<void> {
this.passwordHashes.set(userId, passwordHash);
}
async updateUserEmailVerified(userId: UserId): Promise<void> {
this.emailVerified.set(userId, true);
// Update user status to active
const user = this.users.get(userId);
if (user) {
// Create new user with active status
const updatedUser = AuthenticatedUser.create(user.email, {
id: user.id,
displayName: user.displayName,
status: "active",
roles: [...user.roles],
permissions: [...user.permissions],
});
this.users.set(userId, updatedUser);
}
}
}

View File

@@ -0,0 +1,94 @@
// Tests for auth/token.ts
// Pure unit tests - no database needed
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
generateToken,
hashToken,
parseAuthorizationHeader,
SESSION_COOKIE_NAME,
} from "./token";
describe("token", () => {
describe("generateToken", () => {
it("generates a non-empty string", () => {
const token = generateToken();
assert.equal(typeof token, "string");
assert.ok(token.length > 0);
});
it("generates unique tokens", () => {
const tokens = new Set<string>();
for (let i = 0; i < 100; i++) {
tokens.add(generateToken());
}
assert.equal(tokens.size, 100);
});
it("generates base64url encoded tokens", () => {
const token = generateToken();
// base64url uses A-Z, a-z, 0-9, -, _
assert.match(token, /^[A-Za-z0-9_-]+$/);
});
});
describe("hashToken", () => {
it("returns a hex string", () => {
const hash = hashToken("test-token");
assert.match(hash, /^[a-f0-9]+$/);
});
it("returns consistent hash for same input", () => {
const hash1 = hashToken("test-token");
const hash2 = hashToken("test-token");
assert.equal(hash1, hash2);
});
it("returns different hash for different input", () => {
const hash1 = hashToken("token-1");
const hash2 = hashToken("token-2");
assert.notEqual(hash1, hash2);
});
it("returns 64 character hash (SHA-256)", () => {
const hash = hashToken("test-token");
assert.equal(hash.length, 64);
});
});
describe("parseAuthorizationHeader", () => {
it("returns null for undefined header", () => {
assert.equal(parseAuthorizationHeader(undefined), null);
});
it("returns null for empty string", () => {
assert.equal(parseAuthorizationHeader(""), null);
});
it("returns null for non-bearer auth", () => {
assert.equal(parseAuthorizationHeader("Basic abc123"), null);
});
it("returns null for malformed header", () => {
assert.equal(parseAuthorizationHeader("Bearer"), null);
assert.equal(parseAuthorizationHeader("Bearer token extra"), null);
});
it("extracts token from valid bearer header", () => {
assert.equal(parseAuthorizationHeader("Bearer abc123"), "abc123");
});
it("is case-insensitive for Bearer keyword", () => {
assert.equal(parseAuthorizationHeader("bearer abc123"), "abc123");
assert.equal(parseAuthorizationHeader("BEARER abc123"), "abc123");
});
});
describe("SESSION_COOKIE_NAME", () => {
it("is defined", () => {
assert.equal(typeof SESSION_COOKIE_NAME, "string");
assert.ok(SESSION_COOKIE_NAME.length > 0);
});
});
});

View File

@@ -0,0 +1,42 @@
// token.ts
//
// Token generation and hashing utilities for authentication.
// Raw tokens are never stored - only their SHA-256 hashes.
import { createHash, randomBytes } from "node:crypto";
const TOKEN_BYTES = 32; // 256 bits of entropy
// Generate a cryptographically secure random token
function generateToken(): string {
return randomBytes(TOKEN_BYTES).toString("base64url");
}
// Hash token for storage (never store raw tokens)
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
// Parse token from Authorization header
function parseAuthorizationHeader(header: string | undefined): string | null {
if (!header) {
return null;
}
const parts = header.split(" ");
if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") {
return null;
}
return parts[1];
}
// Cookie name for web sessions
const SESSION_COOKIE_NAME = "diachron_session";
export {
generateToken,
hashToken,
parseAuthorizationHeader,
SESSION_COOKIE_NAME,
};

View File

@@ -0,0 +1,253 @@
// Tests for auth/types.ts
// Pure unit tests - no database needed
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { z } from "zod";
import { AuthenticatedUser, anonymousUser } from "../user";
import {
authMethodParser,
forgotPasswordInputParser,
loginInputParser,
registerInputParser,
resetPasswordInputParser,
Session,
sessionDataParser,
tokenLifetimes,
tokenTypeParser,
} from "./types";
describe("auth/types", () => {
describe("tokenTypeParser", () => {
it("accepts valid token types", () => {
assert.equal(tokenTypeParser.parse("session"), "session");
assert.equal(
tokenTypeParser.parse("password_reset"),
"password_reset",
);
assert.equal(tokenTypeParser.parse("email_verify"), "email_verify");
});
it("rejects invalid token types", () => {
assert.throws(() => tokenTypeParser.parse("invalid"));
});
});
describe("authMethodParser", () => {
it("accepts valid auth methods", () => {
assert.equal(authMethodParser.parse("cookie"), "cookie");
assert.equal(authMethodParser.parse("bearer"), "bearer");
});
it("rejects invalid auth methods", () => {
assert.throws(() => authMethodParser.parse("basic"));
});
});
describe("sessionDataParser", () => {
it("accepts valid session data", () => {
const data = {
tokenId: "abc123",
userId: "user-1",
tokenType: "session",
authMethod: "cookie",
createdAt: new Date(),
expiresAt: new Date(),
};
const result = sessionDataParser.parse(data);
assert.equal(result.tokenId, "abc123");
assert.equal(result.userId, "user-1");
});
it("coerces date strings to dates", () => {
const data = {
tokenId: "abc123",
userId: "user-1",
tokenType: "session",
authMethod: "cookie",
createdAt: "2025-01-01T00:00:00Z",
expiresAt: "2025-01-02T00:00:00Z",
};
const result = sessionDataParser.parse(data);
assert.ok(result.createdAt instanceof Date);
assert.ok(result.expiresAt instanceof Date);
});
it("accepts optional fields", () => {
const data = {
tokenId: "abc123",
userId: "user-1",
tokenType: "session",
authMethod: "cookie",
createdAt: new Date(),
expiresAt: new Date(),
lastUsedAt: new Date(),
userAgent: "Mozilla/5.0",
ipAddress: "127.0.0.1",
isUsed: true,
};
const result = sessionDataParser.parse(data);
assert.equal(result.userAgent, "Mozilla/5.0");
assert.equal(result.ipAddress, "127.0.0.1");
assert.equal(result.isUsed, true);
});
});
describe("loginInputParser", () => {
it("accepts valid login input", () => {
const result = loginInputParser.parse({
email: "test@example.com",
password: "secret",
});
assert.equal(result.email, "test@example.com");
assert.equal(result.password, "secret");
});
it("rejects invalid email", () => {
assert.throws(() =>
loginInputParser.parse({
email: "not-an-email",
password: "secret",
}),
);
});
it("rejects empty password", () => {
assert.throws(() =>
loginInputParser.parse({
email: "test@example.com",
password: "",
}),
);
});
});
describe("registerInputParser", () => {
it("accepts valid registration input", () => {
const result = registerInputParser.parse({
email: "test@example.com",
password: "password123",
displayName: "Test User",
});
assert.equal(result.email, "test@example.com");
assert.equal(result.password, "password123");
assert.equal(result.displayName, "Test User");
});
it("accepts registration without displayName", () => {
const result = registerInputParser.parse({
email: "test@example.com",
password: "password123",
});
assert.equal(result.displayName, undefined);
});
it("rejects password shorter than 8 characters", () => {
assert.throws(() =>
registerInputParser.parse({
email: "test@example.com",
password: "short",
}),
);
});
});
describe("forgotPasswordInputParser", () => {
it("accepts valid email", () => {
const result = forgotPasswordInputParser.parse({
email: "test@example.com",
});
assert.equal(result.email, "test@example.com");
});
it("rejects invalid email", () => {
assert.throws(() =>
forgotPasswordInputParser.parse({
email: "invalid",
}),
);
});
});
describe("resetPasswordInputParser", () => {
it("accepts valid reset input", () => {
const result = resetPasswordInputParser.parse({
token: "abc123",
password: "newpassword",
});
assert.equal(result.token, "abc123");
assert.equal(result.password, "newpassword");
});
it("rejects empty token", () => {
assert.throws(() =>
resetPasswordInputParser.parse({
token: "",
password: "newpassword",
}),
);
});
it("rejects password shorter than 8 characters", () => {
assert.throws(() =>
resetPasswordInputParser.parse({
token: "abc123",
password: "short",
}),
);
});
});
describe("tokenLifetimes", () => {
it("defines session lifetime", () => {
assert.ok(tokenLifetimes.session > 0);
// 30 days in ms
assert.equal(tokenLifetimes.session, 30 * 24 * 60 * 60 * 1000);
});
it("defines password_reset lifetime", () => {
assert.ok(tokenLifetimes.password_reset > 0);
// 1 hour in ms
assert.equal(tokenLifetimes.password_reset, 1 * 60 * 60 * 1000);
});
it("defines email_verify lifetime", () => {
assert.ok(tokenLifetimes.email_verify > 0);
// 24 hours in ms
assert.equal(tokenLifetimes.email_verify, 24 * 60 * 60 * 1000);
});
});
describe("Session", () => {
it("wraps authenticated session", () => {
const user = AuthenticatedUser.create("test@example.com", {
id: "user-1",
});
const sessionData = {
tokenId: "token-1",
userId: "user-1",
tokenType: "session" as const,
authMethod: "cookie" as const,
createdAt: new Date(),
expiresAt: new Date(),
};
const session = new Session(sessionData, user);
assert.equal(session.isAuthenticated(), true);
assert.equal(session.getUser(), user);
assert.equal(session.getData(), sessionData);
assert.equal(session.tokenId, "token-1");
assert.equal(session.userId, "user-1");
});
it("wraps anonymous session", () => {
const session = new Session(null, anonymousUser);
assert.equal(session.isAuthenticated(), false);
assert.equal(session.getUser(), anonymousUser);
assert.equal(session.getData(), null);
assert.equal(session.tokenId, undefined);
assert.equal(session.userId, undefined);
});
});
});

View File

@@ -0,0 +1,96 @@
// types.ts
//
// Authentication types and Zod schemas.
import { z } from "zod";
// Branded type for token IDs (the hash, not the raw token)
export type TokenId = string & { readonly __brand: "TokenId" };
// Token types for different purposes
export const tokenTypeParser = z.enum([
"session",
"password_reset",
"email_verify",
]);
export type TokenType = z.infer<typeof tokenTypeParser>;
// Authentication method - how the token was delivered
export const authMethodParser = z.enum(["cookie", "bearer"]);
export type AuthMethod = z.infer<typeof authMethodParser>;
// Session data schema - what gets stored
export const sessionDataParser = z.object({
tokenId: z.string().min(1),
userId: z.string().min(1),
tokenType: tokenTypeParser,
authMethod: authMethodParser,
createdAt: z.coerce.date(),
expiresAt: z.coerce.date(),
lastUsedAt: z.coerce.date().optional(),
userAgent: z.string().optional(),
ipAddress: z.string().optional(),
isUsed: z.boolean().optional(), // For one-time tokens
});
export type SessionData = z.infer<typeof sessionDataParser>;
// Input validation schemas for auth endpoints
export const loginInputParser = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export const registerInputParser = z.object({
email: z.string().email(),
password: z.string().min(8),
displayName: z.string().optional(),
});
export const forgotPasswordInputParser = z.object({
email: z.string().email(),
});
export const resetPasswordInputParser = z.object({
token: z.string().min(1),
password: z.string().min(8),
});
// Token lifetimes in milliseconds
export const tokenLifetimes: Record<TokenType, number> = {
session: 30 * 24 * 60 * 60 * 1000, // 30 days
password_reset: 1 * 60 * 60 * 1000, // 1 hour
email_verify: 24 * 60 * 60 * 1000, // 24 hours
};
// Import here to avoid circular dependency at module load time
import type { User } 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: User,
) {}
getUser(): User {
return this.user;
}
getData(): SessionData | null {
return this.data;
}
isAuthenticated(): boolean {
return !this.user.isAnonymous();
}
get tokenId(): string | undefined {
return this.data?.tokenId;
}
get userId(): string | undefined {
return this.data?.userId;
}
}

View File

@@ -0,0 +1,24 @@
// Tests for basic/login.ts
// These tests verify the route structure and export
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { loginRoute } from "./login";
describe("basic/login", () => {
describe("loginRoute", () => {
it("has correct path", () => {
assert.equal(loginRoute.path, "/login");
});
it("handles GET and POST methods", () => {
assert.ok(loginRoute.methods.includes("GET"));
assert.ok(loginRoute.methods.includes("POST"));
assert.equal(loginRoute.methods.length, 2);
});
it("has a handler function", () => {
assert.equal(typeof loginRoute.handler, "function");
});
});
});

View File

@@ -0,0 +1,62 @@
import { SESSION_COOKIE_NAME } from "../auth/token";
import { tokenLifetimes } from "../auth/types";
import { request } from "../request";
import { html, redirect, render } from "../request/util";
import type { Call, Result, Route } from "../types";
const loginHandler = async (call: Call): Promise<Result> => {
if (call.method === "GET") {
const c = await render("basic/login", {});
return html(c);
}
// POST - handle login
const { email, password } = call.request.body;
if (!email || !password) {
const c = await render("basic/login", {
error: "Email and password are required",
email,
});
return html(c);
}
const result = await request.auth.login(email, password, "cookie", {
userAgent: call.request.get("User-Agent"),
ipAddress: call.request.ip,
});
if (!result.success) {
const c = await render("basic/login", {
error: result.error,
email,
});
return html(c);
}
// Success - set cookie and redirect to home
const redirectResult = redirect("/");
redirectResult.cookies = [
{
name: SESSION_COOKIE_NAME,
value: result.token,
options: {
httpOnly: true,
secure: false, // Set to true in production with HTTPS
sameSite: "lax",
maxAge: tokenLifetimes.session,
path: "/",
},
},
];
return redirectResult;
};
const loginRoute: Route = {
path: "/login",
methods: ["GET", "POST"],
handler: loginHandler,
};
export { loginRoute };

View File

@@ -0,0 +1,24 @@
// Tests for basic/logout.ts
// These tests verify the route structure and export
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { logoutRoute } from "./logout";
describe("basic/logout", () => {
describe("logoutRoute", () => {
it("has correct path", () => {
assert.equal(logoutRoute.path, "/logout");
});
it("handles GET and POST methods", () => {
assert.ok(logoutRoute.methods.includes("GET"));
assert.ok(logoutRoute.methods.includes("POST"));
assert.equal(logoutRoute.methods.length, 2);
});
it("has a handler function", () => {
assert.equal(typeof logoutRoute.handler, "function");
});
});
});

View File

@@ -0,0 +1,38 @@
import { SESSION_COOKIE_NAME } from "../auth/token";
import { request } from "../request";
import { redirect } from "../request/util";
import type { Call, Result, Route } from "../types";
const logoutHandler = async (call: Call): Promise<Result> => {
// Extract token from cookie and invalidate the session
const token = request.auth.extractToken(call.request);
if (token) {
await request.auth.logout(token);
}
// Clear the cookie and redirect to login
const redirectResult = redirect("/login");
redirectResult.cookies = [
{
name: SESSION_COOKIE_NAME,
value: "",
options: {
httpOnly: true,
secure: false,
sameSite: "lax",
maxAge: 0,
path: "/",
},
},
];
return redirectResult;
};
const logoutRoute: Route = {
path: "/logout",
methods: ["GET", "POST"],
handler: logoutHandler,
};
export { logoutRoute };

View File

@@ -0,0 +1,73 @@
// Tests for basic/routes.ts
// These tests verify the route structure and exports
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { routes } from "./routes";
describe("basic/routes", () => {
describe("routes object", () => {
it("exports routes as an object", () => {
assert.equal(typeof routes, "object");
});
it("contains hello route", () => {
assert.ok("hello" in routes);
assert.equal(routes.hello.path, "/hello");
assert.ok(routes.hello.methods.includes("GET"));
});
it("contains home route", () => {
assert.ok("home" in routes);
assert.equal(routes.home.path, "/");
assert.ok(routes.home.methods.includes("GET"));
});
it("contains login route", () => {
assert.ok("login" in routes);
assert.equal(routes.login.path, "/login");
});
it("contains logout route", () => {
assert.ok("logout" in routes);
assert.equal(routes.logout.path, "/logout");
});
it("all routes have handlers", () => {
for (const [name, route] of Object.entries(routes)) {
assert.equal(
typeof route.handler,
"function",
`Route ${name} should have a handler function`,
);
}
});
it("all routes have methods array", () => {
for (const [name, route] of Object.entries(routes)) {
assert.ok(
Array.isArray(route.methods),
`Route ${name} should have methods array`,
);
assert.ok(
route.methods.length > 0,
`Route ${name} should have at least one method`,
);
}
});
it("all routes have path string", () => {
for (const [name, route] of Object.entries(routes)) {
assert.equal(
typeof route.path,
"string",
`Route ${name} should have a path string`,
);
assert.ok(
route.path.startsWith("/"),
`Route ${name} path should start with /`,
);
}
});
});
});

View File

@@ -0,0 +1,51 @@
import { DateTime } from "ts-luxon";
import { get, User } from "../hydrators/user";
import { request } from "../request";
import { html, render } from "../request/util";
import type { Call, Result, Route } from "../types";
import { loginRoute } from "./login";
import { logoutRoute } from "./logout";
const routes: Record<string, Route> = {
hello: {
path: "/hello",
methods: ["GET"],
handler: async (_call: Call): Promise<Result> => {
const now = DateTime.now();
const c = await render("basic/hello", { now });
return html(c);
},
},
home: {
path: "/",
methods: ["GET"],
handler: async (_call: Call): Promise<Result> => {
const _auth = request.auth;
const me = request.session.getUser();
const id = me.id;
console.log(`*** id: ${id}`);
const u = await get(id);
const email = u?.email || "anonymous@example.com";
const name = u?.display_name || "anonymous";
const showLogin = me.isAnonymous();
const showLogout = !me.isAnonymous();
const c = await render("basic/home", {
name,
email,
showLogin,
showLogout,
});
return html(c);
},
},
login: loginRoute,
logout: logoutRoute,
};
export { routes };

55
backend/diachron/cli.ts Normal file
View File

@@ -0,0 +1,55 @@
import { parseArgs } from "node:util";
const { values } = parseArgs({
options: {
listen: {
type: "string",
short: "l",
},
"log-address": {
type: "string",
default: "8085",
},
},
strict: true,
allowPositionals: false,
});
function parseListenAddress(listen: string | undefined): {
host: string;
port: number;
} {
const defaultHost = "127.0.0.1";
const defaultPort = 3500;
if (!listen) {
return { host: defaultHost, port: defaultPort };
}
const lastColon = listen.lastIndexOf(":");
if (lastColon === -1) {
// Just a port number
const port = parseInt(listen, 10);
if (Number.isNaN(port)) {
throw new Error(`Invalid listen address: ${listen}`);
}
return { host: defaultHost, port };
}
const host = listen.slice(0, lastColon);
const port = parseInt(listen.slice(lastColon + 1), 10);
if (Number.isNaN(port)) {
throw new Error(`Invalid port in listen address: ${listen}`);
}
return { host, port };
}
const listenAddress = parseListenAddress(values.listen);
const logAddress = parseListenAddress(values["log-address"]);
export const cli = {
listen: listenAddress,
logAddress,
};

View File

@@ -0,0 +1,122 @@
// This file belongs to the framework. You are not expected to modify it.
export type ContentType = string;
// tx claude https://claude.ai/share/344fc7bd-5321-4763-af2f-b82275e9f865
const contentTypes = {
text: {
plain: "text/plain",
html: "text/html",
css: "text/css",
javascript: "text/javascript",
xml: "text/xml",
csv: "text/csv",
markdown: "text/markdown",
calendar: "text/calendar",
},
image: {
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
svgPlusXml: "image/svg+xml",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
tiff: "image/tiff",
avif: "image/avif",
},
audio: {
mpeg: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
webm: "audio/webm",
aac: "audio/aac",
midi: "audio/midi",
opus: "audio/opus",
flac: "audio/flac",
},
video: {
mp4: "video/mp4",
webm: "video/webm",
xMsvideo: "video/x-msvideo",
mpeg: "video/mpeg",
ogg: "video/ogg",
quicktime: "video/quicktime",
xMatroska: "video/x-matroska",
},
application: {
json: "application/json",
pdf: "application/pdf",
zip: "application/zip",
xWwwFormUrlencoded: "application/x-www-form-urlencoded",
octetStream: "application/octet-stream",
xml: "application/xml",
gzip: "application/gzip",
javascript: "application/javascript",
ld_json: "application/ld+json",
msword: "application/msword",
vndOpenxmlformatsOfficedocumentWordprocessingmlDocument:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
vndMsExcel: "application/vnd.ms-excel",
vndOpenxmlformatsOfficedocumentSpreadsheetmlSheet:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
vndMsPowerpoint: "application/vnd.ms-powerpoint",
vndOpenxmlformatsOfficedocumentPresentationmlPresentation:
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
sql: "application/sql",
graphql: "application/graphql",
wasm: "application/wasm",
xTar: "application/x-tar",
x7zCompressed: "application/x-7z-compressed",
xRarCompressed: "application/x-rar-compressed",
},
multipart: {
formData: "multipart/form-data",
byteranges: "multipart/byteranges",
},
font: {
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
otf: "font/otf",
},
};
export { contentTypes };
/*
possible additions for later
Looking at what's there, here are a few gaps that might be worth filling:
Streaming/Modern Web:
application/x-ndjson or application/jsonlines - newline-delimited JSON (popular for streaming APIs)
text/event-stream - Server-Sent Events
API/Data Exchange:
application/yaml or text/yaml - YAML files
application/protobuf - Protocol Buffers
application/msgpack - MessagePack
Archives you're missing:
application/x-bzip2 - bzip2 compression
Images:
image/heic - HEIC/HEIF (common on iOS)
Fonts:
application/vnd.ms-fontobject - EOT fonts (legacy but still seen)
Text:
text/rtf - Rich Text Format
The most impactful would probably be text/event-stream (if you do any SSE), application/x-ndjson (common in modern APIs), and maybe text/yaml. The rest are more situational.
But honestly, what you have covers 95% of common web development scenarios. You can definitely add as you go when you encounter specific needs!
*/

View File

@@ -0,0 +1,27 @@
// context.ts
//
// Request-scoped context using AsyncLocalStorage.
// Allows services to access request data (like the current user) without
// needing to pass Call through every function.
import { AsyncLocalStorage } from "node:async_hooks";
import { anonymousUser, type User } from "./user";
type RequestContext = {
user: User;
};
const asyncLocalStorage = new AsyncLocalStorage<RequestContext>();
// Run a function within a request context
function runWithContext<T>(context: RequestContext, fn: () => T): T {
return asyncLocalStorage.run(context, fn);
}
// Get the current user from context, or AnonymousUser if not in a request
function getCurrentUser(): User {
const context = asyncLocalStorage.getStore();
return context?.user ?? anonymousUser;
}
export { getCurrentUser, runWithContext, type RequestContext };

View File

@@ -0,0 +1,48 @@
import nunjucks from "nunjucks";
import { db, migrate, migrationStatus } from "../database";
import { getLogs, log } from "../logging";
// FIXME: This doesn't belong here; move it somewhere else.
const conf = {
templateEngine: () => {
return {
renderTemplate: (template: string, context: object) => {
return nunjucks.renderString(template, context);
},
};
},
};
const database = {
db,
migrate,
migrationStatus,
};
const logging = {
log,
getLogs,
};
const random = {
randomNumber: () => {
return Math.random();
},
};
const misc = {
sleep: (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
},
};
// Keep this asciibetically sorted
const core = {
conf,
database,
logging,
misc,
random,
};
export { core };

View File

@@ -0,0 +1,285 @@
// Tests for database.ts
// Requires test PostgreSQL: docker compose -f docker-compose.test.yml up -d
import assert from "node:assert/strict";
import { after, before, beforeEach, describe, it } from "node:test";
import {
connectionConfig,
db,
migrate,
migrationStatus,
PostgresAuthStore,
pool,
raw,
rawPool,
} from "./database";
import type { UserId } from "./user";
describe("database", () => {
before(async () => {
// Run migrations to set up schema
await migrate();
});
after(async () => {
await pool.end();
});
describe("connectionConfig", () => {
it("has required fields", () => {
assert.ok("host" in connectionConfig);
assert.ok("port" in connectionConfig);
assert.ok("user" in connectionConfig);
assert.ok("password" in connectionConfig);
assert.ok("database" in connectionConfig);
});
it("port is a number", () => {
assert.equal(typeof connectionConfig.port, "number");
});
});
describe("raw", () => {
it("executes raw SQL queries", async () => {
const result = await raw<{ one: number }>("SELECT 1 as one");
assert.equal(result.length, 1);
assert.equal(result[0].one, 1);
});
it("supports parameterized queries", async () => {
const result = await raw<{ sum: number }>(
"SELECT $1::int + $2::int as sum",
[2, 3],
);
assert.equal(result[0].sum, 5);
});
});
describe("db (Kysely instance)", () => {
it("can execute SELECT queries", async () => {
const result = await db
.selectFrom("users")
.select("id")
.limit(1)
.execute();
// May be empty, just verify it runs
assert.ok(Array.isArray(result));
});
});
describe("rawPool", () => {
it("is a pg Pool instance", () => {
assert.ok(rawPool.query !== undefined);
});
it("can execute queries", async () => {
const result = await rawPool.query("SELECT 1 as one");
assert.equal(result.rows[0].one, 1);
});
});
describe("migrate", () => {
it("runs without error when migrations are up to date", async () => {
// Should not throw
await migrate();
});
});
describe("migrationStatus", () => {
it("returns applied and pending arrays", async () => {
const status = await migrationStatus();
assert.ok(Array.isArray(status.applied));
assert.ok(Array.isArray(status.pending));
});
it("shows framework migrations as applied", async () => {
const status = await migrationStatus();
// At least the users migration should be applied
const hasUsersMigration = status.applied.some((m) =>
m.includes("users"),
);
assert.ok(hasUsersMigration);
});
});
describe("PostgresAuthStore", () => {
let store: PostgresAuthStore;
before(() => {
store = new PostgresAuthStore();
});
beforeEach(async () => {
// Clean up test data before each test
await rawPool.query("DELETE FROM sessions");
await rawPool.query("DELETE FROM user_credentials");
await rawPool.query("DELETE FROM user_emails");
await rawPool.query("DELETE FROM users");
});
describe("createUser", () => {
it("creates a user with pending status", async () => {
const user = await store.createUser({
email: "test@example.com",
passwordHash: "hash123",
displayName: "Test User",
});
assert.equal(user.email, "test@example.com");
assert.equal(user.displayName, "Test User");
assert.equal(user.status, "pending");
});
it("stores the password hash", async () => {
const user = await store.createUser({
email: "test@example.com",
passwordHash: "secrethash",
});
const hash = await store.getUserPasswordHash(user.id);
assert.equal(hash, "secrethash");
});
});
describe("getUserByEmail", () => {
it("returns user when found", async () => {
await store.createUser({
email: "find@example.com",
passwordHash: "hash",
});
const user = await store.getUserByEmail("find@example.com");
assert.notEqual(user, null);
assert.equal(user!.email, "find@example.com");
});
it("is case-insensitive", async () => {
await store.createUser({
email: "UPPER@EXAMPLE.COM",
passwordHash: "hash",
});
const user = await store.getUserByEmail("upper@example.com");
assert.notEqual(user, null);
});
it("returns null when not found", async () => {
const user = await store.getUserByEmail("notfound@example.com");
assert.equal(user, null);
});
});
describe("getUserById", () => {
it("returns user when found", async () => {
const created = await store.createUser({
email: "test@example.com",
passwordHash: "hash",
});
const user = await store.getUserById(created.id);
assert.notEqual(user, null);
assert.equal(user!.id, created.id);
});
it("returns null when not found", async () => {
const user = await store.getUserById(
"00000000-0000-0000-0000-000000000000" as UserId,
);
assert.equal(user, null);
});
});
describe("setUserPassword", () => {
it("updates the password hash", async () => {
const user = await store.createUser({
email: "test@example.com",
passwordHash: "oldhash",
});
await store.setUserPassword(user.id, "newhash");
const hash = await store.getUserPasswordHash(user.id);
assert.equal(hash, "newhash");
});
});
describe("updateUserEmailVerified", () => {
it("sets user status to active", async () => {
const created = await store.createUser({
email: "test@example.com",
passwordHash: "hash",
});
assert.equal(created.status, "pending");
await store.updateUserEmailVerified(created.id);
const user = await store.getUserById(created.id);
assert.equal(user!.status, "active");
});
});
describe("session operations", () => {
let userId: UserId;
beforeEach(async () => {
const user = await store.createUser({
email: "session@example.com",
passwordHash: "hash",
});
userId = user.id;
});
it("creates and retrieves sessions", async () => {
const { token, session } = await store.createSession({
userId,
tokenType: "session",
authMethod: "cookie",
expiresAt: new Date(Date.now() + 3600000),
});
assert.ok(token.length > 0);
assert.equal(session.userId, userId);
assert.equal(session.tokenType, "session");
});
it("deletes sessions", async () => {
const { session } = await store.createSession({
userId,
tokenType: "session",
authMethod: "cookie",
expiresAt: new Date(Date.now() + 3600000),
});
await store.deleteSession(session.tokenId as any);
// Session should be soft-deleted (revoked)
const retrieved = await store.getSession(
session.tokenId as any,
);
assert.equal(retrieved, null);
});
it("deletes all user sessions", async () => {
await store.createSession({
userId,
tokenType: "session",
authMethod: "cookie",
expiresAt: new Date(Date.now() + 3600000),
});
await store.createSession({
userId,
tokenType: "session",
authMethod: "bearer",
expiresAt: new Date(Date.now() + 3600000),
});
const count = await store.deleteUserSessions(userId);
assert.equal(count, 2);
});
});
});
});

View File

@@ -0,0 +1,548 @@
// 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 type { Domain } from "./types";
import { AuthenticatedUser, type User, type UserId } from "./user";
// Connection configuration (supports environment variable overrides)
const connectionConfig = {
host: process.env.DB_HOST ?? "localhost",
port: Number(process.env.DB_PORT ?? 5432),
user: process.env.DB_USER ?? "diachron",
password: process.env.DB_PASSWORD ?? "diachron",
database: process.env.DB_NAME ?? "diachron",
};
// Database schema types for Kysely
// Generated<T> marks columns with database defaults (optional on insert)
interface UsersTable {
id: string;
status: Generated<string>;
display_name: string | null;
created_at: Generated<Date>;
updated_at: Generated<Date>;
}
interface UserEmailsTable {
id: string;
user_id: string;
email: string;
normalized_email: string;
is_primary: Generated<boolean>;
is_verified: Generated<boolean>;
created_at: Generated<Date>;
verified_at: Date | null;
revoked_at: Date | null;
}
interface UserCredentialsTable {
id: string;
user_id: string;
credential_type: Generated<string>;
password_hash: string | null;
created_at: Generated<Date>;
updated_at: Generated<Date>;
}
interface SessionsTable {
id: Generated<string>;
token_hash: string;
user_id: string;
user_email_id: string | null;
token_type: string;
auth_method: string;
created_at: Generated<Date>;
expires_at: Date;
revoked_at: Date | null;
ip_address: string | null;
user_agent: string | null;
is_used: Generated<boolean | null>;
}
interface Database {
users: UsersTable;
user_emails: UserEmailsTable;
user_credentials: UserCredentialsTable;
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:
// yyyy-mm-dd_ss_description.sql
// e.g., 2025-01-15_01_initial.sql, 2025-01-15_02_add_users.sql
//
// Migrations directory: express/migrations/
const FRAMEWORK_MIGRATIONS_DIR = path.join(__dirname, "diachron/migrations");
const APP_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(kind: Domain): string[] {
const dir = kind === "fw" ? FRAMEWORK_MIGRATIONS_DIR : APP_MIGRATIONS_DIR;
if (!fs.existsSync(dir)) {
return [];
}
const root = __dirname;
const mm = fs
.readdirSync(dir)
.filter((f) => f.endsWith(".sql"))
.filter((f) => /^\d{4}-\d{2}-\d{2}_\d{2}-/.test(f))
.map((f) => `${dir}/${f}`)
.map((f) => f.replace(`${root}/`, ""))
.sort();
return mm;
}
// Run a single migration
async function runMigration(filename: string): Promise<void> {
// const filepath = path.join(MIGRATIONS_DIR, filename);
const filepath = filename;
const content = fs.readFileSync(filepath, "utf-8");
process.stdout.write(` Migration: ${filename}...`);
// 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(" ✓");
} catch (err) {
console.log(" ✗");
const message = err instanceof Error ? err.message : String(err);
console.error(` Error: ${message}`);
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
function getAllMigrationFiles() {
const fw_files = getMigrationFiles("fw");
const app_files = getMigrationFiles("app");
const all = [...fw_files, ...app_files];
return all;
}
// Run all pending migrations
async function migrate(): Promise<void> {
await ensureMigrationsTable();
const applied = new Set(await getAppliedMigrations());
const all = getAllMigrationFiles();
const pending = all.filter((all) => !applied.has(all));
if (pending.length === 0) {
console.log("No pending migrations");
return;
}
console.log(`Applying ${pending.length} migration(s):`);
for (const file of pending) {
await runMigration(file);
}
}
// List migration status
async function migrationStatus(): Promise<{
applied: string[];
pending: string[];
}> {
await ensureMigrationsTable();
const applied = new Set(await getAppliedMigrations());
const ff = getAllMigrationFiles();
return {
applied: ff.filter((ff) => applied.has(ff)),
pending: ff.filter((ff) => !applied.has(ff)),
};
}
// ============================================================================
// PostgresAuthStore - Database-backed authentication storage
// ============================================================================
class PostgresAuthStore implements AuthStore {
// Session operations
async createSession(
data: CreateSessionData,
): Promise<{ token: string; session: SessionData }> {
const token = generateToken();
const tokenHash = hashToken(token);
const row = await db
.insertInto("sessions")
.values({
token_hash: tokenHash,
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_hash,
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,
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_hash", "=", tokenId)
.where("expires_at", ">", new Date())
.where("revoked_at", "is", null)
.executeTakeFirst();
if (!row) {
return null;
}
return {
tokenId: row.token_hash,
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,
userAgent: row.user_agent ?? undefined,
ipAddress: row.ip_address ?? undefined,
isUsed: row.is_used ?? undefined,
};
}
async updateLastUsed(_tokenId: TokenId): Promise<void> {
// The new schema doesn't have last_used_at column
// This is now a no-op; session activity tracking could be added later
}
async deleteSession(tokenId: TokenId): Promise<void> {
// Soft delete by setting revoked_at
await db
.updateTable("sessions")
.set({ revoked_at: new Date() })
.where("token_hash", "=", tokenId)
.execute();
}
async deleteUserSessions(userId: UserId): Promise<number> {
const result = await db
.updateTable("sessions")
.set({ revoked_at: new Date() })
.where("user_id", "=", userId)
.where("revoked_at", "is", null)
.executeTakeFirst();
return Number(result.numUpdatedRows);
}
// User operations
async getUserByEmail(email: string): Promise<User | null> {
// Find user through user_emails table
const normalizedEmail = email.toLowerCase().trim();
const row = await db
.selectFrom("user_emails")
.innerJoin("users", "users.id", "user_emails.user_id")
.select([
"users.id",
"users.status",
"users.display_name",
"users.created_at",
"users.updated_at",
"user_emails.email",
])
.where("user_emails.normalized_email", "=", normalizedEmail)
.where("user_emails.revoked_at", "is", null)
.executeTakeFirst();
if (!row) {
return null;
}
return this.rowToUser(row);
}
async getUserById(userId: UserId): Promise<User | null> {
// Get user with their primary email
const row = await db
.selectFrom("users")
.leftJoin("user_emails", (join) =>
join
.onRef("user_emails.user_id", "=", "users.id")
.on("user_emails.is_primary", "=", true)
.on("user_emails.revoked_at", "is", null),
)
.select([
"users.id",
"users.status",
"users.display_name",
"users.created_at",
"users.updated_at",
"user_emails.email",
])
.where("users.id", "=", userId)
.executeTakeFirst();
if (!row) {
return null;
}
return this.rowToUser(row);
}
async createUser(data: CreateUserData): Promise<User> {
const userId = crypto.randomUUID();
const emailId = crypto.randomUUID();
const credentialId = crypto.randomUUID();
const now = new Date();
const normalizedEmail = data.email.toLowerCase().trim();
// Create user record
await db
.insertInto("users")
.values({
id: userId,
display_name: data.displayName ?? null,
status: "pending",
created_at: now,
updated_at: now,
})
.execute();
// Create user_email record
await db
.insertInto("user_emails")
.values({
id: emailId,
user_id: userId,
email: data.email,
normalized_email: normalizedEmail,
is_primary: true,
is_verified: false,
created_at: now,
})
.execute();
// Create user_credential record
await db
.insertInto("user_credentials")
.values({
id: credentialId,
user_id: userId,
credential_type: "password",
password_hash: data.passwordHash,
created_at: now,
updated_at: now,
})
.execute();
return new AuthenticatedUser({
id: userId,
email: data.email,
displayName: data.displayName,
status: "pending",
roles: [],
permissions: [],
createdAt: now,
updatedAt: now,
});
}
async getUserPasswordHash(userId: UserId): Promise<string | null> {
const row = await db
.selectFrom("user_credentials")
.select("password_hash")
.where("user_id", "=", userId)
.where("credential_type", "=", "password")
.executeTakeFirst();
return row?.password_hash ?? null;
}
async setUserPassword(userId: UserId, passwordHash: string): Promise<void> {
const now = new Date();
// Try to update existing credential
const result = await db
.updateTable("user_credentials")
.set({ password_hash: passwordHash, updated_at: now })
.where("user_id", "=", userId)
.where("credential_type", "=", "password")
.executeTakeFirst();
// If no existing credential, create one
if (Number(result.numUpdatedRows) === 0) {
await db
.insertInto("user_credentials")
.values({
id: crypto.randomUUID(),
user_id: userId,
credential_type: "password",
password_hash: passwordHash,
created_at: now,
updated_at: now,
})
.execute();
}
// Update user's updated_at
await db
.updateTable("users")
.set({ updated_at: now })
.where("id", "=", userId)
.execute();
}
async updateUserEmailVerified(userId: UserId): Promise<void> {
const now = new Date();
// Update user_emails to mark as verified
await db
.updateTable("user_emails")
.set({
is_verified: true,
verified_at: now,
})
.where("user_id", "=", userId)
.where("is_primary", "=", true)
.execute();
// Update user status to active
await db
.updateTable("users")
.set({
status: "active",
updated_at: now,
})
.where("id", "=", userId)
.execute();
}
// Helper to convert database row to User object
private rowToUser(row: {
id: string;
status: string;
display_name: string | null;
created_at: Date;
updated_at: Date;
email: string | null;
}): User {
return new AuthenticatedUser({
id: row.id,
email: row.email ?? "unknown@example.com",
displayName: row.display_name ?? undefined,
status: row.status as "active" | "suspended" | "pending",
roles: [], // TODO: query from RBAC tables
permissions: [], // TODO: query from RBAC tables
createdAt: row.created_at,
updatedAt: row.updated_at,
});
}
}
// ============================================================================
// Exports
// ============================================================================
export {
db,
raw,
rawPool,
pool,
migrate,
migrationStatus,
connectionConfig,
PostgresAuthStore,
type Database,
};

7
backend/diachron/deps.ts Normal file
View File

@@ -0,0 +1,7 @@
// Database
//export { Client as PostgresClient } from "https://deno.land/x/postgres@v0.19.3/mod.ts";
//export type { ClientOptions as PostgresOptions } from "https://deno.land/x/postgres@v0.19.3/mod.ts";
// Redis
//export { connect as redisConnect } from "https://deno.land/x/redis@v0.37.1/mod.ts";
//export type { Redis } from "https://deno.land/x/redis@v0.37.1/mod.ts";

View File

@@ -0,0 +1,17 @@
import { connectionConfig, migrate, pool } from "../database";
import { dropTables, exitIfUnforced } from "./util";
async function main(): Promise<void> {
exitIfUnforced();
try {
await dropTables();
} finally {
await pool.end();
}
}
main().catch((err) => {
console.error("Failed to clear database:", err.message);
process.exit(1);
});

View File

@@ -0,0 +1,26 @@
// reset-db.ts
// Development command to wipe the database and apply all migrations from scratch
import { connectionConfig, migrate, pool } from "../database";
import { dropTables, exitIfUnforced } from "./util";
async function main(): Promise<void> {
exitIfUnforced();
try {
await dropTables();
console.log("");
await migrate();
console.log("");
console.log("Database reset complete.");
} finally {
await pool.end();
}
}
main().catch((err) => {
console.error("Failed to reset database:", err.message);
process.exit(1);
});

View File

@@ -0,0 +1,42 @@
// FIXME: this is at the wrong level of specificity
import { connectionConfig, migrate, pool } from "../database";
const exitIfUnforced = () => {
const args = process.argv.slice(2);
// Require explicit confirmation unless --force is passed
if (!args.includes("--force")) {
console.error("This will DROP ALL TABLES in the database!");
console.error(` Database: ${connectionConfig.database}`);
console.error(
` Host: ${connectionConfig.host}:${connectionConfig.port}`,
);
console.error("");
console.error("Run with --force to proceed.");
process.exit(1);
}
};
const dropTables = async () => {
console.log("Dropping all tables...");
// Get all table names in the public schema
const result = await pool.query<{ tablename: string }>(`
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
`);
if (result.rows.length > 0) {
// Drop all tables with CASCADE to handle foreign key constraints
const tableNames = result.rows
.map((r) => `"${r.tablename}"`)
.join(", ");
await pool.query(`DROP TABLE IF EXISTS ${tableNames} CASCADE`);
console.log(`Dropped ${result.rows.length} table(s)`);
} else {
console.log("No tables to drop");
}
};
export { dropTables, exitIfUnforced };

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
export const executionContextSchema = z.object({
diachron_root: z.string(),
});
export type ExecutionContext = z.infer<typeof executionContextSchema>;
export function parseExecutionContext(
env: Record<string, string | undefined>,
): ExecutionContext {
return executionContextSchema.parse(env);
}

View File

@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { ZodError } from "zod";
import {
executionContextSchema,
parseExecutionContext,
} from "./execution-context-schema";
describe("parseExecutionContext", () => {
it("parses valid executionContext with diachron_root", () => {
const env = { diachron_root: "/some/path" };
const result = parseExecutionContext(env);
assert.deepEqual(result, { diachron_root: "/some/path" });
});
it("throws ZodError when diachron_root is missing", () => {
const env = {};
assert.throws(() => parseExecutionContext(env), ZodError);
});
it("strips extra fields not in schema", () => {
const env = {
diachron_root: "/some/path",
EXTRA_VAR: "should be stripped",
};
const result = parseExecutionContext(env);
assert.deepEqual(result, { diachron_root: "/some/path" });
assert.equal("EXTRA_VAR" in result, false);
});
});
describe("executionContextSchema", () => {
it("requires diachron_root to be a string", () => {
const result = executionContextSchema.safeParse({ diachron_root: 123 });
assert.equal(result.success, false);
});
});

View File

@@ -0,0 +1,5 @@
import { parseExecutionContext } from "./execution-context-schema";
const executionContext = parseExecutionContext(process.env);
export { executionContext };

View File

View File

@@ -0,0 +1,71 @@
// Tests for handlers.ts
// These tests use mock Call objects
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { Request as ExpressRequest } from "express";
import { Session } from "./auth/types";
import { contentTypes } from "./content-types";
import { multiHandler } from "./handlers";
import { httpCodes } from "./http-codes";
import type { Call } from "./types";
import { anonymousUser } from "./user";
// Helper to create a minimal mock Call
function createMockCall(overrides: Partial<Call> = {}): Call {
const defaultSession = new Session(null, anonymousUser);
return {
pattern: "/test",
path: "/test",
method: "GET",
parameters: {},
request: {} as ExpressRequest,
user: anonymousUser,
session: defaultSession,
...overrides,
};
}
describe("handlers", () => {
describe("multiHandler", () => {
it("returns OK status", async () => {
const call = createMockCall({ method: "GET" });
const result = await multiHandler(call);
assert.equal(result.code, httpCodes.success.OK);
});
it("returns text/plain content type", async () => {
const call = createMockCall();
const result = await multiHandler(call);
assert.equal(result.contentType, contentTypes.text.plain);
});
it("includes method in result", async () => {
const call = createMockCall({ method: "POST" });
const result = await multiHandler(call);
assert.ok(result.result.includes("POST"));
});
it("includes a random number in result", async () => {
const call = createMockCall();
const result = await multiHandler(call);
// Result format: "that was GET (0.123456789)"
assert.match(result.result, /that was \w+ \(\d+\.?\d*\)/);
});
it("works with different HTTP methods", async () => {
const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
for (const method of methods) {
const call = createMockCall({ method });
const result = await multiHandler(call);
assert.ok(result.result.includes(method));
}
});
});
});

View File

@@ -0,0 +1,19 @@
import { contentTypes } from "./content-types";
import { core } from "./core";
import { httpCodes } from "./http-codes";
import type { Call, Handler, Result } from "./types";
const multiHandler: Handler = async (call: Call): Promise<Result> => {
const code = httpCodes.success.OK;
const rn = core.random.randomNumber();
const retval: Result = {
code,
result: `that was ${call.method} (${rn})`,
contentType: contentTypes.text.plain,
};
return retval;
};
export { multiHandler };

View File

@@ -0,0 +1,76 @@
// This file belongs to the framework. You are not expected to modify it.
export type HttpCode = {
code: number;
name: string;
description?: string;
};
type Group = "success" | "redirection" | "clientErrors" | "serverErrors";
type CodeDefinitions = {
[K in Group]: {
[K: string]: HttpCode;
};
};
// tx claude https://claude.ai/share/344fc7bd-5321-4763-af2f-b82275e9f865
const httpCodes: CodeDefinitions = {
success: {
OK: { code: 200, name: "OK", description: "" },
Created: { code: 201, name: "Created" },
Accepted: { code: 202, name: "Accepted" },
NonAuthoritativeInformation: {
code: 203,
name: "Non-Authoritative Information",
},
NoContent: { code: 204, name: "No Content" },
ResetContent: { code: 205, name: "Reset Content" },
PartialContent: { code: 206, name: "Partial Content" },
},
redirection: {
MultipleChoices: { code: 300, name: "Multiple Choices" },
MovedPermanently: { code: 301, name: "Moved Permanently" },
Found: { code: 302, name: "Found" },
SeeOther: { code: 303, name: "See Other" },
NotModified: { code: 304, name: "Not Modified" },
TemporaryRedirect: { code: 307, name: "Temporary Redirect" },
PermanentRedirect: { code: 308, name: "Permanent Redirect" },
},
clientErrors: {
BadRequest: { code: 400, name: "Bad Request" },
Unauthorized: { code: 401, name: "Unauthorized" },
PaymentRequired: { code: 402, name: "Payment Required" },
Forbidden: { code: 403, name: "Forbidden" },
NotFound: { code: 404, name: "Not Found" },
MethodNotAllowed: { code: 405, name: "Method Not Allowed" },
NotAcceptable: { code: 406, name: "Not Acceptable" },
ProxyAuthenticationRequired: {
code: 407,
name: "Proxy Authentication Required",
},
RequestTimeout: { code: 408, name: "Request Timeout" },
Conflict: { code: 409, name: "Conflict" },
Gone: { code: 410, name: "Gone" },
LengthRequired: { code: 411, name: "Length Required" },
PreconditionFailed: { code: 412, name: "Precondition Failed" },
PayloadTooLarge: { code: 413, name: "Payload Too Large" },
URITooLong: { code: 414, name: "URI Too Long" },
UnsupportedMediaType: { code: 415, name: "Unsupported Media Type" },
RangeNotSatisfiable: { code: 416, name: "Range Not Satisfiable" },
ExpectationFailed: { code: 417, name: "Expectation Failed" },
ImATeapot: { code: 418, name: "I'm a teapot" },
UnprocessableEntity: { code: 422, name: "Unprocessable Entity" },
TooManyRequests: { code: 429, name: "Too Many Requests" },
},
serverErrors: {
InternalServerError: { code: 500, name: "Internal Server Error" },
NotImplemented: { code: 501, name: "Not Implemented" },
BadGateway: { code: 502, name: "Bad Gateway" },
ServiceUnavailable: { code: 503, name: "Service Unavailable" },
GatewayTimeout: { code: 504, name: "Gateway Timeout" },
HTTPVersionNotSupported: {
code: 505,
name: "HTTP Version Not Supported",
},
},
};
export { httpCodes };

View File

@@ -0,0 +1,29 @@
import { Kysely, PostgresDialect } from "kysely";
import { Pool } from "pg";
import { connectionConfig } from "../database";
import type { DB } from "../../generated/db";
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new Pool(connectionConfig),
}),
log(event) {
if (event.level === "query") {
// FIXME: Wire this up to the logging system
console.log("SQL:", event.query.sql);
console.log("Params:", event.query.parameters);
}
},
});
abstract class Hydrator<T> {
public db: Kysely<DB>;
protected abstract table: string;
constructor() {
this.db = db;
}
}
export { Hydrator, db };

View File

@@ -0,0 +1 @@
export type Hydrators = {};

View File

@@ -0,0 +1,44 @@
// Test setup for hydrator tests
// Run: DB_PORT=5433 DB_USER=diachron_test DB_PASSWORD=diachron_test DB_NAME=diachron_test npx tsx --test tests/*.test.ts
import { Pool } from "pg";
import { connectionConfig, migrate } from "../../database";
const pool = new Pool(connectionConfig);
export async function setupTestDatabase(): Promise<void> {
await migrate();
}
export async function cleanupTables(): Promise<void> {
// Clean in reverse dependency order
await pool.query("DELETE FROM user_emails");
await pool.query("DELETE FROM users");
}
export async function teardownTestDatabase(): Promise<void> {
await pool.end();
}
export async function insertTestUser(data: {
id: string;
displayName: string;
status: string;
email: string;
}): Promise<void> {
const emailId = crypto.randomUUID();
const normalizedEmail = data.email.toLowerCase().trim();
await pool.query(
`INSERT INTO users (id, display_name, status) VALUES ($1, $2, $3)`,
[data.id, data.displayName, data.status],
);
await pool.query(
`INSERT INTO user_emails (id, user_id, email, normalized_email, is_primary)
VALUES ($1, $2, $3, $4, true)`,
[emailId, data.id, data.email, normalizedEmail],
);
}
export { pool };

View File

@@ -0,0 +1,98 @@
// Tests for user hydrator
// Run with: cd express && DB_PORT=5433 DB_USER=diachron_test DB_PASSWORD=diachron_test DB_NAME=diachron_test ../cmd npx tsx --test diachron/hydrators/tests/user.test.ts
import assert from "node:assert/strict";
import { after, before, beforeEach, describe, it } from "node:test";
import { get } from "../user";
import {
cleanupTables,
insertTestUser,
setupTestDatabase,
teardownTestDatabase,
} from "./setup";
describe("user hydrator", () => {
before(async () => {
await setupTestDatabase();
});
after(async () => {
await teardownTestDatabase();
});
beforeEach(async () => {
await cleanupTables();
});
describe("get", () => {
it("returns null for non-existent user", async () => {
const result = await get("00000000-0000-0000-0000-000000000000");
assert.equal(result, null);
});
it("returns user when found", async () => {
const userId = "cfae0a19-6515-4813-bc2d-1e032b72b203";
await insertTestUser({
id: userId,
displayName: "Test User",
status: "active",
email: "test@example.com",
});
const result = await get(userId);
assert.notEqual(result, null);
assert.equal(result!.id, userId);
assert.equal(result!.display_name, "Test User");
assert.equal(result!.status, "active");
assert.equal(result!.email, "test@example.com");
});
it("validates user data with zod parser", async () => {
const userId = crypto.randomUUID();
await insertTestUser({
id: userId,
displayName: "Valid User",
status: "active",
email: "valid@example.com",
});
const result = await get(userId);
// If we get here without throwing, parsing succeeded
assert.notEqual(result, null);
assert.equal(typeof result!.id, "string");
assert.equal(typeof result!.email, "string");
});
it("returns user with pending status", async () => {
const userId = crypto.randomUUID();
await insertTestUser({
id: userId,
displayName: "Pending User",
status: "pending",
email: "pending@example.com",
});
const result = await get(userId);
assert.notEqual(result, null);
assert.equal(result!.status, "pending");
});
it("returns user with suspended status", async () => {
const userId = crypto.randomUUID();
await insertTestUser({
id: userId,
displayName: "Suspended User",
status: "suspended",
email: "suspended@example.com",
});
const result = await get(userId);
assert.notEqual(result, null);
assert.equal(result!.status, "suspended");
});
});
});

View File

@@ -0,0 +1,59 @@
import {
ColumnType,
Generated,
Insertable,
JSONColumnType,
Selectable,
Updateable,
} from "kysely";
import type { TypeID } from "typeid-js";
import { z } from "zod";
import { db, Hydrator } from "./hydrator";
const parser = z.object({
// id: z.uuidv7(),
id: z.uuid(),
display_name: z.string(),
// FIXME: status is duplicated elsewhere
status: z.union([
z.literal("active"),
z.literal("suspended"),
z.literal("pending"),
]),
email: z.email(),
});
const tp = parser.parse({
id: "cfae0a19-6515-4813-bc2d-1e032b72b203",
display_name: "foo",
status: "active",
email: "mw@philologue.net",
});
export type User = z.infer<typeof parser>;
const get = async (id: string): Promise<null | User> => {
const ret = await db
.selectFrom("users")
.where("users.id", "=", id)
.innerJoin("user_emails", "user_emails.user_id", "users.id")
.select([
"users.id",
"users.status",
"users.display_name",
"user_emails.email",
])
.executeTakeFirst();
if (ret === undefined) {
return null;
}
console.dir(ret);
const parsed = parser.parse(ret);
return parsed;
};
export { get };

View File

@@ -0,0 +1,3 @@
type Brand<K, T> = K & { readonly __brand: T };
export type Extensible = Brand<"Extensible", {}>;

View File

@@ -0,0 +1,53 @@
// Tests for logging.ts
// Note: These tests verify the module structure and types.
// Full integration tests would require a running logging service.
import assert from "node:assert/strict";
import { describe, it } from "node:test";
// We can't easily test log() and getLogs() without mocking fetch,
// but we can verify the module exports correctly and types work.
describe("logging", () => {
describe("module structure", () => {
it("exports log function", async () => {
const { log } = await import("./logging");
assert.equal(typeof log, "function");
});
it("exports getLogs function", async () => {
const { getLogs } = await import("./logging");
assert.equal(typeof getLogs, "function");
});
});
describe("Message type", () => {
// Type-level tests - if these compile, the types are correct
it("accepts valid message sources", () => {
type MessageSource = "logging" | "diagnostic" | "user";
const sources: MessageSource[] = ["logging", "diagnostic", "user"];
assert.equal(sources.length, 3);
});
});
describe("FilterArgument type", () => {
// Type-level tests
it("accepts valid filter options", () => {
type FilterArgument = {
limit?: number;
before?: number;
after?: number;
match?: (string | RegExp)[];
};
const filter: FilterArgument = {
limit: 10,
before: Date.now(),
after: Date.now() - 3600000,
match: ["error", /warning/i],
};
assert.ok(filter.limit === 10);
});
});
});

View File

@@ -0,0 +1,73 @@
// internal-logging.ts
import { cli } from "./cli";
// FIXME: Move this to somewhere more appropriate
type AtLeastOne<T> = [T, ...T[]];
type MessageSource = "logging" | "diagnostic" | "user";
type Message = {
// FIXME: number probably isn't what we want here
timestamp?: number;
source: MessageSource;
text: AtLeastOne<string>;
};
const _m1: Message = { timestamp: 123, source: "logging", text: ["foo"] };
const _m2: Message = {
timestamp: 321,
source: "diagnostic",
text: ["ok", "whatever"],
};
type FilterArgument = {
limit?: number;
before?: number;
after?: number;
// FIXME: add offsets to use instead of or in addition to before/after
match?: (string | RegExp)[];
};
const loggerUrl = `http://${cli.logAddress.host}:${cli.logAddress.port}`;
const log = (message: Message) => {
const payload = {
timestamp: message.timestamp ?? Date.now(),
source: message.source,
text: message.text,
};
fetch(`${loggerUrl}/log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch((err) => {
console.error("[logging] Failed to send log:", err.message);
});
};
const getLogs = async (filter: FilterArgument): Promise<Message[]> => {
const params = new URLSearchParams();
if (filter.limit) {
params.set("limit", String(filter.limit));
}
if (filter.before) {
params.set("before", String(filter.before));
}
if (filter.after) {
params.set("after", String(filter.after));
}
const url = `${loggerUrl}/logs?${params.toString()}`;
const response = await fetch(url);
return response.json();
};
// FIXME: there's scope for more specialized functions although they
// probably should be defined in terms of the basic ones here.
export { getLogs, log };

View File

@@ -0,0 +1,69 @@
// add-user.ts
// Management command to create users from the command line
import { hashPassword } from "../auth/password";
import { PostgresAuthStore, pool } from "../database";
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length < 2) {
console.error(
"Usage: ./mgmt add-user <email> <password> [--display-name <name>] [--active]",
);
process.exit(1);
}
const email = args[0];
const password = args[1];
// Parse optional flags
let displayName: string | undefined;
let makeActive = false;
for (let i = 2; i < args.length; i++) {
if (args[i] === "--display-name" && args[i + 1]) {
displayName = args[i + 1];
i++;
} else if (args[i] === "--active") {
makeActive = true;
}
}
try {
const store = new PostgresAuthStore();
// Check if user already exists
const existing = await store.getUserByEmail(email);
if (existing) {
console.error(`Error: User with email '${email}' already exists`);
process.exit(1);
}
// Hash password and create user
const passwordHash = await hashPassword(password);
const user = await store.createUser({
email,
passwordHash,
displayName,
});
// Optionally activate user immediately
if (makeActive) {
await store.updateUserEmailVerified(user.id);
console.log(
`Created and activated user: ${user.email} (${user.id})`,
);
} else {
console.log(`Created user: ${user.email} (${user.id})`);
console.log(" Status: pending (use --active to create as active)");
}
} finally {
await pool.end();
}
}
main().catch((err) => {
console.error("Failed to create user:", err.message);
process.exit(1);
});

View File

@@ -0,0 +1,45 @@
// migrate.ts
// CLI script for running database migrations
import { migrate, migrationStatus, pool } from "./database";
async function main(): Promise<void> {
const command = process.argv[2] || "run";
try {
switch (command) {
case "run":
await migrate();
break;
case "status": {
const status = await migrationStatus();
console.log("Applied migrations:");
for (const name of status.applied) {
console.log(`${name}`);
}
if (status.pending.length > 0) {
console.log("\nPending migrations:");
for (const name of status.pending) {
console.log(`${name}`);
}
} else {
console.log("\nNo pending migrations");
}
break;
}
default:
console.error(`Unknown command: ${command}`);
console.error("Usage: migrate [run|status]");
process.exit(1);
}
} finally {
await pool.end();
}
}
main().catch((err) => {
console.error("Migration failed:", err);
process.exit(1);
});

View File

@@ -0,0 +1,29 @@
-- 0001_users.sql
-- Create users table for authentication
CREATE TABLE users (
id UUID PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'active',
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE user_emails (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
email TEXT NOT NULL,
normalized_email TEXT NOT NULL,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
verified_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
-- Enforce uniqueness only among *active* emails
CREATE UNIQUE INDEX user_emails_unique_active
ON user_emails (normalized_email)
WHERE revoked_at IS NULL;

View File

@@ -0,0 +1,26 @@
-- 0002_sessions.sql
-- Create sessions table for auth tokens
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash TEXT UNIQUE NOT NULL,
user_id UUID NOT NULL REFERENCES users(id),
user_email_id UUID REFERENCES user_emails(id),
token_type TEXT NOT NULL,
auth_method TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
ip_address INET,
user_agent 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

@@ -0,0 +1,17 @@
-- 0003_user_credentials.sql
-- Create user_credentials table for password storage (extensible for other auth methods)
CREATE TABLE user_credentials (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
credential_type TEXT NOT NULL DEFAULT 'password',
password_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Each user can have at most one credential per type
CREATE UNIQUE INDEX user_credentials_user_type_idx ON user_credentials (user_id, credential_type);
-- Index for user lookups
CREATE INDEX user_credentials_user_id_idx ON user_credentials (user_id);

View File

@@ -0,0 +1,20 @@
CREATE TABLE roles (
id UUID PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT
);
CREATE TABLE groups (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE user_group_roles (
user_id UUID NOT NULL REFERENCES users(id),
group_id UUID NOT NULL REFERENCES groups(id),
role_id UUID NOT NULL REFERENCES roles(id),
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ,
PRIMARY KEY (user_id, group_id, role_id)
);

View File

@@ -0,0 +1,14 @@
CREATE TABLE capabilities (
id UUID PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT
);
CREATE TABLE role_capabilities (
role_id UUID NOT NULL REFERENCES roles(id),
capability_id UUID NOT NULL REFERENCES capabilities(id),
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ,
PRIMARY KEY (role_id, capability_id)
);

View File

@@ -0,0 +1,25 @@
import { AuthService } from "../auth";
import { getCurrentUser } from "../context";
import { PostgresAuthStore } from "../database";
import type { User } from "../user";
import { html, redirect, render } from "./util";
const util = { html, redirect, render };
const session = {
getUser: (): User => {
return getCurrentUser();
},
};
// Initialize auth with PostgreSQL store
const authStore = new PostgresAuthStore();
const auth = new AuthService(authStore);
const request = {
auth,
session,
util,
};
export { request };

View File

@@ -0,0 +1,45 @@
import { contentTypes } from "../content-types";
import { core } from "../core";
import { executionContext } from "../execution-context";
import { httpCodes } from "../http-codes";
import type { RedirectResult, Result } from "../types";
import { loadFile } from "../util";
import { request } from "./index";
type NoUser = {
[key: string]: unknown;
} & {
user?: never;
};
const render = async (path: string, ctx?: NoUser): Promise<string> => {
const fullPath = `${executionContext.diachron_root}/templates/${path}.html.njk`;
const template = await loadFile(fullPath);
const user = request.session.getUser();
const context = { user, ...ctx };
const engine = core.conf.templateEngine();
const retval = engine.renderTemplate(template, context);
return retval;
};
const html = (payload: string): Result => {
const retval: Result = {
code: httpCodes.success.OK,
result: payload,
contentType: contentTypes.text.html,
};
return retval;
};
const redirect = (location: string): RedirectResult => {
return {
code: httpCodes.redirection.SeeOther,
contentType: contentTypes.text.plain,
result: "",
redirect: location,
};
};
export { html, redirect, render };

View File

@@ -0,0 +1,179 @@
// Tests for types.ts
// Pure unit tests
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { Request as ExpressRequest } from "express";
import { Session } from "./auth/types";
import { contentTypes } from "./content-types";
import { httpCodes } from "./http-codes";
import {
AuthenticationRequired,
AuthorizationDenied,
type Call,
isRedirect,
massageMethod,
methodParser,
type Permission,
type RedirectResult,
type Result,
requireAuth,
requirePermission,
} from "./types";
import { AuthenticatedUser, anonymousUser } from "./user";
// Helper to create a minimal mock Call
function createMockCall(overrides: Partial<Call> = {}): Call {
const defaultSession = new Session(null, anonymousUser);
return {
pattern: "/test",
path: "/test",
method: "GET",
parameters: {},
request: {} as ExpressRequest,
user: anonymousUser,
session: defaultSession,
...overrides,
};
}
describe("types", () => {
describe("methodParser", () => {
it("accepts valid HTTP methods", () => {
assert.equal(methodParser.parse("GET"), "GET");
assert.equal(methodParser.parse("POST"), "POST");
assert.equal(methodParser.parse("PUT"), "PUT");
assert.equal(methodParser.parse("PATCH"), "PATCH");
assert.equal(methodParser.parse("DELETE"), "DELETE");
});
it("rejects invalid methods", () => {
assert.throws(() => methodParser.parse("get"));
assert.throws(() => methodParser.parse("OPTIONS"));
assert.throws(() => methodParser.parse("HEAD"));
assert.throws(() => methodParser.parse(""));
});
});
describe("massageMethod", () => {
it("converts lowercase to uppercase", () => {
assert.equal(massageMethod("get"), "GET");
assert.equal(massageMethod("post"), "POST");
assert.equal(massageMethod("put"), "PUT");
assert.equal(massageMethod("patch"), "PATCH");
assert.equal(massageMethod("delete"), "DELETE");
});
it("handles mixed case", () => {
assert.equal(massageMethod("Get"), "GET");
assert.equal(massageMethod("pOsT"), "POST");
});
it("throws for invalid methods", () => {
assert.throws(() => massageMethod("options"));
assert.throws(() => massageMethod("head"));
});
});
describe("isRedirect", () => {
it("returns true for redirect results", () => {
const result: RedirectResult = {
code: httpCodes.redirection.Found,
contentType: contentTypes.text.html,
result: "",
redirect: "/other",
};
assert.equal(isRedirect(result), true);
});
it("returns false for non-redirect results", () => {
const result: Result = {
code: httpCodes.success.OK,
contentType: contentTypes.text.html,
result: "hello",
};
assert.equal(isRedirect(result), false);
});
});
describe("AuthenticationRequired", () => {
it("has correct name and message", () => {
const err = new AuthenticationRequired();
assert.equal(err.name, "AuthenticationRequired");
assert.equal(err.message, "Authentication required");
});
it("is an instance of Error", () => {
const err = new AuthenticationRequired();
assert.ok(err instanceof Error);
});
});
describe("AuthorizationDenied", () => {
it("has correct name and message", () => {
const err = new AuthorizationDenied();
assert.equal(err.name, "AuthorizationDenied");
assert.equal(err.message, "Authorization denied");
});
it("is an instance of Error", () => {
const err = new AuthorizationDenied();
assert.ok(err instanceof Error);
});
});
describe("requireAuth", () => {
it("returns user for authenticated call", () => {
const user = AuthenticatedUser.create("test@example.com");
const session = new Session(null, user);
const call = createMockCall({ user, session });
const result = requireAuth(call);
assert.equal(result, user);
});
it("throws AuthenticationRequired for anonymous user", () => {
const call = createMockCall({ user: anonymousUser });
assert.throws(() => requireAuth(call), AuthenticationRequired);
});
});
describe("requirePermission", () => {
it("returns user when they have the permission", () => {
const user = AuthenticatedUser.create("test@example.com", {
permissions: ["posts:create" as Permission],
});
const session = new Session(null, user);
const call = createMockCall({ user, session });
const result = requirePermission(
call,
"posts:create" as Permission,
);
assert.equal(result, user);
});
it("throws AuthenticationRequired for anonymous user", () => {
const call = createMockCall({ user: anonymousUser });
assert.throws(
() => requirePermission(call, "posts:create" as Permission),
AuthenticationRequired,
);
});
it("throws AuthorizationDenied when missing permission", () => {
const user = AuthenticatedUser.create("test@example.com", {
permissions: ["posts:read" as Permission],
});
const session = new Session(null, user);
const call = createMockCall({ user, session });
assert.throws(
() => requirePermission(call, "posts:create" as Permission),
AuthorizationDenied,
);
});
});
});

117
backend/diachron/types.ts Normal file
View File

@@ -0,0 +1,117 @@
// types.ts
// FIXME: split this up into types used by app developers and types internal
// to the framework.
import type { Request as ExpressRequest } from "express";
import type { MatchFunction } from "path-to-regexp";
import { z } from "zod";
import type { Session } from "./auth/types";
import type { ContentType } from "./content-types";
import type { HttpCode } from "./http-codes";
import type { Permission, User } from "./user";
const methodParser = z.union([
z.literal("GET"),
z.literal("POST"),
z.literal("PUT"),
z.literal("PATCH"),
z.literal("DELETE"),
]);
export type Method = z.infer<typeof methodParser>;
const massageMethod = (input: string): Method => {
const r = methodParser.parse(input.toUpperCase());
return r;
};
export type Call = {
pattern: string;
path: string;
method: Method;
parameters: object;
request: ExpressRequest;
user: User;
session: Session;
};
export type InternalHandler = (req: ExpressRequest) => Promise<Result>;
export type Handler = (call: Call) => Promise<Result>;
export type ProcessedRoute = {
matcher: MatchFunction<Record<string, string>>;
method: Method;
handler: InternalHandler;
};
export type CookieOptions = {
httpOnly?: boolean;
secure?: boolean;
sameSite?: "strict" | "lax" | "none";
maxAge?: number;
path?: string;
};
export type Cookie = {
name: string;
value: string;
options?: CookieOptions;
};
export type Result = {
code: HttpCode;
contentType: ContentType;
result: string;
cookies?: Cookie[];
};
export type RedirectResult = Result & {
redirect: string;
};
export function isRedirect(result: Result): result is RedirectResult {
return "redirect" in result;
}
export type Route = {
path: string;
methods: Method[];
handler: Handler;
interruptable?: boolean;
};
// Authentication error classes
export class AuthenticationRequired extends Error {
constructor() {
super("Authentication required");
this.name = "AuthenticationRequired";
}
}
export class AuthorizationDenied extends Error {
constructor() {
super("Authorization denied");
this.name = "AuthorizationDenied";
}
}
// Helper for handlers to require authentication
export function requireAuth(call: Call): User {
if (call.user.isAnonymous()) {
throw new AuthenticationRequired();
}
return call.user;
}
// Helper for handlers to require specific permission
export function requirePermission(call: Call, permission: Permission): User {
const user = requireAuth(call);
if (!user.hasPermission(permission)) {
throw new AuthorizationDenied();
}
return user;
}
export type Domain = "app" | "fw";
export { methodParser, massageMethod };

View File

@@ -0,0 +1,213 @@
// Tests for user.ts
// These are pure unit tests - no database needed
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
AnonymousUser,
AuthenticatedUser,
anonymousUser,
type Permission,
type Role,
} from "./user";
describe("User", () => {
describe("AuthenticatedUser.create", () => {
it("creates a user with default values", () => {
const user = AuthenticatedUser.create("test@example.com");
assert.equal(user.email, "test@example.com");
assert.equal(user.status, "active");
assert.equal(user.isAnonymous(), false);
assert.deepEqual([...user.roles], []);
assert.deepEqual([...user.permissions], []);
});
it("creates a user with custom values", () => {
const user = AuthenticatedUser.create("test@example.com", {
id: "custom-id",
displayName: "Test User",
status: "pending",
roles: ["admin"],
permissions: ["posts:create"],
});
assert.equal(user.id, "custom-id");
assert.equal(user.displayName, "Test User");
assert.equal(user.status, "pending");
assert.deepEqual([...user.roles], ["admin"]);
assert.deepEqual([...user.permissions], ["posts:create"]);
});
});
describe("status checks", () => {
it("isActive returns true for active users", () => {
const user = AuthenticatedUser.create("test@example.com", {
status: "active",
});
assert.equal(user.isActive(), true);
});
it("isActive returns false for suspended users", () => {
const user = AuthenticatedUser.create("test@example.com", {
status: "suspended",
});
assert.equal(user.isActive(), false);
});
it("isActive returns false for pending users", () => {
const user = AuthenticatedUser.create("test@example.com", {
status: "pending",
});
assert.equal(user.isActive(), false);
});
});
describe("role checks", () => {
it("hasRole returns true when user has the role", () => {
const user = AuthenticatedUser.create("test@example.com", {
roles: ["admin", "editor"],
});
assert.equal(user.hasRole("admin"), true);
assert.equal(user.hasRole("editor"), true);
});
it("hasRole returns false when user does not have the role", () => {
const user = AuthenticatedUser.create("test@example.com", {
roles: ["user"],
});
assert.equal(user.hasRole("admin"), false);
});
it("hasAnyRole returns true when user has at least one role", () => {
const user = AuthenticatedUser.create("test@example.com", {
roles: ["editor"],
});
assert.equal(user.hasAnyRole(["admin", "editor"]), true);
});
it("hasAnyRole returns false when user has none of the roles", () => {
const user = AuthenticatedUser.create("test@example.com", {
roles: ["user"],
});
assert.equal(user.hasAnyRole(["admin", "editor"]), false);
});
it("hasAllRoles returns true when user has all roles", () => {
const user = AuthenticatedUser.create("test@example.com", {
roles: ["admin", "editor", "user"],
});
assert.equal(user.hasAllRoles(["admin", "editor"]), true);
});
it("hasAllRoles returns false when user is missing a role", () => {
const user = AuthenticatedUser.create("test@example.com", {
roles: ["admin"],
});
assert.equal(user.hasAllRoles(["admin", "editor"]), false);
});
});
describe("permission checks", () => {
it("hasPermission returns true for direct permissions", () => {
const user = AuthenticatedUser.create("test@example.com", {
permissions: ["posts:create" as Permission],
});
assert.equal(
user.hasPermission("posts:create" as Permission),
true,
);
});
it("hasPermission returns true for role-derived permissions", () => {
const user = AuthenticatedUser.create("test@example.com", {
roles: ["admin" as Role],
});
// admin role has users:read, users:create, users:update, users:delete
assert.equal(user.hasPermission("users:read" as Permission), true);
assert.equal(
user.hasPermission("users:delete" as Permission),
true,
);
});
it("hasPermission returns false when permission not granted", () => {
const user = AuthenticatedUser.create("test@example.com", {
roles: ["user" as Role],
});
// user role only has users:read
assert.equal(
user.hasPermission("users:delete" as Permission),
false,
);
});
it("can() is a convenience method for hasPermission", () => {
const user = AuthenticatedUser.create("test@example.com", {
roles: ["admin" as Role],
});
assert.equal(user.can("read", "users"), true);
assert.equal(user.can("delete", "users"), true);
assert.equal(user.can("create", "posts"), false);
});
});
describe("effectivePermissions", () => {
it("returns combined direct and role-derived permissions", () => {
const user = AuthenticatedUser.create("test@example.com", {
roles: ["user" as Role],
permissions: ["posts:create" as Permission],
});
const perms = user.effectivePermissions();
assert.equal(perms.has("posts:create" as Permission), true);
assert.equal(perms.has("users:read" as Permission), true); // from user role
});
it("returns empty set for user with no roles or permissions", () => {
const user = AuthenticatedUser.create("test@example.com");
const perms = user.effectivePermissions();
assert.equal(perms.size, 0);
});
});
describe("serialization", () => {
it("toJSON returns plain object", () => {
const user = AuthenticatedUser.create("test@example.com", {
id: "test-id",
displayName: "Test",
status: "active",
roles: ["admin"],
permissions: ["posts:create"],
});
const json = user.toJSON();
assert.equal(json.id, "test-id");
assert.equal(json.email, "test@example.com");
assert.equal(json.displayName, "Test");
assert.equal(json.status, "active");
assert.deepEqual(json.roles, ["admin"]);
assert.deepEqual(json.permissions, ["posts:create"]);
});
it("toString returns readable string", () => {
const user = AuthenticatedUser.create("test@example.com", {
id: "test-id",
});
assert.equal(user.toString(), "User(id test-id)");
});
});
describe("AnonymousUser", () => {
it("isAnonymous returns true", () => {
const user = AnonymousUser.create("anon@example.com");
assert.equal(user.isAnonymous(), true);
});
it("anonymousUser singleton is anonymous", () => {
assert.equal(anonymousUser.isAnonymous(), true);
assert.equal(anonymousUser.id, "-1");
assert.equal(anonymousUser.email, "anonymous@example.com");
});
});
});

232
backend/diachron/user.ts Normal file
View File

@@ -0,0 +1,232 @@
// user.ts
//
// User model for authentication and authorization.
//
// Design notes:
// - `id` is the stable internal identifier (UUID when database-backed)
// - `email` is the primary human-facing identifier
// - Roles provide coarse-grained authorization (admin, editor, etc.)
// - Permissions provide fine-grained authorization (posts:create, etc.)
// - Users can have both roles (which grant permissions) and direct permissions
import { z } from "zod";
// Branded type for user IDs to prevent accidental mixing with other strings
export type UserId = string & { readonly __brand: "UserId" };
// User account status
const userStatusParser = z.enum(["active", "suspended", "pending"]);
export type UserStatus = z.infer<typeof userStatusParser>;
// Role - simple string identifier
const roleParser = z.string().min(1);
export type Role = z.infer<typeof roleParser>;
// Permission format: "resource:action" e.g. "posts:create", "users:delete"
const permissionParser = z.string().regex(/^[a-z_]+:[a-z_]+$/, {
message: "Permission must be in format 'resource:action'",
});
export type Permission = z.infer<typeof permissionParser>;
// Core user data schema - this is what gets stored/serialized
const userDataParser = z.object({
id: z.string().min(1),
email: z.email(),
displayName: z.string().optional(),
status: userStatusParser,
roles: z.array(roleParser),
permissions: z.array(permissionParser),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
export type UserData = z.infer<typeof userDataParser>;
// Role-to-permission mappings
// In a real system this might be database-driven or configurable
type RolePermissionMap = Map<Role, Permission[]>;
const defaultRolePermissions: RolePermissionMap = new Map([
["admin", ["users:read", "users:create", "users:update", "users:delete"]],
["user", ["users:read"]],
]);
export abstract class User {
protected readonly data: UserData;
protected rolePermissions: RolePermissionMap;
constructor(data: UserData, rolePermissions?: RolePermissionMap) {
this.data = userDataParser.parse(data);
this.rolePermissions = rolePermissions ?? defaultRolePermissions;
}
// Identity
get id(): UserId {
return this.data.id as UserId;
}
get email(): string {
return this.data.email;
}
get displayName(): string | undefined {
return this.data.displayName;
}
// Status
get status(): UserStatus {
return this.data.status;
}
isActive(): boolean {
return this.data.status === "active";
}
// Roles
get roles(): readonly Role[] {
return this.data.roles;
}
hasRole(role: Role): boolean {
return this.data.roles.includes(role);
}
hasAnyRole(roles: Role[]): boolean {
return roles.some((role) => this.hasRole(role));
}
hasAllRoles(roles: Role[]): boolean {
return roles.every((role) => this.hasRole(role));
}
// Permissions
get permissions(): readonly Permission[] {
return this.data.permissions;
}
// Get all permissions: direct + role-derived
effectivePermissions(): Set<Permission> {
const perms = new Set<Permission>(this.data.permissions);
for (const role of this.data.roles) {
const rolePerms = this.rolePermissions.get(role);
if (rolePerms) {
for (const p of rolePerms) {
perms.add(p);
}
}
}
return perms;
}
// Check if user has a specific permission (direct or via role)
hasPermission(permission: Permission): boolean {
// Check direct permissions first
if (this.data.permissions.includes(permission)) {
return true;
}
// Check role-derived permissions
for (const role of this.data.roles) {
const rolePerms = this.rolePermissions.get(role);
if (rolePerms?.includes(permission)) {
return true;
}
}
return false;
}
// Convenience method: can user perform action on resource?
can(action: string, resource: string): boolean {
const permission = `${resource}:${action}` as Permission;
return this.hasPermission(permission);
}
// Timestamps
get createdAt(): Date {
return this.data.createdAt;
}
get updatedAt(): Date {
return this.data.updatedAt;
}
// Serialization - returns plain object for storage/transmission
toJSON(): UserData {
return { ...this.data };
}
toString(): string {
return `User(id ${this.id})`;
}
abstract isAnonymous(): boolean;
}
export class AuthenticatedUser extends User {
// Factory for creating new users with sensible defaults
static create(
email: string,
options?: {
id?: string;
displayName?: string;
status?: UserStatus;
roles?: Role[];
permissions?: Permission[];
},
): User {
const now = new Date();
return new AuthenticatedUser({
id: options?.id ?? crypto.randomUUID(),
email,
displayName: options?.displayName,
status: options?.status ?? "active",
roles: options?.roles ?? [],
permissions: options?.permissions ?? [],
createdAt: now,
updatedAt: now,
});
}
isAnonymous(): boolean {
return false;
}
}
// For representing "no user" in contexts where user is optional
export class AnonymousUser extends User {
// FIXME: this is C&Ped with only minimal changes. No bueno.
static create(
email: string,
options?: {
id?: string;
displayName?: string;
status?: UserStatus;
roles?: Role[];
permissions?: Permission[];
},
): AnonymousUser {
const now = new Date(0);
return new AnonymousUser({
id: options?.id ?? crypto.randomUUID(),
email,
displayName: options?.displayName,
status: options?.status ?? "active",
roles: options?.roles ?? [],
permissions: options?.permissions ?? [],
createdAt: now,
updatedAt: now,
});
}
isAnonymous(): boolean {
return true;
}
}
export const anonymousUser = AnonymousUser.create("anonymous@example.com", {
id: "-1",
displayName: "Anonymous User",
});

View File

@@ -0,0 +1,61 @@
// Tests for util.ts
// Pure unit tests with filesystem
import assert from "node:assert/strict";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { after, before, describe, it } from "node:test";
import { loadFile } from "./util";
describe("util", () => {
const testDir = join(import.meta.dirname, ".test-util-tmp");
before(async () => {
await mkdir(testDir, { recursive: true });
});
after(async () => {
await rm(testDir, { recursive: true, force: true });
});
describe("loadFile", () => {
it("loads file contents as string", async () => {
const testFile = join(testDir, "test.txt");
await writeFile(testFile, "hello world");
const content = await loadFile(testFile);
assert.equal(content, "hello world");
});
it("handles utf-8 content", async () => {
const testFile = join(testDir, "utf8.txt");
await writeFile(testFile, "hello \u{1F511} world");
const content = await loadFile(testFile);
assert.equal(content, "hello \u{1F511} world");
});
it("handles empty file", async () => {
const testFile = join(testDir, "empty.txt");
await writeFile(testFile, "");
const content = await loadFile(testFile);
assert.equal(content, "");
});
it("handles multiline content", async () => {
const testFile = join(testDir, "multiline.txt");
await writeFile(testFile, "line1\nline2\nline3");
const content = await loadFile(testFile);
assert.equal(content, "line1\nline2\nline3");
});
it("throws for nonexistent file", async () => {
await assert.rejects(
loadFile(join(testDir, "nonexistent.txt")),
/ENOENT/,
);
});
});
});

11
backend/diachron/util.ts Normal file
View File

@@ -0,0 +1,11 @@
import { readFile } from "node:fs/promises";
// FIXME: Handle the error here
const loadFile = async (path: string): Promise<string> => {
// Specifying 'utf8' returns a string; otherwise, it returns a Buffer
const data = await readFile(path, "utf8");
return data;
};
export { loadFile };