import { v } from "convex/values"; import { mutation, query, internalQuery } from "./_generated/server"; /** * Generate a secure random API key * Format: fb_<32 random hex chars> = 35 chars total */ function generateApiKey(): string { const randomBytes = new Uint8Array(16); crypto.getRandomValues(randomBytes); const hex = Array.from(randomBytes) .map((b) => b.toString(16).padStart(2, "0")) .join(""); return `fb_${hex}`; } /** * Hash an API key using SHA-256 */ async function hashApiKey(key: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(key); const hashBuffer = await crypto.subtle.digest("SHA-256", data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); } /** * Create a new API key. * Returns the full key - this is the only time it will be visible! */ export const create = mutation({ args: { name: v.string(), expiresAt: v.optional(v.number()), }, returns: v.object({ key: v.string(), keyPrefix: v.string(), name: v.string(), expiresAt: v.union(v.number(), v.null()), createdAt: v.number(), }), handler: async (ctx, args) => { const key = generateApiKey(); const keyHash = await hashApiKey(key); const keyPrefix = key.slice(0, 11); // "fb_" + first 8 chars const now = Date.now(); await ctx.db.insert("apiKeys", { keyHash, keyPrefix, name: args.name, expiresAt: args.expiresAt, isRevoked: false, createdAt: now, }); return { key, // Full key - only returned on creation! keyPrefix, name: args.name, expiresAt: args.expiresAt ?? null, createdAt: now, }; }, }); /** * List all API keys (without the actual key values). */ export const list = query({ args: {}, returns: v.array( v.object({ _id: v.id("apiKeys"), keyPrefix: v.string(), name: v.string(), expiresAt: v.union(v.number(), v.null()), isRevoked: v.boolean(), revokedAt: v.union(v.number(), v.null()), createdAt: v.number(), isExpired: v.boolean(), }) ), handler: async (ctx) => { const keys = await ctx.db.query("apiKeys").collect(); const now = Date.now(); return keys.map((k) => ({ _id: k._id, keyPrefix: k.keyPrefix, name: k.name, expiresAt: k.expiresAt ?? null, isRevoked: k.isRevoked, revokedAt: k.revokedAt ?? null, createdAt: k.createdAt, isExpired: k.expiresAt !== undefined && k.expiresAt < now, })); }, }); /** * Revoke an API key by its prefix. */ export const revoke = mutation({ args: { keyPrefix: v.string(), }, returns: v.boolean(), handler: async (ctx, args) => { const key = await ctx.db .query("apiKeys") .withIndex("by_key_prefix", (q) => q.eq("keyPrefix", args.keyPrefix)) .unique(); if (!key) { return false; } await ctx.db.patch(key._id, { isRevoked: true, revokedAt: Date.now(), }); return true; }, }); /** * Rotate an API key - revokes the old key and creates a new one. * Returns the new key (only time it will be visible!). */ export const rotate = mutation({ args: { keyPrefix: v.string(), name: v.optional(v.string()), expiresAt: v.optional(v.number()), }, returns: v.union( v.object({ key: v.string(), keyPrefix: v.string(), name: v.string(), expiresAt: v.union(v.number(), v.null()), createdAt: v.number(), }), v.null() ), handler: async (ctx, args) => { // Find and revoke the old key const oldKey = await ctx.db .query("apiKeys") .withIndex("by_key_prefix", (q) => q.eq("keyPrefix", args.keyPrefix)) .unique(); if (!oldKey) { return null; } // Revoke old key await ctx.db.patch(oldKey._id, { isRevoked: true, revokedAt: Date.now(), }); // Create new key const newKey = generateApiKey(); const keyHash = await hashApiKey(newKey); const keyPrefix = newKey.slice(0, 11); const now = Date.now(); const name = args.name ?? oldKey.name; await ctx.db.insert("apiKeys", { keyHash, keyPrefix, name, expiresAt: args.expiresAt, isRevoked: false, createdAt: now, }); return { key: newKey, keyPrefix, name, expiresAt: args.expiresAt ?? null, createdAt: now, }; }, }); /** * Validate an API key (public - for use by consumer apps via component API). * Returns true if valid and not expired/revoked. */ export const validate = query({ args: { apiKey: v.string(), }, returns: v.boolean(), handler: async (ctx, args) => { const keyHash = await hashApiKey(args.apiKey); const apiKeyDoc = await ctx.db .query("apiKeys") .withIndex("by_key_hash", (q) => q.eq("keyHash", keyHash)) .unique(); if (!apiKeyDoc) { return false; } if (apiKeyDoc.isRevoked) { return false; } if (apiKeyDoc.expiresAt !== undefined && apiKeyDoc.expiresAt < Date.now()) { return false; } return true; }, }); /** * Validate an API key (internal use for HTTP endpoint). * Returns true if valid and not expired/revoked. */ export const validateKey = internalQuery({ args: { key: v.string(), }, returns: v.boolean(), handler: async (ctx, args) => { const keyHash = await hashApiKey(args.key); const apiKey = await ctx.db .query("apiKeys") .withIndex("by_key_hash", (q) => q.eq("keyHash", keyHash)) .unique(); if (!apiKey) { return false; } if (apiKey.isRevoked) { return false; } if (apiKey.expiresAt !== undefined && apiKey.expiresAt < Date.now()) { return false; } return true; }, });