| Component | Choice | Why |
|---|---|---|
| Runtime | Bun | Fast, built-in TypeScript, built-in test runner, built-in SQLite |
| Language | TypeScript | Type safety without build step (Bun runs it natively) |
| HTTP | Hono | Lightweight, fast, middleware-friendly, works everywhere |
| Data | JSONL files | Simple, human-readable, append-only, easy to debug. SQLite as upgrade path if needed |
| Calendar | Google Calendar API (googleapis) | Existing integration, service account auth |
| Blockchain | viem | ERC20 minting for shift rewards (CHT token) |
| Nostr | nostr-tools | Event creation, signing, relay publishing |
| Docs | Markdown → HTML | Serve /docs as rendered HTML, .md extension for raw markdown |
src/
├── index.ts # Entry point, Hono app setup
├── routes/
│ ├── rooms.ts # Room booking endpoints
│ ├── shifts.ts # Shift signup endpoints
│ ├── users.ts # User profile endpoints
│ ├── auth.ts # Device auth flow, app management
│ ├── actions.ts # Prepared action system
│ └── docs.ts # Docs serving (markdown → HTML)
├── services/
│ ├── calendar.ts # Google Calendar operations
│ ├── shifts.ts # Shift business logic
│ ├── rooms.ts # Room booking business logic
│ ├── rewards.ts # Token minting
│ ├── users.ts # User store
│ └── apps.ts # App registration and auth
├── middleware/
│ ├── auth.ts # App auth middleware
│ └── logging.ts # Request logging
├── lib/
│ ├── google-calendar.ts # Google Calendar client
│ ├── blockchain.ts # viem client, minting
│ ├── nostr.ts # Nostr key management, signing, publishing
│ ├── crypto.ts # Envelope encryption (AES-256-GCM)
│ └── config.ts # Environment config
└── data/ # Runtime data (gitignored)
├── users.jsonl
├── apps.jsonl # Registered apps (hashed secrets)
├── device-codes.jsonl # Pending device auth codes
└── actions.jsonl # Prepared actions
- Debuggable:
cat data/users.jsonl | jq .— instant insight - Append-only: No corruption risk from partial writes
- Portable: Copy files to move data, no dump/restore
- Simple: No connection pooling, no migrations, no ORM
- Good enough: We're managing a single coworking space, not millions of users
If JSONL becomes a bottleneck (>10k records, need complex queries):
- Bun has built-in SQLite (
bun:sqlite) — zero dependencies - Migrate with a one-time script that reads JSONL and inserts into SQLite
- Keep the same service interfaces — only the storage layer changes
Each file is append-only JSONL. One JSON object per line.
{"id":"u_1","username":"xdamman","displayName":"Xavier Damman","email":"x@example.com","createdAt":"2026-03-18T15:00:00Z"}
{"id":"u_1","username":"xdamman","displayName":"Xavier Damman","email":"new@example.com","updatedAt":"2026-03-18T16:00:00Z"}Updates are appended. On startup, the file is read and the latest entry per ID wins. Periodic compaction removes stale entries.
The API is identity-provider agnostic. It doesn't know about Discord, Telegram, or any platform.
Apps are external clients that connect to the API. Each app:
- Registers once and receives an
appId+appSecret - Authenticates requests with
Authorization: Bearer <appSecret> - Vouches for its users via
X-User-Idheader - The API trusts the app to have verified the user's identity
App registers:
POST /v1/apps { name: "ElinorBot" }
→ { appId: "app_abc", appSecret: "chb_sk_live_..." } (secret shown once)
App makes requests:
Authorization: Bearer chb_sk_live_...
X-User-Id: u_849888126
The appSecret is stored as a SHA-256 hash in data/apps.jsonl. The plaintext is never stored.
Users are platform-agnostic. A user created via the Discord bot and a user created via CLI are the same entity if linked. Apps provide user metadata (display name, username) on first interaction.
For headless clients that can't open a browser inline:
CLI API Browser
| | |
|-- POST /auth/device->| |
| (with app creds) | |
|<-- deviceCode, | |
| userCode: 482901 | |
| | |
| "Enter 482901 at | |
| api.../auth/verify" | |
| | |
| |<-- User opens URL ---|
| |<-- Enters code ------|
| |--- Links to user --->|
| | |
|-- GET /auth/device/ | |
| :deviceCode ------>| |
|<-- token, userId ----| |
The user must already have an API account (created via any app). The device flow links a CLI session to that existing account.
A master API key (set via ADMIN_API_KEY env var) grants admin access for:
- Registering/revoking apps
- Managing users
- Any endpoint without user context
| Dependency | Version | Purpose |
|---|---|---|
hono |
^4.x | HTTP framework |
googleapis |
^144.x | Google Calendar API |
viem |
^2.x | Ethereum interactions (ERC20 minting) |
nostr-tools |
^2.x | Nostr event creation, signing, relay communication |
marked |
^15.x | Markdown → HTML for docs |
Five production dependencies.
All config via environment variables:
# Server
PORT=3000
BASE_URL=https://api.commonshub.brussels
# Admin
ADMIN_API_KEY=chb_admin_... # Master key for app registration etc.
# Google Calendar
GOOGLE_ACCOUNT_KEY_FILEPATH=./google-account-key.json
GOOGLE_CALENDAR_IMPERSONATE_USER=commonshub@opencollective.com
# Blockchain
RPC_URL=https://...
MINTER_PRIVATE_KEY=0x...
# Nostr
NSEC_MASTER_KEY=... # 32 bytes hex, for encrypting user nsecs
NOSTR_RELAY_URL=wss://relay.commonshub.brussels
NOSTR_ENABLED=true
# Data
DATA_DIR=./data- Runtime: Bun (single binary, no node_modules in production)
- Container: Dockerfile with
oven/bun:1base - Hosting: Coolify on existing VPS (91.99.139.62)
- Domain:
api.commonshub.brussels→ Coolify reverse proxy - SSL: Managed by Coolify (Let's Encrypt)
- Single tenant: This API serves one Commons Hub (Brussels). Multi-tenancy is not a goal.
- Low traffic: <100 requests/minute. No need for caching layers, queues, or horizontal scaling.
- Google Calendar is the source of truth for room bookings and shift events. The API reads/writes to Google Calendar directly.
- Platform agnostic: Users can come from Discord, CLI, web, agents — the API doesn't care. Apps vouch for their users.
- Token minting is slow (~5-15 seconds per transaction). Reward endpoints should respond immediately and process minting asynchronously, returning tx hashes via polling or webhook.
- The API and its clients are independent. The Discord bot calls the API, but the API never calls the bot.