Merge branch 'worktree-agent-a9a8b0dc' into Develop

# Conflicts:
#	.planning/REQUIREMENTS.md
#	.planning/ROADMAP.md
#	.planning/STATE.md
#	drizzle-pg/meta/0000_snapshot.json
#	drizzle-pg/meta/_journal.json
#	src/db/schema.ts
#	src/db/seed.ts
#	src/server/middleware/auth.ts
#	src/server/services/auth.service.ts
#	src/server/services/category.service.ts
#	src/server/services/oauth.service.ts
#	tests/helpers/db.ts
This commit is contained in:
2026-04-05 10:38:29 +02:00
16 changed files with 770 additions and 259 deletions

View File

@@ -67,13 +67,13 @@ app.use("/api/*", async (c, next) => {
return next();
});
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
// Auth middleware for all data routes (userId must be available for per-user scoping)
app.use("/api/*", async (c, next) => {
// Skip auth routes — they handle their own auth
if (c.req.path.startsWith("/api/auth")) return next();
// Skip GET requests — read is public
if (c.req.method === "GET") return next();
// All other methods require auth
// Skip health check
if (c.req.path === "/api/health") return next();
// All methods require auth for userId resolution
return requireAuth(c, next);
});

View File

@@ -1,30 +1,47 @@
import type { Context, Next } from "hono";
import { getAuth } from "@hono/oidc-auth";
import { verifyApiKey } from "../services/auth.service";
import { getOrCreateUser, verifyApiKey } from "../services/auth.service";
import { getOrCreateUncategorized } from "../services/category.service";
import { verifyAccessToken } from "../services/oauth.service";
export async function requireAuth(c: Context, next: Next) {
const db = c.get("db");
// 1. Check API key (programmatic access)
// Check API key first
const apiKey = c.req.header("X-API-Key");
if (apiKey) {
const valid = await verifyApiKey(db, apiKey);
if (valid) return next();
const result = await verifyApiKey(db, apiKey);
if (result) {
c.set("userId", result.userId);
return next();
}
return c.json({ error: "Invalid API key" }, 401);
}
// 2. Check MCP OAuth Bearer token
// Check OAuth Bearer token
const authHeader = c.req.header("Authorization");
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.slice(7);
if (await verifyAccessToken(db, token)) return next();
return c.json({ error: "invalid_token" }, 401);
const result = await verifyAccessToken(db, token);
if (result) {
c.set("userId", result.userId);
return next();
}
return c.json({ error: "Invalid or expired token" }, 401);
}
// 3. Check OIDC session (browser users)
const auth = await getAuth(c);
if (auth) return next();
// Check OIDC session (browser users via Logto)
try {
const { getAuth } = await import("@hono/oidc-auth");
const auth = await getAuth(c);
if (auth?.sub) {
const user = await getOrCreateUser(db, auth.sub);
await getOrCreateUncategorized(db, user.id);
c.set("userId", user.id);
return next();
}
} catch {
// OIDC not configured or session invalid — fall through
}
return c.json({ error: "Authentication required" }, 401);
}

View File

