File size: 6,994 Bytes
97ee7cb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | import { ConvexError, v } from "convex/values";
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
import { requireUserId, resolveUserId } from "./lib/auth";
/** Maximum number of active (non-revoked) API keys per user. */
const MAX_KEYS_PER_USER = 5;
// ---------------------------------------------------------------------------
// Public mutations & queries (require Clerk JWT via ctx.auth)
// ---------------------------------------------------------------------------
/**
* Create a new API key.
*
* The caller must generate the random key client-side (or in the HTTP action)
* and pass the SHA-256 hex hash + the first 8 chars (prefix) here.
* The plaintext key is NEVER stored in Convex.
*
* Requires an active entitlement with apiAccess=true (API_STARTER+ plans).
* Pro plans (tier 1) have apiAccess=false and cannot create keys.
*/
export const createApiKey = mutation({
args: {
name: v.string(),
keyPrefix: v.string(),
keyHash: v.string(),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
// Entitlement gate: only users with apiAccess may create API keys.
// This is catalog-driven — Pro (tier 1) has apiAccess=false;
// API_STARTER+ (tier 2+) have apiAccess=true.
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");
}
// Enforce per-user key limit (count only non-revoked keys).
//
// API keys intentionally reject at the cap instead of silently rotating a
// valid key. If a prior race left too many active rows, converge by
// revoking enough oldest overflow rows to make room for this create.
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 });
}
// After revoking overflow keys there is always exactly one slot free.
activeCount = MAX_KEYS_PER_USER - 1;
}
if (activeCount >= MAX_KEYS_PER_USER) {
throw new ConvexError("KEY_LIMIT_REACHED");
}
// Guard against duplicate hash (astronomically unlikely, but belt-and-suspenders)
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 };
},
});
/** List all API keys for the current user (active + revoked). */
export const listApiKeys = query({
args: {},
handler: async (ctx) => {
// This query is called from the settings UI after a best-effort auth
// readiness wait, but the Convex WebSocket can still observe a brief
// unauthenticated window during sign-out, initial auth, or token rotation.
// Throwing AUTH_REQUIRED from that race pages through Convex auto-Sentry
// (WORLDMONITOR-XM). The UI already gates this query behind a signed-in
// shell, so [] is the honest transient result and cannot expose another
// user's keys.
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,
}));
},
});
/** Revoke a key owned by the current user. */
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 };
},
});
// ---------------------------------------------------------------------------
// Internal (service-to-service) — called from HTTP actions / middleware
// ---------------------------------------------------------------------------
/**
* Look up an API key by its SHA-256 hash.
* Returns the key row (with userId) if found and not revoked, else null.
* Used by the edge gateway to validate incoming API keys.
*/
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,
};
},
});
/**
* Look up the owner of a key by its hash, regardless of revoked status.
* Used by the cache-invalidation endpoint to verify tenancy.
*/
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;
},
});
/**
* Bump lastUsedAt for a key (fire-and-forget from the gateway).
* Skips the write if lastUsedAt was updated within the last 5 minutes
* to reduce Convex write load for hot keys.
*/
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() });
},
});
|