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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Afonso Jramos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# blockrate-server
|
|
2
|
+
|
|
3
|
+
Self-hostable ingestion server and dashboard for [blockrate](https://github.com/afonsojramos/blockrate). One command, one binary, your data on your infrastructure.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bunx blockrate-server
|
|
7
|
+
# [blockrate-server] listening on http://localhost:4318
|
|
8
|
+
# [blockrate-server] Bootstrapped default tenant. API key: br_xxxxxxxxxxxxxxxxxxxx
|
|
9
|
+
# [blockrate-server] dashboard: http://localhost:4318/dashboard
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
That's the entire setup. Open the printed dashboard URL, paste the API key, then wire your client through a same-origin route on your app that forwards to this server with the key attached server-side. See [Why the reporter endpoint must be first-party](../core/README.md#why-the-reporter-endpoint-must-be-first-party) in the core README — self-hosters are first-party by definition, but the rationale (ad blocker list hits, credential leakage) still applies to any browser-to-analytics traffic.
|
|
13
|
+
|
|
14
|
+
## What it gives you
|
|
15
|
+
|
|
16
|
+
- **POST /ingest** — accepts payloads from the OSS [`blockrate`](../core/README.md) client
|
|
17
|
+
- **GET /stats** — per-provider block rate aggregation, sliced by service and date range
|
|
18
|
+
- **/dashboard** — single-page vanilla HTML dashboard reading from `/stats`
|
|
19
|
+
- **Multi-tenant** — one server can ingest for many services across many teams
|
|
20
|
+
- **Per-tenant API keys** with rotation and revocation
|
|
21
|
+
- **Built-in rate limiting** (token bucket per tenant)
|
|
22
|
+
- **CORS preflight** handled automatically — works with any cross-origin client
|
|
23
|
+
- **UA truncation at ingest** — never persists raw user agents (only `Browser Family + major version`)
|
|
24
|
+
- **CLI for tenant management** — create, list, rotate, delete
|
|
25
|
+
|
|
26
|
+
## Storage backends
|
|
27
|
+
|
|
28
|
+
| Backend | When to use | Default |
|
|
29
|
+
| ------------ | ------------------------------------------------------- | -------- |
|
|
30
|
+
| **SQLite** | Single-instance self-host. Zero setup. Persistent file. | ✓ |
|
|
31
|
+
| **Postgres** | Existing Postgres infra, multi-instance, larger scale. | optional |
|
|
32
|
+
|
|
33
|
+
Both are first-class — same `BlockRateStore` interface, same migrations, same query shapes. Switch via `DB_DIALECT=postgres DATABASE_URL=postgres://...`.
|
|
34
|
+
|
|
35
|
+
## Configuration
|
|
36
|
+
|
|
37
|
+
| Env var | Default | Description |
|
|
38
|
+
| --------------------------- | ---------------- | ------------------------------------------------------------------------- |
|
|
39
|
+
| `PORT` | `4318` | HTTP port |
|
|
40
|
+
| `DB_DIALECT` | `sqlite` | `sqlite` or `postgres` |
|
|
41
|
+
| `DB_PATH` | `./blockrate.db` | SQLite file path or Postgres connection URL |
|
|
42
|
+
| `BLOCK_RATE_BOOTSTRAP_KEY` | random | Pin the bootstrap tenant's API key (otherwise generated and printed once) |
|
|
43
|
+
| `BLOCK_RATE_BOOTSTRAP_NAME` | `default` | Name of the bootstrap tenant |
|
|
44
|
+
|
|
45
|
+
The server has **no other env vars** by design — everything else is wired through `blockrate-server tenant *` commands.
|
|
46
|
+
|
|
47
|
+
## Tenant CLI
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
blockrate-server tenant create <name> # → prints a new API key
|
|
51
|
+
blockrate-server tenant list # → id, name, masked key
|
|
52
|
+
blockrate-server tenant rotate <name> # → new key, old key invalidated
|
|
53
|
+
blockrate-server tenant delete <name> # → cascades to all events
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The bootstrap tenant is created on first run with a random API key (printed to the terminal). For production, pin it via `BLOCK_RATE_BOOTSTRAP_KEY` so you don't lose it on container restart.
|
|
57
|
+
|
|
58
|
+
## Deployment recipes
|
|
59
|
+
|
|
60
|
+
### Docker
|
|
61
|
+
|
|
62
|
+
```dockerfile
|
|
63
|
+
FROM oven/bun:1.3-alpine
|
|
64
|
+
WORKDIR /app
|
|
65
|
+
RUN bun install -g blockrate-server
|
|
66
|
+
EXPOSE 4318
|
|
67
|
+
ENV PORT=4318
|
|
68
|
+
ENV DB_PATH=/data/blockrate.db
|
|
69
|
+
VOLUME /data
|
|
70
|
+
CMD ["blockrate-server"]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
docker build -t blockrate-server .
|
|
75
|
+
docker run -d -p 4318:4318 -v /opt/blockrate-data:/data \
|
|
76
|
+
-e BLOCK_RATE_BOOTSTRAP_KEY=$(openssl rand -base64 32) \
|
|
77
|
+
--name blockrate blockrate-server
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### docker-compose
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
services:
|
|
84
|
+
blockrate:
|
|
85
|
+
image: oven/bun:1.3-alpine
|
|
86
|
+
command: bunx blockrate-server
|
|
87
|
+
ports: ["4318:4318"]
|
|
88
|
+
volumes:
|
|
89
|
+
- ./data:/app
|
|
90
|
+
environment:
|
|
91
|
+
PORT: 4318
|
|
92
|
+
DB_PATH: /app/blockrate.db
|
|
93
|
+
BLOCK_RATE_BOOTSTRAP_KEY: ${BLOCK_RATE_KEY}
|
|
94
|
+
restart: unless-stopped
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Railway
|
|
98
|
+
|
|
99
|
+
1. Create a service from a fork of this repo (or wrap in a tiny Dockerfile per above)
|
|
100
|
+
2. Mount a Volume at `/data`
|
|
101
|
+
3. Env vars:
|
|
102
|
+
```
|
|
103
|
+
PORT=4318
|
|
104
|
+
DB_PATH=/data/blockrate.db
|
|
105
|
+
BLOCK_RATE_BOOTSTRAP_KEY=<openssl rand -base64 32>
|
|
106
|
+
```
|
|
107
|
+
4. Add a custom domain pointing at the service
|
|
108
|
+
5. Optional: switch to Postgres by adding the Postgres addon and setting `DB_DIALECT=postgres DB_PATH=${{Postgres.DATABASE_URL}}`
|
|
109
|
+
|
|
110
|
+
### fly.io
|
|
111
|
+
|
|
112
|
+
```toml
|
|
113
|
+
# fly.toml
|
|
114
|
+
app = "blockrate"
|
|
115
|
+
|
|
116
|
+
[build]
|
|
117
|
+
image = "oven/bun:1.3-alpine"
|
|
118
|
+
|
|
119
|
+
[mounts]
|
|
120
|
+
source = "block_rate_data"
|
|
121
|
+
destination = "/data"
|
|
122
|
+
|
|
123
|
+
[env]
|
|
124
|
+
PORT = "4318"
|
|
125
|
+
DB_PATH = "/data/blockrate.db"
|
|
126
|
+
|
|
127
|
+
[[services]]
|
|
128
|
+
internal_port = 4318
|
|
129
|
+
protocol = "tcp"
|
|
130
|
+
[[services.ports]]
|
|
131
|
+
handlers = ["tls", "http"]
|
|
132
|
+
port = 443
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
fly launch --no-deploy
|
|
137
|
+
fly volumes create block_rate_data --size 1
|
|
138
|
+
fly secrets set BLOCK_RATE_BOOTSTRAP_KEY=$(openssl rand -base64 32)
|
|
139
|
+
fly deploy
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### systemd (bare metal / VPS)
|
|
143
|
+
|
|
144
|
+
```ini
|
|
145
|
+
# /etc/systemd/system/blockrate.service
|
|
146
|
+
[Unit]
|
|
147
|
+
Description=blockrate ingestion server
|
|
148
|
+
After=network.target
|
|
149
|
+
|
|
150
|
+
[Service]
|
|
151
|
+
Type=simple
|
|
152
|
+
User=blockrate
|
|
153
|
+
WorkingDirectory=/opt/blockrate
|
|
154
|
+
Environment=PORT=4318
|
|
155
|
+
Environment=DB_PATH=/var/lib/blockrate/blockrate.db
|
|
156
|
+
EnvironmentFile=/etc/blockrate/blockrate.env
|
|
157
|
+
ExecStart=/usr/local/bin/bun /opt/blockrate/node_modules/.bin/blockrate-server
|
|
158
|
+
Restart=on-failure
|
|
159
|
+
RestartSec=5
|
|
160
|
+
|
|
161
|
+
# Hardening
|
|
162
|
+
NoNewPrivileges=true
|
|
163
|
+
ProtectSystem=strict
|
|
164
|
+
ProtectHome=true
|
|
165
|
+
PrivateTmp=true
|
|
166
|
+
ReadWritePaths=/var/lib/blockrate
|
|
167
|
+
|
|
168
|
+
[Install]
|
|
169
|
+
WantedBy=multi-user.target
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
sudo useradd -r -s /bin/false blockrate
|
|
174
|
+
sudo mkdir -p /var/lib/blockrate /etc/blockrate
|
|
175
|
+
sudo chown blockrate:blockrate /var/lib/blockrate
|
|
176
|
+
echo "BLOCK_RATE_BOOTSTRAP_KEY=$(openssl rand -base64 32)" | sudo tee /etc/blockrate/blockrate.env
|
|
177
|
+
sudo chmod 600 /etc/blockrate/blockrate.env
|
|
178
|
+
sudo systemctl daemon-reload
|
|
179
|
+
sudo systemctl enable --now blockrate
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Reverse proxy
|
|
183
|
+
|
|
184
|
+
The server speaks plain HTTP — terminate TLS at a reverse proxy.
|
|
185
|
+
|
|
186
|
+
### Caddy
|
|
187
|
+
|
|
188
|
+
```caddyfile
|
|
189
|
+
br.example.com {
|
|
190
|
+
reverse_proxy localhost:4318
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### nginx
|
|
195
|
+
|
|
196
|
+
```nginx
|
|
197
|
+
server {
|
|
198
|
+
listen 443 ssl http2;
|
|
199
|
+
server_name br.example.com;
|
|
200
|
+
|
|
201
|
+
ssl_certificate /etc/letsencrypt/live/br.example.com/fullchain.pem;
|
|
202
|
+
ssl_certificate_key /etc/letsencrypt/live/br.example.com/privkey.pem;
|
|
203
|
+
|
|
204
|
+
location / {
|
|
205
|
+
proxy_pass http://127.0.0.1:4318;
|
|
206
|
+
proxy_set_header Host $host;
|
|
207
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
208
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
209
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Cloudflare
|
|
215
|
+
|
|
216
|
+
Just add a Cloudflare Tunnel pointing at `localhost:4318`. No port-forwarding, no certs to renew.
|
|
217
|
+
|
|
218
|
+
## Security hardening
|
|
219
|
+
|
|
220
|
+
The server is **defaults-on**:
|
|
221
|
+
|
|
222
|
+
- **Built-in rate limit** — 60-burst / 10/sec per tenant (token bucket, in-process)
|
|
223
|
+
- **CORS preflight** allows any origin — necessary for browser POSTs from arbitrary client sites
|
|
224
|
+
- **API keys** are random 51-char tokens (`br_` + 24 random hex bytes), never plaintext-logged
|
|
225
|
+
- **`user_agent` is truncated at ingest** — only `Browser Family + major version` is persisted, never the raw UA. This is the single biggest privacy lever and it's non-negotiable.
|
|
226
|
+
- **Zod validation** on every payload — bad shapes return 400 without touching the DB
|
|
227
|
+
|
|
228
|
+
What it does **not** do (you handle these at the proxy / infra layer):
|
|
229
|
+
|
|
230
|
+
- TLS termination (use Caddy / nginx / Cloudflare)
|
|
231
|
+
- IP allow-listing (use a firewall or proxy ACL)
|
|
232
|
+
- Distributed rate limiting (in-process token bucket only — single-instance only; for multi-instance, see "Multi-instance" below)
|
|
233
|
+
- Authentication beyond the bearer API key (no users, no sessions, no OAuth — that's what [blockrate.app](https://blockrate.app) is for)
|
|
234
|
+
|
|
235
|
+
## Backups
|
|
236
|
+
|
|
237
|
+
### SQLite
|
|
238
|
+
|
|
239
|
+
Use [Litestream](https://litestream.io) for continuous replication to S3/B2/etc:
|
|
240
|
+
|
|
241
|
+
```yaml
|
|
242
|
+
# /etc/litestream.yml
|
|
243
|
+
dbs:
|
|
244
|
+
- path: /var/lib/blockrate/blockrate.db
|
|
245
|
+
replicas:
|
|
246
|
+
- url: s3://my-bucket/blockrate
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
WAL mode is already enabled. Litestream sees writes immediately.
|
|
250
|
+
|
|
251
|
+
### Postgres
|
|
252
|
+
|
|
253
|
+
Whatever your Postgres provider offers — Railway/Supabase/Neon all do automatic backups + PITR. For self-hosted Postgres, use `pg_dump` on a schedule or `pgbackrest`/`barman` for incremental.
|
|
254
|
+
|
|
255
|
+
## Multi-instance considerations
|
|
256
|
+
|
|
257
|
+
The default in-process token bucket rate limiter doesn't survive horizontal scaling. If you run more than one instance:
|
|
258
|
+
|
|
259
|
+
- Switch to a Postgres-backed limiter (write your own — the `BlockRateStore` interface is small enough to extend)
|
|
260
|
+
- Or front the cluster with a proxy that does the rate limiting (nginx `limit_req`, Caddy `rate_limit`, Cloudflare WAF rules)
|
|
261
|
+
- The `events` insert path is already concurrency-safe — Postgres handles it; SQLite serializes via WAL
|
|
262
|
+
|
|
263
|
+
For Phase 1 of any deployment, **start with one instance**. Postgres connection pooling becomes the next bottleneck, not the request handler.
|
|
264
|
+
|
|
265
|
+
## Migrating from SQLite to Postgres
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
# 1. Export from SQLite
|
|
269
|
+
DB_DIALECT=sqlite DB_PATH=./blockrate.db blockrate-server tenant list > tenants.txt
|
|
270
|
+
|
|
271
|
+
# 2. Stop the SQLite-backed server
|
|
272
|
+
# 3. Run migrations against Postgres
|
|
273
|
+
DB_DIALECT=postgres DB_PATH="postgres://..." blockrate-server # creates tables
|
|
274
|
+
|
|
275
|
+
# 4. Manually copy tenants and events (the schemas are identical)
|
|
276
|
+
# Use pgloader or a one-shot script reading both DBs
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
The SQLite ↔ Postgres parity is enforced at the test level (`packages/server/test/store-parity.test.ts`) — every store operation runs against both backends in CI.
|
|
280
|
+
|
|
281
|
+
## Programmatic use
|
|
282
|
+
|
|
283
|
+
You don't have to use the CLI:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import { createServer, createStore, createTenant } from "blockrate-server";
|
|
287
|
+
|
|
288
|
+
const store = await createStore({ dialect: "postgres", url: process.env.DATABASE_URL });
|
|
289
|
+
await createTenant(store, "my-app");
|
|
290
|
+
const app = await createServer({ store });
|
|
291
|
+
|
|
292
|
+
Bun.serve({ port: 4318, fetch: app.fetch });
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The full public surface is in [`src/index.ts`](src/index.ts):
|
|
296
|
+
|
|
297
|
+
- `createStore`, `SqliteStore`, `PostgresStore` + the `BlockRateStore` interface
|
|
298
|
+
- `createServer`, `ServerOptions`
|
|
299
|
+
- `createTenant`, `listTenants`, `deleteTenant`, `rotateTenantKey`, `generateApiKey`
|
|
300
|
+
- `blockRatePayloadSchema` (Zod)
|
|
301
|
+
- `truncateUserAgent` — pure function, also exposed at `blockrate-server/ua` for use without pulling in the SQLite store
|
|
302
|
+
- `TokenBucketLimiter` — same, at `blockrate-server/rate-limit`
|
|
303
|
+
|
|
304
|
+
## License
|
|
305
|
+
|
|
306
|
+
MIT.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
CREATE TABLE `events` (
|
|
2
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
3
|
+
`tenant_id` integer NOT NULL,
|
|
4
|
+
`service` text DEFAULT 'default' NOT NULL,
|
|
5
|
+
`timestamp` integer NOT NULL,
|
|
6
|
+
`url` text NOT NULL,
|
|
7
|
+
`user_agent` text NOT NULL,
|
|
8
|
+
`provider` text NOT NULL,
|
|
9
|
+
`status` text NOT NULL,
|
|
10
|
+
`latency` integer NOT NULL,
|
|
11
|
+
FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON UPDATE no action ON DELETE cascade
|
|
12
|
+
);
|
|
13
|
+
--> statement-breakpoint
|
|
14
|
+
CREATE INDEX `idx_events_tenant_service` ON `events` (`tenant_id`,`service`,`timestamp`);--> statement-breakpoint
|
|
15
|
+
CREATE INDEX `idx_events_provider` ON `events` (`provider`);--> statement-breakpoint
|
|
16
|
+
CREATE TABLE `tenants` (
|
|
17
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
18
|
+
`name` text NOT NULL,
|
|
19
|
+
`api_key` text NOT NULL,
|
|
20
|
+
`created_at` integer DEFAULT (unixepoch()) NOT NULL
|
|
21
|
+
);
|
|
22
|
+
--> statement-breakpoint
|
|
23
|
+
CREATE UNIQUE INDEX `tenants_api_key_unique` ON `tenants` (`api_key`);
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "6",
|
|
3
|
+
"dialect": "sqlite",
|
|
4
|
+
"id": "883686a6-ae7b-44ec-b604-6efeff1ba82d",
|
|
5
|
+
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
6
|
+
"tables": {
|
|
7
|
+
"events": {
|
|
8
|
+
"name": "events",
|
|
9
|
+
"columns": {
|
|
10
|
+
"id": {
|
|
11
|
+
"name": "id",
|
|
12
|
+
"type": "integer",
|
|
13
|
+
"primaryKey": true,
|
|
14
|
+
"notNull": true,
|
|
15
|
+
"autoincrement": true
|
|
16
|
+
},
|
|
17
|
+
"tenant_id": {
|
|
18
|
+
"name": "tenant_id",
|
|
19
|
+
"type": "integer",
|
|
20
|
+
"primaryKey": false,
|
|
21
|
+
"notNull": true,
|
|
22
|
+
"autoincrement": false
|
|
23
|
+
},
|
|
24
|
+
"service": {
|
|
25
|
+
"name": "service",
|
|
26
|
+
"type": "text",
|
|
27
|
+
"primaryKey": false,
|
|
28
|
+
"notNull": true,
|
|
29
|
+
"autoincrement": false,
|
|
30
|
+
"default": "'default'"
|
|
31
|
+
},
|
|
32
|
+
"timestamp": {
|
|
33
|
+
"name": "timestamp",
|
|
34
|
+
"type": "integer",
|
|
35
|
+
"primaryKey": false,
|
|
36
|
+
"notNull": true,
|
|
37
|
+
"autoincrement": false
|
|
38
|
+
},
|
|
39
|
+
"url": {
|
|
40
|
+
"name": "url",
|
|
41
|
+
"type": "text",
|
|
42
|
+
"primaryKey": false,
|
|
43
|
+
"notNull": true,
|
|
44
|
+
"autoincrement": false
|
|
45
|
+
},
|
|
46
|
+
"user_agent": {
|
|
47
|
+
"name": "user_agent",
|
|
48
|
+
"type": "text",
|
|
49
|
+
"primaryKey": false,
|
|
50
|
+
"notNull": true,
|
|
51
|
+
"autoincrement": false
|
|
52
|
+
},
|
|
53
|
+
"provider": {
|
|
54
|
+
"name": "provider",
|
|
55
|
+
"type": "text",
|
|
56
|
+
"primaryKey": false,
|
|
57
|
+
"notNull": true,
|
|
58
|
+
"autoincrement": false
|
|
59
|
+
},
|
|
60
|
+
"status": {
|
|
61
|
+
"name": "status",
|
|
62
|
+
"type": "text",
|
|
63
|
+
"primaryKey": false,
|
|
64
|
+
"notNull": true,
|
|
65
|
+
"autoincrement": false
|
|
66
|
+
},
|
|
67
|
+
"latency": {
|
|
68
|
+
"name": "latency",
|
|
69
|
+
"type": "integer",
|
|
70
|
+
"primaryKey": false,
|
|
71
|
+
"notNull": true,
|
|
72
|
+
"autoincrement": false
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"indexes": {
|
|
76
|
+
"idx_events_tenant_service": {
|
|
77
|
+
"name": "idx_events_tenant_service",
|
|
78
|
+
"columns": ["tenant_id", "service", "timestamp"],
|
|
79
|
+
"isUnique": false
|
|
80
|
+
},
|
|
81
|
+
"idx_events_provider": {
|
|
82
|
+
"name": "idx_events_provider",
|
|
83
|
+
"columns": ["provider"],
|
|
84
|
+
"isUnique": false
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"foreignKeys": {
|
|
88
|
+
"events_tenant_id_tenants_id_fk": {
|
|
89
|
+
"name": "events_tenant_id_tenants_id_fk",
|
|
90
|
+
"tableFrom": "events",
|
|
91
|
+
"tableTo": "tenants",
|
|
92
|
+
"columnsFrom": ["tenant_id"],
|
|
93
|
+
"columnsTo": ["id"],
|
|
94
|
+
"onDelete": "cascade",
|
|
95
|
+
"onUpdate": "no action"
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"compositePrimaryKeys": {},
|
|
99
|
+
"uniqueConstraints": {},
|
|
100
|
+
"checkConstraints": {}
|
|
101
|
+
},
|
|
102
|
+
"tenants": {
|
|
103
|
+
"name": "tenants",
|
|
104
|
+
"columns": {
|
|
105
|
+
"id": {
|
|
106
|
+
"name": "id",
|
|
107
|
+
"type": "integer",
|
|
108
|
+
"primaryKey": true,
|
|
109
|
+
"notNull": true,
|
|
110
|
+
"autoincrement": true
|
|
111
|
+
},
|
|
112
|
+
"name": {
|
|
113
|
+
"name": "name",
|
|
114
|
+
"type": "text",
|
|
115
|
+
"primaryKey": false,
|
|
116
|
+
"notNull": true,
|
|
117
|
+
"autoincrement": false
|
|
118
|
+
},
|
|
119
|
+
"api_key": {
|
|
120
|
+
"name": "api_key",
|
|
121
|
+
"type": "text",
|
|
122
|
+
"primaryKey": false,
|
|
123
|
+
"notNull": true,
|
|
124
|
+
"autoincrement": false
|
|
125
|
+
},
|
|
126
|
+
"created_at": {
|
|
127
|
+
"name": "created_at",
|
|
128
|
+
"type": "integer",
|
|
129
|
+
"primaryKey": false,
|
|
130
|
+
"notNull": true,
|
|
131
|
+
"autoincrement": false,
|
|
132
|
+
"default": "(unixepoch())"
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"indexes": {
|
|
136
|
+
"tenants_api_key_unique": {
|
|
137
|
+
"name": "tenants_api_key_unique",
|
|
138
|
+
"columns": ["api_key"],
|
|
139
|
+
"isUnique": true
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
"foreignKeys": {},
|
|
143
|
+
"compositePrimaryKeys": {},
|
|
144
|
+
"uniqueConstraints": {},
|
|
145
|
+
"checkConstraints": {}
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
"views": {},
|
|
149
|
+
"enums": {},
|
|
150
|
+
"_meta": {
|
|
151
|
+
"schemas": {},
|
|
152
|
+
"tables": {},
|
|
153
|
+
"columns": {}
|
|
154
|
+
},
|
|
155
|
+
"internal": {
|
|
156
|
+
"indexes": {}
|
|
157
|
+
}
|
|
158
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "blockrate-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Self-hostable ingestion server and dashboard for block-rate.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"blockrate-server": "./src/cli.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"drizzle"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./src/index.ts",
|
|
15
|
+
"types": "./src/index.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts",
|
|
18
|
+
"./ua": "./src/ua.ts",
|
|
19
|
+
"./rate-limit": "./src/rate-limit.ts",
|
|
20
|
+
"./validate": "./src/validate.ts"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"dev": "bun run src/cli.ts",
|
|
24
|
+
"db:generate": "drizzle-kit generate",
|
|
25
|
+
"test": "bun test"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"drizzle-orm": "^0.36.0",
|
|
29
|
+
"zod": "^3.23.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"drizzle-kit": "^0.28.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createServer } from "./server";
|
|
3
|
+
import { createStore } from "./stores";
|
|
4
|
+
import { createTenant, listTenants, deleteTenant, rotateTenantKey } from "./tenant";
|
|
5
|
+
import type { Dialect } from "./store";
|
|
6
|
+
|
|
7
|
+
const dbPath = process.env.DB_PATH || "./blockrate.db";
|
|
8
|
+
const port = Number(process.env.PORT) || 4318;
|
|
9
|
+
const dialect: Dialect = (process.env.DB_DIALECT as Dialect) || "sqlite";
|
|
10
|
+
|
|
11
|
+
const [cmd, sub, ...rest] = process.argv.slice(2);
|
|
12
|
+
|
|
13
|
+
function usage(exitCode = 0): never {
|
|
14
|
+
const msg = `\
|
|
15
|
+
blockrate-server — self-hostable blockrate ingestion + dashboard
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
blockrate-server Start the server (default)
|
|
19
|
+
blockrate-server serve Start the server
|
|
20
|
+
blockrate-server tenant create <name> Create a tenant and print its API key
|
|
21
|
+
blockrate-server tenant list List all tenants
|
|
22
|
+
blockrate-server tenant delete <name> Delete a tenant and all its events
|
|
23
|
+
blockrate-server tenant rotate <name> Rotate a tenant's API key
|
|
24
|
+
|
|
25
|
+
Environment:
|
|
26
|
+
PORT HTTP port (default 4318)
|
|
27
|
+
DB_DIALECT sqlite | postgres (default sqlite)
|
|
28
|
+
DB_PATH SQLite file path or Postgres connection URL
|
|
29
|
+
(default ./blockrate.db)
|
|
30
|
+
BLOCK_RATE_BOOTSTRAP_KEY Pin the bootstrap tenant's API key
|
|
31
|
+
BLOCK_RATE_BOOTSTRAP_NAME Name of the bootstrap tenant (default "default")
|
|
32
|
+
`;
|
|
33
|
+
(exitCode === 0 ? console.log : console.error)(msg);
|
|
34
|
+
process.exit(exitCode);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (cmd === "tenant") {
|
|
38
|
+
const store = await createStore({ dialect, url: dbPath });
|
|
39
|
+
switch (sub) {
|
|
40
|
+
case "create": {
|
|
41
|
+
const name = rest[0];
|
|
42
|
+
if (!name) {
|
|
43
|
+
console.error("error: tenant name is required");
|
|
44
|
+
usage(1);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const tenant = await createTenant(store, name);
|
|
48
|
+
console.log(`Created tenant "${tenant.name}"`);
|
|
49
|
+
console.log(`API key: ${tenant.apiKey}`);
|
|
50
|
+
console.log("Store this securely — it will not be shown again.");
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error((err as Error).message);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case "list": {
|
|
58
|
+
const rows = await listTenants(store);
|
|
59
|
+
if (rows.length === 0) {
|
|
60
|
+
console.log("(no tenants)");
|
|
61
|
+
} else {
|
|
62
|
+
for (const t of rows) {
|
|
63
|
+
const masked = t.apiKey.slice(0, 6) + "…" + t.apiKey.slice(-4);
|
|
64
|
+
console.log(`${t.id}\t${t.name}\t${masked}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case "delete": {
|
|
70
|
+
const name = rest[0];
|
|
71
|
+
if (!name) {
|
|
72
|
+
console.error("error: tenant name is required");
|
|
73
|
+
usage(1);
|
|
74
|
+
}
|
|
75
|
+
const ok = await deleteTenant(store, name);
|
|
76
|
+
if (!ok) {
|
|
77
|
+
console.error(`tenant "${name}" not found`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
console.log(`Deleted tenant "${name}"`);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case "rotate": {
|
|
84
|
+
const name = rest[0];
|
|
85
|
+
if (!name) {
|
|
86
|
+
console.error("error: tenant name is required");
|
|
87
|
+
usage(1);
|
|
88
|
+
}
|
|
89
|
+
const key = await rotateTenantKey(store, name);
|
|
90
|
+
if (!key) {
|
|
91
|
+
console.error(`tenant "${name}" not found`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
console.log(`Rotated API key for "${name}"`);
|
|
95
|
+
console.log(`API key: ${key}`);
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
default:
|
|
99
|
+
usage(1);
|
|
100
|
+
}
|
|
101
|
+
store.close();
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
106
|
+
usage(0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (cmd && cmd !== "serve") {
|
|
110
|
+
console.error(`unknown command: ${cmd}`);
|
|
111
|
+
usage(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const app = await createServer({ port, dbPath, dialect });
|
|
115
|
+
|
|
116
|
+
Bun.serve({ port, fetch: app.fetch });
|
|
117
|
+
|
|
118
|
+
console.log(`[blockrate-server] listening on http://localhost:${port} (${dialect})`);
|
|
119
|
+
console.log(`[blockrate-server] dashboard: http://localhost:${port}/dashboard`);
|