Discord
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user