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:
@@ -57,12 +57,22 @@ export async function requireSessionOrApiKey(
|
||||
const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
|
||||
|
||||
const [keyRow] = await db
|
||||
.select({ id: apiKeys.id, userId: apiKeys.userId })
|
||||
.select({ id: apiKeys.id, userId: apiKeys.userId, scope: apiKeys.scope })
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.keyHash, keyHash))
|
||||
.limit(1);
|
||||
|
||||
if (keyRow) {
|
||||
// Read-scoped keys can't make any state-changing request — enforced
|
||||
// once here rather than in every route, since a route can't tell
|
||||
// whether it's being called by a "read" key without this check.
|
||||
if (keyRow.scope === "read" && !["GET", "HEAD", "OPTIONS"].includes(req.method)) {
|
||||
return {
|
||||
session: null,
|
||||
response: NextResponse.json({ error: "This API key is read-only" }, { status: 403 }),
|
||||
};
|
||||
}
|
||||
|
||||
// Update lastUsedAt asynchronously — don't block response
|
||||
void db
|
||||
.update(apiKeys)
|
||||
|
||||
Reference in New Issue
Block a user