@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.
- package/CHANGELOG.md +68 -0
- package/LICENSE.md +21 -0
- package/README.md +361 -0
- package/bin/cli.ts +169 -0
- package/dist/Analytics.d.ts +558 -0
- package/dist/api.d.ts +109 -0
- package/dist/batching.d.ts +93 -0
- package/dist/chunk-2mx7fq49.js +4 -0
- package/dist/chunk-3z29508k.js +204 -0
- package/dist/chunk-deephkz6.js +56 -0
- package/dist/chunk-j261vgyp.js +305 -0
- package/dist/chunk-xga17tz7.js +207 -0
- package/dist/config.d.ts +132 -0
- package/dist/dashboard/components/index.d.ts +80 -0
- package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
- package/dist/dashboard/index.d.ts +110 -0
- package/dist/dashboard/types/index.d.ts +144 -0
- package/dist/dashboard/utils/index.d.ts +81 -0
- package/dist/dynamodb.d.ts +86 -0
- package/dist/funnels.d.ts +104 -0
- package/dist/geolocation.d.ts +96 -0
- package/dist/index.d.ts +371 -0
- package/dist/index.js +10211 -0
- package/dist/infrastructure/cdk.d.ts +60 -0
- package/dist/infrastructure/cloudformation.d.ts +44 -0
- package/dist/infrastructure/index.d.ts +45 -0
- package/dist/infrastructure/setup.d.ts +133 -0
- package/dist/integrations/cloudflare.d.ts +76 -0
- package/dist/integrations/hono.d.ts +60 -0
- package/dist/integrations/index.d.ts +24 -0
- package/dist/integrations/nuxt.d.ts +7 -0
- package/dist/integrations/nuxt.js +42 -0
- package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
- package/dist/integrations/runtime/use-ts-analytics.js +17 -0
- package/dist/integrations/stx.d.ts +88 -0
- package/dist/integrations/stx.js +18 -0
- package/dist/lib/crypto-random.d.ts +4 -0
- package/dist/lib/salt.d.ts +16 -0
- package/dist/local.d.ts +56 -0
- package/dist/model-connector.d.ts +145 -0
- package/dist/models/AggregatedStats.d.ts +9 -0
- package/dist/models/CampaignStats.d.ts +9 -0
- package/dist/models/Conversion.d.ts +11 -0
- package/dist/models/CustomEvent.d.ts +11 -0
- package/dist/models/DeviceStats.d.ts +9 -0
- package/dist/models/EventStats.d.ts +9 -0
- package/dist/models/GeoStats.d.ts +9 -0
- package/dist/models/Goal.d.ts +9 -0
- package/dist/models/GoalStats.d.ts +9 -0
- package/dist/models/PageStats.d.ts +11 -0
- package/dist/models/PageView.d.ts +13 -0
- package/dist/models/RealtimeStats.d.ts +9 -0
- package/dist/models/ReferrerStats.d.ts +9 -0
- package/dist/models/Session.d.ts +11 -0
- package/dist/models/Site.d.ts +11 -0
- package/dist/models/index.d.ts +28 -0
- package/dist/models/types.d.ts +60 -0
- package/dist/sqs-buffering.d.ts +243 -0
- package/dist/stacks-integration.d.ts +159 -0
- package/dist/tracking-script.d.ts +71 -0
- package/dist/tracking.d.ts +17 -0
- package/dist/tracking.js +26 -0
- package/dist/types.d.ts +595 -0
- package/dist/utils/geolocation.d.ts +111 -0
- package/dist/utils/user-agent.d.ts +17 -0
- package/dist/version.d.ts +7 -0
- package/package.json +119 -0
- package/src/Analytics.ts +3349 -0
- package/src/api.ts +1286 -0
- package/src/assets/crosswind.css +2220 -0
- package/src/batching.ts +452 -0
- package/src/components/dashboard/index.ts +18 -0
- package/src/config.ts +456 -0
- package/src/dashboard/Dashboard.stx +1517 -0
- package/src/dashboard/components/AlertCard.stx +177 -0
- package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
- package/src/dashboard/components/AnimatedNumber.stx +86 -0
- package/src/dashboard/components/BarChart.stx +220 -0
- package/src/dashboard/components/BrowserBreakdown.stx +98 -0
- package/src/dashboard/components/BrowsersTable.stx +125 -0
- package/src/dashboard/components/CampaignBreakdown.stx +163 -0
- package/src/dashboard/components/CampaignTable.stx +238 -0
- package/src/dashboard/components/CountryList.stx +101 -0
- package/src/dashboard/components/DataTable.stx +226 -0
- package/src/dashboard/components/DateRangePicker.stx +77 -0
- package/src/dashboard/components/DeviceBreakdown.stx +94 -0
- package/src/dashboard/components/DevicesTable.stx +163 -0
- package/src/dashboard/components/DonutChart.stories.ts +55 -0
- package/src/dashboard/components/DonutChart.stx +157 -0
- package/src/dashboard/components/EmptyState.stx +90 -0
- package/src/dashboard/components/EngagementMetrics.stx +183 -0
- package/src/dashboard/components/EventsSection.stx +192 -0
- package/src/dashboard/components/FilterBar.stx +104 -0
- package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
- package/src/dashboard/components/FunnelChart.stx +142 -0
- package/src/dashboard/components/GeoTable.stx +337 -0
- package/src/dashboard/components/GoalsPanel.stx +109 -0
- package/src/dashboard/components/Header.stx +39 -0
- package/src/dashboard/components/HeatmapChart.stx +140 -0
- package/src/dashboard/components/LiveActivityFeed.stx +172 -0
- package/src/dashboard/components/MetricComparison.stx +106 -0
- package/src/dashboard/components/MiniStats.stx +96 -0
- package/src/dashboard/components/OSBreakdown.stx +124 -0
- package/src/dashboard/components/PageDetailCard.stx +102 -0
- package/src/dashboard/components/PagesTable.stx +127 -0
- package/src/dashboard/components/ProgressRing.stx +89 -0
- package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
- package/src/dashboard/components/RealtimeCounter.stx +46 -0
- package/src/dashboard/components/ReferrersTable.stx +180 -0
- package/src/dashboard/components/SparklineChart.stx +160 -0
- package/src/dashboard/components/StatCard.stories.ts +58 -0
- package/src/dashboard/components/StatCard.stx +90 -0
- package/src/dashboard/components/SummaryStats.stx +81 -0
- package/src/dashboard/components/TabNav.stx +66 -0
- package/src/dashboard/components/ThemeSwitcher.stx +124 -0
- package/src/dashboard/components/TimeSeriesChart.stx +106 -0
- package/src/dashboard/components/TopList.stories.ts +58 -0
- package/src/dashboard/components/TopList.stx +74 -0
- package/src/dashboard/components/TrendIndicator.stx +84 -0
- package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
- package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
- package/src/dashboard/components/heatmap/index.ts +12 -0
- package/src/dashboard/components/index.ts +86 -0
- package/src/dashboard/composables/useAnalytics.ts +465 -0
- package/src/dashboard/demo/DemoApp.stx +370 -0
- package/src/dashboard/demo/index.ts +8 -0
- package/src/dashboard/demo/mockData.ts +234 -0
- package/src/dashboard/index.ts +117 -0
- package/src/dashboard/stx-shim.d.ts +4 -0
- package/src/dashboard/types/index.ts +203 -0
- package/src/dashboard/utils/index.ts +426 -0
- package/src/dynamodb.ts +344 -0
- package/src/funnels.ts +534 -0
- package/src/geolocation.ts +515 -0
- package/src/handlers/alerts.ts +328 -0
- package/src/handlers/annotations.ts +128 -0
- package/src/handlers/api-keys.ts +240 -0
- package/src/handlers/auth.ts +1020 -0
- package/src/handlers/authz.ts +137 -0
- package/src/handlers/collect.ts +724 -0
- package/src/handlers/data.ts +625 -0
- package/src/handlers/experiments.ts +138 -0
- package/src/handlers/funnels.ts +216 -0
- package/src/handlers/goals.ts +218 -0
- package/src/handlers/heatmaps.ts +272 -0
- package/src/handlers/index.ts +56 -0
- package/src/handlers/lib/read-cache.ts +63 -0
- package/src/handlers/misc.ts +486 -0
- package/src/handlers/oauth.ts +233 -0
- package/src/handlers/performance.ts +388 -0
- package/src/handlers/sessions.ts +413 -0
- package/src/handlers/sharing.ts +198 -0
- package/src/handlers/stats.ts +1368 -0
- package/src/handlers/team.ts +161 -0
- package/src/handlers/uptime.ts +283 -0
- package/src/handlers/views.ts +390 -0
- package/src/handlers/webhooks.ts +226 -0
- package/src/heatmap/index.ts +32 -0
- package/src/heatmap/tracking-script.ts +452 -0
- package/src/heatmap/types.ts +79 -0
- package/src/index.ts +387 -0
- package/src/infrastructure/cdk.ts +496 -0
- package/src/infrastructure/cloudformation.ts +595 -0
- package/src/infrastructure/index.ts +48 -0
- package/src/infrastructure/setup.ts +611 -0
- package/src/integrations/cloudflare.ts +732 -0
- package/src/integrations/hono.ts +589 -0
- package/src/integrations/index.ts +27 -0
- package/src/integrations/nuxt.ts +78 -0
- package/src/integrations/runtime/use-ts-analytics.ts +32 -0
- package/src/integrations/stx.ts +138 -0
- package/src/jobs/index.ts +127 -0
- package/src/lib/crypto-random.ts +41 -0
- package/src/lib/ddb-errors.ts +20 -0
- package/src/lib/dynamodb.ts +216 -0
- package/src/lib/email.ts +38 -0
- package/src/lib/ga-import.ts +471 -0
- package/src/lib/ga4-api.ts +244 -0
- package/src/lib/goals.ts +205 -0
- package/src/lib/index.ts +6 -0
- package/src/lib/ingest-counters.ts +123 -0
- package/src/lib/log.ts +36 -0
- package/src/lib/membership.ts +98 -0
- package/src/lib/plans.ts +90 -0
- package/src/lib/quota.ts +46 -0
- package/src/lib/rate-limit.ts +27 -0
- package/src/lib/rollups.ts +472 -0
- package/src/lib/salt.ts +97 -0
- package/src/lib/scheduler.ts +97 -0
- package/src/lib/significance.ts +66 -0
- package/src/lib/site-retention.ts +56 -0
- package/src/local.ts +360 -0
- package/src/model-connector.ts +616 -0
- package/src/models/AggregatedStats.ts +162 -0
- package/src/models/CampaignStats.ts +141 -0
- package/src/models/Conversion.ts +135 -0
- package/src/models/CustomEvent.ts +123 -0
- package/src/models/DeviceStats.ts +105 -0
- package/src/models/EventStats.ts +108 -0
- package/src/models/GeoStats.ts +112 -0
- package/src/models/Goal.ts +105 -0
- package/src/models/GoalStats.ts +106 -0
- package/src/models/PageStats.ts +152 -0
- package/src/models/PageView.ts +277 -0
- package/src/models/RealtimeStats.ts +96 -0
- package/src/models/ReferrerStats.ts +115 -0
- package/src/models/Session.ts +235 -0
- package/src/models/Site.ts +115 -0
- package/src/models/index.ts +50 -0
- package/src/models/orm/index.ts +2043 -0
- package/src/models/types.ts +71 -0
- package/src/router.ts +521 -0
- package/src/sqs-buffering.ts +806 -0
- package/src/stacks-integration.ts +527 -0
- package/src/tracking-script.ts +944 -0
- package/src/tracking.ts +27 -0
- package/src/types/analytics.ts +219 -0
- package/src/types/api.ts +75 -0
- package/src/types/bun-router.d.ts +15 -0
- package/src/types/dashboard.ts +62 -0
- package/src/types/index.ts +66 -0
- package/src/types/stx.d.ts +27 -0
- package/src/types/window.d.ts +101 -0
- package/src/types.ts +911 -0
- package/src/utils/cache.ts +148 -0
- package/src/utils/date.ts +118 -0
- package/src/utils/filters.ts +71 -0
- package/src/utils/geolocation.ts +323 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/response.ts +180 -0
- package/src/utils/timezone-country.ts +242 -0
- package/src/utils/user-agent.ts +110 -0
- 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
|
+
}
|