@stacksjs/ts-analytics 0.1.6

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 (237) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +361 -0
  4. package/bin/cli.ts +169 -0
  5. package/dist/Analytics.d.ts +558 -0
  6. package/dist/api.d.ts +109 -0
  7. package/dist/batching.d.ts +93 -0
  8. package/dist/chunk-2mx7fq49.js +4 -0
  9. package/dist/chunk-3z29508k.js +204 -0
  10. package/dist/chunk-deephkz6.js +56 -0
  11. package/dist/chunk-j261vgyp.js +305 -0
  12. package/dist/chunk-xga17tz7.js +207 -0
  13. package/dist/config.d.ts +132 -0
  14. package/dist/dashboard/components/index.d.ts +80 -0
  15. package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
  16. package/dist/dashboard/index.d.ts +110 -0
  17. package/dist/dashboard/types/index.d.ts +144 -0
  18. package/dist/dashboard/utils/index.d.ts +81 -0
  19. package/dist/dynamodb.d.ts +86 -0
  20. package/dist/funnels.d.ts +104 -0
  21. package/dist/geolocation.d.ts +96 -0
  22. package/dist/index.d.ts +371 -0
  23. package/dist/index.js +10211 -0
  24. package/dist/infrastructure/cdk.d.ts +60 -0
  25. package/dist/infrastructure/cloudformation.d.ts +44 -0
  26. package/dist/infrastructure/index.d.ts +45 -0
  27. package/dist/infrastructure/setup.d.ts +133 -0
  28. package/dist/integrations/cloudflare.d.ts +76 -0
  29. package/dist/integrations/hono.d.ts +60 -0
  30. package/dist/integrations/index.d.ts +24 -0
  31. package/dist/integrations/nuxt.d.ts +7 -0
  32. package/dist/integrations/nuxt.js +42 -0
  33. package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
  34. package/dist/integrations/runtime/use-ts-analytics.js +17 -0
  35. package/dist/integrations/stx.d.ts +88 -0
  36. package/dist/integrations/stx.js +18 -0
  37. package/dist/lib/crypto-random.d.ts +4 -0
  38. package/dist/lib/salt.d.ts +16 -0
  39. package/dist/local.d.ts +56 -0
  40. package/dist/model-connector.d.ts +145 -0
  41. package/dist/models/AggregatedStats.d.ts +9 -0
  42. package/dist/models/CampaignStats.d.ts +9 -0
  43. package/dist/models/Conversion.d.ts +11 -0
  44. package/dist/models/CustomEvent.d.ts +11 -0
  45. package/dist/models/DeviceStats.d.ts +9 -0
  46. package/dist/models/EventStats.d.ts +9 -0
  47. package/dist/models/GeoStats.d.ts +9 -0
  48. package/dist/models/Goal.d.ts +9 -0
  49. package/dist/models/GoalStats.d.ts +9 -0
  50. package/dist/models/PageStats.d.ts +11 -0
  51. package/dist/models/PageView.d.ts +13 -0
  52. package/dist/models/RealtimeStats.d.ts +9 -0
  53. package/dist/models/ReferrerStats.d.ts +9 -0
  54. package/dist/models/Session.d.ts +11 -0
  55. package/dist/models/Site.d.ts +11 -0
  56. package/dist/models/index.d.ts +28 -0
  57. package/dist/models/types.d.ts +60 -0
  58. package/dist/sqs-buffering.d.ts +243 -0
  59. package/dist/stacks-integration.d.ts +159 -0
  60. package/dist/tracking-script.d.ts +71 -0
  61. package/dist/tracking.d.ts +17 -0
  62. package/dist/tracking.js +26 -0
  63. package/dist/types.d.ts +595 -0
  64. package/dist/utils/geolocation.d.ts +111 -0
  65. package/dist/utils/user-agent.d.ts +17 -0
  66. package/dist/version.d.ts +7 -0
  67. package/package.json +119 -0
  68. package/src/Analytics.ts +3349 -0
  69. package/src/api.ts +1286 -0
  70. package/src/assets/crosswind.css +2220 -0
  71. package/src/batching.ts +452 -0
  72. package/src/components/dashboard/index.ts +18 -0
  73. package/src/config.ts +456 -0
  74. package/src/dashboard/Dashboard.stx +1517 -0
  75. package/src/dashboard/components/AlertCard.stx +177 -0
  76. package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
  77. package/src/dashboard/components/AnimatedNumber.stx +86 -0
  78. package/src/dashboard/components/BarChart.stx +220 -0
  79. package/src/dashboard/components/BrowserBreakdown.stx +98 -0
  80. package/src/dashboard/components/BrowsersTable.stx +125 -0
  81. package/src/dashboard/components/CampaignBreakdown.stx +163 -0
  82. package/src/dashboard/components/CampaignTable.stx +238 -0
  83. package/src/dashboard/components/CountryList.stx +101 -0
  84. package/src/dashboard/components/DataTable.stx +226 -0
  85. package/src/dashboard/components/DateRangePicker.stx +77 -0
  86. package/src/dashboard/components/DeviceBreakdown.stx +94 -0
  87. package/src/dashboard/components/DevicesTable.stx +163 -0
  88. package/src/dashboard/components/DonutChart.stories.ts +55 -0
  89. package/src/dashboard/components/DonutChart.stx +157 -0
  90. package/src/dashboard/components/EmptyState.stx +90 -0
  91. package/src/dashboard/components/EngagementMetrics.stx +183 -0
  92. package/src/dashboard/components/EventsSection.stx +192 -0
  93. package/src/dashboard/components/FilterBar.stx +104 -0
  94. package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
  95. package/src/dashboard/components/FunnelChart.stx +142 -0
  96. package/src/dashboard/components/GeoTable.stx +337 -0
  97. package/src/dashboard/components/GoalsPanel.stx +109 -0
  98. package/src/dashboard/components/Header.stx +39 -0
  99. package/src/dashboard/components/HeatmapChart.stx +140 -0
  100. package/src/dashboard/components/LiveActivityFeed.stx +172 -0
  101. package/src/dashboard/components/MetricComparison.stx +106 -0
  102. package/src/dashboard/components/MiniStats.stx +96 -0
  103. package/src/dashboard/components/OSBreakdown.stx +124 -0
  104. package/src/dashboard/components/PageDetailCard.stx +102 -0
  105. package/src/dashboard/components/PagesTable.stx +127 -0
  106. package/src/dashboard/components/ProgressRing.stx +89 -0
  107. package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
  108. package/src/dashboard/components/RealtimeCounter.stx +46 -0
  109. package/src/dashboard/components/ReferrersTable.stx +180 -0
  110. package/src/dashboard/components/SparklineChart.stx +160 -0
  111. package/src/dashboard/components/StatCard.stories.ts +58 -0
  112. package/src/dashboard/components/StatCard.stx +90 -0
  113. package/src/dashboard/components/SummaryStats.stx +81 -0
  114. package/src/dashboard/components/TabNav.stx +66 -0
  115. package/src/dashboard/components/ThemeSwitcher.stx +124 -0
  116. package/src/dashboard/components/TimeSeriesChart.stx +106 -0
  117. package/src/dashboard/components/TopList.stories.ts +58 -0
  118. package/src/dashboard/components/TopList.stx +74 -0
  119. package/src/dashboard/components/TrendIndicator.stx +84 -0
  120. package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
  121. package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
  122. package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
  123. package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
  124. package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
  125. package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
  126. package/src/dashboard/components/heatmap/index.ts +12 -0
  127. package/src/dashboard/components/index.ts +86 -0
  128. package/src/dashboard/composables/useAnalytics.ts +465 -0
  129. package/src/dashboard/demo/DemoApp.stx +370 -0
  130. package/src/dashboard/demo/index.ts +8 -0
  131. package/src/dashboard/demo/mockData.ts +234 -0
  132. package/src/dashboard/index.ts +117 -0
  133. package/src/dashboard/stx-shim.d.ts +4 -0
  134. package/src/dashboard/types/index.ts +203 -0
  135. package/src/dashboard/utils/index.ts +426 -0
  136. package/src/dynamodb.ts +344 -0
  137. package/src/funnels.ts +534 -0
  138. package/src/geolocation.ts +515 -0
  139. package/src/handlers/alerts.ts +328 -0
  140. package/src/handlers/annotations.ts +128 -0
  141. package/src/handlers/api-keys.ts +240 -0
  142. package/src/handlers/auth.ts +1020 -0
  143. package/src/handlers/authz.ts +137 -0
  144. package/src/handlers/collect.ts +724 -0
  145. package/src/handlers/data.ts +625 -0
  146. package/src/handlers/experiments.ts +138 -0
  147. package/src/handlers/funnels.ts +216 -0
  148. package/src/handlers/goals.ts +218 -0
  149. package/src/handlers/heatmaps.ts +272 -0
  150. package/src/handlers/index.ts +56 -0
  151. package/src/handlers/lib/read-cache.ts +63 -0
  152. package/src/handlers/misc.ts +486 -0
  153. package/src/handlers/oauth.ts +233 -0
  154. package/src/handlers/performance.ts +388 -0
  155. package/src/handlers/sessions.ts +413 -0
  156. package/src/handlers/sharing.ts +198 -0
  157. package/src/handlers/stats.ts +1368 -0
  158. package/src/handlers/team.ts +161 -0
  159. package/src/handlers/uptime.ts +283 -0
  160. package/src/handlers/views.ts +390 -0
  161. package/src/handlers/webhooks.ts +226 -0
  162. package/src/heatmap/index.ts +32 -0
  163. package/src/heatmap/tracking-script.ts +452 -0
  164. package/src/heatmap/types.ts +79 -0
  165. package/src/index.ts +387 -0
  166. package/src/infrastructure/cdk.ts +496 -0
  167. package/src/infrastructure/cloudformation.ts +595 -0
  168. package/src/infrastructure/index.ts +48 -0
  169. package/src/infrastructure/setup.ts +611 -0
  170. package/src/integrations/cloudflare.ts +732 -0
  171. package/src/integrations/hono.ts +589 -0
  172. package/src/integrations/index.ts +27 -0
  173. package/src/integrations/nuxt.ts +78 -0
  174. package/src/integrations/runtime/use-ts-analytics.ts +32 -0
  175. package/src/integrations/stx.ts +138 -0
  176. package/src/jobs/index.ts +127 -0
  177. package/src/lib/crypto-random.ts +41 -0
  178. package/src/lib/ddb-errors.ts +20 -0
  179. package/src/lib/dynamodb.ts +216 -0
  180. package/src/lib/email.ts +38 -0
  181. package/src/lib/ga-import.ts +471 -0
  182. package/src/lib/ga4-api.ts +244 -0
  183. package/src/lib/goals.ts +205 -0
  184. package/src/lib/index.ts +6 -0
  185. package/src/lib/ingest-counters.ts +123 -0
  186. package/src/lib/log.ts +36 -0
  187. package/src/lib/membership.ts +98 -0
  188. package/src/lib/plans.ts +90 -0
  189. package/src/lib/quota.ts +46 -0
  190. package/src/lib/rate-limit.ts +27 -0
  191. package/src/lib/rollups.ts +472 -0
  192. package/src/lib/salt.ts +97 -0
  193. package/src/lib/scheduler.ts +97 -0
  194. package/src/lib/significance.ts +66 -0
  195. package/src/lib/site-retention.ts +56 -0
  196. package/src/local.ts +360 -0
  197. package/src/model-connector.ts +616 -0
  198. package/src/models/AggregatedStats.ts +162 -0
  199. package/src/models/CampaignStats.ts +141 -0
  200. package/src/models/Conversion.ts +135 -0
  201. package/src/models/CustomEvent.ts +123 -0
  202. package/src/models/DeviceStats.ts +105 -0
  203. package/src/models/EventStats.ts +108 -0
  204. package/src/models/GeoStats.ts +112 -0
  205. package/src/models/Goal.ts +105 -0
  206. package/src/models/GoalStats.ts +106 -0
  207. package/src/models/PageStats.ts +152 -0
  208. package/src/models/PageView.ts +277 -0
  209. package/src/models/RealtimeStats.ts +96 -0
  210. package/src/models/ReferrerStats.ts +115 -0
  211. package/src/models/Session.ts +235 -0
  212. package/src/models/Site.ts +115 -0
  213. package/src/models/index.ts +50 -0
  214. package/src/models/orm/index.ts +2043 -0
  215. package/src/models/types.ts +71 -0
  216. package/src/router.ts +521 -0
  217. package/src/sqs-buffering.ts +806 -0
  218. package/src/stacks-integration.ts +527 -0
  219. package/src/tracking-script.ts +944 -0
  220. package/src/tracking.ts +27 -0
  221. package/src/types/analytics.ts +219 -0
  222. package/src/types/api.ts +75 -0
  223. package/src/types/bun-router.d.ts +15 -0
  224. package/src/types/dashboard.ts +62 -0
  225. package/src/types/index.ts +66 -0
  226. package/src/types/stx.d.ts +27 -0
  227. package/src/types/window.d.ts +101 -0
  228. package/src/types.ts +911 -0
  229. package/src/utils/cache.ts +148 -0
  230. package/src/utils/date.ts +118 -0
  231. package/src/utils/filters.ts +71 -0
  232. package/src/utils/geolocation.ts +323 -0
  233. package/src/utils/index.ts +9 -0
  234. package/src/utils/response.ts +180 -0
  235. package/src/utils/timezone-country.ts +242 -0
  236. package/src/utils/user-agent.ts +110 -0
  237. package/src/version.ts +7 -0
