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() });
  },
});