@riebeckite/analytics-cloudflare 0.0.3 → 0.0.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/README.md CHANGED
@@ -1,69 +1,96 @@
1
- # @riebeckite/analytics-cloudflare
2
-
3
- Independent Cloudflare Worker runtime for `@riebeckite/plugin-analytics`. The
4
- generic plugin contains only browser configuration and analytics contracts; this
5
- package owns Worker bindings and storage.
6
-
7
- ## Architecture
8
-
9
- `createWorker` is the presentation boundary for a dedicated Worker runtime. It
10
- validates HTTP input, calls the generic `AnalyticsProvider` contract through the
11
- runtime-owned `AnalyticsStorage` boundary, and maps unsupported reads to HTTP
12
- errors. D1 and KV live in infrastructure and are never imported by
13
- `@riebeckite/plugin-analytics`. The site's only public setting remains the
14
- generic plugin's `collectorUrl`; bindings and storage selection stay in this
15
- Worker.
16
-
17
- ## Choose exactly one storage adapter
18
-
19
- - **`D1AnalyticsStorage`**: atomic SQLite upserts into UTC daily aggregate
20
- buckets. It supports capture, content page-view totals, popular-content
21
- ranking, and date-bucket time ranges. It stores no raw event log. A range is
22
- bucket-based: bounds select entire UTC days, not exact sub-day event windows.
23
- - **`KvAnalyticsStorage`**: minimal best-effort total capture only. KV lacks
24
- atomic increment and aggregate listing, so concurrent writes can be lost and
25
- `content_page_views` / `popular_content` deliberately throw
26
- `UnsupportedAnalyticsQueryError` (the Worker returns `501`). Do not use it
27
- for reporting.
28
-
29
- There is no default adapter. Bind and construct exactly one in the Worker.
30
-
31
- ```ts
32
- import { createWorker, D1AnalyticsStorage } from "@riebeckite/analytics-cloudflare";
33
-
34
- export interface Env { ANALYTICS_DB: D1Database; }
35
- export default { fetch: (request: Request, env: Env) =>
36
- createWorker({ storage: new D1AnalyticsStorage(env.ANALYTICS_DB), cors: { allowedOrigins: ["https://www.example.com"] } }).fetch(request),
37
- };
38
- ```
39
-
40
- The Worker binding is constructed per request because `env` exists only in
41
- `fetch`.
42
-
43
- ## Endpoints and validation
44
-
45
- - `POST /events` accepts only JSON `page_view` payloads with an ISO timestamp,
46
- a 1–160 character safe content ID (`[A-Za-z0-9._:-]` after the first
47
- alphanumeric character), optional bounded path and language, and no unknown
48
- fields. Bodies are limited to 8 KiB.
49
- - `GET /content/:contentId/page-views?from=&to=` and `GET /popular?limit=&from=&to=`
50
- are available only when storage advertises the corresponding capability.
51
-
52
- Origin access is deny-by-default. Set explicit `allowedOrigins`; use `"any"`
53
- only for a consciously public collector. No request IP, cookies, user-agent,
54
- or fingerprinting data is read or stored. The accepted `path` and `lang` are
55
- contextual metadata and D1 does not persist them.
56
-
57
- ## D1 schema and deployment
58
-
59
- Workers must not migrate D1 during a request. Apply the included schema before
60
- deploying:
61
-
62
- ```sh
63
- pnpm exec wrangler d1 execute ANALYTICS_DB --file node_modules/@riebeckite/analytics-cloudflare/migrations/0001_analytics_page_views.sql
64
- pnpm exec wrangler deploy
65
- ```
66
-
67
- Copy either `templates/analytics-cloudflare/d1` or `kv` into a separate Worker
68
- repository/directory. It has its own `main` and must not replace a static
69
- Riebeckite site's `wrangler.jsonc` or main entry.
1
+ # @riebeckite/analytics-cloudflare
2
+
3
+ Independent Cloudflare Worker runtime for `@riebeckite/plugin-analytics`. The
4
+ generic plugin contains only browser configuration and analytics contracts; this
5
+ package owns Worker bindings and storage.
6
+
7
+ [日本語](./README_ja.md)
8
+
9
+ ## Architecture
10
+
11
+ `createWorker` is the presentation boundary for a dedicated Worker runtime. It
12
+ validates HTTP input, calls the generic `AnalyticsProvider` contract through the
13
+ runtime-owned `AnalyticsStorage` boundary, and maps unsupported reads to HTTP
14
+ errors. D1 and KV live in infrastructure and are never imported by
15
+ `@riebeckite/plugin-analytics`. The site's only public setting remains the
16
+ generic plugin's `collectorUrl`; bindings and storage selection stay in this
17
+ Worker.
18
+
19
+ ## Choose exactly one storage adapter
20
+
21
+ - **`D1AnalyticsStorage`**: atomic SQLite upserts into UTC daily aggregate
22
+ buckets. It supports capture, content page-view totals, popular-content
23
+ ranking, and date-bucket time ranges. It stores no raw event log. A range is
24
+ bucket-based: bounds select entire UTC days, not exact sub-day event windows.
25
+ - **`KvAnalyticsStorage`**: minimal best-effort total capture only. KV lacks
26
+ atomic increment and aggregate listing, so concurrent writes can be lost and
27
+ `content_page_views` / `popular_content` deliberately throw
28
+ `UnsupportedAnalyticsQueryError` (the Worker returns `501`). Do not use it
29
+ for reporting.
30
+
31
+ There is no default adapter. Bind and construct exactly one in the Worker.
32
+
33
+ ```ts
34
+ import { createWorker, D1AnalyticsStorage } from "@riebeckite/analytics-cloudflare";
35
+
36
+ export interface Env { ANALYTICS_DB: D1Database; }
37
+ export default { fetch: (request: Request, env: Env) =>
38
+ createWorker({ storage: new D1AnalyticsStorage(env.ANALYTICS_DB), cors: { allowedOrigins: ["https://www.example.com"] } }).fetch(request),
39
+ };
40
+ ```
41
+
42
+ The Worker binding is constructed per request because `env` exists only in
43
+ `fetch`.
44
+
45
+ ## Rate limiting is a mitigation, not authentication
46
+
47
+ Origin checking is not authentication: any client can forge an allowed `Origin`
48
+ and POST fabricated `page_view` events. The optional `rateLimit` boundary adds a
49
+ fixed-window limit per connecting IP and returns HTTP `429` once exceeded.
50
+
51
+ - **`D1AnalyticsRateLimiter` / `d1RateLimiter(db, { maxRequests, windowMs })`**:
52
+ an atomic, isolate-shared D1 counter. Apply
53
+ `migrations/0002_analytics_rate_limits.sql` before deploying.
54
+ - **`MemoryAnalyticsRateLimiter`**: process-local counters for tests and local
55
+ development. Worker isolates are short-lived and not shared, so this does not
56
+ limit a distributed client across isolates. Use D1 for production.
57
+
58
+ Rate limiting only reduces abuse from a single client; it never fully prevents
59
+ fabricated or distributed page views. Treat collected analytics as untrusted.
60
+
61
+ ```ts
62
+ rateLimit: new D1AnalyticsRateLimiter(env.ANALYTICS_DB, {
63
+ maxRequests: 60,
64
+ windowMs: 60_000,
65
+ }),
66
+ ```
67
+
68
+ ## Endpoints and validation
69
+
70
+ - `POST /events` accepts only JSON `page_view` payloads with an ISO timestamp,
71
+ a 1–160 character trimmed content ID without control characters, optional
72
+ bounded path and language, and no unknown fields. Bodies are limited to 8 KiB.
73
+ When a rate limiter is configured, a client over its window limit gets `429`.
74
+ - `GET /content/:contentId/page-views?from=&to=` and `GET /popular?limit=&from=&to=`
75
+ are available only when storage advertises the corresponding capability.
76
+
77
+ Origin access is deny-by-default. Set explicit `allowedOrigins`; use `"any"`
78
+ only for a consciously public collector. `path` and `lang` are contextual
79
+ metadata and D1 does not persist them. When a rate limiter is configured, the
80
+ connecting IP (`CF-Connecting-IP`) is read as the rate-limit key; the D1
81
+ limiter stores that key only for the active window.
82
+
83
+ ## D1 schema and deployment
84
+
85
+ Workers must not migrate D1 during a request. Apply the included schema before
86
+ deploying:
87
+
88
+ ```sh
89
+ pnpm exec wrangler d1 execute ANALYTICS_DB --file node_modules/@riebeckite/analytics-cloudflare/migrations/0001_analytics_page_views.sql
90
+ pnpm exec wrangler d1 execute ANALYTICS_DB --file node_modules/@riebeckite/analytics-cloudflare/migrations/0002_analytics_rate_limits.sql
91
+ pnpm exec wrangler deploy
92
+ ```
93
+
94
+ Copy either `templates/analytics-cloudflare/d1` or `kv` into a separate Worker
95
+ repository/directory. It has its own `main` and must not replace a static
96
+ Riebeckite site's `wrangler.jsonc` or main entry.
package/README_ja.md CHANGED
@@ -1,16 +1,27 @@
1
- # @riebeckite/analytics-cloudflare
2
-
3
- `@riebeckite/plugin-analytics` のための独立した Cloudflare Worker 実行環境です。汎用プラグインには Cloudflare 固有コードを含めません。
4
-
5
- ## ストレージは必ず一つ選ぶ
6
-
7
- - **D1**: UTC 日単位で集計し、原子的な upsert、コンテンツ別 PV、人気記事、日バケット単位の期間指定に対応します。生イベントは保存しません。時刻境界は日単位であり、サブ日精度ではありません。
8
- - **KV**: 最小のベストエフォート集計のみです。原子的な加算や集計検索がないため、同時書き込みで欠損し得ます。読み取り API は対応能力として宣言せず、`UnsupportedAnalyticsQueryError` / HTTP 501 を返します。レポーティング用途には D1 を使ってください。
9
-
10
- 既定アダプターはありません。テンプレートの D1 または KV を一つだけ選び、静的サイト用の `wrangler.jsonc` や main は変更しません。
11
-
12
- ## プライバシーと検証
13
-
14
- `POST /events` は 8 KiB 以下の JSON `page_view` だけを受け入れ、安全な content ID、ISO 時刻、長さを制限した `path` / `lang` を検証します。IP、Cookie、User-Agent、フィンガープリントは読まず保存しません。Origin は既定で拒否されるため、許可するサイト Origin を明示してください。
15
-
16
- D1 スキーマはリクエスト中に実行せず、デプロイ前に `migrations/0001_analytics_page_views.sql` を `wrangler d1 execute` で明示適用してください。詳しい設定は各テンプレートの README を参照してください。
1
+ # @riebeckite/analytics-cloudflare
2
+
3
+ `@riebeckite/plugin-analytics` のための独立した Cloudflare Worker 実行環境です。汎用プラグインには Cloudflare 固有コードを含めません。
4
+
5
+ [English](./README.md)
6
+
7
+ ## ストレージは必ず一つ選ぶ
8
+
9
+ - **D1**: UTC 日単位で集計し、原子的な upsert、コンテンツ別 PV、人気記事、日バケット単位の期間指定に対応します。生イベントは保存しません。時刻境界は日単位であり、サブ日精度ではありません。
10
+ - **KV**: 最小のベストエフォート集計のみです。原子的な加算や集計検索がないため、同時書き込みで欠損し得ます。読み取り API は対応能力として宣言せず、`UnsupportedAnalyticsQueryError` / HTTP 501 を返します。レポーティング用途には D1 を使ってください。
11
+
12
+ 既定アダプターはありません。テンプレートの D1 または KV を一つだけ選び、静的サイト用の `wrangler.jsonc` や main は変更しません。
13
+
14
+ ## レート制限は緩和策であり、認証ではない
15
+
16
+ Origin は認証ではないため、許可された `Origin` を偽装して `page_view` を直接 POST できます。任意の `rateLimit` 境界を渡すと、接続元 IP ごとの固定時間窓で回数を制限し、超過時に HTTP 429 を返します。
17
+
18
+ - **`D1AnalyticsRateLimiter` / `d1RateLimiter(db, { maxRequests, windowMs })`**: D1 に原子的なカウンターを持ち、isolate 間で共有します。デプロイ前に `migrations/0002_analytics_rate_limits.sql` を適用してください。
19
+ - **`MemoryAnalyticsRateLimiter`**: テストとローカル開発向けのプロセス内カウンターです。Worker の isolate は短命で共有されないため、分散したクライアントは制限できません。本番は D1 を使ってください。
20
+
21
+ レート制限は単一クライアントからの濫用を減らすだけで、偽造や分散アクセスを完全には防げません。収集した計測は信頼できないデータとして扱ってください。
22
+
23
+ ## プライバシーと検証
24
+
25
+ `POST /events` は 8 KiB 以下の JSON `page_view` だけを受け入れ、安全な content ID、ISO 時刻、長さを制限した `path` / `lang` を検証します。Cookie、User-Agent、フィンガープリントは読み取りも保存もしません。Origin は既定で拒否されるため、許可するサイト Origin を明示してください。レート制限を設定した場合に限り、接続元 IP(`CF-Connecting-IP`)をレート制限キーとして読み取ります。D1 版はそのキーを有効な時間窓のあいだだけ保存します。
26
+
27
+ D1 スキーマはリクエスト中に実行せず、デプロイ前に `migrations/0001_analytics_page_views.sql` と `migrations/0002_analytics_rate_limits.sql` を `wrangler d1 execute` で明示適用してください。詳しい設定は各テンプレートの README を参照してください。
package/dist/index.d.ts CHANGED
@@ -1,7 +1,12 @@
1
+ export type { AnalyticsRateLimiter } from "./src/domain/analytics/rate_limit.js";
1
2
  export type { AnalyticsStorage } from "./src/domain/analytics/storage.js";
