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:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user