@stacksjs/ts-analytics 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +361 -0
  4. package/bin/cli.ts +169 -0
  5. package/dist/Analytics.d.ts +558 -0
  6. package/dist/api.d.ts +109 -0
  7. package/dist/batching.d.ts +93 -0
  8. package/dist/chunk-2mx7fq49.js +4 -0
  9. package/dist/chunk-3z29508k.js +204 -0
  10. package/dist/chunk-deephkz6.js +56 -0
  11. package/dist/chunk-j261vgyp.js +305 -0
  12. package/dist/chunk-xga17tz7.js +207 -0
  13. package/dist/config.d.ts +132 -0
  14. package/dist/dashboard/components/index.d.ts +80 -0
  15. package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
  16. package/dist/dashboard/index.d.ts +110 -0
  17. package/dist/dashboard/types/index.d.ts +144 -0
  18. package/dist/dashboard/utils/index.d.ts +81 -0
  19. package/dist/dynamodb.d.ts +86 -0
  20. package/dist/funnels.d.ts +104 -0
  21. package/dist/geolocation.d.ts +96 -0
  22. package/dist/index.d.ts +371 -0
  23. package/dist/index.js +10211 -0
  24. package/dist/infrastructure/cdk.d.ts +60 -0
  25. package/dist/infrastructure/cloudformation.d.ts +44 -0
  26. package/dist/infrastructure/index.d.ts +45 -0
  27. package/dist/infrastructure/setup.d.ts +133 -0
  28. package/dist/integrations/cloudflare.d.ts +76 -0
  29. package/dist/integrations/hono.d.ts +60 -0
  30. package/dist/integrations/index.d.ts +24 -0
  31. package/dist/integrations/nuxt.d.ts +7 -0
  32. package/dist/integrations/nuxt.js +42 -0
  33. package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
  34. package/dist/integrations/runtime/use-ts-analytics.js +17 -0
  35. package/dist/integrations/stx.d.ts +88 -0
  36. package/dist/integrations/stx.js +18 -0
  37. package/dist/lib/crypto-random.d.ts +4 -0
  38. package/dist/lib/salt.d.ts +16 -0
  39. package/dist/local.d.ts +56 -0
  40. package/dist/model-connector.d.ts +145 -0
  41. package/dist/models/AggregatedStats.d.ts +9 -0
  42. package/dist/models/CampaignStats.d.ts +9 -0
  43. package/dist/models/Conversion.d.ts +11 -0
  44. package/dist/models/CustomEvent.d.ts +11 -0
  45. package/dist/models/DeviceStats.d.ts +9 -0
  46. package/dist/models/EventStats.d.ts +9 -0
  47. package/dist/models/GeoStats.d.ts +9 -0
  48. package/dist/models/Goal.d.ts +9 -0
  49. package/dist/models/GoalStats.d.ts +9 -0
  50. package/dist/models/PageStats.d.ts +11 -0
  51. package/dist/models/PageView.d.ts +13 -0
  52. package/dist/models/RealtimeStats.d.ts +9 -0
  53. package/dist/models/ReferrerStats.d.ts +9 -0
  54. package/dist/models/Session.d.ts +11 -0
  55. package/dist/models/Site.d.ts +11 -0
  56. package/dist/models/index.d.ts +28 -0
  57. package/dist/models/types.d.ts +60 -0
  58. package/dist/sqs-buffering.d.ts +243 -0
  59. package/dist/stacks-integration.d.ts +159 -0
  60. package/dist/tracking-script.d.ts +71 -0
  61. package/dist/tracking.d.ts +17 -0
  62. package/dist/tracking.js +26 -0
  63. package/dist/types.d.ts +595 -0
  64. package/dist/utils/geolocation.d.ts +111 -0
  65. package/dist/utils/user-agent.d.ts +17 -0
  66. package/dist/version.d.ts +7 -0
  67. package/package.json +119 -0
  68. package/src/Analytics.ts +3349 -0
  69. package/src/api.ts +1286 -0
  70. package/src/assets/crosswind.css +2220 -0
  71. package/src/batching.ts +452 -0
  72. package/src/components/dashboard/index.ts +18 -0
  73. package/src/config.ts +456 -0
  74. package/src/dashboard/Dashboard.stx +1517 -0
  75. package/src/dashboard/components/AlertCard.stx +177 -0
  76. package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
  77. package/src/dashboard/components/AnimatedNumber.stx +86 -0
  78. package/src/dashboard/components/BarChart.stx +220 -0
  79. package/src/dashboard/components/BrowserBreakdown.stx +98 -0
  80. package/src/dashboard/components/BrowsersTable.stx +125 -0
  81. package/src/dashboard/components/CampaignBreakdown.stx +163 -0
  82. package/src/dashboard/components/CampaignTable.stx +238 -0
  83. package/src/dashboard/components/CountryList.stx +101 -0
  84. package/src/dashboard/components/DataTable.stx +226 -0
  85. package/src/dashboard/components/DateRangePicker.stx +77 -0
  86. package/src/dashboard/components/DeviceBreakdown.stx +94 -0
  87. package/src/dashboard/components/DevicesTable.stx +163 -0
  88. package/src/dashboard/components/DonutChart.stories.ts +55 -0
  89. package/src/dashboard/components/DonutChart.stx +157 -0
  90. package/src/dashboard/components/EmptyState.stx +90 -0
  91. package/src/dashboard/components/EngagementMetrics.stx +183 -0
  92. package/src/dashboard/components/EventsSection.stx +192 -0
  93. package/src/dashboard/components/FilterBar.stx +104 -0
  94. package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
  95. package/src/dashboard/components/FunnelChart.stx +142 -0
  96. package/src/dashboard/components/GeoTable.stx +337 -0
  97. package/src/dashboard/components/GoalsPanel.stx +109 -0
  98. package/src/dashboard/components/Header.stx +39 -0
  99. package/src/dashboard/components/HeatmapChart.stx +140 -0
  100. package/src/dashboard/components/LiveActivityFeed.stx +172 -0
  101. package/src/dashboard/components/MetricComparison.stx +106 -0
  102. package/src/dashboard/components/MiniStats.stx +96 -0
  103. package/src/dashboard/components/OSBreakdown.stx +124 -0
  104. package/src/dashboard/components/PageDetailCard.stx +102 -0
  105. package/src/dashboard/components/PagesTable.stx +127 -0
  106. package/src/dashboard/components/ProgressRing.stx +89 -0
  107. package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
  108. package/src/dashboard/components/RealtimeCounter.stx +46 -0
  109. package/src/dashboard/components/ReferrersTable.stx +180 -0
  110. package/src/dashboard/components/SparklineChart.stx +160 -0
  111. package/src/dashboard/components/StatCard.stories.ts +58 -0
  112. package/src/dashboard/components/StatCard.stx +90 -0
  113. package/src/dashboard/components/SummaryStats.stx +81 -0
  114. package/src/dashboard/components/TabNav.stx +66 -0
  115. package/src/dashboard/components/ThemeSwitcher.stx +124 -0
  116. package/src/dashboard/components/TimeSeriesChart.stx +106 -0
  117. package/src/dashboard/components/TopList.stories.ts +58 -0
  118. package/src/dashboard/components/TopList.stx +74 -0
  119. package/src/dashboard/components/TrendIndicator.stx +84 -0
  120. package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
  121. package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
  122. package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
  123. package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
  124. package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
  125. package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
  126. package/src/dashboard/components/heatmap/index.ts +12 -0
  127. package/src/dashboard/components/index.ts +86 -0
  128. package/src/dashboard/composables/useAnalytics.ts +465 -0
  129. package/src/dashboard/demo/DemoApp.stx +370 -0
  130. package/src/dashboard/demo/index.ts +8 -0
  131. package/src/dashboard/demo/mockData.ts +234 -0
  132. package/src/dashboard/index.ts +117 -0
  133. package/src/dashboard/stx-shim.d.ts +4 -0
  134. package/src/dashboard/types/index.ts +203 -0
  135. package/src/dashboard/utils/index.ts +426 -0
  136. package/src/dynamodb.ts +344 -0
  137. package/src/funnels.ts +534 -0
  138. package/src/geolocation.ts +515 -0
  139. package/src/handlers/alerts.ts +328 -0
  140. package/src/handlers/annotations.ts +128 -0
  141. package/src/handlers/api-keys.ts +240 -0
  142. package/src/handlers/auth.ts +1020 -0
  143. package/src/handlers/authz.ts +137 -0
  144. package/src/handlers/collect.ts +724 -0
  145. package/src/handlers/data.ts +625 -0
  146. package/src/handlers/experiments.ts +138 -0
  147. package/src/handlers/funnels.ts +216 -0
  148. package/src/handlers/goals.ts +218 -0
  149. package/src/handlers/heatmaps.ts +272 -0
  150. package/src/handlers/index.ts +56 -0
  151. package/src/handlers/lib/read-cache.ts +63 -0
  152. package/src/handlers/misc.ts +486 -0
  153. package/src/handlers/oauth.ts +233 -0
  154. package/src/handlers/performance.ts +388 -0
  155. package/src/handlers/sessions.ts +413 -0
  156. package/src/handlers/sharing.ts +198 -0
  157. package/src/handlers/stats.ts +1368 -0
  158. package/src/handlers/team.ts +161 -0
  159. package/src/handlers/uptime.ts +283 -0
  160. package/src/handlers/views.ts +390 -0
  161. package/src/handlers/webhooks.ts +226 -0
  162. package/src/heatmap/index.ts +32 -0
  163. package/src/heatmap/tracking-script.ts +452 -0
  164. package/src/heatmap/types.ts +79 -0
  165. package/src/index.ts +387 -0
  166. package/src/infrastructure/cdk.ts +496 -0
  167. package/src/infrastructure/cloudformation.ts +595 -0
  168. package/src/infrastructure/index.ts +48 -0
  169. package/src/infrastructure/setup.ts +611 -0
  170. package/src/integrations/cloudflare.ts +732 -0
  171. package/src/integrations/hono.ts +589 -0
  172. package/src/integrations/index.ts +27 -0
  173. package/src/integrations/nuxt.ts +78 -0
  174. package/src/integrations/runtime/use-ts-analytics.ts +32 -0
  175. package/src/integrations/stx.ts +138 -0
  176. package/src/jobs/index.ts +127 -0
  177. package/src/lib/crypto-random.ts +41 -0
  178. package/src/lib/ddb-errors.ts +20 -0
  179. package/src/lib/dynamodb.ts +216 -0
  180. package/src/lib/email.ts +38 -0
  181. package/src/lib/ga-import.ts +471 -0
  182. package/src/lib/ga4-api.ts +244 -0
  183. package/src/lib/goals.ts +205 -0
  184. package/src/lib/index.ts +6 -0
  185. package/src/lib/ingest-counters.ts +123 -0
  186. package/src/lib/log.ts +36 -0
  187. package/src/lib/membership.ts +98 -0
  188. package/src/lib/plans.ts +90 -0
  189. package/src/lib/quota.ts +46 -0
  190. package/src/lib/rate-limit.ts +27 -0
  191. package/src/lib/rollups.ts +472 -0
  192. package/src/lib/salt.ts +97 -0
  193. package/src/lib/scheduler.ts +97 -0
  194. package/src/lib/significance.ts +66 -0
  195. package/src/lib/site-retention.ts +56 -0
  196. package/src/local.ts +360 -0
  197. package/src/model-connector.ts +616 -0
  198. package/src/models/AggregatedStats.ts +162 -0
  199. package/src/models/CampaignStats.ts +141 -0
  200. package/src/models/Conversion.ts +135 -0
  201. package/src/models/CustomEvent.ts +123 -0
  202. package/src/models/DeviceStats.ts +105 -0
  203. package/src/models/EventStats.ts +108 -0
  204. package/src/models/GeoStats.ts +112 -0
  205. package/src/models/Goal.ts +105 -0
  206. package/src/models/GoalStats.ts +106 -0
  207. package/src/models/PageStats.ts +152 -0
  208. package/src/models/PageView.ts +277 -0
  209. package/src/models/RealtimeStats.ts +96 -0
  210. package/src/models/ReferrerStats.ts +115 -0
  211. package/src/models/Session.ts +235 -0
  212. package/src/models/Site.ts +115 -0
  213. package/src/models/index.ts +50 -0
  214. package/src/models/orm/index.ts +2043 -0
  215. package/src/models/types.ts +71 -0
  216. package/src/router.ts +521 -0
  217. package/src/sqs-buffering.ts +806 -0
  218. package/src/stacks-integration.ts +527 -0
  219. package/src/tracking-script.ts +944 -0
  220. package/src/tracking.ts +27 -0
  221. package/src/types/analytics.ts +219 -0
  222. package/src/types/api.ts +75 -0
  223. package/src/types/bun-router.d.ts +15 -0
  224. package/src/types/dashboard.ts +62 -0
  225. package/src/types/index.ts +66 -0
  226. package/src/types/stx.d.ts +27 -0
  227. package/src/types/window.d.ts +101 -0
  228. package/src/types.ts +911 -0
  229. package/src/utils/cache.ts +148 -0
  230. package/src/utils/date.ts +118 -0
  231. package/src/utils/filters.ts +71 -0
  232. package/src/utils/geolocation.ts +323 -0
  233. package/src/utils/index.ts +9 -0
  234. package/src/utils/response.ts +180 -0
  235. package/src/utils/timezone-country.ts +242 -0
  236. package/src/utils/user-agent.ts +110 -0
  237. package/src/version.ts +7 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Project membership — the User <-> Project (site) relationship.
3
+ *
4
+ * One user can belong to many projects, each with a role. Stored as a pair of
5
+ * mirrored items so both directions are a single-partition query:
6
+ * USER#{userId} / MEMBERSHIP#SITE#{siteId} -> a user's projects
7
+ * SITE#{siteId} / MEMBER#{userId} -> a project's members
8
+ *
9
+ * getMembership() is the O(1) lookup the authz layer uses on every request.
10
+ */
11
+ import { dynamodb, TABLE_NAME, marshall, unmarshall } from './dynamodb'
12
+
13
+ export type Role = 'owner' | 'admin' | 'editor' | 'viewer'
14
+
15
+ const RANK: Record<Role, number> = { viewer: 1, editor: 2, admin: 3, owner: 4 }
16
+
17
+ /** True if `role` is at least as privileged as `min`. */
18
+ export function roleAtLeast(role: Role | null | undefined, min: Role): boolean {
19
+ return !!role && RANK[role] >= RANK[min]
20
+ }
21
+
22
+ export async function addMembership(userId: string, siteId: string, role: Role): Promise<void> {
23
+ const addedAt = new Date().toISOString()
24
+ await dynamodb.putItem({
25
+ TableName: TABLE_NAME,
26
+ Item: marshall({ pk: `USER#${userId}`, sk: `MEMBERSHIP#SITE#${siteId}`, userId, siteId, role, addedAt }),
27
+ })
28
+ await dynamodb.putItem({
29
+ TableName: TABLE_NAME,
30
+ Item: marshall({ pk: `SITE#${siteId}`, sk: `MEMBER#${userId}`, userId, siteId, role, addedAt }),
31
+ })
32
+ }
33
+
34
+ export async function removeMembership(userId: string, siteId: string): Promise<void> {
35
+ await dynamodb.deleteItem({ TableName: TABLE_NAME, Key: { pk: { S: `USER#${userId}` }, sk: { S: `MEMBERSHIP#SITE#${siteId}` } } })
36
+ await dynamodb.deleteItem({ TableName: TABLE_NAME, Key: { pk: { S: `SITE#${siteId}` }, sk: { S: `MEMBER#${userId}` } } })
37
+ }
38
+
39
+ /** The caller's role on a project, or null if they're not a member. */
40
+ export async function getMembership(userId: string, siteId: string): Promise<Role | null> {
41
+ const r = await dynamodb.getItem({
42
+ TableName: TABLE_NAME,
43
+ Key: { pk: { S: `USER#${userId}` }, sk: { S: `MEMBERSHIP#SITE#${siteId}` } },
44
+ })
45
+ if (!r.Item) return null
46
+ return (unmarshall(r.Item).role as Role) || null
47
+ }
48
+
49
+ export async function getUserMemberships(userId: string): Promise<{ siteId: string; role: Role }[]> {
50
+ const r = await dynamodb.query({
51
+ TableName: TABLE_NAME,
52
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :sk)',
53
+ ExpressionAttributeValues: { ':pk': { S: `USER#${userId}` }, ':sk': { S: 'MEMBERSHIP#SITE#' } },
54
+ }) as { Items?: any[] }
55
+ return (r.Items || []).map(unmarshall).map((m: any) => ({ siteId: m.siteId, role: m.role as Role }))
56
+ }
57
+
58
+ // ---- Pending invites (for emails without an account yet) -------------------
59
+ // Keyed by email so they can be found + auto-accepted when the person signs up:
60
+ // INVITE#{email} / SITE#{siteId} -> role
61
+
62
+ export async function addPendingInvite(email: string, siteId: string, role: Role): Promise<void> {
63
+ await dynamodb.putItem({
64
+ TableName: TABLE_NAME,
65
+ Item: marshall({ pk: `INVITE#${email.toLowerCase()}`, sk: `SITE#${siteId}`, email: email.toLowerCase(), siteId, role, invitedAt: new Date().toISOString() }),
66
+ })
67
+ }
68
+
69
+ export async function getPendingInvites(email: string): Promise<{ siteId: string; role: Role }[]> {
70
+ const r = await dynamodb.query({
71
+ TableName: TABLE_NAME,
72
+ KeyConditionExpression: 'pk = :pk',
73
+ ExpressionAttributeValues: { ':pk': { S: `INVITE#${email.toLowerCase()}` } },
74
+ }) as { Items?: any[] }
75
+ return (r.Items || []).map(unmarshall).map((i: any) => ({ siteId: i.siteId, role: i.role as Role }))
76
+ }
77
+
78
+ /** Convert all pending invites for an email into real memberships. Returns count. */
79
+ export async function acceptInvites(userId: string, email: string): Promise<number> {
80
+ const invites = await getPendingInvites(email)
81
+ for (const inv of invites) {
82
+ await addMembership(userId, inv.siteId, inv.role)
83
+ await dynamodb.deleteItem({
84
+ TableName: TABLE_NAME,
85
+ Key: { pk: { S: `INVITE#${email.toLowerCase()}` }, sk: { S: `SITE#${inv.siteId}` } },
86
+ })
87
+ }
88
+ return invites.length
89
+ }
90
+
91
+ export async function getSiteMembers(siteId: string): Promise<{ userId: string; role: Role }[]> {
92
+ const r = await dynamodb.query({
93
+ TableName: TABLE_NAME,
94
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :sk)',
95
+ ExpressionAttributeValues: { ':pk': { S: `SITE#${siteId}` }, ':sk': { S: 'MEMBER#' } },
96
+ }) as { Items?: any[] }
97
+ return (r.Items || []).map(unmarshall).map((m: any) => ({ userId: m.userId, role: m.role as Role }))
98
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Account plans & limits (#62) — billing foundation.
3
+ *
4
+ * Every account has a plan (USER# record `plan` field, default 'free') with
5
+ * three limits: projects, monthly events across all owned projects, and max
6
+ * retention days. 0 means unlimited. Enforcement points:
7
+ * - project create → maxProjects (hard 403)
8
+ * - ingest → monthlyEvents via an atomic per-account month counter
9
+ * (USER#{ownerId} / QUOTA#{YYYY-MM}); fails open
10
+ * - retention update → retentionDays clamp
11
+ * All plans are effectively generous-free until billing exists; the checks and
12
+ * storage are what this scaffolds.
13
+ */
14
+ import { dynamodb, TABLE_NAME, unmarshall } from './dynamodb'
15
+ import { monthKey, quotaExceeded } from './quota'
16
+
17
+ export interface PlanLimits {
18
+ /** Max owned projects (0 = unlimited). */
19
+ maxProjects: number
20
+ /** Max events per calendar month across all owned projects (0 = unlimited). */
21
+ monthlyEvents: number
22
+ /** Max data retention in days (0 = unlimited). */
23
+ retentionDays: number
24
+ }
25
+
26
+ export const PLANS: Record<string, PlanLimits> = {
27
+ free: { maxProjects: 25, monthlyEvents: 1_000_000, retentionDays: 365 },
28
+ pro: { maxProjects: 100, monthlyEvents: 10_000_000, retentionDays: 1095 },
29
+ unlimited: { maxProjects: 0, monthlyEvents: 0, retentionDays: 0 },
30
+ }
31
+
32
+ /** Limits for a plan name; unknown/missing plans resolve to free. Pure — unit tested. */
33
+ export function planLimits(plan?: string | null): PlanLimits {
34
+ return PLANS[plan || 'free'] || PLANS.free
35
+ }
36
+
37
+ // Plan lookups happen on the ingest hot path → small in-memory cache.
38
+ const planCache = new Map<string, { plan: string, expires: number }>()
39
+ const PLAN_CACHE_TTL = 5 * 60_000
40
+
41
+ /** The account's plan name (USER# record `plan`, default 'free'). */
42
+ export async function getUserPlan(userId: string): Promise<string> {
43
+ const hit = planCache.get(userId)
44
+ if (hit && hit.expires > Date.now())
45
+ return hit.plan
46
+ let plan = 'free'
47
+ try {
48
+ const res = await dynamodb.getItem({
49
+ TableName: TABLE_NAME,
50
+ Key: { pk: { S: `USER#${userId}` }, sk: { S: `USER#${userId}` } },
51
+ }) as { Item?: Record<string, any> }
52
+ if (res.Item) {
53
+ const user = unmarshall(res.Item)
54
+ if (user.plan && PLANS[user.plan])
55
+ plan = user.plan
56
+ }
57
+ }
58
+ catch (e) {
59
+ console.error('Plan lookup failed:', (e as Error).message)
60
+ }
61
+ planCache.set(userId, { plan, expires: Date.now() + PLAN_CACHE_TTL })
62
+ return plan
63
+ }
64
+
65
+ /**
66
+ * Atomically count an event against the owner's monthly account quota and
67
+ * report whether it is still within plan. Fails open on errors; no-op (true)
68
+ * when the plan is unlimited.
69
+ */
70
+ export async function accountEventWithinQuota(ownerId: string): Promise<boolean> {
71
+ const limits = planLimits(await getUserPlan(ownerId))
72
+ if (limits.monthlyEvents <= 0)
73
+ return true
74
+ try {
75
+ const res = await dynamodb.updateItem({
76
+ TableName: TABLE_NAME,
77
+ Key: { pk: { S: `USER#${ownerId}` }, sk: { S: `QUOTA#${monthKey()}` } },
78
+ UpdateExpression: 'ADD #c :one SET #t = if_not_exists(#t, :ttl)',
79
+ ExpressionAttributeNames: { '#c': 'count', '#t': 'ttl' },
80
+ ExpressionAttributeValues: { ':one': { N: '1' }, ':ttl': { N: String(Math.floor(Date.now() / 1000) + 70 * 24 * 3600) } },
81
+ ReturnValues: 'UPDATED_NEW',
82
+ }) as { Attributes?: Record<string, unknown> }
83
+ const count = res.Attributes ? (unmarshall(res.Attributes).count as number) : 0
84
+ return !quotaExceeded(count, limits.monthlyEvents)
85
+ }
86
+ catch (e) {
87
+ console.error('Account quota check failed:', (e as Error).message)
88
+ return true
89
+ }
90
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Per-project monthly event quota for error ingest.
3
+ *
4
+ * A coarse cap (separate from the per-key rate limit in lib/rate-limit) that
5
+ * bounds total events per project per calendar month. Backed by a DynamoDB
6
+ * counter so it holds across instances. Disabled by default (no quota set).
7
+ */
8
+ import { dynamodb, TABLE_NAME, unmarshall } from './dynamodb'
9
+
10
+ /** Monthly event cap per project (env ANALYTICS_MONTHLY_EVENT_QUOTA; 0 = unlimited). */
11
+ export const MONTHLY_EVENT_QUOTA: number = Number(process.env.ANALYTICS_MONTHLY_EVENT_QUOTA) || 0
12
+
13
+ /** Current month bucket key (YYYY-MM). */
14
+ export function monthKey(d: Date = new Date()): string {
15
+ return d.toISOString().slice(0, 7)
16
+ }
17
+
18
+ /** True when the running count has passed the limit (limit <= 0 means unlimited). */
19
+ export function quotaExceeded(count: number, limit: number): boolean {
20
+ return limit > 0 && count > limit
21
+ }
22
+
23
+ /**
24
+ * Atomically increment a project's monthly event counter and report whether it
25
+ * is still within quota. Fails open (returns true) on errors so a quota glitch
26
+ * never drops events; a no-op (true) when no quota is configured.
27
+ */
28
+ export async function recordEventWithinQuota(siteId: string): Promise<boolean> {
29
+ if (MONTHLY_EVENT_QUOTA <= 0) return true
30
+ try {
31
+ const res = await dynamodb.updateItem({
32
+ TableName: TABLE_NAME,
33
+ Key: { pk: { S: `SITE#${siteId}` }, sk: { S: `QUOTA#${monthKey()}` } },
34
+ UpdateExpression: 'ADD #c :one SET #t = if_not_exists(#t, :ttl)',
35
+ ExpressionAttributeNames: { '#c': 'count', '#t': 'ttl' },
36
+ ExpressionAttributeValues: { ':one': { N: '1' }, ':ttl': { N: String(Math.floor(Date.now() / 1000) + 70 * 24 * 3600) } },
37
+ ReturnValues: 'UPDATED_NEW',
38
+ }) as { Attributes?: Record<string, unknown> }
39
+ const count = res.Attributes ? (unmarshall(res.Attributes).count as number) : 0
40
+ return !quotaExceeded(count, MONTHLY_EVENT_QUOTA)
41
+ }
42
+ catch (e) {
43
+ console.error('Quota check failed:', (e as Error).message)
44
+ return true
45
+ }
46
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * In-memory fixed-window rate limiter.
3
+ *
4
+ * Keyed by an arbitrary string (e.g. an API key id). Good enough for a single
5
+ * server process; a multi-instance deploy would back this with DynamoDB/Redis,
6
+ * but this closes the "no enforcement at all" gap and returns 429 on abuse.
7
+ */
8
+ interface Bucket { count: number; resetAt: number }
9
+
10
+ const buckets = new Map<string, Bucket>()
11
+
12
+ /** Returns true if the request is allowed, false if the key is over its limit. */
13
+ export function rateLimitAllow(key: string, limit: number, windowMs: number): boolean {
14
+ const now = Date.now()
15
+ let b = buckets.get(key)
16
+ if (!b || b.resetAt <= now) {
17
+ b = { count: 0, resetAt: now + windowMs }
18
+ buckets.set(key, b)
19
+ }
20
+ b.count++
21
+ return b.count <= limit
22
+ }
23
+
24
+ /** Test/util: clear all buckets. */
25
+ export function resetRateLimits(): void {
26
+ buckets.clear()
27
+ }