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

@@ -1,58 +1,86 @@
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import {
doublePrecision,
integer,
pgTable,
primaryKey,
serial,
text,
timestamp,
unique,
} from "drizzle-orm/pg-core";
export const categories = sqliteTable("categories", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique(),
icon: text("icon").notNull().default("package"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
// ── Users ───────────────────────────────────────────────────────────
export const users = pgTable("users", {
id: serial("id").primaryKey(),
logtoSub: text("logto_sub").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const items = sqliteTable("items", {
id: integer("id").primaryKey({ autoIncrement: true }),
// ── Categories ──────────────────────────────────────────────────────
export const categories = pgTable(
"categories",
{
id: serial("id").primaryKey(),
name: text("name").notNull(),
icon: text("icon").notNull().default("package"),
userId: integer("user_id")
.notNull()
.references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [unique().on(table.userId, table.name)],
);
// ── Items ───────────────────────────────────────────────────────────
export const items = pgTable("items", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
weightGrams: real("weight_grams"),
weightGrams: doublePrecision("weight_grams"),
priceCents: integer("price_cents"),
categoryId: integer("category_id")
.notNull()
.references(() => categories.id),
userId: integer("user_id")
.notNull()
.references(() => users.id),
notes: text("notes"),
productUrl: text("product_url"),
imageFilename: text("image_filename"),
imageSourceUrl: text("image_source_url"),
quantity: integer("quantity").notNull().default(1),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const threads = sqliteTable("threads", {
id: integer("id").primaryKey({ autoIncrement: true }),
// ── Threads ─────────────────────────────────────────────────────────
export const threads = pgTable("threads", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
status: text("status").notNull().default("active"),
resolvedCandidateId: integer("resolved_candidate_id"),
categoryId: integer("category_id")
.notNull()
.references(() => categories.id),
createdAt: integer("created_at", { mode: "timestamp" })
userId: integer("user_id")
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
.references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const threadCandidates = sqliteTable("thread_candidates", {
id: integer("id").primaryKey({ autoIncrement: true }),
// ── Thread Candidates ───────────────────────────────────────────────
export const threadCandidates = pgTable("thread_candidates", {
id: serial("id").primaryKey(),
threadId: integer("thread_id")
.notNull()
.references(() => threads.id, { onDelete: "cascade" }),
name: text("name").notNull(),
weightGrams: real("weight_grams"),
weightGrams: doublePrecision("weight_grams"),
priceCents: integer("price_cents"),
categoryId: integer("category_id")
.notNull()
@@ -64,28 +92,27 @@ export const threadCandidates = sqliteTable("thread_candidates", {
status: text("status").notNull().default("researching"),
pros: text("pros"),
cons: text("cons"),
sortOrder: real("sort_order").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
sortOrder: doublePrecision("sort_order").notNull().default(0),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const setups = sqliteTable("setups", {
id: integer("id").primaryKey({ autoIncrement: true }),
// ── Setups ──────────────────────────────────────────────────────────
export const setups = pgTable("setups", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
userId: integer("user_id")
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
.references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const setupItems = sqliteTable("setup_items", {
id: integer("id").primaryKey({ autoIncrement: true }),
// ── Setup Items ─────────────────────────────────────────────────────
export const setupItems = pgTable("setup_items", {
id: serial("id").primaryKey(),
setupId: integer("setup_id")
.notNull()
.references(() => setups.id, { onDelete: "cascade" }),
@@ -95,52 +122,69 @@ export const setupItems = sqliteTable("setup_items", {
classification: text("classification").notNull().default("base"),
});
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
});
// ── Settings ────────────────────────────────────────────────────────
export const apiKeys = sqliteTable("api_keys", {
id: integer("id").primaryKey({ autoIncrement: true }),
export const settings = pgTable(
"settings",
{
userId: integer("user_id")
.notNull()
.references(() => users.id),
key: text("key").notNull(),
value: text("value").notNull(),
},
(table) => [primaryKey({ columns: [table.userId, table.key] })],
);
// ── API Keys ────────────────────────────────────────────────────────
export const apiKeys = pgTable("api_keys", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
keyHash: text("key_hash").notNull(),
keyPrefix: text("key_prefix").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
userId: integer("user_id")
.notNull()
.$defaultFn(() => new Date()),
.references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const oauthClients = sqliteTable("oauth_clients", {
id: integer("id").primaryKey({ autoIncrement: true }),
// ── OAuth Clients ───────────────────────────────────────────────────
export const oauthClients = pgTable("oauth_clients", {
id: serial("id").primaryKey(),
clientId: text("client_id").notNull().unique(),
clientName: text("client_name"),
redirectUris: text("redirect_uris").notNull(), // JSON array
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const oauthCodes = sqliteTable("oauth_codes", {
id: integer("id").primaryKey({ autoIncrement: true }),
// ── OAuth Authorization Codes ───────────────────────────────────────
export const oauthCodes = pgTable("oauth_codes", {
id: serial("id").primaryKey(),
code: text("code").notNull().unique(),
clientId: text("client_id").notNull(),
codeChallenge: text("code_challenge").notNull(),
codeChallengeMethod: text("code_challenge_method").notNull().default("S256"),
codeChallengeMethod: text("code_challenge_method")
.notNull()
.default("S256"),
redirectUri: text("redirect_uri").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
expiresAt: timestamp("expires_at").notNull(),
used: integer("used").notNull().default(0),
});
export const oauthTokens = sqliteTable("oauth_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
// ── OAuth Tokens ────────────────────────────────────────────────────
export const oauthTokens = pgTable("oauth_tokens", {
id: serial("id").primaryKey(),
accessTokenHash: text("access_token_hash").notNull().unique(),
refreshTokenHash: text("refresh_token_hash").notNull().unique(),
clientId: text("client_id").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), // access token expiry
refreshExpiresAt: integer("refresh_expires_at", {
mode: "timestamp",
}).notNull(), // refresh token expiry
createdAt: integer("created_at", { mode: "timestamp" })
userId: integer("user_id")
.notNull()
.$defaultFn(() => new Date()),
.references(() => users.id),
expiresAt: timestamp("expires_at").notNull(),
refreshExpiresAt: timestamp("refresh_expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});

View File

@@ -1,12 +1,4 @@
import { db } from "./index.ts";
import { categories } from "./schema.ts";
export async function seedDefaults() {
const existing = await db.select().from(categories);
if (existing.length === 0) {
await db.insert(categories).values({
name: "Uncategorized",
icon: "package",
});
}
export function seedDefaults() {
// Per-user default categories are created on first login (Phase 16)
// The getOrCreateUncategorized helper in category.service.ts handles this lazily.
}

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 ──────────────────────────────────────────────────────────