3
+ export type { D1AnalyticsRateLimiterOptions } from "./src/infrastructure/d1/d1_analytics_rate_limiter.js";
4
+ export { D1AnalyticsRateLimiter, d1RateLimiter, } from "./src/infrastructure/d1/d1_analytics_rate_limiter.js";
2
5
  export { D1AnalyticsStorage, d1Storage, } from "./src/infrastructure/d1/d1_analytics_storage.js";
3
6
  export type { D1Database } from "./src/infrastructure/d1/d1_types.js";
4
7
  export type { KvNamespace } from "./src/infrastructure/kv/kv_analytics_storage.js";
5
8
  export { KvAnalyticsStorage, kvStorage, } from "./src/infrastructure/kv/kv_analytics_storage.js";
9
+ export type { MemoryAnalyticsRateLimiterOptions } from "./src/infrastructure/memory/memory_rate_limiter.js";
10
+ export { MemoryAnalyticsRateLimiter } from "./src/infrastructure/memory/memory_rate_limiter.js";
6
11
  export type { AnalyticsCorsOptions, AnalyticsWorker, AnalyticsWorkerOptions, } from "./src/presentation/worker/create_worker.js";
7
12
  export { createWorker } from "./src/presentation/worker/create_worker.js";
package/dist/index.js CHANGED
@@ -1,3 +1,50 @@
1
+ // src/domain/analytics/rate_limit.ts
2
+ function assertRateLimitPolicy(policy) {
3
+ if (!Number.isInteger(policy.maxRequests) || policy.maxRequests < 1)
4
+ throw new RangeError("rate limit maxRequests must be a positive integer");
5
+ if (!Number.isInteger(policy.windowMs) || policy.windowMs < 1)
6
+ throw new RangeError("rate limit windowMs must be a positive integer");
7
+ }
8
+ function rateLimitWindowStart(now, windowMs) {
9
+ return Math.floor(now / windowMs) * windowMs;
10
+ }
11
+ function rateLimitOutcome(hits, maxRequests) {
12
+ return hits <= maxRequests ? "allowed" : "limited";
13
+ }
14
+
15
+ // src/infrastructure/d1/d1_analytics_rate_limiter.ts
16
+ var D1AnalyticsRateLimiter = class {
17
+ constructor(database, options) {
18
+ this.database = database;
19
+ this.options = options;
20
+ assertRateLimitPolicy(options);
21
+ this.#now = options.now ?? Date.now;
22
+ }
23
+ database;
24
+ options;
25
+ #now;
26
+ async check(key) {
27
+ const windowStart = rateLimitWindowStart(
28
+ this.#now(),
29
+ this.options.windowMs
30
+ );
31
+ const result = await this.database.prepare(
32
+ `INSERT INTO analytics_rate_limits (bucket_key, window_start, hits)
33
+ VALUES (?, ?, 1)
34
+ ON CONFLICT(bucket_key, window_start)
35
+ DO UPDATE SET hits = hits + 1
36
+ RETURNING hits`
37
+ ).bind(key, windowStart).all();
38
+ return rateLimitOutcome(
39
+ Number(result.results?.[0]?.hits ?? 0),
40
+ this.options.maxRequests
41
+ );
42
+ }
43
+ };
44
+ function d1RateLimiter(database, options) {
45
+ return new D1AnalyticsRateLimiter(database, options);
46
+ }
47
+
1
48
  // src/infrastructure/d1/d1_analytics_storage.ts
