@vantageos/backend-doctor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +88 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +94 -0
  4. package/dist/contract.d.ts +18 -0
  5. package/dist/contract.js +29 -0
  6. package/dist/coverage-cli.d.ts +2 -0
  7. package/dist/coverage-cli.js +17 -0
  8. package/dist/coverage.d.ts +32 -0
  9. package/dist/coverage.js +77 -0
  10. package/dist/detectors/oracle-axis1.d.ts +86 -0
  11. package/dist/detectors/oracle-axis1.js +221 -0
  12. package/dist/detectors/oracle-axis2.d.ts +22 -0
  13. package/dist/detectors/oracle-axis2.js +149 -0
  14. package/dist/detectors/predicates.d.ts +19 -0
  15. package/dist/detectors/predicates.js +334 -0
  16. package/dist/detectors/shared.d.ts +56 -0
  17. package/dist/detectors/shared.js +103 -0
  18. package/dist/detectors/source/_scan.d.ts +43 -0
  19. package/dist/detectors/source/_scan.js +82 -0
  20. package/dist/detectors/source/backfill.d.ts +27 -0
  21. package/dist/detectors/source/backfill.js +211 -0
  22. package/dist/detectors/source/contract.d.ts +33 -0
  23. package/dist/detectors/source/contract.js +385 -0
  24. package/dist/detectors/source/identity.d.ts +47 -0
  25. package/dist/detectors/source/identity.js +410 -0
  26. package/dist/detectors/source/jobs.d.ts +10 -0
  27. package/dist/detectors/source/jobs.js +209 -0
  28. package/dist/detectors/source/lifecycle.d.ts +6 -0
  29. package/dist/detectors/source/lifecycle.js +147 -0
  30. package/dist/detectors/source/readbudget.d.ts +6 -0
  31. package/dist/detectors/source/readbudget.js +133 -0
  32. package/dist/detectors/source/refusal.d.ts +6 -0
  33. package/dist/detectors/source/refusal.js +125 -0
  34. package/dist/detectors/source/security.d.ts +37 -0
  35. package/dist/detectors/source/security.js +392 -0
  36. package/dist/detectors/source/writesafety.d.ts +14 -0
  37. package/dist/detectors/source/writesafety.js +345 -0
  38. package/dist/detectors/standard/_doc.d.ts +66 -0
  39. package/dist/detectors/standard/_doc.js +152 -0
  40. package/dist/detectors/standard/r41-frontier.d.ts +58 -0
  41. package/dist/detectors/standard/r41-frontier.js +176 -0
  42. package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
  43. package/dist/detectors/standard/r46-returns-validator.js +474 -0
  44. package/dist/detectors/standard/standard-doc.d.ts +12 -0
  45. package/dist/detectors/standard/standard-doc.js +271 -0
  46. package/dist/detectors/structural.d.ts +14 -0
  47. package/dist/detectors/structural.js +83 -0
  48. package/dist/engine-types.d.ts +10 -0
  49. package/dist/engine-types.js +1 -0
  50. package/dist/not-checkable.d.ts +22 -0
  51. package/dist/not-checkable.js +37 -0
  52. package/dist/oracle.d.ts +33 -0
  53. package/dist/oracle.js +133 -0
  54. package/dist/registry.d.ts +11 -0
  55. package/dist/registry.js +140 -0
  56. package/dist/report.d.ts +35 -0
  57. package/dist/report.js +138 -0
  58. package/dist/severity.d.ts +30 -0
  59. package/dist/severity.js +121 -0
  60. package/dist/tally.d.ts +57 -0
  61. package/dist/tally.js +152 -0
  62. package/dist/trigger.d.ts +47 -0
  63. package/dist/trigger.js +143 -0
  64. package/fixtures/backend-standard-51ec7af1.md +648 -0
  65. package/fixtures/backend-standard-a9d7877b.md +629 -0
  66. package/fixtures/mcp-app-standard-a74a042b.md +279 -0
  67. package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
  68. package/fixtures/mcp-server-standard-a74a042b.md +434 -0
  69. package/fixtures/mcp-standard-a74a042b.md +354 -0
  70. package/fixtures/vp-1200-green/.pin +1 -0
  71. package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
  72. package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
  73. package/fixtures/vp-1200-red/.pin +1 -0
  74. package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
  75. package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
  76. package/fixtures/vp-by-tool-day158.csv +190 -0
  77. package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
  78. package/fixtures/vp-convex/.pin +1 -0
  79. package/fixtures/vp-convex/bun.lock +783 -0
  80. package/fixtures/vp-convex/convex/auth.config.ts +8 -0
  81. package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
  82. package/fixtures/vp-convex/convex/convex.config.ts +14 -0
  83. package/fixtures/vp-convex/convex/credentials.ts +591 -0
  84. package/fixtures/vp-convex/convex/crons.ts +72 -0
  85. package/fixtures/vp-convex/convex/dashboard.ts +199 -0
  86. package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
  87. package/fixtures/vp-convex/convex/issues.ts +544 -0
  88. package/fixtures/vp-convex/convex/kb.ts +305 -0
  89. package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
  90. package/fixtures/vp-convex/convex/kbShared.ts +56 -0
  91. package/fixtures/vp-convex/convex/licenses.ts +443 -0
  92. package/fixtures/vp-convex/convex/memories.ts +406 -0
  93. package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
  94. package/fixtures/vp-convex/convex/messages.ts +971 -0
  95. package/fixtures/vp-convex/convex/migrations.ts +239 -0
  96. package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
  97. package/fixtures/vp-convex/convex/oauth.ts +1608 -0
  98. package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
  99. package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
  100. package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
  101. package/fixtures/vp-convex/convex/schema.ts +1229 -0
  102. package/fixtures/vp-convex/convex/tasks.ts +2782 -0
  103. package/fixtures/vp-r31-green/.pin +1 -0
  104. package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
  105. package/fixtures/vp-r31-red/.pin +1 -0
  106. package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
  107. package/fixtures/vr-by-tool-day159.csv +89 -0
  108. package/package.json +39 -0
