blockrate-server 0.1.0

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.
@@ -0,0 +1,131 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { drizzle } from "drizzle-orm/bun-sqlite";
3
+ import { and, eq, gte, sql } from "drizzle-orm";
4
+ import { readdirSync, readFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { tenants, events } from "../schema.sqlite";
8
+ import type { BlockRateStore, NewStoredEvent, StatsQuery, StatsRow, StoredTenant } from "../store";
9
+
10
+ export class SqliteStore implements BlockRateStore {
11
+ private sqlite: Database;
12
+ private db: ReturnType<typeof drizzle<{ tenants: typeof tenants; events: typeof events }>>;
13
+
14
+ constructor(path = "./blockrate.db") {
15
+ this.sqlite = new Database(path);
16
+ this.sqlite.exec("PRAGMA journal_mode = WAL;");
17
+ this.sqlite.exec("PRAGMA foreign_keys = ON;");
18
+ this.db = drizzle(this.sqlite, { schema: { tenants, events } });
19
+ this.runMigrations();
20
+ }
21
+
22
+ private runMigrations() {
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+ const migrationsDir = join(here, "..", "..", "drizzle");
25
+ let files: string[];
26
+ try {
27
+ files = readdirSync(migrationsDir)
28
+ .filter((f) => f.endsWith(".sql"))
29
+ .sort();
30
+ } catch {
31
+ return;
32
+ }
33
+ this.sqlite.exec(
34
+ "CREATE TABLE IF NOT EXISTS __migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL);",
35
+ );
36
+ const applied = new Set(
37
+ this.sqlite
38
+ .query("SELECT name FROM __migrations")
39
+ .all()
40
+ .map((r: any) => r.name as string),
41
+ );
42
+ for (const file of files) {
43
+ if (applied.has(file)) continue;
44
+ const migrationSql = readFileSync(join(migrationsDir, file), "utf8");
45
+ this.sqlite.transaction(() => {
46
+ for (const stmt of migrationSql.split("--> statement-breakpoint")) {
47
+ const trimmed = stmt.trim();
48
+ if (trimmed) this.sqlite.exec(trimmed);
49
+ }
50
+ this.sqlite
51
+ .query("INSERT INTO __migrations (name, applied_at) VALUES (?, ?)")
52
+ .run(file, Math.floor(Date.now() / 1000));
53
+ })();
54
+ }
55
+ }
56
+
57
+ async findTenantByApiKey(apiKey: string): Promise<StoredTenant | null> {
58
+ const row = this.db.select().from(tenants).where(eq(tenants.apiKey, apiKey)).get();
59
+ return row ?? null;
60
+ }
61
+
62
+ async findTenantByName(name: string): Promise<StoredTenant | null> {
63
+ const row = this.db.select().from(tenants).where(eq(tenants.name, name)).get();
64
+ return row ?? null;
65
+ }
66
+
67
+ async createTenant(input: { name: string; apiKey: string }): Promise<StoredTenant> {
68
+ this.db.insert(tenants).values(input).run();
69
+ return (await this.findTenantByApiKey(input.apiKey))!;
70
+ }
71
+
72
+ async listTenants(): Promise<StoredTenant[]> {
73
+ return this.db.select().from(tenants).all();
74
+ }
75
+
76
+ async deleteTenant(name: string): Promise<boolean> {
77
+ const row = await this.findTenantByName(name);
78
+ if (!row) return false;
79
+ this.db.delete(events).where(eq(events.tenantId, row.id)).run();
80
+ this.db.delete(tenants).where(eq(tenants.id, row.id)).run();
81
+ return true;
82
+ }
83
+
84
+ async updateTenantApiKey(name: string, apiKey: string): Promise<boolean> {
85
+ const row = await this.findTenantByName(name);
86
+ if (!row) return false;
87
+ this.db.update(tenants).set({ apiKey }).where(eq(tenants.id, row.id)).run();
88
+ return true;
89
+ }
90
+
91
+ async insertEvents(rows: NewStoredEvent[]): Promise<void> {
92
+ if (rows.length === 0) return;
93
+ this.db.insert(events).values(rows).run();
94
+ }
95
+
96
+ async getStats(query: StatsQuery): Promise<StatsRow[]> {
97
+ const where = query.service
98
+ ? and(
99
+ eq(events.tenantId, query.tenantId),
100
+ eq(events.service, query.service),
101
+ gte(events.timestamp, query.since),
102
+ )
103
+ : and(eq(events.tenantId, query.tenantId), gte(events.timestamp, query.since));
104
+
105
+ const rows = this.db
106
+ .select({
107
+ provider: events.provider,
108
+ total: sql<number>`COUNT(*)`.as("total"),
109
+ blocked: sql<number>`SUM(CASE WHEN ${events.status} = 'blocked' THEN 1 ELSE 0 END)`.as(
110
+ "blocked",
111
+ ),
112
+ avgLatency: sql<number>`AVG(${events.latency})`.as("avg_latency"),
113
+ })
114
+ .from(events)
115
+ .where(where)
116
+ .groupBy(events.provider)
117
+ .all();
118
+
119
+ return rows.map((r) => ({
120
+ provider: r.provider,
121
+ total: Number(r.total),
122
+ blocked: Number(r.blocked),
123
+ blockRate: Number(r.total) > 0 ? Number(r.blocked) / Number(r.total) : 0,
124
+ avgLatency: Math.round(Number(r.avgLatency) || 0),
125
+ }));
126
+ }
127
+
128
+ close(): void {
129
+ this.sqlite.close();
130
+ }
131
+ }
package/src/tenant.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import type { BlockRateStore, StoredTenant } from "./store";
3
+
4
+ export function generateApiKey(): string {
5
+ return "br_" + randomBytes(24).toString("hex");
6
+ }
7
+
8
+ export async function createTenant(
9
+ store: BlockRateStore,
10
+ name: string,
11
+ apiKey: string = generateApiKey(),
12
+ ): Promise<StoredTenant> {
13
+ const existing = await store.findTenantByName(name);
14
+ if (existing) {
15
+ throw new Error(`Tenant "${name}" already exists`);
16
+ }
17
+ return store.createTenant({ name, apiKey });
18
+ }
19
+
20
+ export async function listTenants(store: BlockRateStore): Promise<StoredTenant[]> {
21
+ return store.listTenants();
22
+ }
23
+
24
+ export async function deleteTenant(store: BlockRateStore, name: string): Promise<boolean> {
25
+ return store.deleteTenant(name);
26
+ }
27
+
28
+ export async function rotateTenantKey(store: BlockRateStore, name: string): Promise<string | null> {
29
+ const existing = await store.findTenantByName(name);
30
+ if (!existing) return null;
31
+ const newKey = generateApiKey();
32
+ await store.updateTenantApiKey(name, newKey);
33
+ return newKey;
34
+ }
package/src/ua.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Truncate a full User-Agent string to just "browser family + major version".
3
+ *
4
+ * We never want to persist full UA strings because they're PII-adjacent
5
+ * (high fingerprinting entropy). Keeping only browser + major version
6
+ * preserves the one slice analytics actually cares about — "does block
7
+ * rate differ by browser?" — while dropping everything else.
8
+ *
9
+ * Examples:
10
+ * "Mozilla/5.0 ... Chrome/131.0.0.0 Safari/537.36" → "Chrome 131"
11
+ * "Mozilla/5.0 ... Firefox/124.0" → "Firefox 124"
12
+ * "Mozilla/5.0 ... Version/17.3 Safari/605.1.15" → "Safari 17"
13
+ * "" → "unknown"
14
+ */
15
+ export function truncateUserAgent(ua: string | null | undefined): string {
16
+ if (!ua) return "unknown";
17
+
18
+ // Order matters — Edge and Opera include "Chrome" and "Safari" substrings,
19
+ // Samsung Internet includes "Chrome", so match the specific ones first.
20
+ const patterns: [RegExp, string][] = [
21
+ [/Edg(?:e|iOS|A)?\/(\d+)/, "Edge"],
22
+ [/OPR\/(\d+)/, "Opera"],
23
+ [/SamsungBrowser\/(\d+)/, "Samsung Internet"],
24
+ [/FxiOS\/(\d+)/, "Firefox"],
25
+ [/Firefox\/(\d+)/, "Firefox"],
26
+ [/CriOS\/(\d+)/, "Chrome"],
27
+ [/Chrome\/(\d+)/, "Chrome"],
28
+ [/Version\/(\d+)[\d.]*\s+(?:Mobile\/\S+\s+)?Safari/, "Safari"],
29
+ ];
30
+
31
+ for (const [re, name] of patterns) {
32
+ const match = re.exec(ua);
33
+ if (match) {
34
+ const result = `${name} ${match[1]}`;
35
+ return result.length > 64 ? result.slice(0, 64) : result;
36
+ }
37
+ }
38
+
39
+ return "other";
40
+ }
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+
3
+ export const providerResultSchema = z.object({
4
+ name: z.string().min(1).max(64),
5
+ status: z.enum(["loaded", "blocked"]),
6
+ latency: z.number().int().min(0).max(60_000),
7
+ });
8
+
9
+ export const blockRatePayloadSchema = z.object({
10
+ timestamp: z.string().datetime(),
11
+ url: z.string().max(2048),
12
+ userAgent: z.string().max(1024),
13
+ service: z.string().min(1).max(64).optional(),
14
+ providers: z.array(providerResultSchema).min(1).max(64),
15
+ });
16
+
17
+ export type BlockRatePayload = z.infer<typeof blockRatePayloadSchema>;