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:
180
src/db/schema.ts
180
src/db/schema.ts
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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