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.
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/drizzle/0000_square_storm.sql +23 -0
- package/drizzle/meta/0000_snapshot.json +158 -0
- package/drizzle/meta/_journal.json +13 -0
- package/package.json +34 -0
- package/src/cli.ts +119 -0
- package/src/dashboard.ts +82 -0
- package/src/handlers.ts +80 -0
- package/src/index.ts +32 -0
- package/src/rate-limit.ts +30 -0
- package/src/schema.postgres.ts +38 -0
- package/src/schema.sqlite.ts +36 -0
- package/src/server.ts +103 -0
- package/src/store.ts +63 -0
- package/src/stores/index.ts +27 -0
- package/src/stores/postgres.ts +162 -0
- package/src/stores/sqlite.ts +131 -0
- package/src/tenant.ts +34 -0
- package/src/ua.ts +40 -0
- package/src/validate.ts +17 -0
package/src/dashboard.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export const dashboardHtml = /* html */ `<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>blockrate dashboard</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root { color-scheme: light dark; }
|
|
8
|
+
body { font: 14px/1.5 ui-sans-serif, system-ui, sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
|
|
9
|
+
h1 { margin-bottom: 0.25rem; }
|
|
10
|
+
.muted { color: #888; }
|
|
11
|
+
form { display: flex; gap: 0.5rem; margin: 1rem 0; flex-wrap: wrap; }
|
|
12
|
+
input, select, button { padding: 0.4rem 0.6rem; font: inherit; }
|
|
13
|
+
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
|
14
|
+
th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid #8884; }
|
|
15
|
+
th { background: #8881; }
|
|
16
|
+
.bar { position: relative; height: 8px; background: #8883; border-radius: 4px; overflow: hidden; }
|
|
17
|
+
.bar > div { position: absolute; inset: 0; width: var(--w); background: linear-gradient(90deg, #f59e0b, #ef4444); }
|
|
18
|
+
.err { color: #ef4444; }
|
|
19
|
+
</style>
|
|
20
|
+
</head>
|
|
21
|
+
<body>
|
|
22
|
+
<h1>blockrate</h1>
|
|
23
|
+
<p class="muted">Per-provider block rate across your services.</p>
|
|
24
|
+
<form id="f">
|
|
25
|
+
<input name="key" type="password" placeholder="API key" required>
|
|
26
|
+
<input name="service" placeholder="service (optional)">
|
|
27
|
+
<select name="since">
|
|
28
|
+
<option value="1">last 24h</option>
|
|
29
|
+
<option value="7" selected>last 7 days</option>
|
|
30
|
+
<option value="30">last 30 days</option>
|
|
31
|
+
</select>
|
|
32
|
+
<button type="submit">Load</button>
|
|
33
|
+
</form>
|
|
34
|
+
<div id="out"></div>
|
|
35
|
+
<script>
|
|
36
|
+
const f = document.getElementById("f");
|
|
37
|
+
const out = document.getElementById("out");
|
|
38
|
+
const stored = localStorage.getItem("br_key");
|
|
39
|
+
if (stored) f.key.value = stored;
|
|
40
|
+
f.addEventListener("submit", async (e) => {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
const key = f.key.value.trim();
|
|
43
|
+
localStorage.setItem("br_key", key);
|
|
44
|
+
const params = new URLSearchParams({ since: f.since.value });
|
|
45
|
+
if (f.service.value) params.set("service", f.service.value);
|
|
46
|
+
out.innerHTML = "Loading...";
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch("/stats?" + params, {
|
|
49
|
+
headers: { "x-blockrate-key": key },
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) throw new Error(await res.text());
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
if (!data.stats.length) {
|
|
54
|
+
out.innerHTML = "<p class='muted'>No data yet.</p>";
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const rows = data.stats
|
|
58
|
+
.sort((a, b) => b.blockRate - a.blockRate)
|
|
59
|
+
.map((s) => {
|
|
60
|
+
const pct = (s.blockRate * 100).toFixed(1);
|
|
61
|
+
return \`<tr>
|
|
62
|
+
<td>\${s.provider}</td>
|
|
63
|
+
<td>\${s.total.toLocaleString()}</td>
|
|
64
|
+
<td>\${pct}%</td>
|
|
65
|
+
<td><div class="bar"><div style="--w:\${pct}%"></div></div></td>
|
|
66
|
+
<td>\${s.avgLatency}ms</td>
|
|
67
|
+
</tr>\`;
|
|
68
|
+
})
|
|
69
|
+
.join("");
|
|
70
|
+
out.innerHTML = \`
|
|
71
|
+
<p class="muted">Tenant: <b>\${data.tenant}</b> · \${data.service ?? "all services"} · last \${data.sinceDays}d</p>
|
|
72
|
+
<table>
|
|
73
|
+
<thead><tr><th>Provider</th><th>Checks</th><th>Block rate</th><th></th><th>Avg latency</th></tr></thead>
|
|
74
|
+
<tbody>\${rows}</tbody>
|
|
75
|
+
</table>\`;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
out.innerHTML = "<p class='err'>" + err.message + "</p>";
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
</script>
|
|
81
|
+
</body>
|
|
82
|
+
</html>`;
|
package/src/handlers.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { blockRatePayloadSchema } from "./validate";
|
|
2
|
+
import { truncateUserAgent } from "./ua";
|
|
3
|
+
import type { BlockRateStore, StoredTenant } from "./store";
|
|
4
|
+
|
|
5
|
+
export async function authenticate(
|
|
6
|
+
store: BlockRateStore,
|
|
7
|
+
request: Request,
|
|
8
|
+
): Promise<StoredTenant | null> {
|
|
9
|
+
const key =
|
|
10
|
+
request.headers.get("x-blockrate-key") ||
|
|
11
|
+
request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ||
|
|
12
|
+
null;
|
|
13
|
+
if (!key) return null;
|
|
14
|
+
return store.findTenantByApiKey(key);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function handleIngest(
|
|
18
|
+
store: BlockRateStore,
|
|
19
|
+
request: Request,
|
|
20
|
+
tenant: StoredTenant,
|
|
21
|
+
): Promise<Response> {
|
|
22
|
+
let body: unknown;
|
|
23
|
+
try {
|
|
24
|
+
body = await request.json();
|
|
25
|
+
} catch {
|
|
26
|
+
return json({ error: "invalid json" }, 400);
|
|
27
|
+
}
|
|
28
|
+
const parsed = blockRatePayloadSchema.safeParse(body);
|
|
29
|
+
if (!parsed.success) {
|
|
30
|
+
return json({ error: "invalid payload", issues: parsed.error.issues }, 400);
|
|
31
|
+
}
|
|
32
|
+
const { timestamp, url, userAgent, service, providers } = parsed.data;
|
|
33
|
+
const truncatedUa = truncateUserAgent(userAgent);
|
|
34
|
+
const ts = new Date(timestamp);
|
|
35
|
+
await store.insertEvents(
|
|
36
|
+
providers.map((p) => ({
|
|
37
|
+
tenantId: tenant.id,
|
|
38
|
+
service: service ?? "default",
|
|
39
|
+
timestamp: ts,
|
|
40
|
+
url,
|
|
41
|
+
userAgent: truncatedUa,
|
|
42
|
+
provider: p.name,
|
|
43
|
+
status: p.status,
|
|
44
|
+
latency: p.latency,
|
|
45
|
+
})),
|
|
46
|
+
);
|
|
47
|
+
return new Response(null, { status: 204 });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function handleStats(
|
|
51
|
+
store: BlockRateStore,
|
|
52
|
+
request: Request,
|
|
53
|
+
tenant: StoredTenant,
|
|
54
|
+
): Promise<Response> {
|
|
55
|
+
const url = new URL(request.url);
|
|
56
|
+
const service = url.searchParams.get("service") ?? undefined;
|
|
57
|
+
const sinceParam = url.searchParams.get("since");
|
|
58
|
+
const sinceDays = sinceParam ? Math.max(1, parseInt(sinceParam, 10)) : 7;
|
|
59
|
+
const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000);
|
|
60
|
+
|
|
61
|
+
const stats = await store.getStats({
|
|
62
|
+
tenantId: tenant.id,
|
|
63
|
+
service,
|
|
64
|
+
since,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return json({
|
|
68
|
+
tenant: tenant.name,
|
|
69
|
+
service: service ?? null,
|
|
70
|
+
sinceDays,
|
|
71
|
+
stats,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function json(data: unknown, status = 200): Response {
|
|
76
|
+
return new Response(JSON.stringify(data), {
|
|
77
|
+
status,
|
|
78
|
+
headers: { "Content-Type": "application/json" },
|
|
79
|
+
});
|
|
80
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Server
|
|
2
|
+
export { createServer } from "./server";
|
|
3
|
+
export type { ServerOptions } from "./server";
|
|
4
|
+
|
|
5
|
+
// Stores
|
|
6
|
+
export { createStore, SqliteStore, PostgresStore } from "./stores";
|
|
7
|
+
export type {
|
|
8
|
+
BlockRateStore,
|
|
9
|
+
StoredTenant,
|
|
10
|
+
NewStoredEvent,
|
|
11
|
+
StatsRow,
|
|
12
|
+
StatsQuery,
|
|
13
|
+
Dialect,
|
|
14
|
+
CreateStoreOptions,
|
|
15
|
+
} from "./store";
|
|
16
|
+
|
|
17
|
+
// Schemas (both dialects exposed for users who want raw drizzle access)
|
|
18
|
+
export * as sqliteSchema from "./schema.sqlite";
|
|
19
|
+
export * as postgresSchema from "./schema.postgres";
|
|
20
|
+
|
|
21
|
+
// Validation
|
|
22
|
+
export { blockRatePayloadSchema } from "./validate";
|
|
23
|
+
export type { BlockRatePayload } from "./validate";
|
|
24
|
+
|
|
25
|
+
// User-agent truncation (used by both self-hosted and hosted)
|
|
26
|
+
export { truncateUserAgent } from "./ua";
|
|
27
|
+
|
|
28
|
+
// Tenant management
|
|
29
|
+
export { createTenant, listTenants, deleteTenant, rotateTenantKey, generateApiKey } from "./tenant";
|
|
30
|
+
|
|
31
|
+
// Rate limiter (reusable in other contexts like blockrate.app)
|
|
32
|
+
export { TokenBucketLimiter } from "./rate-limit";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
interface Bucket {
|
|
2
|
+
tokens: number;
|
|
3
|
+
updatedAt: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export class TokenBucketLimiter {
|
|
7
|
+
private buckets = new Map<string, Bucket>();
|
|
8
|
+
constructor(
|
|
9
|
+
private capacity: number,
|
|
10
|
+
private refillPerSecond: number,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
take(key: string): boolean {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
const b = this.buckets.get(key) ?? {
|
|
16
|
+
tokens: this.capacity,
|
|
17
|
+
updatedAt: now,
|
|
18
|
+
};
|
|
19
|
+
const elapsed = (now - b.updatedAt) / 1000;
|
|
20
|
+
b.tokens = Math.min(this.capacity, b.tokens + elapsed * this.refillPerSecond);
|
|
21
|
+
b.updatedAt = now;
|
|
22
|
+
if (b.tokens < 1) {
|
|
23
|
+
this.buckets.set(key, b);
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
b.tokens -= 1;
|
|
27
|
+
this.buckets.set(key, b);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { pgTable, serial, text, integer, timestamp, index, pgEnum } from "drizzle-orm/pg-core";
|
|
3
|
+
|
|
4
|
+
export const statusEnum = pgEnum("block_rate_status", ["loaded", "blocked"]);
|
|
5
|
+
|
|
6
|
+
export const tenants = pgTable("tenants", {
|
|
7
|
+
id: serial("id").primaryKey(),
|
|
8
|
+
name: text("name").notNull(),
|
|
9
|
+
apiKey: text("api_key").notNull().unique(),
|
|
10
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
11
|
+
.notNull()
|
|
12
|
+
.default(sql`now()`),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const events = pgTable(
|
|
16
|
+
"events",
|
|
17
|
+
{
|
|
18
|
+
id: serial("id").primaryKey(),
|
|
19
|
+
tenantId: integer("tenant_id")
|
|
20
|
+
.notNull()
|
|
21
|
+
.references(() => tenants.id, { onDelete: "cascade" }),
|
|
22
|
+
service: text("service").notNull().default("default"),
|
|
23
|
+
timestamp: timestamp("timestamp", { withTimezone: true }).notNull(),
|
|
24
|
+
url: text("url").notNull(),
|
|
25
|
+
userAgent: text("user_agent").notNull(),
|
|
26
|
+
provider: text("provider").notNull(),
|
|
27
|
+
status: statusEnum("status").notNull(),
|
|
28
|
+
latency: integer("latency").notNull(),
|
|
29
|
+
},
|
|
30
|
+
(t) => ({
|
|
31
|
+
byTenantService: index("idx_events_tenant_service").on(t.tenantId, t.service, t.timestamp),
|
|
32
|
+
byProvider: index("idx_events_provider").on(t.provider),
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export type Tenant = typeof tenants.$inferSelect;
|
|
37
|
+
export type Event = typeof events.$inferSelect;
|
|
38
|
+
export type NewEvent = typeof events.$inferInsert;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
|
3
|
+
|
|
4
|
+
export const tenants = sqliteTable("tenants", {
|
|
5
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
6
|
+
name: text("name").notNull(),
|
|
7
|
+
apiKey: text("api_key").notNull().unique(),
|
|
8
|
+
createdAt: integer("created_at", { mode: "timestamp" })
|
|
9
|
+
.notNull()
|
|
10
|
+
.default(sql`(unixepoch())`),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const events = sqliteTable(
|
|
14
|
+
"events",
|
|
15
|
+
{
|
|
16
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
17
|
+
tenantId: integer("tenant_id")
|
|
18
|
+
.notNull()
|
|
19
|
+
.references(() => tenants.id, { onDelete: "cascade" }),
|
|
20
|
+
service: text("service").notNull().default("default"),
|
|
21
|
+
timestamp: integer("timestamp", { mode: "timestamp" }).notNull(),
|
|
22
|
+
url: text("url").notNull(),
|
|
23
|
+
userAgent: text("user_agent").notNull(),
|
|
24
|
+
provider: text("provider").notNull(),
|
|
25
|
+
status: text("status", { enum: ["loaded", "blocked"] }).notNull(),
|
|
26
|
+
latency: integer("latency").notNull(),
|
|
27
|
+
},
|
|
28
|
+
(t) => ({
|
|
29
|
+
byTenantService: index("idx_events_tenant_service").on(t.tenantId, t.service, t.timestamp),
|
|
30
|
+
byProvider: index("idx_events_provider").on(t.provider),
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
export type Tenant = typeof tenants.$inferSelect;
|
|
35
|
+
export type Event = typeof events.$inferSelect;
|
|
36
|
+
export type NewEvent = typeof events.$inferInsert;
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createStore } from "./stores";
|
|
2
|
+
import { authenticate, handleIngest, handleStats, json } from "./handlers";
|
|
3
|
+
import { TokenBucketLimiter } from "./rate-limit";
|
|
4
|
+
import { dashboardHtml } from "./dashboard";
|
|
5
|
+
import { generateApiKey } from "./tenant";
|
|
6
|
+
import type { BlockRateStore, Dialect } from "./store";
|
|
7
|
+
|
|
8
|
+
export interface ServerOptions {
|
|
9
|
+
port?: number;
|
|
10
|
+
/** SQLite file path or Postgres connection string. */
|
|
11
|
+
dbPath?: string;
|
|
12
|
+
/** Storage backend. Default "sqlite". */
|
|
13
|
+
dialect?: Dialect;
|
|
14
|
+
/** Requests/second per api key. Default 10. */
|
|
15
|
+
rateLimit?: number;
|
|
16
|
+
/** Burst capacity per api key. Default 60. */
|
|
17
|
+
rateLimitBurst?: number;
|
|
18
|
+
/** CORS allowed origin. Default "*". */
|
|
19
|
+
corsOrigin?: string;
|
|
20
|
+
/** Inject a pre-built store (e.g. for tests). */
|
|
21
|
+
store?: BlockRateStore;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const CORS_HEADERS = (origin: string) => ({
|
|
25
|
+
"Access-Control-Allow-Origin": origin,
|
|
26
|
+
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
|
27
|
+
"Access-Control-Allow-Headers": "content-type, x-blockrate-key, authorization",
|
|
28
|
+
"Access-Control-Max-Age": "86400",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export async function createServer(options: ServerOptions = {}) {
|
|
32
|
+
const store =
|
|
33
|
+
options.store ??
|
|
34
|
+
(await createStore({
|
|
35
|
+
dialect: options.dialect,
|
|
36
|
+
url: options.dbPath,
|
|
37
|
+
}));
|
|
38
|
+
const corsOrigin = options.corsOrigin ?? "*";
|
|
39
|
+
const limiter = new TokenBucketLimiter(options.rateLimitBurst ?? 60, options.rateLimit ?? 10);
|
|
40
|
+
|
|
41
|
+
await ensureBootstrapTenant(store);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
store,
|
|
45
|
+
async fetch(request: Request): Promise<Response> {
|
|
46
|
+
const url = new URL(request.url);
|
|
47
|
+
const cors = CORS_HEADERS(corsOrigin);
|
|
48
|
+
|
|
49
|
+
if (request.method === "OPTIONS") {
|
|
50
|
+
return new Response(null, { status: 204, headers: cors });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const withCors = (res: Response) => {
|
|
54
|
+
for (const [k, v] of Object.entries(cors)) res.headers.set(k, v);
|
|
55
|
+
return res;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (url.pathname === "/health") {
|
|
59
|
+
return withCors(json({ ok: true }));
|
|
60
|
+
}
|
|
61
|
+
if (url.pathname === "/" || url.pathname === "/dashboard") {
|
|
62
|
+
return new Response(dashboardHtml, {
|
|
63
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const needsAuth = url.pathname === "/ingest" || url.pathname === "/stats";
|
|
68
|
+
if (!needsAuth) {
|
|
69
|
+
return withCors(json({ error: "not found" }, 404));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const tenant = await authenticate(store, request);
|
|
73
|
+
if (!tenant) {
|
|
74
|
+
return withCors(json({ error: "unauthorized" }, 401));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!limiter.take(`tenant:${tenant.id}`)) {
|
|
78
|
+
return withCors(json({ error: "rate limited" }, 429));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (url.pathname === "/ingest" && request.method === "POST") {
|
|
82
|
+
return withCors(await handleIngest(store, request, tenant));
|
|
83
|
+
}
|
|
84
|
+
if (url.pathname === "/stats" && request.method === "GET") {
|
|
85
|
+
return withCors(await handleStats(store, request, tenant));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return withCors(json({ error: "method not allowed" }, 405));
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function ensureBootstrapTenant(store: BlockRateStore): Promise<void> {
|
|
94
|
+
const existing = await store.listTenants();
|
|
95
|
+
if (existing.length > 0) return;
|
|
96
|
+
const apiKey = process.env.BLOCK_RATE_BOOTSTRAP_KEY || generateApiKey();
|
|
97
|
+
await store.createTenant({
|
|
98
|
+
name: process.env.BLOCK_RATE_BOOTSTRAP_NAME || "default",
|
|
99
|
+
apiKey,
|
|
100
|
+
});
|
|
101
|
+
console.log(`[blockrate-server] Bootstrapped default tenant. API key: ${apiKey}`);
|
|
102
|
+
console.log("[blockrate-server] Store this securely — it will not be shown again.");
|
|
103
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic data layer for blockrate-server. Both SQLite (self-hosted
|
|
3
|
+
* default) and Postgres (for users with an existing Postgres) implement this
|
|
4
|
+
* interface. Handlers talk to `BlockRateStore` — never to Drizzle directly.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface StoredTenant {
|
|
8
|
+
id: number;
|
|
9
|
+
name: string;
|
|
10
|
+
apiKey: string;
|
|
11
|
+
createdAt: Date;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface NewStoredEvent {
|
|
15
|
+
tenantId: number;
|
|
16
|
+
service: string;
|
|
17
|
+
timestamp: Date;
|
|
18
|
+
url: string;
|
|
19
|
+
/** Truncated to browser family + major version — never the raw UA. */
|
|
20
|
+
userAgent: string;
|
|
21
|
+
provider: string;
|
|
22
|
+
status: "loaded" | "blocked";
|
|
23
|
+
latency: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface StatsRow {
|
|
27
|
+
provider: string;
|
|
28
|
+
total: number;
|
|
29
|
+
blocked: number;
|
|
30
|
+
blockRate: number;
|
|
31
|
+
avgLatency: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface StatsQuery {
|
|
35
|
+
tenantId: number;
|
|
36
|
+
service?: string;
|
|
37
|
+
since: Date;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BlockRateStore {
|
|
41
|
+
// tenant management
|
|
42
|
+
findTenantByApiKey(apiKey: string): Promise<StoredTenant | null>;
|
|
43
|
+
findTenantByName(name: string): Promise<StoredTenant | null>;
|
|
44
|
+
createTenant(input: { name: string; apiKey: string }): Promise<StoredTenant>;
|
|
45
|
+
listTenants(): Promise<StoredTenant[]>;
|
|
46
|
+
deleteTenant(name: string): Promise<boolean>;
|
|
47
|
+
updateTenantApiKey(name: string, apiKey: string): Promise<boolean>;
|
|
48
|
+
|
|
49
|
+
// events
|
|
50
|
+
insertEvents(rows: NewStoredEvent[]): Promise<void>;
|
|
51
|
+
getStats(query: StatsQuery): Promise<StatsRow[]>;
|
|
52
|
+
|
|
53
|
+
/** Close the underlying connection. */
|
|
54
|
+
close(): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type Dialect = "sqlite" | "postgres";
|
|
58
|
+
|
|
59
|
+
export interface CreateStoreOptions {
|
|
60
|
+
dialect?: Dialect;
|
|
61
|
+
/** SQLite path or Postgres connection string. */
|
|
62
|
+
url?: string;
|
|
63
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { BlockRateStore, CreateStoreOptions } from "../store";
|
|
2
|
+
import { SqliteStore } from "./sqlite";
|
|
3
|
+
import { PostgresStore } from "./postgres";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build a store from a dialect + URL. SQLite is the default for self-hosters;
|
|
7
|
+
* Postgres is available for users with existing PG infrastructure.
|
|
8
|
+
*
|
|
9
|
+
* createStore({ dialect: "sqlite", url: "./blockrate.db" })
|
|
10
|
+
* createStore({ dialect: "postgres", url: "postgres://..." })
|
|
11
|
+
*/
|
|
12
|
+
export async function createStore(options: CreateStoreOptions = {}): Promise<BlockRateStore> {
|
|
13
|
+
const dialect = options.dialect ?? "sqlite";
|
|
14
|
+
if (dialect === "sqlite") {
|
|
15
|
+
return new SqliteStore(options.url ?? "./blockrate.db");
|
|
16
|
+
}
|
|
17
|
+
if (dialect === "postgres") {
|
|
18
|
+
if (!options.url) {
|
|
19
|
+
throw new Error("postgres dialect requires a connection URL");
|
|
20
|
+
}
|
|
21
|
+
return PostgresStore.fromUrl(options.url);
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`unknown dialect: ${dialect}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { SqliteStore } from "./sqlite";
|
|
27
|
+
export { PostgresStore } from "./postgres";
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { and, eq, gte, sql } from "drizzle-orm";
|
|
2
|
+
import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js";
|
|
3
|
+
import { drizzle as drizzlePglite } from "drizzle-orm/pglite";
|
|
4
|
+
import postgres from "postgres";
|
|
5
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
6
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { tenants, events } from "../schema.postgres";
|
|
10
|
+
import type { BlockRateStore, NewStoredEvent, StatsQuery, StatsRow, StoredTenant } from "../store";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Supports both production postgres-js connections and in-process PGlite
|
|
14
|
+
* (used by tests). Both speak the same SQL — we just inject the drizzle
|
|
15
|
+
* db and a raw-exec function for migrations.
|
|
16
|
+
*/
|
|
17
|
+
export class PostgresStore implements BlockRateStore {
|
|
18
|
+
constructor(
|
|
19
|
+
private db: any,
|
|
20
|
+
private execRaw: (sql: string) => Promise<void>,
|
|
21
|
+
private closeFn: () => void | Promise<void>,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
static async fromUrl(url: string): Promise<PostgresStore> {
|
|
25
|
+
const client = postgres(url);
|
|
26
|
+
const db = drizzlePostgres(client, { schema: { tenants, events } });
|
|
27
|
+
const exec = async (s: string) => {
|
|
28
|
+
await client.unsafe(s);
|
|
29
|
+
};
|
|
30
|
+
const close = async () => {
|
|
31
|
+
await client.end({ timeout: 2 });
|
|
32
|
+
};
|
|
33
|
+
const store = new PostgresStore(db, exec, close);
|
|
34
|
+
await store.runMigrations();
|
|
35
|
+
return store;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static async fromPglite(dataDir?: string): Promise<PostgresStore> {
|
|
39
|
+
const client = dataDir ? new PGlite(dataDir) : new PGlite();
|
|
40
|
+
await client.waitReady;
|
|
41
|
+
const db = drizzlePglite(client, { schema: { tenants, events } });
|
|
42
|
+
const exec = async (s: string) => {
|
|
43
|
+
await client.exec(s);
|
|
44
|
+
};
|
|
45
|
+
const close = () => {
|
|
46
|
+
void client.close();
|
|
47
|
+
};
|
|
48
|
+
const store = new PostgresStore(db, exec, close);
|
|
49
|
+
await store.runMigrations();
|
|
50
|
+
return store;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private async runMigrations() {
|
|
54
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
const migrationsDir = join(here, "..", "..", "drizzle-postgres");
|
|
56
|
+
let files: string[];
|
|
57
|
+
try {
|
|
58
|
+
files = readdirSync(migrationsDir)
|
|
59
|
+
.filter((f) => f.endsWith(".sql"))
|
|
60
|
+
.sort();
|
|
61
|
+
} catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await this.execRaw(
|
|
66
|
+
`CREATE TABLE IF NOT EXISTS __migrations (
|
|
67
|
+
name TEXT PRIMARY KEY,
|
|
68
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
69
|
+
);`,
|
|
70
|
+
);
|
|
71
|
+
const appliedRows = await this.db.execute(sql`SELECT name FROM __migrations`);
|
|
72
|
+
const applied = new Set(
|
|
73
|
+
(appliedRows as any).rows
|
|
74
|
+
? (appliedRows as any).rows.map((r: any) => r.name as string)
|
|
75
|
+
: (appliedRows as any).map((r: any) => r.name as string),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
if (applied.has(file)) continue;
|
|
80
|
+
const migrationSql = readFileSync(join(migrationsDir, file), "utf8");
|
|
81
|
+
for (const stmt of migrationSql.split("--> statement-breakpoint")) {
|
|
82
|
+
const trimmed = stmt.trim();
|
|
83
|
+
if (trimmed) await this.execRaw(trimmed);
|
|
84
|
+
}
|
|
85
|
+
await this.db.execute(sql`INSERT INTO __migrations (name) VALUES (${file})`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async findTenantByApiKey(apiKey: string): Promise<StoredTenant | null> {
|
|
90
|
+
const rows = await this.db.select().from(tenants).where(eq(tenants.apiKey, apiKey)).limit(1);
|
|
91
|
+
return rows[0] ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async findTenantByName(name: string): Promise<StoredTenant | null> {
|
|
95
|
+
const rows = await this.db.select().from(tenants).where(eq(tenants.name, name)).limit(1);
|
|
96
|
+
return rows[0] ?? null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async createTenant(input: { name: string; apiKey: string }): Promise<StoredTenant> {
|
|
100
|
+
const rows = await this.db.insert(tenants).values(input).returning();
|
|
101
|
+
return rows[0];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async listTenants(): Promise<StoredTenant[]> {
|
|
105
|
+
return await this.db.select().from(tenants);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async deleteTenant(name: string): Promise<boolean> {
|
|
109
|
+
const row = await this.findTenantByName(name);
|
|
110
|
+
if (!row) return false;
|
|
111
|
+
await this.db.delete(events).where(eq(events.tenantId, row.id));
|
|
112
|
+
await this.db.delete(tenants).where(eq(tenants.id, row.id));
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async updateTenantApiKey(name: string, apiKey: string): Promise<boolean> {
|
|
117
|
+
const row = await this.findTenantByName(name);
|
|
118
|
+
if (!row) return false;
|
|
119
|
+
await this.db.update(tenants).set({ apiKey }).where(eq(tenants.id, row.id));
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async insertEvents(rows: NewStoredEvent[]): Promise<void> {
|
|
124
|
+
if (rows.length === 0) return;
|
|
125
|
+
await this.db.insert(events).values(rows);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async getStats(query: StatsQuery): Promise<StatsRow[]> {
|
|
129
|
+
const where = query.service
|
|
130
|
+
? and(
|
|
131
|
+
eq(events.tenantId, query.tenantId),
|
|
132
|
+
eq(events.service, query.service),
|
|
133
|
+
gte(events.timestamp, query.since),
|
|
134
|
+
)
|
|
135
|
+
: and(eq(events.tenantId, query.tenantId), gte(events.timestamp, query.since));
|
|
136
|
+
|
|
137
|
+
const rows = await this.db
|
|
138
|
+
.select({
|
|
139
|
+
provider: events.provider,
|
|
140
|
+
total: sql<number>`COUNT(*)`.as("total"),
|
|
141
|
+
blocked: sql<number>`SUM(CASE WHEN ${events.status} = 'blocked' THEN 1 ELSE 0 END)`.as(
|
|
142
|
+
"blocked",
|
|
143
|
+
),
|
|
144
|
+
avgLatency: sql<number>`AVG(${events.latency})`.as("avg_latency"),
|
|
145
|
+
})
|
|
146
|
+
.from(events)
|
|
147
|
+
.where(where)
|
|
148
|
+
.groupBy(events.provider);
|
|
149
|
+
|
|
150
|
+
return rows.map((r: any) => ({
|
|
151
|
+
provider: r.provider,
|
|
152
|
+
total: Number(r.total),
|
|
153
|
+
blocked: Number(r.blocked),
|
|
154
|
+
blockRate: Number(r.total) > 0 ? Number(r.blocked) / Number(r.total) : 0,
|
|
155
|
+
avgLatency: Math.round(Number(r.avgLatency) || 0),
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
close(): void {
|
|
160
|
+
void this.closeFn();
|
|
161
|
+
}
|
|
162
|
+
}
|