File size: 7,873 Bytes
3464008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
209
210
211
212
213
214
215
216
/**
 * POST /api/user/mcp-revoke
 *
 * Clerk-authenticated endpoint that revokes a Pro MCP token row owned by
 * the caller and invalidates any in-flight bearer cache.
 *
 * Tenancy model:
 *   The `userId` forwarded to Convex's `internal-revoke-pro-mcp-token` HTTP
 *   action is taken from the freshly-verified Clerk session β€” never from
 *   the request body. The Convex `internalRevokeProMcpToken` mutation
 *   asserts `row.userId === userId` (NOT_FOUND otherwise), so a Pro user
 *   cannot revoke another user's token even if they craft the body.
 *
 *   We deliberately call the INTERNAL HTTP route rather than the public
 *   `revokeProMcpToken` Convex mutation. Two reasons:
 *     1. Memory `convex-httpclient-no-default-timeout-bypasses-typed-503`:
 *        instantiating `ConvexHttpClient` from the edge with no default
 *        fetch timeout is a known foot-gun. The internal route is plain
 *        fetch with `AbortSignal.timeout` β€” same posture as U2's helper.
 *     2. Atomicity with the negative-cache invalidation: after Convex
 *        revokes successfully, we set `pro-mcp-token-neg:<tokenId>` so
 *        any in-flight Pro MCP request whose bearer points to this row
 *        gets a 401 within the 60s neg-cache window.
 *
 * Request:  { tokenId: string }
 * Response: { ok: true }
 *
 * Status codes:
 *   - 200 success
 *   - 400 missing/empty tokenId
 *   - 401 missing/invalid Clerk session
 *   - 404 Convex returned NOT_FOUND (caller does not own this row, OR the
 *         row id is malformed/never-existed β€” collapsed per anti-enumeration)
 *   - 405 non-POST
 *   - 409 Convex returned ALREADY_REVOKED
 *   - 503 Convex network/transient error (Retry-After: 5)
 *
 * Cache-Control: no-store.
 */

export const config = { runtime: 'edge' };

// @ts-expect-error β€” JS module, no declaration file
import { getCorsHeaders } from '../_cors.js';
// @ts-expect-error β€” JS module, no declaration file
import { captureSilentError } from '../_sentry-edge.js';
import { resolveClerkSession } from '../../server/_shared/auth-session';
import { invalidateProMcpTokenCache } from '../../server/_shared/pro-mcp-token';

/** Convex internal HTTP-action call timeout. Mirrors U2's pro-mcp-token.ts. */
const CONVEX_TIMEOUT_MS = 3_000;

/** Inner handler β€” exported for unit tests with injected deps. */
export interface RevokeDeps {
  /** Resolves the Clerk userId from the request's Bearer header. Null = unauth. */
  resolveUserId: (req: Request) => Promise<string | null>;
  /**
   * Calls Convex `/api/internal-revoke-pro-mcp-token` server-to-server.
   * Returns the discriminated outcome:
   *   - ok: 200 from Convex
   *   - not-found: 404 (NOT_FOUND or invalid id)
   *   - already-revoked: 409
   *   - network: 5xx, transport error, timeout, or env unset
   */
  convexRevoke: (
    userId: string,
    tokenId: string,
  ) => Promise<{ ok: true } | { ok: false; reason: 'not-found' | 'already-revoked' | 'network' }>;
  /**
   * Best-effort negative-cache write. Failures are logged but do NOT mask
   * the success β€” the revoke is already authoritative on Convex; the cache
   * sentinel is a defensive optimisation that shrinks the staleness window
   * for already-resolved bearers from "next bearer-resolution" to "next
   * negative-cache read".
   */
  invalidateCache: (tokenId: string) => Promise<void>;
}

async function callConvexRevoke(
  userId: string,
  tokenId: string,
): Promise<
  { ok: true } | { ok: false; reason: 'not-found' | 'already-revoked' | 'network' }
