jettypod 4.4.116 → 4.4.120

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