// server/handlers/api-keys.ts — API Key management: create, list, get, revoke, update
// Supports linking keys to creations (TV menus, displays, landing pages)
import { createHash, randomUUID } from "node:crypto";
import type { SupabaseClient } from "@supabase/supabase-js";
const KEY_COLS = "id, name, key_prefix, key_type, scope, is_active, rate_limit_per_minute, rate_limit_per_day, last_used_at, request_count, expires_at, revoked_at, revoked_reason, creation_id, created_at, updated_at";
export async function handleAPIKeys(
sb: SupabaseClient,
args: Record<string, unknown>,
storeId?: string,
): Promise<{ success: boolean; data?: unknown; error?: string }> {
const sid = storeId as string;
const action = args.action as string;
switch (action) {
// ---- generate: Create a new API key ----
case "generate": {
const name = args.name as string;
if (!name?.trim()) return { success: false, error: "name is required" };
const keyType = (args.key_type as string) || "live";
if (keyType !== "live" && keyType !== "test") {
return { success: false, error: "key_type must be 'live' or 'test'" };
}
const scopes = (args.scopes as string[]) || ["*"];
const rateLimitPerMinute = (args.rate_limit_per_minute as number) || 60;
const rateLimitPerDay = (args.rate_limit_per_day as number) || 10000;
const expiresAt = args.expires_at as string | undefined;
const creationId = args.creation_id as string | undefined;
// Resolve owner — use provided owner_user_id or find store owner
let ownerUserId = args.owner_user_id as string | undefined;
if (!ownerUserId) {
const { data: store } = await sb
.from("stores")
.select("owner_user_id")
.eq("id", sid)
.single();
if (store?.owner_user_id) {
ownerUserId = store.owner_user_id;
}
}
if (!ownerUserId) {
return { success: false, error: "Could not determine key owner. Provide owner_user_id or ensure store has an owner." };
}
// If creation_id provided, verify it belongs to this store
if (creationId) {
const { data: creation } = await sb
.from("creations")
.select("id, name")
.eq("id", creationId)
.eq("store_id", sid)
.single();
if (!creation) {
return { success: false, error: "Creation not found in this store." };
}
}
// Generate key: wk_<type>_<32-char-random>
const rawUuid = randomUUID().replace(/-/g, "");
const keyValue = `wk_${keyType}_${rawUuid}`;
const keyPrefix = keyValue.substring(0, 12);
const keyHash = createHash("sha256").update(keyValue).digest("hex");
const { data, error } = await sb.from("api_keys").insert({
owner_user_id: ownerUserId,
store_id: sid,
name: name.trim(),
key_prefix: keyPrefix,
key_hash: keyHash,
key_type: keyType,
scope: scopes,
is_active: true,
rate_limit_per_minute: rateLimitPerMinute,
rate_limit_per_day: rateLimitPerDay,
expires_at: expiresAt || null,
creation_id: creationId || null,
}).select(KEY_COLS).single();
if (error) return { success: false, error: error.message };
return {
success: true,
data: {
...data,
key_value: keyValue,
warning: "Copy this key now. The full key value will NOT be returned again — only the hash is stored.",
},
};
}
// ---- list: List all API keys for store ----
case "list": {
const limit = Math.min((args.limit as number) || 25, 100);
let q = sb
.from("api_keys")
.select(KEY_COLS)
.eq("store_id", sid)
.order("created_at", { ascending: false })
.limit(limit);
if (args.is_active !== undefined) q = q.eq("is_active", args.is_active as boolean);
if (args.key_type) q = q.eq("key_type", args.key_type as string);
if (args.creation_id) q = q.eq("creation_id", args.creation_id as string);
const { data, error } = await q;
if (error) return { success: false, error: error.message };
return { success: true, data: { count: data?.length || 0, keys: data } };
}
// ---- get: Get a single API key by ID ----
case "get": {
const keyId = args.key_id as string;
if (!keyId) return { success: false, error: "key_id is required" };
const { data, error } = await sb
.from("api_keys")
.select(KEY_COLS)
.eq("id", keyId)
.eq("store_id", sid)
.single();
if (error) return { success: false, error: error.message };
return { success: true, data };
}
// ---- revoke: Deactivate an API key ----
case "revoke": {
const keyId = args.key_id as string;
if (!keyId) return { success: false, error: "key_id is required" };
const reason = (args.reason as string) || "Revoked via MCP tool";
const { data, error } = await sb
.from("api_keys")
.update({
is_active: false,
revoked_at: new Date().toISOString(),
revoked_reason: reason,
})
.eq("id", keyId)
.eq("store_id", sid)
.eq("is_active", true)
.select("id, name, key_prefix, is_active, revoked_at, revoked_reason, creation_id")
.single();
if (error) return { success: false, error: error.message };
return { success: true, data };
}
// ---- update: Update key name, scopes, rate limits, or creation link ----
case "update": {
const keyId = args.key_id as string;
if (!keyId) return { success: false, error: "key_id is required" };
const updates: Record<string, unknown> = {};
if (args.name !== undefined) updates.name = args.name;
if (args.scopes !== undefined) updates.scope = args.scopes;
if (args.rate_limit_per_minute !== undefined) updates.rate_limit_per_minute = args.rate_limit_per_minute;
if (args.rate_limit_per_day !== undefined) updates.rate_limit_per_day = args.rate_limit_per_day;
if (args.expires_at !== undefined) updates.expires_at = args.expires_at || null;
if (args.creation_id !== undefined) updates.creation_id = args.creation_id || null;
if (Object.keys(updates).length === 0) {
return { success: false, error: "No fields to update." };
}
const { data, error } = await sb
.from("api_keys")
.update(updates)
.eq("id", keyId)
.eq("store_id", sid)
.select(KEY_COLS)
.single();
if (error) return { success: false, error: error.message };
return { success: true, data };
}
// ---- delete: Permanently delete an API key ----
case "delete": {
const keyId = args.key_id as string;
if (!keyId) return { success: false, error: "key_id is required" };
const { error } = await sb
.from("api_keys")
.delete()
.eq("id", keyId)
.eq("store_id", sid);
if (error) return { success: false, error: error.message };
return { success: true, data: { deleted: true, key_id: keyId } };
}
// ---- list_creations: List creations available for key linking ----
case "list_creations": {
const creationType = args.creation_type as string | undefined;
const limit = Math.min((args.limit as number) || 50, 100);
let q = sb
.from("creations")
.select("id, name, creation_type, status, slug, display_mode, location_id, created_at")
.eq("store_id", sid)
.order("creation_type")
.order("name")
.limit(limit);
if (creationType) q = q.eq("creation_type", creationType);
const { data, error } = await q;
if (error) return { success: false, error: error.message };
// Also fetch which creations already have API keys
const creationIds = (data || []).map((c: any) => c.id);
const { data: linkedKeys } = creationIds.length > 0
? await sb.from("api_keys").select("creation_id, id, name, key_prefix, is_active").in("creation_id", creationIds).eq("store_id", sid)
: { data: [] };
const keyMap = new Map<string, any>();
for (const k of linkedKeys || []) {
if (k.creation_id) keyMap.set(k.creation_id, k);
}
const enriched = (data || []).map((c: any) => ({
...c,
api_key: keyMap.get(c.id) || null,
}));
return { success: true, data: { count: enriched.length, creations: enriched } };
}
default:
return {
success: false,
error: `Unknown api_keys action: ${action}. Available: generate, list, get, revoke, update, delete, list_creations`,
};
}
}