> {
  const siteUrl = process.env.CONVEX_SITE_URL;
  const sharedSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
  if (!siteUrl || !sharedSecret) {
    return { ok: false, reason: 'network' };
  }

  let resp: Response;
  try {
    resp = await fetch(`${siteUrl}/api/internal-revoke-pro-mcp-token`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'User-Agent': 'worldmonitor-gateway/1.0',
        'x-convex-shared-secret': sharedSecret,
      },
      body: JSON.stringify({ userId, tokenId }),
      signal: AbortSignal.timeout(CONVEX_TIMEOUT_MS),
    });
  } catch (err) {
    console.warn(
      '[mcp-revoke] Convex fetch failed:',
      err instanceof Error ? err.message : String(err),
    );
    captureSilentError(err, {
      tags: { route: 'api/user/mcp-revoke', step: 'convex-fetch' },
    });
    return { ok: false, reason: 'network' };
  }

  if (resp.ok) return { ok: true };
  if (resp.status === 404) return { ok: false, reason: 'not-found' };
  if (resp.status === 409) return { ok: false, reason: 'already-revoked' };
  return { ok: false, reason: 'network' };
}

export async function revokeHandler(req: Request, deps: RevokeDeps): Promise<Response> {
  const cors = getCorsHeaders(req);
  const jsonHeaders = {
    ...cors,
    'Content-Type': 'application/json',
    'Cache-Control': 'no-store',
  };

  if (req.method === 'OPTIONS') {
    return new Response(null, { status: 204, headers: cors });
  }
  if (req.method !== 'POST') {
    return new Response(JSON.stringify({ error: 'method_not_allowed' }), {
      status: 405,
      headers: { ...jsonHeaders, Allow: 'POST, OPTIONS' },
    });
  }

  const userId = await deps.resolveUserId(req);
  if (!userId) {
    return new Response(JSON.stringify({ error: 'unauthenticated' }), {
      status: 401,
      headers: jsonHeaders,
    });
  }

  let body: unknown;
  try {
    body = await req.json();
  } catch {
    return new Response(JSON.stringify({ error: 'invalid_json' }), {
      status: 400,
      headers: jsonHeaders,
    });
  }

  const tokenId = (body as { tokenId?: unknown })?.tokenId;
  if (typeof tokenId !== 'string' || tokenId.length === 0) {
    return new Response(JSON.stringify({ error: 'missing_token_id' }), {
      status: 400,
      headers: jsonHeaders,
    });
  }

  // userId here is ALWAYS from the Clerk session β€” never trust client input.
  const result = await deps.convexRevoke(userId, tokenId);

  if (result.ok) {
    // Best-effort cache invalidation. Failures are swallowed here AND
    // inside invalidateProMcpTokenCache (writeNegCache logs + returns).
    // The revoke succeeded on Convex, which is the authoritative state β€”
    // surfacing a Redis blip as a 5xx would mislead the user about whether
    // the revoke actually landed (it did). The 60s neg-cache window is
    // still bounded by the read side: U2's validate path always Convex-
    // round-trips on cache miss, so a missed sentinel write only delays
    // bearer-revocation propagation by exactly one fetch.
    try {
      await deps.invalidateCache(tokenId);
    } catch (err) {
      console.warn(
        '[mcp-revoke] invalidateCache failed (revoke still succeeded):',
        err instanceof Error ? err.message : String(err),
      );
      captureSilentError(err, {
        tags: { route: 'api/user/mcp-revoke', step: 'invalidate-cache' },
      });
    }
    return new Response(JSON.stringify({ ok: true }), { status: 200, headers: jsonHeaders });
  }

  if (result.reason === 'not-found') {
    return new Response(JSON.stringify({ error: 'not_found' }), {
      status: 404,
      headers: jsonHeaders,
    });
  }
  if (result.reason === 'already-revoked') {
    return new Response(JSON.stringify({ error: 'already_revoked' }), {
      status: 409,
      headers: jsonHeaders,
    });
  }
  // network β†’ 503 + Retry-After (mirrors U7's typed-503 posture).
  return new Response(JSON.stringify({ error: 'service_unavailable' }), {
    status: 503,
    headers: { ...jsonHeaders, 'Retry-After': '5' },
  });
}

export default async function handler(req: Request): Promise<Response> {
  return revokeHandler(req, {
    resolveUserId: async (r) => (await resolveClerkSession(r))?.userId ?? null,
    convexRevoke: callConvexRevoke,
    invalidateCache: invalidateProMcpTokenCache,
  });
}