@@ -0,0 +1,1020 @@
1
+ /**
2
+ * Authentication handlers
3
+ *
4
+ * Cookie-based session auth with DynamoDB storage.
5
+ * Uses Bun.password (built-in bcrypt) for password hashing.
6
+ */
7
+
8
+ import { dynamodb, TABLE_NAME, marshall, unmarshall } from '../lib/dynamodb'
9
+ import { htmlResponse, jsonResponse, errorResponse } from '../utils/response'
10
+ import { sendEmail, appBaseUrl } from '../lib/email'
11
+ import { acceptInvites, addMembership } from '../lib/membership'
12
+ import { getQueryParams } from '../../deploy/lambda-adapter'
13
+ import path from 'node:path'
14
+ import fs from 'node:fs'
15
+
16
+ // Session cookie config
17
+ const SESSION_COOKIE = 'session'
18
+ const SESSION_TTL_DAYS = 30
19
+
20
+ /**
21
+ * Create a new user in DynamoDB
22
+ */
23
+ async function createUser(email: string, password: string, name = ''): Promise<{ userId: string; email: string; name: string }> {
24
+ const userId = crypto.randomUUID()
25
+ const hash = await Bun.password.hash(password, 'argon2id')
26
+ const now = new Date().toISOString()
27
+
28
+ await dynamodb.putItem({
29
+ TableName: TABLE_NAME,
30
+ Item: marshall({
31
+ pk: `USER#${userId}`,
32
+ sk: `USER#${userId}`,
33
+ gsi1pk: `EMAIL#${email.toLowerCase()}`,
34
+ gsi1sk: `USER#${userId}`,
35
+ userId,
36
+ email: email.toLowerCase(),
37
+ name,
38
+ passwordHash: hash,
39
+ emailVerified: false,
40
+ createdAt: now,
41
+ updatedAt: now,
42
+ }),
43
+ })
44
+
45
+ return { userId, email: email.toLowerCase(), name }
46
+ }
47
+
48
+ /**
49
+ * Create an account from a verified OAuth profile (#53). No usable password —
50
+ * a random one is hashed so password login stays possible only via reset.
51
+ */
52
+ export async function createOAuthUser(email: string, name: string, emailVerified: boolean): Promise<{ userId: string, email: string }> {
53
+ const user = await createUser(email, crypto.randomUUID() + crypto.randomUUID(), name)
54
+ if (emailVerified) {
55
+ await updateUserFields(user.userId, { emailVerified: true })
56
+ }
57
+ return { userId: user.userId, email: user.email }
58
+ }
59
+
60
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
61
+
62
+ /**
63
+ * Login-CSRF defense for session-establishing form POSTs (#1/#2).
64
+ *
65
+ * The auth forms accept CORS-safe encodings, so SameSite=Lax can't stop a
66
+ * cross-site page from auto-submitting one and having the browser STORE the
67
+ * returned Set-Cookie (login-CSRF / forced-logout). We reject only requests
68
+ * the browser positively marks as cross-site via Sec-Fetch-Site — same-origin
69
+ * forms (including those relayed by the dev proxy, which forwards the header)
70
+ * send `same-origin`/`same-site`; absent metadata (old browsers) is allowed so
71
+ * we never block a legitimate sign-in. The JSON API twins are already
72
+ * preflight-protected by their application/json requirement.
73
+ */
74
+ export function crossSiteFormPost(request: Request): boolean {
75
+ return (request.headers.get('sec-fetch-site') || '') === 'cross-site'
76
+ }
77
+
78
+ function csrfReject(): Response {
79
+ return new Response(null, { status: 302, headers: { Location: '/login?error=csrf' } })
80
+ }
81
+
82
+ /** Validate sign-up / credential input. Returns an error message, or null if valid. */
83
+ export function validateCredentials(email: string, password: string): string | null {
84
+ if (!email || !password) return 'Email and password are required'
85
+ if (!EMAIL_RE.test(email)) return 'Please enter a valid email address'
86
+ if (password.length < 8) return 'Password must be at least 8 characters'
87
+ return null
88
+ }
89
+
90
+ const SESSION_MAX_AGE = SESSION_TTL_DAYS * 86400
91
+
92
+ /**
93
+ * Build the session Set-Cookie header (Secure in production).
94
+ *
95
+ * Split-domain installs (#118, CORS_ORIGINS set) need SameSite=None so the
96
+ * browser sends the cookie on cross-origin fetches from the dashboard —
97
+ * which requires Secure (HTTPS), so split-domain is HTTPS-only by
98
+ * construction. The router's origin allowlist + CSRF gate compensate for
99
+ * what Lax protection is given up.
100
+ */
101
+ export function sessionCookie(token: string, maxAge: number = SESSION_MAX_AGE): string {
102
+ const splitDomain = !!(process.env.CORS_ORIGINS || '').trim()
103
+ if (splitDomain)
104
+ return `${SESSION_COOKIE}=${token}; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=${maxAge}`
105
+ const secure = process.env.NODE_ENV === 'production' ? ' Secure;' : ''
106
+ return `${SESSION_COOKIE}=${token}; HttpOnly;${secure} SameSite=Lax; Path=/; Max-Age=${maxAge}`
107
+ }
108
+
109
+ /**
110
+ * POST /api/auth/signup — create an account (JSON) and start a session.
111
+ */
112
+ export async function handleSignup(request: Request): Promise<Response> {
113
+ let body: { email?: string; password?: string; name?: string }
114
+ try {
115
+ body = await request.json() as typeof body
116
+ }
117
+ catch {
118
+ return errorResponse('Invalid request body', 400)
119
+ }
120
+
121
+ const email = (body.email || '').trim().toLowerCase()
122
+ const password = body.password || ''
123
+ const name = (body.name || '').trim()
124
+
125
+ const invalid = validateCredentials(email, password)
126
+ if (invalid) return errorResponse(invalid, 400)
127
+
128
+ const existing = await getUserByEmail(email)
129
+ if (existing) return errorResponse('An account with this email already exists', 409)
130
+
131
+ const user = await createUser(email, password, name)
132
+ // Turn any pending project invites for this email into memberships.
133
+ await acceptInvites(user.userId, user.email)
134
+ await sendVerificationEmail(user.userId, user.email)
135
+ const token = await createSession(user.userId, user.email)
136
+
137
+ return jsonResponse(
138
+ { user: { userId: user.userId, email: user.email, name, emailVerified: false } },
139
+ 201,
140
+ { 'Set-Cookie': sessionCookie(token) },
141
+ )
142
+ }
143
+
144
+ /**
145
+ * POST /api/auth/login — authenticate (JSON) and start a session.
146
+ */
147
+ export async function handleAuthLogin(request: Request): Promise<Response> {
148
+ let body: { email?: string; password?: string }
149
+ try {
150
+ body = await request.json() as typeof body
151
+ }
152
+ catch {
153
+ return errorResponse('Invalid request body', 400)
154
+ }
155
+
156
+ const email = (body.email || '').trim().toLowerCase()
157
+ const password = body.password || ''
158
+ if (!email || !password) return errorResponse('Email and password are required', 400)
159
+
160
+ if (await getLoginFailures(email) >= MAX_LOGIN_ATTEMPTS) {
161
+ return errorResponse('Too many failed attempts. Please try again in a few minutes.', 429)
162
+ }
163
+
164
+ const user = await getUserByEmail(email)
165
+ const ok = user ? await Bun.password.verify(password, user.passwordHash) : false
166
+ if (!user || !ok) {
167
+ await recordLoginFailure(email)
168
+ return errorResponse('Invalid email or password', 401)
169
+ }
170
+
171
+ await clearLoginFailures(email)
172
+ const token = await createSession(user.userId, user.email)
173
+ return jsonResponse({ user: publicUser(user) }, 200, { 'Set-Cookie': sessionCookie(token) })
174
+ }
175
+
176
+ /**
177
+ * GET /api/auth/me — return the currently authenticated user.
178
+ */
179
+ export async function handleMe(request: Request): Promise<Response> {
180
+ // The 401 carries the enforcement mode so the SPA gate (#115) knows whether
181
+ // to bounce to /login or stay open (ANALYTICS_REQUIRE_AUTH=false installs).
182
+ // Same expression as authz.authRequired() — importing it here would create
183
+ // an auth ↔ authz module cycle.
184
+ const requireAuth = process.env.ANALYTICS_REQUIRE_AUTH !== 'false'
185
+ const session = await getSessionFromRequest(request)
186
+ if (!session) return jsonResponse({ error: 'Not authenticated', requireAuth }, 401)
187
+ const user = await getUserById(session.userId)
188
+ if (!user) return jsonResponse({ error: 'Not authenticated', requireAuth }, 401)
189
+ return jsonResponse({ user: publicUser(user) })
190
+ }
191
+
192
+ /**
193
+ * POST /api/auth/logout — destroy the session (JSON variant of /logout).
194
+ */
195
+ export async function handleApiLogout(request: Request): Promise<Response> {
196
+ const token = getSessionToken(request)
197
+ if (token) {
198
+ try {
199
+ await deleteSession(token)
200
+ }
201
+ catch {
202
+ // best-effort
203
+ }
204
+ }
205
+ return jsonResponse({ ok: true }, 200, {
206
+ 'Set-Cookie': `${SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`,
207
+ })
208
+ }
209
+
210
+ /**
211
+ * POST /api/auth/logout-all — revoke every session for the current user.
212
+ */
213
+ export async function handleLogoutAll(request: Request): Promise<Response> {
214
+ const session = await getSessionFromRequest(request)
215
+ if (!session) return errorResponse('Not authenticated', 401)
216
+ await invalidateUserSessions(session.userId)
217
+ return jsonResponse({ ok: true }, 200, {
218
+ 'Set-Cookie': `${SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`,
219
+ })
220
+ }
221
+
222
+ /**
223
+ * GET /api/auth/verify?token= — confirm an email address (single-use token).
224
+ */
225
+ export async function handleVerifyEmail(request: Request): Promise<Response> {
226
+ const token = new URL(request.url).searchParams.get('token') || ''
227
+ if (!token) return errorResponse('Missing token', 400)
228
+
229
+ const userId = await consumeToken('VERIFY', token)
230
+ if (!userId) return errorResponse('This verification link is invalid or has expired', 400)
231
+
232
+ await updateUserFields(userId, { emailVerified: true })
233
+ return new Response(null, { status: 302, headers: { Location: '/dashboard?verified=1' } })
234
+ }
235
+
236
+ /**
237
+ * POST /api/auth/verify/resend — re-send the verification email (auth required).
238
+ */
239
+ export async function handleResendVerification(request: Request): Promise<Response> {
240
+ const session = await getSessionFromRequest(request)
241
+ if (!session) return errorResponse('Not authenticated', 401)
242
+ const user = await getUserById(session.userId)
243
+ if (!user) return errorResponse('Not authenticated', 401)
244
+ if (user.emailVerified) return jsonResponse({ ok: true, alreadyVerified: true })
245
+
246
+ await sendVerificationEmail(user.userId, user.email)
247
+ return jsonResponse({ ok: true })
248
+ }
249
+
250
+ /**
251
+ * POST /api/auth/forgot — email a password-reset link. Always 200 (never
252
+ * reveals whether an account exists).
253
+ */
254
+ export async function handleForgotPassword(request: Request): Promise<Response> {
255
+ let body: { email?: string }
256
+ try {
257
+ body = await request.json() as typeof body
258
+ }
259
+ catch {
260
+ return errorResponse('Invalid request body', 400)
261
+ }
262
+
263
+ const email = (body.email || '').trim().toLowerCase()
264
+ if (email && EMAIL_RE.test(email)) {
265
+ const user = await getUserByEmail(email)
266
+ if (user) {
267
+ const token = await createToken('RESET', user.userId, 3600)
268
+ const link = `${appBaseUrl()}/reset?token=${token}`
269
+ await sendEmail({
270
+ to: email,
271
+ subject: 'Reset your password',
272
+ text: `Reset your password here:\n\n${link}\n\nThis link expires in 1 hour. If you didn't request it, you can safely ignore this email.`,
273
+ })
274
+ }
275
+ }
276
+
277
+ return jsonResponse({ ok: true })
278
+ }
279
+
280
+ /**
281
+ * POST /api/auth/reset — set a new password from a reset token, then sign out
282
+ * every existing session.
283
+ */
284
+ export async function handleResetPassword(request: Request): Promise<Response> {
285
+ let body: { token?: string; password?: string }
286
+ try {
287
+ body = await request.json() as typeof body
288
+ }
289
+ catch {
290
+ return errorResponse('Invalid request body', 400)
291
+ }
292
+
293
+ const token = body.token || ''
294
+ const password = body.password || ''
295
+ if (!token) return errorResponse('Missing token', 400)
296
+ if (password.length < 8) return errorResponse('Password must be at least 8 characters', 400)
297
+
298
+ const userId = await consumeToken('RESET', token)
299
+ if (!userId) return errorResponse('This reset link is invalid or has expired', 400)
300
+
301
+ const hash = await Bun.password.hash(password, 'argon2id')
302
+ await updateUserFields(userId, { passwordHash: hash })
303
+ await invalidateUserSessions(userId)
304
+ return jsonResponse({ ok: true })
305
+ }
306
+
307
+ /**
308
+ * PUT /api/auth/profile — update the current user's display name.
309
+ */
310
+ export async function handleUpdateProfile(request: Request): Promise<Response> {
311
+ const session = await getSessionFromRequest(request)
312
+ if (!session) return errorResponse('Not authenticated', 401)
313
+ let body: { name?: string }
314
+ try {
315
+ body = await request.json() as typeof body
316
+ }
317
+ catch {
318
+ return errorResponse('Invalid request body', 400)
319
+ }
320
+ await updateUserFields(session.userId, { name: (body.name || '').trim() })
321
+ const user = await getUserById(session.userId)
322
+ return jsonResponse({ user: publicUser(user) })
323
+ }
324
+
325
+ /**
326
+ * POST /api/auth/password — change password (requires current password); signs
327
+ * out other sessions but keeps the caller logged in on this device.
328
+ */
329
+ export async function handleChangePassword(request: Request): Promise<Response> {
330
+ const session = await getSessionFromRequest(request)
331
+ if (!session) return errorResponse('Not authenticated', 401)
332
+ let body: { currentPassword?: string; newPassword?: string }
333
+ try {
334
+ body = await request.json() as typeof body
335
+ }
336
+ catch {
337
+ return errorResponse('Invalid request body', 400)
338
+ }
339
+ const next = body.newPassword || ''
340
+ if (next.length < 8) return errorResponse('New password must be at least 8 characters', 400)
341
+
342
+ const user = await getUserById(session.userId)
343
+ if (!user) return errorResponse('Not authenticated', 401)
344
+ const ok = await Bun.password.verify(body.currentPassword || '', user.passwordHash)
345
+ if (!ok) return errorResponse('Current password is incorrect', 400)
346
+
347
+ const hash = await Bun.password.hash(next, 'argon2id')
348
+ await updateUserFields(session.userId, { passwordHash: hash })
349
+ await invalidateUserSessions(session.userId)
350
+ const token = await createSession(user.userId, user.email)
351
+ return jsonResponse({ ok: true }, 200, { 'Set-Cookie': sessionCookie(token) })
352
+ }
353
+
354
+ /**
355
+ * POST /api/auth/email — change email (requires password); resets verification
356
+ * and re-sends the verification link.
357
+ */
358
+ export async function handleChangeEmail(request: Request): Promise<Response> {
359
+ const session = await getSessionFromRequest(request)
360
+ if (!session) return errorResponse('Not authenticated', 401)
361
+ let body: { email?: string; password?: string }
362
+ try {
363
+ body = await request.json() as typeof body
364
+ }
365
+ catch {
366
+ return errorResponse('Invalid request body', 400)
367
+ }
368
+ const email = (body.email || '').trim().toLowerCase()
369
+ if (!EMAIL_RE.test(email)) return errorResponse('Please enter a valid email address', 400)
370
+
371
+ const user = await getUserById(session.userId)
372
+ if (!user) return errorResponse('Not authenticated', 401)
373
+ const ok = await Bun.password.verify(body.password || '', user.passwordHash)
374
+ if (!ok) return errorResponse('Password is incorrect', 400)
375
+ if (email === user.email) return jsonResponse({ user: publicUser(user) })
376
+
377
+ const existing = await getUserByEmail(email)
378
+ if (existing) return errorResponse('That email is already in use', 409)
379
+
380
+ await updateUserFields(session.userId, { email, gsi1pk: `EMAIL#${email}`, emailVerified: false })
381
+ await sendVerificationEmail(session.userId, email)
382
+ const updated = await getUserById(session.userId)
383
+ return jsonResponse({ user: publicUser(updated) })
384
+ }
385
+
386
+ /**
387
+ * Look up a user by email using GSI1
388
+ */
389
+ export async function getUserByEmail(email: string): Promise<any | null> {
390
+ const result = await dynamodb.query({
391
+ TableName: TABLE_NAME,
392
+ IndexName: 'GSI1',
393
+ KeyConditionExpression: 'gsi1pk = :pk',
394
+ ExpressionAttributeValues: {
395
+ ':pk': { S: `EMAIL#${email.toLowerCase()}` },
396
+ },
397
+ }) as { Items?: any[] }
398
+
399
+ if (!result.Items || result.Items.length === 0) return null
400
+ return unmarshall(result.Items[0])
401
+ }
402
+
403
+ /**
404
+ * Look up a user by id
405
+ */
406
+ async function getUserById(userId: string): Promise<any | null> {
407
+ const result = await dynamodb.getItem({
408
+ TableName: TABLE_NAME,
409
+ Key: { pk: { S: `USER#${userId}` }, sk: { S: `USER#${userId}` } },
410
+ })
411
+ return result.Item ? unmarshall(result.Item) : null
412
+ }
413
+
414
+ /** Shape a user record for API responses (never leaks the password hash). */
415
+ function publicUser(u: any): { userId: string; email: string; name: string; emailVerified: boolean; plan: string } {
416
+ return { userId: u.userId, email: u.email, name: u.name || '', emailVerified: !!u.emailVerified, plan: u.plan || 'free' }
417
+ }
418
+
419
+ /** Patch fields on a user record. */
420
+ export async function updateUserFields(userId: string, fields: Record<string, string | boolean>): Promise<void> {
421
+ const keys = Object.keys(fields)
422
+ if (keys.length === 0) return
423
+ const names: Record<string, string> = { '#u': 'updatedAt' }
424
+ const values: Record<string, any> = marshall({ ':u': new Date().toISOString() })
425
+ const sets: string[] = ['#u = :u']
426
+ keys.forEach((k, i) => {
427
+ names[`#k${i}`] = k
428
+ Object.assign(values, marshall({ [`:v${i}`]: fields[k] }))
429
+ sets.push(`#k${i} = :v${i}`)
430
+ })
431
+ await dynamodb.updateItem({
432
+ TableName: TABLE_NAME,
433
+ Key: { pk: { S: `USER#${userId}` }, sk: { S: `USER#${userId}` } },
434
+ UpdateExpression: `SET ${sets.join(', ')}`,
435
+ ExpressionAttributeNames: names,
436
+ ExpressionAttributeValues: values,
437
+ })
438
+ }
439
+
440
+ // ---- Single-use email tokens (verify / reset) ------------------------------
441
+ type TokenKind = 'VERIFY' | 'RESET'
442
+
443
+ async function createToken(kind: TokenKind, userId: string, ttlSec: number): Promise<string> {
444
+ const token = crypto.randomUUID()
445
+ await dynamodb.putItem({
446
+ TableName: TABLE_NAME,
447
+ Item: marshall({
448
+ pk: `${kind}#${token}`,
449
+ sk: `${kind}#${token}`,
450
+ userId,
451
+ kind,
452
+ createdAt: new Date().toISOString(),
453
+ ttl: Math.floor(Date.now() / 1000) + ttlSec,
454
+ }),
455
+ })
456
+ return token
457
+ }
458
+
459
+ /** Validate + consume a single-use token, returning its userId (or null). */
460
+ async function consumeToken(kind: TokenKind, token: string): Promise<string | null> {
461
+ const r = await dynamodb.getItem({
462
+ TableName: TABLE_NAME,
463
+ Key: { pk: { S: `${kind}#${token}` }, sk: { S: `${kind}#${token}` } },
464
+ })
465
+ if (!r.Item) return null
466
+ const item = unmarshall(r.Item)
467
+ await dynamodb.deleteItem({
468
+ TableName: TABLE_NAME,
469
+ Key: { pk: { S: `${kind}#${token}` }, sk: { S: `${kind}#${token}` } },
470
+ })
471
+ if (item.ttl && item.ttl < Math.floor(Date.now() / 1000)) return null
472
+ return item.userId || null
473
+ }
474
+
475
+ async function sendVerificationEmail(userId: string, email: string): Promise<void> {
476
+ const token = await createToken('VERIFY', userId, 24 * 3600)
477
+ const link = `${appBaseUrl()}/api/auth/verify?token=${token}`
478
+ await sendEmail({
479
+ to: email,
480
+ subject: 'Verify your email',
481
+ text: `Welcome to Analytics! Confirm your email address:\n\n${link}\n\nThis link expires in 24 hours.`,
482
+ })
483
+ }
484
+
485
+ /**
486
+ * Create a session in DynamoDB with TTL
487
+ */
488
+ export async function createSession(userId: string, email: string): Promise<string> {
489
+ const token = crypto.randomUUID()
490
+ const now = new Date()
491
+ const ttl = Math.floor(now.getTime() / 1000) + (SESSION_TTL_DAYS * 86400)
492
+
493
+ await dynamodb.putItem({
494
+ TableName: TABLE_NAME,
495
+ Item: marshall({
496
+ pk: `SESSION#${token}`,
497
+ sk: `SESSION#${token}`,
498
+ gsi1pk: `USER_SESSIONS#${userId}`,
499
+ gsi1sk: `SESSION#${token}`,
500
+ userId,
501
+ email,
502
+ createdAt: now.toISOString(),
503
+ ttl,
504
+ }),
505
+ })
506
+
507
+ return token
508
+ }
509
+
510
+ /** Delete every session for a user (password reset, log-out-everywhere). */
511
+ export async function invalidateUserSessions(userId: string): Promise<void> {
512
+ const r = await dynamodb.query({
513
+ TableName: TABLE_NAME,
514
+ IndexName: 'GSI1',
515
+ KeyConditionExpression: 'gsi1pk = :pk',
516
+ ExpressionAttributeValues: { ':pk': { S: `USER_SESSIONS#${userId}` } },
517
+ }) as { Items?: any[] }
518
+ for (const raw of (r.Items || [])) {
519
+ const s = unmarshall(raw)
520
+ await dynamodb.deleteItem({ TableName: TABLE_NAME, Key: { pk: { S: s.pk }, sk: { S: s.sk } } })
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Get session data from DynamoDB
526
+ */
527
+ async function getSession(token: string): Promise<{ userId: string; email: string } | null> {
528
+ const result = await dynamodb.getItem({
529
+ TableName: TABLE_NAME,
530
+ Key: {
531
+ pk: { S: `SESSION#${token}` },
532
+ sk: { S: `SESSION#${token}` },
533
+ },
534
+ })
535
+
536
+ if (!result.Item) return null
537
+
538
+ const session = unmarshall(result.Item)
539
+
540
+ // Check TTL manually (DynamoDB TTL can be delayed)
541
+ const now = Math.floor(Date.now() / 1000)
542
+ if (session.ttl && session.ttl < now) {
543
+ await deleteSession(token)
544
+ return null
545
+ }
546
+
547
+ // Sliding refresh: once past the halfway mark, extend the TTL. Writes at most
548
+ // once per ~half-window per session, so it's effectively free.
549
+ const fullTtl = SESSION_TTL_DAYS * 86400
550
+ if (session.ttl && session.ttl - now < fullTtl / 2) {
551
+ try {
552
+ await dynamodb.updateItem({
553
+ TableName: TABLE_NAME,
554
+ Key: { pk: { S: `SESSION#${token}` }, sk: { S: `SESSION#${token}` } },
555
+ UpdateExpression: 'SET #t = :ttl',
556
+ ExpressionAttributeNames: { '#t': 'ttl' },
557
+ ExpressionAttributeValues: { ':ttl': { N: String(now + fullTtl) } },
558
+ })
559
+ }
560
+ catch {
561
+ // best-effort
562
+ }
563
+ }
564
+
565
+ return { userId: session.userId, email: session.email }
566
+ }
567
+
568
+ /**
569
+ * Delete a session from DynamoDB
570
+ */
571
+ async function deleteSession(token: string): Promise<void> {
572
+ await dynamodb.deleteItem({
573
+ TableName: TABLE_NAME,
574
+ Key: {
575
+ pk: { S: `SESSION#${token}` },
576
+ sk: { S: `SESSION#${token}` },
577
+ },
578
+ })
579
+ }
580
+
581
+ // ---- Login throttling (brute-force protection) -----------------------------
582
+ const MAX_LOGIN_ATTEMPTS = 5
583
+ const LOGIN_WINDOW_SEC = 15 * 60
584
+
585
+ function failKey(email: string): { pk: { S: string }; sk: { S: string } } {
586
+ return { pk: { S: `LOGINFAIL#${email}` }, sk: { S: `LOGINFAIL#${email}` } }
587
+ }
588
+
589
+ /** Failed-login count within the active window (0 if none/expired). */
590
+ export async function getLoginFailures(email: string): Promise<number> {
591
+ const r = await dynamodb.getItem({ TableName: TABLE_NAME, Key: failKey(email) })
592
+ if (!r.Item) return 0
593
+ const item = unmarshall(r.Item)
594
+ const now = Math.floor(Date.now() / 1000)
595
+ if (!item.windowStart || now - item.windowStart >= LOGIN_WINDOW_SEC) return 0
596
+ return item.count || 0
597
+ }
598
+
599
+ async function recordLoginFailure(email: string): Promise<void> {
600
+ const r = await dynamodb.getItem({ TableName: TABLE_NAME, Key: failKey(email) })
601
+ const now = Math.floor(Date.now() / 1000)
602
+ let count = 1
603
+ let windowStart = now
604
+ if (r.Item) {
605
+ const item = unmarshall(r.Item)
606
+ if (item.windowStart && now - item.windowStart < LOGIN_WINDOW_SEC) {
607
+ count = (item.count || 0) + 1
608
+ windowStart = item.windowStart
609
+ }
610
+ }
611
+ await dynamodb.putItem({
612
+ TableName: TABLE_NAME,
613
+ Item: marshall({ pk: `LOGINFAIL#${email}`, sk: `LOGINFAIL#${email}`, count, windowStart, ttl: windowStart + LOGIN_WINDOW_SEC }),
614
+ })
615
+ }
616
+
617
+ async function clearLoginFailures(email: string): Promise<void> {
618
+ try {
619
+ await dynamodb.deleteItem({ TableName: TABLE_NAME, Key: failKey(email) })
620
+ }
621
+ catch {
622
+ // best-effort
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Parse the session cookie from a request
628
+ */
629
+ function getSessionToken(request: Request): string | null {
630
+ const cookie = request.headers.get('cookie')
631
+ if (!cookie) return null
632
+
633
+ const match = cookie.match(/(?:^|;\s*)session=([^;]+)/)
634
+ return match ? match[1] : null
635
+ }
636
+
637
+ /**
638
+ * Get authenticated user from request, or null
639
+ */
640
+ export async function getSessionFromRequest(request: Request): Promise<{ userId: string; email: string } | null> {
641
+ const token = getSessionToken(request)
642
+ if (!token) return null
643
+ return getSession(token)
644
+ }
645
+
646
+ /**
647
+ * Auth guard — returns user data or a redirect Response
648
+ */
649
+ export async function requireAuth(request: Request): Promise<{ userId: string; email: string } | Response> {
650
+ const session = await getSessionFromRequest(request)
651
+ if (!session) {
652
+ return new Response(null, {
653
+ status: 302,
654
+ headers: { Location: '/login' },
655
+ })
656
+ }
657
+ return session
658
+ }
659
+
660
+ /**
661
+ * GET /login — render login page
662
+ */
663
+ export async function handleLoginPage(request: Request): Promise<Response> {
664
+ // If already authenticated, redirect to dashboard
665
+ const session = await getSessionFromRequest(request)
666
+ if (session) {
667
+ return new Response(null, {
668
+ status: 302,
669
+ headers: { Location: '/dashboard' },
670
+ })
671
+ }
672
+
673
+ const query = getQueryParams(request)
674
+ const error = query.error || ''
675
+
676
+ // Try pre-built view first, then STX fallback
677
+ const prebuiltPath = path.resolve(process.cwd(), 'dist/views/login.html')
678
+ if (fs.existsSync(prebuiltPath)) {
679
+ let html = await Bun.file(prebuiltPath).text()
680
+ html = html.replace('{{__ERROR__}}', error)
681
+ return htmlResponse(html)
682
+ }
683
+
684
+ // STX fallback for dev
685
+ try {
686
+ const { processDirectives, extractVariables, defaultConfig } = await import('@stacksjs/stx')
687
+ const viewsDir = path.resolve(import.meta.dir, '../views')
688
+ const templatePath = path.join(viewsDir, 'login.stx')
689
+ const content = await Bun.file(templatePath).text()
690
+
691
+ const scriptMatch = content.match(/<script\s+server\s*>([\s\S]*?)<\/script>/i)
692
+ const scriptContent = scriptMatch ? scriptMatch[1] : ''
693
+ let templateContent = scriptMatch
694
+ ? content.replace(/<script\s+server\s*>[\s\S]*?<\/script>/i, '')
695
+ : content
696
+
697
+ templateContent = templateContent.replace(/<script\s+client\s*>/gi, '<script>')
698
+
699
+ const context: Record<string, unknown> = {
700
+ __filename: templatePath,
701
+ __dirname: path.dirname(templatePath),
702
+ props: { error },
703
+ error,
704
+ }
705
+
706
+ if (scriptContent) {
707
+ await extractVariables(scriptContent, context, templatePath)
708
+ }
709
+
710
+ const config = {
711
+ ...defaultConfig,
712
+ componentsDir: path.resolve(import.meta.dir, '../components'),
713
+ layoutsDir: path.join(viewsDir, 'layouts'),
714
+ partialsDir: path.join(viewsDir, 'partials'),
715
+ }
716
+
717
+ const html = await processDirectives(templateContent, context, templatePath, config, new Set())
718
+ return htmlResponse(html)
719
+ }
720
+ catch (e) {
721
+ console.error('[auth] Failed to render login page:', e)
722
+ return htmlResponse(loginFallbackHtml(error))
723
+ }
724
+ }
725
+
726
+ /**
727
+ * Fallback HTML if STX rendering fails
728
+ */
729
+ function loginFallbackHtml(error: string): string {
730
+ return `<!DOCTYPE html>
731
+ <html lang="en" data-theme="dark">
732
+ <head>
733
+ <meta charset="UTF-8">
734
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
735
+ <title>Login — Analytics</title>
736
+ <style>
737
+ :root, [data-theme="dark"] {
738
+ --bg: #0f1117; --bg2: #1a1d27; --bg3: #252830;
739
+ --text: #e4e6eb; --text2: #8b8fa3; --border: #2d3040;
740
+ --accent: #6366f1; --accent-hover: #5558e6;
741
+ }
742
+ * { margin: 0; padding: 0; box-sizing: border-box; }
743
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; align-items: center; justify-content: center; }
744
+ .login-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; padding: 2.5rem; width: 100%; max-width: 400px; }
745
+ h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
746
+ .subtitle { color: var(--text2); font-size: 0.875rem; margin-bottom: 2rem; }
747
+ .error { background: #3b1219; color: #f87171; border: 1px solid #5c2130; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1.5rem; font-size: 0.875rem; }
748
+ label { display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.375rem; color: var(--text2); }
749
+ input { width: 100%; padding: 0.625rem 0.875rem; background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; color: var(--text); font-size: 0.9375rem; outline: none; margin-bottom: 1.25rem; }
750
+ input:focus { border-color: var(--accent); }
751
+ button { width: 100%; padding: 0.75rem; background: var(--accent); color: white; border: none; border-radius: 8px; font-size: 0.9375rem; font-weight: 600; cursor: pointer; }
752
+ button:hover { background: var(--accent-hover); }
753
+ </style>
754
+ </head>
755
+ <body>
756
+ <div class="login-card">
757
+ <h1>Welcome back</h1>
758
+ <p class="subtitle">Sign in to your analytics dashboard</p>
759
+ ${error ? `<div class="error">${error === 'invalid' ? 'Invalid email or password' : error === 'missing' ? 'Please fill in all fields' : 'An error occurred'}</div>` : ''}
760
+ <form method="POST" action="/login">
761
+ <label for="email">Email</label>
762
+ <input type="email" id="email" name="email" required autocomplete="email" autofocus>
763
+ <label for="password">Password</label>
764
+ <input type="password" id="password" name="password" required autocomplete="current-password">
765
+ <button type="submit">Sign in</button>
766
+ </form>
767
+ </div>
768
+ </body>
769
+ </html>`
770
+ }
771
+
772
+ /**
773
+ * POST /login — authenticate and create session
774
+ */
775
+ export async function handleLogin(request: Request): Promise<Response> {
776
+ if (crossSiteFormPost(request)) return csrfReject()
777
+ try {
778
+ const contentType = request.headers.get('content-type') || ''
779
+ let email = ''
780
+ let password = ''
781
+
782
+ if (contentType.includes('application/x-www-form-urlencoded') || contentType.includes('multipart/form-data')) {
783
+ const formData = await request.formData()
784
+ email = (formData.get('email') as string || '').trim()
785
+ password = formData.get('password') as string || ''
786
+ }
787
+ else {
788
+ const body = await request.json() as { email?: string; password?: string }
789
+ email = (body.email || '').trim()
790
+ password = body.password || ''
791
+ }
792
+
793
+ if (!email || !password) {
794
+ return new Response(null, {
795
+ status: 302,
796
+ headers: { Location: '/login?error=missing' },
797
+ })
798
+ }
799
+
800
+ if (await getLoginFailures(email) >= MAX_LOGIN_ATTEMPTS) {
801
+ return new Response(null, {
802
+ status: 302,
803
+ headers: { Location: '/login?error=throttled' },
804
+ })
805
+ }
806
+
807
+ const user = await getUserByEmail(email)
808
+ if (!user) {
809
+ await recordLoginFailure(email)
810
+ return new Response(null, {
811
+ status: 302,
812
+ headers: { Location: '/login?error=invalid' },
813
+ })
814
+ }
815
+
816
+ const valid = await Bun.password.verify(password, user.passwordHash)
817
+ if (!valid) {
818
+ await recordLoginFailure(email)
819
+ return new Response(null, {
820
+ status: 302,
821
+ headers: { Location: '/login?error=invalid' },
822
+ })
823
+ }
824
+
825
+ await clearLoginFailures(email)
826
+ const token = await createSession(user.userId, user.email)
827
+ const maxAge = SESSION_TTL_DAYS * 86400
828
+
829
+ return new Response(null, {
830
+ status: 302,
831
+ headers: {
832
+ Location: '/dashboard',
833
+ 'Set-Cookie': `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}`,
834
+ },
835
+ })
836
+ }
837
+ catch (error) {
838
+ console.error('[auth] Login error:', error)
839
+ return new Response(null, {
840
+ status: 302,
841
+ headers: { Location: '/login?error=server' },
842
+ })
843
+ }
844
+ }
845
+
846
+ /**
847
+ * POST /logout — destroy session and clear cookie
848
+ */
849
+ export async function handleLogout(request: Request): Promise<Response> {
850
+ // Forced-logout CSRF guard (#2): don't clear the cookie on a cross-site POST.
851
+ if (crossSiteFormPost(request)) return new Response(null, { status: 302, headers: { Location: '/dashboard' } })
852
+ const token = getSessionToken(request)
853
+ if (token) {
854
+ try {
855
+ await deleteSession(token)
856
+ }
857
+ catch (e) {
858
+ console.error('[auth] Logout error:', e)
859
+ }
860
+ }
861
+
862
+ return new Response(null, {
863
+ status: 302,
864
+ headers: {
865
+ Location: '/login',
866
+ 'Set-Cookie': `${SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`,
867
+ },
868
+ })
869
+ }
870
+
871
+ /**
872
+ * Assign unowned sites to the default admin user.
873
+ * The user account is created out-of-band (not in source).
874
+ */
875
+ export async function assignUnownedSites(): Promise<void> {
876
+ const adminEmail = process.env.ADMIN_EMAIL
877
+ if (!adminEmail) return
878
+
879
+ try {
880
+ const admin = await getUserByEmail(adminEmail)
881
+ if (!admin) {
882
+ console.log(`[auth] Admin user ${adminEmail} not found, skipping site assignment`)
883
+ return
884
+ }
885
+
886
+ const userId = admin.userId
887
+
888
+ // Assign any unowned sites to admin
889
+ const sitesResult = await dynamodb.query({
890
+ TableName: TABLE_NAME,
891
+ KeyConditionExpression: 'pk = :pk',
892
+ ExpressionAttributeValues: {
893
+ ':pk': { S: 'SITES' },
894
+ },
895
+ }) as { Items?: any[] }
896
+
897
+ for (const raw of (sitesResult.Items || [])) {
898
+ const site = unmarshall(raw)
899
+ if (!site.ownerId) {
900
+ await dynamodb.updateItem({
901
+ TableName: TABLE_NAME,
902
+ Key: {
903
+ pk: { S: 'SITES' },
904
+ sk: { S: site.sk },
905
+ },
906
+ UpdateExpression: 'SET ownerId = :ownerId, gsi1pk = :gsi1pk, gsi1sk = :gsi1sk',
907
+ ExpressionAttributeValues: {
908
+ ':ownerId': { S: userId },
909
+ ':gsi1pk': { S: `OWNER#${userId}` },
910
+ ':gsi1sk': { S: site.sk },
911
+ },
912
+ })
913
+ // Also grant the membership the authz guard checks (#114) — ownerId
914
+ // alone left the admin able to see but not open backfilled sites.
915
+ await addMembership(userId, site.siteId || site.id, 'owner').catch(() => {})
916
+ console.log(`[auth] Assigned site ${site.siteId || site.id} to ${adminEmail}`)
917
+ }
918
+ }
919
+ }
920
+ catch (error) {
921
+ console.error('[auth] Site assignment error:', error)
922
+ }
923
+ }
924
+
925
+ // ── Account-first form pages (#113) ─────────────────────────────────────────
926
+
927
+ /**
928
+ * POST /signup — HTML form variant of handleSignup: create the account, start
929
+ * a session, land in the (empty) project hub. Errors redirect back with a code.
930
+ */
931
+ export async function handleSignupForm(request: Request): Promise<Response> {
932
+ const back = (code: string): Response =>
933
+ new Response(null, { status: 302, headers: { Location: `/signup?error=${code}` } })
934
+ if (crossSiteFormPost(request)) return back('csrf')
935
+ try {
936
+ const formData = await request.formData()
937
+ const name = (formData.get('name') as string || '').trim()
938
+ const email = (formData.get('email') as string || '').trim().toLowerCase()
939
+ const password = formData.get('password') as string || ''
940
+
941
+ const invalid = validateCredentials(email, password)
942
+ if (invalid) return back(invalid.includes('valid email') ? 'email' : invalid.includes('8 characters') ? 'password' : 'missing')
943
+ if (await getUserByEmail(email)) return back('exists')
944
+
945
+ const user = await createUser(email, password, name)
946
+ await acceptInvites(user.userId, user.email)
947
+ await sendVerificationEmail(user.userId, user.email)
948
+ const token = await createSession(user.userId, user.email)
949
+
950
+ return new Response(null, {
951
+ status: 302,
952
+ headers: { Location: '/dashboard', 'Set-Cookie': sessionCookie(token) },
953
+ })
954
+ }
955
+ catch (error) {
956
+ console.error('Signup form error:', error)
957
+ return back('server')
958
+ }
959
+ }
960
+
961
+ /**
962
+ * POST /forgot — HTML form variant of handleForgotPassword. Always redirects
963
+ * to the sent state (never reveals whether the account exists).
964
+ */
965
+ export async function handleForgotForm(request: Request): Promise<Response> {
966
+ if (crossSiteFormPost(request)) return csrfReject()
967
+ try {
968
+ const formData = await request.formData()
969
+ const email = (formData.get('email') as string || '').trim().toLowerCase()
970
+ if (email && EMAIL_RE.test(email)) {
971
+ const user = await getUserByEmail(email)
972
+ if (user) {
973
+ const token = await createToken('RESET', user.userId, 3600)
974
+ const link = `${appBaseUrl()}/reset?token=${token}`
975
+ await sendEmail({
976
+ to: email,
977
+ subject: 'Reset your password',
978
+ text: `Reset your password by visiting: ${link}\n\nThis link expires in 1 hour. If you didn't request this, you can ignore this email.`,
979
+ })
980
+ }
981
+ }
982
+ }
983
+ catch (error) {
984
+ console.error('Forgot form error:', error)
985
+ }
986
+ return new Response(null, { status: 302, headers: { Location: '/forgot?sent=1' } })
987
+ }
988
+
989
+ /**
990
+ * POST /reset — HTML form variant of handleResetPassword: consume the
991
+ * single-use token, set the new password, revoke existing sessions.
992
+ */
993
+ export async function handleResetForm(request: Request): Promise<Response> {
994
+ if (crossSiteFormPost(request)) return csrfReject()
995
+ try {
996
+ const formData = await request.formData()
997
+ const token = formData.get('token') as string || ''
998
+ const password = formData.get('password') as string || ''
999
+
1000
+ if (!token) return new Response(null, { status: 302, headers: { Location: '/forgot' } })
1001
+ if (!password || password.length < 8) {
1002
+ return new Response(null, { status: 302, headers: { Location: `/reset?token=${encodeURIComponent(token)}&error=password` } })
1003
+ }
1004
+
1005
+ const userId = await consumeToken('RESET', token)
1006
+ if (!userId) {
1007
+ return new Response(null, { status: 302, headers: { Location: '/forgot?error=expired' } })
1008
+ }
1009
+
1010
+ const hash = await Bun.password.hash(password, 'argon2id')
1011
+ await updateUserFields(userId, { passwordHash: hash })
1012
+ await invalidateUserSessions(userId)
1013
+
1014
+ return new Response(null, { status: 302, headers: { Location: '/login?reset=1' } })
1015
+ }
1016
+ catch (error) {
1017
+ console.error('Reset form error:', error)
1018
+ return new Response(null, { status: 302, headers: { Location: '/forgot?error=server' } })
1019
+ }
1020
+ }