file based auth
This commit is contained in:
@@ -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"
|
||||
},
|
||||
"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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
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';
|
||||
|
||||
@@ -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
|
||||
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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user