diff --git a/bun.lock b/bun.lock index 1b1ea09a..78d95136 100644 --- a/bun.lock +++ b/bun.lock @@ -141,11 +141,6 @@ "version": "0.0.1", "dependencies": { "definitions": "workspace:*", - "drizzle-orm": "^0.45.1", - "postgres": "^3.4.5", - }, - "devDependencies": { - "drizzle-kit": "^0.31.8", }, }, "src/workspaces/components": { diff --git a/scripts/migrate-pg-to-files.ts b/scripts/migrate-pg-to-files.ts new file mode 100644 index 00000000..683c76fb --- /dev/null +++ b/scripts/migrate-pg-to-files.ts @@ -0,0 +1,79 @@ +/** + * One-time migration: PostgreSQL auth tables → JSON files + * + * Usage: + * POSTGRES_URL="postgres://..." bun run scripts/migrate-pg-to-files.ts + * + * Reads users and passkeys from Postgres, writes JSON files to {DATA_PATH}/auth/. + * Safe to run multiple times (overwrites files). + */ + +import { join } from 'node:path'; +import { mkdir } from 'node:fs/promises'; +import postgres from 'postgres'; + +const POSTGRES_URL = process.env.POSTGRES_URL; +if (!POSTGRES_URL) { + console.error('POSTGRES_URL env var is required'); + process.exit(1); +} + +const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); +const AUTH_DIR = join(DATA_PATH, 'auth'); + +const sql = postgres(POSTGRES_URL); + +try { + await mkdir(AUTH_DIR, { recursive: true }); + + const users = await sql`SELECT id, email, password, role, status, name, username, avatar, password_changed_at FROM users ORDER BY id`; + const passkeys = await sql`SELECT id, email, origin, credential_id, public_key, counter FROM passkeys ORDER BY id`; + + const mappedUsers = users.map((u) => ({ + id: Number(u.id), + email: u.email, + password: u.password ?? null, + role: u.role ?? 'Member', + status: u.status ?? 'Unverified', + name: u.name ?? null, + username: u.username ?? null, + avatar: u.avatar ?? null, + passwordChangedAt: u.password_changed_at ? Number(u.password_changed_at) : null, + })); + + const mappedPasskeys = passkeys.map((p) => ({ + id: Number(p.id), + email: p.email, + origin: p.origin ?? null, + credentialId: p.credential_id ?? null, + publicKey: p.public_key ?? null, + counter: Number(p.counter ?? 0), + })); + + const maxUserId = mappedUsers.reduce((max, u) => Math.max(max, u.id), 0); + const maxPasskeyId = mappedPasskeys.reduce((max, p) => Math.max(max, p.id), 0); + + const meta = { + nextUserId: maxUserId + 1, + nextPasskeyId: maxPasskeyId + 1, + }; + + const write = (file: string, data: unknown) => Bun.write(join(AUTH_DIR, file), JSON.stringify(data, null, 2)); + + await Promise.all([ + write('users.json', mappedUsers), + write('passkeys.json', mappedPasskeys), + write('passkey-challenges.json', []), + write('token-blacklist.json', []), + write('meta.json', meta), + ]); + + console.log(`Migrated ${mappedUsers.length} users, ${mappedPasskeys.length} passkeys`); + console.log(`Files written to ${AUTH_DIR}`); + console.log(`meta: nextUserId=${meta.nextUserId}, nextPasskeyId=${meta.nextPasskeyId}`); +} catch (err) { + console.error('Migration failed:', err); + process.exit(1); +} finally { + await sql.end(); +} diff --git a/src/databases/officer_db/drizzle.config.ts b/src/databases/officer_db/drizzle.config.ts deleted file mode 100644 index 19856671..00000000 --- a/src/databases/officer_db/drizzle.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { config } from 'dotenv'; -config({ path: '../../../.env' }); - -const { POSTGRES_URL } = process.env; - -export default { - schema: './src/schema/index.ts', - out: './migrations', - dialect: 'postgresql', - dbCredentials: { - url: POSTGRES_URL, - }, -}; diff --git a/src/databases/officer_db/migrations/0000_broken_gauntlet.sql b/src/databases/officer_db/migrations/0000_broken_gauntlet.sql deleted file mode 100644 index 1dc70854..00000000 --- a/src/databases/officer_db/migrations/0000_broken_gauntlet.sql +++ /dev/null @@ -1,38 +0,0 @@ -CREATE TYPE "public"."user_roles" AS ENUM('Member', 'Admin', 'Owner', 'Super Admin');--> statement-breakpoint -CREATE TYPE "public"."user_status" AS ENUM('Unverified', 'Active', 'Prospect', 'Invited', 'Blocked', 'Banned', 'Deleted');--> statement-breakpoint -CREATE TABLE "users" ( - "id" bigserial PRIMARY KEY NOT NULL, - "email" varchar(256) NOT NULL, - "password" varchar(256), - "role" "user_roles" DEFAULT 'Member', - "status" "user_status" DEFAULT 'Unverified', - "name" varchar(128), - "avatar" varchar(512000), - "password_changed_at" bigint, - CONSTRAINT "users_email_unique" UNIQUE("email") -); ---> statement-breakpoint -CREATE TABLE "passkeys" ( - "id" bigserial PRIMARY KEY NOT NULL, - "email" varchar(256) NOT NULL, - "origin" varchar(256), - "credential_id" text, - "public_key" text, - "counter" integer DEFAULT 0 NOT NULL -); ---> statement-breakpoint -CREATE TABLE "passkey_challenges" ( - "email" varchar(255) NOT NULL, - "origin" varchar(512) NOT NULL, - "challenge" varchar(512) NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "passkey_challenges_email_origin_pk" PRIMARY KEY("email","origin") -); ---> statement-breakpoint -CREATE TABLE "token_blacklist" ( - "jti" varchar(64) PRIMARY KEY NOT NULL, - "expires_at" bigint NOT NULL -); ---> statement-breakpoint -CREATE INDEX "idx_passkey_challenges_created_at" ON "passkey_challenges" USING btree ("created_at");--> statement-breakpoint -CREATE INDEX "idx_token_blacklist_expires_at" ON "token_blacklist" USING btree ("expires_at"); \ No newline at end of file diff --git a/src/databases/officer_db/migrations/0001_fat_blonde_phantom.sql b/src/databases/officer_db/migrations/0001_fat_blonde_phantom.sql deleted file mode 100644 index 55133f60..00000000 --- a/src/databases/officer_db/migrations/0001_fat_blonde_phantom.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "users" ADD COLUMN "username" varchar(128); \ No newline at end of file diff --git a/src/databases/officer_db/migrations/meta/0000_snapshot.json b/src/databases/officer_db/migrations/meta/0000_snapshot.json deleted file mode 100644 index 0c8796b4..00000000 --- a/src/databases/officer_db/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,269 +0,0 @@ -{ - "id": "b9130f42-0743-4c4b-aaf1-dce52e708e22", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "email": { - "name": "email", - "type": "varchar(256)", - "primaryKey": false, - "notNull": true - }, - "password": { - "name": "password", - "type": "varchar(256)", - "primaryKey": false, - "notNull": false - }, - "role": { - "name": "role", - "type": "user_roles", - "typeSchema": "public", - "primaryKey": false, - "notNull": false, - "default": "'Member'" - }, - "status": { - "name": "status", - "type": "user_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": false, - "default": "'Unverified'" - }, - "name": { - "name": "name", - "type": "varchar(128)", - "primaryKey": false, - "notNull": false - }, - "avatar": { - "name": "avatar", - "type": "varchar(512000)", - "primaryKey": false, - "notNull": false - }, - "password_changed_at": { - "name": "password_changed_at", - "type": "bigint", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_email_unique": { - "name": "users_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.passkeys": { - "name": "passkeys", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "email": { - "name": "email", - "type": "varchar(256)", - "primaryKey": false, - "notNull": true - }, - "origin": { - "name": "origin", - "type": "varchar(256)", - "primaryKey": false, - "notNull": false - }, - "credential_id": { - "name": "credential_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "public_key": { - "name": "public_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "counter": { - "name": "counter", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.passkey_challenges": { - "name": "passkey_challenges", - "schema": "", - "columns": { - "email": { - "name": "email", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "origin": { - "name": "origin", - "type": "varchar(512)", - "primaryKey": false, - "notNull": true - }, - "challenge": { - "name": "challenge", - "type": "varchar(512)", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "idx_passkey_challenges_created_at": { - "name": "idx_passkey_challenges_created_at", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "passkey_challenges_email_origin_pk": { - "name": "passkey_challenges_email_origin_pk", - "columns": [ - "email", - "origin" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.token_blacklist": { - "name": "token_blacklist", - "schema": "", - "columns": { - "jti": { - "name": "jti", - "type": "varchar(64)", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "idx_token_blacklist_expires_at": { - "name": "idx_token_blacklist_expires_at", - "columns": [ - { - "expression": "expires_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.user_roles": { - "name": "user_roles", - "schema": "public", - "values": [ - "Member", - "Admin", - "Owner", - "Super Admin" - ] - }, - "public.user_status": { - "name": "user_status", - "schema": "public", - "values": [ - "Unverified", - "Active", - "Prospect", - "Invited", - "Blocked", - "Banned", - "Deleted" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/src/databases/officer_db/migrations/meta/0001_snapshot.json b/src/databases/officer_db/migrations/meta/0001_snapshot.json deleted file mode 100644 index c9e905d9..00000000 --- a/src/databases/officer_db/migrations/meta/0001_snapshot.json +++ /dev/null @@ -1,275 +0,0 @@ -{ - "id": "7c0d3634-8b25-41e9-b6f3-24fad2005532", - "prevId": "b9130f42-0743-4c4b-aaf1-dce52e708e22", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "email": { - "name": "email", - "type": "varchar(256)", - "primaryKey": false, - "notNull": true - }, - "password": { - "name": "password", - "type": "varchar(256)", - "primaryKey": false, - "notNull": false - }, - "role": { - "name": "role", - "type": "user_roles", - "typeSchema": "public", - "primaryKey": false, - "notNull": false, - "default": "'Member'" - }, - "status": { - "name": "status", - "type": "user_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": false, - "default": "'Unverified'" - }, - "name": { - "name": "name", - "type": "varchar(128)", - "primaryKey": false, - "notNull": false - }, - "username": { - "name": "username", - "type": "varchar(128)", - "primaryKey": false, - "notNull": false - }, - "avatar": { - "name": "avatar", - "type": "varchar(512000)", - "primaryKey": false, - "notNull": false - }, - "password_changed_at": { - "name": "password_changed_at", - "type": "bigint", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_email_unique": { - "name": "users_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.passkeys": { - "name": "passkeys", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "email": { - "name": "email", - "type": "varchar(256)", - "primaryKey": false, - "notNull": true - }, - "origin": { - "name": "origin", - "type": "varchar(256)", - "primaryKey": false, - "notNull": false - }, - "credential_id": { - "name": "credential_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "public_key": { - "name": "public_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "counter": { - "name": "counter", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.passkey_challenges": { - "name": "passkey_challenges", - "schema": "", - "columns": { - "email": { - "name": "email", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "origin": { - "name": "origin", - "type": "varchar(512)", - "primaryKey": false, - "notNull": true - }, - "challenge": { - "name": "challenge", - "type": "varchar(512)", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "idx_passkey_challenges_created_at": { - "name": "idx_passkey_challenges_created_at", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "passkey_challenges_email_origin_pk": { - "name": "passkey_challenges_email_origin_pk", - "columns": [ - "email", - "origin" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.token_blacklist": { - "name": "token_blacklist", - "schema": "", - "columns": { - "jti": { - "name": "jti", - "type": "varchar(64)", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "idx_token_blacklist_expires_at": { - "name": "idx_token_blacklist_expires_at", - "columns": [ - { - "expression": "expires_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.user_roles": { - "name": "user_roles", - "schema": "public", - "values": [ - "Member", - "Admin", - "Owner", - "Super Admin" - ] - }, - "public.user_status": { - "name": "user_status", - "schema": "public", - "values": [ - "Unverified", - "Active", - "Prospect", - "Invited", - "Blocked", - "Banned", - "Deleted" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/src/databases/officer_db/migrations/meta/_journal.json b/src/databases/officer_db/migrations/meta/_journal.json deleted file mode 100644 index b8206cab..00000000 --- a/src/databases/officer_db/migrations/meta/_journal.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1770915839349, - "tag": "0000_broken_gauntlet", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1771340427681, - "tag": "0001_fat_blonde_phantom", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/src/databases/officer_db/package.json b/src/databases/officer_db/package.json index fb05739e..2a8c8d08 100644 --- a/src/databases/officer_db/package.json +++ b/src/databases/officer_db/package.json @@ -8,18 +8,7 @@ "./types": "./src/types.ts" }, "license": "MIT", - "scripts": { - "generate": "bun x drizzle-kit generate", - "push": "bun x drizzle-kit push && bun run sps", - "studio": "bun x drizzle-kit studio", - "sps": "./run_migrations_sp.sh" - }, "dependencies": { - "definitions": "workspace:*", - "drizzle-orm": "^0.45.1", - "postgres": "^3.4.5" - }, - "devDependencies": { - "drizzle-kit": "^0.31.8" + "definitions": "workspace:*" } } diff --git a/src/databases/officer_db/run_migrations_sp.sh b/src/databases/officer_db/run_migrations_sp.sh deleted file mode 100755 index cab27f3e..00000000 --- a/src/databases/officer_db/run_migrations_sp.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/bash - -# Run all stored procedure migrations in order -# This script applies SQL files from the stored-procedures directory to the statistics database -# It parses the POSTGRES_URL from the root .env file - -set -e - -# Find the root .env file -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ENV_FILE="$SCRIPT_DIR/../../../.env" -MIGRATIONS_DIR="$SCRIPT_DIR/src/stored-procedures" - -if [ ! -f "$ENV_FILE" ]; then - echo "Error: .env file not found at $ENV_FILE" - exit 1 -fi - -# Parse POSTGRES_URL from .env and strip quotes and carriage returns -POSTGRES_URL=$(grep "^POSTGRES_URL=" "$ENV_FILE" | cut -d'=' -f2- | sed 's/^"//;s/"$//' | tr -d '\r\n') - -if [ -z "$POSTGRES_URL" ]; then - echo "Error: POSTGRES_URL not found in .env file" - exit 1 -fi - -# Parse PostgreSQL connection string -# Format: postgres://user:password@host:port/database -DB_USER=$(printf '%s' "$POSTGRES_URL" | sed -E 's|postgres://([^:]+):.*|\1|') -DB_PASSWORD=$(printf '%s' "$POSTGRES_URL" | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|') -DB_HOST=$(printf '%s' "$POSTGRES_URL" | sed -E 's|.*@([^:]+):.*|\1|') -DB_PORT=$(printf '%s' "$POSTGRES_URL" | sed -E 's|.*@[^:]+:([0-9]+)/.*|\1|') -DB_NAME=$(printf '%s' "$POSTGRES_URL" | sed -E 's|.*:[0-9]+/([^?]+).*|\1|') - -echo "Running stored procedure migrations from: $MIGRATIONS_DIR" -echo "Database: postgres://$DB_USER@$DB_HOST:$DB_PORT/$DB_NAME" - -# Get all .sql files sorted by name -MIGRATIONS=$(find "$MIGRATIONS_DIR" -name "*.sql" -type f | sort) - -if [ -z "$MIGRATIONS" ]; then - echo "No migrations found in $MIGRATIONS_DIR" - exit 0 -fi - -for migration_file in $MIGRATIONS; do - migration_name=$(basename "$migration_file") - echo "Applying migration: $migration_name" - - # Execute the migration file with password from environment - PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -p "$DB_PORT" -f "$migration_file" - - if [ $? -eq 0 ]; then - echo "✓ Successfully applied: $migration_name" - else - echo "✗ Failed to apply: $migration_name" - exit 1 - fi -done - -echo "All stored procedure migrations completed successfully!" diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 072ae831..3515d22b 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -1,15 +1,20 @@ -import { config } from 'dotenv'; -config({ path: '../../../../.env' }); -import { drizzle } from 'drizzle-orm/postgres-js'; -import postgres from 'postgres'; -import * as Schema from './schema'; -export * from './schema'; -export * from 'drizzle-orm'; - -const { POSTGRES_URL } = process.env; -console.log('POSTGRES_URL', POSTGRES_URL); -const pgClient = postgres(POSTGRES_URL!); - -const officerdb = drizzle(pgClient, { schema: Schema }); - -export { officerdb, pgClient }; +export { + initAuthStore, + getUsers, + getUserById, + getUserByEmail, + getUserCount, + createUser, + updateUser, + deleteUser, + getPasskeysByEmail, + getPasskeysByEmailAndOrigin, + getPasskeyByCredentialId, + createPasskey, + updatePasskey, + storeChallenge, + consumeChallenge, + blacklistToken, + isTokenBlacklisted, + cleanupExpiredTokens, +} from './store'; diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts deleted file mode 100644 index 80024cd0..00000000 --- a/src/databases/officer_db/src/schema/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './users'; -export * from './passkeys'; -export * from './passkey-challenges'; -export * from './token-blacklist'; diff --git a/src/databases/officer_db/src/schema/passkey-challenges.ts b/src/databases/officer_db/src/schema/passkey-challenges.ts deleted file mode 100644 index 3043e35f..00000000 --- a/src/databases/officer_db/src/schema/passkey-challenges.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { pgTable, varchar, timestamp, index, primaryKey } from 'drizzle-orm/pg-core'; - -export const PasskeyChallenges = pgTable( - 'passkey_challenges', - { - email: varchar('email', { length: 255 }).notNull(), - origin: varchar('origin', { length: 512 }).notNull(), - challenge: varchar('challenge', { length: 512 }).notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - primaryKey({ columns: [table.email, table.origin] }), - index('idx_passkey_challenges_created_at').on(table.createdAt), - ], -); diff --git a/src/databases/officer_db/src/schema/passkeys.ts b/src/databases/officer_db/src/schema/passkeys.ts deleted file mode 100644 index 60dab88e..00000000 --- a/src/databases/officer_db/src/schema/passkeys.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { pgTable, varchar, text, integer } from 'drizzle-orm/pg-core'; -import { bigserial } from 'drizzle-orm/pg-core'; -import { relations } from 'drizzle-orm'; -import { Users } from './users'; - -export const Passkeys = pgTable('passkeys', { - id: bigserial('id', { mode: 'number' }).primaryKey(), - email: varchar('email', { length: 256 }).notNull(), - origin: varchar('origin', { length: 256 }), - credentialId: text('credential_id'), - publicKey: text('public_key'), - counter: integer('counter').notNull().default(0), -}); - -export const PasskeysRelations = relations(Passkeys, ({ one }) => ({ - user: one(Users, { - fields: [Passkeys.email], - references: [Users.email], - }), -})); diff --git a/src/databases/officer_db/src/schema/token-blacklist.ts b/src/databases/officer_db/src/schema/token-blacklist.ts deleted file mode 100644 index 1151798c..00000000 --- a/src/databases/officer_db/src/schema/token-blacklist.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { pgTable, varchar, bigint, index } from 'drizzle-orm/pg-core'; - -export const TokenBlacklist = pgTable( - 'token_blacklist', - { - jti: varchar('jti', { length: 64 }).primaryKey(), - expiresAt: bigint('expires_at', { mode: 'number' }).notNull(), - }, - (table) => [index('idx_token_blacklist_expires_at').on(table.expiresAt)], -); diff --git a/src/databases/officer_db/src/schema/users.ts b/src/databases/officer_db/src/schema/users.ts deleted file mode 100644 index 83575917..00000000 --- a/src/databases/officer_db/src/schema/users.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { pgTable, pgEnum, varchar } from 'drizzle-orm/pg-core'; -import { bigint, bigserial } from 'drizzle-orm/pg-core'; -import { relations } from 'drizzle-orm'; -import { Passkeys } from './passkeys'; -import { USER_STATUSES, USER_ROLES } from 'definitions'; - -export const userStatusEnum = pgEnum('user_status', USER_STATUSES); -export const userRolesEnum = pgEnum('user_roles', USER_ROLES); - -export const Users = pgTable('users', { - id: bigserial('id', { mode: 'number' }).primaryKey(), - email: varchar('email', { length: 256 }).unique().notNull(), - password: varchar('password', { length: 256 }), - role: userRolesEnum('role').default(USER_ROLES[0]), - status: userStatusEnum('status').default(USER_STATUSES[0]), - name: varchar('name', { length: 128 }), - username: varchar('username', { length: 128 }).unique(), - avatar: varchar('avatar', { length: 512000 }), - passwordChangedAt: bigint('password_changed_at', { mode: 'number' }), -}); - -export const UsersRelations = relations(Users, ({ many }) => ({ - passkeys: many(Passkeys), -})); diff --git a/src/databases/officer_db/src/store.ts b/src/databases/officer_db/src/store.ts new file mode 100644 index 00000000..fe02bcb9 --- /dev/null +++ b/src/databases/officer_db/src/store.ts @@ -0,0 +1,230 @@ +import { join } from 'node:path'; +import { mkdir } from 'node:fs/promises'; +import type { UserSelect, UserInsert, PasskeySelect, PasskeyInsert } from './types'; + +type PasskeyChallenge = { + email: string; + origin: string; + challenge: string; + createdAt: number; +}; + +type TokenBlacklistEntry = { + jti: string; + expiresAt: number; +}; + +type Meta = { + nextUserId: number; + nextPasskeyId: number; +}; + +const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); +const AUTH_DIR = join(DATA_PATH, 'auth'); + +const files = { + users: join(AUTH_DIR, 'users.json'), + passkeys: join(AUTH_DIR, 'passkeys.json'), + challenges: join(AUTH_DIR, 'passkey-challenges.json'), + blacklist: join(AUTH_DIR, 'token-blacklist.json'), + meta: join(AUTH_DIR, 'meta.json'), +}; + +let users: UserSelect[] = []; +let passkeys: PasskeySelect[] = []; +let challenges: PasskeyChallenge[] = []; +let blacklist: TokenBlacklistEntry[] = []; +let meta: Meta = { nextUserId: 1, nextPasskeyId: 1 }; + +async function readJson(path: string, fallback: T): Promise { + try { + const file = Bun.file(path); + if (!(await file.exists())) return fallback; + return (await file.json()) as T; + } catch { + return fallback; + } +} + +const writeJson = (path: string, data: unknown) => Bun.write(path, JSON.stringify(data, null, 2)); + +async function flushUsers() { + await writeJson(files.users, users); +} + +async function flushPasskeys() { + await writeJson(files.passkeys, passkeys); +} + +async function flushChallenges() { + await writeJson(files.challenges, challenges); +} + +async function flushBlacklist() { + await writeJson(files.blacklist, blacklist); +} + +async function flushMeta() { + await writeJson(files.meta, meta); +} + +// ── Lifecycle ── + +export async function initAuthStore() { + await mkdir(AUTH_DIR, { recursive: true }); + users = await readJson(files.users, []); + passkeys = await readJson(files.passkeys, []); + challenges = await readJson(files.challenges, []); + blacklist = await readJson(files.blacklist, []); + meta = await readJson(files.meta, { nextUserId: 1, nextPasskeyId: 1 }); + + // Reconcile meta with existing data + const maxUserId = users.reduce((max, u) => Math.max(max, u.id), 0); + const maxPasskeyId = passkeys.reduce((max, p) => Math.max(max, p.id), 0); + if (meta.nextUserId <= maxUserId) meta.nextUserId = maxUserId + 1; + if (meta.nextPasskeyId <= maxPasskeyId) meta.nextPasskeyId = maxPasskeyId + 1; +} + +// ── Users ── + +export function getUsers(): UserSelect[] { + return users; +} + +export function getUserById(id: number): UserSelect | undefined { + return users.find((u) => u.id === id); +} + +export function getUserByEmail(email: string): UserSelect | undefined { + return users.find((u) => u.email === email); +} + +export function getUserCount(): number { + return users.length; +} + +export async function createUser(data: UserInsert): Promise { + const id = meta.nextUserId++; + const user: UserSelect = { + id, + email: data.email, + password: data.password ?? null, + role: data.role ?? 'Member', + status: data.status ?? 'Unverified', + name: data.name ?? null, + username: data.username ?? null, + avatar: data.avatar ?? null, + passwordChangedAt: data.passwordChangedAt ?? null, + }; + users.push(user); + await Promise.all([flushUsers(), flushMeta()]); + return user; +} + +export async function updateUser(id: number, data: Partial>): Promise { + const idx = users.findIndex((u) => u.id === id); + if (idx === -1) return undefined; + users[idx] = { ...users[idx]!, ...data }; + await flushUsers(); + return users[idx]; +} + +export async function deleteUser(id: number): Promise { + const idx = users.findIndex((u) => u.id === id); + if (idx === -1) return false; + users.splice(idx, 1); + await flushUsers(); + return true; +} + +// ── Passkeys ── + +export function getPasskeysByEmail(email: string): PasskeySelect[] { + return passkeys.filter((p) => p.email === email); +} + +export function getPasskeysByEmailAndOrigin(email: string, origin: string): PasskeySelect[] { + return passkeys.filter((p) => p.email === email && p.origin === origin); +} + +export function getPasskeyByCredentialId(email: string, credentialId: string): PasskeySelect | undefined { + return passkeys.find((p) => p.email === email && p.credentialId === credentialId); +} + +export async function createPasskey(data: PasskeyInsert): Promise { + const id = meta.nextPasskeyId++; + const passkey: PasskeySelect = { + id, + email: data.email, + origin: data.origin ?? null, + credentialId: data.credentialId ?? null, + publicKey: data.publicKey ?? null, + counter: data.counter ?? 0, + }; + passkeys.push(passkey); + await Promise.all([flushPasskeys(), flushMeta()]); + return passkey; +} + +export async function updatePasskey( + id: number, + data: Partial>, +): Promise { + const idx = passkeys.findIndex((p) => p.id === id); + if (idx === -1) return undefined; + passkeys[idx] = { ...passkeys[idx]!, ...data }; + await flushPasskeys(); + return passkeys[idx]; +} + +// ── Passkey Challenges ── + +export async function storeChallenge(email: string, origin: string, challenge: string) { + const idx = challenges.findIndex((c) => c.email === email && c.origin === origin); + const entry: PasskeyChallenge = { email, origin, challenge, createdAt: Date.now() }; + if (idx !== -1) { + challenges[idx] = entry; + } else { + challenges.push(entry); + } + await flushChallenges(); +} + +export async function consumeChallenge(email: string, origin: string, ttlMs: number): Promise { + const now = Date.now(); + + // Remove expired challenges + challenges = challenges.filter((c) => now - c.createdAt < ttlMs); + + const idx = challenges.findIndex((c) => c.email === email && c.origin === origin); + if (idx === -1) { + await flushChallenges(); + return null; + } + + const entry = challenges[idx]!; + challenges.splice(idx, 1); + await flushChallenges(); + + if (now - entry.createdAt >= ttlMs) return null; + return entry.challenge; +} + +// ── Token Blacklist ── + +export async function blacklistToken(jti: string, expiresAt: number) { + if (blacklist.some((b) => b.jti === jti)) return; + blacklist.push({ jti, expiresAt }); + await flushBlacklist(); +} + +export function isTokenBlacklisted(jti: string): boolean { + return blacklist.some((b) => b.jti === jti); +} + +export async function cleanupExpiredTokens() { + const now = Math.floor(Date.now() / 1000); + const before = blacklist.length; + blacklist = blacklist.filter((b) => b.expiresAt >= now); + if (blacklist.length !== before) await flushBlacklist(); +} diff --git a/src/databases/officer_db/src/stored-procedures/0000_table_modifications.sql b/src/databases/officer_db/src/stored-procedures/0000_table_modifications.sql deleted file mode 100644 index de4ed65b..00000000 --- a/src/databases/officer_db/src/stored-procedures/0000_table_modifications.sql +++ /dev/null @@ -1,27 +0,0 @@ -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM pg_class - WHERE relname = 'passkey_challenges' - AND relpersistence = 'p' - ) THEN - ALTER TABLE passkey_challenges SET UNLOGGED; - RAISE NOTICE 'passkey_challenges set to UNLOGGED'; - ELSE - RAISE NOTICE 'passkey_challenges already UNLOGGED or does not exist'; - END IF; -END $$; - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM pg_class - WHERE relname = 'token_blacklist' - AND relpersistence = 'p' - ) THEN - ALTER TABLE token_blacklist SET UNLOGGED; - RAISE NOTICE 'token_blacklist set to UNLOGGED'; - ELSE - RAISE NOTICE 'token_blacklist already UNLOGGED or does not exist'; - END IF; -END $$; diff --git a/src/databases/officer_db/src/types.ts b/src/databases/officer_db/src/types.ts index 2e3941a4..bc9761a9 100644 --- a/src/databases/officer_db/src/types.ts +++ b/src/databases/officer_db/src/types.ts @@ -1,21 +1,50 @@ -import * as Schema from './schema'; +import type { USER_ROLES, USER_STATUSES } from 'definitions'; // Auth -export type PasskeySelect = typeof Schema.Passkeys.$inferSelect; -export type PasskeyInsert = typeof Schema.Passkeys.$inferInsert; -export type Passkey = PasskeySelect & { - user: User; +export type UserSelect = { + id: number; + email: string; + password: string | null; + role: (typeof USER_ROLES)[number] | null; + status: (typeof USER_STATUSES)[number] | null; + name: string | null; + username: string | null; + avatar: string | null; + passwordChangedAt: number | null; +}; + +export type UserInsert = { + email: string; + password?: string | null; + role?: (typeof USER_ROLES)[number] | null; + status?: (typeof USER_STATUSES)[number] | null; + name?: string | null; + username?: string | null; + avatar?: string | null; + passwordChangedAt?: number | null; }; -export type UserSelect = typeof Schema.Users.$inferSelect; -export type UserInsert = typeof Schema.Users.$inferInsert; export type User = UserSelect & { passkeys: Passkey[]; }; -// Security -export type PasskeyChallenge = typeof Schema.PasskeyChallenges.$inferSelect; -export type PasskeyChallengeInsert = typeof Schema.PasskeyChallenges.$inferInsert; +export type PasskeySelect = { + id: number; + email: string; + origin: string | null; + credentialId: string | null; + publicKey: string | null; + counter: number; +}; -export type TokenBlacklist = typeof Schema.TokenBlacklist.$inferSelect; -export type TokenBlacklistInsert = typeof Schema.TokenBlacklist.$inferInsert; +export type PasskeyInsert = { + email: string; + origin?: string | null; + credentialId?: string | null; + publicKey?: string | null; + counter?: number; +}; + +export type Passkey = PasskeySelect & { + user: User; +}; diff --git a/src/server.tsx b/src/server.tsx index e44d48bd..87b2e248 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -1,10 +1,9 @@ import './servers/bootstrap'; import type { ServerWebSocket } from 'bun'; import { serve } from 'bun'; -import { eq } from 'drizzle-orm'; import { honoServer } from './servers/hono'; import { verify } from './servers/jwt'; -import { officerdb, TokenBlacklist } from 'officerdb'; +import { isTokenBlacklisted } from 'officerdb'; import { terminalWebsocket, initTerminalSidecars } from './servers/api/terminal/websocket'; import { piWebsocket } from './servers/api/pi/websocket'; import { findEntryBySlug, touchEntry } from './servers/api/dev-server/router'; @@ -93,10 +92,7 @@ async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi') if (!user) return new Response('Unauthorized', { status: 401 }); if (user.jti) { - const blacklisted = await officerdb.query.TokenBlacklist.findFirst({ - where: eq(TokenBlacklist.jti, user.jti), - }); - if (blacklisted) return new Response('Unauthorized', { status: 401 }); + if (isTokenBlacklisted(user.jti)) return new Response('Unauthorized', { status: 401 }); } const url = new URL(req.url); diff --git a/src/servers/_middlewares/user-middleware.ts b/src/servers/_middlewares/user-middleware.ts index 34ff1ada..464ab786 100644 --- a/src/servers/_middlewares/user-middleware.ts +++ b/src/servers/_middlewares/user-middleware.ts @@ -2,7 +2,7 @@ import type { MiddlewareHandler } from 'hono'; import { verify } from '@@/jwt'; import * as errors from '@@/custom-errors'; import { isOriginAllowed } from './origin-validation'; -import { officerdb, eq, Users, TokenBlacklist } from 'officerdb'; +import { getUserById, isTokenBlacklisted } from 'officerdb'; // Role permissions: which HTTP methods each role can use // Roles not listed here are denied by default (fail-safe) @@ -46,18 +46,12 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) { // Check if token is blacklisted (explicit signout) if (user.jti) { - const blacklisted = await officerdb.query.TokenBlacklist.findFirst({ - where: eq(TokenBlacklist.jti, user.jti), - }); - if (blacklisted) throw errors.UNAUTHORIZED(); + if (isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED(); } // Check if token was issued before password change if (user.iat && user.id) { - const dbUser = await officerdb.query.Users.findFirst({ - where: eq(Users.id, user.id), - columns: { passwordChangedAt: true }, - }); + const dbUser = getUserById(user.id); if (dbUser?.passwordChangedAt) { // iat is in seconds, passwordChangedAt is in milliseconds const tokenIssuedAt = user.iat * 1000; diff --git a/src/servers/api/auth/bootstrap.ts b/src/servers/api/auth/bootstrap.ts index 934eab1b..c4c84ade 100644 --- a/src/servers/api/auth/bootstrap.ts +++ b/src/servers/api/auth/bootstrap.ts @@ -1,6 +1,6 @@ import type { Handler } from 'hono'; import { sendMail } from 'emailer'; -import { officerdb, count, Users } from 'officerdb'; +import { getUserCount, createUser } from 'officerdb'; import { sign, verify } from '@@/jwt'; import argon2 from 'argon2'; import * as errors from '@@/custom-errors'; @@ -13,8 +13,7 @@ export const bootstrapHandler: Handler = async function (ctx) { const token = body.token as string; const email = body.email as string; - const result = await officerdb.select({ count: count() }).from(Users); - const userCount = result[0]?.count ?? 0; + const userCount = getUserCount(); if (userCount > 0) throw errors.FORBIDDEN('Registration is closed'); if (!token) { @@ -35,7 +34,7 @@ export const bootstrapHandler: Handler = async function (ctx) { return ctx.json({ ok: true }); } - const payload = await verify(token).catch(() => null) as { email: string } | null; + const payload = ((await verify(token).catch(() => null)) as { email: string } | null); if (!payload?.email) throw errors.BAD_REQUEST('Invalid or expired token'); const name = body.name as string; @@ -50,16 +49,15 @@ export const bootstrapHandler: Handler = async function (ctx) { const passwordHash = await argon2.hash(password); - const insertedUsers = await officerdb.insert(Users).values({ + await createUser({ email: payload.email, password: passwordHash, name: name.trim(), username: username.trim(), role: 'Super Admin', status: 'Active', - }).returning(); + }); - if (!insertedUsers || insertedUsers.length === 0) throw errors.INTERNAL_SERVER_ERROR('Failed to create user'); syncUserPiConfig(payload.email).catch(() => {}); return ctx.json({ ok: true }); }; diff --git a/src/servers/api/auth/change-password.ts b/src/servers/api/auth/change-password.ts index aabb9728..5f29c2e9 100644 --- a/src/servers/api/auth/change-password.ts +++ b/src/servers/api/auth/change-password.ts @@ -1,5 +1,5 @@ import type { Handler } from 'hono'; -import { officerdb, eq, Users } from 'officerdb'; +import { getUserById, updateUser } from 'officerdb'; import argon2 from 'argon2'; import { sign } from '@@/jwt'; import * as errors from '@@/custom-errors'; @@ -13,10 +13,7 @@ export const changePasswordHandler: Handler = async function (ctx) { if (isProduction) validatePassword(newPassword); const reqUser = ctx.get('user'); - const dbUser = await officerdb.query.Users.findFirst({ - where: eq(Users.id, reqUser.id), - columns: { password: true, username: true }, - }); + const dbUser = getUserById(reqUser.id); if (!dbUser) throw errors.UNAUTHORIZED(); @@ -28,7 +25,7 @@ export const changePasswordHandler: Handler = async function (ctx) { const newPasswordHash = await argon2.hash(newPassword); // Use floored seconds-to-ms so the token iat (also floored) is never behind const passwordChangedAt = Math.floor(Date.now() / 1000) * 1000; - await officerdb.update(Users).set({ password: newPasswordHash, passwordChangedAt }).where(eq(Users.id, reqUser.id)); + await updateUser(reqUser.id, { password: newPasswordHash, passwordChangedAt }); const { id, email, name, role } = reqUser; const username = dbUser.username ?? reqUser.username; diff --git a/src/servers/api/auth/forgot-password.ts b/src/servers/api/auth/forgot-password.ts index cec0eb2e..29ce13ce 100644 --- a/src/servers/api/auth/forgot-password.ts +++ b/src/servers/api/auth/forgot-password.ts @@ -1,5 +1,5 @@ import type { Handler } from 'hono'; -import { officerdb, eq, Users } from 'officerdb'; +import { getUserByEmail } from 'officerdb'; import { sign } from '@@/jwt'; import { sendMail } from 'emailer'; @@ -7,9 +7,7 @@ export const forgotPasswordHandler: Handler = async function (ctx) { const { email } = ctx.get('body'); const origin = ctx.get('origin'); - const dbUser = await officerdb.query.Users.findFirst({ - where: eq(Users.email, email), - }); + const dbUser = getUserByEmail(email); if (!dbUser) return ctx.json({ ok: true }); const verificationCode = await sign({ id: dbUser.id, email, purpose: 'reset-password' }, '6h'); diff --git a/src/servers/api/auth/passkey-router.ts b/src/servers/api/auth/passkey-router.ts index 667228de..941f8770 100644 --- a/src/servers/api/auth/passkey-router.ts +++ b/src/servers/api/auth/passkey-router.ts @@ -4,7 +4,15 @@ import { createRouter } from '../../create-router'; import { userMiddleware, passkeyRateLimiter } from '../../_middlewares'; import { sign } from '../../jwt'; import * as errors from '../../custom-errors'; -import { officerdb, eq, and, lt, Passkeys, Users, PasskeyChallenges } from 'officerdb'; +import { + getUserByEmail, + getPasskeysByEmailAndOrigin, + getPasskeyByCredentialId, + createPasskey, + updatePasskey, + storeChallenge, + consumeChallenge, +} from 'officerdb'; import { generateRegistrationOptions, verifyRegistrationResponse, @@ -27,45 +35,6 @@ function getRpId(origin: string): string { } } -async function storeChallenge(email: string, origin: string, challenge: string) { - await officerdb - .insert(PasskeyChallenges) - .values({ email, origin, challenge }) - .onConflictDoUpdate({ - target: [PasskeyChallenges.email, PasskeyChallenges.origin], - set: { challenge, createdAt: new Date() }, - }); -} - -async function getAndDeleteChallenge(email: string, origin: string): Promise { - const minValidTime = new Date(Date.now() - CHALLENGE_TTL_MS); - - // Delete expired challenges for this email/origin - await officerdb - .delete(PasskeyChallenges) - .where( - and( - eq(PasskeyChallenges.email, email), - eq(PasskeyChallenges.origin, origin), - lt(PasskeyChallenges.createdAt, minValidTime), - ), - ); - - // Get and delete the challenge in one operation - const result = await officerdb - .delete(PasskeyChallenges) - .where(and(eq(PasskeyChallenges.email, email), eq(PasskeyChallenges.origin, origin))) - .returning(); - - const entry = result[0]; - if (!entry) return null; - - // Double-check TTL (in case of race condition) - if (entry.createdAt < minValidTime) return null; - - return entry.challenge; -} - // Generate registration options (challenge) for creating a new passkey const passkeyRouterPostChallenge: Handler = async (ctx) => { const { email } = ctx.req.param(); @@ -73,9 +42,7 @@ const passkeyRouterPostChallenge: Handler = async (ctx) => { const rpId = getRpId(origin); // Get existing passkeys to exclude them - const existingPasskeys = await officerdb.query.Passkeys.findMany({ - where: and(eq(Passkeys.email, email!), eq(Passkeys.origin, origin)), - }); + const existingPasskeys = getPasskeysByEmailAndOrigin(email!, origin); const options = await generateRegistrationOptions({ rpName: RP_NAME, @@ -104,7 +71,7 @@ const passkeyRouterPost: Handler = async (ctx) => { const { email } = ctx.get('user') as User; const response = ctx.get('body') as RegistrationResponseJSON; - const storedChallenge = await getAndDeleteChallenge(email, origin); + const storedChallenge = await consumeChallenge(email, origin, CHALLENGE_TTL_MS); if (!storedChallenge) throw errors.BAD_CREDENTIALS(); const verification = await verifyRegistrationResponse({ @@ -120,15 +87,14 @@ const passkeyRouterPost: Handler = async (ctx) => { const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; - const values = { + await createPasskey({ email, origin, credentialId: credential.id, publicKey: Buffer.from(credential.publicKey).toString('base64'), counter: credential.counter, - }; + }); - await officerdb.insert(Passkeys).values(values); return ctx.json({ ok: true, credentialDeviceType, credentialBackedUp }); }; passkeyRouter.post('/credentials', userMiddleware, passkeyRouterPost); @@ -139,9 +105,7 @@ const passkeyRouterGet: Handler = async (ctx) => { const origin = ctx.get('origin') as string; const rpId = getRpId(origin); - const passkeys = await officerdb.query.Passkeys.findMany({ - where: and(eq(Passkeys.email, email!), eq(Passkeys.origin, origin)), - }); + const passkeys = getPasskeysByEmailAndOrigin(email!, origin); const options = await generateAuthenticationOptions({ rpID: rpId, @@ -163,13 +127,11 @@ const passkeyRouterPostVerify: Handler = async (ctx) => { const rpId = getRpId(origin); const response = ctx.get('body') as AuthenticationResponseJSON; - const storedChallenge = await getAndDeleteChallenge(email!, origin); + const storedChallenge = await consumeChallenge(email!, origin, CHALLENGE_TTL_MS); if (!storedChallenge) throw errors.BAD_CREDENTIALS(); // Find the passkey being used - const dbPasskey = await officerdb.query.Passkeys.findFirst({ - where: and(eq(Passkeys.email, email!), eq(Passkeys.credentialId, response.id)), - }); + const dbPasskey = getPasskeyByCredentialId(email!, response.id); if (!dbPasskey || !dbPasskey.publicKey) throw errors.BAD_CREDENTIALS(); @@ -188,27 +150,21 @@ const passkeyRouterPostVerify: Handler = async (ctx) => { if (!verification.verified) throw errors.BAD_CREDENTIALS(); // Update counter to prevent replay attacks - await officerdb - .update(Passkeys) - .set({ counter: verification.authenticationInfo.newCounter }) - .where(eq(Passkeys.id, dbPasskey.id)); - - const dbUser = await officerdb.query.Users.findFirst({ - where: eq(Users.email, email!), - with: { passkeys: true }, - }); + await updatePasskey(dbPasskey.id, { counter: verification.authenticationInfo.newCounter }); + const dbUser = getUserByEmail(email!); if (!dbUser) throw errors.UNAUTHORIZED(); + const passkeys = getPasskeysByEmailAndOrigin(email!, origin); + const { id, name, username, role } = dbUser; - const passkeys = dbUser.passkeys?.length ?? 0; const token = await sign({ id, email, name, username, role, - passkeys, + passkeys: passkeys.length, }); return ctx.json({ @@ -219,7 +175,7 @@ const passkeyRouterPostVerify: Handler = async (ctx) => { name, username, role, - passkeys, + passkeys: passkeys.length, }, }); }; diff --git a/src/servers/api/auth/resend-verification.ts b/src/servers/api/auth/resend-verification.ts index 5c6850b0..281c32e3 100644 --- a/src/servers/api/auth/resend-verification.ts +++ b/src/servers/api/auth/resend-verification.ts @@ -1,5 +1,5 @@ import type { Handler } from 'hono'; -import { officerdb, eq, Users } from 'officerdb'; +import { getUserByEmail } from 'officerdb'; import { sign } from '@@/jwt'; import * as errors from '@@/custom-errors'; import { sendMail } from 'emailer'; @@ -10,9 +10,7 @@ export const resendVerificationHandler: Handler = async function (ctx) { if (!email || typeof email !== 'string') throw errors.BAD_REQUEST('Email is required'); - const user = await officerdb.query.Users.findFirst({ - where: eq(Users.email, email), - }); + const user = getUserByEmail(email); if (!user) throw errors.NOT_FOUND('User not found'); if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified'); diff --git a/src/servers/api/auth/reset-password.ts b/src/servers/api/auth/reset-password.ts index 605d7903..65b2bba8 100644 --- a/src/servers/api/auth/reset-password.ts +++ b/src/servers/api/auth/reset-password.ts @@ -1,6 +1,6 @@ import type { User } from 'types'; import type { Handler } from 'hono'; -import { officerdb, eq, Users } from 'officerdb'; +import { updateUser } from 'officerdb'; import { verify } from '@@/jwt'; import argon2 from 'argon2'; import * as errors from '@@/custom-errors'; @@ -13,10 +13,7 @@ export const resetPasswordHandler: Handler = async function (ctx) { const userInfo = (await verify(verificationCode)) as User; if (!userInfo) throw errors.UNAUTHORIZED(); const passwordHash = await argon2.hash(password); - await officerdb - .update(Users) - .set({ password: passwordHash, status: 'Active', passwordChangedAt: now }) - .where(eq(Users.id, userInfo.id)); + await updateUser(userInfo.id, { password: passwordHash, status: 'Active', passwordChangedAt: now }); return ctx.json({ ok: true }); }; diff --git a/src/servers/api/auth/signin.ts b/src/servers/api/auth/signin.ts index 9fbf5b04..084f7fb0 100755 --- a/src/servers/api/auth/signin.ts +++ b/src/servers/api/auth/signin.ts @@ -1,7 +1,7 @@ import type { Handler } from 'hono'; import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; -import { officerdb, eq, and, Users, Passkeys } from 'officerdb'; +import { getUserByEmail, getPasskeysByEmailAndOrigin } from 'officerdb'; import { sign } from '@@/jwt'; import { getClaudeDir } from '@@/data-path'; import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config'; @@ -13,13 +13,9 @@ const TEST_USERS: number[] = []; export const signinHandler: Handler = async function (ctx) { const { email, password } = ctx.get('body'); const origin = ctx.get('origin'); - const dbUser = await officerdb.query.Users.findFirst({ - where: eq(Users.email, email), - }); + const dbUser = getUserByEmail(email); - const passkeys = await officerdb.query.Passkeys.findMany({ - where: and(eq(Passkeys.email, email), eq(Passkeys.origin, origin)), - }); + const passkeys = getPasskeysByEmailAndOrigin(email, origin); if (!dbUser || !dbUser.password) throw errors.UNAUTHORIZED(); const { status } = dbUser; diff --git a/src/servers/api/auth/signout.ts b/src/servers/api/auth/signout.ts index b2cd842a..eb630a31 100644 --- a/src/servers/api/auth/signout.ts +++ b/src/servers/api/auth/signout.ts @@ -1,23 +1,10 @@ import type { Handler } from 'hono'; -import { officerdb, TokenBlacklist, lt } from 'officerdb'; - -// Cleanup expired blacklist entries (can be called periodically) -export async function cleanupExpiredTokens() { - const now = Math.floor(Date.now() / 1000); - await officerdb.delete(TokenBlacklist).where(lt(TokenBlacklist.expiresAt, now)); -} +import { blacklistToken, cleanupExpiredTokens } from 'officerdb'; export const signoutHandler: Handler = async (ctx) => { const user = ctx.get('user') as { jti: string; exp: number }; - // Add token to blacklist - await officerdb - .insert(TokenBlacklist) - .values({ - jti: user.jti, - expiresAt: user.exp, - }) - .onConflictDoNothing(); + await blacklistToken(user.jti, user.exp); // Opportunistic cleanup of expired tokens (non-blocking) cleanupExpiredTokens().catch(() => {}); diff --git a/src/servers/api/auth/signup.ts b/src/servers/api/auth/signup.ts index 5b24e68e..c80f8a17 100644 --- a/src/servers/api/auth/signup.ts +++ b/src/servers/api/auth/signup.ts @@ -1,7 +1,7 @@ import type { Handler } from 'hono'; -import { officerdb, count, Users } from 'officerdb'; +import { getUserCount, createUser } from 'officerdb'; import { sign } from '@@/jwt'; -import { USER_ROLES, USER_STATUSES } from 'definitions'; +import type { USER_ROLES, USER_STATUSES } from 'definitions'; import * as errors from '@@/custom-errors'; import { sendMail } from 'emailer'; @@ -13,19 +13,14 @@ export const signupHandler: Handler = async function (ctx) { throw errors.BAD_REQUEST('Invalid email address'); } - const result = await officerdb.select({ count: count() }).from(Users); - const userCount = result[0]?.count ?? 0; + const userCount = getUserCount(); if (userCount > 0) throw errors.FORBIDDEN('Registration is closed'); - const newUser = { + const dbUser = await createUser({ email: body.email as string, status: 'Unverified' as (typeof USER_STATUSES)[number], role: 'Admin' as (typeof USER_ROLES)[number], - }; - - const insertedUsers = await officerdb.insert(Users).values(newUser).returning(); - if (!insertedUsers || insertedUsers.length === 0) throw errors.INTERNAL_SERVER_ERROR('Failed to create user'); - const dbUser = insertedUsers[0]!; + }); const verificationCode = await sign({ id: dbUser.id, email: dbUser.email }, '24h'); const url = `${origin}/auth/verify?verificationCode=${verificationCode}`; diff --git a/src/servers/api/auth/users-me.ts b/src/servers/api/auth/users-me.ts index 25ff9b76..e93a479c 100644 --- a/src/servers/api/auth/users-me.ts +++ b/src/servers/api/auth/users-me.ts @@ -1,24 +1,19 @@ import type { Handler } from 'hono'; import type { User } from 'types'; import * as errors from '@@/custom-errors'; -import { officerdb, eq, Users, Passkeys } from 'officerdb'; +import { getUserById, getPasskeysByEmailAndOrigin } from 'officerdb'; export const usersMe: Handler = async function (ctx) { const user = ctx.get('user') as User; const origin = ctx.get('origin') as string; - const dbUser = await officerdb.query.Users.findFirst({ - where: eq(Users.id, user.id), - columns: { password: false }, - with: { - passkeys: { where: eq(Passkeys.origin, origin || '') }, - }, - }); + const dbUser = getUserById(user.id); if (!dbUser) return errors.NOT_FOUND(); - const { passkeys, ...userWithoutPasskeys } = dbUser; - const returnUser = { ...userWithoutPasskeys, passkeyCount: passkeys.length }; + const passkeys = getPasskeysByEmailAndOrigin(dbUser.email, origin || ''); + const { password, ...userWithoutPassword } = dbUser; + const returnUser = { ...userWithoutPassword, passkeyCount: passkeys.length }; return ctx.json(returnUser); }; diff --git a/src/servers/api/auth/verify-token.ts b/src/servers/api/auth/verify-token.ts index dd5f34af..7f16fbc9 100644 --- a/src/servers/api/auth/verify-token.ts +++ b/src/servers/api/auth/verify-token.ts @@ -1,6 +1,6 @@ import type { Handler } from 'hono'; import type { User } from 'types'; -import { officerdb, eq, Users } from 'officerdb'; +import { getUserById } from 'officerdb'; import { verify } from '@@/jwt'; import * as errors from '@@/custom-errors'; @@ -22,9 +22,7 @@ export const verifyTokenHandler: Handler = async function (ctx) { if (!userInfo?.id) throw errors.BAD_REQUEST('Token is invalid or expired'); - const user = await officerdb.query.Users.findFirst({ - where: eq(Users.id, userInfo.id), - }); + const user = getUserById(userInfo.id); if (!user) throw errors.NOT_FOUND('User not found'); // Reset-password tokens skip the verification status check diff --git a/src/servers/api/auth/verify.ts b/src/servers/api/auth/verify.ts index 8595c842..889af665 100644 --- a/src/servers/api/auth/verify.ts +++ b/src/servers/api/auth/verify.ts @@ -1,6 +1,6 @@ import type { Handler } from 'hono'; import type { User } from 'types'; -import { officerdb, eq, Users } from 'officerdb'; +import { getUserById, updateUser } from 'officerdb'; import { verify as verifyJwt, sign } from '@@/jwt'; import argon2 from 'argon2'; import * as errors from '@@/custom-errors'; @@ -11,9 +11,7 @@ export const verifyHandler: Handler = async function (ctx) { const userInfo = (await verifyJwt(verificationCode)) as User; if (!userInfo) throw errors.BAD_REQUEST(); - const user = await officerdb.query.Users.findFirst({ - where: eq(Users.id, userInfo.id), - }); + const user = getUserById(userInfo.id); if (!user) throw errors.NOT_FOUND('User not found'); const updates: Record = { status: 'Active' }; @@ -37,16 +35,10 @@ export const verifyHandler: Handler = async function (ctx) { updates.password = await argon2.hash(password); } - const [updatedUser] = await officerdb - .update(Users) - .set(updates) - .where(eq(Users.id, userInfo.id)) - .returning({ username: Users.username }); + await updateUser(userInfo.id, updates); // Re-fetch user to get final values after update - const finalUser = await officerdb.query.Users.findFirst({ - where: eq(Users.id, userInfo.id), - }); + const finalUser = getUserById(userInfo.id); if (!finalUser) throw errors.NOT_FOUND('User not found'); // Issue a token so the user is logged in immediately diff --git a/src/servers/api/landing-page-data/landing-page-data.ts b/src/servers/api/landing-page-data/landing-page-data.ts index a05ed21d..0cd105cd 100644 --- a/src/servers/api/landing-page-data/landing-page-data.ts +++ b/src/servers/api/landing-page-data/landing-page-data.ts @@ -1,10 +1,9 @@ import { createRouter } from '../../create-router'; -import { officerdb, count, Users } from 'officerdb'; +import { getUserCount } from 'officerdb'; export const landingPageDataRouter = createRouter(); landingPageDataRouter.get('/', async (ctx) => { - const result = await officerdb.select({ count: count() }).from(Users); - const userCount = result[0]?.count ?? 0; + const userCount = getUserCount(); return ctx.json({ registrationOpen: userCount === 0 }); }); diff --git a/src/servers/api/server-settings/server-settings.ts b/src/servers/api/server-settings/server-settings.ts index 33b4c241..2e1b0256 100644 --- a/src/servers/api/server-settings/server-settings.ts +++ b/src/servers/api/server-settings/server-settings.ts @@ -3,7 +3,6 @@ import { homedir } from 'node:os'; import { mkdir } from 'node:fs/promises'; import { readdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; -import { officerdb, count, Users } from 'officerdb'; import { claudeCodeRouter } from './claude-code'; import { opencodeRouter } from './opencode'; import { piMonoRouter } from './pi-mono'; diff --git a/src/servers/api/server-settings/sync-user-pi-config.ts b/src/servers/api/server-settings/sync-user-pi-config.ts index 8f840155..e241470e 100644 --- a/src/servers/api/server-settings/sync-user-pi-config.ts +++ b/src/servers/api/server-settings/sync-user-pi-config.ts @@ -2,7 +2,7 @@ import { join } from 'node:path'; import { mkdir, copyFile } from 'node:fs/promises'; import { PI_CONFIG_DIR, getUserPiConfigDir } from '../../data-path'; import { readApiKeys, readAccessPolicy, PROVIDERS } from './pi-mono'; -import { officerdb, Users } from 'officerdb'; +import { getUsers } from 'officerdb'; const PI_MODELS_FILE = join(PI_CONFIG_DIR, 'models.json'); const PI_SETTINGS_FILE = join(PI_CONFIG_DIR, 'settings.json'); @@ -140,7 +140,7 @@ export async function syncUserPiConfig(email: string): Promise { } export async function syncAllUserPiConfigs(): Promise { - const users = await officerdb.select({ email: Users.email }).from(Users); + const users = getUsers(); if (users.length === 0) return; const [appConfig, policy, apiKeys] = await Promise.all([ diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index b19db6a9..849d8cc3 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { getHomeDir } from '@@/data-path'; import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config'; -import { officerdb, Users } from 'officerdb'; +import { getUsers } from 'officerdb'; type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number }; type ShellInfo = { command: string; args: string[]; name: string }; @@ -269,7 +269,7 @@ const startHostSidecar = async () => { export const initTerminalSidecars = async () => { await startHostSidecar(); ensureDockerImage(); - const users = await officerdb.select({ id: Users.id, email: Users.email, username: Users.username }).from(Users); + const users = getUsers(); for (const user of users) { const homeDir = getHomeDir(user.email); mkdirSync(dirname(homeDir), { recursive: true }); diff --git a/src/servers/api/users/update-user.ts b/src/servers/api/users/update-user.ts index 078b1303..7ef86197 100644 --- a/src/servers/api/users/update-user.ts +++ b/src/servers/api/users/update-user.ts @@ -1,5 +1,5 @@ import type { Handler } from 'hono'; -import { officerdb, eq, Users } from 'officerdb'; +import { updateUser } from 'officerdb'; import * as errors from '@@/custom-errors'; export const updateUserHandler: Handler = async function (ctx) { @@ -8,10 +8,11 @@ export const updateUserHandler: Handler = async function (ctx) { if (typeof name !== 'string') throw errors.BAD_REQUEST('Name is required'); - await officerdb - .update(Users) - .set({ name, username: typeof username === 'string' ? username : undefined, avatar: avatar ?? null }) - .where(eq(Users.id, reqUser.id)); + await updateUser(reqUser.id, { + name, + username: typeof username === 'string' ? username : undefined, + avatar: avatar ?? null, + }); return ctx.json({ ok: true }); }; diff --git a/src/servers/api/users/users-router.ts b/src/servers/api/users/users-router.ts index c6982ca2..e602f6d8 100644 --- a/src/servers/api/users/users-router.ts +++ b/src/servers/api/users/users-router.ts @@ -1,4 +1,4 @@ -import { officerdb, eq, Users } from 'officerdb'; +import { getUsers, getUserByEmail, getUserById, createUser, deleteUser } from 'officerdb'; import { createRouter } from '@@/create-router'; import { sign } from '@@/jwt'; import { USER_ROLES } from 'definitions'; @@ -15,7 +15,7 @@ usersRouter.get('/', async (ctx) => { const user = ctx.get('user'); if (user.role !== 'Super Admin') throw errors.FORBIDDEN(); - const users = await officerdb.query.Users.findMany(); + const users = getUsers(); const sanitized = users.map(({ password, ...rest }) => rest); return ctx.json(sanitized); @@ -39,17 +39,14 @@ usersRouter.post('/invite', async (ctx) => { ? (role as (typeof USER_ROLES)[number]) : ('Member' as const); - const existing = await officerdb.query.Users.findFirst({ where: eq(Users.email, email) }); + const existing = getUserByEmail(email); if (existing) throw errors.CONFLICT('A user with this email already exists'); - const insertedUsers = await officerdb.insert(Users).values({ + const dbUser = await createUser({ email, role: assignedRole, status: 'Invited', - }).returning(); - - if (!insertedUsers || insertedUsers.length === 0) throw errors.INTERNAL_SERVER_ERROR('Failed to create user'); - const dbUser = insertedUsers[0]!; + }); const origin = ctx.get('origin'); const verificationCode = await sign({ id: dbUser.id, email: dbUser.email }, '24h'); @@ -74,7 +71,7 @@ usersRouter.post('/:id/resend-invite', async (ctx) => { const id = Number(ctx.req.param('id')); if (!id || isNaN(id)) throw errors.BAD_REQUEST('Invalid user ID'); - const target = await officerdb.query.Users.findFirst({ where: eq(Users.id, id) }); + const target = getUserById(id); if (!target) throw errors.NOT_FOUND('User not found'); if (target.status !== 'Invited') throw errors.BAD_REQUEST('User is not in Invited status'); @@ -101,9 +98,9 @@ usersRouter.delete('/:id', async (ctx) => { if (!id || isNaN(id)) throw errors.BAD_REQUEST('Invalid user ID'); if (id === reqUser.id) throw errors.BAD_REQUEST('Cannot delete yourself'); - const target = await officerdb.query.Users.findFirst({ where: eq(Users.id, id) }); + const target = getUserById(id); if (!target) throw errors.NOT_FOUND('User not found'); - await officerdb.delete(Users).where(eq(Users.id, id)); + await deleteUser(id); return ctx.json({ ok: true }); }); diff --git a/src/servers/bootstrap.ts b/src/servers/bootstrap.ts index 6207a647..a31292ba 100644 --- a/src/servers/bootstrap.ts +++ b/src/servers/bootstrap.ts @@ -4,10 +4,13 @@ import { homedir } from 'node:os'; import { DATA_PATH, PI_CONFIG_DIR } from './data-path'; import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config'; import { syncAllUserPiConfigs } from './api/server-settings/sync-user-pi-config'; +import { initAuthStore } from 'officerdb'; mkdirSync(DATA_PATH, { recursive: true }); mkdirSync(PI_CONFIG_DIR, { recursive: true }); +await initAuthStore(); + async function ensurePiInstalled(): Promise { try { const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });