@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,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth/SSO sign-in — Google + GitHub (#53).
|
|
3
|
+
*
|
|
4
|
+
* Standard authorization-code flow, linking to the existing USER# accounts by
|
|
5
|
+
* verified email (created on first login). Email/password stays primary.
|
|
6
|
+
*
|
|
7
|
+
* Configuration (per provider; the start endpoint 503s when unset):
|
|
8
|
+
* GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
|
|
9
|
+
* GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET
|
|
10
|
+
* The provider app's callback URL must be
|
|
11
|
+
* {APP_BASE_URL}/api/auth/oauth/{provider}/callback
|
|
12
|
+
*
|
|
13
|
+
* CSRF: each start mints a single-use state record (OAUTH#{state}, 10min TTL)
|
|
14
|
+
* that the callback consumes; a mismatched or replayed state is rejected.
|
|
15
|
+
*/
|
|
16
|
+
import { dynamodb, TABLE_NAME, marshall, unmarshall } from '../lib/dynamodb'
|
|
17
|
+
import { jsonResponse, errorResponse } from '../utils/response'
|
|
18
|
+
import { getUserByEmail, createOAuthUser, createSession, sessionCookie, updateUserFields } from './auth'
|
|
19
|
+
import { acceptInvites } from '../lib/membership'
|
|
20
|
+
import { appBaseUrl } from '../lib/email'
|
|
21
|
+
|
|
22
|
+
export type OAuthProvider = 'google' | 'github'
|
|
23
|
+
|
|
24
|
+
interface ProviderConfig {
|
|
25
|
+
authorizeUrl: string
|
|
26
|
+
tokenUrl: string
|
|
27
|
+
scope: string
|
|
28
|
+
clientId: string
|
|
29
|
+
clientSecret: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Provider endpoints + credentials; null when the env credentials are unset. */
|
|
33
|
+
export function providerConfig(provider: string): ProviderConfig | null {
|
|
34
|
+
if (provider === 'google') {
|
|
35
|
+
const clientId = process.env.GOOGLE_CLIENT_ID || ''
|
|
36
|
+
const clientSecret = process.env.GOOGLE_CLIENT_SECRET || ''
|
|
37
|
+
if (!clientId || !clientSecret)
|
|
38
|
+
return null
|
|
39
|
+
return {
|
|
40
|
+
authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
41
|
+
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
42
|
+
scope: 'openid email profile',
|
|
43
|
+
clientId,
|
|
44
|
+
clientSecret,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (provider === 'github') {
|
|
48
|
+
const clientId = process.env.GITHUB_CLIENT_ID || ''
|
|
49
|
+
const clientSecret = process.env.GITHUB_CLIENT_SECRET || ''
|
|
50
|
+
if (!clientId || !clientSecret)
|
|
51
|
+
return null
|
|
52
|
+
return {
|
|
53
|
+
authorizeUrl: 'https://github.com/login/oauth/authorize',
|
|
54
|
+
tokenUrl: 'https://github.com/login/oauth/access_token',
|
|
55
|
+
scope: 'read:user user:email',
|
|
56
|
+
clientId,
|
|
57
|
+
clientSecret,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The authorize redirect URL. Pure — unit tested. */
|
|
64
|
+
export function buildAuthorizeUrl(cfg: Pick<ProviderConfig, 'authorizeUrl' | 'clientId' | 'scope'>, redirectUri: string, state: string): string {
|
|
65
|
+
const params = new URLSearchParams({
|
|
66
|
+
client_id: cfg.clientId,
|
|
67
|
+
redirect_uri: redirectUri,
|
|
68
|
+
response_type: 'code',
|
|
69
|
+
scope: cfg.scope,
|
|
70
|
+
state,
|
|
71
|
+
})
|
|
72
|
+
return `${cfg.authorizeUrl}?${params}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function redirectUriFor(provider: string): string {
|
|
76
|
+
return `${appBaseUrl()}/api/auth/oauth/${provider}/callback`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Single-use CSRF state ───────────────────────────────────────────────────
|
|
80
|
+
async function createState(provider: string): Promise<string> {
|
|
81
|
+
const state = crypto.randomUUID()
|
|
82
|
+
await dynamodb.putItem({
|
|
83
|
+
TableName: TABLE_NAME,
|
|
84
|
+
Item: marshall({
|
|
85
|
+
pk: `OAUTH#${state}`,
|
|
86
|
+
sk: `OAUTH#${state}`,
|
|
87
|
+
provider,
|
|
88
|
+
ttl: Math.floor(Date.now() / 1000) + 600,
|
|
89
|
+
}),
|
|
90
|
+
})
|
|
91
|
+
return state
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function consumeState(state: string): Promise<string | null> {
|
|
95
|
+
const key = { pk: { S: `OAUTH#${state}` }, sk: { S: `OAUTH#${state}` } }
|
|
96
|
+
const r = await dynamodb.getItem({ TableName: TABLE_NAME, Key: key })
|
|
97
|
+
if (!r.Item)
|
|
98
|
+
return null
|
|
99
|
+
await dynamodb.deleteItem({ TableName: TABLE_NAME, Key: key })
|
|
100
|
+
const item = unmarshall(r.Item)
|
|
101
|
+
if (item.ttl && item.ttl < Math.floor(Date.now() / 1000))
|
|
102
|
+
return null
|
|
103
|
+
return item.provider || null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Handlers ────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/** GET /api/auth/oauth/{provider} — redirect to the provider's consent page. */
|
|
109
|
+
export async function handleOAuthStart(_request: Request, provider: string): Promise<Response> {
|
|
110
|
+
const cfg = providerConfig(provider)
|
|
111
|
+
if (!cfg) {
|
|
112
|
+
return jsonResponse({ error: `${provider} sign-in is not configured on this server` }, 503)
|
|
113
|
+
}
|
|
114
|
+
const state = await createState(provider)
|
|
115
|
+
const url = buildAuthorizeUrl(cfg, redirectUriFor(provider), state)
|
|
116
|
+
return new Response(null, { status: 302, headers: { Location: url } })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface OAuthProfile {
|
|
120
|
+
email: string
|
|
121
|
+
emailVerified: boolean
|
|
122
|
+
name: string
|
|
123
|
+
providerId: string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function exchangeCode(cfg: ProviderConfig, provider: string, code: string): Promise<string | null> {
|
|
127
|
+
const res = await fetch(cfg.tokenUrl, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
|
|
130
|
+
body: new URLSearchParams({
|
|
131
|
+
client_id: cfg.clientId,
|
|
132
|
+
client_secret: cfg.clientSecret,
|
|
133
|
+
code,
|
|
134
|
+
grant_type: 'authorization_code',
|
|
135
|
+
redirect_uri: redirectUriFor(provider),
|
|
136
|
+
}),
|
|
137
|
+
})
|
|
138
|
+
if (!res.ok)
|
|
139
|
+
return null
|
|
140
|
+
const data = await res.json() as { access_token?: string }
|
|
141
|
+
return data.access_token || null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function fetchProfile(provider: string, accessToken: string): Promise<OAuthProfile | null> {
|
|
145
|
+
const auth = { Authorization: `Bearer ${accessToken}` }
|
|
146
|
+
if (provider === 'google') {
|
|
147
|
+
const res = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { headers: auth })
|
|
148
|
+
if (!res.ok)
|
|
149
|
+
return null
|
|
150
|
+
const p = await res.json() as { sub?: string, email?: string, email_verified?: boolean, name?: string }
|
|
151
|
+
if (!p.email)
|
|
152
|
+
return null
|
|
153
|
+
return { email: p.email, emailVerified: !!p.email_verified, name: p.name || '', providerId: String(p.sub || '') }
|
|
154
|
+
}
|
|
155
|
+
if (provider === 'github') {
|
|
156
|
+
const res = await fetch('https://api.github.com/user', { headers: { ...auth, 'User-Agent': 'ts-analytics' } })
|
|
157
|
+
if (!res.ok)
|
|
158
|
+
return null
|
|
159
|
+
const p = await res.json() as { id?: number, name?: string, login?: string, email?: string | null }
|
|
160
|
+
let email = p.email || ''
|
|
161
|
+
let verified = false
|
|
162
|
+
const emailsRes = await fetch('https://api.github.com/user/emails', { headers: { ...auth, 'User-Agent': 'ts-analytics' } })
|
|
163
|
+
if (emailsRes.ok) {
|
|
164
|
+
const emails = await emailsRes.json() as Array<{ email: string, primary: boolean, verified: boolean }>
|
|
165
|
+
const primary = emails.find(e => e.primary && e.verified) || emails.find(e => e.verified)
|
|
166
|
+
if (primary) {
|
|
167
|
+
email = primary.email
|
|
168
|
+
verified = true
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (!email)
|
|
172
|
+
return null
|
|
173
|
+
return { email, emailVerified: verified, name: p.name || p.login || '', providerId: String(p.id || '') }
|
|
174
|
+
}
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function loginRedirect(reason: string): Response {
|
|
179
|
+
return new Response(null, { status: 302, headers: { Location: `/login?error=${encodeURIComponent(reason)}` } })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** GET /api/auth/oauth/{provider}/callback — exchange, link/create, sign in. */
|
|
183
|
+
export async function handleOAuthCallback(request: Request, provider: string): Promise<Response> {
|
|
184
|
+
try {
|
|
185
|
+
const cfg = providerConfig(provider)
|
|
186
|
+
if (!cfg)
|
|
187
|
+
return jsonResponse({ error: `${provider} sign-in is not configured on this server` }, 503)
|
|
188
|
+
|
|
189
|
+
const url = new URL(request.url)
|
|
190
|
+
const code = url.searchParams.get('code') || ''
|
|
191
|
+
const state = url.searchParams.get('state') || ''
|
|
192
|
+
if (!code || !state)
|
|
193
|
+
return loginRedirect('oauth')
|
|
194
|
+
|
|
195
|
+
const stateProvider = await consumeState(state)
|
|
196
|
+
if (stateProvider !== provider)
|
|
197
|
+
return loginRedirect('oauth')
|
|
198
|
+
|
|
199
|
+
const accessToken = await exchangeCode(cfg, provider, code)
|
|
200
|
+
if (!accessToken)
|
|
201
|
+
return loginRedirect('oauth')
|
|
202
|
+
|
|
203
|
+
const profile = await fetchProfile(provider, accessToken)
|
|
204
|
+
if (!profile)
|
|
205
|
+
return loginRedirect('oauth')
|
|
206
|
+
|
|
207
|
+
// Only trust provider-verified emails to link to an existing account —
|
|
208
|
+
// otherwise an attacker could claim someone's address on the provider and
|
|
209
|
+
// take over their account here.
|
|
210
|
+
let user = await getUserByEmail(profile.email)
|
|
211
|
+
if (user && !profile.emailVerified)
|
|
212
|
+
return loginRedirect('oauth-unverified-email')
|
|
213
|
+
|
|
214
|
+
if (!user) {
|
|
215
|
+
const created = await createOAuthUser(profile.email, profile.name, profile.emailVerified)
|
|
216
|
+
await acceptInvites(created.userId, created.email)
|
|
217
|
+
user = { userId: created.userId, email: created.email }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Record the provider link (idempotent) so the account shows how it signs in.
|
|
221
|
+
await updateUserFields(user.userId, { [`${provider}Id`]: profile.providerId, updatedAt: new Date().toISOString() })
|
|
222
|
+
|
|
223
|
+
const token = await createSession(user.userId, user.email)
|
|
224
|
+
return new Response(null, {
|
|
225
|
+
status: 302,
|
|
226
|
+
headers: { 'Location': '/dashboard', 'Set-Cookie': sessionCookie(token) },
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
console.error('OAuth callback error:', error)
|
|
231
|
+
return errorResponse('OAuth sign-in failed')
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance & Web Vitals handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { generateId } from '../index'
|
|
6
|
+
import { dynamodb, TABLE_NAME, unmarshall, marshall } from '../lib/dynamodb'
|
|
7
|
+
import { parseDateRange } from '../utils/date'
|
|
8
|
+
import { jsonResponse, errorResponse } from '../utils/response'
|
|
9
|
+
import { getQueryParams } from '../../deploy/lambda-adapter'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GET /api/sites/{siteId}/vitals
|
|
13
|
+
*/
|
|
14
|
+
export async function handleGetVitals(request: Request, siteId: string): Promise<Response> {
|
|
15
|
+
try {
|
|
16
|
+
const query = getQueryParams(request)
|
|
17
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
18
|
+
|
|
19
|
+
const result = await dynamodb.query({
|
|
20
|
+
TableName: TABLE_NAME,
|
|
21
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
22
|
+
ExpressionAttributeValues: {
|
|
23
|
+
':pk': { S: `SITE#${siteId}` },
|
|
24
|
+
':start': { S: `VITAL#${startDate.toISOString()}` },
|
|
25
|
+
':end': { S: `VITAL#${endDate.toISOString()}` },
|
|
26
|
+
},
|
|
27
|
+
}) as { Items?: any[] }
|
|
28
|
+
|
|
29
|
+
const vitals = (result.Items || []).map(unmarshall)
|
|
30
|
+
|
|
31
|
+
// Aggregate vitals by metric
|
|
32
|
+
const metrics: Record<string, { values: number[]; paths: Record<string, number[]> }> = {
|
|
33
|
+
LCP: { values: [], paths: {} },
|
|
34
|
+
FID: { values: [], paths: {} },
|
|
35
|
+
CLS: { values: [], paths: {} },
|
|
36
|
+
FCP: { values: [], paths: {} },
|
|
37
|
+
TTFB: { values: [], paths: {} },
|
|
38
|
+
INP: { values: [], paths: {} },
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const vital of vitals) {
|
|
42
|
+
const metric = vital.metric || vital.name
|
|
43
|
+
if (metrics[metric]) {
|
|
44
|
+
// CLS travels x1000 on the wire (the tracker rounds values to ints,
|
|
45
|
+
// which would floor raw CLS to 0) — normalize back to the real
|
|
46
|
+
// unitless value at read time so percentiles/ratings are correct (#133).
|
|
47
|
+
const value = metric === 'CLS' ? (vital.value || 0) / 1000 : vital.value
|
|
48
|
+
metrics[metric].values.push(value)
|
|
49
|
+
const path = vital.path || '/'
|
|
50
|
+
if (!metrics[metric].paths[path]) {
|
|
51
|
+
metrics[metric].paths[path] = []
|
|
52
|
+
}
|
|
53
|
+
metrics[metric].paths[path].push(value)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Calculate percentiles
|
|
58
|
+
const calculatePercentile = (arr: number[], p: number) => {
|
|
59
|
+
if (arr.length === 0) return 0
|
|
60
|
+
const sorted = [...arr].sort((a, b) => a - b)
|
|
61
|
+
const idx = Math.ceil((p / 100) * sorted.length) - 1
|
|
62
|
+
return sorted[Math.max(0, idx)]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const getRating = (metric: string, value: number): 'good' | 'needs-improvement' | 'poor' => {
|
|
66
|
+
const thresholds: Record<string, [number, number]> = {
|
|
67
|
+
LCP: [2500, 4000],
|
|
68
|
+
FID: [100, 300],
|
|
69
|
+
CLS: [0.1, 0.25],
|
|
70
|
+
FCP: [1800, 3000],
|
|
71
|
+
TTFB: [800, 1800],
|
|
72
|
+
INP: [200, 500],
|
|
73
|
+
}
|
|
74
|
+
const [good, poor] = thresholds[metric] || [0, 0]
|
|
75
|
+
if (value <= good) return 'good'
|
|
76
|
+
if (value <= poor) return 'needs-improvement'
|
|
77
|
+
return 'poor'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Shape matches the Vital contract the tab binds (metric/samples/
|
|
81
|
+
// good/needsImprovement/poor, src/types/analytics.ts) — the old
|
|
82
|
+
// {name,count,rating} response left every card rendering "0 samples".
|
|
83
|
+
const summary = Object.entries(metrics).map(([name, data]) => {
|
|
84
|
+
const samples = data.values.length
|
|
85
|
+
const buckets = { 'good': 0, 'needs-improvement': 0, 'poor': 0 }
|
|
86
|
+
for (const v of data.values) buckets[getRating(name, v)]++
|
|
87
|
+
const pct = (n: number): number => samples > 0 ? Math.round((n / samples) * 100) : 0
|
|
88
|
+
return {
|
|
89
|
+
metric: name,
|
|
90
|
+
name,
|
|
91
|
+
p50: calculatePercentile(data.values, 50),
|
|
92
|
+
p75: calculatePercentile(data.values, 75),
|
|
93
|
+
p95: calculatePercentile(data.values, 95),
|
|
94
|
+
samples,
|
|
95
|
+
count: samples,
|
|
96
|
+
good: pct(buckets.good),
|
|
97
|
+
needsImprovement: pct(buckets['needs-improvement']),
|
|
98
|
+
poor: pct(buckets.poor),
|
|
99
|
+
rating: getRating(name, calculatePercentile(data.values, 75)),
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Get worst performing pages
|
|
104
|
+
const worstPages: Array<{ path: string; metric: string; p75: number; rating: string }> = []
|
|
105
|
+
for (const [metric, data] of Object.entries(metrics)) {
|
|
106
|
+
for (const [path, values] of Object.entries(data.paths)) {
|
|
107
|
+
const p75 = calculatePercentile(values, 75)
|
|
108
|
+
const rating = getRating(metric, p75)
|
|
109
|
+
if (rating === 'poor' || rating === 'needs-improvement') {
|
|
110
|
+
worstPages.push({ path, metric, p75, rating })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
worstPages.sort((a, b) => (b.rating === 'poor' ? 1 : 0) - (a.rating === 'poor' ? 1 : 0))
|
|
115
|
+
|
|
116
|
+
return jsonResponse({
|
|
117
|
+
vitals: summary,
|
|
118
|
+
worstPages: worstPages.slice(0, 10),
|
|
119
|
+
totalMeasurements: vitals.length,
|
|
120
|
+
dateRange: {
|
|
121
|
+
start: startDate.toISOString(),
|
|
122
|
+
end: endDate.toISOString(),
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
console.error('Get vitals error:', error)
|
|
128
|
+
return errorResponse('Failed to fetch vitals')
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* GET /api/sites/{siteId}/vitals-trends
|
|
134
|
+
*/
|
|
135
|
+
export async function handleGetVitalsTrends(request: Request, siteId: string): Promise<Response> {
|
|
136
|
+
try {
|
|
137
|
+
const query = getQueryParams(request)
|
|
138
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
139
|
+
const metric = query.metric || 'LCP'
|
|
140
|
+
|
|
141
|
+
const result = await dynamodb.query({
|
|
142
|
+
TableName: TABLE_NAME,
|
|
143
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
144
|
+
ExpressionAttributeValues: {
|
|
145
|
+
':pk': { S: `SITE#${siteId}` },
|
|
146
|
+
':start': { S: `VITAL#${startDate.toISOString()}` },
|
|
147
|
+
':end': { S: `VITAL#${endDate.toISOString()}` },
|
|
148
|
+
},
|
|
149
|
+
}) as { Items?: any[] }
|
|
150
|
+
|
|
151
|
+
const vitals = (result.Items || []).map(unmarshall)
|
|
152
|
+
.filter(v => (v.metric || v.name) === metric)
|
|
153
|
+
|
|
154
|
+
// Group by day
|
|
155
|
+
const dailyData: Record<string, number[]> = {}
|
|
156
|
+
for (const vital of vitals) {
|
|
157
|
+
const day = vital.timestamp.slice(0, 10)
|
|
158
|
+
if (!dailyData[day]) dailyData[day] = []
|
|
159
|
+
// CLS travels x1000 on the wire — normalize at read time (#133).
|
|
160
|
+
dailyData[day].push(metric === 'CLS' ? (vital.value || 0) / 1000 : vital.value)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const calculatePercentile = (arr: number[], p: number) => {
|
|
164
|
+
if (arr.length === 0) return 0
|
|
165
|
+
const sorted = [...arr].sort((a, b) => a - b)
|
|
166
|
+
const idx = Math.ceil((p / 100) * sorted.length) - 1
|
|
167
|
+
return sorted[Math.max(0, idx)]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const trends = Object.entries(dailyData)
|
|
171
|
+
.map(([date, values]) => ({
|
|
172
|
+
date,
|
|
173
|
+
p50: calculatePercentile(values, 50),
|
|
174
|
+
p75: calculatePercentile(values, 75),
|
|
175
|
+
p95: calculatePercentile(values, 95),
|
|
176
|
+
count: values.length,
|
|
177
|
+
}))
|
|
178
|
+
.sort((a, b) => a.date.localeCompare(b.date))
|
|
179
|
+
|
|
180
|
+
return jsonResponse({
|
|
181
|
+
metric,
|
|
182
|
+
trends,
|
|
183
|
+
dateRange: {
|
|
184
|
+
start: startDate.toISOString(),
|
|
185
|
+
end: endDate.toISOString(),
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
console.error('Get vitals trends error:', error)
|
|
191
|
+
return errorResponse('Failed to fetch vitals trends')
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* POST /api/sites/{siteId}/performance-budgets
|
|
197
|
+
*/
|
|
198
|
+
export async function handleCreatePerformanceBudget(request: Request, siteId: string): Promise<Response> {
|
|
199
|
+
try {
|
|
200
|
+
const body = await request.json() as Record<string, any>
|
|
201
|
+
|
|
202
|
+
if (!body.metric || body.threshold === undefined) {
|
|
203
|
+
return jsonResponse({ error: 'Missing required fields: metric, threshold' }, 400)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const validMetrics = ['LCP', 'FID', 'CLS', 'FCP', 'TTFB', 'INP']
|
|
207
|
+
if (!validMetrics.includes(body.metric)) {
|
|
208
|
+
return jsonResponse({ error: `Invalid metric. Must be one of: ${validMetrics.join(', ')}` }, 400)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const budgetId = generateId()
|
|
212
|
+
const budget = {
|
|
213
|
+
pk: `SITE#${siteId}`,
|
|
214
|
+
sk: `BUDGET#${budgetId}`,
|
|
215
|
+
id: budgetId,
|
|
216
|
+
siteId,
|
|
217
|
+
metric: body.metric,
|
|
218
|
+
threshold: body.threshold,
|
|
219
|
+
path: body.path || '*',
|
|
220
|
+
alertOnViolation: body.alertOnViolation ?? true,
|
|
221
|
+
createdAt: new Date().toISOString(),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await dynamodb.putItem({
|
|
225
|
+
TableName: TABLE_NAME,
|
|
226
|
+
Item: marshall(budget),
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
return jsonResponse({ budget }, 201)
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
console.error('Create performance budget error:', error)
|
|
233
|
+
return errorResponse('Failed to create performance budget')
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* GET /api/sites/{siteId}/performance-budgets
|
|
239
|
+
*/
|
|
240
|
+
export async function handleGetPerformanceBudgets(_request: Request, siteId: string): Promise<Response> {
|
|
241
|
+
try {
|
|
242
|
+
const result = await dynamodb.query({
|
|
243
|
+
TableName: TABLE_NAME,
|
|
244
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
|
|
245
|
+
ExpressionAttributeValues: {
|
|
246
|
+
':pk': { S: `SITE#${siteId}` },
|
|
247
|
+
':prefix': { S: 'BUDGET#' },
|
|
248
|
+
},
|
|
249
|
+
}) as { Items?: any[] }
|
|
250
|
+
|
|
251
|
+
const budgets = (result.Items || []).map(unmarshall)
|
|
252
|
+
|
|
253
|
+
return jsonResponse({ budgets })
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
console.error('Get performance budgets error:', error)
|
|
257
|
+
return errorResponse('Failed to fetch performance budgets')
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* DELETE /api/sites/{siteId}/performance-budgets/{budgetId}
|
|
263
|
+
*/
|
|
264
|
+
export async function handleDeletePerformanceBudget(_request: Request, siteId: string, budgetId: string): Promise<Response> {
|
|
265
|
+
try {
|
|
266
|
+
await dynamodb.deleteItem({
|
|
267
|
+
TableName: TABLE_NAME,
|
|
268
|
+
Key: marshall({
|
|
269
|
+
pk: `SITE#${siteId}`,
|
|
270
|
+
sk: `BUDGET#${budgetId}`,
|
|
271
|
+
}),
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
return jsonResponse({ success: true })
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
console.error('Delete performance budget error:', error)
|
|
278
|
+
return errorResponse('Failed to delete performance budget')
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* GET /api/sites/{siteId}/performance-budgets/check
|
|
284
|
+
*/
|
|
285
|
+
export async function handleCheckPerformanceBudgets(request: Request, siteId: string): Promise<Response> {
|
|
286
|
+
try {
|
|
287
|
+
const query = getQueryParams(request)
|
|
288
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
289
|
+
|
|
290
|
+
// Get budgets
|
|
291
|
+
const budgetsResult = await dynamodb.query({
|
|
292
|
+
TableName: TABLE_NAME,
|
|
293
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
|
|
294
|
+
ExpressionAttributeValues: {
|
|
295
|
+
':pk': { S: `SITE#${siteId}` },
|
|
296
|
+
':prefix': { S: 'BUDGET#' },
|
|
297
|
+
},
|
|
298
|
+
}) as { Items?: any[] }
|
|
299
|
+
|
|
300
|
+
const budgets = (budgetsResult.Items || []).map(unmarshall)
|
|
301
|
+
|
|
302
|
+
if (budgets.length === 0) {
|
|
303
|
+
return jsonResponse({ budgets: [], violations: [] })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Get vitals
|
|
307
|
+
const vitalsResult = await dynamodb.query({
|
|
308
|
+
TableName: TABLE_NAME,
|
|
309
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
310
|
+
ExpressionAttributeValues: {
|
|
311
|
+
':pk': { S: `SITE#${siteId}` },
|
|
312
|
+
':start': { S: `VITAL#${startDate.toISOString()}` },
|
|
313
|
+
':end': { S: `VITAL#${endDate.toISOString()}` },
|
|
314
|
+
},
|
|
315
|
+
}) as { Items?: any[] }
|
|
316
|
+
|
|
317
|
+
const vitals = (vitalsResult.Items || []).map(unmarshall)
|
|
318
|
+
|
|
319
|
+
// Check each budget
|
|
320
|
+
const violations: Array<{
|
|
321
|
+
budgetId: string
|
|
322
|
+
metric: string
|
|
323
|
+
threshold: number
|
|
324
|
+
currentValue: number
|
|
325
|
+
currentP75: number
|
|
326
|
+
exceededBy: number
|
|
327
|
+
path: string
|
|
328
|
+
violationCount: number
|
|
329
|
+
}> = []
|
|
330
|
+
|
|
331
|
+
const calculatePercentile = (arr: number[], p: number) => {
|
|
332
|
+
if (arr.length === 0) return 0
|
|
333
|
+
const sorted = [...arr].sort((a, b) => a - b)
|
|
334
|
+
const idx = Math.ceil((p / 100) * sorted.length) - 1
|
|
335
|
+
return sorted[Math.max(0, idx)]
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const budget of budgets) {
|
|
339
|
+
const relevantVitals = vitals.filter(v => {
|
|
340
|
+
const metric = v.metric || v.name
|
|
341
|
+
if (metric !== budget.metric) return false
|
|
342
|
+
if (budget.path && budget.path !== '*') {
|
|
343
|
+
return v.path === budget.path
|
|
344
|
+
}
|
|
345
|
+
return true
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
if (relevantVitals.length === 0) continue
|
|
349
|
+
|
|
350
|
+
// CLS travels x1000 on the wire — normalize before comparing against
|
|
351
|
+
// user-entered unitless thresholds like 0.1 (#133).
|
|
352
|
+
const values = relevantVitals.map(v => budget.metric === 'CLS' ? (v.value || 0) / 1000 : v.value)
|
|
353
|
+
const p75 = calculatePercentile(values, 75)
|
|
354
|
+
const violatingValues = values.filter(v => v > budget.threshold)
|
|
355
|
+
|
|
356
|
+
if (p75 > budget.threshold) {
|
|
357
|
+
violations.push({
|
|
358
|
+
budgetId: budget.id,
|
|
359
|
+
metric: budget.metric,
|
|
360
|
+
threshold: budget.threshold,
|
|
361
|
+
// currentValue/exceededBy are the BudgetViolation contract the tab
|
|
362
|
+
// renders; currentP75 kept for back-compat.
|
|
363
|
+
currentValue: p75,
|
|
364
|
+
currentP75: p75,
|
|
365
|
+
exceededBy: Number((p75 - budget.threshold).toFixed(3)),
|
|
366
|
+
path: budget.path || '*',
|
|
367
|
+
violationCount: violatingValues.length,
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return jsonResponse({
|
|
373
|
+
budgets: budgets.map(b => ({
|
|
374
|
+
...b,
|
|
375
|
+
hasViolation: violations.some(v => v.budgetId === b.id),
|
|
376
|
+
})),
|
|
377
|
+
violations,
|
|
378
|
+
dateRange: {
|
|
379
|
+
start: startDate.toISOString(),
|
|
380
|
+
end: endDate.toISOString(),
|
|
381
|
+
},
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
console.error('Check performance budgets error:', error)
|
|
386
|
+
return errorResponse('Failed to check performance budgets')
|
|
387
|
+
}
|
|
388
|
+
}
|