feat: read-only API key scoping

New keys can be created as "Full access" (default, unchanged) or
"Read-only" — read-only keys can only make GET/HEAD/OPTIONS requests,
enforced once in requireSessionOrApiKey (lib/api-auth.ts) rather than in
every route, since a route has no way to know a request came from a
scoped key without that check. Existing keys default to full access —
no behavior change for anyone who doesn't opt in.

Also included in this migration: the chat_messages table for the
next commit (chat history persistence) — generated together since both
touched packages/db/src/schema/users.ts in the same pass.

Verified locally: created both a read-only and a full-access key,
confirmed GET succeeds and POST 403s on the read-only key, confirmed
POST still works on the full-access key, and checked the scope badges
render correctly in the real Settings → API Keys UI.
This commit is contained in:
Arnaud
2026-07-12 22:37:14 +02:00
parent b2f2274673
commit 0062220d8e
11 changed files with 5038 additions and 8 deletions
+26
View File
@@ -0,0 +1,26 @@
import { pgTable, text, timestamp, pgEnum, index } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./users";
import { recipes } from "./recipes";
export const chatRoleEnum = pgEnum("chat_role", ["user", "assistant"]);
// Persists both the per-recipe chat (recipeId set) and the general cooking
// assistant on the recipes homepage (recipeId null) — same table, since a
// user searching "their chat history" expects both to show up together.
export const chatMessages = pgTable("chat_messages", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
recipeId: text("recipe_id").references(() => recipes.id, { onDelete: "cascade" }),
role: chatRoleEnum("role").notNull(),
content: text("content").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
}, (t) => [
index("chat_messages_user_idx").on(t.userId, t.createdAt),
index("chat_messages_recipe_idx").on(t.recipeId),
]);
export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
user: one(users, { fields: [chatMessages.userId], references: [users.id] }),
recipe: one(recipes, { fields: [chatMessages.recipeId], references: [recipes.id] }),
}));
+1
View File
@@ -6,3 +6,4 @@ export * from "./tiers";
export * from "./webhooks";
export * from "./messaging";
export * from "./billing";
export * from "./ai-chat";
+2
View File
@@ -12,6 +12,7 @@ import { relations } from "drizzle-orm";
export const userRoleEnum = pgEnum("user_role", ["user", "moderator", "admin"]);
export const tierEnum = pgEnum("tier", ["free", "pro"]);
export const unitPrefEnum = pgEnum("unit_pref", ["metric", "imperial"]);
export const apiKeyScopeEnum = pgEnum("api_key_scope", ["full", "read"]);
export const users = pgTable("users", {
id: text("id").primaryKey(),
@@ -96,6 +97,7 @@ export const apiKeys = pgTable("api_keys", {
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
keyHash: text("key_hash").notNull().unique(),
scope: apiKeyScopeEnum("scope").notNull().default("full"),
lastUsedAt: timestamp("last_used_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
});