jettypod 4.4.118 → 4.4.121

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 (240) hide show
  1. package/.env +4 -3
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -0,0 +1,33 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react-swc'
3
+ import tailwindcss from '@tailwindcss/vite'
4
+ import path from 'path'
5
+
6
+ export default defineConfig({
7
+ plugins: [
8
+ react(),
9
+ tailwindcss(),
10
+ ],
11
+ resolve: {
12
+ alias: {
13
+ '@': path.resolve(__dirname, '.'),
14
+ },
15
+ },
16
+ server: {
17
+ port: 1420,
18
+ strictPort: true,
19
+ },
20
+ build: {
21
+ outDir: 'dist',
22
+ emptyOutDir: true,
23
+ rollupOptions: {
24
+ output: {
25
+ manualChunks: {
26
+ 'vendor-motion': ['framer-motion'],
27
+ 'vendor-dnd': ['@dnd-kit/core', '@dnd-kit/utilities'],
28
+ 'vendor-markdown': ['react-markdown', 'remark-gfm'],
29
+ },
30
+ },
31
+ },
32
+ },
33
+ })
@@ -58,10 +58,41 @@ interface StripeCheckoutSession {
58
58
  subscription: string;
59
59
  }
60
60
 
61
+ interface TauriPlatformArtifact {
62
+ url: string;
63
+ signature: string;
64
+ }
65
+
66
+ interface TauriUpdateManifest {
67
+ version: string;
68
+ notes: string;
69
+ pub_date: string;
70
+ platforms: Record<string, TauriPlatformArtifact>;
71
+ }
72
+
61
73
  // ─── JWT ────────────────────────────────────────────────────────────
62
74
 
63
75
  const JWT_EXPIRY = 30 * 24 * 60 * 60; // 30 days
64
76
 
77
+ // Cache imported CryptoKeys to avoid re-importing on every JWT operation
78
+ const cryptoKeyCache = new Map<string, CryptoKey>();
79
+
80
+ async function getHmacKey(secret: string, usage: 'sign' | 'verify'): Promise<CryptoKey> {
81
+ const cacheKey = `${secret}:${usage}`;
82
+ let key = cryptoKeyCache.get(cacheKey);
83
+ if (!key) {
84
+ key = await crypto.subtle.importKey(
85
+ 'raw',
86
+ new TextEncoder().encode(secret),
87
+ { name: 'HMAC', hash: 'SHA-256' },
88
+ false,
89
+ [usage]
90
+ );
91
+ cryptoKeyCache.set(cacheKey, key);
92
+ }
93
+ return key;
94
+ }
95
+
65
96
  async function signJWT(payload: JWTPayload, secret: string): Promise<string> {
66
97
  const header = { alg: 'HS256', typ: 'JWT' };
67
98
  const encode = (obj: unknown) =>
@@ -71,13 +102,7 @@ async function signJWT(payload: JWTPayload, secret: string): Promise<string> {
71
102
  const payloadB64 = encode(payload);
72
103
  const signingInput = `${headerB64}.${payloadB64}`;
73
104
 
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
- );
105
+ const key = await getHmacKey(secret, 'sign');
81
106
  const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput));
82
107
  const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig)))
83
108
  .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
@@ -92,13 +117,7 @@ async function verifyJWT(token: string, secret: string): Promise<JWTPayload | nu
92
117
  const [headerB64, payloadB64, sigB64] = parts;
93
118
  const signingInput = `${headerB64}.${payloadB64}`;
94
119
 
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
- );
120
+ const key = await getHmacKey(secret, 'verify');
102
121
 
103
122
  const sigBytes = Uint8Array.from(
104
123
  atob(sigB64.replace(/-/g, '+').replace(/_/g, '/')),
@@ -176,37 +195,9 @@ async function validateAccess(
176
195
  return { valid: false, error: 'Invalid customer token' };
177
196
  }
178
197
 
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
- }
198
+ const plan = await determinePlanFromStripe(customerId, env);
199
+ if (plan !== 'free') {
200
+ return { valid: true };
210
201
  }
