jettypod 4.4.115 → 4.4.118

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 (73) hide show
  1. package/.env +7 -0
  2. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +25 -9
  3. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +7 -3
  4. package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
  5. package/apps/dashboard/app/api/usage/route.ts +17 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +24 -0
  7. package/apps/dashboard/app/install-claude/page.tsx +8 -6
  8. package/apps/dashboard/app/login/page.tsx +229 -0
  9. package/apps/dashboard/app/page.tsx +5 -3
  10. package/apps/dashboard/app/settings/page.tsx +2 -0
  11. package/apps/dashboard/app/subscribe/page.tsx +11 -0
  12. package/apps/dashboard/app/welcome/page.tsx +23 -0
  13. package/apps/dashboard/components/AppShell.tsx +51 -9
  14. package/apps/dashboard/components/CardMenu.tsx +14 -5
  15. package/apps/dashboard/components/ClaudePanel.tsx +65 -9
  16. package/apps/dashboard/components/ConnectClaudeScreen.tsx +223 -0
  17. package/apps/dashboard/components/DragContext.tsx +73 -64
  18. package/apps/dashboard/components/DraggableCard.tsx +6 -46
  19. package/apps/dashboard/components/GateCard.tsx +21 -0
  20. package/apps/dashboard/components/InstallClaudeScreen.tsx +132 -30
  21. package/apps/dashboard/components/KanbanBoard.tsx +173 -56
  22. package/apps/dashboard/components/PlaceholderCard.tsx +9 -19
  23. package/apps/dashboard/components/ProjectSwitcher.tsx +28 -0
  24. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +34 -3
  25. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +30 -2
  26. package/apps/dashboard/components/SubscribeContent.tsx +191 -0
  27. package/apps/dashboard/components/TipCard.tsx +176 -0
  28. package/apps/dashboard/components/UpgradeBanner.tsx +29 -0
  29. package/apps/dashboard/components/WelcomeScreen.tsx +14 -4
  30. package/apps/dashboard/components/settings/AccountSection.tsx +163 -0
  31. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +292 -29
  32. package/apps/dashboard/contexts/UsageContext.tsx +131 -0
  33. package/apps/dashboard/contexts/usageHelpers.js +9 -0
  34. package/apps/dashboard/electron/ipc-handlers.js +220 -114
  35. package/apps/dashboard/electron/main.js +415 -37
  36. package/apps/dashboard/electron/preload.js +23 -4
  37. package/apps/dashboard/electron/session-manager.js +141 -0
  38. package/apps/dashboard/electron-builder.config.js +3 -5
  39. package/apps/dashboard/lib/claude-process-manager.ts +6 -4
  40. package/apps/dashboard/lib/db-bridge.ts +32 -0
  41. package/apps/dashboard/lib/db.ts +159 -13
  42. package/apps/dashboard/lib/session-state-machine.ts +3 -0
  43. package/apps/dashboard/lib/session-stream-manager.ts +76 -13
  44. package/apps/dashboard/lib/tests.ts +3 -1
  45. package/apps/dashboard/next.config.js +19 -14
  46. package/apps/dashboard/package.json +3 -1
  47. package/apps/dashboard/scripts/upload-to-r2.js +89 -0
  48. package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
  49. package/apps/update-server/package.json +16 -0
  50. package/apps/update-server/schema.sql +31 -0
  51. package/apps/update-server/src/index.ts +1074 -0
  52. package/apps/update-server/tsconfig.json +16 -0
  53. package/apps/update-server/wrangler.toml +35 -0
  54. package/docs/bdd-guidance.md +390 -0
  55. package/jettypod.js +5 -4
  56. package/lib/migrations/027-plan-at-creation-column.js +31 -0
  57. package/lib/migrations/028-ready-for-review-column.js +27 -0
  58. package/lib/schema.js +3 -1
  59. package/lib/seed-onboarding.js +100 -68
  60. package/lib/work-commands/index.js +43 -13
  61. package/lib/work-tracking/index.js +46 -27
  62. package/package.json +1 -1
  63. package/skills-templates/bug-mode/SKILL.md +5 -11
  64. package/skills-templates/request-routing/SKILL.md +24 -11
  65. package/skills-templates/simple-improvement/SKILL.md +35 -19
  66. package/skills-templates/stable-mode/SKILL.md +5 -6
  67. package/templates/bdd-guidance.md +139 -0
  68. package/templates/bdd-scaffolding/wait.js +18 -0
  69. package/templates/bdd-scaffolding/world.js +19 -0
  70. package/.jettypod-backup/work.db +0 -0
  71. package/apps/dashboard/app/access-code/page.tsx +0 -110
  72. package/lib/discovery-checkpoint.js +0 -123
  73. package/skills-templates/project-discovery/SKILL.md +0 -372