@@ -0,0 +1,591 @@
1
+ /**
2
+ * credentials.ts — User-grade Bearer token issuance via Clerk JWT exchange.
3
+ *
4
+ * POST /issueBearerFromClerk
5
+ * Body: { clerkJwt: string, extId: string, extVersion?: string }
6
+ *
7
+ * Flow:
8
+ * 1. Verify Clerk JWT (JWKS from CLERK_JWT_ISSUER_DOMAIN env var)
9
+ * 2. Extract sub + email from verified claims
10
+ * 3. Resolve / create workspace keyed on clerkUserId
11
+ * 4. Whitelist extId against VP_ALLOWED_EXT_IDS env var
12
+ * 5. Rate-limit: 5 req / min per clerkUserId
13
+ * 6. Issue 32-byte Bearer; store SHA-256 hash only
14
+ * 7. Audit log
15
+ * 8. Return { workspaceId, bearer, expiresAt, userName, workspaceName }
16
+ *
17
+ * Token hash pattern mirrors oauth_access_tokens (oauth.ts).
18
+ * Raw bearer is returned exactly once; never re-derivable from DB.
19
+ *
20
+ * httpAction registration: convex/http.ts delegates to handleIssueBearerFromClerk.
21
+ * Internal mutations are called via ctx.runMutation with makeFunctionReference
22
+ * (avoids _generated/api.ts dependency before codegen runs).
23
+ */
24
+
25
+ import { makeFunctionReference } from "convex/server";
26
+ import { v } from "convex/values";
27
+ import type { ActionCtx } from "./_generated/server";
28
+ import { internalMutation, internalQuery } from "./_generated/server";
29
+
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+ // Function references (avoids circular _generated/api dependency)
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+
34
+ const _checkRateLimitRef = makeFunctionReference<"mutation">(
35
+ "credentials:_checkRateLimit",
36
+ );
37
+ const _getOrCreateWorkspaceRef = makeFunctionReference<"mutation">(
38
+ "credentials:_getOrCreateWorkspace",
39
+ );
40
+ const _issueTokenRef = makeFunctionReference<"mutation">(
41
+ "credentials:_issueToken",
42
+ );
43
+ const _auditLogRef = makeFunctionReference<"mutation">("credentials:_auditLog");
44
+
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+ // CORS helpers
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ const ALLOWED_ORIGINS = ["https://vantagepeers.com", "http://localhost:3000"];
50
+
51
+ function isAllowedOrigin(origin: string | null): boolean {
52
+ if (!origin) return false;
53
+ if (ALLOWED_ORIGINS.includes(origin)) return true;
54
+ // Allow *.vantagepeers.com subdomains
55
+ if (/^https:\/\/[a-z0-9-]+\.vantagepeers\.com$/.test(origin)) return true;
56
+ return false;
57
+ }
58
+
59
+ function corsHeaders(origin: string | null): Record<string, string> {
60
+ const effectiveOrigin = isAllowedOrigin(origin)
61
+ ? (origin as string)
62
+ : "https://vantagepeers.com";
63
+ return {
64
+ "Access-Control-Allow-Origin": effectiveOrigin,
65
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
66
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
67
+ "Access-Control-Max-Age": "86400",
68
+ Vary: "Origin",
69
+ };
70
+ }
71
+
72
+ // ─────────────────────────────────────────────────────────────────────────────
73
+ // Clerk JWT verification (manual JWKS — no @clerk/backend dependency)
74
+ // ─────────────────────────────────────────────────────────────────────────────
75
+
76
+ interface ClerkClaims {
77
+ sub: string;
78
+ email?: string;
79
+ name?: string;
80
+ iss: string;
81
+ aud: string | string[];
82
+ exp: number;
83
+ iat: number;
84
+ nbf?: number;
85
+ }
86
+
87
+ interface JwksKey {
88
+ kty: string;
89
+ kid: string;
90
+ use: string;
91
+ alg: string;
92
+ n: string;
93
+ e: string;
94
+ }
95
+
96
+ interface JwksResponse {
97
+ keys: JwksKey[];
98
+ }
99
+
100
+ function base64UrlToUint8Array(input: string): Uint8Array {
101
+ const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
102
+ const padded = base64.padEnd(
103
+ base64.length + ((4 - (base64.length % 4)) % 4),
104
+ "=",
105
+ );
106
+ const binary = atob(padded);
107
+ const bytes = new Uint8Array(binary.length);
108
+ for (let i = 0; i < binary.length; i++) {
109
+ bytes[i] = binary.charCodeAt(i);
110
+ }
111
+ return bytes;
112
+ }
113
+
114
+ async function importRsaPublicKey(jwk: JwksKey): Promise<CryptoKey> {
115
+ return crypto.subtle.importKey(
116
+ "jwk",
117
+ {
118
+ kty: jwk.kty,
119
+ n: jwk.n,
120
+ e: jwk.e,
121
+ alg: jwk.alg,
122
+ use: jwk.use,
123
+ },
124
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
125
+ false,
126
+ ["verify"],
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Verifies a Clerk-issued JWT using JWKS discovery.
132
+ * Exported for testing (allows mock injection via vi.mock).
133
+ * Throws on any verification failure (signature, expiry, issuer).
134
+ */
135
+ export async function verifyClerkJwt(
136
+ token: string,
137
+ issuerDomain: string,
138
+ ): Promise<ClerkClaims> {
139
+ const parts = token.split(".");
140
+ if (parts.length !== 3) {
141
+ throw new Error("Invalid JWT format");
142
+ }
143
+ const [headerB64, payloadB64, signatureB64] = parts;
144
+
145
+ const header = JSON.parse(
146
+ new TextDecoder().decode(base64UrlToUint8Array(headerB64)),
147
+ ) as { kid: string; alg: string };
148
+
149
+ const claims = JSON.parse(
150
+ new TextDecoder().decode(base64UrlToUint8Array(payloadB64)),
151
+ ) as ClerkClaims;
152
+
153
+ // Fast-fail: check temporal claims before JWKS fetch
154
+ const CLOCK_SKEW_SEC = 60;
155
+ const nowSec = Math.floor(Date.now() / 1000);
156
+
157
+ // exp check with clock skew tolerance (allow tokens up to 60s past their stated exp)
158
+ if (typeof claims.exp !== "number" || claims.exp < nowSec - CLOCK_SKEW_SEC) {
159
+ throw new Error("JWT expired");
160
+ }
161
+
162
+ // nbf check with clock skew tolerance (refuse tokens whose nbf is more than 60s in the future)
163
+ if (claims.nbf !== undefined) {
164
+ if (typeof claims.nbf !== "number") {
165
+ throw new Error("JWT nbf claim invalid type");
166
+ }
167
+ if (claims.nbf > nowSec + CLOCK_SKEW_SEC) {
168
+ throw new Error("JWT not yet valid (nbf > now + skew)");
169
+ }
170
+ }
171
+
172
+ // iat sanity check (if present, must not be in the far future)
173
+ if (typeof claims.iat === "number" && claims.iat > nowSec + CLOCK_SKEW_SEC) {
174
+ throw new Error("JWT iat in the future beyond clock skew tolerance");
175
+ }
176
+
177
+ const expectedIssuer = issuerDomain.startsWith("https://")
178
+ ? issuerDomain.replace(/\/$/, "")
179
+ : `https://${issuerDomain}`;
180
+
181
+ if (claims.iss !== expectedIssuer) {
182
+ throw new Error(
183
+ `JWT issuer mismatch: expected ${expectedIssuer}, got ${claims.iss}`,
184
+ );
185
+ }
186
+
187
+ // aud validation — VP_CLERK_EXPECTED_AUD env var required
188
+ const expectedAud = process.env.VP_CLERK_EXPECTED_AUD;
189
+ if (!expectedAud) {
190
+ throw new Error(
191
+ "VP_CLERK_EXPECTED_AUD env var not set — refusing to validate JWT without expected audience",
192
+ );
193
+ }
194
+ const audClaim = claims.aud;
195
+ const audMatches = Array.isArray(audClaim)
196
+ ? audClaim.includes(expectedAud)
197
+ : audClaim === expectedAud;
198
+ if (!audMatches) {
199
+ throw new Error(
200
+ `JWT aud claim mismatch: expected ${expectedAud}, got ${JSON.stringify(audClaim)}`,
201
+ );
202
+ }
203
+
204
+ const jwksUrl = `${expectedIssuer}/.well-known/jwks.json`;
205
+ const jwksRes = await fetch(jwksUrl);
206
+ if (!jwksRes.ok) {
207
+ throw new Error(`Failed to fetch JWKS from ${jwksUrl}: ${jwksRes.status}`);
208
+ }
209
+ const jwks = (await jwksRes.json()) as JwksResponse;
210
+
211
+ const jwk = jwks.keys.find((k) => k.kid === header.kid);
212
+ if (!jwk) {
213
+ throw new Error(`No matching JWKS key for kid=${header.kid}`);
214
+ }
215
+
216
+ const publicKey = await importRsaPublicKey(jwk);
217
+ const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
218
+ const sigBytes = base64UrlToUint8Array(signatureB64);
219
+ // Cast to ArrayBuffer to satisfy strict lib.dom.d.ts BufferSource typing
220
+ const sigBuffer = sigBytes.buffer as ArrayBuffer;
221
+
222
+ const valid = await crypto.subtle.verify(
223
+ "RSASSA-PKCS1-v1_5",
224
+ publicKey,
225
+ sigBuffer,
226
+ signingInput,
227
+ );
228
+ if (!valid) {
229
+ throw new Error("JWT signature verification failed");
230
+ }
231
+
232
+ return claims;
233
+ }
234
+
235
+ // ─────────────────────────────────────────────────────────────────────────────
236
+ // SHA-256 hex helper
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+
239
+ export async function sha256Hex(input: string): Promise<string> {
240
+ const encoded = new TextEncoder().encode(input);
241
+ const hashBuffer = await crypto.subtle.digest("SHA-256", encoded);
242
+ return Array.from(new Uint8Array(hashBuffer))
243
+ .map((b) => b.toString(16).padStart(2, "0"))
244
+ .join("");
245
+ }
246
+
247
+ // ─────────────────────────────────────────────────────────────────────────────
248
+ // Internal mutations (database access — ctx.db not available in httpAction)
249
+ // ─────────────────────────────────────────────────────────────────────────────
250
+
251
+ export const _getOrCreateWorkspace = internalMutation({
252
+ args: {
253
+ clerkUserId: v.string(),
254
+ email: v.optional(v.string()),
255
+ name: v.optional(v.string()),
256
+ },
257
+ returns: v.object({
258
+ workspaceId: v.string(),
259
+ workspaceName: v.string(),
260
+ isNew: v.boolean(),
261
+ }),
262
+ handler: async (_ctx, args) => {
263
+ // workspaceId is the stable clerkUserId — no separate workspace table needed.
264
+ // Workspace creation (separate table) is deferred to V0.0.3 multi-tenant work.
265
+ const workspaceId = args.clerkUserId;
266
+ const workspaceName =
267
+ args.name ??
268
+ args.email?.split("@")[0] ??
269
+ `user-${args.clerkUserId.slice(-8)}`;
270
+ return { workspaceId, workspaceName, isNew: false };
271
+ },
272
+ });
273
+
274
+ export const _checkRateLimit = internalMutation({
275
+ args: {
276
+ key: v.string(),
277
+ maxPerWindow: v.number(),
278
+ windowMs: v.number(),
279
+ },
280
+ returns: v.object({ allowed: v.boolean(), count: v.number() }),
281
+ handler: async (ctx, args) => {
282
+ const now = Date.now();
283
+ const existing = await ctx.db
284
+ .query("credentialsRateLimits")
285
+ .withIndex("by_key", (q) => q.eq("key", args.key))
286
+ .unique();
287
+
288
+ if (!existing || now - existing.windowStart >= args.windowMs) {
289
+ if (existing) {
290
+ await ctx.db.patch(existing._id, { count: 1, windowStart: now });
291
+ } else {
292
+ await ctx.db.insert("credentialsRateLimits", {
293
+ key: args.key,
294
+ count: 1,
295
+ windowStart: now,
296
+ });
297
+ }
298
+ return { allowed: true, count: 1 };
299
+ }
300
+
301
+ const newCount = existing.count + 1;
302
+ if (newCount > args.maxPerWindow) {
303
+ return { allowed: false, count: existing.count };
304
+ }
305
+ await ctx.db.patch(existing._id, { count: newCount });
306
+ return { allowed: true, count: newCount };
307
+ },
308
+ });
309
+
310
+ export const _issueToken = internalMutation({
311
+ args: {
312
+ tokenHash: v.string(),
313
+ clerkUserId: v.string(),
314
+ workspaceId: v.string(),
315
+ extId: v.string(),
316
+ expiresAt: v.number(),
317
+ },
318
+ returns: v.null(),
319
+ handler: async (ctx, args) => {
320
+ await ctx.db.insert("userBearerTokens", {
321
+ tokenHash: args.tokenHash,
322
+ clerkUserId: args.clerkUserId,
323
+ workspaceId: args.workspaceId,
324
+ extId: args.extId,
325
+ expiresAt: args.expiresAt,
326
+ revoked: false,
327
+ createdAt: Date.now(),
328
+ });
329
+ return null;
330
+ },
331
+ });
332
+
333
+ export const _auditLog = internalMutation({
334
+ args: {
335
+ clerkUserId: v.string(),
336
+ workspaceId: v.string(),
337
+ extId: v.string(),
338
+ extVersion: v.optional(v.string()),
339
+ issuedAt: v.number(),
340
+ ip: v.optional(v.string()),
341
+ userAgent: v.optional(v.string()),
342
+ },
343
+ returns: v.null(),
344
+ handler: async (ctx, args) => {
345
+ await ctx.db.insert("credentialsAuditLog", {
346
+ clerkUserId: args.clerkUserId,
347
+ workspaceId: args.workspaceId,
348
+ extId: args.extId,
349
+ extVersion: args.extVersion,
350
+ issuedAt: args.issuedAt,
351
+ ip: args.ip,
352
+ userAgent: args.userAgent,
353
+ });
354
+ return null;
355
+ },
356
+ });
357
+
358
+ export const _getTokenByHash = internalQuery({
359
+ args: { tokenHash: v.string() },
360
+ returns: v.union(
361
+ v.object({
362
+ clerkUserId: v.string(),
363
+ workspaceId: v.string(),
364
+ extId: v.string(),
365
+ expiresAt: v.number(),
366
+ revoked: v.boolean(),
367
+ }),
368
+ v.null(),
369
+ ),
370
+ handler: async (ctx, args) => {
371
+ const row = await ctx.db
372
+ .query("userBearerTokens")
373
+ .withIndex("by_token_hash", (q) => q.eq("tokenHash", args.tokenHash))
374
+ .unique();
375
+ if (!row) return null;
376
+ return {
377
+ clerkUserId: row.clerkUserId,
378
+ workspaceId: row.workspaceId,
379
+ extId: row.extId,
380
+ expiresAt: row.expiresAt,
381
+ revoked: row.revoked,
382
+ };
383
+ },
384
+ });
385
+
386
+ // ─────────────────────────────────────────────────────────────────────────────
387
+ // Core handler — called from http.ts httpAction wrapper
388
+ // ─────────────────────────────────────────────────────────────────────────────
389
+
390
+ export async function handleIssueBearerFromClerk(
391
+ ctx: ActionCtx,
392
+ request: Request,
393
+ // Dependency injection hook — override in tests to skip real JWKS fetch.
394
+ _verifyJwt: (
395
+ token: string,
396
+ issuerDomain: string,
397
+ ) => Promise<ClerkClaims> = verifyClerkJwt,
398
+ ): Promise<Response> {
399
+ const origin = request.headers.get("origin");
400
+ const corsHdrs = corsHeaders(origin);
401
+
402
+ // ── CORS preflight ────────────────────────────────────────────────────────
403
+ if (request.method === "OPTIONS") {
404
+ return new Response(null, { status: 204, headers: corsHdrs });
405
+ }
406
+
407
+ // ── Parse body ────────────────────────────────────────────────────────────
408
+ let body: { clerkJwt?: unknown; extId?: unknown; extVersion?: unknown };
409
+ try {
410
+ body = (await request.json()) as typeof body;
411
+ } catch {
412
+ return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
413
+ status: 400,
414
+ headers: { ...corsHdrs, "Content-Type": "application/json" },
415
+ });
416
+ }
417
+
418
+ const { clerkJwt, extId, extVersion } = body;
419
+
420
+ if (typeof clerkJwt !== "string" || !clerkJwt) {
421
+ return new Response(
422
+ JSON.stringify({ error: "Missing required field: clerkJwt" }),
423
+ {
424
+ status: 400,
425
+ headers: { ...corsHdrs, "Content-Type": "application/json" },
426
+ },
427
+ );
428
+ }
429
+ if (typeof extId !== "string" || !extId) {
430
+ return new Response(
431
+ JSON.stringify({ error: "Missing required field: extId" }),
432
+ {
433
+ status: 400,
434
+ headers: { ...corsHdrs, "Content-Type": "application/json" },
435
+ },
436
+ );
437
+ }
438
+
439
+ // ── 1. Verify Clerk JWT ───────────────────────────────────────────────────
440
+ const issuerDomain = process.env.CLERK_JWT_ISSUER_DOMAIN;
441
+ if (!issuerDomain) {
442
+ return new Response(
443
+ JSON.stringify({
444
+ error: "Server misconfigured: CLERK_JWT_ISSUER_DOMAIN not set",
445
+ }),
446
+ {
447
+ status: 500,
448
+ headers: { ...corsHdrs, "Content-Type": "application/json" },
449
+ },
450
+ );
451
+ }
452
+
453
+ let claims: ClerkClaims;
454
+ try {
455
+ claims = await _verifyJwt(clerkJwt, issuerDomain);
456
+ } catch (err) {
457
+ return new Response(
458
+ JSON.stringify({
459
+ error: "Invalid Clerk JWT",
460
+ detail: err instanceof Error ? err.message : String(err),
461
+ }),
462
+ {
463
+ status: 401,
464
+ headers: { ...corsHdrs, "Content-Type": "application/json" },
465
+ },
466
+ );
467
+ }
468
+
469
+ const clerkUserId = claims.sub;
470
+ const email = claims.email;
471
+ const userName: string =
472
+ (claims.name as string | undefined) ??
473
+ email?.split("@")[0] ??
474
+ `user-${clerkUserId.slice(-8)}`;
475
+
476
+ // ── 2. Whitelist extId ────────────────────────────────────────────────────
477
+ const allowedExtIdsRaw = process.env.VP_ALLOWED_EXT_IDS;
478
+ const allowedExtIds: string[] = allowedExtIdsRaw
479
+ ? allowedExtIdsRaw
480
+ .split(",")
481
+ .map((s) => s.trim())
482
+ .filter(Boolean)
483
+ : [];
484
+
485
+ if (!allowedExtIds.length) {
486
+ if (process.env.NODE_ENV === "production") {
487
+ return new Response(
488
+ JSON.stringify({
489
+ error:
490
+ "Server misconfigured: VP_ALLOWED_EXT_IDS not configured — refusing all extension auth in production",
491
+ }),
492
+ {
493
+ status: 500,
494
+ headers: { ...corsHdrs, "Content-Type": "application/json" },
495
+ },
496
+ );
497
+ }
498
+ // allow-hardcode: dev-only fallback
499
+ allowedExtIds.push("mhfnnhkmnclmnnllhmoidkflgpkogjpe");
500
+ }
501
+
502
+ if (!allowedExtIds.includes(extId)) {
503
+ return new Response(JSON.stringify({ error: "Extension not authorized" }), {
504
+ status: 403,
505
+ headers: { ...corsHdrs, "Content-Type": "application/json" },
506
+ });
507
+ }
508
+
509
+ // ── 3. Rate limit: 5 requests per minute per clerkUserId ─────────────────
510
+ const rateLimitKey = `${clerkUserId}-issueBearer`;
511
+ const rateLimit = (await ctx.runMutation(_checkRateLimitRef, {
512
+ key: rateLimitKey,
513
+ maxPerWindow: 5,
514
+ windowMs: 60_000,
515
+ })) as { allowed: boolean; count: number };
516
+
517
+ if (!rateLimit.allowed) {
518
+ return new Response(
519
+ JSON.stringify({
520
+ error: "Rate limit exceeded. Try again in 1 minute.",
521
+ }),
522
+ {
523
+ status: 429,
524
+ headers: {
525
+ ...corsHdrs,
526
+ "Content-Type": "application/json",
527
+ "Retry-After": "60",
528
+ },
529
+ },
530
+ );
531
+ }
532
+
533
+ // ── 4. Resolve / create workspace ────────────────────────────────────────
534
+ const workspace = (await ctx.runMutation(_getOrCreateWorkspaceRef, {
535
+ clerkUserId,
536
+ email,
537
+ name: claims.name as string | undefined,
538
+ })) as { workspaceId: string; workspaceName: string; isNew: boolean };
539
+
540
+ // ── 5. Generate Bearer token — 32 random bytes → 64-char hex ─────────────
541
+ const rawBytes = new Uint8Array(32);
542
+ crypto.getRandomValues(rawBytes);
543
+ const bearer = Array.from(rawBytes)
544
+ .map((b) => b.toString(16).padStart(2, "0"))
545
+ .join("");
546
+
547
+ const tokenHash = await sha256Hex(bearer);
548
+ const TTL_7_DAYS = 7 * 24 * 60 * 60 * 1000;
549
+ const expiresAt = Date.now() + TTL_7_DAYS;
550
+
551
+ // ── 6. Store hash only — raw bearer NEVER written to DB ──────────────────
552
+ await ctx.runMutation(_issueTokenRef, {
553
+ tokenHash,
554
+ clerkUserId,
555
+ workspaceId: workspace.workspaceId,
556
+ extId,
557
+ expiresAt,
558
+ });
559
+
560
+ // ── 7. Audit log ──────────────────────────────────────────────────────────
561
+ const ip =
562
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
563
+ request.headers.get("x-real-ip") ??
564
+ undefined;
565
+ const userAgent = request.headers.get("user-agent") ?? undefined;
566
+
567
+ await ctx.runMutation(_auditLogRef, {
568
+ clerkUserId,
569
+ workspaceId: workspace.workspaceId,
570
+ extId,
571
+ extVersion: typeof extVersion === "string" ? extVersion : undefined,
572
+ issuedAt: Date.now(),
573
+ ip,
574
+ userAgent,
575
+ });
576
+
577
+ // ── 8. Return payload ─────────────────────────────────────────────────────
578
+ return new Response(
579
+ JSON.stringify({
580
+ workspaceId: workspace.workspaceId,
581
+ bearer,
582
+ expiresAt,
583
+ userName,
584
+ workspaceName: workspace.workspaceName,
585
+ }),
586
+ {
587
+ status: 200,
588
+ headers: { ...corsHdrs, "Content-Type": "application/json" },
589
+ },
590
+ );
591
+ }
@@ -0,0 +1,72 @@
1
+ import { cronJobs } from "convex/server";
2
+ import { internal } from "./_generated/api";
3
+
4
+ const crons = cronJobs();
5
+
6
+ // Process recurring tasks every 15 minutes
7
+ crons.interval(
8
+ "process recurring tasks",
9
+ { minutes: 15 },
10
+ internal.recurringTasks.processDueTasks,
11
+ );
12
+
13
+ // Poll monitored deployments for errors every 5 minutes
14
+ crons.interval(
15
+ "error monitor",
16
+ { minutes: 5 },
17
+ internal.errorMonitorActions.pollAllDeployments,
18
+ {},
19
+ );
20
+
21
+ // Calculate issue resolution stats daily at 6am UTC
22
+ crons.cron(
23
+ "daily issue stats",
24
+ "0 6 * * *",
25
+ internal.issueStats.calculateAllRepos,
26
+ {},
27
+ );
28
+
29
+ // Poll open PRs on external repos every hour
30
+ crons.interval("pr monitor", { hours: 1 }, internal.prMonitor.pollOpenPRs, {});
31
+
32
+ // Purge expired OAuth auth codes + tokens every hour (B2)
33
+ crons.interval(
34
+ "cleanup expired oauth",
35
+ { hours: 1 },
36
+ internal.oauthDcr.cleanupExpiredOAuth,
37
+ {},
38
+ );
39
+
40
+ // Auto-resolve stale false-positive IRP missions.
41
+ // Day 76 doctrine mechanism 3: "any automation that creates work must resolve it."
42
+ // Closes missions + tasks + GH issues for errors that stopped recurring (>24h quiet).
43
+ crons.interval( // allow-time-estimate: polling interval — cron config
44
+ "auto-resolve stale irp",
45
+ { hours: 6 },
46
+ internal.errorMonitorAutoResolver.autoResolveStaleIrp,
47
+ {},
48
+ );
49
+
50
+ // Day 98 (k173yr5n1) Mechanism (c2) — auto-close per-PR Deploy tasks that
51
+ // were already covered by a bundled deploy chain (recorded via
52
+ // githubRepoMapping.recordDeployment). Pair with Mechanism (a) which
53
+ // prevents new such tasks from spawning; this cron catches residue.
54
+ crons.interval( // allow-time-estimate: polling interval — cron config
55
+ "resolve stale deploy tasks",
56
+ { hours: 6 },
57
+ internal.tasks.resolveStaleDeployTasks,
58
+ {},
59
+ );
60
+
61
+ // PR C — (c1) Issue-closed external sweep.
62
+ // GH issue closed externally → linked IRP missions + cascade tasks stay zombie.
63
+ // Fix: sweep active missions with GH issue refs, close mission + tasks if
64
+ // the GH issue is now state=closed. Runs every 6h aligned with c2 cron.
65
+ crons.interval( // allow-time-estimate: polling interval — cron config
66
+ "issue closed sweep",
67
+ { hours: 6 },
68
+ internal.issueClosedSweep.sweepIssueClosed,
69
+ {},
70
+ );
71
+
72
+ export default crons;