File size: 4,818 Bytes
9d2d895
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * Frontend service for managing user API keys.
 *
 * Uses the shared ConvexClient (WebSocket) to call mutations/queries in
 * convex/apiKeys.ts. Key generation + hashing happens client-side so the
 * plaintext key is shown to the user exactly once without a round-trip
 * that could log it.
 */

import {
  getConvexClient,
  getConvexApi,
  waitForConvexAuthForUser,
} from './convex-client';
import { getClerkToken, getCurrentClerkUser } from './clerk';
import {
  assertAccountStillCurrent,
  settleAccountOperation,
} from './account-operation';

export interface ApiKeyInfo {
  id: string;
  name: string;
  keyPrefix: string;
  createdAt: number;
  lastUsedAt?: number;
  revokedAt?: number;
}

export interface CreateApiKeyResult {
  id: string;
  name: string;
  keyPrefix: string;
  /** Plaintext key — shown to the user ONCE. */
  key: string;
}

/** Generate a random key: wm_<40 hex chars> (20 bytes = 160 bits). */
export function generateKey(): string {
  const raw = new Uint8Array(20);
  crypto.getRandomValues(raw);
  const hex = Array.from(raw, (b) => b.toString(16).padStart(2, '0')).join('');
  return `wm_${hex}`;
}

/** SHA-256 hex digest of a string. */
async function sha256Hex(input: string): Promise<string> {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
  return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, '0')).join('');
}

/**
 * Create a new API key for the current user.
 * Returns the full plaintext key (shown once) and metadata.
 */
export async function createApiKey(name: string): Promise<CreateApiKeyResult> {
  const userId = getCurrentClerkUser()?.id;
  if (!userId) throw new Error('Sign in to create an API key.');

  const plaintext = generateKey();
  const keyPrefix = plaintext.slice(0, 8);
  const keyHash = await sha256Hex(plaintext);

  const [client, api] = await Promise.all([getConvexClient(), getConvexApi()]);
  if (!client || !api) throw new Error('Convex unavailable');
  if (!await waitForConvexAuthForUser(userId)) {
    throw new Error('Account changed while creating the API key. Try again.');
  }

  const result = await settleAccountOperation(
    userId,
    'creating the API key',
    () => client.mutation(
      (api as any).apiKeys.createApiKey,
      { name: name.trim(), keyPrefix, keyHash },
    ),
  );
  assertAccountStillCurrent(userId, 'creating the API key');

  return { id: result.id, name: result.name, keyPrefix: result.keyPrefix, key: plaintext };
}

/** List all API keys for the current user. */
export async function listApiKeys(): Promise<ApiKeyInfo[]> {
  const userId = getCurrentClerkUser()?.id;
  if (!userId) return [];

  const [client, api] = await Promise.all([getConvexClient(), getConvexApi()]);
  if (!client || !api) return [];
  if (!await waitForConvexAuthForUser(userId)) {
    assertAccountStillCurrent(userId, 'loading API keys');
    throw new Error('Authentication unavailable while loading API keys. Try again.');
  }

  return settleAccountOperation(
    userId,
    'loading API keys',
    () => client.query((api as any).apiKeys.listApiKeys, {}),
  );
}

/** Revoke an API key by its Convex document ID. */
export async function revokeApiKey(keyId: string): Promise<void> {
  const userId = getCurrentClerkUser()?.id;
  if (!userId) throw new Error('Sign in to revoke API keys.');

  const [client, api] = await Promise.all([getConvexClient(), getConvexApi()]);
  if (!client || !api) throw new Error('Convex unavailable');
  if (!await waitForConvexAuthForUser(userId)) {
    throw new Error('Account changed while revoking the API key. Try again.');
  }

  const result = await settleAccountOperation(
    userId,
    'revoking the API key',
    () => client.mutation((api as any).apiKeys.revokeApiKey, { keyId }),
  );
  assertAccountStillCurrent(userId, 'revoking the API key');

  // Await cache bust so the gateway stops accepting the revoked key immediately.
  // If this fails, the 60s cache TTL limits the staleness window.
  if (result?.keyHash) {
    const token = await settleAccountOperation(
      userId,
      'invalidating the API key cache',
      getClerkToken,
    );
    if (token) {
      assertAccountStillCurrent(userId, 'invalidating the API key cache');
      const resp = await settleAccountOperation(
        userId,
        'invalidating the API key cache',
        () => fetch('/api/invalidate-user-api-key-cache', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
          body: JSON.stringify({ keyHash: result.keyHash }),
        }),
      );
      assertAccountStillCurrent(userId, 'invalidating the API key cache');
      if (!resp.ok) {
        console.warn('[api-keys] cache invalidation failed:', resp.status);
      }
    }
  }
}