| import { ConvexError, v } from "convex/values"; |
| import { internalMutation, internalQuery, mutation, query } from "./_generated/server"; |
| import { requireUserId, resolveUserId } from "./lib/auth"; |
|
|
| |
| const MAX_KEYS_PER_USER = 5; |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const createApiKey = mutation({ |
| args: { |
| name: v.string(), |
| keyPrefix: v.string(), |
| keyHash: v.string(), |
| }, |
| handler: async (ctx, args) => { |
| const userId = await requireUserId(ctx); |
|
|
| |
| |
| |
| const entitlement = await ctx.db |
| .query("entitlements") |
| .withIndex("by_userId", (q) => q.eq("userId", userId)) |
| .first(); |
| if ( |
| !entitlement || |
| entitlement.validUntil < Date.now() || |
| !entitlement.features.apiAccess |
| ) { |
| throw new ConvexError("API_ACCESS_REQUIRED"); |
| } |
|
|
| if (!args.name.trim()) { |
| throw new ConvexError("INVALID_NAME"); |
| } |
| if (!/^wm_[a-f0-9]{5}$/.test(args.keyPrefix)) { |
| throw new ConvexError("INVALID_PREFIX"); |
| } |
| if (!/^[a-f0-9]{64}$/.test(args.keyHash)) { |
| throw new ConvexError("INVALID_HASH"); |
| } |
|
|
| |
| |
| |
| |
| |
| const existing = await ctx.db |
| .query("userApiKeys") |
| .withIndex("by_userId", (q) => q.eq("userId", userId)) |
| .collect(); |
| const active = existing.filter((k) => !k.revokedAt); |
| let activeCount = active.length; |
| if (active.length > MAX_KEYS_PER_USER) { |
| active.sort((a, b) => a.createdAt - b.createdAt); |
| const toRevoke = active.slice(0, active.length - (MAX_KEYS_PER_USER - 1)); |
| const now = Date.now(); |
| for (const key of toRevoke) { |
| await ctx.db.patch(key._id, { revokedAt: now }); |
| } |
| |
| activeCount = MAX_KEYS_PER_USER - 1; |
| } |
| if (activeCount >= MAX_KEYS_PER_USER) { |
| throw new ConvexError("KEY_LIMIT_REACHED"); |
| } |
|
|
| |
| const dup = await ctx.db |
| .query("userApiKeys") |
| .withIndex("by_keyHash", (q) => q.eq("keyHash", args.keyHash)) |
| .first(); |
| if (dup) { |
| throw new ConvexError("DUPLICATE_KEY"); |
| } |
|
|
| const id = await ctx.db.insert("userApiKeys", { |
| userId, |
| name: args.name.trim(), |
| keyPrefix: args.keyPrefix, |
| keyHash: args.keyHash, |
| createdAt: Date.now(), |
| }); |
|
|
| return { id, name: args.name.trim(), keyPrefix: args.keyPrefix }; |
| }, |
| }); |
|
|
| |
| export const listApiKeys = query({ |
| args: {}, |
| handler: async (ctx) => { |
| |
| |
| |
| |
| |
| |
| |
| const userId = await resolveUserId(ctx); |
| if (!userId) return []; |
| const keys = await ctx.db |
| .query("userApiKeys") |
| .withIndex("by_userId", (q) => q.eq("userId", userId)) |
| .collect(); |
|
|
| return keys.map((k) => ({ |
| id: k._id, |
| name: k.name, |
| keyPrefix: k.keyPrefix, |
| createdAt: k.createdAt, |
| lastUsedAt: k.lastUsedAt, |
| revokedAt: k.revokedAt, |
| })); |
| }, |
| }); |
|
|
| |
| export const revokeApiKey = mutation({ |
| args: { keyId: v.id("userApiKeys") }, |
| handler: async (ctx, args) => { |
| const userId = await requireUserId(ctx); |
| const key = await ctx.db.get(args.keyId); |
|
|
| if (!key || key.userId !== userId) { |
| throw new ConvexError("NOT_FOUND"); |
| } |
| if (key.revokedAt) { |
| throw new ConvexError("ALREADY_REVOKED"); |
| } |
|
|
| await ctx.db.patch(args.keyId, { revokedAt: Date.now() }); |
| return { ok: true, keyHash: key.keyHash }; |
| }, |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| export const validateKeyByHash = internalQuery({ |
| args: { keyHash: v.string() }, |
| handler: async (ctx, args) => { |
| const key = await ctx.db |
| .query("userApiKeys") |
| .withIndex("by_keyHash", (q) => q.eq("keyHash", args.keyHash)) |
| .first(); |
|
|
| if (!key || key.revokedAt) return null; |
|
|
| return { |
| id: key._id, |
| userId: key.userId, |
| name: key.name, |
| }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| export const getKeyOwner = internalQuery({ |
| args: { keyHash: v.string() }, |
| handler: async (ctx, args) => { |
| const key = await ctx.db |
| .query("userApiKeys") |
| .withIndex("by_keyHash", (q) => q.eq("keyHash", args.keyHash)) |
| .first(); |
| return key ? { userId: key.userId } : null; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| const TOUCH_DEBOUNCE_MS = 5 * 60 * 1000; |
|
|
| export const touchKeyLastUsed = internalMutation({ |
| args: { keyId: v.id("userApiKeys") }, |
| handler: async (ctx, args) => { |
| const key = await ctx.db.get(args.keyId); |
| if (!key || key.revokedAt) return; |
| if (key.lastUsedAt && key.lastUsedAt > Date.now() - TOUCH_DEBOUNCE_MS) return; |
| await ctx.db.patch(args.keyId, { lastUsedAt: Date.now() }); |
| }, |
| }); |
|
|