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:
@@ -0,0 +1,16 @@
|
||||
CREATE TYPE "public"."api_key_scope" AS ENUM('full', 'read');--> statement-breakpoint
|
||||
CREATE TYPE "public"."chat_role" AS ENUM('user', 'assistant');--> statement-breakpoint
|
||||
CREATE TABLE "chat_messages" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"recipe_id" text,
|
||||
"role" "chat_role" NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "api_keys" ADD COLUMN "scope" "api_key_scope" DEFAULT 'full' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_recipe_id_recipes_id_fk" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "chat_messages_user_idx" ON "chat_messages" USING btree ("user_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "chat_messages_recipe_idx" ON "chat_messages" USING btree ("recipe_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -246,6 +246,13 @@
|
||||
"when": 1783875228288,
|
||||
"tag": "0034_dapper_nocturne",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "7",
|
||||
"when": 1783887815564,
|
||||
"tag": "0035_gifted_plazm",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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] }),
|
||||
}));
|
||||
@@ -6,3 +6,4 @@ export * from "./tiers";
|
||||
export * from "./webhooks";
|
||||
export * from "./messaging";
|
||||
export * from "./billing";
|
||||
export * from "./ai-chat";
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user