@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,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
|
+
}
|
package/src/lib/plans.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/quota.ts
ADDED
|
@@ -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
|
+
}
|