This commit is contained in:
2026-02-27 08:27:43 +00:00
parent 7bf55af5b3
commit bc4c20929c
48 changed files with 4344 additions and 275 deletions
+1
View File
@@ -31,6 +31,7 @@ export {
getUserIntegration,
upsertUserIntegration,
deleteUserIntegration,
findUserByIntegrationConfig,
} from './queries/integrations';
export { db } from './db';
@@ -1,6 +1,7 @@
import { eq, and } from 'drizzle-orm';
import { eq, and, sql } from 'drizzle-orm';
import { db } from '../db';
import { serverIntegrations, userIntegrations } from '../schema';
import { users } from '../schema/auth';
import type { ServerIntegrationSelect, UserIntegrationSelect } from '../types';
// ── Server Integrations ──
@@ -71,3 +72,41 @@ export async function deleteUserIntegration(userId: number, provider: string): P
.returning({ id: userIntegrations.id });
return result.length > 0;
}
// ── Cross-table lookup ──
type UserIntegrationWithUser = UserIntegrationSelect & {
user: { id: number; email: string; username: string | null; role: string };
};
export async function findUserByIntegrationConfig(
provider: string,
configKey: string,
configValue: string,
): Promise<UserIntegrationWithUser | undefined> {
const [row] = await db
.select({
id: userIntegrations.id,
userId: userIntegrations.userId,
provider: userIntegrations.provider,
serverIntegrationId: userIntegrations.serverIntegrationId,
config: userIntegrations.config,
createdAt: userIntegrations.createdAt,
updatedAt: userIntegrations.updatedAt,
user: {
id: users.id,
email: users.email,
username: users.username,
role: users.role,
},
})
.from(userIntegrations)
.innerJoin(users, eq(userIntegrations.userId, users.id))
.where(
and(
eq(userIntegrations.provider, provider),
sql`${userIntegrations.config}->>${configKey} = ${configValue}`,
),
);
return row as UserIntegrationWithUser | undefined;
}
-230
View File
@@ -1,230 +0,0 @@
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();
}