file based auth
This commit is contained in:
@@ -141,11 +141,6 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"definitions": "workspace:*",
|
"definitions": "workspace:*",
|
||||||
"drizzle-orm": "^0.45.1",
|
|
||||||
"postgres": "^3.4.5",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"drizzle-kit": "^0.31.8",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"src/workspaces/components": {
|
"src/workspaces/components": {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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");
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE "users" ADD COLUMN "username" varchar(128);
|
|
||||||
@@ -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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -8,18 +8,7 @@
|
|||||||
"./types": "./src/types.ts"
|
"./types": "./src/types.ts"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"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": {
|
"dependencies": {
|
||||||
"definitions": "workspace:*",
|
"definitions": "workspace:*"
|
||||||
"drizzle-orm": "^0.45.1",
|
|
||||||
"postgres": "^3.4.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"drizzle-kit": "^0.31.8"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!"
|
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
import { config } from 'dotenv';
|
export {
|
||||||
config({ path: '../../../../.env' });
|
initAuthStore,
|
||||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
getUsers,
|
||||||
import postgres from 'postgres';
|
getUserById,
|
||||||
import * as Schema from './schema';
|
getUserByEmail,
|
||||||
export * from './schema';
|
getUserCount,
|
||||||
export * from 'drizzle-orm';
|
createUser,
|
||||||
|
updateUser,
|
||||||
const { POSTGRES_URL } = process.env;
|
deleteUser,
|
||||||
console.log('POSTGRES_URL', POSTGRES_URL);
|
getPasskeysByEmail,
|
||||||
const pgClient = postgres(POSTGRES_URL!);
|
getPasskeysByEmailAndOrigin,
|
||||||
|
getPasskeyByCredentialId,
|
||||||
const officerdb = drizzle(pgClient, { schema: Schema });
|
createPasskey,
|
||||||
|
updatePasskey,
|
||||||
export { officerdb, pgClient };
|
storeChallenge,
|
||||||
|
consumeChallenge,
|
||||||
|
blacklistToken,
|
||||||
|
isTokenBlacklisted,
|
||||||
|
cleanupExpiredTokens,
|
||||||
|
} from './store';
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from './users';
|
|
||||||
export * from './passkeys';
|
|
||||||
export * from './passkey-challenges';
|
|
||||||
export * from './token-blacklist';
|
|
||||||
@@ -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),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
@@ -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],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
@@ -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)],
|
|
||||||
);
|
|
||||||
@@ -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),
|
|
||||||
}));
|
|
||||||
@@ -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<T>(path: string, fallback: T): Promise<T> {
|
||||||
|
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<UserSelect> {
|
||||||
|
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<Omit<UserSelect, 'id'>>): Promise<UserSelect | undefined> {
|
||||||
|
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<boolean> {
|
||||||
|
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<PasskeySelect> {
|
||||||
|
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<Omit<PasskeySelect, 'id'>>,
|
||||||
|
): Promise<PasskeySelect | undefined> {
|
||||||
|
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<string | null> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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 $$;
|
|
||||||
@@ -1,21 +1,50 @@
|
|||||||
import * as Schema from './schema';
|
import type { USER_ROLES, USER_STATUSES } from 'definitions';
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
export type PasskeySelect = typeof Schema.Passkeys.$inferSelect;
|
export type UserSelect = {
|
||||||
export type PasskeyInsert = typeof Schema.Passkeys.$inferInsert;
|
id: number;
|
||||||
export type Passkey = PasskeySelect & {
|
email: string;
|
||||||
user: User;
|
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 & {
|
export type User = UserSelect & {
|
||||||
passkeys: Passkey[];
|
passkeys: Passkey[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Security
|
export type PasskeySelect = {
|
||||||
export type PasskeyChallenge = typeof Schema.PasskeyChallenges.$inferSelect;
|
id: number;
|
||||||
export type PasskeyChallengeInsert = typeof Schema.PasskeyChallenges.$inferInsert;
|
email: string;
|
||||||
|
origin: string | null;
|
||||||
|
credentialId: string | null;
|
||||||
|
publicKey: string | null;
|
||||||
|
counter: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type TokenBlacklist = typeof Schema.TokenBlacklist.$inferSelect;
|
export type PasskeyInsert = {
|
||||||
export type TokenBlacklistInsert = typeof Schema.TokenBlacklist.$inferInsert;
|
email: string;
|
||||||
|
origin?: string | null;
|
||||||
|
credentialId?: string | null;
|
||||||
|
publicKey?: string | null;
|
||||||
|
counter?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Passkey = PasskeySelect & {
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
|||||||
+2
-6
@@ -1,10 +1,9 @@
|
|||||||
import './servers/bootstrap';
|
import './servers/bootstrap';
|
||||||
import type { ServerWebSocket } from 'bun';
|
import type { ServerWebSocket } from 'bun';
|
||||||
import { serve } from 'bun';
|
import { serve } from 'bun';
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { honoServer } from './servers/hono';
|
import { honoServer } from './servers/hono';
|
||||||
import { verify } from './servers/jwt';
|
import { verify } from './servers/jwt';
|
||||||
import { officerdb, TokenBlacklist } from 'officerdb';
|
import { isTokenBlacklisted } from 'officerdb';
|
||||||
import { terminalWebsocket, initTerminalSidecars } from './servers/api/terminal/websocket';
|
import { terminalWebsocket, initTerminalSidecars } from './servers/api/terminal/websocket';
|
||||||
import { piWebsocket } from './servers/api/pi/websocket';
|
import { piWebsocket } from './servers/api/pi/websocket';
|
||||||
import { findEntryBySlug, touchEntry } from './servers/api/dev-server/router';
|
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) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
if (user.jti) {
|
if (user.jti) {
|
||||||
const blacklisted = await officerdb.query.TokenBlacklist.findFirst({
|
if (isTokenBlacklisted(user.jti)) return new Response('Unauthorized', { status: 401 });
|
||||||
where: eq(TokenBlacklist.jti, user.jti),
|
|
||||||
});
|
|
||||||
if (blacklisted) return new Response('Unauthorized', { status: 401 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { MiddlewareHandler } from 'hono';
|
|||||||
import { verify } from '@@/jwt';
|
import { verify } from '@@/jwt';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { isOriginAllowed } from './origin-validation';
|
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
|
// Role permissions: which HTTP methods each role can use
|
||||||
// Roles not listed here are denied by default (fail-safe)
|
// 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)
|
// Check if token is blacklisted (explicit signout)
|
||||||
if (user.jti) {
|
if (user.jti) {
|
||||||
const blacklisted = await officerdb.query.TokenBlacklist.findFirst({
|
if (isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED();
|
||||||
where: eq(TokenBlacklist.jti, user.jti),
|
|
||||||
});
|
|
||||||
if (blacklisted) throw errors.UNAUTHORIZED();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token was issued before password change
|
// Check if token was issued before password change
|
||||||
if (user.iat && user.id) {
|
if (user.iat && user.id) {
|
||||||
const dbUser = await officerdb.query.Users.findFirst({
|
const dbUser = getUserById(user.id);
|
||||||
where: eq(Users.id, user.id),
|
|
||||||
columns: { passwordChangedAt: true },
|
|
||||||
});
|
|
||||||
if (dbUser?.passwordChangedAt) {
|
if (dbUser?.passwordChangedAt) {
|
||||||
// iat is in seconds, passwordChangedAt is in milliseconds
|
// iat is in seconds, passwordChangedAt is in milliseconds
|
||||||
const tokenIssuedAt = user.iat * 1000;
|
const tokenIssuedAt = user.iat * 1000;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import { sendMail } from 'emailer';
|
import { sendMail } from 'emailer';
|
||||||
import { officerdb, count, Users } from 'officerdb';
|
import { getUserCount, createUser } from 'officerdb';
|
||||||
import { sign, verify } from '@@/jwt';
|
import { sign, verify } from '@@/jwt';
|
||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
@@ -13,8 +13,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
|||||||
const token = body.token as string;
|
const token = body.token as string;
|
||||||
const email = body.email as string;
|
const email = body.email as string;
|
||||||
|
|
||||||
const result = await officerdb.select({ count: count() }).from(Users);
|
const userCount = getUserCount();
|
||||||
const userCount = result[0]?.count ?? 0;
|
|
||||||
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
|
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -35,7 +34,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
|||||||
return ctx.json({ ok: true });
|
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');
|
if (!payload?.email) throw errors.BAD_REQUEST('Invalid or expired token');
|
||||||
|
|
||||||
const name = body.name as string;
|
const name = body.name as string;
|
||||||
@@ -50,16 +49,15 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
|||||||
|
|
||||||
const passwordHash = await argon2.hash(password);
|
const passwordHash = await argon2.hash(password);
|
||||||
|
|
||||||
const insertedUsers = await officerdb.insert(Users).values({
|
await createUser({
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
password: passwordHash,
|
password: passwordHash,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
role: 'Super Admin',
|
role: 'Super Admin',
|
||||||
status: 'Active',
|
status: 'Active',
|
||||||
}).returning();
|
});
|
||||||
|
|
||||||
if (!insertedUsers || insertedUsers.length === 0) throw errors.INTERNAL_SERVER_ERROR('Failed to create user');
|
|
||||||
syncUserPiConfig(payload.email).catch(() => {});
|
syncUserPiConfig(payload.email).catch(() => {});
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import { officerdb, eq, Users } from 'officerdb';
|
import { getUserById, updateUser } from 'officerdb';
|
||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
import { sign } from '@@/jwt';
|
import { sign } from '@@/jwt';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
@@ -13,10 +13,7 @@ export const changePasswordHandler: Handler = async function (ctx) {
|
|||||||
if (isProduction) validatePassword(newPassword);
|
if (isProduction) validatePassword(newPassword);
|
||||||
const reqUser = ctx.get('user');
|
const reqUser = ctx.get('user');
|
||||||
|
|
||||||
const dbUser = await officerdb.query.Users.findFirst({
|
const dbUser = getUserById(reqUser.id);
|
||||||
where: eq(Users.id, reqUser.id),
|
|
||||||
columns: { password: true, username: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dbUser) throw errors.UNAUTHORIZED();
|
if (!dbUser) throw errors.UNAUTHORIZED();
|
||||||
|
|
||||||
@@ -28,7 +25,7 @@ export const changePasswordHandler: Handler = async function (ctx) {
|
|||||||
const newPasswordHash = await argon2.hash(newPassword);
|
const newPasswordHash = await argon2.hash(newPassword);
|
||||||
// Use floored seconds-to-ms so the token iat (also floored) is never behind
|
// Use floored seconds-to-ms so the token iat (also floored) is never behind
|
||||||
const passwordChangedAt = Math.floor(Date.now() / 1000) * 1000;
|
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 { id, email, name, role } = reqUser;
|
||||||
const username = dbUser.username ?? reqUser.username;
|
const username = dbUser.username ?? reqUser.username;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import { officerdb, eq, Users } from 'officerdb';
|
import { getUserByEmail } from 'officerdb';
|
||||||
import { sign } from '@@/jwt';
|
import { sign } from '@@/jwt';
|
||||||
import { sendMail } from 'emailer';
|
import { sendMail } from 'emailer';
|
||||||
|
|
||||||
@@ -7,9 +7,7 @@ export const forgotPasswordHandler: Handler = async function (ctx) {
|
|||||||
const { email } = ctx.get('body');
|
const { email } = ctx.get('body');
|
||||||
const origin = ctx.get('origin');
|
const origin = ctx.get('origin');
|
||||||
|
|
||||||
const dbUser = await officerdb.query.Users.findFirst({
|
const dbUser = getUserByEmail(email);
|
||||||
where: eq(Users.email, email),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dbUser) return ctx.json({ ok: true });
|
if (!dbUser) return ctx.json({ ok: true });
|
||||||
const verificationCode = await sign({ id: dbUser.id, email, purpose: 'reset-password' }, '6h');
|
const verificationCode = await sign({ id: dbUser.id, email, purpose: 'reset-password' }, '6h');
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import { createRouter } from '../../create-router';
|
|||||||
import { userMiddleware, passkeyRateLimiter } from '../../_middlewares';
|
import { userMiddleware, passkeyRateLimiter } from '../../_middlewares';
|
||||||
import { sign } from '../../jwt';
|
import { sign } from '../../jwt';
|
||||||
import * as errors from '../../custom-errors';
|
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 {
|
import {
|
||||||
generateRegistrationOptions,
|
generateRegistrationOptions,
|
||||||
verifyRegistrationResponse,
|
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<string | null> {
|
|
||||||
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
|
// Generate registration options (challenge) for creating a new passkey
|
||||||
const passkeyRouterPostChallenge: Handler = async (ctx) => {
|
const passkeyRouterPostChallenge: Handler = async (ctx) => {
|
||||||
const { email } = ctx.req.param();
|
const { email } = ctx.req.param();
|
||||||
@@ -73,9 +42,7 @@ const passkeyRouterPostChallenge: Handler = async (ctx) => {
|
|||||||
const rpId = getRpId(origin);
|
const rpId = getRpId(origin);
|
||||||
|
|
||||||
// Get existing passkeys to exclude them
|
// Get existing passkeys to exclude them
|
||||||
const existingPasskeys = await officerdb.query.Passkeys.findMany({
|
const existingPasskeys = getPasskeysByEmailAndOrigin(email!, origin);
|
||||||
where: and(eq(Passkeys.email, email!), eq(Passkeys.origin, origin)),
|
|
||||||
});
|
|
||||||
|
|
||||||
const options = await generateRegistrationOptions({
|
const options = await generateRegistrationOptions({
|
||||||
rpName: RP_NAME,
|
rpName: RP_NAME,
|
||||||
@@ -104,7 +71,7 @@ const passkeyRouterPost: Handler = async (ctx) => {
|
|||||||
const { email } = ctx.get('user') as User;
|
const { email } = ctx.get('user') as User;
|
||||||
const response = ctx.get('body') as RegistrationResponseJSON;
|
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();
|
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
|
||||||
|
|
||||||
const verification = await verifyRegistrationResponse({
|
const verification = await verifyRegistrationResponse({
|
||||||
@@ -120,15 +87,14 @@ const passkeyRouterPost: Handler = async (ctx) => {
|
|||||||
|
|
||||||
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
||||||
|
|
||||||
const values = {
|
await createPasskey({
|
||||||
email,
|
email,
|
||||||
origin,
|
origin,
|
||||||
credentialId: credential.id,
|
credentialId: credential.id,
|
||||||
publicKey: Buffer.from(credential.publicKey).toString('base64'),
|
publicKey: Buffer.from(credential.publicKey).toString('base64'),
|
||||||
counter: credential.counter,
|
counter: credential.counter,
|
||||||
};
|
});
|
||||||
|
|
||||||
await officerdb.insert(Passkeys).values(values);
|
|
||||||
return ctx.json({ ok: true, credentialDeviceType, credentialBackedUp });
|
return ctx.json({ ok: true, credentialDeviceType, credentialBackedUp });
|
||||||
};
|
};
|
||||||
passkeyRouter.post('/credentials', userMiddleware, passkeyRouterPost);
|
passkeyRouter.post('/credentials', userMiddleware, passkeyRouterPost);
|
||||||
@@ -139,9 +105,7 @@ const passkeyRouterGet: Handler = async (ctx) => {
|
|||||||
const origin = ctx.get('origin') as string;
|
const origin = ctx.get('origin') as string;
|
||||||
const rpId = getRpId(origin);
|
const rpId = getRpId(origin);
|
||||||
|
|
||||||
const passkeys = await officerdb.query.Passkeys.findMany({
|
const passkeys = getPasskeysByEmailAndOrigin(email!, origin);
|
||||||
where: and(eq(Passkeys.email, email!), eq(Passkeys.origin, origin)),
|
|
||||||
});
|
|
||||||
|
|
||||||
const options = await generateAuthenticationOptions({
|
const options = await generateAuthenticationOptions({
|
||||||
rpID: rpId,
|
rpID: rpId,
|
||||||
@@ -163,13 +127,11 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
|
|||||||
const rpId = getRpId(origin);
|
const rpId = getRpId(origin);
|
||||||
const response = ctx.get('body') as AuthenticationResponseJSON;
|
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();
|
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
|
||||||
|
|
||||||
// Find the passkey being used
|
// Find the passkey being used
|
||||||
const dbPasskey = await officerdb.query.Passkeys.findFirst({
|
const dbPasskey = getPasskeyByCredentialId(email!, response.id);
|
||||||
where: and(eq(Passkeys.email, email!), eq(Passkeys.credentialId, response.id)),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dbPasskey || !dbPasskey.publicKey) throw errors.BAD_CREDENTIALS();
|
if (!dbPasskey || !dbPasskey.publicKey) throw errors.BAD_CREDENTIALS();
|
||||||
|
|
||||||
@@ -188,27 +150,21 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
|
|||||||
if (!verification.verified) throw errors.BAD_CREDENTIALS();
|
if (!verification.verified) throw errors.BAD_CREDENTIALS();
|
||||||
|
|
||||||
// Update counter to prevent replay attacks
|
// Update counter to prevent replay attacks
|
||||||
await officerdb
|
await updatePasskey(dbPasskey.id, { counter: verification.authenticationInfo.newCounter });
|
||||||
.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 },
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const dbUser = getUserByEmail(email!);
|
||||||
if (!dbUser) throw errors.UNAUTHORIZED();
|
if (!dbUser) throw errors.UNAUTHORIZED();
|
||||||
|
|
||||||
|
const passkeys = getPasskeysByEmailAndOrigin(email!, origin);
|
||||||
|
|
||||||
const { id, name, username, role } = dbUser;
|
const { id, name, username, role } = dbUser;
|
||||||
const passkeys = dbUser.passkeys?.length ?? 0;
|
|
||||||
const token = await sign({
|
const token = await sign({
|
||||||
id,
|
id,
|
||||||
email,
|
email,
|
||||||
name,
|
name,
|
||||||
username,
|
username,
|
||||||
role,
|
role,
|
||||||
passkeys,
|
passkeys: passkeys.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
return ctx.json({
|
return ctx.json({
|
||||||
@@ -219,7 +175,7 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
|
|||||||
name,
|
name,
|
||||||
username,
|
username,
|
||||||
role,
|
role,
|
||||||
passkeys,
|
passkeys: passkeys.length,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import { officerdb, eq, Users } from 'officerdb';
|
import { getUserByEmail } from 'officerdb';
|
||||||
import { sign } from '@@/jwt';
|
import { sign } from '@@/jwt';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { sendMail } from 'emailer';
|
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');
|
if (!email || typeof email !== 'string') throw errors.BAD_REQUEST('Email is required');
|
||||||
|
|
||||||
const user = await officerdb.query.Users.findFirst({
|
const user = getUserByEmail(email);
|
||||||
where: eq(Users.email, email),
|
|
||||||
});
|
|
||||||
if (!user) throw errors.NOT_FOUND('User not found');
|
if (!user) throw errors.NOT_FOUND('User not found');
|
||||||
if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
|
if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { User } from 'types';
|
import type { User } from 'types';
|
||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import { officerdb, eq, Users } from 'officerdb';
|
import { updateUser } from 'officerdb';
|
||||||
import { verify } from '@@/jwt';
|
import { verify } from '@@/jwt';
|
||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
@@ -13,10 +13,7 @@ export const resetPasswordHandler: Handler = async function (ctx) {
|
|||||||
const userInfo = (await verify(verificationCode)) as User;
|
const userInfo = (await verify(verificationCode)) as User;
|
||||||
if (!userInfo) throw errors.UNAUTHORIZED();
|
if (!userInfo) throw errors.UNAUTHORIZED();
|
||||||
const passwordHash = await argon2.hash(password);
|
const passwordHash = await argon2.hash(password);
|
||||||
await officerdb
|
await updateUser(userInfo.id, { password: passwordHash, status: 'Active', passwordChangedAt: now });
|
||||||
.update(Users)
|
|
||||||
.set({ password: passwordHash, status: 'Active', passwordChangedAt: now })
|
|
||||||
.where(eq(Users.id, userInfo.id));
|
|
||||||
|
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { officerdb, eq, and, Users, Passkeys } from 'officerdb';
|
import { getUserByEmail, getPasskeysByEmailAndOrigin } from 'officerdb';
|
||||||
import { sign } from '@@/jwt';
|
import { sign } from '@@/jwt';
|
||||||
import { getClaudeDir } from '@@/data-path';
|
import { getClaudeDir } from '@@/data-path';
|
||||||
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
|
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) {
|
export const signinHandler: Handler = async function (ctx) {
|
||||||
const { email, password } = ctx.get('body');
|
const { email, password } = ctx.get('body');
|
||||||
const origin = ctx.get('origin');
|
const origin = ctx.get('origin');
|
||||||
const dbUser = await officerdb.query.Users.findFirst({
|
const dbUser = getUserByEmail(email);
|
||||||
where: eq(Users.email, email),
|
|
||||||
});
|
|
||||||
|
|
||||||
const passkeys = await officerdb.query.Passkeys.findMany({
|
const passkeys = getPasskeysByEmailAndOrigin(email, origin);
|
||||||
where: and(eq(Passkeys.email, email), eq(Passkeys.origin, origin)),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dbUser || !dbUser.password) throw errors.UNAUTHORIZED();
|
if (!dbUser || !dbUser.password) throw errors.UNAUTHORIZED();
|
||||||
const { status } = dbUser;
|
const { status } = dbUser;
|
||||||
|
|||||||
@@ -1,23 +1,10 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import { officerdb, TokenBlacklist, lt } from 'officerdb';
|
import { blacklistToken, cleanupExpiredTokens } 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));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const signoutHandler: Handler = async (ctx) => {
|
export const signoutHandler: Handler = async (ctx) => {
|
||||||
const user = ctx.get('user') as { jti: string; exp: number };
|
const user = ctx.get('user') as { jti: string; exp: number };
|
||||||
|
|
||||||
// Add token to blacklist
|
await blacklistToken(user.jti, user.exp);
|
||||||
await officerdb
|
|
||||||
.insert(TokenBlacklist)
|
|
||||||
.values({
|
|
||||||
jti: user.jti,
|
|
||||||
expiresAt: user.exp,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
|
|
||||||
// Opportunistic cleanup of expired tokens (non-blocking)
|
// Opportunistic cleanup of expired tokens (non-blocking)
|
||||||
cleanupExpiredTokens().catch(() => {});
|
cleanupExpiredTokens().catch(() => {});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import { officerdb, count, Users } from 'officerdb';
|
import { getUserCount, createUser } from 'officerdb';
|
||||||
import { sign } from '@@/jwt';
|
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 * as errors from '@@/custom-errors';
|
||||||
import { sendMail } from 'emailer';
|
import { sendMail } from 'emailer';
|
||||||
|
|
||||||
@@ -13,19 +13,14 @@ export const signupHandler: Handler = async function (ctx) {
|
|||||||
throw errors.BAD_REQUEST('Invalid email address');
|
throw errors.BAD_REQUEST('Invalid email address');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await officerdb.select({ count: count() }).from(Users);
|
const userCount = getUserCount();
|
||||||
const userCount = result[0]?.count ?? 0;
|
|
||||||
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
|
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
|
||||||
|
|
||||||
const newUser = {
|
const dbUser = await createUser({
|
||||||
email: body.email as string,
|
email: body.email as string,
|
||||||
status: 'Unverified' as (typeof USER_STATUSES)[number],
|
status: 'Unverified' as (typeof USER_STATUSES)[number],
|
||||||
role: 'Admin' as (typeof USER_ROLES)[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 verificationCode = await sign({ id: dbUser.id, email: dbUser.email }, '24h');
|
||||||
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
|
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import type { User } from 'types';
|
import type { User } from 'types';
|
||||||
import * as errors from '@@/custom-errors';
|
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) {
|
export const usersMe: Handler = async function (ctx) {
|
||||||
const user = ctx.get('user') as User;
|
const user = ctx.get('user') as User;
|
||||||
const origin = ctx.get('origin') as string;
|
const origin = ctx.get('origin') as string;
|
||||||
|
|
||||||
const dbUser = await officerdb.query.Users.findFirst({
|
const dbUser = getUserById(user.id);
|
||||||
where: eq(Users.id, user.id),
|
|
||||||
columns: { password: false },
|
|
||||||
with: {
|
|
||||||
passkeys: { where: eq(Passkeys.origin, origin || '') },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dbUser) return errors.NOT_FOUND();
|
if (!dbUser) return errors.NOT_FOUND();
|
||||||
|
|
||||||
const { passkeys, ...userWithoutPasskeys } = dbUser;
|
const passkeys = getPasskeysByEmailAndOrigin(dbUser.email, origin || '');
|
||||||
const returnUser = { ...userWithoutPasskeys, passkeyCount: passkeys.length };
|
const { password, ...userWithoutPassword } = dbUser;
|
||||||
|
const returnUser = { ...userWithoutPassword, passkeyCount: passkeys.length };
|
||||||
|
|
||||||
return ctx.json(returnUser);
|
return ctx.json(returnUser);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import type { User } from 'types';
|
import type { User } from 'types';
|
||||||
import { officerdb, eq, Users } from 'officerdb';
|
import { getUserById } from 'officerdb';
|
||||||
import { verify } from '@@/jwt';
|
import { verify } from '@@/jwt';
|
||||||
import * as errors from '@@/custom-errors';
|
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');
|
if (!userInfo?.id) throw errors.BAD_REQUEST('Token is invalid or expired');
|
||||||
|
|
||||||
const user = await officerdb.query.Users.findFirst({
|
const user = getUserById(userInfo.id);
|
||||||
where: eq(Users.id, userInfo.id),
|
|
||||||
});
|
|
||||||
if (!user) throw errors.NOT_FOUND('User not found');
|
if (!user) throw errors.NOT_FOUND('User not found');
|
||||||
|
|
||||||
// Reset-password tokens skip the verification status check
|
// Reset-password tokens skip the verification status check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import type { User } from 'types';
|
import type { User } from 'types';
|
||||||
import { officerdb, eq, Users } from 'officerdb';
|
import { getUserById, updateUser } from 'officerdb';
|
||||||
import { verify as verifyJwt, sign } from '@@/jwt';
|
import { verify as verifyJwt, sign } from '@@/jwt';
|
||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
@@ -11,9 +11,7 @@ export const verifyHandler: Handler = async function (ctx) {
|
|||||||
const userInfo = (await verifyJwt(verificationCode)) as User;
|
const userInfo = (await verifyJwt(verificationCode)) as User;
|
||||||
if (!userInfo) throw errors.BAD_REQUEST();
|
if (!userInfo) throw errors.BAD_REQUEST();
|
||||||
|
|
||||||
const user = await officerdb.query.Users.findFirst({
|
const user = getUserById(userInfo.id);
|
||||||
where: eq(Users.id, userInfo.id),
|
|
||||||
});
|
|
||||||
if (!user) throw errors.NOT_FOUND('User not found');
|
if (!user) throw errors.NOT_FOUND('User not found');
|
||||||
|
|
||||||
const updates: Record<string, unknown> = { status: 'Active' };
|
const updates: Record<string, unknown> = { status: 'Active' };
|
||||||
@@ -37,16 +35,10 @@ export const verifyHandler: Handler = async function (ctx) {
|
|||||||
updates.password = await argon2.hash(password);
|
updates.password = await argon2.hash(password);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updatedUser] = await officerdb
|
await updateUser(userInfo.id, updates);
|
||||||
.update(Users)
|
|
||||||
.set(updates)
|
|
||||||
.where(eq(Users.id, userInfo.id))
|
|
||||||
.returning({ username: Users.username });
|
|
||||||
|
|
||||||
// Re-fetch user to get final values after update
|
// Re-fetch user to get final values after update
|
||||||
const finalUser = await officerdb.query.Users.findFirst({
|
const finalUser = getUserById(userInfo.id);
|
||||||
where: eq(Users.id, userInfo.id),
|
|
||||||
});
|
|
||||||
if (!finalUser) throw errors.NOT_FOUND('User not found');
|
if (!finalUser) throw errors.NOT_FOUND('User not found');
|
||||||
|
|
||||||
// Issue a token so the user is logged in immediately
|
// Issue a token so the user is logged in immediately
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { officerdb, count, Users } from 'officerdb';
|
import { getUserCount } from 'officerdb';
|
||||||
|
|
||||||
export const landingPageDataRouter = createRouter();
|
export const landingPageDataRouter = createRouter();
|
||||||
|
|
||||||
landingPageDataRouter.get('/', async (ctx) => {
|
landingPageDataRouter.get('/', async (ctx) => {
|
||||||
const result = await officerdb.select({ count: count() }).from(Users);
|
const userCount = getUserCount();
|
||||||
const userCount = result[0]?.count ?? 0;
|
|
||||||
return ctx.json({ registrationOpen: userCount === 0 });
|
return ctx.json({ registrationOpen: userCount === 0 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { homedir } from 'node:os';
|
|||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
import { readdirSync, existsSync } from 'node:fs';
|
import { readdirSync, existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { officerdb, count, Users } from 'officerdb';
|
|
||||||
import { claudeCodeRouter } from './claude-code';
|
import { claudeCodeRouter } from './claude-code';
|
||||||
import { opencodeRouter } from './opencode';
|
import { opencodeRouter } from './opencode';
|
||||||
import { piMonoRouter } from './pi-mono';
|
import { piMonoRouter } from './pi-mono';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { join } from 'node:path';
|
|||||||
import { mkdir, copyFile } from 'node:fs/promises';
|
import { mkdir, copyFile } from 'node:fs/promises';
|
||||||
import { PI_CONFIG_DIR, getUserPiConfigDir } from '../../data-path';
|
import { PI_CONFIG_DIR, getUserPiConfigDir } from '../../data-path';
|
||||||
import { readApiKeys, readAccessPolicy, PROVIDERS } from './pi-mono';
|
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_MODELS_FILE = join(PI_CONFIG_DIR, 'models.json');
|
||||||
const PI_SETTINGS_FILE = join(PI_CONFIG_DIR, 'settings.json');
|
const PI_SETTINGS_FILE = join(PI_CONFIG_DIR, 'settings.json');
|
||||||
@@ -140,7 +140,7 @@ export async function syncUserPiConfig(email: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function syncAllUserPiConfigs(): Promise<void> {
|
export async function syncAllUserPiConfigs(): Promise<void> {
|
||||||
const users = await officerdb.select({ email: Users.email }).from(Users);
|
const users = getUsers();
|
||||||
if (users.length === 0) return;
|
if (users.length === 0) return;
|
||||||
|
|
||||||
const [appConfig, policy, apiKeys] = await Promise.all([
|
const [appConfig, policy, apiKeys] = await Promise.all([
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { getHomeDir } from '@@/data-path';
|
import { getHomeDir } from '@@/data-path';
|
||||||
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
|
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 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 };
|
type ShellInfo = { command: string; args: string[]; name: string };
|
||||||
@@ -269,7 +269,7 @@ const startHostSidecar = async () => {
|
|||||||
export const initTerminalSidecars = async () => {
|
export const initTerminalSidecars = async () => {
|
||||||
await startHostSidecar();
|
await startHostSidecar();
|
||||||
ensureDockerImage();
|
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) {
|
for (const user of users) {
|
||||||
const homeDir = getHomeDir(user.email);
|
const homeDir = getHomeDir(user.email);
|
||||||
mkdirSync(dirname(homeDir), { recursive: true });
|
mkdirSync(dirname(homeDir), { recursive: true });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import { officerdb, eq, Users } from 'officerdb';
|
import { updateUser } from 'officerdb';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
|
|
||||||
export const updateUserHandler: Handler = async function (ctx) {
|
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');
|
if (typeof name !== 'string') throw errors.BAD_REQUEST('Name is required');
|
||||||
|
|
||||||
await officerdb
|
await updateUser(reqUser.id, {
|
||||||
.update(Users)
|
name,
|
||||||
.set({ name, username: typeof username === 'string' ? username : undefined, avatar: avatar ?? null })
|
username: typeof username === 'string' ? username : undefined,
|
||||||
.where(eq(Users.id, reqUser.id));
|
avatar: avatar ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { officerdb, eq, Users } from 'officerdb';
|
import { getUsers, getUserByEmail, getUserById, createUser, deleteUser } from 'officerdb';
|
||||||
import { createRouter } from '@@/create-router';
|
import { createRouter } from '@@/create-router';
|
||||||
import { sign } from '@@/jwt';
|
import { sign } from '@@/jwt';
|
||||||
import { USER_ROLES } from 'definitions';
|
import { USER_ROLES } from 'definitions';
|
||||||
@@ -15,7 +15,7 @@ usersRouter.get('/', async (ctx) => {
|
|||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
if (user.role !== 'Super Admin') throw errors.FORBIDDEN();
|
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);
|
const sanitized = users.map(({ password, ...rest }) => rest);
|
||||||
|
|
||||||
return ctx.json(sanitized);
|
return ctx.json(sanitized);
|
||||||
@@ -39,17 +39,14 @@ usersRouter.post('/invite', async (ctx) => {
|
|||||||
? (role as (typeof USER_ROLES)[number])
|
? (role as (typeof USER_ROLES)[number])
|
||||||
: ('Member' as const);
|
: ('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');
|
if (existing) throw errors.CONFLICT('A user with this email already exists');
|
||||||
|
|
||||||
const insertedUsers = await officerdb.insert(Users).values({
|
const dbUser = await createUser({
|
||||||
email,
|
email,
|
||||||
role: assignedRole,
|
role: assignedRole,
|
||||||
status: 'Invited',
|
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 origin = ctx.get('origin');
|
||||||
const verificationCode = await sign({ id: dbUser.id, email: dbUser.email }, '24h');
|
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'));
|
const id = Number(ctx.req.param('id'));
|
||||||
if (!id || isNaN(id)) throw errors.BAD_REQUEST('Invalid user 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) throw errors.NOT_FOUND('User not found');
|
||||||
if (target.status !== 'Invited') throw errors.BAD_REQUEST('User is not in Invited status');
|
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 || isNaN(id)) throw errors.BAD_REQUEST('Invalid user ID');
|
||||||
if (id === reqUser.id) throw errors.BAD_REQUEST('Cannot delete yourself');
|
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');
|
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 });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { homedir } from 'node:os';
|
|||||||
import { DATA_PATH, PI_CONFIG_DIR } from './data-path';
|
import { DATA_PATH, PI_CONFIG_DIR } from './data-path';
|
||||||
import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config';
|
import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config';
|
||||||
import { syncAllUserPiConfigs } from './api/server-settings/sync-user-pi-config';
|
import { syncAllUserPiConfigs } from './api/server-settings/sync-user-pi-config';
|
||||||
|
import { initAuthStore } from 'officerdb';
|
||||||
|
|
||||||
mkdirSync(DATA_PATH, { recursive: true });
|
mkdirSync(DATA_PATH, { recursive: true });
|
||||||
mkdirSync(PI_CONFIG_DIR, { recursive: true });
|
mkdirSync(PI_CONFIG_DIR, { recursive: true });
|
||||||
|
|
||||||
|
await initAuthStore();
|
||||||
|
|
||||||
async function ensurePiInstalled(): Promise<boolean> {
|
async function ensurePiInstalled(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
|
const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
|||||||
Reference in New Issue
Block a user