2
49
  import { assertAnalyticsQuerySupported } from "@riebeckite/plugin-analytics";
3
50
  var D1AnalyticsStorage = class {
@@ -105,6 +152,25 @@ function isPageView2(event) {
105
152
  return event.type === "page_view";
106
153
  }
107
154
 
155
+ // src/infrastructure/memory/memory_rate_limiter.ts
156
+ var MemoryAnalyticsRateLimiter = class {
157
+ constructor(options) {
158
+ this.options = options;
159
+ assertRateLimitPolicy(options);
160
+ this.#now = options.now ?? Date.now;
161
+ }
162
+ options;
163
+ #now;
164
+ #windows = /* @__PURE__ */ new Map();
165
+ async check(key) {
166
+ const start = rateLimitWindowStart(this.#now(), this.options.windowMs);
167
+ const current = this.#windows.get(key);
168
+ const hits = current?.start === start ? current.hits + 1 : 1;
169
+ this.#windows.set(key, { start, hits });
170
+ return rateLimitOutcome(hits, this.options.maxRequests);
171
+ }
172
+ };
173
+
108
174
  // src/presentation/worker/create_worker.ts
109
175
  var MAX_BODY_BYTES = 8192;
110
176
  var MAX_CONTENT_ID_LENGTH = 160;
@@ -121,7 +187,12 @@ function createWorker(options) {
121
187
  }
122
188
  const url = new URL(request.url);
123
189
  if (request.method === "POST" && url.pathname === "/events") {
124
- return captureEvent(request, options.storage, corsHeaders);
190
+ return captureEvent(
191
+ request,
192
+ options.storage,
193
+ options.rateLimit,
194
+ corsHeaders
195
+ );
125
196
  }
126
197
  if (request.method === "GET" && url.pathname === "/popular") {
127
198
  return queryPopular(url, options.storage, corsHeaders);
@@ -136,7 +207,7 @@ function createWorker(options) {
136
207
  }
137
208
  };
138
209
  }
139
- async function captureEvent(request, storage, headers) {
210
+ async function captureEvent(request, storage, rateLimit, headers) {
140
211
  if (!request.headers.get("content-type")?.includes("application/json")) {
141
212
  return json({ error: "unsupported_media_type" }, 415, headers);
142
213
  }
@@ -156,9 +227,17 @@ async function captureEvent(request, storage, headers) {
156
227
  }
157
228
  const event = validateEvent(value);
158
229
  if (!event) return json({ error: "invalid_event" }, 400, headers);
230
+ if (rateLimit) {
231
+ const outcome = await rateLimit.check(clientIp(request));
232
+ if (outcome === "limited")
233
+ return json({ error: "rate_limited" }, 429, headers);
234
+ }
159
235
  await storage.capture(event);
160
236
  return new Response(null, { status: 204, headers });
161
237
  }
238
+ function clientIp(request) {
239
+ return request.headers.get("cf-connecting-ip") ?? "unknown";
240
+ }
162
241
  async function queryContent(contentId, url, storage, headers) {
163
242
  if (!isContentId(contentId))
164
243
  return json({ error: "invalid_content_id" }, 400, headers);
@@ -281,9 +360,12 @@ function isIsoDate(value) {
281
360
  return typeof value === "string" && value.length <= 40 && !Number.isNaN(Date.parse(value));
282
361
  }
283
362
  export {
363
+ D1AnalyticsRateLimiter,
284
364
  D1AnalyticsStorage,
285
365
  KvAnalyticsStorage,
366
+ MemoryAnalyticsRateLimiter,
286
367
  createWorker,
368
+ d1RateLimiter,
287
369
  d1Storage,
288
370
  kvStorage
289
371
  };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Runtime-owned rate-limit boundary for the analytics collector.
3
+ *
4
+ * Origin checks are not authentication, so a client can forge an allowed
5
+ * `Origin` and POST fabricated events. Rate limiting reduces that abuse from a
6
+ * single client key; it is a mitigation, not complete prevention, and never
7
+ * makes collected analytics authorization data.
8
+ */
9
+ export type AnalyticsRateLimitOutcome = "allowed" | "limited";
10
+ export interface AnalyticsRateLimiter {
11
+ /** Records one attempt for `key` and reports whether it may proceed. */
12
+ check(key: string): Promise<AnalyticsRateLimitOutcome>;
13
+ }
14
+ export interface AnalyticsRateLimitPolicy {
15
+ /** Maximum accepted attempts per key inside one window. */
16
+ readonly maxRequests: number;
17
+ /** Window length in milliseconds. */
18
+ readonly windowMs: number;
19
+ }
20
+ /** Rejects policies that would silently disable limiting (zero or non-integer). */
21
+ export declare function assertRateLimitPolicy(policy: AnalyticsRateLimitPolicy): void;
22
+ /** Fixed-window bucket start for `now` in epoch milliseconds. */
23
+ export declare function rateLimitWindowStart(now: number, windowMs: number): number;
24
+ /** Maps the hit count inside the current window to an outcome. */
25
+ export declare function rateLimitOutcome(hits: number, maxRequests: number): AnalyticsRateLimitOutcome;
@@ -0,0 +1,22 @@
1
+ import { type AnalyticsRateLimiter, type AnalyticsRateLimitOutcome, type AnalyticsRateLimitPolicy } from "../../domain/analytics/rate_limit.js";
2
+ import type { D1Database } from "./d1_types.js";
3
+ export type D1AnalyticsRateLimiterOptions = AnalyticsRateLimitPolicy & Readonly<{
4
+ /** Clock injection for deterministic tests. Defaults to `Date.now`. */
5
+ now?: () => number;
6
+ }>;
7
+ /**
8
+ * D1-backed fixed-window limiter shared across Worker isolates.
9
+ *
10
+ * The connecting IP is stored as the bucket key for the active window only.
11
+ * Expired rows are never read again, but D1 has no TTL; prune old rows
12
+ * periodically (for example from a scheduled Worker) if retention matters.
13
+ */
14
+ export declare class D1AnalyticsRateLimiter implements AnalyticsRateLimiter {
15
+ #private;
16
+ private readonly database;
17
+ private readonly options;
18
+ constructor(database: D1Database, options: D1AnalyticsRateLimiterOptions);
19
+ check(key: string): Promise<AnalyticsRateLimitOutcome>;
20
+ }
21
+ /** Creates a D1-backed analytics rate limiter from a Worker binding. */
22
+ export declare function d1RateLimiter(database: D1Database, options: D1AnalyticsRateLimiterOptions): AnalyticsRateLimiter;
@@ -0,0 +1,18 @@
1
+ import { type AnalyticsRateLimiter, type AnalyticsRateLimitOutcome, type AnalyticsRateLimitPolicy } from "../../domain/analytics/rate_limit.js";
2
+ export type MemoryAnalyticsRateLimiterOptions = AnalyticsRateLimitPolicy & Readonly<{
3
+ /** Clock injection for deterministic tests. Defaults to `Date.now`. */
4
+ now?: () => number;
5
+ }>;
6
+ /**
7
+ * Process-local fixed-window limiter for tests and local development.
8
+ *
9
+ * In Cloudflare Workers this state lives in one isolate only: it is lost on cold
10
+ * starts and is not shared across isolates or locations, so it is a best-effort
11
+ * mitigation. Use `D1AnalyticsRateLimiter` for a shared, durable counter.
12
+ */
13
+ export declare class MemoryAnalyticsRateLimiter implements AnalyticsRateLimiter {
14
+ #private;
15
+ private readonly options;
16
+ constructor(options: MemoryAnalyticsRateLimiterOptions);
17
+ check(key: string): Promise<AnalyticsRateLimitOutcome>;
18
+ }
@@ -1,3 +1,4 @@
1
+ import type { AnalyticsRateLimiter } from "../../domain/analytics/rate_limit.js";
1
2
  import type { AnalyticsStorage } from "../../domain/analytics/storage.js";
2
3
  export type AnalyticsCorsOptions = Readonly<{
3
4
  /** Explicit browser origins, or "any" for a deliberately public collector. */
@@ -9,6 +10,12 @@ export type AnalyticsWorkerOptions = Readonly<{
9
10
  /** One explicitly selected runtime storage adapter. No default is provided. */
10
11
  storage: AnalyticsStorage;
11
12
  cors?: AnalyticsCorsOptions;
13
+ /**
14
+ * Optional per-client-IP limiter applied to `POST /events`. Rate limiting is
15
+ * a mitigation for event forgery, not authentication; see the domain
16
+ * `AnalyticsRateLimiter` contract.
17
+ */
18
+ rateLimit?: AnalyticsRateLimiter;
12
19
  }>;
13
20
  export type AnalyticsWorker = Readonly<{
14
21
  fetch(request: Request): Promise<Response>;
@@ -0,0 +1,10 @@
1
+ -- Apply explicitly with: wrangler d1 execute <DATABASE_NAME> --file migrations/0002_analytics_rate_limits.sql
2
+ -- D1 Workers cannot safely run schema migration during request handling.
3
+ -- The connecting IP is stored as bucket_key for the active window only.
4
+ -- D1 has no TTL; prune expired rows periodically if retention matters.
5
+ CREATE TABLE IF NOT EXISTS analytics_rate_limits (
6
+ bucket_key TEXT NOT NULL,
7
+ window_start INTEGER NOT NULL,
8
+ hits INTEGER NOT NULL DEFAULT 0 CHECK (hits >= 0),
9
+ PRIMARY KEY (bucket_key, window_start)
10
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riebeckite/analytics-cloudflare",
3
- "version": "0.0.3",
3
+ "version": "0.0.6",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,7 +14,10 @@
14
14
  }
15
15
  },
16
16
  "dependencies": {
17
- "@riebeckite/plugin-analytics": "0.0.3"
17
+ "@riebeckite/plugin-analytics": "0.0.6"
18
+ },
19
+ "devDependencies": {
20
+ "tsx": "^4.22.4"
18
21
  },
19
22
  "publishConfig": {
20
23
  "access": "public"