feat(14-03): convert auth/oauth/csv services to async, await seedDefaults

- auth.service.ts: 10 functions async, removed .all()/.get()/.run()
- oauth.service.ts: 7 functions async, boolean conversion (used: true/false)
- csv.service.ts: export/import functions async, removed .all()/.get()/.run()
- server index.ts: seedDefaults() now awaited for async DB
- PGlite smoke test confirms async services work end-to-end
This commit is contained in:
2026-04-04 12:35:18 +02:00
parent 4d705af3f1
commit 75bf3e0dcd
4 changed files with 102 additions and 112 deletions

View File

@@ -16,7 +16,7 @@ import { threadRoutes } from "./routes/threads.ts";
import { totalRoutes } from "./routes/totals.ts"; import { totalRoutes } from "./routes/totals.ts";
// Seed default data on startup // Seed default data on startup
seedDefaults(); await seedDefaults();
const app = new Hono(); const app = new Hono();

View File

@@ -13,7 +13,11 @@ export async function createUser(
password: string, password: string,
) { ) {
const passwordHash = await Bun.password.hash(password); const passwordHash = await Bun.password.hash(password);
return db.insert(users).values({ username, passwordHash }).returning().get(); const [row] = await db
.insert(users)
.values({ username, passwordHash })
.returning();
return row;
} }
export async function verifyPassword( export async function verifyPassword(
@@ -21,11 +25,10 @@ export async function verifyPassword(
username: string, username: string,
password: string, password: string,
) { ) {
const user = db const [user] = await db
.select() .select()
.from(users) .from(users)
.where(eq(users.username, username)) .where(eq(users.username, username));
.get();
if (!user) return null; if (!user) return null;
@@ -33,8 +36,8 @@ export async function verifyPassword(
return valid ? user : null; return valid ? user : null;
} }
export function getUserCount(db: Db = prodDb): number { export async function getUserCount(db: Db = prodDb): Promise<number> {
const result = db.select({ value: count() }).from(users).get(); const [result] = await db.select({ value: count() }).from(users);
return result?.value ?? 0; return result?.value ?? 0;
} }
@@ -48,17 +51,17 @@ export async function changePassword(
if (!user) return false; if (!user) return false;
const newHash = await Bun.password.hash(newPassword); const newHash = await Bun.password.hash(newPassword);
db.update(users) await db
.update(users)
.set({ passwordHash: newHash }) .set({ passwordHash: newHash })
.where(eq(users.id, user.id)) .where(eq(users.id, user.id));
.run();
return true; return true;
} }
// ── Session Management ─────────────────────────────────────────────── // ── Session Management ───────────────────────────────────────────────
export function createSession( export async function createSession(
db: Db = prodDb, db: Db = prodDb,
userId: number, userId: number,
expiryDays = 30, expiryDays = 30,
@@ -66,44 +69,44 @@ export function createSession(
const id = randomBytes(32).toString("hex"); const id = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000); const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
return db const [row] = await db
.insert(sessions) .insert(sessions)
.values({ id, userId, expiresAt }) .values({ id, userId, expiresAt })
.returning() .returning();
.get();
return row;
} }
export function getSession(db: Db = prodDb, sessionId: string) { export async function getSession(db: Db = prodDb, sessionId: string) {
const session = db const [session] = await db
.select() .select()
.from(sessions) .from(sessions)
.where(eq(sessions.id, sessionId)) .where(eq(sessions.id, sessionId));
.get();
if (!session) return null; if (!session) return null;
if (session.expiresAt < new Date()) { if (session.expiresAt < new Date()) {
db.delete(sessions).where(eq(sessions.id, sessionId)).run(); await db.delete(sessions).where(eq(sessions.id, sessionId));
return null; return null;
} }
return session; return session;
} }
export function deleteSession(db: Db = prodDb, sessionId: string) { export async function deleteSession(db: Db = prodDb, sessionId: string) {
db.delete(sessions).where(eq(sessions.id, sessionId)).run(); await db.delete(sessions).where(eq(sessions.id, sessionId));
} }
export function refreshSession( export async function refreshSession(
db: Db = prodDb, db: Db = prodDb,
sessionId: string, sessionId: string,
expiryDays = 30, expiryDays = 30,
) { ) {
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000); const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
db.update(sessions) await db
.update(sessions)
.set({ expiresAt }) .set({ expiresAt })
.where(eq(sessions.id, sessionId)) .where(eq(sessions.id, sessionId));
.run();
} }
// ── API Key Management ─────────────────────────────────────────────── // ── API Key Management ───────────────────────────────────────────────
@@ -113,11 +116,10 @@ export async function createApiKey(db: Db = prodDb, name: string) {
const keyHash = await Bun.password.hash(rawKey); const keyHash = await Bun.password.hash(rawKey);
const keyPrefix = rawKey.slice(0, 8); const keyPrefix = rawKey.slice(0, 8);
const record = db const [record] = await db
.insert(apiKeys) .insert(apiKeys)
.values({ name, keyHash, keyPrefix }) .values({ name, keyHash, keyPrefix })
.returning() .returning();
.get();
return { ...record, rawKey }; return { ...record, rawKey };
} }
@@ -127,11 +129,10 @@ export async function verifyApiKey(
rawKey: string, rawKey: string,
): Promise<boolean> { ): Promise<boolean> {
const prefix = rawKey.slice(0, 8); const prefix = rawKey.slice(0, 8);
const candidates = db const candidates = await db
.select() .select()
.from(apiKeys) .from(apiKeys)
.where(eq(apiKeys.keyPrefix, prefix)) .where(eq(apiKeys.keyPrefix, prefix));
.all();
for (const candidate of candidates) { for (const candidate of candidates) {
const valid = await Bun.password.verify(rawKey, candidate.keyHash); const valid = await Bun.password.verify(rawKey, candidate.keyHash);
@@ -141,7 +142,7 @@ export async function verifyApiKey(
return false; return false;
} }
export function listApiKeys(db: Db = prodDb) { export async function listApiKeys(db: Db = prodDb) {
return db return db
.select({ .select({
id: apiKeys.id, id: apiKeys.id,
@@ -149,10 +150,9 @@ export function listApiKeys(db: Db = prodDb) {
keyPrefix: apiKeys.keyPrefix, keyPrefix: apiKeys.keyPrefix,
createdAt: apiKeys.createdAt, createdAt: apiKeys.createdAt,
}) })
.from(apiKeys) .from(apiKeys);
.all();
} }
export function deleteApiKey(db: Db = prodDb, id: number) { export async function deleteApiKey(db: Db = prodDb, id: number) {
db.delete(apiKeys).where(eq(apiKeys.id, id)).run(); await db.delete(apiKeys).where(eq(apiKeys.id, id));
} }

View File

@@ -84,8 +84,8 @@ function parseCsv(content: string): { headers: string[]; rows: string[][] } {
// ─── Export ─────────────────────────────────────────────────────────────────── // ─── Export ───────────────────────────────────────────────────────────────────
export function exportItemsCsv(db: Db = prodDb): string { export async function exportItemsCsv(db: Db = prodDb): Promise<string> {
const rows = db const rows = await db
.select({ .select({
name: items.name, name: items.name,
quantity: items.quantity, quantity: items.quantity,
@@ -96,8 +96,7 @@ export function exportItemsCsv(db: Db = prodDb): string {
productUrl: items.productUrl, productUrl: items.productUrl,
}) })
.from(items) .from(items)
.innerJoin(categories, eq(items.categoryId, categories.id)) .innerJoin(categories, eq(items.categoryId, categories.id));
.all();
const header = const header =
"name,quantity,weightGrams,priceCents,category,notes,productUrl"; "name,quantity,weightGrams,priceCents,category,notes,productUrl";
@@ -124,10 +123,10 @@ export interface ImportResult {
errors: string[]; errors: string[];
} }
export function importItemsCsv( export async function importItemsCsv(
db: Db = prodDb, db: Db = prodDb,
csvContent: string, csvContent: string,
): ImportResult { ): Promise<ImportResult> {
const { headers, rows } = parseCsv(csvContent); const { headers, rows } = parseCsv(csvContent);
const result: ImportResult = { const result: ImportResult = {
@@ -152,10 +151,9 @@ export function importItemsCsv(
// Build a local category cache (name → id) seeded from the DB // Build a local category cache (name → id) seeded from the DB
const categoryCache = new Map<string, number>(); const categoryCache = new Map<string, number>();
const existingCategories = db const existingCategories = await db
.select({ id: categories.id, name: categories.name }) .select({ id: categories.id, name: categories.name })
.from(categories) .from(categories);
.all();
for (const cat of existingCategories) { for (const cat of existingCategories) {
categoryCache.set(cat.name.toLowerCase(), cat.id); categoryCache.set(cat.name.toLowerCase(), cat.id);
} }
@@ -181,11 +179,10 @@ export function importItemsCsv(
categoryId = categoryCache.get(cacheKey)!; categoryId = categoryCache.get(cacheKey)!;
} else { } else {
// Create a new category // Create a new category
const inserted = db const [inserted] = await db
.insert(categories) .insert(categories)
.values({ name: categoryName, icon: "package" }) .values({ name: categoryName, icon: "package" })
.returning() .returning();
.get();
categoryId = inserted.id; categoryId = inserted.id;
categoryCache.set(cacheKey, categoryId); categoryCache.set(cacheKey, categoryId);
result.createdCategories.push(categoryName); result.createdCategories.push(categoryName);
@@ -222,19 +219,17 @@ export function importItemsCsv(
const notes = notesIdx >= 0 ? row[notesIdx]?.trim() || null : null; const notes = notesIdx >= 0 ? row[notesIdx]?.trim() || null : null;
const productUrl = urlIdx >= 0 ? row[urlIdx]?.trim() || null : null; const productUrl = urlIdx >= 0 ? row[urlIdx]?.trim() || null : null;
db.insert(items) await db.insert(items).values({
.values({ name,
name, quantity,
quantity, weightGrams,
weightGrams, priceCents,
priceCents, categoryId,
categoryId, notes,
notes, productUrl,
productUrl, imageFilename: null,
imageFilename: null, imageSourceUrl: null,
imageSourceUrl: null, });
})
.run();
result.imported++; result.imported++;
} catch (err) { } catch (err) {

View File

@@ -7,53 +7,50 @@ type Db = typeof prodDb;
// ── Client Registration ────────────────────────────────────────────── // ── Client Registration ──────────────────────────────────────────────
export function registerClient( export async function registerClient(
db: Db = prodDb, db: Db = prodDb,
clientName: string, clientName: string,
redirectUris: string[], redirectUris: string[],
): { clientId: string } { ): Promise<{ clientId: string }> {
const clientId = randomUUID(); const clientId = randomUUID();
const redirectUrisJson = JSON.stringify(redirectUris); const redirectUrisJson = JSON.stringify(redirectUris);
db.insert(oauthClients) await db
.values({ clientId, clientName, redirectUris: redirectUrisJson }) .insert(oauthClients)
.run(); .values({ clientId, clientName, redirectUris: redirectUrisJson });
return { clientId }; return { clientId };
} }
export function getClient(db: Db = prodDb, clientId: string) { export async function getClient(db: Db = prodDb, clientId: string) {
return ( const [row] = await db
db .select()
.select() .from(oauthClients)
.from(oauthClients) .where(eq(oauthClients.clientId, clientId));
.where(eq(oauthClients.clientId, clientId))
.get() ?? null return row ?? null;
);
} }
// ── Authorization Code ─────────────────────────────────────────────── // ── Authorization Code ───────────────────────────────────────────────
export function createAuthorizationCode( export async function createAuthorizationCode(
db: Db = prodDb, db: Db = prodDb,
clientId: string, clientId: string,
codeChallenge: string, codeChallenge: string,
codeChallengeMethod: string, codeChallengeMethod: string,
redirectUri: string, redirectUri: string,
): { code: string } { ): Promise<{ code: string }> {
const code = randomBytes(32).toString("hex"); const code = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
db.insert(oauthCodes) await db.insert(oauthCodes).values({
.values({ code,
code, clientId,
clientId, codeChallenge,
codeChallenge, codeChallengeMethod,
codeChallengeMethod, redirectUri,
redirectUri, expiresAt,
expiresAt, });
})
.run();
return { code }; return { code };
} }
@@ -69,14 +66,13 @@ export async function exchangeCode(
refreshToken: string; refreshToken: string;
expiresIn: number; expiresIn: number;
} | null> { } | null> {
const record = db const [record] = await db
.select() .select()
.from(oauthCodes) .from(oauthCodes)
.where(eq(oauthCodes.code, code)) .where(eq(oauthCodes.code, code));
.get();
if (!record) return null; if (!record) return null;
if (record.used !== 0) return null; if (record.used !== false) return null;
if (record.clientId !== clientId) return null; if (record.clientId !== clientId) return null;
if (record.redirectUri !== redirectUri) return null; if (record.redirectUri !== redirectUri) return null;
if (record.expiresAt < new Date()) return null; if (record.expiresAt < new Date()) return null;
@@ -89,17 +85,20 @@ export async function exchangeCode(
if (computedChallenge !== record.codeChallenge) return null; if (computedChallenge !== record.codeChallenge) return null;
// Mark code as used // Mark code as used
db.update(oauthCodes).set({ used: 1 }).where(eq(oauthCodes.code, code)).run(); await db
.update(oauthCodes)
.set({ used: true })
.where(eq(oauthCodes.code, code));
return generateTokens(db, clientId); return generateTokens(db, clientId);
} }
// ── Token Management ───────────────────────────────────────────────── // ── Token Management ─────────────────────────────────────────────────
function generateTokens( async function generateTokens(
db: Db, db: Db,
clientId: string, clientId: string,
): { accessToken: string; refreshToken: string; expiresIn: number } { ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const accessToken = randomBytes(32).toString("hex"); const accessToken = randomBytes(32).toString("hex");
const refreshToken = randomBytes(32).toString("hex"); const refreshToken = randomBytes(32).toString("hex");
@@ -113,15 +112,13 @@ function generateTokens(
const expiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour const expiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour
const refreshExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days const refreshExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
db.insert(oauthTokens) await db.insert(oauthTokens).values({
.values({ accessTokenHash,
accessTokenHash, refreshTokenHash,
refreshTokenHash, clientId,
clientId, expiresAt,
expiresAt, refreshExpiresAt,
refreshExpiresAt, });
})
.run();
return { accessToken, refreshToken, expiresIn: 3600 }; return { accessToken, refreshToken, expiresIn: 3600 };
} }
@@ -132,11 +129,10 @@ export async function verifyAccessToken(
): Promise<boolean> { ): Promise<boolean> {
const tokenHash = createHash("sha256").update(token).digest("hex"); const tokenHash = createHash("sha256").update(token).digest("hex");
const record = db const [record] = await db
.select() .select()
.from(oauthTokens) .from(oauthTokens)
.where(eq(oauthTokens.accessTokenHash, tokenHash)) .where(eq(oauthTokens.accessTokenHash, tokenHash));
.get();
if (!record) return false; if (!record) return false;
if (record.expiresAt < new Date()) return false; if (record.expiresAt < new Date()) return false;
@@ -155,7 +151,7 @@ export async function refreshAccessToken(
} | null> { } | null> {
const tokenHash = createHash("sha256").update(refreshToken).digest("hex"); const tokenHash = createHash("sha256").update(refreshToken).digest("hex");
const record = db const [record] = await db
.select() .select()
.from(oauthTokens) .from(oauthTokens)
.where( .where(
@@ -163,22 +159,21 @@ export async function refreshAccessToken(
eq(oauthTokens.refreshTokenHash, tokenHash), eq(oauthTokens.refreshTokenHash, tokenHash),
eq(oauthTokens.clientId, clientId), eq(oauthTokens.clientId, clientId),
), ),
) );
.get();
if (!record) return null; if (!record) return null;
if (record.refreshExpiresAt < new Date()) return null; if (record.refreshExpiresAt < new Date()) return null;
// Delete old token pair // Delete old token pair
db.delete(oauthTokens).where(eq(oauthTokens.id, record.id)).run(); await db.delete(oauthTokens).where(eq(oauthTokens.id, record.id));
return generateTokens(db, clientId); return generateTokens(db, clientId);
} }
// ── Cleanup ────────────────────────────────────────────────────────── // ── Cleanup ──────────────────────────────────────────────────────────
export function cleanExpiredOAuthData(db: Db = prodDb): void { export async function cleanExpiredOAuthData(db: Db = prodDb): Promise<void> {
const now = new Date(); const now = new Date();
db.delete(oauthCodes).where(lt(oauthCodes.expiresAt, now)).run(); await db.delete(oauthCodes).where(lt(oauthCodes.expiresAt, now));
db.delete(oauthTokens).where(lt(oauthTokens.expiresAt, now)).run(); await db.delete(oauthTokens).where(lt(oauthTokens.expiresAt, now));
} }