211
202
 
212
203
  return { valid: false, error: 'No active subscription or purchase found' };
@@ -318,6 +309,16 @@ async function googleAuthCallback(request: Request, env: Env): Promise<Response>
318
309
 
319
310
  const OTP_TTL = 300; // 5 minutes
320
311
  const OTP_LENGTH = 6;
312
+ const OTP_SEND_RATE_LIMIT = 5; // max sends per window
313
+ const OTP_VERIFY_RATE_LIMIT = 10; // max verify attempts per window
314
+ const RATE_LIMIT_WINDOW = 900; // 15 minutes
315
+
316
+ async function checkRateLimit(kv: KVNamespace, key: string, limit: number): Promise<boolean> {
317
+ const current = parseInt(await kv.get(key) || '0', 10);
318
+ if (current >= limit) return false;
319
+ await kv.put(key, String(current + 1), { expirationTtl: RATE_LIMIT_WINDOW });
320
+ return true;
321
+ }
321
322
 
322
323
  function generateOTP(): string {
323
324
  const array = new Uint32Array(1);
@@ -326,18 +327,28 @@ function generateOTP(): string {
326
327
  }
327
328
 
328
329
  async function handleSendOTP(request: Request, env: Env): Promise<Response> {
329
- const body = (await request.json()) as { email?: string };
330
+ let body: { email?: string };
331
+ try {
332
+ body = (await request.json()) as { email?: string };
333
+ } catch {
334
+ return Response.json({ error: 'Invalid request body' }, { status: 400 });
335
+ }
330
336
  const email = body.email?.trim().toLowerCase();
331
337
 
332
338
  if (!email || !email.includes('@')) {
333
339
  return Response.json({ error: 'Valid email required' }, { status: 400 });
334
340
  }
335
341
 
342
+ const allowed = await checkRateLimit(env.AUTH_KV, `rate:otp-send:${email}`, OTP_SEND_RATE_LIMIT);
343
+ if (!allowed) {
344
+ return Response.json({ error: 'Too many requests. Try again later.' }, { status: 429 });
345
+ }
346
+
336
347
  const code = generateOTP();
337
348
 
338
349
  await env.AUTH_KV.put(`otp:${email}`, code, { expirationTtl: OTP_TTL });
339
350
 
340
- await fetch('https://api.resend.com/emails', {
351
+ const emailRes = await fetch('https://api.resend.com/emails', {
341
352
  method: 'POST',
342
353
  headers: {
343
354
  Authorization: `Bearer ${env.RESEND_API_KEY}`,
@@ -351,11 +362,21 @@ async function handleSendOTP(request: Request, env: Env): Promise<Response> {
351
362
  }),
352
363
  });
353
364
 
365
+ if (!emailRes.ok) {
366
+ console.error('Failed to send OTP email:', emailRes.status, await emailRes.text());
367
+ return Response.json({ error: 'Failed to send email' }, { status: 502 });
368
+ }
369
+
354
370
  return Response.json({ sent: true });
355
371
  }
356
372
 
357
373
  async function handleVerifyOTP(request: Request, env: Env): Promise<Response> {
358
- const body = (await request.json()) as { email?: string; code?: string };
374
+ let body: { email?: string; code?: string };
375
+ try {
376
+ body = (await request.json()) as { email?: string; code?: string };
377
+ } catch {
378
+ return Response.json({ error: 'Invalid request body' }, { status: 400 });
379
+ }
359
380
  const email = body.email?.trim().toLowerCase();
360
381
  const code = body.code?.trim();
361
382
 
@@ -363,6 +384,11 @@ async function handleVerifyOTP(request: Request, env: Env): Promise<Response> {
363
384
  return Response.json({ error: 'Email and code required' }, { status: 400 });
364
385
  }
365
386
 
387
+ const allowed = await checkRateLimit(env.AUTH_KV, `rate:otp-verify:${email}`, OTP_VERIFY_RATE_LIMIT);
388
+ if (!allowed) {
389
+ return Response.json({ error: 'Too many attempts. Try again later.' }, { status: 429 });
390
+ }
391
+
366
392
  const storedCode = await env.AUTH_KV.get(`otp:${email}`);
367
393
  if (!storedCode || storedCode !== code) {
368
394
  return Response.json({ error: 'Invalid or expired code' }, { status: 401 });
@@ -504,11 +530,17 @@ async function incrementUsage(db: D1Database, userId: string): Promise<void> {
504
530
  // ─── Authenticated Route Handlers ───────────────────────────────────
505
531
 
506
532
  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>();
533
+ // Run user fetch and usage check in parallel to avoid sequential DB queries
534
+ const [dbUser, usageFromJwt] = await Promise.all([
535
+ env.AUTH_DB.prepare('SELECT * FROM users WHERE id = ?').bind(user.sub).first<User>(),
536
+ checkUsageLimit(env.AUTH_DB, user.sub, user.plan),
537
+ ]);
509
538
  const currentPlan = dbUser?.plan || user.plan;
510
539
 
511
- const usage = await checkUsageLimit(env.AUTH_DB, user.sub, currentPlan);
540
+ // If plan changed from JWT, re-check usage with correct plan
541
+ const usage = currentPlan !== user.plan
542
+ ? await checkUsageLimit(env.AUTH_DB, user.sub, currentPlan)
543
+ : usageFromJwt;
512
544
 
513
545
  const response: Record<string, unknown> = {
514
546
  user: { id: user.sub, email: user.email, plan: currentPlan },
@@ -539,7 +571,12 @@ async function handleUsageIncrement(user: JWTPayload, env: Env): Promise<Respons
539
571
  }
540
572
 
541
573
  async function handleLinkStripe(request: Request, user: JWTPayload, env: Env): Promise<Response> {
542
- const body = (await request.json()) as { customerId?: string };
574
+ let body: { customerId?: string };
575
+ try {
576
+ body = (await request.json()) as { customerId?: string };
577
+ } catch {
578
+ return Response.json({ error: 'Invalid request body' }, { status: 400 });
579
+ }
543
580
  const customerId = body.customerId;
544
581
 
545
582
  if (!customerId?.startsWith('cus_')) {
@@ -588,6 +625,68 @@ async function determinePlanFromStripe(customerId: string, env: Env): Promise<st
588
625
  return 'free';
589
626
  }
590
627
 
628
+ // ─── Platform Telemetry ─────────────────────────────────────────────
629
+
630
+ function logUpdateCheck(request: Request, platform: 'electron' | 'tauri', meta: Record<string, string> = {}): void {
631
+ const userAgent = request.headers.get('User-Agent') || 'unknown';
632
+ console.log(JSON.stringify({
633
+ event: 'update_check',
634
+ platform,
635
+ user_agent: userAgent,
636
+ ...meta,
637
+ }));
638
+ }
639
+
640
+ // ─── Tauri Update ──────────────────────────────────────────────────
641
+
642
+ /** Compare two semver strings. Returns 1 if a > b, -1 if a < b, 0 if equal. */
643
+ function compareSemver(a: string, b: string): number {
644
+ const pa = a.replace(/^v/, '').split('.').map(Number);
645
+ const pb = b.replace(/^v/, '').split('.').map(Number);
646
+ for (let i = 0; i < 3; i++) {
647
+ const diff = (pa[i] || 0) - (pb[i] || 0);
648
+ if (diff !== 0) return diff > 0 ? 1 : -1;
649
+ }
650
+ return 0;
651
+ }
652
+
653
+ async function handleTauriUpdate(
654
+ target: string,
655
+ arch: string,
656
+ currentVersion: string,
657
+ requestUrl: URL,
658
+ env: Env
659
+ ): Promise<Response> {
660
+ const object = await env.RELEASE_ARTIFACTS.get('tauri-releases.json');
661
+ if (!object) {
662
+ return Response.json({ error: 'No Tauri releases available' }, { status: 404 });
663
+ }
664
+
665
+ const manifest = (await object.json()) as TauriUpdateManifest;
666
+ const platformKey = `${target}-${arch}`;
667
+ const platform = manifest.platforms[platformKey];
668
+
669
+ if (!platform) {
670
+ return Response.json({ error: `Unsupported platform: ${platformKey}` }, { status: 404 });
671
+ }
672
+
673
+ if (compareSemver(manifest.version, currentVersion) <= 0) {
674
+ return new Response('', { status: 204 });
675
+ }
676
+
677
+ const downloadUrl = platform.url.startsWith('http')
678
+ ? platform.url
679
+ : `${requestUrl.origin}/updates/download/${platform.url}`;
680
+
681
+ return Response.json({
682
+ version: manifest.version,
683
+ notes: manifest.notes,
684
+ pub_date: manifest.pub_date,
685
+ url: downloadUrl,
686
+ signature: platform.signature,
687
+ });
688
+ }
689
+
591
690
  // ─── Existing Route Handlers ────────────────────────────────────────
592
691
 
593
692
  async function handleUpdateManifest(env: Env): Promise<Response> {
@@ -599,7 +698,7 @@ async function handleUpdateManifest(env: Env): Promise<Response> {
599
698
  return new Response(object.body, {
600
699
  headers: {
601
700
  'Content-Type': 'text/yaml',
602
- 'Cache-Control': 'no-cache',
701
+ 'Cache-Control': 'public, max-age=3600, must-revalidate',
603
702
  'ETag': object.httpEtag,
604
703
  },
605
704
  });
@@ -634,22 +733,54 @@ async function handleArtifactDownload(pathname: string, env: Env): Promise<Respo
634
733
  });
635
734
  }
636
735
 
736
+ async function handleTauriPublicDownload(platformKey: string, env: Env): Promise<Response | null> {
737
+ const object = await env.RELEASE_ARTIFACTS.get('tauri-releases.json');
738
+ if (!object) return null;
739
+
740
+ const manifest = (await object.json()) as TauriUpdateManifest;
741
+ const platform = manifest.platforms[platformKey];
742
+ if (!platform?.url) return null;
743
+
744
+ const filename = platform.url;
745
+ const artifact = await env.RELEASE_ARTIFACTS.get(filename);
746
+ if (!artifact) return null;
747
+
748
+ const contentType = filename.endsWith('.dmg')
749
+ ? 'application/x-apple-diskimage'
750
+ : filename.endsWith('.AppImage')
751
+ ? 'application/x-executable'
752
+ : 'application/octet-stream';
753
+
754
+ return new Response(artifact.body, {
755
+ headers: {
756
+ 'Content-Type': contentType,
757
+ 'Content-Length': artifact.size.toString(),
758
+ 'Content-Disposition': `attachment; filename="${filename}"`,
759
+ 'Cache-Control': 'public, max-age=86400',
760
+ 'ETag': artifact.httpEtag,
761
+ },
762
+ });
763
+ }
764
+
637
765
  async function handlePublicDownload(arch: string, env: Env): Promise<Response> {
638
- // Parse latest-mac.yml to find current filenames
766
+ // Try Tauri artifacts first
767
+ const platformKey = arch === 'arm64' ? 'darwin-aarch64' : 'darwin-x86_64';
768
+ const tauriResponse = await handleTauriPublicDownload(platformKey, env);
769
+ if (tauriResponse) return tauriResponse;
770
+
771
+ // Fall back to Electron manifest (latest-mac.yml) during transition
639
772
  const manifest = await env.RELEASE_ARTIFACTS.get('latest-mac.yml');
640
773
  if (!manifest) {
641
774
  return Response.json({ error: 'No releases available' }, { status: 404 });
642
775
  }
643
776
 
644
777
  const yml = await manifest.text();
645
- // Find DMG filenames from the manifest
646
778
  const dmgFiles = [...yml.matchAll(/url:\s*(\S+\.dmg)/g)].map(m => m[1]);
647
779
 
648
780
  let filename: string | undefined;
649
781
  if (arch === 'arm64') {
650
782
  filename = dmgFiles.find(f => f.includes('arm64'));
651
783
  } else {
652
- // x64 — the one without arm64
653
784
  filename = dmgFiles.find(f => !f.includes('arm64'));
654
785
  }
655
786
 
@@ -667,6 +798,7 @@ async function handlePublicDownload(arch: string, env: Env): Promise<Response> {
667
798
  'Content-Type': 'application/x-apple-diskimage',
668
799
  'Content-Length': object.size.toString(),
669
800
  'Content-Disposition': `attachment; filename="${filename}"`,
801
+ 'Cache-Control': 'public, max-age=86400',
670
802
  'ETag': object.httpEtag,
671
803
  },
672
804
  });
@@ -692,13 +824,7 @@ async function verifyStripeSignature(
692
824
  if (age > 300) return false;
693
825
 
694
826
  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
- );
827
+ const key = await getHmacKey(secret, 'sign');
702
828
  const sig = await crypto.subtle.sign(
703
829
  'HMAC',
704
830
  key,
@@ -834,30 +960,33 @@ async function handleCheckoutSuccess(url: URL, env: Env): Promise<Response> {
834
960
  return new Response('Invalid session', { status: 400 });
835
961
  }
836
962
 
837
- const session = (await response.json()) as StripeCheckoutSession;
838
- const customerId = session.customer;
839
-
840
963
  return new Response(
841
964
  `<!DOCTYPE html>
842
965
  <html>
843
- <head><title>JettyPod — Subscription Activated</title>
966
+ <head>
967
+ <meta charset="utf-8">
968
+ <meta name="viewport" content="width=device-width, initial-scale=1">
969
+ <title>JettyPod \u2014 Subscription Activated</title>
844
970
  <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>
971
+ 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; }
972
+ .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; }
973
+ .check { width: 56px; height: 56px; background: #E8EEEF; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 24px; }
974
+ .check svg { width: 28px; height: 28px; color: #4A6365; }
975
+ h1 { color: #18181b; margin: 0 0 12px; font-size: 24px; letter-spacing: -0.025em; }
976
+ p { color: #52525b; line-height: 1.6; margin: 0; font-size: 15px; }
977
+ .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; }
978
+ .cta:hover { filter: brightness(1.05); }
979
+ </style>
980
+ </head>
852
981
  <body>
853
982
  <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>
983
+ <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>
984
+ <h1>You're all set!</h1>
985
+ <p>Your subscription is active. Head back to JettyPod to get started.</p>
986
+ <a class="cta" href="jettypod://subscription/activated">Open JettyPod</a>
858
987
  </div>
859
988
  </body></html>`,
860
- { headers: { 'Content-Type': 'text/html' } }
989
+ { headers: { 'Content-Type': 'text/html; charset=utf-8' } }
861
990
  );
862
991
  }
863
992
 
@@ -1012,12 +1141,19 @@ async function handleRequest(request: Request, url: URL, pathname: string, env:
1012
1141
  return handlePublicDownload('arm64', env);
1013
1142
  }
1014
1143
 
1144
+ if (request.method === 'GET' && pathname === '/download/linux') {
1145
+ const response = await handleTauriPublicDownload('linux-x86_64', env);
1146
+ if (response) return response;
1147
+ return Response.json({ error: 'No Linux release available' }, { status: 404 });
1148
+ }
1149
+
1015
1150
  // ── Update routes (paid plan or legacy cus_ token) ─────────────
1016
1151
 
1017
- // Update manifest
1152
+ // Update manifest (Electron)
1018
1153
  if (request.method === 'GET' && pathname === '/updates/latest-mac.yml') {
1019
1154
  const authError = await authenticateUpdateRequest(request, env);
1020
1155
  if (authError) return authError;
1156
+ logUpdateCheck(request, 'electron');
1021
1157
  return handleUpdateManifest(env);
1022
1158
  }
1023
1159
 
@@ -1028,6 +1164,18 @@ async function handleRequest(request: Request, url: URL, pathname: string, env:
1028
1164
  return handleArtifactDownload(pathname, env);
1029
1165
  }
1030
1166
 
1167
+ // Tauri update manifest — /tauri/:target/:arch/:current_version
1168
+ if (request.method === 'GET' && pathname.startsWith('/tauri/')) {
1169
+ const authError = await authenticateUpdateRequest(request, env);
1170
+ if (authError) return authError;
1171
+ const parts = pathname.split('/').filter(Boolean); // ["tauri", target, arch, version]
1172
+ if (parts.length === 4) {
1173
+ logUpdateCheck(request, 'tauri', { target: parts[1], arch: parts[2], current_version: parts[3] });
1174
+ return handleTauriUpdate(parts[1], parts[2], parts[3], url, env);
1175
+ }
1176
+ return Response.json({ error: 'Invalid update URL format' }, { status: 400 });
1177
+ }
1178
+
1031
1179
  // ── Stripe/checkout routes (existing) ──────────────────────────
1032
1180
 
1033
1181
  if (request.method === 'POST' && pathname === '/checkout/create-session') {
@@ -245,28 +245,29 @@ function evaluateBashCommand(command, inputRef, cwd) {
245
245
  }
246
246
  }
247
247
 
248
- // BLOCKED: work merge or tests merge from inside a worktree
249
- // This prevents shell CWD corruption when worktree is deleted
248
+ // BLOCKED: work merge or tests merge from inside a worktree WITHOUT explicit ID
249
+ // Merge with explicit ID is safe: the merge command handles CWD internally via
250
+ // process.chdir() and does NOT delete the worktree (only cleanup does).
251
+ // Bare merge (no ID) is blocked because getCurrentWork() relies on branch detection
252
+ // which breaks after process.chdir() moves away from the worktree.
250
253
  if (/jettypod\s+(work|tests)\s+merge\b/.test(strippedCommand)) {
251
254
  const isInWorktree = cwd && cwd.includes('.jettypod-work');
252
- if (isInWorktree) {
253
- // Extract main repo path and work item ID for helpful error message
255
+ const hasExplicitId = /merge\s+\d+/.test(strippedCommand);
256
+ if (isInWorktree && !hasExplicitId) {
254
257
  const mainRepoPath = cwd.split('.jettypod-work')[0].replace(/\/$/, '');
255
- const workIdMatch = strippedCommand.match(/merge\s+(\d+)/);
256
- const workId = workIdMatch ? workIdMatch[1] : '<id>';
257
258
 
258
259
  return {
259
260
  allowed: false,
260
- message: 'Cannot merge from inside a worktree',
261
- hint: `Current CWD: ${cwd}
262
- Main repo: ${mainRepoPath}
261
+ message: 'Cannot merge from inside a worktree without an explicit work item ID',
262
+ hint: `Provide the work item ID explicitly:
263
263
 
264
- Run these as TWO SEPARATE Bash calls:
264
+ jettypod work merge <id>
265
265
 
266
- 1. cd ${mainRepoPath}
267
- 2. jettypod work merge ${workId}
266
+ This works from anywhere — the merge command handles CWD internally.
267
+ After merge, cd to main repo before cleanup:
268
268
 
269
- Why: Hooks check CWD before 'cd &&' executes, so chained commands fail.`
269
+ cd ${mainRepoPath}
270
+ jettypod work cleanup <id>`
270
271
  };
271
272
  }
272
273
  }
@@ -0,0 +1,19 @@
1
+ [package]
2
+ name = "jettypod-cli"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "JettyPod workflow CLI"
6
+
7
+ [[bin]]
8
+ name = "jettypod"
9
+ path = "src/main.rs"
10
+
11
+ [dependencies]
12
+ jettypod-core = { path = "../jettypod-core" }
13
+ clap = { version = "4", features = ["derive"] }
14
+ tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
15
+ anyhow = { workspace = true }
16
+ log = { workspace = true }
17
+ serde_json = { workspace = true }
18
+ chrono = { workspace = true }
19
+ rusqlite = { version = "0.31", features = ["bundled"] }