@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,443 @@
1
+ /**
2
+ * License management for VantagePeers open-core self-host pack.
3
+ *
4
+ * Security model:
5
+ * - Raw license keys are NEVER stored. Only SHA-256 hex hashes persist.
6
+ * - generate() is gated behind BEARER_SECRET_MASTER (timing-safe comparison).
7
+ * - activate() validates key hash + email match + status + expiry.
8
+ * - validate() is read-only and never throws — returns "unknown" for bad keys.
9
+ *
10
+ * Pricing: 99 EUR/year — productCode "vantage-peers-self-host",
11
+ * tier "open-core-99-eur-yr".
12
+ */
13
+
14
+ import { v } from "convex/values";
15
+ import { internalMutation, mutation, query } from "./_generated/server";
16
+
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+ // Crypto helpers (Convex V8 runtime — SubtleCrypto + getRandomValues available)
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+
21
+ /** SHA-256 hex digest of a UTF-8 string. */
22
+ async function sha256Hex(input: string): Promise<string> {
23
+ const encoder = new TextEncoder();
24
+ const bytes = encoder.encode(input);
25
+ const hashBuffer = await crypto.subtle.digest("SHA-256", bytes);
26
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
27
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
28
+ }
29
+
30
+ /** Constant-time string comparison to prevent timing attacks. */
31
+ async function timingSafeEqual(a: string, b: string): Promise<boolean> {
32
+ const encoder = new TextEncoder();
33
+ const aBytes = encoder.encode(a);
34
+ const bBytes = encoder.encode(b);
35
+ if (aBytes.length !== bBytes.length) {
36
+ // Always run the HMAC to consume constant time.
37
+ const dummy = new Uint8Array(aBytes.length);
38
+ const aKey = await crypto.subtle.importKey(
39
+ "raw",
40
+ aBytes,
41
+ { name: "HMAC", hash: "SHA-256" },
42
+ false,
43
+ ["sign"],
44
+ );
45
+ await crypto.subtle.sign("HMAC", aKey, dummy);
46
+ return false;
47
+ }
48
+ let diff = 0;
49
+ for (let i = 0; i < aBytes.length; i++) {
50
+ diff |= aBytes[i] ^ bBytes[i];
51
+ }
52
+ return diff === 0;
53
+ }
54
+
55
+ /**
56
+ * Generate a cryptographically random license key.
57
+ * Uses crypto.getRandomValues (CSPRNG) — never Math.random.
58
+ * Returns a base64url-encoded 32-byte key (~43 chars, URL-safe, no padding).
59
+ */
60
+ function generateRawKey(): string {
61
+ const bytes = new Uint8Array(32);
62
+ crypto.getRandomValues(bytes);
63
+ // Convert to base64url manually (URL-safe, no "=" padding)
64
+ const base64 = btoa(String.fromCharCode(...bytes));
65
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
66
+ }
67
+
68
+ /** Assert the caller holds the BEARER_SECRET_MASTER token. */
69
+ async function requireMasterAuth(callerToken: string): Promise<void> {
70
+ const masterToken = process.env.BEARER_SECRET_MASTER;
71
+ if (!masterToken) {
72
+ throw new Error("Server misconfiguration: BEARER_SECRET_MASTER not set");
73
+ }
74
+ const valid = await timingSafeEqual(callerToken, masterToken);
75
+ if (!valid) {
76
+ throw new Error("Forbidden");
77
+ }
78
+ }
79
+
80
+ // ─────────────────────────────────────────────────────────────────────────────
81
+ // generate — admin-only: create and return a new license key
82
+ // ─────────────────────────────────────────────────────────────────────────────
83
+
84
+ export const generate = mutation({
85
+ args: {
86
+ callerToken: v.string(), // must match BEARER_SECRET_MASTER
87
+ customerEmail: v.string(),
88
+ customerName: v.optional(v.string()),
89
+ productCode: v.string(),
90
+ tier: v.string(),
91
+ purchaseLocale: v.optional(v.union(v.literal("en"), v.literal("fr"))),
92
+ githubRepos: v.optional(v.array(v.string())),
93
+ gumroadOrderId: v.optional(v.string()),
94
+ expiresInDays: v.optional(v.number()), // defaults to 365
95
+ },
96
+ returns: v.object({
97
+ licenseKey: v.string(),
98
+ licenseId: v.id("licenses"),
99
+ expiresAt: v.number(),
100
+ }),
101
+ handler: async (ctx, args) => {
102
+ await requireMasterAuth(args.callerToken);
103
+
104
+ const rawKey = generateRawKey();
105
+ const keyHash = await sha256Hex(rawKey);
106
+
107
+ const now = Date.now();
108
+ const expiresInDays = args.expiresInDays ?? 365;
109
+ const expiresAt = now + expiresInDays * 24 * 60 * 60 * 1000;
110
+
111
+ const licenseId = await ctx.db.insert("licenses", {
112
+ keyHash,
113
+ customerEmail: args.customerEmail,
114
+ customerName: args.customerName,
115
+ productCode: args.productCode,
116
+ tier: args.tier,
117
+ purchasedAt: now,
118
+ expiresAt,
119
+ gumroadOrderId: args.gumroadOrderId,
120
+ status: "active",
121
+ githubRepos: args.githubRepos,
122
+ purchaseLocale: args.purchaseLocale,
123
+ });
124
+
125
+ return { licenseKey: rawKey, licenseId, expiresAt };
126
+ },
127
+ });
128
+
129
+ // ─────────────────────────────────────────────────────────────────────────────
130
+ // activate — mark a license as activated (sets activatedAt on first call)
131
+ // ─────────────────────────────────────────────────────────────────────────────
132
+
133
+ export const activate = mutation({
134
+ args: {
135
+ licenseKey: v.string(),
136
+ customerEmail: v.string(),
137
+ },
138
+ returns: v.object({
139
+ ok: v.boolean(),
140
+ expiresAt: v.number(),
141
+ }),
142
+ handler: async (ctx, args) => {
143
+ const keyHash = await sha256Hex(args.licenseKey);
144
+
145
+ const license = await ctx.db
146
+ .query("licenses")
147
+ .withIndex("by_keyHash", (q) => q.eq("keyHash", keyHash))
148
+ .unique();
149
+
150
+ if (!license) {
151
+ throw new Error("License invalid or expired");
152
+ }
153
+
154
+ if (license.customerEmail !== args.customerEmail) {
155
+ throw new Error("License invalid or expired");
156
+ }
157
+
158
+ if (license.status !== "active") {
159
+ throw new Error("License invalid or expired");
160
+ }
161
+
162
+ const now = Date.now();
163
+ if (license.expiresAt <= now) {
164
+ // Mark expired in DB for consistency
165
+ await ctx.db.patch(license._id, { status: "expired" });
166
+ throw new Error("License invalid or expired");
167
+ }
168
+
169
+ // Set activatedAt only on first activation
170
+ if (license.activatedAt === undefined) {
171
+ await ctx.db.patch(license._id, { activatedAt: now });
172
+ }
173
+
174
+ return { ok: true, expiresAt: license.expiresAt };
175
+ },
176
+ });
177
+
178
+ // ─────────────────────────────────────────────────────────────────────────────
179
+ // validate — read-only license status check, never throws
180
+ // ─────────────────────────────────────────────────────────────────────────────
181
+
182
+ export const validate = query({
183
+ args: {
184
+ licenseKey: v.string(),
185
+ },
186
+ returns: v.object({
187
+ status: v.union(
188
+ v.literal("active"),
189
+ v.literal("trial"),
190
+ v.literal("revoked"),
191
+ v.literal("expired"),
192
+ v.literal("unknown"),
193
+ ),
194
+ expiresAt: v.optional(v.number()),
195
+ customerEmail: v.optional(v.string()),
196
+ }),
197
+ handler: async (ctx, args) => {
198
+ const keyHash = await sha256Hex(args.licenseKey);
199
+
200
+ const license = await ctx.db
201
+ .query("licenses")
202
+ .withIndex("by_keyHash", (q) => q.eq("keyHash", keyHash))
203
+ .unique();
204
+
205
+ if (!license) {
206
+ return { status: "unknown" as const };
207
+ }
208
+
209
+ // Check expiry on the fly — status field may lag until activate/cron updates it
210
+ const now = Date.now();
211
+ if (license.status === "active" && license.expiresAt <= now) {
212
+ return {
213
+ status: "expired" as const,
214
+ expiresAt: license.expiresAt,
215
+ customerEmail: license.customerEmail,
216
+ };
217
+ }
218
+
219
+ return {
220
+ status: license.status,
221
+ expiresAt: license.expiresAt,
222
+ customerEmail: license.customerEmail,
223
+ };
224
+ },
225
+ });
226
+
227
+ // ─────────────────────────────────────────────────────────────────────────────
228
+ // generateInternal — server-only: generate a license without master token
229
+ // Called by gumroadWebhook action (which has already verified the Gumroad
230
+ // HMAC signature before reaching this point).
231
+ // ─────────────────────────────────────────────────────────────────────────────
232
+
233
+ export const generateInternal = internalMutation({
234
+ args: {
235
+ customerEmail: v.string(),
236
+ customerName: v.optional(v.string()),
237
+ productCode: v.string(),
238
+ tier: v.string(),
239
+ purchaseLocale: v.optional(v.union(v.literal("en"), v.literal("fr"))),
240
+ githubRepos: v.optional(v.array(v.string())),
241
+ gumroadOrderId: v.optional(v.string()),
242
+ expiresInDays: v.optional(v.number()),
243
+ },
244
+ returns: v.object({
245
+ licenseKey: v.string(),
246
+ licenseId: v.id("licenses"),
247
+ expiresAt: v.number(),
248
+ }),
249
+ handler: async (ctx, args) => {
250
+ const rawKey = generateRawKey();
251
+ const keyHash = await sha256Hex(rawKey);
252
+
253
+ const now = Date.now();
254
+ const expiresInDays = args.expiresInDays ?? 365;
255
+ const expiresAt = now + expiresInDays * 24 * 60 * 60 * 1000;
256
+
257
+ const licenseId = await ctx.db.insert("licenses", {
258
+ keyHash,
259
+ customerEmail: args.customerEmail,
260
+ customerName: args.customerName,
261
+ productCode: args.productCode,
262
+ tier: args.tier,
263
+ purchasedAt: now,
264
+ expiresAt,
265
+ gumroadOrderId: args.gumroadOrderId,
266
+ status: "active",
267
+ githubRepos: args.githubRepos,
268
+ purchaseLocale: args.purchaseLocale,
269
+ });
270
+
271
+ return { licenseKey: rawKey, licenseId, expiresAt };
272
+ },
273
+ });
274
+
275
+ // ─────────────────────────────────────────────────────────────────────────────
276
+ // findOrCreateForGumroad — atomic idempotency + trial→active upgrade
277
+ // Single internalMutation that replaces the separate getByGumroadOrderId query
278
+ // + generateInternal mutation pair used in iter 1.
279
+ //
280
+ // Logic (all within one Convex transaction):
281
+ // 1. If gumroadOrderId already has a license → return existing (idempotent).
282
+ // 2. If customerEmail has a trial license → upgrade it to active (keep keyHash).
283
+ // 3. Otherwise → generate fresh key + insert new license row.
284
+ // ─────────────────────────────────────────────────────────────────────────────
285
+
286
+ export const findOrCreateForGumroad = internalMutation({
287
+ args: {
288
+ customerEmail: v.string(),
289
+ customerName: v.optional(v.string()),
290
+ productCode: v.string(),
291
+ tier: v.string(),
292
+ purchaseLocale: v.optional(v.union(v.literal("en"), v.literal("fr"))),
293
+ githubRepos: v.optional(v.array(v.string())),
294
+ gumroadOrderId: v.string(),
295
+ expiresInDays: v.optional(v.number()),
296
+ },
297
+ returns: v.object({
298
+ licenseKey: v.union(v.string(), v.null()),
299
+ licenseId: v.id("licenses"),
300
+ expiresAt: v.number(),
301
+ isExisting: v.boolean(),
302
+ isUpgraded: v.boolean(),
303
+ customerEmail: v.string(),
304
+ emailSent: v.optional(v.boolean()),
305
+ }),
306
+ handler: async (ctx, args) => {
307
+ const now = Date.now();
308
+ const expiresInDays = args.expiresInDays ?? 365;
309
+ const expiresAt = now + expiresInDays * 24 * 60 * 60 * 1000;
310
+
311
+ // Step 1: idempotency — existing license for this gumroadOrderId
312
+ const byOrderId = await ctx.db
313
+ .query("licenses")
314
+ .withIndex("by_gumroadOrderId", (q) =>
315
+ q.eq("gumroadOrderId", args.gumroadOrderId),
316
+ )
317
+ .unique();
318
+
319
+ if (byOrderId !== null) {
320
+ if (byOrderId.status === "active") {
321
+ console.warn(
322
+ `[findOrCreateForGumroad] Duplicate purchase noise: gumroadOrderId "${args.gumroadOrderId}" already mapped to active license ${byOrderId._id}. Skipping create.`,
323
+ );
324
+ }
325
+ return {
326
+ licenseKey: null,
327
+ licenseId: byOrderId._id,
328
+ expiresAt: byOrderId.expiresAt,
329
+ isExisting: true,
330
+ isUpgraded: false,
331
+ customerEmail: byOrderId.customerEmail,
332
+ emailSent: byOrderId.emailSent,
333
+ };
334
+ }
335
+
336
+ // Step 2: trial→active upgrade — existing trial for this email
337
+ const trialForEmail = await ctx.db
338
+ .query("licenses")
339
+ .withIndex("by_customerEmail", (q) =>
340
+ q.eq("customerEmail", args.customerEmail),
341
+ )
342
+ .filter((q) => q.eq(q.field("status"), "trial"))
343
+ .first();
344
+
345
+ if (trialForEmail !== null) {
346
+ await ctx.db.patch(trialForEmail._id, {
347
+ status: "active",
348
+ gumroadOrderId: args.gumroadOrderId,
349
+ purchaseLocale: args.purchaseLocale,
350
+ expiresAt,
351
+ activatedAt: now,
352
+ });
353
+ return {
354
+ licenseKey: null, // customer keeps using their trial key
355
+ licenseId: trialForEmail._id,
356
+ expiresAt,
357
+ isExisting: true,
358
+ isUpgraded: true,
359
+ customerEmail: trialForEmail.customerEmail,
360
+ emailSent: trialForEmail.emailSent,
361
+ };
362
+ }
363
+
364
+ // Step 3: net-new purchase — generate fresh key + insert
365
+ const rawKey = generateRawKey();
366
+ const keyHash = await sha256Hex(rawKey);
367
+
368
+ const licenseId = await ctx.db.insert("licenses", {
369
+ keyHash,
370
+ customerEmail: args.customerEmail,
371
+ customerName: args.customerName,
372
+ productCode: args.productCode,
373
+ tier: args.tier,
374
+ purchasedAt: now,
375
+ expiresAt,
376
+ gumroadOrderId: args.gumroadOrderId,
377
+ status: "active",
378
+ githubRepos: args.githubRepos,
379
+ purchaseLocale: args.purchaseLocale,
380
+ });
381
+
382
+ return {
383
+ licenseKey: rawKey,
384
+ licenseId,
385
+ expiresAt,
386
+ isExisting: false,
387
+ isUpgraded: false,
388
+ customerEmail: args.customerEmail,
389
+ emailSent: undefined,
390
+ };
391
+ },
392
+ });
393
+
394
+ // ─────────────────────────────────────────────────────────────────────────────
395
+ // flagEmailSent — internal: mark email delivery status on a license row
396
+ // Called by gumroadWebhook after attempting email send.
397
+ // ─────────────────────────────────────────────────────────────────────────────
398
+
399
+ export const flagEmailSent = internalMutation({
400
+ args: {
401
+ licenseId: v.id("licenses"),
402
+ emailSent: v.boolean(),
403
+ },
404
+ returns: v.null(),
405
+ handler: async (ctx, args) => {
406
+ await ctx.db.patch(args.licenseId, { emailSent: args.emailSent });
407
+ return null;
408
+ },
409
+ });
410
+
411
+ // ─────────────────────────────────────────────────────────────────────────────
412
+ // getByGumroadOrderId — internal: idempotency check for webhook handler
413
+ // ─────────────────────────────────────────────────────────────────────────────
414
+
415
+ export const getByGumroadOrderId = internalMutation({
416
+ args: {
417
+ gumroadOrderId: v.string(),
418
+ },
419
+ returns: v.union(
420
+ v.object({
421
+ licenseId: v.id("licenses"),
422
+ customerEmail: v.string(),
423
+ emailSent: v.optional(v.boolean()),
424
+ }),
425
+ v.null(),
426
+ ),
427
+ handler: async (ctx, args) => {
428
+ const existing = await ctx.db
429
+ .query("licenses")
430
+ .withIndex("by_gumroadOrderId", (q) =>
431
+ q.eq("gumroadOrderId", args.gumroadOrderId),
432
+ )
433
+ .unique();
434
+
435
+ if (!existing) return null;
436
+
437
+ return {
438
+ licenseId: existing._id,
439
+ customerEmail: existing.customerEmail,
440
+ emailSent: existing.emailSent,
441
+ };
442
+ },
443
+ });