Compare commits
10 Commits
e136c07928
...
241d3e799e
| Author | SHA1 | Date | |
|---|---|---|---|
| 241d3e799e | |||
| 49dc0e3fe0 | |||
| c7b8cd33da | |||
| 6c0895de07 | |||
| 17ea6ba02d | |||
| 661def8a5c | |||
| 74d75d08dd | |||
| ad6d405206 | |||
| e9ccf6d757 | |||
| 34ec5be7ec |
@@ -3,6 +3,7 @@ import express, {
|
|||||||
type Response as ExpressResponse,
|
type Response as ExpressResponse,
|
||||||
} from "express";
|
} from "express";
|
||||||
import { match } from "path-to-regexp";
|
import { match } from "path-to-regexp";
|
||||||
|
import { Session } from "./auth";
|
||||||
import { cli } from "./cli";
|
import { cli } from "./cli";
|
||||||
import { contentTypes } from "./content-types";
|
import { contentTypes } from "./content-types";
|
||||||
import { httpCodes } from "./http-codes";
|
import { httpCodes } from "./http-codes";
|
||||||
@@ -36,7 +37,7 @@ const processedRoutes: { [K in Method]: ProcessedRoute[] } = {
|
|||||||
DELETE: [],
|
DELETE: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
|
function _isPromise<T>(value: T | Promise<T>): value is Promise<T> {
|
||||||
return typeof (value as any)?.then === "function";
|
return typeof (value as any)?.then === "function";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => {
|
|||||||
console.log("request.originalUrl", request.originalUrl);
|
console.log("request.originalUrl", request.originalUrl);
|
||||||
|
|
||||||
// Authenticate the request
|
// Authenticate the request
|
||||||
const user = await services.auth.validateRequest(request);
|
const auth = await services.auth.validateRequest(request);
|
||||||
|
|
||||||
const req: Call = {
|
const req: Call = {
|
||||||
pattern: route.path,
|
pattern: route.path,
|
||||||
@@ -67,7 +68,8 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => {
|
|||||||
method,
|
method,
|
||||||
parameters: { one: 1, two: 2 },
|
parameters: { one: 1, two: 2 },
|
||||||
request,
|
request,
|
||||||
user,
|
user: auth.user,
|
||||||
|
session: new Session(auth.session, auth.user),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,7 +7,14 @@
|
|||||||
// Import authRoutes directly from "./auth/routes" instead.
|
// Import authRoutes directly from "./auth/routes" instead.
|
||||||
|
|
||||||
export { hashPassword, verifyPassword } from "./password";
|
export { hashPassword, verifyPassword } from "./password";
|
||||||
export { AuthService } from "./service";
|
export { type AuthResult, AuthService } from "./service";
|
||||||
export { type AuthStore, InMemoryAuthStore } from "./store";
|
export { type AuthStore, InMemoryAuthStore } from "./store";
|
||||||
export { generateToken, hashToken, SESSION_COOKIE_NAME } from "./token";
|
export { generateToken, hashToken, SESSION_COOKIE_NAME } from "./token";
|
||||||
export * from "./types";
|
export {
|
||||||
|
type AuthMethod,
|
||||||
|
Session,
|
||||||
|
type SessionData,
|
||||||
|
type TokenId,
|
||||||
|
type TokenType,
|
||||||
|
tokenLifetimes,
|
||||||
|
} from "./types";
|
||||||
|
|||||||
@@ -28,8 +28,11 @@ function scryptAsync(
|
|||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
scrypt(password, salt, keylen, options, (err, derivedKey) => {
|
scrypt(password, salt, keylen, options, (err, derivedKey) => {
|
||||||
if (err) reject(err);
|
if (err) {
|
||||||
else resolve(derivedKey);
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(derivedKey);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// password reset, and email verification.
|
// password reset, and email verification.
|
||||||
|
|
||||||
import type { Request as ExpressRequest } from "express";
|
import type { Request as ExpressRequest } from "express";
|
||||||
import { AnonymousUser, type MaybeUser, type User, type UserId } from "../user";
|
import { AnonymousUser, type User, type UserId } from "../user";
|
||||||
import { hashPassword, verifyPassword } from "./password";
|
import { hashPassword, verifyPassword } from "./password";
|
||||||
import type { AuthStore } from "./store";
|
import type { AuthStore } from "./store";
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
parseAuthorizationHeader,
|
parseAuthorizationHeader,
|
||||||
SESSION_COOKIE_NAME,
|
SESSION_COOKIE_NAME,
|
||||||
} from "./token";
|
} from "./token";
|
||||||
import { type TokenId, tokenLifetimes } from "./types";
|
import { type SessionData, type TokenId, tokenLifetimes } from "./types";
|
||||||
|
|
||||||
type LoginResult =
|
type LoginResult =
|
||||||
| { success: true; token: string; user: User }
|
| { success: true; token: string; user: User }
|
||||||
@@ -24,6 +24,11 @@ type RegisterResult =
|
|||||||
|
|
||||||
type SimpleResult = { success: true } | { success: false; error: string };
|
type SimpleResult = { success: true } | { success: false; error: string };
|
||||||
|
|
||||||
|
// Result of validating a request/token - contains both user and session
|
||||||
|
export type AuthResult =
|
||||||
|
| { authenticated: true; user: User; session: SessionData }
|
||||||
|
| { authenticated: false; user: typeof AnonymousUser; session: null };
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(private store: AuthStore) {}
|
constructor(private store: AuthStore) {}
|
||||||
|
|
||||||
@@ -68,7 +73,7 @@ export class AuthService {
|
|||||||
|
|
||||||
// === Session Validation ===
|
// === Session Validation ===
|
||||||
|
|
||||||
async validateRequest(request: ExpressRequest): Promise<MaybeUser> {
|
async validateRequest(request: ExpressRequest): Promise<AuthResult> {
|
||||||
// Try cookie first (for web requests)
|
// Try cookie first (for web requests)
|
||||||
let token = this.extractCookieToken(request);
|
let token = this.extractCookieToken(request);
|
||||||
|
|
||||||
@@ -78,38 +83,40 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return AnonymousUser;
|
return { authenticated: false, user: AnonymousUser, session: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.validateToken(token);
|
return this.validateToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateToken(token: string): Promise<MaybeUser> {
|
async validateToken(token: string): Promise<AuthResult> {
|
||||||
const tokenId = hashToken(token) as TokenId;
|
const tokenId = hashToken(token) as TokenId;
|
||||||
const session = await this.store.getSession(tokenId);
|
const session = await this.store.getSession(tokenId);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return AnonymousUser;
|
return { authenticated: false, user: AnonymousUser, session: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.tokenType !== "session") {
|
if (session.tokenType !== "session") {
|
||||||
return AnonymousUser;
|
return { authenticated: false, user: AnonymousUser, session: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.store.getUserById(session.userId as UserId);
|
const user = await this.store.getUserById(session.userId as UserId);
|
||||||
if (!user || !user.isActive()) {
|
if (!user || !user.isActive()) {
|
||||||
return AnonymousUser;
|
return { authenticated: false, user: AnonymousUser, session: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last used (fire and forget)
|
// Update last used (fire and forget)
|
||||||
this.store.updateLastUsed(tokenId).catch(() => {});
|
this.store.updateLastUsed(tokenId).catch(() => {});
|
||||||
|
|
||||||
return user;
|
return { authenticated: true, user, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractCookieToken(request: ExpressRequest): string | null {
|
private extractCookieToken(request: ExpressRequest): string | null {
|
||||||
const cookies = request.get("Cookie");
|
const cookies = request.get("Cookie");
|
||||||
if (!cookies) return null;
|
if (!cookies) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
for (const cookie of cookies.split(";")) {
|
for (const cookie of cookies.split(";")) {
|
||||||
const [name, ...valueParts] = cookie.trim().split("=");
|
const [name, ...valueParts] = cookie.trim().split("=");
|
||||||
@@ -240,7 +247,9 @@ export class AuthService {
|
|||||||
extractToken(request: ExpressRequest): string | null {
|
extractToken(request: ExpressRequest): string | null {
|
||||||
// Try Authorization header first
|
// Try Authorization header first
|
||||||
const token = parseAuthorizationHeader(request.get("Authorization"));
|
const token = parseAuthorizationHeader(request.get("Authorization"));
|
||||||
if (token) return token;
|
if (token) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
// Try cookie
|
// Try cookie
|
||||||
return this.extractCookieToken(request);
|
return this.extractCookieToken(request);
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ export class InMemoryAuthStore implements AuthStore {
|
|||||||
|
|
||||||
async getSession(tokenId: TokenId): Promise<SessionData | null> {
|
async getSession(tokenId: TokenId): Promise<SessionData | null> {
|
||||||
const session = this.sessions.get(tokenId);
|
const session = this.sessions.get(tokenId);
|
||||||
if (!session) return null;
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Check expiration
|
// Check expiration
|
||||||
if (new Date() > session.expiresAt) {
|
if (new Date() > session.expiresAt) {
|
||||||
@@ -110,7 +112,9 @@ export class InMemoryAuthStore implements AuthStore {
|
|||||||
|
|
||||||
async getUserByEmail(email: string): Promise<User | null> {
|
async getUserByEmail(email: string): Promise<User | null> {
|
||||||
const userId = this.usersByEmail.get(email.toLowerCase());
|
const userId = this.usersByEmail.get(email.toLowerCase());
|
||||||
if (!userId) return null;
|
if (!userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return this.users.get(userId) ?? null;
|
return this.users.get(userId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ function hashToken(token: string): string {
|
|||||||
|
|
||||||
// Parse token from Authorization header
|
// Parse token from Authorization header
|
||||||
function parseAuthorizationHeader(header: string | undefined): string | null {
|
function parseAuthorizationHeader(header: string | undefined): string | null {
|
||||||
if (!header) return null;
|
if (!header) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const parts = header.split(" ");
|
const parts = header.split(" ");
|
||||||
if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") {
|
if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") {
|
||||||
|
|||||||
@@ -62,3 +62,35 @@ export const tokenLifetimes: Record<TokenType, number> = {
|
|||||||
password_reset: 1 * 60 * 60 * 1000, // 1 hour
|
password_reset: 1 * 60 * 60 * 1000, // 1 hour
|
||||||
email_verify: 24 * 60 * 60 * 1000, // 24 hours
|
email_verify: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Import here to avoid circular dependency at module load time
|
||||||
|
import { AnonymousUser, type MaybeUser } from "../user";
|
||||||
|
|
||||||
|
// Session wrapper class providing a consistent interface for handlers.
|
||||||
|
// Always present on Call (never null), but may represent an anonymous session.
|
||||||
|
export class Session {
|
||||||
|
constructor(
|
||||||
|
private readonly data: SessionData | null,
|
||||||
|
private readonly user: MaybeUser,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getUser(): MaybeUser {
|
||||||
|
return this.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
getData(): SessionData | null {
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
return this.user !== AnonymousUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
get tokenId(): string | undefined {
|
||||||
|
return this.data?.tokenId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get userId(): string | undefined {
|
||||||
|
return this.data?.userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,10 @@
|
|||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true
|
"recommended": true,
|
||||||
|
"style": {
|
||||||
|
"useBlockStatements": "error"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function parseListenAddress(listen: string | undefined): {
|
|||||||
if (lastColon === -1) {
|
if (lastColon === -1) {
|
||||||
// Just a port number
|
// Just a port number
|
||||||
const port = parseInt(listen, 10);
|
const port = parseInt(listen, 10);
|
||||||
if (isNaN(port)) {
|
if (Number.isNaN(port)) {
|
||||||
throw new Error(`Invalid listen address: ${listen}`);
|
throw new Error(`Invalid listen address: ${listen}`);
|
||||||
}
|
}
|
||||||
return { host: defaultHost, port };
|
return { host: defaultHost, port };
|
||||||
@@ -39,7 +39,7 @@ function parseListenAddress(listen: string | undefined): {
|
|||||||
const host = listen.slice(0, lastColon);
|
const host = listen.slice(0, lastColon);
|
||||||
const port = parseInt(listen.slice(lastColon + 1), 10);
|
const port = parseInt(listen.slice(lastColon + 1), 10);
|
||||||
|
|
||||||
if (isNaN(port)) {
|
if (Number.isNaN(port)) {
|
||||||
throw new Error(`Invalid port in listen address: ${listen}`);
|
throw new Error(`Invalid port in listen address: ${listen}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Extensible } from "./interfaces";
|
|
||||||
|
|
||||||
export type ContentType = string;
|
export type ContentType = string;
|
||||||
|
|
||||||
// tx claude https://claude.ai/share/344fc7bd-5321-4763-af2f-b82275e9f865
|
// tx claude https://claude.ai/share/344fc7bd-5321-4763-af2f-b82275e9f865
|
||||||
|
|||||||
397
express/database.ts
Normal file
397
express/database.ts
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
// database.ts
|
||||||
|
// PostgreSQL database access with Kysely query builder and simple migrations
|
||||||
|
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import {
|
||||||
|
type Generated,
|
||||||
|
Kysely,
|
||||||
|
PostgresDialect,
|
||||||
|
type Selectable,
|
||||||
|
sql,
|
||||||
|
} from "kysely";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import type {
|
||||||
|
AuthStore,
|
||||||
|
CreateSessionData,
|
||||||
|
CreateUserData,
|
||||||
|
} from "./auth/store";
|
||||||
|
import { generateToken, hashToken } from "./auth/token";
|
||||||
|
import type { SessionData, TokenId } from "./auth/types";
|
||||||
|
import { User, type UserId } from "./user";
|
||||||
|
|
||||||
|
// Connection configuration
|
||||||
|
const connectionConfig = {
|
||||||
|
host: "localhost",
|
||||||
|
port: 5432,
|
||||||
|
user: "diachron",
|
||||||
|
password: "diachron",
|
||||||
|
database: "diachron",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Database schema types for Kysely
|
||||||
|
// Generated<T> marks columns with database defaults (optional on insert)
|
||||||
|
interface UsersTable {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
password_hash: string;
|
||||||
|
display_name: string | null;
|
||||||
|
status: Generated<string>;
|
||||||
|
roles: Generated<string[]>;
|
||||||
|
permissions: Generated<string[]>;
|
||||||
|
email_verified: Generated<boolean>;
|
||||||
|
created_at: Generated<Date>;
|
||||||
|
updated_at: Generated<Date>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionsTable {
|
||||||
|
token_id: string;
|
||||||
|
user_id: string;
|
||||||
|
token_type: string;
|
||||||
|
auth_method: string;
|
||||||
|
created_at: Generated<Date>;
|
||||||
|
expires_at: Date;
|
||||||
|
last_used_at: Date | null;
|
||||||
|
user_agent: string | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
is_used: Generated<boolean | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Database {
|
||||||
|
users: UsersTable;
|
||||||
|
sessions: SessionsTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the connection pool
|
||||||
|
const pool = new Pool(connectionConfig);
|
||||||
|
|
||||||
|
// Create the Kysely instance
|
||||||
|
const db = new Kysely<Database>({
|
||||||
|
dialect: new PostgresDialect({ pool }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Raw pool access for when you need it
|
||||||
|
const rawPool = pool;
|
||||||
|
|
||||||
|
// Execute raw SQL (for when Kysely doesn't fit)
|
||||||
|
async function raw<T = unknown>(
|
||||||
|
query: string,
|
||||||
|
params: unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
return result.rows as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Migrations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Migration file naming convention:
|
||||||
|
// NNNN_description.sql
|
||||||
|
// e.g., 0001_initial.sql, 0002_add_users.sql
|
||||||
|
//
|
||||||
|
// Migrations directory: express/migrations/
|
||||||
|
|
||||||
|
const MIGRATIONS_DIR = path.join(__dirname, "migrations");
|
||||||
|
const MIGRATIONS_TABLE = "_migrations";
|
||||||
|
|
||||||
|
interface MigrationRecord {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
applied_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure migrations table exists
|
||||||
|
async function ensureMigrationsTable(): Promise<void> {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of applied migrations
|
||||||
|
async function getAppliedMigrations(): Promise<string[]> {
|
||||||
|
const result = await pool.query<MigrationRecord>(
|
||||||
|
`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY name`,
|
||||||
|
);
|
||||||
|
return result.rows.map((r) => r.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pending migration files
|
||||||
|
function getMigrationFiles(): string[] {
|
||||||
|
if (!fs.existsSync(MIGRATIONS_DIR)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return fs
|
||||||
|
.readdirSync(MIGRATIONS_DIR)
|
||||||
|
.filter((f) => f.endsWith(".sql"))
|
||||||
|
.filter((f) => /^\d{4}_/.test(f))
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a single migration
|
||||||
|
async function runMigration(filename: string): Promise<void> {
|
||||||
|
const filepath = path.join(MIGRATIONS_DIR, filename);
|
||||||
|
const content = fs.readFileSync(filepath, "utf-8");
|
||||||
|
|
||||||
|
// Run migration in a transaction
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
await client.query(content);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES ($1)`,
|
||||||
|
[filename],
|
||||||
|
);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
console.log(`Applied migration: ${filename}`);
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all pending migrations
|
||||||
|
async function migrate(): Promise<void> {
|
||||||
|
await ensureMigrationsTable();
|
||||||
|
|
||||||
|
const applied = new Set(await getAppliedMigrations());
|
||||||
|
const files = getMigrationFiles();
|
||||||
|
const pending = files.filter((f) => !applied.has(f));
|
||||||
|
|
||||||
|
if (pending.length === 0) {
|
||||||
|
console.log("No pending migrations");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Running ${pending.length} migration(s)...`);
|
||||||
|
for (const file of pending) {
|
||||||
|
await runMigration(file);
|
||||||
|
}
|
||||||
|
console.log("Migrations complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
// List migration status
|
||||||
|
async function migrationStatus(): Promise<{
|
||||||
|
applied: string[];
|
||||||
|
pending: string[];
|
||||||
|
}> {
|
||||||
|
await ensureMigrationsTable();
|
||||||
|
const applied = new Set(await getAppliedMigrations());
|
||||||
|
const files = getMigrationFiles();
|
||||||
|
return {
|
||||||
|
applied: files.filter((f) => applied.has(f)),
|
||||||
|
pending: files.filter((f) => !applied.has(f)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PostgresAuthStore - Database-backed authentication storage
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class PostgresAuthStore implements AuthStore {
|
||||||
|
// Session operations
|
||||||
|
|
||||||
|
async createSession(
|
||||||
|
data: CreateSessionData,
|
||||||
|
): Promise<{ token: string; session: SessionData }> {
|
||||||
|
const token = generateToken();
|
||||||
|
const tokenId = hashToken(token);
|
||||||
|
|
||||||
|
const row = await db
|
||||||
|
.insertInto("sessions")
|
||||||
|
.values({
|
||||||
|
token_id: tokenId,
|
||||||
|
user_id: data.userId,
|
||||||
|
token_type: data.tokenType,
|
||||||
|
auth_method: data.authMethod,
|
||||||
|
expires_at: data.expiresAt,
|
||||||
|
user_agent: data.userAgent ?? null,
|
||||||
|
ip_address: data.ipAddress ?? null,
|
||||||
|
})
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
const session: SessionData = {
|
||||||
|
tokenId: row.token_id,
|
||||||
|
userId: row.user_id,
|
||||||
|
tokenType: row.token_type as SessionData["tokenType"],
|
||||||
|
authMethod: row.auth_method as SessionData["authMethod"],
|
||||||
|
createdAt: row.created_at,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
lastUsedAt: row.last_used_at ?? undefined,
|
||||||
|
userAgent: row.user_agent ?? undefined,
|
||||||
|
ipAddress: row.ip_address ?? undefined,
|
||||||
|
isUsed: row.is_used ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { token, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(tokenId: TokenId): Promise<SessionData | null> {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom("sessions")
|
||||||
|
.selectAll()
|
||||||
|
.where("token_id", "=", tokenId)
|
||||||
|
.where("expires_at", ">", new Date())
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokenId: row.token_id,
|
||||||
|
userId: row.user_id,
|
||||||
|
tokenType: row.token_type as SessionData["tokenType"],
|
||||||
|
authMethod: row.auth_method as SessionData["authMethod"],
|
||||||
|
createdAt: row.created_at,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
lastUsedAt: row.last_used_at ?? undefined,
|
||||||
|
userAgent: row.user_agent ?? undefined,
|
||||||
|
ipAddress: row.ip_address ?? undefined,
|
||||||
|
isUsed: row.is_used ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLastUsed(tokenId: TokenId): Promise<void> {
|
||||||
|
await db
|
||||||
|
.updateTable("sessions")
|
||||||
|
.set({ last_used_at: new Date() })
|
||||||
|
.where("token_id", "=", tokenId)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSession(tokenId: TokenId): Promise<void> {
|
||||||
|
await db
|
||||||
|
.deleteFrom("sessions")
|
||||||
|
.where("token_id", "=", tokenId)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUserSessions(userId: UserId): Promise<number> {
|
||||||
|
const result = await db
|
||||||
|
.deleteFrom("sessions")
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return Number(result.numDeletedRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User operations
|
||||||
|
|
||||||
|
async getUserByEmail(email: string): Promise<User | null> {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom("users")
|
||||||
|
.selectAll()
|
||||||
|
.where(sql`LOWER(email)`, "=", email.toLowerCase())
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.rowToUser(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserById(userId: UserId): Promise<User | null> {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom("users")
|
||||||
|
.selectAll()
|
||||||
|
.where("id", "=", userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.rowToUser(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(data: CreateUserData): Promise<User> {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const row = await db
|
||||||
|
.insertInto("users")
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
email: data.email,
|
||||||
|
password_hash: data.passwordHash,
|
||||||
|
display_name: data.displayName ?? null,
|
||||||
|
status: "pending",
|
||||||
|
roles: [],
|
||||||
|
permissions: [],
|
||||||
|
email_verified: false,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
return this.rowToUser(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserPasswordHash(userId: UserId): Promise<string | null> {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom("users")
|
||||||
|
.select("password_hash")
|
||||||
|
.where("id", "=", userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return row?.password_hash ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setUserPassword(userId: UserId, passwordHash: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.updateTable("users")
|
||||||
|
.set({ password_hash: passwordHash, updated_at: new Date() })
|
||||||
|
.where("id", "=", userId)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserEmailVerified(userId: UserId): Promise<void> {
|
||||||
|
await db
|
||||||
|
.updateTable("users")
|
||||||
|
.set({
|
||||||
|
email_verified: true,
|
||||||
|
status: "active",
|
||||||
|
updated_at: new Date(),
|
||||||
|
})
|
||||||
|
.where("id", "=", userId)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert database row to User object
|
||||||
|
private rowToUser(row: Selectable<UsersTable>): User {
|
||||||
|
return new User({
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
displayName: row.display_name ?? undefined,
|
||||||
|
status: row.status as "active" | "suspended" | "pending",
|
||||||
|
roles: row.roles,
|
||||||
|
permissions: row.permissions,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Exports
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
db,
|
||||||
|
raw,
|
||||||
|
rawPool,
|
||||||
|
pool,
|
||||||
|
migrate,
|
||||||
|
migrationStatus,
|
||||||
|
connectionConfig,
|
||||||
|
PostgresAuthStore,
|
||||||
|
type Database,
|
||||||
|
};
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Extensible } from "./interfaces";
|
|
||||||
|
|
||||||
export type HttpCode = {
|
export type HttpCode = {
|
||||||
code: number;
|
code: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ type Message = {
|
|||||||
text: AtLeastOne<string>;
|
text: AtLeastOne<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const m1: Message = { timestamp: 123, source: "logging", text: ["foo"] };
|
const _m1: Message = { timestamp: 123, source: "logging", text: ["foo"] };
|
||||||
const m2: Message = {
|
const _m2: Message = {
|
||||||
timestamp: 321,
|
timestamp: 321,
|
||||||
source: "diagnostic",
|
source: "diagnostic",
|
||||||
text: ["ok", "whatever"],
|
text: ["ok", "whatever"],
|
||||||
|
|||||||
21
express/migrations/0001_users.sql
Normal file
21
express/migrations/0001_users.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- 0001_users.sql
|
||||||
|
-- Create users table for authentication
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
roles TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
permissions TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for email lookups (login)
|
||||||
|
CREATE INDEX users_email_idx ON users (LOWER(email));
|
||||||
|
|
||||||
|
-- Index for status filtering
|
||||||
|
CREATE INDEX users_status_idx ON users (status);
|
||||||
24
express/migrations/0002_sessions.sql
Normal file
24
express/migrations/0002_sessions.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- 0002_sessions.sql
|
||||||
|
-- Create sessions table for auth tokens
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
token_id TEXT PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_type TEXT NOT NULL,
|
||||||
|
auth_method TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
is_used BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for user session lookups (logout all, etc.)
|
||||||
|
CREATE INDEX sessions_user_id_idx ON sessions (user_id);
|
||||||
|
|
||||||
|
-- Index for expiration cleanup
|
||||||
|
CREATE INDEX sessions_expires_at_idx ON sessions (expires_at);
|
||||||
|
|
||||||
|
-- Index for token type filtering
|
||||||
|
CREATE INDEX sessions_token_type_idx ON sessions (token_type);
|
||||||
@@ -18,9 +18,11 @@
|
|||||||
"@types/nunjucks": "^3.2.6",
|
"@types/nunjucks": "^3.2.6",
|
||||||
"@vercel/ncc": "^0.38.4",
|
"@vercel/ncc": "^0.38.4",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"kysely": "^0.28.9",
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.11",
|
||||||
"nunjucks": "^3.2.4",
|
"nunjucks": "^3.2.4",
|
||||||
"path-to-regexp": "^8.3.0",
|
"path-to-regexp": "^8.3.0",
|
||||||
|
"pg": "^8.16.3",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"ts-luxon": "^6.2.0",
|
"ts-luxon": "^6.2.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
@@ -46,6 +48,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.3.10",
|
"@biomejs/biome": "2.3.10",
|
||||||
"@types/express": "^5.0.5"
|
"@types/express": "^5.0.5",
|
||||||
|
"@types/pg": "^8.16.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
131
express/pnpm-lock.yaml
generated
131
express/pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
|||||||
express:
|
express:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.1.0
|
version: 5.1.0
|
||||||
|
kysely:
|
||||||
|
specifier: ^0.28.9
|
||||||
|
version: 0.28.9
|
||||||
nodemon:
|
nodemon:
|
||||||
specifier: ^3.1.11
|
specifier: ^3.1.11
|
||||||
version: 3.1.11
|
version: 3.1.11
|
||||||
@@ -32,6 +35,9 @@ importers:
|
|||||||
path-to-regexp:
|
path-to-regexp:
|
||||||
specifier: ^8.3.0
|
specifier: ^8.3.0
|
||||||
version: 8.3.0
|
version: 8.3.0
|
||||||
|
pg:
|
||||||
|
specifier: ^8.16.3
|
||||||
|
version: 8.16.3
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.6.2
|
specifier: ^3.6.2
|
||||||
version: 3.6.2
|
version: 3.6.2
|
||||||
@@ -57,6 +63,9 @@ importers:
|
|||||||
'@types/express':
|
'@types/express':
|
||||||
specifier: ^5.0.5
|
specifier: ^5.0.5
|
||||||
version: 5.0.5
|
version: 5.0.5
|
||||||
|
'@types/pg':
|
||||||
|
specifier: ^8.16.0
|
||||||
|
version: 8.16.0
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -380,6 +389,9 @@ packages:
|
|||||||
'@types/nunjucks@3.2.6':
|
'@types/nunjucks@3.2.6':
|
||||||
resolution: {integrity: sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==}
|
resolution: {integrity: sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==}
|
||||||
|
|
||||||
|
'@types/pg@8.16.0':
|
||||||
|
resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==}
|
||||||
|
|
||||||
'@types/qs@6.14.0':
|
'@types/qs@6.14.0':
|
||||||
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
|
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
|
||||||
|
|
||||||
@@ -645,6 +657,10 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
kysely@0.28.9:
|
||||||
|
resolution: {integrity: sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
make-error@1.3.6:
|
make-error@1.3.6:
|
||||||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||||
|
|
||||||
@@ -715,6 +731,40 @@ packages:
|
|||||||
path-to-regexp@8.3.0:
|
path-to-regexp@8.3.0:
|
||||||
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
|
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
|
||||||
|
|
||||||
|
pg-cloudflare@1.2.7:
|
||||||
|
resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==}
|
||||||
|
|
||||||
|
pg-connection-string@2.9.1:
|
||||||
|
resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==}
|
||||||
|
|
||||||
|
pg-int8@1.0.1:
|
||||||
|
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||||
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
|
pg-pool@3.10.1:
|
||||||
|
resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==}
|
||||||
|
peerDependencies:
|
||||||
|
pg: '>=8.0'
|
||||||
|
|
||||||
|
pg-protocol@1.10.3:
|
||||||
|
resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==}
|
||||||
|
|
||||||
|
pg-types@2.2.0:
|
||||||
|
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
pg@8.16.3:
|
||||||
|
resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==}
|
||||||
|
engines: {node: '>= 16.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
pg-native: '>=3.0.1'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
pg-native:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
pgpass@1.0.5:
|
||||||
|
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -722,6 +772,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
|
postgres-array@2.0.0:
|
||||||
|
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
postgres-bytea@1.0.1:
|
||||||
|
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
postgres-date@1.0.7:
|
||||||
|
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
postgres-interval@1.2.0:
|
||||||
|
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
prettier@3.6.2:
|
prettier@3.6.2:
|
||||||
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
|
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -799,6 +865,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
|
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
split2@4.2.0:
|
||||||
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
statuses@2.0.1:
|
statuses@2.0.1:
|
||||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -875,6 +945,10 @@ packages:
|
|||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
|
xtend@4.0.2:
|
||||||
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
yn@3.1.1:
|
yn@3.1.1:
|
||||||
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
|
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1118,6 +1192,12 @@ snapshots:
|
|||||||
|
|
||||||
'@types/nunjucks@3.2.6': {}
|
'@types/nunjucks@3.2.6': {}
|
||||||
|
|
||||||
|
'@types/pg@8.16.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.10.1
|
||||||
|
pg-protocol: 1.10.3
|
||||||
|
pg-types: 2.2.0
|
||||||
|
|
||||||
'@types/qs@6.14.0': {}
|
'@types/qs@6.14.0': {}
|
||||||
|
|
||||||
'@types/range-parser@1.2.7': {}
|
'@types/range-parser@1.2.7': {}
|
||||||
@@ -1421,6 +1501,8 @@ snapshots:
|
|||||||
|
|
||||||
jsesc@3.1.0: {}
|
jsesc@3.1.0: {}
|
||||||
|
|
||||||
|
kysely@0.28.9: {}
|
||||||
|
|
||||||
make-error@1.3.6: {}
|
make-error@1.3.6: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
@@ -1480,10 +1562,55 @@ snapshots:
|
|||||||
|
|
||||||
path-to-regexp@8.3.0: {}
|
path-to-regexp@8.3.0: {}
|
||||||
|
|
||||||
|
pg-cloudflare@1.2.7:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
pg-connection-string@2.9.1: {}
|
||||||
|
|
||||||
|
pg-int8@1.0.1: {}
|
||||||
|
|
||||||
|
pg-pool@3.10.1(pg@8.16.3):
|
||||||
|
dependencies:
|
||||||
|
pg: 8.16.3
|
||||||
|
|
||||||
|
pg-protocol@1.10.3: {}
|
||||||
|
|
||||||
|
pg-types@2.2.0:
|
||||||
|
dependencies:
|
||||||
|
pg-int8: 1.0.1
|
||||||
|
postgres-array: 2.0.0
|
||||||
|
postgres-bytea: 1.0.1
|
||||||
|
postgres-date: 1.0.7
|
||||||
|
postgres-interval: 1.2.0
|
||||||
|
|
||||||
|
pg@8.16.3:
|
||||||
|
dependencies:
|
||||||
|
pg-connection-string: 2.9.1
|
||||||
|
pg-pool: 3.10.1(pg@8.16.3)
|
||||||
|
pg-protocol: 1.10.3
|
||||||
|
pg-types: 2.2.0
|
||||||
|
pgpass: 1.0.5
|
||||||
|
optionalDependencies:
|
||||||
|
pg-cloudflare: 1.2.7
|
||||||
|
|
||||||
|
pgpass@1.0.5:
|
||||||
|
dependencies:
|
||||||
|
split2: 4.2.0
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.1: {}
|
||||||
|
|
||||||
|
postgres-array@2.0.0: {}
|
||||||
|
|
||||||
|
postgres-bytea@1.0.1: {}
|
||||||
|
|
||||||
|
postgres-date@1.0.7: {}
|
||||||
|
|
||||||
|
postgres-interval@1.2.0:
|
||||||
|
dependencies:
|
||||||
|
xtend: 4.0.2
|
||||||
|
|
||||||
prettier@3.6.2: {}
|
prettier@3.6.2: {}
|
||||||
|
|
||||||
proxy-addr@2.0.7:
|
proxy-addr@2.0.7:
|
||||||
@@ -1587,6 +1714,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.7.3
|
semver: 7.7.3
|
||||||
|
|
||||||
|
split2@4.2.0: {}
|
||||||
|
|
||||||
statuses@2.0.1: {}
|
statuses@2.0.1: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
@@ -1650,6 +1779,8 @@ snapshots:
|
|||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
yn@3.1.1: {}
|
yn@3.1.1: {}
|
||||||
|
|
||||||
zod@4.1.12: {}
|
zod@4.1.12: {}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { DateTime } from "ts-luxon";
|
|||||||
import { authRoutes } from "./auth/routes";
|
import { authRoutes } from "./auth/routes";
|
||||||
import { contentTypes } from "./content-types";
|
import { contentTypes } from "./content-types";
|
||||||
import { multiHandler } from "./handlers";
|
import { multiHandler } from "./handlers";
|
||||||
import { HttpCode, httpCodes } from "./http-codes";
|
import { httpCodes } from "./http-codes";
|
||||||
import { services } from "./services";
|
import { services } from "./services";
|
||||||
import { type Call, ProcessedRoute, type Result, type Route } from "./types";
|
import type { Call, Result, Route } from "./types";
|
||||||
|
|
||||||
// FIXME: Obviously put this somewhere else
|
// FIXME: Obviously put this somewhere else
|
||||||
const okText = (result: string): Result => {
|
const okText = (result: string): Result => {
|
||||||
@@ -41,7 +41,7 @@ const routes: Route[] = [
|
|||||||
{
|
{
|
||||||
path: "/list",
|
path: "/list",
|
||||||
methods: ["GET"],
|
methods: ["GET"],
|
||||||
handler: async (call: Call): Promise<Result> => {
|
handler: async (_call: Call): Promise<Result> => {
|
||||||
const code = httpCodes.success.OK;
|
const code = httpCodes.success.OK;
|
||||||
const lr = (rr: Route[]) => {
|
const lr = (rr: Route[]) => {
|
||||||
const ret = rr.map((r: Route) => {
|
const ret = rr.map((r: Route) => {
|
||||||
@@ -51,19 +51,35 @@ const routes: Route[] = [
|
|||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
const listing = lr(routes).join(", ");
|
const rrr = lr(routes);
|
||||||
|
|
||||||
|
const template = `
|
||||||
|
<html>
|
||||||
|
<head></head>
|
||||||
|
<body>
|
||||||
|
<ul>
|
||||||
|
{% for route in rrr %}
|
||||||
|
<li><a href="{{ route }}">{{ route }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
const result = nunjucks.renderString(template, { rrr });
|
||||||
|
|
||||||
|
const _listing = lr(routes).join(", ");
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
result: listing + "\n",
|
result,
|
||||||
contentType: contentTypes.text.plain,
|
contentType: contentTypes.text.html,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/whoami",
|
path: "/whoami",
|
||||||
methods: ["GET"],
|
methods: ["GET"],
|
||||||
handler: async (_call: Call): Promise<Result> => {
|
handler: async (call: Call): Promise<Result> => {
|
||||||
const me = services.session.getUser();
|
const me = call.session.getUser();
|
||||||
const template = `
|
const template = `
|
||||||
<html>
|
<html>
|
||||||
<head></head>
|
<head></head>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
// services.ts
|
// services.ts
|
||||||
|
|
||||||
import { AuthService, InMemoryAuthStore } from "../auth";
|
import { AuthService } from "../auth";
|
||||||
import { config } from "../config";
|
import { db, migrate, migrationStatus, PostgresAuthStore } from "../database";
|
||||||
import { getLogs, log } from "../logging";
|
import { getLogs, log } from "../logging";
|
||||||
import { AnonymousUser, anonymousUser, type User } from "../user";
|
import { anonymousUser, type User } from "../user";
|
||||||
|
|
||||||
//const database = Client({
|
const database = {
|
||||||
|
db,
|
||||||
//})
|
migrate,
|
||||||
|
migrationStatus,
|
||||||
const database = {};
|
};
|
||||||
|
|
||||||
const logging = {
|
const logging = {
|
||||||
log,
|
log,
|
||||||
@@ -34,8 +34,8 @@ const session = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize auth with in-memory store
|
// Initialize auth with PostgreSQL store
|
||||||
const authStore = new InMemoryAuthStore();
|
const authStore = new PostgresAuthStore();
|
||||||
const auth = new AuthService(authStore);
|
const auth = new AuthService(authStore);
|
||||||
|
|
||||||
// Keep this asciibetically sorted
|
// Keep this asciibetically sorted
|
||||||
|
|||||||
@@ -2,14 +2,12 @@
|
|||||||
|
|
||||||
// FIXME: split this up into types used by app developers and types internal
|
// FIXME: split this up into types used by app developers and types internal
|
||||||
// to the framework.
|
// to the framework.
|
||||||
import {
|
import type { Request as ExpressRequest } from "express";
|
||||||
type Request as ExpressRequest,
|
|
||||||
Response as ExpressResponse,
|
|
||||||
} from "express";
|
|
||||||
import type { MatchFunction } from "path-to-regexp";
|
import type { MatchFunction } from "path-to-regexp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { type ContentType, contentTypes } from "./content-types";
|
import type { Session } from "./auth/types";
|
||||||
import { type HttpCode, httpCodes } from "./http-codes";
|
import type { ContentType } from "./content-types";
|
||||||
|
import type { HttpCode } from "./http-codes";
|
||||||
import {
|
import {
|
||||||
AnonymousUser,
|
AnonymousUser,
|
||||||
type MaybeUser,
|
type MaybeUser,
|
||||||
@@ -39,6 +37,7 @@ export type Call = {
|
|||||||
parameters: object;
|
parameters: object;
|
||||||
request: ExpressRequest;
|
request: ExpressRequest;
|
||||||
user: MaybeUser;
|
user: MaybeUser;
|
||||||
|
session: Session;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InternalHandler = (req: ExpressRequest) => Promise<Result>;
|
export type InternalHandler = (req: ExpressRequest) => Promise<Result>;
|
||||||
|
|||||||
Reference in New Issue
Block a user