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.
- package/.env +7 -0
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
- package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
- package/apps/dashboard/app/api/usage/route.ts +17 -0
- package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
- package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
- package/apps/dashboard/app/connect-claude/page.tsx +24 -0
- package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
- package/apps/dashboard/app/demo/gates/page.tsx +42 -42
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +6 -2
- package/apps/dashboard/app/install-claude/page.tsx +9 -7
- package/apps/dashboard/app/layout.tsx +17 -5
- package/apps/dashboard/app/login/page.tsx +250 -0
- package/apps/dashboard/app/page.tsx +11 -9
- package/apps/dashboard/app/settings/page.tsx +4 -2
- package/apps/dashboard/app/signup/page.tsx +245 -0
- package/apps/dashboard/app/subscribe/page.tsx +11 -0
- package/apps/dashboard/app/welcome/page.tsx +24 -1
- package/apps/dashboard/app/work/[id]/page.tsx +34 -50
- package/apps/dashboard/components/AppShell.tsx +95 -55
- package/apps/dashboard/components/CardMenu.tsx +56 -13
- package/apps/dashboard/components/ClaudePanel.tsx +301 -582
- package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
- package/apps/dashboard/components/CopyableId.tsx +3 -3
- package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
- package/apps/dashboard/components/DragContext.tsx +75 -65
- package/apps/dashboard/components/DraggableCard.tsx +6 -46
- package/apps/dashboard/components/DropZone.tsx +2 -2
- package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
- package/apps/dashboard/components/EditableTitle.tsx +26 -6
- package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
- package/apps/dashboard/components/EpicGroup.tsx +329 -0
- package/apps/dashboard/components/GateCard.tsx +100 -16
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
- package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
- package/apps/dashboard/components/JettyLoader.tsx +38 -0
- package/apps/dashboard/components/KanbanBoard.tsx +147 -766
- package/apps/dashboard/components/KanbanCard.tsx +506 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
- package/apps/dashboard/components/MainNav.tsx +20 -54
- package/apps/dashboard/components/MessageBlock.tsx +391 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -15
- package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
- package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
- package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
- package/apps/dashboard/components/ReviewFooter.tsx +141 -0
- package/apps/dashboard/components/SessionList.tsx +19 -18
- package/apps/dashboard/components/SubscribeContent.tsx +206 -0
- package/apps/dashboard/components/TestTree.tsx +15 -14
- package/apps/dashboard/components/TipCard.tsx +177 -0
- package/apps/dashboard/components/Toast.tsx +5 -5
- package/apps/dashboard/components/TypeIcon.tsx +56 -0
- package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
- package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
- package/apps/dashboard/components/WorkItemTree.tsx +9 -28
- package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
- package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
- package/apps/dashboard/contexts/UsageContext.tsx +155 -0
- package/apps/dashboard/contexts/usageHelpers.js +9 -0
- package/apps/dashboard/electron/ipc-handlers.js +281 -88
- package/apps/dashboard/electron/main.js +691 -131
- package/apps/dashboard/electron/preload.js +25 -4
- package/apps/dashboard/electron/session-manager.js +163 -0
- package/apps/dashboard/electron-builder.config.js +3 -5
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/lib/backlog-parser.ts +50 -0
- package/apps/dashboard/lib/claude-process-manager.ts +50 -11
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/db-bridge.ts +33 -0
- package/apps/dashboard/lib/db.ts +136 -20
- package/apps/dashboard/lib/kanban-utils.ts +70 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/session-state-machine.ts +3 -0
- package/apps/dashboard/lib/session-stream-manager.ts +144 -38
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/tests.ts +3 -1
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next.config.js +35 -14
- package/apps/dashboard/package.json +6 -3
- package/apps/dashboard/public/bug-icon.svg +9 -0
- package/apps/dashboard/public/buoy-icon.svg +9 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.svg +9 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.svg +14 -0
- package/apps/dashboard/public/star-icon.svg +9 -0
- package/apps/dashboard/public/wrench-icon.svg +9 -0
- package/apps/dashboard/scripts/upload-to-r2.js +89 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
- package/apps/update-server/package.json +16 -0
- package/apps/update-server/schema.sql +31 -0
- package/apps/update-server/src/index.ts +1085 -0
- package/apps/update-server/tsconfig.json +16 -0
- package/apps/update-server/wrangler.toml +35 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +54 -116
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/migrations/027-plan-at-creation-column.js +33 -0
- package/lib/migrations/028-ready-for-review-column.js +27 -0
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +13 -6
- package/lib/seed-onboarding.js +101 -69
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +129 -16
- package/lib/work-tracking/index.js +86 -46
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +39 -28
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +131 -68
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/epic-planning/SKILL.md +68 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +83 -73
- package/skills-templates/production-mode/SKILL.md +49 -49
- package/skills-templates/request-routing/SKILL.md +27 -14
- package/skills-templates/simple-improvement/SKILL.md +68 -44
- package/skills-templates/speed-mode/SKILL.md +209 -128
- package/skills-templates/stable-mode/SKILL.md +105 -94
- package/templates/bdd-guidance.md +139 -0
- package/templates/bdd-scaffolding/wait.js +18 -0
- package/templates/bdd-scaffolding/world.js +19 -0
- package/.jettypod-backup/work.db +0 -0
- package/apps/dashboard/app/access-code/page.tsx +0 -110
- package/lib/discovery-checkpoint.js +0 -123
- 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>;
|