@@ -0,0 +1,1074 @@
1
+ export interface Env {
2
+ RELEASE_ARTIFACTS: R2Bucket;
3
+ STRIPE_SECRET_KEY: string;
4
+ STRIPE_WEBHOOK_SECRET: string;
5
+ STRIPE_MONTHLY_PRICE_ID: string;
6
+ STRIPE_LIFETIME_PRICE_ID: string;
7
+ ENVIRONMENT: string;
8
+ // Auth bindings
9
+ AUTH_DB: D1Database;
10
+ AUTH_KV: KVNamespace;
11
+ GOOGLE_CLIENT_ID: string;
12
+ GOOGLE_CLIENT_SECRET: string;
13
+ JWT_SECRET: string;
14
+ RESEND_API_KEY: string;
15
+ }
16
+
17
+ // ─── Types ──────────────────────────────────────────────────────────
18
+
19
+ interface User {
20
+ id: string;
21
+ email: string;
22
+ name: string | null;
23
+ avatar_url: string | null;
24
+ auth_provider: string;
25
+ google_id: string | null;
26
+ stripe_customer_id: string | null;
27
+ plan: string;
28
+ created_at: string;
29
+ updated_at: string;
30
+ }
31
+
32
+ interface JWTPayload {
33
+ sub: string;
34
+ email: string;
35
+ plan: string;
36
+ iat: number;
37
+ exp: number;
38
+ }
39
+
40
+ interface StripeEvent {
41
+ type: string;
42
+ data: {
43
+ object: {
44
+ id: string;
45
+ customer: string;
46
+ status: string;
47
+ mode?: string;
48
+ metadata?: Record<string, string>;
49
+ };
50
+ };
51
+ }
52
+
53
+ interface StripeCheckoutSession {
54
+ id: string;
55
+ url: string;
56
+ customer: string;
57
+ payment_status: string;
58
+ subscription: string;
59
+ }
60
+
61
+ // ─── JWT ────────────────────────────────────────────────────────────
62
+
63
+ const JWT_EXPIRY = 30 * 24 * 60 * 60; // 30 days
64
+
65
+ async function signJWT(payload: JWTPayload, secret: string): Promise<string> {
66
+ const header = { alg: 'HS256', typ: 'JWT' };
67
+ const encode = (obj: unknown) =>
68
+ btoa(JSON.stringify(obj)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
69
+
70
+ const headerB64 = encode(header);
71
+ const payloadB64 = encode(payload);
72
+ const signingInput = `${headerB64}.${payloadB64}`;
73
+
74
+ const key = await crypto.subtle.importKey(
75
+ 'raw',
76
+ new TextEncoder().encode(secret),
77
+ { name: 'HMAC', hash: 'SHA-256' },
78
+ false,
79
+ ['sign']
80
+ );
81
+ const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput));
82
+ const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig)))
83
+ .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
84
+
85
+ return `${signingInput}.${sigB64}`;
86
+ }
87
+
88
+ async function verifyJWT(token: string, secret: string): Promise<JWTPayload | null> {
89
+ const parts = token.split('.');
90
+ if (parts.length !== 3) return null;
91
+
92
+ const [headerB64, payloadB64, sigB64] = parts;
93
+ const signingInput = `${headerB64}.${payloadB64}`;
94
+
95
+ const key = await crypto.subtle.importKey(
96
+ 'raw',
97
+ new TextEncoder().encode(secret),
98
+ { name: 'HMAC', hash: 'SHA-256' },
99
+ false,
100
+ ['verify']
101
+ );
102
+
103
+ const sigBytes = Uint8Array.from(
104
+ atob(sigB64.replace(/-/g, '+').replace(/_/g, '/')),
105
+ (c) => c.charCodeAt(0)
106
+ );
107
+ const valid = await crypto.subtle.verify(
108
+ 'HMAC',
109
+ key,
110
+ sigBytes,
111
+ new TextEncoder().encode(signingInput)
112
+ );
113
+ if (!valid) return null;
114
+
115
+ const payload = JSON.parse(
116
+ atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/'))
117
+ ) as JWTPayload;
118
+
119
+ if (payload.exp < Math.floor(Date.now() / 1000)) return null;
120
+
121
+ return payload;
122
+ }
123
+
124
+ function issueJWT(user: User, secret: string): Promise<string> {
125
+ const now = Math.floor(Date.now() / 1000);
126
+ return signJWT(
127
+ {
128
+ sub: user.id,
129
+ email: user.email,
130
+ plan: user.plan,
131
+ iat: now,
132
+ exp: now + JWT_EXPIRY,
133
+ },
134
+ secret
135
+ );
136
+ }
137
+
138
+ // ─── Auth Middleware ─────────────────────────────────────────────────
139
+
140
+ async function authenticateUser(
141
+ request: Request,
142
+ env: Env
143
+ ): Promise<{ user: JWTPayload } | { error: Response }> {
144
+ const auth = request.headers.get('Authorization');
145
+ if (!auth?.startsWith('Bearer ')) {
146
+ return { error: Response.json({ error: 'Missing Authorization header' }, { status: 401 }) };
147
+ }
148
+
149
+ const token = auth.slice(7);
150
+
151
+ if (token.startsWith('cus_')) {
152
+ return { error: Response.json({ error: 'Please update JettyPod to use the new login system' }, { status: 401 }) };
153
+ }
154
+
155
+ const payload = await verifyJWT(token, env.JWT_SECRET);
156
+ if (!payload) {
157
+ return { error: Response.json({ error: 'Invalid or expired token' }, { status: 401 }) };
158
+ }
159
+
160
+ return { user: payload };
161
+ }
162
+
163
+ // ─── Legacy Stripe Auth (for update endpoints) ─────────────────────
164
+
165
+ function getBearerToken(request: Request): string | null {
166
+ const auth = request.headers.get('Authorization');
167
+ if (!auth?.startsWith('Bearer ')) return null;
168
+ return auth.slice(7);
169
+ }
170
+
171
+ async function validateAccess(
172
+ customerId: string,
173
+ env: Env
174
+ ): Promise<{ valid: boolean; error?: string }> {
175
+ if (!customerId.startsWith('cus_')) {
176
+ return { valid: false, error: 'Invalid customer token' };
177
+ }
178
+
179
+ const headers = { Authorization: `Bearer ${env.STRIPE_SECRET_KEY}` };
180
+
181
+ const subResponse = await fetch(
182
+ `https://api.stripe.com/v1/customers/${encodeURIComponent(customerId)}/subscriptions?status=active&limit=1`,
183
+ { headers }
184
+ );
185
+
186
+ if (subResponse.ok) {
187
+ const subData = (await subResponse.json()) as { data: unknown[] };
188
+ if (subData.data && subData.data.length > 0) {
189
+ return { valid: true };
190
+ }
191
+ } else if (subResponse.status === 404) {
192
+ return { valid: false, error: 'Customer not found' };
193
+ }
194
+
195
+ const chargeResponse = await fetch(
196
+ `https://api.stripe.com/v1/charges?customer=${encodeURIComponent(customerId)}&limit=1`,
197
+ { headers }
198
+ );
199
+
200
+ if (chargeResponse.ok) {
201
+ const chargeData = (await chargeResponse.json()) as {
202
+ data: Array<{ paid: boolean; refunded: boolean }>;
203
+ };
204
+ const hasSuccessfulPayment = chargeData.data?.some(
205
+ (charge) => charge.paid && !charge.refunded
206
+ );
207
+ if (hasSuccessfulPayment) {
208
+ return { valid: true };
209
+ }
210
+ }
211
+
212
+ return { valid: false, error: 'No active subscription or purchase found' };
213
+ }
214
+
215
+ /** Auth middleware for update endpoints — accepts JWT (paid) OR legacy cus_ token */
216
+ async function authenticateUpdateRequest(
217
+ request: Request,
218
+ env: Env
219
+ ): Promise<Response | null> {
220
+ const token = getBearerToken(request);
221
+ if (!token) {
222
+ return Response.json({ error: 'Missing Authorization header' }, { status: 401 });
223
+ }
224
+
225
+ // New path: JWT token — verify and check paid plan
226
+ if (!token.startsWith('cus_')) {
227
+ const payload = await verifyJWT(token, env.JWT_SECRET);
228
+ if (!payload) {
229
+ return Response.json({ error: 'Invalid or expired token' }, { status: 401 });
230
+ }
231
+ if (payload.plan === 'free') {
232
+ return Response.json({ error: 'Paid plan required for updates' }, { status: 403 });
233
+ }
234
+ return null; // Authenticated paid user
235
+ }
236
+
237
+ // Legacy path: Stripe customer ID
238
+ const result = await validateAccess(token, env);
239
+ if (!result.valid) {
240
+ return Response.json(
241
+ { error: result.error ?? 'Invalid or expired subscription' },
242
+ { status: 403 }
243
+ );
244
+ }
245
+
246
+ return null;
247
+ }
248
+
249
+ // ─── Google OAuth ───────────────────────────────────────────────────
250
+
251
+ function googleAuthRedirect(env: Env, origin: string): Response {
252
+ const params = new URLSearchParams({
253
+ client_id: env.GOOGLE_CLIENT_ID,
254
+ redirect_uri: `${origin}/auth/google/callback`,
255
+ response_type: 'code',
256
+ scope: 'openid email profile',
257
+ access_type: 'offline',
258
+ prompt: 'select_account',
259
+ });
260
+
261
+ return Response.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`, 302);
262
+ }
263
+
264
+ async function googleAuthCallback(request: Request, env: Env): Promise<Response> {
265
+ const url = new URL(request.url);
266
+ const code = url.searchParams.get('code');
267
+ if (!code) {
268
+ return Response.json({ error: 'Missing code parameter' }, { status: 400 });
269
+ }
270
+
271
+ const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
272
+ method: 'POST',
273
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
274
+ body: new URLSearchParams({
275
+ code,
276
+ client_id: env.GOOGLE_CLIENT_ID,
277
+ client_secret: env.GOOGLE_CLIENT_SECRET,
278
+ redirect_uri: `${url.origin}/auth/google/callback`,
279
+ grant_type: 'authorization_code',
280
+ }),
281
+ });
282
+
283
+ if (!tokenResponse.ok) {
284
+ return Response.json({ error: 'Google token exchange failed' }, { status: 400 });
285
+ }
286
+
287
+ const tokens = (await tokenResponse.json()) as { id_token: string };
288
+
289
+ const idPayload = JSON.parse(
290
+ atob(tokens.id_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))
291
+ ) as { sub: string; email: string; name: string; picture: string };
292
+
293
+ const user = await findOrCreateGoogleUser(env.AUTH_DB, {
294
+ googleId: idPayload.sub,
295
+ email: idPayload.email,
296
+ name: idPayload.name,
297
+ avatarUrl: idPayload.picture,
298
+ });
299
+
300
+ const jwt = await issueJWT(user, env.JWT_SECRET);
301
+
302
+ return new Response(
303
+ `<!DOCTYPE html>
304
+ <html><head><title>Signing in...</title></head>
305
+ <body>
306
+ <script>
307
+ window.location.href = 'jettypod://auth/callback?token=${jwt}';
308
+ setTimeout(() => {
309
+ document.body.innerHTML = '<p>Signed in! You can close this tab and return to JettyPod.</p>';
310
+ }, 1000);
311
+ </script>
312
+ </body></html>`,
313
+ { headers: { 'Content-Type': 'text/html' } }
314
+ );
315
+ }
316
+
317
+ // ─── Email OTP ──────────────────────────────────────────────────────
318
+
319
+ const OTP_TTL = 300; // 5 minutes
320
+ const OTP_LENGTH = 6;
321
+
322
+ function generateOTP(): string {
323
+ const array = new Uint32Array(1);
324
+ crypto.getRandomValues(array);
325
+ return String(array[0] % 1000000).padStart(OTP_LENGTH, '0');
326
+ }
327
+
328
+ async function handleSendOTP(request: Request, env: Env): Promise<Response> {
329
+ const body = (await request.json()) as { email?: string };
330
+ const email = body.email?.trim().toLowerCase();
331
+
332
+ if (!email || !email.includes('@')) {
333
+ return Response.json({ error: 'Valid email required' }, { status: 400 });
334
+ }
335
+
336
+ const code = generateOTP();
337
+
338
+ await env.AUTH_KV.put(`otp:${email}`, code, { expirationTtl: OTP_TTL });
339
+
340
+ await fetch('https://api.resend.com/emails', {
341
+ method: 'POST',
342
+ headers: {
343
+ Authorization: `Bearer ${env.RESEND_API_KEY}`,
344
+ 'Content-Type': 'application/json',
345
+ },
346
+ body: JSON.stringify({
347
+ from: 'JettyPod <hello@tx.jettypod.com>',
348
+ to: [email],
349
+ subject: 'Your JettyPod sign-in code',
350
+ html: `<p>Your sign-in code is: <strong>${code}</strong></p><p>It expires in 5 minutes.</p>`,
351
+ }),
352
+ });
353
+
354
+ return Response.json({ sent: true });
355
+ }
356
+
357
+ async function handleVerifyOTP(request: Request, env: Env): Promise<Response> {
358
+ const body = (await request.json()) as { email?: string; code?: string };
359
+ const email = body.email?.trim().toLowerCase();
360
+ const code = body.code?.trim();
361
+
362
+ if (!email || !code) {
363
+ return Response.json({ error: 'Email and code required' }, { status: 400 });
364
+ }
365
+
366
+ const storedCode = await env.AUTH_KV.get(`otp:${email}`);
367
+ if (!storedCode || storedCode !== code) {
368
+ return Response.json({ error: 'Invalid or expired code' }, { status: 401 });
369
+ }
370
+
371
+ await env.AUTH_KV.delete(`otp:${email}`);
372
+
373
+ const user = await findOrCreateEmailUser(env.AUTH_DB, email);
374
+ const jwt = await issueJWT(user, env.JWT_SECRET);
375
+
376
+ return Response.json({ token: jwt, user: { id: user.id, email: user.email, plan: user.plan } });
377
+ }
378
+
379
+ // ─── User Management ────────────────────────────────────────────────
380
+
381
+ async function findOrCreateGoogleUser(
382
+ db: D1Database,
383
+ profile: { googleId: string; email: string; name: string; avatarUrl: string }
384
+ ): Promise<User> {
385
+ let user = await db
386
+ .prepare('SELECT * FROM users WHERE google_id = ?')
387
+ .bind(profile.googleId)
388
+ .first<User>();
389
+
390
+ if (user) {
391
+ await db
392
+ .prepare("UPDATE users SET name = ?, avatar_url = ?, updated_at = datetime('now') WHERE id = ?")
393
+ .bind(profile.name, profile.avatarUrl, user.id)
394
+ .run();
395
+ return user;
396
+ }
397
+
398
+ // Check if email already exists (account linking: email OTP → Google)
399
+ user = await db.prepare('SELECT * FROM users WHERE email = ?').bind(profile.email).first<User>();
400
+
401
+ if (user) {
402
+ await db
403
+ .prepare(
404
+ "UPDATE users SET google_id = ?, name = ?, avatar_url = ?, auth_provider = 'google', updated_at = datetime('now') WHERE id = ?"
405
+ )
406
+ .bind(profile.googleId, profile.name, profile.avatarUrl, user.id)
407
+ .run();
408
+ return { ...user, google_id: profile.googleId, name: profile.name, avatar_url: profile.avatarUrl };
409
+ }
410
+
411
+ const id = crypto.randomUUID();
412
+ await db
413
+ .prepare(
414
+ "INSERT INTO users (id, email, name, avatar_url, auth_provider, google_id, plan) VALUES (?, ?, ?, ?, 'google', ?, 'free')"
415
+ )
416
+ .bind(id, profile.email, profile.name, profile.avatarUrl, profile.googleId)
417
+ .run();
418
+
419
+ return {
420
+ id,
421
+ email: profile.email,
422
+ name: profile.name,
423
+ avatar_url: profile.avatarUrl,
424
+ auth_provider: 'google',
425
+ google_id: profile.googleId,
426
+ stripe_customer_id: null,
427
+ plan: 'free',
428
+ created_at: new Date().toISOString(),
429
+ updated_at: new Date().toISOString(),
430
+ };
431
+ }
432
+
433
+ async function findOrCreateEmailUser(db: D1Database, email: string): Promise<User> {
434
+ const user = await db.prepare('SELECT * FROM users WHERE email = ?').bind(email).first<User>();
435
+ if (user) return user;
436
+
437
+ const id = crypto.randomUUID();
438
+ await db
439
+ .prepare("INSERT INTO users (id, email, auth_provider, plan) VALUES (?, ?, 'email', 'free')")
440
+ .bind(id, email)
441
+ .run();
442
+
443
+ return {
444
+ id,
445
+ email,
446
+ name: null,
447
+ avatar_url: null,
448
+ auth_provider: 'email',
449
+ google_id: null,
450
+ stripe_customer_id: null,
451
+ plan: 'free',
452
+ created_at: new Date().toISOString(),
453
+ updated_at: new Date().toISOString(),
454
+ };
455
+ }
456
+
457
+ // ─── Usage Tracking ─────────────────────────────────────────────────
458
+
459
+ const FREE_WEEKLY_LIMIT = 20;
460
+
461
+ function getCurrentWeekStart(): string {
462
+ const now = new Date();
463
+ const day = now.getUTCDay();
464
+ const diff = day === 0 ? -6 : 1 - day;
465
+ const monday = new Date(now);
466
+ monday.setUTCDate(now.getUTCDate() + diff);
467
+ return monday.toISOString().split('T')[0];
468
+ }
469
+
470
+ async function checkUsageLimit(
471
+ db: D1Database,
472
+ userId: string,
473
+ plan: string
474
+ ): Promise<{ allowed: boolean; used: number; limit: number; remaining: number }> {
475
+ if (plan !== 'free') {
476
+ return { allowed: true, used: 0, limit: Infinity, remaining: Infinity };
477
+ }
478
+
479
+ const weekStart = getCurrentWeekStart();
480
+ const row = await db
481
+ .prepare('SELECT work_items_created FROM usage WHERE user_id = ? AND week_start = ?')
482
+ .bind(userId, weekStart)
483
+ .first<{ work_items_created: number }>();
484
+
485
+ const used = row?.work_items_created ?? 0;
486
+ const remaining = Math.max(0, FREE_WEEKLY_LIMIT - used);
487
+
488
+ return { allowed: remaining > 0, used, limit: FREE_WEEKLY_LIMIT, remaining };
489
+ }
490
+
491
+ async function incrementUsage(db: D1Database, userId: string): Promise<void> {
492
+ const weekStart = getCurrentWeekStart();
493
+ await db
494
+ .prepare(
495
+ `INSERT INTO usage (user_id, week_start, work_items_created)
496
+ VALUES (?, ?, 1)
497
+ ON CONFLICT(user_id, week_start)
498
+ DO UPDATE SET work_items_created = work_items_created + 1`
499
+ )
500
+ .bind(userId, weekStart)
501
+ .run();
502
+ }
503
+
504
+ // ─── Authenticated Route Handlers ───────────────────────────────────
505
+
506
+ async function handleGetMe(user: JWTPayload, env: Env): Promise<Response> {
507
+ // Check D1 for current plan (may differ from JWT if webhook updated it)
508
+ const dbUser = await env.AUTH_DB.prepare('SELECT * FROM users WHERE id = ?').bind(user.sub).first<User>();
509
+ const currentPlan = dbUser?.plan || user.plan;
510
+
511
+ const usage = await checkUsageLimit(env.AUTH_DB, user.sub, currentPlan);
512
+
513
+ const response: Record<string, unknown> = {
514
+ user: { id: user.sub, email: user.email, plan: currentPlan },
515
+ usage: { used: usage.used, limit: usage.limit, remaining: usage.remaining, allowed: usage.allowed },
516
+ };
517
+
518
+ // Issue a new JWT if plan changed (e.g., webhook upgraded from free to paid)
519
+ if (dbUser && currentPlan !== user.plan) {
520
+ response.token = await issueJWT(dbUser, env.JWT_SECRET);
521
+ }
522
+
523
+ return Response.json(response);
524
+ }
525
+
526
+ async function handleUsageCheck(user: JWTPayload, env: Env): Promise<Response> {
527
+ const usage = await checkUsageLimit(env.AUTH_DB, user.sub, user.plan);
528
+ return Response.json(usage);
529
+ }
530
+
531
+ async function handleUsageIncrement(user: JWTPayload, env: Env): Promise<Response> {
532
+ const usage = await checkUsageLimit(env.AUTH_DB, user.sub, user.plan);
533
+ if (!usage.allowed) {
534
+ return Response.json({ error: 'Weekly limit reached', ...usage }, { status: 429 });
535
+ }
536
+
537
+ await incrementUsage(env.AUTH_DB, user.sub);
538
+ return Response.json({ used: usage.used + 1, limit: usage.limit, remaining: usage.remaining - 1 });
539
+ }
540
+
541
+ async function handleLinkStripe(request: Request, user: JWTPayload, env: Env): Promise<Response> {
542
+ const body = (await request.json()) as { customerId?: string };
543
+ const customerId = body.customerId;
544
+
545
+ if (!customerId?.startsWith('cus_')) {
546
+ return Response.json({ error: 'Invalid customer ID' }, { status: 400 });
547
+ }
548
+
549
+ const plan = await determinePlanFromStripe(customerId, env);
550
+
551
+ await env.AUTH_DB
552
+ .prepare("UPDATE users SET stripe_customer_id = ?, plan = ?, updated_at = datetime('now') WHERE id = ?")
553
+ .bind(customerId, plan, user.sub)
554
+ .run();
555
+
556
+ const updatedUser = await env.AUTH_DB.prepare('SELECT * FROM users WHERE id = ?').bind(user.sub).first<User>();
557
+ if (!updatedUser) {
558
+ return Response.json({ error: 'User not found' }, { status: 404 });
559
+ }
560
+
561
+ const newToken = await issueJWT(updatedUser, env.JWT_SECRET);
562
+ return Response.json({ token: newToken, plan });
563
+ }
564
+
565
+ async function determinePlanFromStripe(customerId: string, env: Env): Promise<string> {
566
+ const headers = { Authorization: `Bearer ${env.STRIPE_SECRET_KEY}` };
567
+
568
+ const subRes = await fetch(
569
+ `https://api.stripe.com/v1/customers/${encodeURIComponent(customerId)}/subscriptions?status=active&limit=1`,
570
+ { headers }
571
+ );
572
+ if (subRes.ok) {
573
+ const subData = (await subRes.json()) as { data: unknown[] };
574
+ if (subData.data?.length > 0) return 'monthly';
575
+ }
576
+
577
+ const chargeRes = await fetch(
578
+ `https://api.stripe.com/v1/charges?customer=${encodeURIComponent(customerId)}&limit=1`,
579
+ { headers }
580
+ );
581
+ if (chargeRes.ok) {
582
+ const chargeData = (await chargeRes.json()) as {
583
+ data: Array<{ paid: boolean; refunded: boolean }>;
584
+ };
585
+ if (chargeData.data?.some((c) => c.paid && !c.refunded)) return 'lifetime';
586
+ }
587
+
588
+ return 'free';
589
+ }
590
+
591
+ // ─── Existing Route Handlers ────────────────────────────────────────
592
+
593
+ async function handleUpdateManifest(env: Env): Promise<Response> {
594
+ const object = await env.RELEASE_ARTIFACTS.get('latest-mac.yml');
595
+ if (!object) {
596
+ return Response.json({ error: 'Update manifest not found' }, { status: 404 });
597
+ }
598
+
599
+ return new Response(object.body, {
600
+ headers: {
601
+ 'Content-Type': 'text/yaml',
602
+ 'Cache-Control': 'no-cache',
603
+ 'ETag': object.httpEtag,
604
+ },
605
+ });
606
+ }
607
+
608
+ async function handleArtifactDownload(pathname: string, env: Env): Promise<Response> {
609
+ const filename = pathname.replace('/updates/download/', '');
610
+ if (!filename || filename.includes('/')) {
611
+ return Response.json({ error: 'Invalid filename' }, { status: 400 });
612
+ }
613
+
614
+ const object = await env.RELEASE_ARTIFACTS.get(filename);
615
+ if (!object) {
616
+ return Response.json({ error: 'Artifact not found' }, { status: 404 });
617
+ }
618
+
619
+ const contentType = filename.endsWith('.dmg')
620
+ ? 'application/x-apple-diskimage'
621
+ : filename.endsWith('.zip')
622
+ ? 'application/zip'
623
+ : filename.endsWith('.yml') || filename.endsWith('.yaml')
624
+ ? 'text/yaml'
625
+ : 'application/octet-stream';
626
+
627
+ return new Response(object.body, {
628
+ headers: {
629
+ 'Content-Type': contentType,
630
+ 'Content-Length': object.size.toString(),
631
+ 'Content-Disposition': `attachment; filename="${filename}"`,
632
+ 'ETag': object.httpEtag,
633
+ },
634
+ });
635
+ }
636
+
637
+ async function handlePublicDownload(arch: string, env: Env): Promise<Response> {
638
+ // Parse latest-mac.yml to find current filenames
639
+ const manifest = await env.RELEASE_ARTIFACTS.get('latest-mac.yml');
640
+ if (!manifest) {
641
+ return Response.json({ error: 'No releases available' }, { status: 404 });
642
+ }
643
+
644
+ const yml = await manifest.text();
645
+ // Find DMG filenames from the manifest
646
+ const dmgFiles = [...yml.matchAll(/url:\s*(\S+\.dmg)/g)].map(m => m[1]);
647
+
648
+ let filename: string | undefined;
649
+ if (arch === 'arm64') {
650
+ filename = dmgFiles.find(f => f.includes('arm64'));
651
+ } else {
652
+ // x64 — the one without arm64
653
+ filename = dmgFiles.find(f => !f.includes('arm64'));
654
+ }
655
+
656
+ if (!filename) {
657
+ return Response.json({ error: `No DMG found for arch: ${arch}` }, { status: 404 });
658
+ }
659
+
660
+ const object = await env.RELEASE_ARTIFACTS.get(filename);
661
+ if (!object) {
662
+ return Response.json({ error: 'Artifact not found' }, { status: 404 });
663
+ }
664
+
665
+ return new Response(object.body, {
666
+ headers: {
667
+ 'Content-Type': 'application/x-apple-diskimage',
668
+ 'Content-Length': object.size.toString(),
669
+ 'Content-Disposition': `attachment; filename="${filename}"`,
670
+ 'ETag': object.httpEtag,
671
+ },
672
+ });
673
+ }
674
+
675
+ async function verifyStripeSignature(
676
+ payload: string,
677
+ sigHeader: string,
678
+ secret: string
679
+ ): Promise<boolean> {
680
+ const parts = Object.fromEntries(
681
+ sigHeader.split(',').map((part) => {
682
+ const [key, value] = part.split('=');
683
+ return [key, value];
684
+ })
685
+ );
686
+
687
+ const timestamp = parts['t'];
688
+ const signature = parts['v1'];
689
+ if (!timestamp || !signature) return false;
690
+
691
+ const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
692
+ if (age > 300) return false;
693
+
694
+ const signedPayload = `${timestamp}.${payload}`;
695
+ const key = await crypto.subtle.importKey(
696
+ 'raw',
697
+ new TextEncoder().encode(secret),
698
+ { name: 'HMAC', hash: 'SHA-256' },
699
+ false,
700
+ ['sign']
701
+ );
702
+ const sig = await crypto.subtle.sign(
703
+ 'HMAC',
704
+ key,
705
+ new TextEncoder().encode(signedPayload)
706
+ );
707
+ const expected = Array.from(new Uint8Array(sig))
708
+ .map((b) => b.toString(16).padStart(2, '0'))
709
+ .join('');
710
+
711
+ return expected === signature;
712
+ }
713
+
714
+ async function handleStripeWebhook(request: Request, env: Env): Promise<Response> {
715
+ const sigHeader = request.headers.get('Stripe-Signature');
716
+ if (!sigHeader) {
717
+ return Response.json({ error: 'Missing Stripe-Signature header' }, { status: 400 });
718
+ }
719
+
720
+ const body = await request.text();
721
+ const valid = await verifyStripeSignature(body, sigHeader, env.STRIPE_WEBHOOK_SECRET);
722
+ if (!valid) {
723
+ return Response.json({ error: 'Invalid signature' }, { status: 400 });
724
+ }
725
+
726
+ const event = JSON.parse(body) as StripeEvent;
727
+
728
+ switch (event.type) {
729
+ case 'checkout.session.completed': {
730
+ const session = event.data.object;
731
+ const userId = session.metadata?.user_id;
732
+ const customerId = session.customer;
733
+
734
+ if (userId && customerId) {
735
+ const plan = session.mode === 'subscription' ? 'monthly' : 'lifetime';
736
+
737
+ await env.AUTH_DB
738
+ .prepare("UPDATE users SET stripe_customer_id = ?, plan = ?, updated_at = datetime('now') WHERE id = ?")
739
+ .bind(customerId, plan, userId)
740
+ .run();
741
+
742
+ console.log(`Webhook: checkout.session.completed | user=${userId} customer=${customerId} plan=${plan}`);
743
+ } else {
744
+ console.log(`Webhook: checkout.session.completed | missing user_id or customer, skipping link`);
745
+ }
746
+ break;
747
+ }
748
+ case 'customer.subscription.created':
749
+ case 'customer.subscription.updated':
750
+ case 'customer.subscription.deleted':
751
+ console.log(
752
+ `Webhook: ${event.type} | customer=${event.data.object.customer} status=${event.data.object.status}`
753
+ );
754
+ break;
755
+ default:
756
+ console.log(`Webhook: unhandled event type ${event.type}`);
757
+ }
758
+
759
+ return Response.json({ received: true });
760
+ }
761
+
762
+ async function handleCreateCheckout(request: Request, env: Env): Promise<Response> {
763
+ let plan = 'monthly';
764
+ try {
765
+ const reqBody = (await request.json()) as { plan?: string };
766
+ if (reqBody.plan === 'lifetime') plan = 'lifetime';
767
+ } catch {
768
+ // Default to monthly if no body
769
+ }
770
+
771
+ // Extract user ID from JWT if authenticated
772
+ let userId: string | null = null;
773
+ const authHeader = request.headers.get('Authorization');
774
+ if (authHeader?.startsWith('Bearer ')) {
775
+ const token = authHeader.slice(7);
776
+ if (!token.startsWith('cus_')) {
777
+ const payload = await verifyJWT(token, env.JWT_SECRET);
778
+ if (payload) {
779
+ userId = payload.sub;
780
+ }
781
+ }
782
+ }
783
+
784
+ const isLifetime = plan === 'lifetime';
785
+ const priceId = isLifetime ? env.STRIPE_LIFETIME_PRICE_ID : env.STRIPE_MONTHLY_PRICE_ID;
786
+ const mode = isLifetime ? 'payment' : 'subscription';
787
+
788
+ const body = new URLSearchParams({
789
+ 'mode': mode,
790
+ 'line_items[0][price]': priceId,
791
+ 'line_items[0][quantity]': '1',
792
+ 'success_url': `${new URL(request.url).origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
793
+ 'cancel_url': `${new URL(request.url).origin}/checkout/cancelled`,
794
+ });
795
+
796
+ // Include user ID in checkout session metadata if authenticated
797
+ if (userId) {
798
+ body.append('metadata[user_id]', userId);
799
+ }
800
+
801
+ const response = await fetch('https://api.stripe.com/v1/checkout/sessions', {
802
+ method: 'POST',
803
+ headers: {
804
+ Authorization: `Bearer ${env.STRIPE_SECRET_KEY}`,
805
+ 'Content-Type': 'application/x-www-form-urlencoded',
806
+ },
807
+ body: body.toString(),
808
+ });
809
+
810
+ if (!response.ok) {
811
+ return Response.json({ error: 'Failed to create checkout session' }, { status: 500 });
812
+ }
813
+
814
+ const session = (await response.json()) as StripeCheckoutSession;
815
+ return Response.json({ url: session.url });
816
+ }
817
+
818
+ async function handleCheckoutSuccess(url: URL, env: Env): Promise<Response> {
819
+ const sessionId = url.searchParams.get('session_id');
820
+ if (!sessionId) {
821
+ return new Response('Missing session_id', { status: 400 });
822
+ }
823
+
824
+ const response = await fetch(
825
+ `https://api.stripe.com/v1/checkout/sessions/${encodeURIComponent(sessionId)}`,
826
+ {
827
+ headers: {
828
+ Authorization: `Bearer ${env.STRIPE_SECRET_KEY}`,
829
+ },
830
+ }
831
+ );
832
+
833
+ if (!response.ok) {
834
+ return new Response('Invalid session', { status: 400 });
835
+ }
836
+
837
+ const session = (await response.json()) as StripeCheckoutSession;
838
+ const customerId = session.customer;
839
+
840
+ return new Response(
841
+ `<!DOCTYPE html>
842
+ <html>
843
+ <head><title>JettyPod — Subscription Activated</title>
844
+ <style>
845
+ body { font-family: -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #fafafa; }
846
+ .card { background: white; padding: 48px; border-radius: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); text-align: center; max-width: 440px; }
847
+ h1 { color: #1a1a1a; margin-bottom: 8px; }
848
+ p { color: #666; line-height: 1.6; }
849
+ .customer-id { background: #f0f0f0; padding: 12px 20px; border-radius: 8px; font-family: monospace; font-size: 16px; margin: 24px 0; user-select: all; cursor: pointer; }
850
+ .hint { font-size: 13px; color: #999; }
851
+ </style></head>
852
+ <body>
853
+ <div class="card">
854
+ <h1>Payment Successful!</h1>
855
+ <p>Copy your customer ID and paste it into JettyPod:</p>
856
+ <div class="customer-id" onclick="navigator.clipboard.writeText('${customerId}')">${customerId}</div>
857
+ <p class="hint">Click to copy. Then paste it in the JettyPod app.</p>
858
+ </div>
859
+ </body></html>`,
860
+ { headers: { 'Content-Type': 'text/html' } }
861
+ );
862
+ }
863
+
864
+ async function handleValidateSubscription(request: Request, env: Env): Promise<Response> {
865
+ let customerId: string;
866
+ try {
867
+ const body = (await request.json()) as { customerId?: string };
868
+ customerId = body.customerId ?? '';
869
+ } catch {
870
+ return Response.json({ error: 'Invalid request body' }, { status: 400 });
871
+ }
872
+
873
+ if (!customerId) {
874
+ return Response.json({ error: 'customerId is required' }, { status: 400 });
875
+ }
876
+
877
+ const result = await validateAccess(customerId, env);
878
+ return Response.json(result);
879
+ }
880
+
881
+ // ─── Billing Portal ─────────────────────────────────────────────────
882
+
883
+ async function handleBillingPortal(user: JWTPayload, env: Env): Promise<Response> {
884
+ const dbUser = await env.AUTH_DB
885
+ .prepare('SELECT * FROM users WHERE id = ?')
886
+ .bind(user.sub)
887
+ .first<User>();
888
+
889
+ if (!dbUser?.stripe_customer_id) {
890
+ return Response.json({ error: 'No billing info on file' }, { status: 404 });
891
+ }
892
+
893
+ const portalResponse = await fetch('https://api.stripe.com/v1/billing_portal/sessions', {
894
+ method: 'POST',
895
+ headers: {
896
+ 'Authorization': `Bearer ${env.STRIPE_SECRET_KEY}`,
897
+ 'Content-Type': 'application/x-www-form-urlencoded',
898
+ },
899
+ body: new URLSearchParams({
900
+ 'customer': dbUser.stripe_customer_id,
901
+ 'return_url': 'https://jettypod.com',
902
+ }).toString(),
903
+ });
904
+
905
+ if (!portalResponse.ok) {
906
+ return Response.json({ error: 'Failed to create portal session' }, { status: 500 });
907
+ }
908
+
909
+ const session = (await portalResponse.json()) as { url: string };
910
+ return Response.json({ url: session.url });
911
+ }
912
+
913
+ // ─── CORS ───────────────────────────────────────────────────────────
914
+
915
+ const CORS_HEADERS: Record<string, string> = {
916
+ 'Access-Control-Allow-Origin': '*',
917
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
918
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
919
+ 'Access-Control-Max-Age': '86400',
920
+ };
921
+
922
+ function withCors(response: Response): Response {
923
+ const headers = new Headers(response.headers);
924
+ for (const [key, value] of Object.entries(CORS_HEADERS)) {
925
+ headers.set(key, value);
926
+ }
927
+ return new Response(response.body, {
928
+ status: response.status,
929
+ statusText: response.statusText,
930
+ headers,
931
+ });
932
+ }
933
+
934
+ // ─── Request Router ─────────────────────────────────────────────────
935
+
936
+ async function handleRequest(request: Request, url: URL, pathname: string, env: Env): Promise<Response> {
937
+ // Health check — no auth required
938
+ if (request.method === 'GET' && pathname === '/health') {
939
+ return Response.json({
940
+ status: 'ok',
941
+ service: 'jettypod-update-server',
942
+ environment: env.ENVIRONMENT,
943
+ });
944
+ }
945
+
946
+ // ── Auth routes (public, no JWT required) ──────────────────────
947
+
948
+ // Google OAuth — redirect to Google consent screen
949
+ if (request.method === 'GET' && pathname === '/auth/google') {
950
+ return googleAuthRedirect(env, url.origin);
951
+ }
952
+
953
+ // Google OAuth — callback from Google
954
+ if (request.method === 'GET' && pathname === '/auth/google/callback') {
955
+ return googleAuthCallback(request, env);
956
+ }
957
+
958
+ // Email OTP — send code
959
+ if (request.method === 'POST' && pathname === '/auth/otp/send') {
960
+ return handleSendOTP(request, env);
961
+ }
962
+
963
+ // Email OTP — verify code and get JWT
964
+ if (request.method === 'POST' && pathname === '/auth/otp/verify') {
965
+ return handleVerifyOTP(request, env);
966
+ }
967
+
968
+ // ── Authenticated routes (JWT required) ────────────────────────
969
+
970
+ // Get current user info + usage
971
+ if (request.method === 'GET' && pathname === '/auth/me') {
972
+ const auth = await authenticateUser(request, env);
973
+ if ('error' in auth) return auth.error;
974
+ return handleGetMe(auth.user, env);
975
+ }
976
+
977
+ // Check usage limit
978
+ if (request.method === 'POST' && pathname === '/usage/check') {
979
+ const auth = await authenticateUser(request, env);
980
+ if ('error' in auth) return auth.error;
981
+ return handleUsageCheck(auth.user, env);
982
+ }
983
+
984
+ // Record work item creation
985
+ if (request.method === 'POST' && pathname === '/usage/increment') {
986
+ const auth = await authenticateUser(request, env);
987
+ if ('error' in auth) return auth.error;
988
+ return handleUsageIncrement(auth.user, env);
989
+ }
990
+
991
+ // Link Stripe customer to user account
992
+ if (request.method === 'POST' && pathname === '/auth/link-stripe') {
993
+ const auth = await authenticateUser(request, env);
994
+ if ('error' in auth) return auth.error;
995
+ return handleLinkStripe(request, auth.user, env);
996
+ }
997
+
998
+ // Create Stripe billing portal session
999
+ if (request.method === 'POST' && pathname === '/billing/customer-portal') {
1000
+ const auth = await authenticateUser(request, env);
1001
+ if ('error' in auth) return auth.error;
1002
+ return handleBillingPortal(auth.user, env);
1003
+ }
1004
+
1005
+ // ── Public download routes (no auth) ───────────────────────────
1006
+
1007
+ if (request.method === 'GET' && pathname === '/download/mac') {
1008
+ return handlePublicDownload('x64', env);
1009
+ }
1010
+
1011
+ if (request.method === 'GET' && pathname === '/download/mac/arm64') {
1012
+ return handlePublicDownload('arm64', env);
1013
+ }
1014
+
1015
+ // ── Update routes (paid plan or legacy cus_ token) ─────────────
1016
+
1017
+ // Update manifest
1018
+ if (request.method === 'GET' && pathname === '/updates/latest-mac.yml') {
1019
+ const authError = await authenticateUpdateRequest(request, env);
1020
+ if (authError) return authError;
1021
+ return handleUpdateManifest(env);
1022
+ }
1023
+
1024
+ // Release artifact download
1025
+ if (request.method === 'GET' && pathname.startsWith('/updates/download/')) {
1026
+ const authError = await authenticateUpdateRequest(request, env);
1027
+ if (authError) return authError;
1028
+ return handleArtifactDownload(pathname, env);
1029
+ }
1030
+
1031
+ // ── Stripe/checkout routes (existing) ──────────────────────────
1032
+
1033
+ if (request.method === 'POST' && pathname === '/checkout/create-session') {
1034
+ return handleCreateCheckout(request, env);
1035
+ }
1036
+
1037
+ if (request.method === 'GET' && pathname === '/checkout/success') {
1038
+ return handleCheckoutSuccess(url, env);
1039
+ }
1040
+
1041
+ if (request.method === 'GET' && pathname === '/checkout/cancelled') {
1042
+ return new Response(
1043
+ '<!DOCTYPE html><html><head><title>Cancelled</title><style>body{font-family:-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#fafafa;}.card{background:white;padding:48px;border-radius:16px;box-shadow:0 2px 8px rgba(0,0,0,0.08);text-align:center;}h1{color:#1a1a1a;}p{color:#666;}</style></head><body><div class="card"><h1>Checkout Cancelled</h1><p>You can close this tab and try again from the JettyPod app.</p></div></body></html>',
1044
+ { headers: { 'Content-Type': 'text/html' } }
1045
+ );
1046
+ }
1047
+
1048
+ if (request.method === 'POST' && pathname === '/subscription/validate') {
1049
+ return handleValidateSubscription(request, env);
1050
+ }
1051
+
1052
+ if (request.method === 'POST' && pathname === '/webhooks/stripe') {
1053
+ return handleStripeWebhook(request, env);
1054
+ }
1055
+
1056
+ return new Response('Not Found', { status: 404 });
1057
+ }
1058
+
1059
+ // ─── Main Fetch Handler ─────────────────────────────────────────────
1060
+
1061
+ export default {
1062
+ async fetch(request: Request, env: Env): Promise<Response> {
1063
+ const url = new URL(request.url);
1064
+ const { pathname } = url;
1065
+
1066
+ // CORS preflight
1067
+ if (request.method === 'OPTIONS') {
1068
+ return new Response(null, { status: 204, headers: CORS_HEADERS });
1069
+ }
1070
+
1071
+ const response = await handleRequest(request, url, pathname, env);
1072
+ return withCors(response);
1073
+ },
1074
+ } satisfies ExportedHandler<Env>;