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 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
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "sqlite",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "6",
8
+ "when": 1775652139660,
9
+ "tag": "0000_square_storm",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
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`);