@@ -1,20 +1,41 @@
import { randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { apiKeys } from "../../db/schema.ts";
import { apiKeys, users } from "../../db/schema.ts";
type Db = typeof prodDb;
// ── User Management ──────────────────────────────────────────────────
export async function getOrCreateUser(
db: Db,
logtoSub: string,
): Promise<{ id: number }> {
const [user] = await db
.insert(users)
.values({ logtoSub })
.onConflictDoUpdate({
target: users.logtoSub,
set: { logtoSub },
})
.returning({ id: users.id });
return user;
}
// ── API Key Management ───────────────────────────────────────────────
export async function createApiKey(db: Db = prodDb, name: string) {
export async function createApiKey(
db: Db = prodDb,
name: string,
userId: number,
) {
const rawKey = randomBytes(32).toString("hex");
const keyHash = await Bun.password.hash(rawKey);
const keyPrefix = rawKey.slice(0, 8);
const [record] = await db
.insert(apiKeys)
.values({ name, keyHash, keyPrefix })
.values({ name, keyHash, keyPrefix, userId })
.returning();
return { ...record, rawKey };
@@ -23,7 +44,7 @@ export async function createApiKey(db: Db = prodDb, name: string) {
export async function verifyApiKey(
db: Db = prodDb,
rawKey: string,
): Promise<boolean> {
): Promise<{ userId: number } | null> {
const prefix = rawKey.slice(0, 8);
const candidates = await db
.select()
@@ -32,13 +53,13 @@ export async function verifyApiKey(
for (const candidate of candidates) {
const valid = await Bun.password.verify(rawKey, candidate.keyHash);
if (valid) return true;
if (valid) return { userId: candidate.userId };
}
return false;
return null;
}
export async function listApiKeys(db: Db = prodDb) {
export async function listApiKeys(db: Db = prodDb, userId: number) {
return db
.select({
id: apiKeys.id,
@@ -46,9 +67,16 @@ export async function listApiKeys(db: Db = prodDb) {
keyPrefix: apiKeys.keyPrefix,
createdAt: apiKeys.createdAt,
})
.from(apiKeys);
.from(apiKeys)
.where(eq(apiKeys.userId, userId));
}
export async function deleteApiKey(db: Db = prodDb, id: number) {
await db.delete(apiKeys).where(eq(apiKeys.id, id));
export async function deleteApiKey(
db: Db = prodDb,
id: number,
userId: number,
) {
await db
.delete(apiKeys)
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId)));
}

View File

@@ -1,53 +1,68 @@
import { asc, eq } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { categories, items } from "../../db/schema.ts";
type Db = typeof prodDb;
export async function getAllCategories(db: Db = prodDb) {
return db.select().from(categories).orderBy(asc(categories.name));
export async function getOrCreateUncategorized(
db: Db,
userId: number,
): Promise<number> {
const [existing] = await db
.select({ id: categories.id })
.from(categories)
.where(and(eq(categories.userId, userId), eq(categories.name, "Uncategorized")));
if (existing) return existing.id;
const [created] = await db
.insert(categories)
.values({ name: "Uncategorized", icon: "package", userId })
.returning({ id: categories.id });
return created.id;
}
export async function createCategory(
export function getAllCategories(db: Db = prodDb) {
return db.select().from(categories).orderBy(asc(categories.name)).all();
}
export function createCategory(
db: Db = prodDb,
data: { name: string; icon?: string },
) {
const [row] = await db
return db
.insert(categories)
.values({
name: data.name,
...(data.icon ? { icon: data.icon } : {}),
})
.returning();
return row;
.returning()
.get();
}
export async function updateCategory(
export function updateCategory(
db: Db = prodDb,
id: number,
data: { name?: string; icon?: string },
) {
const [existing] = await db
const existing = db
.select({ id: categories.id })
.from(categories)
.where(eq(categories.id, id));
.where(eq(categories.id, id))
.get();
if (!existing) return null;
const [row] = await db
return db
.update(categories)
.set(data)
.where(eq(categories.id, id))
.returning();
return row;
.returning()
.get();
}
export async function deleteCategory(
export function deleteCategory(
db: Db = prodDb,
id: number,
): Promise<{ success: boolean; error?: string }> {
): { success: boolean; error?: string } {
// Guard: cannot delete Uncategorized (id=1)
if (id === 1) {
return {
@@ -57,23 +72,24 @@ export async function deleteCategory(
}
// Check if category exists
const [existing] = await db
const existing = db
.select({ id: categories.id })
.from(categories)
.where(eq(categories.id, id));
.where(eq(categories.id, id))
.get();
if (!existing) {
return { success: false, error: "Category not found" };
}
// Reassign items to Uncategorized (id=1), then delete atomically
await db.transaction(async (tx) => {
await tx
.update(items)
db.transaction(() => {
db.update(items)
.set({ categoryId: 1 })
.where(eq(items.categoryId, id));
.where(eq(items.categoryId, id))
.run();
await tx.delete(categories).where(eq(categories.id, id));
db.delete(categories).where(eq(categories.id, id)).run();
});
return { success: true };

View File

@@ -23,12 +23,12 @@ export async function registerClient(
}
export async function getClient(db: Db = prodDb, clientId: string) {
const [row] = await db
const [record] = await db
.select()
.from(oauthClients)
.where(eq(oauthClients.clientId, clientId));
return row ?? null;
return record ?? null;
}
// ── Authorization Code ───────────────────────────────────────────────
@@ -61,6 +61,7 @@ export async function exchangeCode(
codeVerifier: string,
clientId: string,
redirectUri: string,
userId: number,
): Promise<{
accessToken: string;
refreshToken: string;
@@ -72,7 +73,7 @@ export async function exchangeCode(
.where(eq(oauthCodes.code, code));
if (!record) return null;
if (record.used !== false) return null;
if (record.used !== 0) return null;
if (record.clientId !== clientId) return null;
if (record.redirectUri !== redirectUri) return null;
if (record.expiresAt < new Date()) return null;
@@ -87,10 +88,10 @@ export async function exchangeCode(
// Mark code as used
await db
.update(oauthCodes)
.set({ used: true })
.set({ used: 1 })
.where(eq(oauthCodes.code, code));
return generateTokens(db, clientId);
return generateTokens(db, clientId, userId);
}
// ── Token Management ─────────────────────────────────────────────────
@@ -98,6 +99,7 @@ export async function exchangeCode(
async function generateTokens(
db: Db,
clientId: string,
userId: number,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const accessToken = randomBytes(32).toString("hex");
const refreshToken = randomBytes(32).toString("hex");
@@ -116,6 +118,7 @@ async function generateTokens(
accessTokenHash,
refreshTokenHash,
clientId,
userId,
expiresAt,
refreshExpiresAt,
});
@@ -126,7 +129,7 @@ async function generateTokens(
export async function verifyAccessToken(
db: Db = prodDb,
token: string,
): Promise<boolean> {
): Promise<{ userId: number } | null> {
const tokenHash = createHash("sha256").update(token).digest("hex");
const [record] = await db
@@ -134,16 +137,17 @@ export async function verifyAccessToken(
.from(oauthTokens)
.where(eq(oauthTokens.accessTokenHash, tokenHash));
if (!record) return false;
if (record.expiresAt < new Date()) return false;
if (!record) return null;
if (record.expiresAt < new Date()) return null;
return true;
return { userId: record.userId };
}
export async function refreshAccessToken(
db: Db = prodDb,
refreshToken: string,
clientId: string,
userId: number,
): Promise<{
accessToken: string;
refreshToken: string;
@@ -167,7 +171,7 @@ export async function refreshAccessToken(
// Delete old token pair
await db.delete(oauthTokens).where(eq(oauthTokens.id, record.id));
return generateTokens(db, clientId);
return generateTokens(db, clientId, userId);
}
// ── Cleanup ──────────────────────────────────────────────────────────