From fe912e6a94d35b2accd2711765cb582b523afae8 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Sun, 16 Nov 2025 16:34:28 +0100 Subject: [PATCH] feat: Implement content-based offer IDs with SHA-256 hashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added hash-id.ts utility for SHA-256 content hashing - Offer IDs now generated from hash of {sdp, topics} (sorted) - Removed peerId from hash (inferred from authentication) - Server generates deterministic IDs for idempotent offer creation - Updated SQLite and D1 storage implementations - Removed optional id field from CreateOfferRequest - Same offer content always produces same ID 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .dockerignore | 12 + .gitignore | 13 + API.md | 458 +++++++++++ CLAUDE.md | 47 ++ Dockerfile | 57 ++ README.md | 183 +++++ build.js | 29 + migrations/0001_add_peer_id.sql | 21 + migrations/0002_remove_topics.sql | 22 + migrations/0003_remove_origin.sql | 29 + migrations/schema.sql | 18 + package-lock.json | 1216 +++++++++++++++++++++++++++++ package.json | 27 + src/app.ts | 529 +++++++++++++ src/bloom.ts | 66 ++ src/config.ts | 51 ++ src/crypto.ts | 149 ++++ src/index.ts | 75 ++ src/middleware/auth.ts | 51 ++ src/storage/d1.ts | 371 +++++++++ src/storage/hash-id.ts | 37 + src/storage/sqlite.ts | 385 +++++++++ src/storage/types.ts | 167 ++++ src/worker.ts | 74 ++ tsconfig.json | 20 + wrangler.toml | 45 ++ wrangler.toml.example | 26 + 27 files changed, 4178 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 API.md create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 build.js create mode 100644 migrations/0001_add_peer_id.sql create mode 100644 migrations/0002_remove_topics.sql create mode 100644 migrations/0003_remove_origin.sql create mode 100644 migrations/schema.sql create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/app.ts create mode 100644 src/bloom.ts create mode 100644 src/config.ts create mode 100644 src/crypto.ts create mode 100644 src/index.ts create mode 100644 src/middleware/auth.ts create mode 100644 src/storage/d1.ts create mode 100644 src/storage/hash-id.ts create mode 100644 src/storage/sqlite.ts create mode 100644 src/storage/types.ts create mode 100644 src/worker.ts create mode 100644 tsconfig.json create mode 100644 wrangler.toml create mode 100644 wrangler.toml.example diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0f085da --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +*.log +.git +.gitignore +.env +README.md +API.md +.DS_Store +*.db +*.db-journal +data/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62ed63c --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +*.log +.DS_Store +.env +*.db +*.db-journal +data/ + +# Wrangler / Cloudflare Workers +.wrangler/ +.dev.vars +wrangler.toml.backup diff --git a/API.md b/API.md new file mode 100644 index 0000000..7ba338c --- /dev/null +++ b/API.md @@ -0,0 +1,458 @@ +# HTTP API + +This API provides peer signaling and tracking endpoints for distributed peer-to-peer applications. Uses JSON request/response bodies with Origin-based session isolation. + +All endpoints require an `Origin` header and accept `application/json` content type. + +--- + +## Overview + +Sessions are organized by: +- **Origin**: The HTTP Origin header (e.g., `https://example.com`) - isolates sessions by application +- **Topic**: A string identifier for grouping related peers (max 256 chars) +- **Info**: User-provided metadata (max 1024 chars) to uniquely identify each peer + +This allows multiple peers from the same application (origin) to discover each other through topics while preventing duplicate connections by comparing the info field. + +--- + +## GET `/` + +Returns server version information including the git commit hash used to build the server. + +### Response + +**Content-Type:** `application/json` + +**Success (200 OK):** +```json +{ + "version": "a1b2c3d" +} +``` + +**Notes:** +- Returns the git commit hash from build time +- Returns "unknown" if git information is not available + +### Example + +```bash +curl -X GET http://localhost:3000/ +``` + +--- + +## GET `/topics` + +Lists all topics with the count of available peers for each (paginated). Returns only topics that have unanswered sessions. + +### Request + +**Headers:** +- `Origin: https://example.com` (required) + +**Query Parameters:** + +| Parameter | Type | Required | Default | Description | +|-----------|--------|----------|---------|---------------------------------| +| `page` | number | No | `1` | Page number (starting from 1) | +| `limit` | number | No | `100` | Results per page (max 1000) | + +### Response + +**Content-Type:** `application/json` + +**Success (200 OK):** +```json +{ + "topics": [ + { + "topic": "my-room", + "count": 3 + }, + { + "topic": "another-room", + "count": 1 + } + ], + "pagination": { + "page": 1, + "limit": 100, + "total": 2, + "hasMore": false + } +} +``` + +**Notes:** +- Only returns topics from the same origin as the request +- Only includes topics with at least one unanswered session +- Topics are sorted alphabetically +- Counts only include unexpired sessions +- Maximum 1000 results per page + +### Examples + +**Default pagination (page 1, limit 100):** +```bash +curl -X GET http://localhost:3000/topics \ + -H "Origin: https://example.com" +``` + +**Custom pagination:** +```bash +curl -X GET "http://localhost:3000/topics?page=2&limit=50" \ + -H "Origin: https://example.com" +``` + +--- + +## GET `/:topic/sessions` + +Discovers available peers for a given topic. Returns all unanswered sessions from the requesting origin. + +### Request + +**Headers:** +- `Origin: https://example.com` (required) + +**Path Parameters:** + +| Parameter | Type | Required | Description | +|-----------|--------|----------|-------------------------------| +| `topic` | string | Yes | Topic identifier to query | + +### Response + +**Content-Type:** `application/json` + +**Success (200 OK):** +```json +{ + "sessions": [ + { + "code": "550e8400-e29b-41d4-a716-446655440000", + "info": "peer-123", + "offer": "", + "offerCandidates": [""], + "createdAt": 1699564800000, + "expiresAt": 1699565100000 + }, + { + "code": "660e8400-e29b-41d4-a716-446655440001", + "info": "peer-456", + "offer": "", + "offerCandidates": [], + "createdAt": 1699564850000, + "expiresAt": 1699565150000 + } + ] +} +``` + +**Notes:** +- Only returns sessions from the same origin as the request +- Only returns sessions that haven't been answered yet +- Sessions are ordered by creation time (newest first) +- Use the `info` field to avoid answering your own offers + +### Example + +```bash +curl -X GET http://localhost:3000/my-room/sessions \ + -H "Origin: https://example.com" +``` + +--- + +## POST `/:topic/offer` + +Announces peer availability and creates a new session for the specified topic. Returns a unique session code (UUID) for other peers to connect to. + +### Request + +**Headers:** +- `Content-Type: application/json` +- `Origin: https://example.com` (required) + +**Path Parameters:** + +| Parameter | Type | Required | Description | +|-----------|--------|----------|----------------------------------------------| +| `topic` | string | Yes | Topic identifier for grouping peers (max 256 characters) | + +**Body Parameters:** + +| Parameter | Type | Required | Description | +|-----------|--------|----------|----------------------------------------------| +| `info` | string | Yes | Peer identifier/metadata (max 1024 characters) | +| `offer` | string | Yes | Signaling data for peer connection | + +### Response + +**Content-Type:** `application/json` + +**Success (200 OK):** +```json +{ + "code": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +Returns a unique UUID session code. + +### Example + +```bash +curl -X POST http://localhost:3000/my-room/offer \ + -H "Content-Type: application/json" \ + -H "Origin: https://example.com" \ + -d '{ + "info": "peer-123", + "offer": "" + }' + +# Response: +# {"code":"550e8400-e29b-41d4-a716-446655440000"} +``` + +--- + +## POST `/answer` + +Connects to an existing peer session by sending connection data or exchanging signaling information. + +### Request + +**Headers:** +- `Content-Type: application/json` +- `Origin: https://example.com` (required) + +**Body Parameters:** + +| Parameter | Type | Required | Description | +|-------------|--------|----------|----------------------------------------------------------| +| `code` | string | Yes | The session UUID from the offer | +| `answer` | string | No* | Response signaling data for connection establishment | +| `candidate` | string | No* | Additional signaling data for connection negotiation | +| `side` | string | Yes | Which peer is sending: `offerer` or `answerer` | + +*Either `answer` or `candidate` must be provided, but not both. + +### Response + +**Content-Type:** `application/json` + +**Success (200 OK):** +```json +{ + "success": true +} +``` + +**Notes:** +- Origin header must match the session's origin +- Sessions are isolated by origin to group topics by domain + +### Examples + +**Sending connection response:** +```bash +curl -X POST http://localhost:3000/answer \ + -H "Content-Type: application/json" \ + -H "Origin: https://example.com" \ + -d '{ + "code": "550e8400-e29b-41d4-a716-446655440000", + "answer": "", + "side": "answerer" + }' + +# Response: +# {"success":true} +``` + +**Sending additional signaling data:** +```bash +curl -X POST http://localhost:3000/answer \ + -H "Content-Type: application/json" \ + -H "Origin: https://example.com" \ + -d '{ + "code": "550e8400-e29b-41d4-a716-446655440000", + "candidate": "", + "side": "offerer" + }' + +# Response: +# {"success":true} +``` + +--- + +## POST `/poll` + +Retrieves session data including offers, responses, and signaling information from the other peer. + +### Request + +**Headers:** +- `Content-Type: application/json` +- `Origin: https://example.com` (required) + +**Body Parameters:** + +| Parameter | Type | Required | Description | +|-----------|--------|----------|-------------------------------------------------| +| `code` | string | Yes | The session UUID | +| `side` | string | Yes | Which side is polling: `offerer` or `answerer` | + +### Response + +**Content-Type:** `application/json` + +**Success (200 OK):** + +Response varies by side: + +**For `side=offerer` (the offerer polls for response from answerer):** +```json +{ + "answer": "", + "answerCandidates": [ + "", + "" + ] +} +``` + +**For `side=answerer` (the answerer polls for offer from offerer):** +```json +{ + "offer": "", + "offerCandidates": [ + "", + "" + ] +} +``` + +**Notes:** +- `answer` will be `null` if the answerer hasn't responded yet +- Candidate arrays will be empty `[]` if no additional signaling data has been sent +- Use this endpoint for polling to check for new signaling data +- Origin header must match the session's origin + +### Examples + +**Answerer polling for signaling data:** +```bash +curl -X POST http://localhost:3000/poll \ + -H "Content-Type: application/json" \ + -H "Origin: https://example.com" \ + -d '{ + "code": "550e8400-e29b-41d4-a716-446655440000", + "side": "answerer" + }' + +# Response: +# { +# "offer": "", +# "offerCandidates": [""] +# } +``` + +**Offerer polling for response:** +```bash +curl -X POST http://localhost:3000/poll \ + -H "Content-Type: application/json" \ + -H "Origin: https://example.com" \ + -d '{ + "code": "550e8400-e29b-41d4-a716-446655440000", + "side": "offerer" + }' + +# Response: +# { +# "answer": "", +# "answerCandidates": [""] +# } +``` + +--- + +## GET `/health` + +Health check endpoint. + +### Response + +**Content-Type:** `application/json` + +**Success (200 OK):** +```json +{ + "status": "ok", + "timestamp": 1699564800000 +} +``` + +--- + +## Error Responses + +All endpoints may return the following error responses: + +**400 Bad Request:** +```json +{ + "error": "Missing or invalid required parameter: topic" +} +``` + +**404 Not Found:** +```json +{ + "error": "Session not found, expired, or origin mismatch" +} +``` + +**500 Internal Server Error:** +```json +{ + "error": "Internal server error" +} +``` + +--- + +## Usage Flow + +### Peer Discovery and Connection + +1. **Check server version (optional):** + - GET `/` to see server version information + +2. **Discover active topics:** + - GET `/topics` to see all topics and peer counts + - Optional: paginate through results with `?page=2&limit=100` + +3. **Peer A announces availability:** + - POST `/:topic/offer` with peer identifier and signaling data + - Receives a unique session code + +4. **Peer B discovers peers:** + - GET `/:topic/sessions` to list available sessions in a topic + - Filters out sessions with their own info to avoid self-connection + - Selects a peer to connect to + +5. **Peer B initiates connection:** + - POST `/answer` with the session code and their signaling data + +6. **Both peers exchange signaling information:** + - POST `/answer` with additional signaling data as needed + - POST `/poll` to retrieve signaling data from the other peer + +7. **Peer connection established** + - Peers use exchanged signaling data to establish direct connection + - Session automatically expires after configured timeout diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..273d55c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,47 @@ +# Rondevu Server Development Guidelines + +## WebRTC Signaling Best Practices + +### ICE Candidate Storage + +**IMPORTANT: Store ICE candidates as raw JSON without enforcing structure.** + +When handling ICE candidates in the signaling server: + +- ✅ **DO** store candidates as `JSON.stringify(candidate)` in the database +- ✅ **DO** retrieve candidates as `JSON.parse(candidate)` from the database +- ✅ **DO** use generic types like `any` in TypeScript for candidate data +- ❌ **DON'T** define strict types for ICE candidate structure +- ❌ **DON'T** validate or modify candidate properties +- ❌ **DON'T** assume you know what properties clients will send + +**Why?** The server is just a relay - it doesn't need to understand the candidate structure. Different browsers and future WebRTC versions may include different properties. By keeping the server agnostic, we maintain maximum compatibility. + +### Server Role Filtering + +The server MUST filter ICE candidates by role: +- Offerers receive only answerer candidates (`WHERE role = 'answerer'`) +- Answerers receive only offerer candidates (`WHERE role = 'offerer'`) + +This prevents peers from receiving their own candidates, which would cause connection failures. + +## Security + +- Always validate authentication tokens before allowing operations +- Verify ownership before allowing modifications +- Rate limit API endpoints to prevent abuse +- Clean up expired offers regularly + +## Performance + +- Use transactions for batch operations (SQLite) +- Index frequently queried columns (offer_id, role, created_at) +- Set appropriate TTLs for offers +- Implement pagination for large result sets + +## Code Quality + +- Handle errors gracefully with informative HTTP status codes +- Log important events for debugging +- Use TypeScript types for API contracts, but keep data types generic +- Write tests for critical paths diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..096d420 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source files +COPY tsconfig.json ./ +COPY build.js ./ +COPY src ./src + +# Build TypeScript +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Install production dependencies only +COPY package*.json ./ +RUN npm ci --omit=dev && \ + npm cache clean --force + +# Copy built files from builder +COPY --from=builder /app/dist ./dist + +# Create data directory for SQLite +RUN mkdir -p /app/data && \ + chown -R node:node /app + +# Switch to non-root user +USER node + +# Environment variables with defaults +ENV PORT=3000 +ENV STORAGE_TYPE=sqlite +ENV STORAGE_PATH=/app/data/sessions.db +ENV SESSION_TIMEOUT=300000 +ENV CODE_CHARS=0123456789 +ENV CODE_LENGTH=9 +ENV CORS_ORIGINS=* + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:${PORT}/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" + +# Start server +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e64383d --- /dev/null +++ b/README.md @@ -0,0 +1,183 @@ +# Rondevu Server + +🌐 **Topic-based peer discovery and WebRTC signaling** + +Scalable peer-to-peer connection establishment with topic-based discovery, stateless authentication, and complete WebRTC signaling. + +**Related repositories:** +- [@xtr-dev/rondevu-client](https://www.npmjs.com/package/@xtr-dev/rondevu-client) - TypeScript client library +- [rondevu-demo](https://rondevu-demo.pages.dev) - Interactive demo + +--- + +## Features + +- **Topic-Based Discovery**: Tag offers with topics (e.g., torrent infohashes) for efficient peer finding +- **Stateless Authentication**: AES-256-GCM encrypted credentials, no server-side sessions +- **Bloom Filters**: Client-side peer exclusion for efficient discovery +- **Multi-Offer Support**: Create multiple offers per peer simultaneously +- **Complete WebRTC Signaling**: Offer/answer exchange and ICE candidate relay +- **Dual Storage**: SQLite (Node.js/Docker) and Cloudflare D1 (Workers) backends + +## Quick Start + +**Node.js:** +```bash +npm install && npm start +``` + +**Docker:** +```bash +docker build -t rondevu . && docker run -p 3000:3000 -e STORAGE_PATH=:memory: rondevu +``` + +**Cloudflare Workers:** +```bash +npx wrangler deploy +``` + +## API Endpoints + +### Public Endpoints + +#### `GET /` +Returns server version and info + +#### `GET /health` +Health check endpoint with version + +#### `POST /register` +Register a new peer and receive credentials (peerId + secret) + +**Response:** +```json +{ + "peerId": "f17c195f067255e357232e34cf0735d9", + "secret": "DdorTR8QgSn9yngn+4qqR8cs1aMijvX..." +} +``` + +#### `GET /topics?limit=50&offset=0` +List all topics with active peer counts (paginated) + +**Query Parameters:** +- `limit` (optional): Maximum number of topics to return (default: 50, max: 200) +- `offset` (optional): Number of topics to skip (default: 0) + +**Response:** +```json +{ + "topics": [ + {"topic": "movie-xyz", "activePeers": 42}, + {"topic": "torrent-abc", "activePeers": 15} + ], + "total": 123, + "limit": 50, + "offset": 0 +} +``` + +#### `GET /offers/by-topic/:topic?limit=50&bloom=...` +Find offers by topic with optional bloom filter exclusion + +**Query Parameters:** +- `limit` (optional): Maximum offers to return (default: 50, max: 200) +- `bloom` (optional): Base64-encoded bloom filter to exclude known peers + +**Response:** +```json +{ + "topic": "movie-xyz", + "offers": [ + { + "id": "offer-id", + "peerId": "peer-id", + "sdp": "v=0...", + "topics": ["movie-xyz", "hd-content"], + "expiresAt": 1234567890, + "lastSeen": 1234567890 + } + ], + "total": 42, + "returned": 10 +} +``` + +#### `GET /peers/:peerId/offers` +View all offers from a specific peer + +### Authenticated Endpoints + +All authenticated endpoints require `Authorization: Bearer {peerId}:{secret}` header. + +#### `POST /offers` +Create one or more offers + +**Request:** +```json +{ + "offers": [ + { + "sdp": "v=0...", + "topics": ["movie-xyz", "hd-content"], + "ttl": 300000 + } + ] +} +``` + +#### `GET /offers/mine` +List all offers owned by authenticated peer + +#### `PUT /offers/:offerId/heartbeat` +Update last_seen timestamp for an offer + +#### `DELETE /offers/:offerId` +Delete a specific offer + +#### `POST /offers/:offerId/answer` +Answer an offer (locks it to answerer) + +**Request:** +```json +{ + "sdp": "v=0..." +} +``` + +#### `GET /offers/answers` +Poll for answers to your offers + +#### `POST /offers/:offerId/ice-candidates` +Post ICE candidates for an offer + +**Request:** +```json +{ + "candidates": ["candidate:1 1 UDP..."] +} +``` + +#### `GET /offers/:offerId/ice-candidates?since=1234567890` +Get ICE candidates from the other peer + +## Configuration + +Environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `3000` | Server port (Node.js/Docker) | +| `CORS_ORIGINS` | `*` | Comma-separated allowed origins | +| `STORAGE_PATH` | `./rondevu.db` | SQLite database path (use `:memory:` for in-memory) | +| `VERSION` | `0.4.0` | Server version (semver) | +| `AUTH_SECRET` | Random 32-byte hex | Secret key for credential encryption | +| `OFFER_DEFAULT_TTL` | `300000` | Default offer TTL in ms (5 minutes) | +| `OFFER_MIN_TTL` | `60000` | Minimum offer TTL in ms (1 minute) | +| `OFFER_MAX_TTL` | `3600000` | Maximum offer TTL in ms (1 hour) | +| `MAX_OFFERS_PER_REQUEST` | `10` | Maximum offers per create request | +| `MAX_TOPICS_PER_OFFER` | `20` | Maximum topics per offer | + +## License + +MIT diff --git a/build.js b/build.js new file mode 100644 index 0000000..c0c4e6b --- /dev/null +++ b/build.js @@ -0,0 +1,29 @@ +// Build script using esbuild +const esbuild = require('esbuild'); +const { execSync } = require('child_process'); + +// Get git commit hash +let version = 'unknown'; +try { + version = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim(); +} catch (err) { + console.warn('Could not get git commit hash, using "unknown"'); +} + +esbuild.build({ + entryPoints: ['src/index.ts'], + bundle: true, + platform: 'node', + target: 'node20', + outfile: 'dist/index.js', + format: 'cjs', + external: [ + 'better-sqlite3', + '@hono/node-server', + 'hono' + ], + sourcemap: true, + define: { + 'process.env.RONDEVU_VERSION': JSON.stringify(version) + } +}).catch(() => process.exit(1)); diff --git a/migrations/0001_add_peer_id.sql b/migrations/0001_add_peer_id.sql new file mode 100644 index 0000000..963b2a9 --- /dev/null +++ b/migrations/0001_add_peer_id.sql @@ -0,0 +1,21 @@ +-- Drop old sessions table with 'info' column +DROP TABLE IF EXISTS sessions; + +-- Create sessions table with peer_id column +CREATE TABLE sessions ( + code TEXT PRIMARY KEY, + origin TEXT NOT NULL, + topic TEXT NOT NULL, + peer_id TEXT NOT NULL CHECK(length(peer_id) <= 1024), + offer TEXT NOT NULL, + answer TEXT, + offer_candidates TEXT NOT NULL DEFAULT '[]', + answer_candidates TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +-- Create indexes for efficient queries +CREATE INDEX idx_expires_at ON sessions(expires_at); +CREATE INDEX idx_origin_topic ON sessions(origin, topic); +CREATE INDEX idx_origin_topic_expires ON sessions(origin, topic, expires_at); diff --git a/migrations/0002_remove_topics.sql b/migrations/0002_remove_topics.sql new file mode 100644 index 0000000..8fe4492 --- /dev/null +++ b/migrations/0002_remove_topics.sql @@ -0,0 +1,22 @@ +-- Remove topics and rename sessions to offers +-- This is a breaking change requiring a fresh database + +-- Drop old sessions table +DROP TABLE IF EXISTS sessions; + +-- Create offers table (without topic) +CREATE TABLE offers ( + code TEXT PRIMARY KEY, + origin TEXT NOT NULL, + peer_id TEXT NOT NULL CHECK(length(peer_id) <= 1024), + offer TEXT NOT NULL, + answer TEXT, + offer_candidates TEXT NOT NULL DEFAULT '[]', + answer_candidates TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +-- Create indexes for efficient queries +CREATE INDEX idx_offers_expires_at ON offers(expires_at); +CREATE INDEX idx_offers_origin ON offers(origin); diff --git a/migrations/0003_remove_origin.sql b/migrations/0003_remove_origin.sql new file mode 100644 index 0000000..4476060 --- /dev/null +++ b/migrations/0003_remove_origin.sql @@ -0,0 +1,29 @@ +-- Migration: Remove origin column from offers table +-- This simplifies offer lookup to only use offer codes +-- Origin-based bucketing is no longer needed + +-- Create new offers table without origin column +CREATE TABLE offers_new ( + code TEXT PRIMARY KEY, + peer_id TEXT NOT NULL CHECK(length(peer_id) <= 1024), + offer TEXT NOT NULL, + answer TEXT, + offer_candidates TEXT NOT NULL DEFAULT '[]', + answer_candidates TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +-- Copy data from old table +INSERT INTO offers_new (code, peer_id, offer, answer, offer_candidates, answer_candidates, created_at, expires_at) +SELECT code, peer_id, offer, answer, offer_candidates, answer_candidates, created_at, expires_at +FROM offers; + +-- Drop old table +DROP TABLE offers; + +-- Rename new table +ALTER TABLE offers_new RENAME TO offers; + +-- Recreate index +CREATE INDEX IF NOT EXISTS idx_offers_expires_at ON offers(expires_at); diff --git a/migrations/schema.sql b/migrations/schema.sql new file mode 100644 index 0000000..0fdfc51 --- /dev/null +++ b/migrations/schema.sql @@ -0,0 +1,18 @@ +-- Create sessions table +CREATE TABLE IF NOT EXISTS sessions ( + code TEXT PRIMARY KEY, + origin TEXT NOT NULL, + topic TEXT NOT NULL, + peer_id TEXT NOT NULL CHECK(length(peer_id) <= 1024), + offer TEXT NOT NULL, + answer TEXT, + offer_candidates TEXT NOT NULL DEFAULT '[]', + answer_candidates TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +-- Create indexes for efficient queries +CREATE INDEX IF NOT EXISTS idx_expires_at ON sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_origin_topic ON sessions(origin, topic); +CREATE INDEX IF NOT EXISTS idx_origin_topic_expires ON sessions(origin, topic, expires_at); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1714386 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1216 @@ +{ + "name": "@xtr-dev/rondevu-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@xtr-dev/rondevu-server", + "version": "0.1.0", + "dependencies": { + "@hono/node-server": "^1.19.6", + "better-sqlite3": "^12.4.1", + "hono": "^4.10.4" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20251014.0", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^24.9.2", + "esbuild": "^0.25.11", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20251014.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251014.0.tgz", + "integrity": "sha512-tEW98J/kOa0TdylIUOrLKRdwkUw0rvvYVlo+Ce0mqRH3c8kSoxLzUH9gfCvwLe0M89z1RkzFovSKAW2Nwtyn3w==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", + "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", + "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", + "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", + "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", + "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", + "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", + "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", + "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", + "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", + "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", + "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", + "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", + "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", + "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", + "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", + "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", + "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", + "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", + "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", + "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", + "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", + "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", + "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", + "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", + "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz", + "integrity": "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "24.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", + "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.4.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz", + "integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", + "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.11", + "@esbuild/android-arm": "0.25.11", + "@esbuild/android-arm64": "0.25.11", + "@esbuild/android-x64": "0.25.11", + "@esbuild/darwin-arm64": "0.25.11", + "@esbuild/darwin-x64": "0.25.11", + "@esbuild/freebsd-arm64": "0.25.11", + "@esbuild/freebsd-x64": "0.25.11", + "@esbuild/linux-arm": "0.25.11", + "@esbuild/linux-arm64": "0.25.11", + "@esbuild/linux-ia32": "0.25.11", + "@esbuild/linux-loong64": "0.25.11", + "@esbuild/linux-mips64el": "0.25.11", + "@esbuild/linux-ppc64": "0.25.11", + "@esbuild/linux-riscv64": "0.25.11", + "@esbuild/linux-s390x": "0.25.11", + "@esbuild/linux-x64": "0.25.11", + "@esbuild/netbsd-arm64": "0.25.11", + "@esbuild/netbsd-x64": "0.25.11", + "@esbuild/openbsd-arm64": "0.25.11", + "@esbuild/openbsd-x64": "0.25.11", + "@esbuild/openharmony-arm64": "0.25.11", + "@esbuild/sunos-x64": "0.25.11", + "@esbuild/win32-arm64": "0.25.11", + "@esbuild/win32-ia32": "0.25.11", + "@esbuild/win32-x64": "0.25.11" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.10.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.4.tgz", + "integrity": "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.80.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.80.0.tgz", + "integrity": "sha512-LyPuZJcI9HVwzXK1GPxWNzrr+vr8Hp/3UqlmWxxh8p54U1ZbclOqbSog9lWHaCX+dBaiGi6n/hIX+mKu74GmPA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7f65755 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "@xtr-dev/rondevu-server", + "version": "0.1.0", + "description": "Topic-based peer discovery and signaling server for distributed P2P applications", + "main": "dist/index.js", + "scripts": { + "build": "node build.js", + "typecheck": "tsc", + "dev": "ts-node src/index.ts", + "start": "node dist/index.js", + "test": "echo \"Error: no test specified\" && exit 1", + "deploy": "npx wrangler deploy src/worker.ts --var VERSION:$(git rev-parse --short HEAD)" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20251014.0", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^24.9.2", + "esbuild": "^0.25.11", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + }, + "dependencies": { + "@hono/node-server": "^1.19.6", + "better-sqlite3": "^12.4.1", + "hono": "^4.10.4" + } +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..b8fffa2 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,529 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { Storage } from './storage/types.ts'; +import { Config } from './config.ts'; +import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts'; +import { generatePeerId, encryptPeerId } from './crypto.ts'; +import { parseBloomFilter } from './bloom.ts'; +import type { Context } from 'hono'; + +/** + * Creates the Hono application with topic-based WebRTC signaling endpoints + */ +export function createApp(storage: Storage, config: Config) { + const app = new Hono(); + + // Create auth middleware + const authMiddleware = createAuthMiddleware(config.authSecret); + + // Enable CORS with dynamic origin handling + app.use('/*', cors({ + origin: (origin) => { + // If no origin restrictions (wildcard), allow any origin + if (config.corsOrigins.length === 1 && config.corsOrigins[0] === '*') { + return origin; + } + // Otherwise check if origin is in allowed list + if (config.corsOrigins.includes(origin)) { + return origin; + } + // Default to first allowed origin + return config.corsOrigins[0]; + }, + allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Origin', 'Authorization'], + exposeHeaders: ['Content-Type'], + maxAge: 600, + credentials: true, + })); + + /** + * GET / + * Returns server version information + */ + app.get('/', (c) => { + return c.json({ + version: config.version, + name: 'Rondevu', + description: 'Topic-based peer discovery and signaling server' + }); + }); + + /** + * GET /health + * Health check endpoint with version + */ + app.get('/health', (c) => { + return c.json({ + status: 'ok', + timestamp: Date.now(), + version: config.version + }); + }); + + /** + * POST /register + * Register a new peer and receive credentials + */ + app.post('/register', async (c) => { + try { + // Generate new peer ID + const peerId = generatePeerId(); + + // Encrypt peer ID with server secret (async operation) + const secret = await encryptPeerId(peerId, config.authSecret); + + return c.json({ + peerId, + secret + }, 200); + } catch (err) { + console.error('Error registering peer:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * POST /offers + * Creates one or more offers with topics + * Requires authentication + */ + app.post('/offers', authMiddleware, async (c) => { + try { + const body = await c.req.json(); + const { offers } = body; + + if (!Array.isArray(offers) || offers.length === 0) { + return c.json({ error: 'Missing or invalid required parameter: offers (must be non-empty array)' }, 400); + } + + if (offers.length > config.maxOffersPerRequest) { + return c.json({ error: `Too many offers. Maximum ${config.maxOffersPerRequest} per request` }, 400); + } + + const peerId = getAuthenticatedPeerId(c); + + // Validate and prepare offers + const offerRequests = []; + for (const offer of offers) { + // Validate SDP + if (!offer.sdp || typeof offer.sdp !== 'string') { + return c.json({ error: 'Each offer must have an sdp field' }, 400); + } + + if (offer.sdp.length > 65536) { + return c.json({ error: 'SDP must be 64KB or less' }, 400); + } + + // Validate topics + if (!Array.isArray(offer.topics) || offer.topics.length === 0) { + return c.json({ error: 'Each offer must have a non-empty topics array' }, 400); + } + + if (offer.topics.length > config.maxTopicsPerOffer) { + return c.json({ error: `Too many topics. Maximum ${config.maxTopicsPerOffer} per offer` }, 400); + } + + for (const topic of offer.topics) { + if (typeof topic !== 'string' || topic.length === 0 || topic.length > 256) { + return c.json({ error: 'Each topic must be a string between 1 and 256 characters' }, 400); + } + } + + // Validate and clamp TTL + let ttl = offer.ttl || config.offerDefaultTtl; + if (ttl < config.offerMinTtl) { + ttl = config.offerMinTtl; + } + if (ttl > config.offerMaxTtl) { + ttl = config.offerMaxTtl; + } + + offerRequests.push({ + id: offer.id, + peerId, + sdp: offer.sdp, + topics: offer.topics, + expiresAt: Date.now() + ttl, + }); + } + + // Create offers + const createdOffers = await storage.createOffers(offerRequests); + + // Return simplified response + return c.json({ + offers: createdOffers.map(o => ({ + id: o.id, + peerId: o.peerId, + topics: o.topics, + expiresAt: o.expiresAt + })) + }, 200); + } catch (err) { + console.error('Error creating offers:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * GET /offers/by-topic/:topic + * Find offers by topic with optional bloom filter exclusion + * Public endpoint (no auth required) + */ + app.get('/offers/by-topic/:topic', async (c) => { + try { + const topic = c.req.param('topic'); + const bloomParam = c.req.query('bloom'); + const limitParam = c.req.query('limit'); + + const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50; + + // Parse bloom filter if provided + let excludePeerIds: string[] = []; + if (bloomParam) { + const bloom = parseBloomFilter(bloomParam); + if (!bloom) { + return c.json({ error: 'Invalid bloom filter format' }, 400); + } + + // Get all offers for topic first + const allOffers = await storage.getOffersByTopic(topic); + + // Test each peer ID against bloom filter + const excludeSet = new Set(); + for (const offer of allOffers) { + if (bloom.test(offer.peerId)) { + excludeSet.add(offer.peerId); + } + } + + excludePeerIds = Array.from(excludeSet); + } + + // Get filtered offers + let offers = await storage.getOffersByTopic(topic, excludePeerIds.length > 0 ? excludePeerIds : undefined); + + // Apply limit + const total = offers.length; + offers = offers.slice(0, limit); + + return c.json({ + topic, + offers: offers.map(o => ({ + id: o.id, + peerId: o.peerId, + sdp: o.sdp, + topics: o.topics, + expiresAt: o.expiresAt, + lastSeen: o.lastSeen + })), + total: bloomParam ? total + excludePeerIds.length : total, + returned: offers.length + }, 200); + } catch (err) { + console.error('Error fetching offers by topic:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * GET /topics + * List all topics with active peer counts (paginated) + * Public endpoint (no auth required) + */ + app.get('/topics', async (c) => { + try { + const limitParam = c.req.query('limit'); + const offsetParam = c.req.query('offset'); + + const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50; + const offset = offsetParam ? parseInt(offsetParam, 10) : 0; + + const result = await storage.getTopics(limit, offset); + + return c.json({ + topics: result.topics, + total: result.total, + limit, + offset + }, 200); + } catch (err) { + console.error('Error fetching topics:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * GET /peers/:peerId/offers + * View all offers from a specific peer + * Public endpoint + */ + app.get('/peers/:peerId/offers', async (c) => { + try { + const peerId = c.req.param('peerId'); + const offers = await storage.getOffersByPeerId(peerId); + + // Collect unique topics + const topicsSet = new Set(); + offers.forEach(o => o.topics.forEach(t => topicsSet.add(t))); + + return c.json({ + peerId, + offers: offers.map(o => ({ + id: o.id, + sdp: o.sdp, + topics: o.topics, + expiresAt: o.expiresAt, + lastSeen: o.lastSeen + })), + topics: Array.from(topicsSet) + }, 200); + } catch (err) { + console.error('Error fetching peer offers:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * GET /offers/mine + * List all offers owned by authenticated peer + * Requires authentication + */ + app.get('/offers/mine', authMiddleware, async (c) => { + try { + const peerId = getAuthenticatedPeerId(c); + const offers = await storage.getOffersByPeerId(peerId); + + return c.json({ + peerId, + offers: offers.map(o => ({ + id: o.id, + sdp: o.sdp, + topics: o.topics, + createdAt: o.createdAt, + expiresAt: o.expiresAt, + lastSeen: o.lastSeen, + answererPeerId: o.answererPeerId, + answeredAt: o.answeredAt + })) + }, 200); + } catch (err) { + console.error('Error fetching own offers:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * PUT /offers/:offerId/heartbeat + * Update last_seen timestamp for an offer + * Requires authentication and ownership + */ + app.put('/offers/:offerId/heartbeat', authMiddleware, async (c) => { + try { + const offerId = c.req.param('offerId'); + const peerId = getAuthenticatedPeerId(c); + + // Verify ownership + const offer = await storage.getOfferById(offerId); + if (!offer) { + return c.json({ error: 'Offer not found or expired' }, 404); + } + + if (offer.peerId !== peerId) { + return c.json({ error: 'Not authorized to update this offer' }, 403); + } + + const now = Date.now(); + await storage.updateOfferLastSeen(offerId, now); + + return c.json({ + id: offerId, + lastSeen: now + }, 200); + } catch (err) { + console.error('Error updating offer heartbeat:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * DELETE /offers/:offerId + * Delete a specific offer + * Requires authentication and ownership + */ + app.delete('/offers/:offerId', authMiddleware, async (c) => { + try { + const offerId = c.req.param('offerId'); + const peerId = getAuthenticatedPeerId(c); + + const deleted = await storage.deleteOffer(offerId, peerId); + + if (!deleted) { + return c.json({ error: 'Offer not found or not authorized' }, 404); + } + + return c.json({ deleted: true }, 200); + } catch (err) { + console.error('Error deleting offer:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * POST /offers/:offerId/answer + * Answer a specific offer (locks it to answerer) + * Requires authentication + */ + app.post('/offers/:offerId/answer', authMiddleware, async (c) => { + try { + const offerId = c.req.param('offerId'); + const peerId = getAuthenticatedPeerId(c); + const body = await c.req.json(); + const { sdp } = body; + + if (!sdp || typeof sdp !== 'string') { + return c.json({ error: 'Missing or invalid required parameter: sdp' }, 400); + } + + if (sdp.length > 65536) { + return c.json({ error: 'SDP must be 64KB or less' }, 400); + } + + const result = await storage.answerOffer(offerId, peerId, sdp); + + if (!result.success) { + return c.json({ error: result.error }, 400); + } + + return c.json({ + offerId, + answererId: peerId, + answeredAt: Date.now() + }, 200); + } catch (err) { + console.error('Error answering offer:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * GET /offers/answers + * Poll for answers to all of authenticated peer's offers + * Requires authentication (offerer) + */ + app.get('/offers/answers', authMiddleware, async (c) => { + try { + const peerId = getAuthenticatedPeerId(c); + const offers = await storage.getAnsweredOffers(peerId); + + return c.json({ + answers: offers.map(o => ({ + offerId: o.id, + answererId: o.answererPeerId, + sdp: o.answerSdp, + answeredAt: o.answeredAt, + topics: o.topics + })) + }, 200); + } catch (err) { + console.error('Error fetching answers:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * POST /offers/:offerId/ice-candidates + * Post ICE candidates for an offer + * Requires authentication (must be offerer or answerer) + */ + app.post('/offers/:offerId/ice-candidates', authMiddleware, async (c) => { + try { + const offerId = c.req.param('offerId'); + const peerId = getAuthenticatedPeerId(c); + const body = await c.req.json(); + const { candidates } = body; + + if (!Array.isArray(candidates) || candidates.length === 0) { + return c.json({ error: 'Missing or invalid required parameter: candidates (must be non-empty array)' }, 400); + } + + // Verify offer exists and caller is offerer or answerer + const offer = await storage.getOfferById(offerId); + if (!offer) { + return c.json({ error: 'Offer not found or expired' }, 404); + } + + let role: 'offerer' | 'answerer'; + if (offer.peerId === peerId) { + role = 'offerer'; + } else if (offer.answererPeerId === peerId) { + role = 'answerer'; + } else { + return c.json({ error: 'Not authorized to post ICE candidates for this offer' }, 403); + } + + const added = await storage.addIceCandidates(offerId, peerId, role, candidates); + + return c.json({ + offerId, + candidatesAdded: added + }, 200); + } catch (err) { + console.error('Error adding ICE candidates:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * GET /offers/:offerId/ice-candidates + * Poll for ICE candidates from the other peer + * Requires authentication (must be offerer or answerer) + */ + app.get('/offers/:offerId/ice-candidates', authMiddleware, async (c) => { + try { + const offerId = c.req.param('offerId'); + const peerId = getAuthenticatedPeerId(c); + const sinceParam = c.req.query('since'); + + const since = sinceParam ? parseInt(sinceParam, 10) : undefined; + + // Verify offer exists and caller is offerer or answerer + const offer = await storage.getOfferById(offerId); + if (!offer) { + return c.json({ error: 'Offer not found or expired' }, 404); + } + + let targetRole: 'offerer' | 'answerer'; + if (offer.peerId === peerId) { + // Offerer wants answerer's candidates + targetRole = 'answerer'; + console.log(`[ICE GET] Offerer ${peerId} requesting answerer ICE candidates for offer ${offerId}, since=${since}, answererPeerId=${offer.answererPeerId}`); + } else if (offer.answererPeerId === peerId) { + // Answerer wants offerer's candidates + targetRole = 'offerer'; + console.log(`[ICE GET] Answerer ${peerId} requesting offerer ICE candidates for offer ${offerId}, since=${since}, offererPeerId=${offer.peerId}`); + } else { + return c.json({ error: 'Not authorized to view ICE candidates for this offer' }, 403); + } + + const candidates = await storage.getIceCandidates(offerId, targetRole, since); + console.log(`[ICE GET] Found ${candidates.length} candidates for offer ${offerId}, targetRole=${targetRole}, since=${since}`); + + return c.json({ + offerId, + candidates: candidates.map(c => ({ + candidate: c.candidate, + peerId: c.peerId, + role: c.role, + createdAt: c.createdAt + })) + }, 200); + } catch (err) { + console.error('Error fetching ICE candidates:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + return app; +} diff --git a/src/bloom.ts b/src/bloom.ts new file mode 100644 index 0000000..0e0af2e --- /dev/null +++ b/src/bloom.ts @@ -0,0 +1,66 @@ +/** + * Bloom filter utility for testing if peer IDs might be in a set + * Used to filter out known peers from discovery results + */ + +export class BloomFilter { + private bits: Uint8Array; + private size: number; + private numHashes: number; + + /** + * Creates a bloom filter from a base64 encoded bit array + */ + constructor(base64Data: string, numHashes: number = 3) { + // Decode base64 to Uint8Array (works in both Node.js and Workers) + const binaryString = atob(base64Data); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + this.bits = bytes; + this.size = this.bits.length * 8; + this.numHashes = numHashes; + } + + /** + * Test if a peer ID might be in the filter + * Returns true if possibly in set, false if definitely not in set + */ + test(peerId: string): boolean { + for (let i = 0; i < this.numHashes; i++) { + const hash = this.hash(peerId, i); + const index = hash % this.size; + const byteIndex = Math.floor(index / 8); + const bitIndex = index % 8; + + if (!(this.bits[byteIndex] & (1 << bitIndex))) { + return false; + } + } + return true; + } + + /** + * Simple hash function (FNV-1a variant) + */ + private hash(str: string, seed: number): number { + let hash = 2166136261 ^ seed; + for (let i = 0; i < str.length; i++) { + hash ^= str.charCodeAt(i); + hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); + } + return hash >>> 0; + } +} + +/** + * Helper to parse bloom filter from base64 string + */ +export function parseBloomFilter(base64: string): BloomFilter | null { + try { + return new BloomFilter(base64); + } catch { + return null; + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..d22f95d --- /dev/null +++ b/src/config.ts @@ -0,0 +1,51 @@ +import { generateSecretKey } from './crypto.ts'; + +/** + * Application configuration + * Reads from environment variables with sensible defaults + */ +export interface Config { + port: number; + storageType: 'sqlite' | 'memory'; + storagePath: string; + corsOrigins: string[]; + version: string; + authSecret: string; + offerDefaultTtl: number; + offerMaxTtl: number; + offerMinTtl: number; + cleanupInterval: number; + maxOffersPerRequest: number; + maxTopicsPerOffer: number; +} + +/** + * Loads configuration from environment variables + */ +export function loadConfig(): Config { + // Generate or load auth secret + let authSecret = process.env.AUTH_SECRET; + if (!authSecret) { + authSecret = generateSecretKey(); + console.warn('WARNING: No AUTH_SECRET provided. Generated temporary secret:', authSecret); + console.warn('All peer credentials will be invalidated on server restart.'); + console.warn('Set AUTH_SECRET environment variable to persist credentials across restarts.'); + } + + return { + port: parseInt(process.env.PORT || '3000', 10), + storageType: (process.env.STORAGE_TYPE || 'sqlite') as 'sqlite' | 'memory', + storagePath: process.env.STORAGE_PATH || ':memory:', + corsOrigins: process.env.CORS_ORIGINS + ? process.env.CORS_ORIGINS.split(',').map(o => o.trim()) + : ['*'], + version: process.env.VERSION || 'unknown', + authSecret, + offerDefaultTtl: parseInt(process.env.OFFER_DEFAULT_TTL || '60000', 10), + offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10), + offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10), + cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || '60000', 10), + maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || '100', 10), + maxTopicsPerOffer: parseInt(process.env.MAX_TOPICS_PER_OFFER || '50', 10), + }; +} diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 0000000..dc4afe0 --- /dev/null +++ b/src/crypto.ts @@ -0,0 +1,149 @@ +/** + * Crypto utilities for stateless peer authentication + * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers + */ + +const ALGORITHM = 'AES-GCM'; +const IV_LENGTH = 12; // 96 bits for GCM +const KEY_LENGTH = 32; // 256 bits + +/** + * Generates a random peer ID (16 bytes = 32 hex chars) + */ +export function generatePeerId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Generates a random secret key for encryption (32 bytes = 64 hex chars) + */ +export function generateSecretKey(): string { + const bytes = crypto.getRandomValues(new Uint8Array(KEY_LENGTH)); + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Convert hex string to Uint8Array + */ +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; +} + +/** + * Convert Uint8Array to base64 string + */ +function bytesToBase64(bytes: Uint8Array): string { + const binString = Array.from(bytes, (byte) => + String.fromCodePoint(byte) + ).join(''); + return btoa(binString); +} + +/** + * Convert base64 string to Uint8Array + */ +function base64ToBytes(base64: string): Uint8Array { + const binString = atob(base64); + return Uint8Array.from(binString, (char) => char.codePointAt(0)!); +} + +/** + * Encrypts a peer ID using the server secret key + * Returns base64-encoded encrypted data (IV + ciphertext) + */ +export async function encryptPeerId(peerId: string, secretKeyHex: string): Promise { + const keyBytes = hexToBytes(secretKeyHex); + + if (keyBytes.length !== KEY_LENGTH) { + throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`); + } + + // Import key + const key = await crypto.subtle.importKey( + 'raw', + keyBytes, + { name: ALGORITHM, length: 256 }, + false, + ['encrypt'] + ); + + // Generate random IV + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + + // Encrypt peer ID + const encoder = new TextEncoder(); + const data = encoder.encode(peerId); + + const encrypted = await crypto.subtle.encrypt( + { name: ALGORITHM, iv }, + key, + data + ); + + // Combine IV + ciphertext and encode as base64 + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(encrypted), iv.length); + + return bytesToBase64(combined); +} + +/** + * Decrypts an encrypted peer ID secret + * Returns the plaintext peer ID or throws if decryption fails + */ +export async function decryptPeerId(encryptedSecret: string, secretKeyHex: string): Promise { + try { + const keyBytes = hexToBytes(secretKeyHex); + + if (keyBytes.length !== KEY_LENGTH) { + throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`); + } + + // Decode base64 + const combined = base64ToBytes(encryptedSecret); + + // Extract IV and ciphertext + const iv = combined.slice(0, IV_LENGTH); + const ciphertext = combined.slice(IV_LENGTH); + + // Import key + const key = await crypto.subtle.importKey( + 'raw', + keyBytes, + { name: ALGORITHM, length: 256 }, + false, + ['decrypt'] + ); + + // Decrypt + const decrypted = await crypto.subtle.decrypt( + { name: ALGORITHM, iv }, + key, + ciphertext + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } catch (err) { + throw new Error('Failed to decrypt peer ID: invalid secret or secret key'); + } +} + +/** + * Validates that a peer ID and secret match + * Returns true if valid, false otherwise + */ +export async function validateCredentials(peerId: string, encryptedSecret: string, secretKey: string): Promise { + try { + const decryptedPeerId = await decryptPeerId(encryptedSecret, secretKey); + return decryptedPeerId === peerId; + } catch { + return false; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..dd5d302 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,75 @@ +import { serve } from '@hono/node-server'; +import { createApp } from './app.ts'; +import { loadConfig } from './config.ts'; +import { SQLiteStorage } from './storage/sqlite.ts'; +import { Storage } from './storage/types.ts'; + +/** + * Main entry point for the standalone Node.js server + */ +async function main() { + const config = loadConfig(); + + console.log('Starting Rondevu server...'); + console.log('Configuration:', { + port: config.port, + storageType: config.storageType, + storagePath: config.storagePath, + offerDefaultTtl: `${config.offerDefaultTtl}ms`, + offerMaxTtl: `${config.offerMaxTtl}ms`, + offerMinTtl: `${config.offerMinTtl}ms`, + cleanupInterval: `${config.cleanupInterval}ms`, + maxOffersPerRequest: config.maxOffersPerRequest, + maxTopicsPerOffer: config.maxTopicsPerOffer, + corsOrigins: config.corsOrigins, + version: config.version, + }); + + let storage: Storage; + + if (config.storageType === 'sqlite') { + storage = new SQLiteStorage(config.storagePath); + console.log('Using SQLite storage'); + } else { + throw new Error('Unsupported storage type'); + } + + // Start periodic cleanup of expired offers + const cleanupInterval = setInterval(async () => { + try { + const now = Date.now(); + const deleted = await storage.deleteExpiredOffers(now); + if (deleted > 0) { + console.log(`Cleanup: Deleted ${deleted} expired offer(s)`); + } + } catch (err) { + console.error('Cleanup error:', err); + } + }, config.cleanupInterval); + + const app = createApp(storage, config); + + const server = serve({ + fetch: app.fetch, + port: config.port, + }); + + console.log(`Server running on http://localhost:${config.port}`); + console.log('Ready to accept connections'); + + // Graceful shutdown handler + const shutdown = async () => { + console.log('\nShutting down gracefully...'); + clearInterval(cleanupInterval); + await storage.close(); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..b0e3586 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,51 @@ +import { Context, Next } from 'hono'; +import { validateCredentials } from '../crypto.ts'; + +/** + * Authentication middleware for Rondevu + * Validates Bearer token in format: {peerId}:{encryptedSecret} + */ +export function createAuthMiddleware(authSecret: string) { + return async (c: Context, next: Next) => { + const authHeader = c.req.header('Authorization'); + + if (!authHeader) { + return c.json({ error: 'Missing Authorization header' }, 401); + } + + // Expect format: Bearer {peerId}:{secret} + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { + return c.json({ error: 'Invalid Authorization header format. Expected: Bearer {peerId}:{secret}' }, 401); + } + + const credentials = parts[1].split(':'); + if (credentials.length !== 2) { + return c.json({ error: 'Invalid credentials format. Expected: {peerId}:{secret}' }, 401); + } + + const [peerId, encryptedSecret] = credentials; + + // Validate credentials (async operation) + const isValid = await validateCredentials(peerId, encryptedSecret, authSecret); + if (!isValid) { + return c.json({ error: 'Invalid credentials' }, 401); + } + + // Attach peer ID to context for use in handlers + c.set('peerId', peerId); + + await next(); + }; +} + +/** + * Helper to get authenticated peer ID from context + */ +export function getAuthenticatedPeerId(c: Context): string { + const peerId = c.get('peerId'); + if (!peerId) { + throw new Error('No authenticated peer ID in context'); + } + return peerId; +} diff --git a/src/storage/d1.ts b/src/storage/d1.ts new file mode 100644 index 0000000..7c26815 --- /dev/null +++ b/src/storage/d1.ts @@ -0,0 +1,371 @@ +import { Storage, Offer, IceCandidate, CreateOfferRequest, TopicInfo } from './types.ts'; +import { generateOfferHash } from './hash-id.ts'; + +/** + * D1 storage adapter for topic-based offer management using Cloudflare D1 + * NOTE: This implementation is a placeholder and needs to be fully tested + */ +export class D1Storage implements Storage { + private db: D1Database; + + /** + * Creates a new D1 storage instance + * @param db D1Database instance from Cloudflare Workers environment + */ + constructor(db: D1Database) { + this.db = db; + } + + /** + * Initializes database schema with new topic-based structure + * This should be run once during setup, not on every request + */ + async initializeDatabase(): Promise { + await this.db.exec(` + CREATE TABLE IF NOT EXISTS offers ( + id TEXT PRIMARY KEY, + peer_id TEXT NOT NULL, + sdp TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + answerer_peer_id TEXT, + answer_sdp TEXT, + answered_at INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id); + CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at); + CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen); + CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id); + + CREATE TABLE IF NOT EXISTS offer_topics ( + offer_id TEXT NOT NULL, + topic TEXT NOT NULL, + PRIMARY KEY (offer_id, topic), + FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic); + CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id); + + CREATE TABLE IF NOT EXISTS ice_candidates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + offer_id TEXT NOT NULL, + peer_id TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')), + candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object + created_at INTEGER NOT NULL, + FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id); + CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id); + CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at); + `); + } + + async createOffers(offers: CreateOfferRequest[]): Promise { + const created: Offer[] = []; + + // D1 doesn't support true transactions yet, so we do this sequentially + for (const offer of offers) { + const id = offer.id || await generateOfferHash(offer.sdp, offer.topics); + const now = Date.now(); + + // Insert offer + await this.db.prepare(` + INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen) + VALUES (?, ?, ?, ?, ?, ?) + `).bind(id, offer.peerId, offer.sdp, now, offer.expiresAt, now).run(); + + // Insert topics + for (const topic of offer.topics) { + await this.db.prepare(` + INSERT INTO offer_topics (offer_id, topic) + VALUES (?, ?) + `).bind(id, topic).run(); + } + + created.push({ + id, + peerId: offer.peerId, + sdp: offer.sdp, + topics: offer.topics, + createdAt: now, + expiresAt: offer.expiresAt, + lastSeen: now, + }); + } + + return created; + } + + async getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise { + let query = ` + SELECT DISTINCT o.* + FROM offers o + INNER JOIN offer_topics ot ON o.id = ot.offer_id + WHERE ot.topic = ? AND o.expires_at > ? + `; + + const params: any[] = [topic, Date.now()]; + + if (excludePeerIds && excludePeerIds.length > 0) { + const placeholders = excludePeerIds.map(() => '?').join(','); + query += ` AND o.peer_id NOT IN (${placeholders})`; + params.push(...excludePeerIds); + } + + query += ' ORDER BY o.last_seen DESC'; + + const result = await this.db.prepare(query).bind(...params).all(); + + if (!result.results) { + return []; + } + + return Promise.all(result.results.map(row => this.rowToOffer(row as any))); + } + + async getOffersByPeerId(peerId: string): Promise { + const result = await this.db.prepare(` + SELECT * FROM offers + WHERE peer_id = ? AND expires_at > ? + ORDER BY last_seen DESC + `).bind(peerId, Date.now()).all(); + + if (!result.results) { + return []; + } + + return Promise.all(result.results.map(row => this.rowToOffer(row as any))); + } + + async getOfferById(offerId: string): Promise { + const result = await this.db.prepare(` + SELECT * FROM offers + WHERE id = ? AND expires_at > ? + `).bind(offerId, Date.now()).first(); + + if (!result) { + return null; + } + + return this.rowToOffer(result as any); + } + + async updateOfferLastSeen(offerId: string, lastSeen: number): Promise { + await this.db.prepare(` + UPDATE offers + SET last_seen = ? + WHERE id = ? AND expires_at > ? + `).bind(lastSeen, offerId, Date.now()).run(); + } + + async deleteOffer(offerId: string, ownerPeerId: string): Promise { + const result = await this.db.prepare(` + DELETE FROM offers + WHERE id = ? AND peer_id = ? + `).bind(offerId, ownerPeerId).run(); + + return (result.meta.changes || 0) > 0; + } + + async deleteExpiredOffers(now: number): Promise { + const result = await this.db.prepare(` + DELETE FROM offers WHERE expires_at < ? + `).bind(now).run(); + + return result.meta.changes || 0; + } + + async answerOffer( + offerId: string, + answererPeerId: string, + answerSdp: string + ): Promise<{ success: boolean; error?: string }> { + // Check if offer exists and is not expired + const offer = await this.getOfferById(offerId); + + if (!offer) { + return { + success: false, + error: 'Offer not found or expired' + }; + } + + // Check if offer already has an answerer + if (offer.answererPeerId) { + return { + success: false, + error: 'Offer already answered' + }; + } + + // Update offer with answer + const result = await this.db.prepare(` + UPDATE offers + SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ? + WHERE id = ? AND answerer_peer_id IS NULL + `).bind(answererPeerId, answerSdp, Date.now(), offerId).run(); + + if ((result.meta.changes || 0) === 0) { + return { + success: false, + error: 'Offer already answered (race condition)' + }; + } + + return { success: true }; + } + + async getAnsweredOffers(offererPeerId: string): Promise { + const result = await this.db.prepare(` + SELECT * FROM offers + WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ? + ORDER BY answered_at DESC + `).bind(offererPeerId, Date.now()).all(); + + if (!result.results) { + return []; + } + + return Promise.all(result.results.map(row => this.rowToOffer(row as any))); + } + + async addIceCandidates( + offerId: string, + peerId: string, + role: 'offerer' | 'answerer', + candidates: any[] + ): Promise { + console.log(`[D1] addIceCandidates: offerId=${offerId}, peerId=${peerId}, role=${role}, count=${candidates.length}`); + + // Give each candidate a unique timestamp to avoid "since" filtering issues + // D1 doesn't have transactions, so insert one by one + for (let i = 0; i < candidates.length; i++) { + const timestamp = Date.now() + i; // Ensure unique timestamps + await this.db.prepare(` + INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at) + VALUES (?, ?, ?, ?, ?) + `).bind( + offerId, + peerId, + role, + JSON.stringify(candidates[i]), // Store full object as JSON + timestamp + ).run(); + } + + return candidates.length; + } + + async getIceCandidates( + offerId: string, + targetRole: 'offerer' | 'answerer', + since?: number + ): Promise { + let query = ` + SELECT * FROM ice_candidates + WHERE offer_id = ? AND role = ? + `; + + const params: any[] = [offerId, targetRole]; + + if (since !== undefined) { + query += ' AND created_at > ?'; + params.push(since); + } + + query += ' ORDER BY created_at ASC'; + + console.log(`[D1] getIceCandidates query: offerId=${offerId}, targetRole=${targetRole}, since=${since}`); + const result = await this.db.prepare(query).bind(...params).all(); + console.log(`[D1] getIceCandidates result: ${result.results?.length || 0} rows`); + + if (!result.results) { + return []; + } + + const candidates = result.results.map((row: any) => ({ + id: row.id, + offerId: row.offer_id, + peerId: row.peer_id, + role: row.role, + candidate: JSON.parse(row.candidate), // Parse JSON back to object + createdAt: row.created_at, + })); + + if (candidates.length > 0) { + console.log(`[D1] First candidate createdAt: ${candidates[0].createdAt}, since: ${since}`); + } + + return candidates; + } + + async getTopics(limit: number, offset: number): Promise<{ + topics: TopicInfo[]; + total: number; + }> { + // Get total count of topics with active offers + const countResult = await this.db.prepare(` + SELECT COUNT(DISTINCT ot.topic) as count + FROM offer_topics ot + INNER JOIN offers o ON ot.offer_id = o.id + WHERE o.expires_at > ? + `).bind(Date.now()).first(); + + const total = (countResult as any)?.count || 0; + + // Get topics with peer counts (paginated) + const topicsResult = await this.db.prepare(` + SELECT + ot.topic, + COUNT(DISTINCT o.peer_id) as active_peers + FROM offer_topics ot + INNER JOIN offers o ON ot.offer_id = o.id + WHERE o.expires_at > ? + GROUP BY ot.topic + ORDER BY active_peers DESC, ot.topic ASC + LIMIT ? OFFSET ? + `).bind(Date.now(), limit, offset).all(); + + const topics = (topicsResult.results || []).map((row: any) => ({ + topic: row.topic, + activePeers: row.active_peers, + })); + + return { topics, total }; + } + + async close(): Promise { + // D1 doesn't require explicit connection closing + // Connections are managed by the Cloudflare Workers runtime + } + + /** + * Helper method to convert database row to Offer object with topics + */ + private async rowToOffer(row: any): Promise { + // Get topics for this offer + const topicResult = await this.db.prepare(` + SELECT topic FROM offer_topics WHERE offer_id = ? + `).bind(row.id).all(); + + const topics = topicResult.results?.map((t: any) => t.topic) || []; + + return { + id: row.id, + peerId: row.peer_id, + sdp: row.sdp, + topics, + createdAt: row.created_at, + expiresAt: row.expires_at, + lastSeen: row.last_seen, + answererPeerId: row.answerer_peer_id || undefined, + answerSdp: row.answer_sdp || undefined, + answeredAt: row.answered_at || undefined, + }; + } +} diff --git a/src/storage/hash-id.ts b/src/storage/hash-id.ts new file mode 100644 index 0000000..3e5f60f --- /dev/null +++ b/src/storage/hash-id.ts @@ -0,0 +1,37 @@ +/** + * Generates a content-based offer ID using SHA-256 hash + * Creates deterministic IDs based on offer content (sdp, topics) + * PeerID is not included as it's inferred from authentication + * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers + * + * @param sdp - The WebRTC SDP offer + * @param topics - Array of topic strings + * @returns SHA-256 hash of the sanitized offer content + */ +export async function generateOfferHash( + sdp: string, + topics: string[] +): Promise { + // Sanitize and normalize the offer content + // Only include core offer content (not peerId - that's inferred from auth) + const sanitizedOffer = { + sdp, + topics: [...topics].sort(), // Sort topics for consistency + }; + + // Create non-prettified JSON string + const jsonString = JSON.stringify(sanitizedOffer); + + // Convert string to Uint8Array for hashing + const encoder = new TextEncoder(); + const data = encoder.encode(jsonString); + + // Generate SHA-256 hash + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + + // Convert hash to hex string + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + + return hashHex; +} diff --git a/src/storage/sqlite.ts b/src/storage/sqlite.ts new file mode 100644 index 0000000..7beb6d4 --- /dev/null +++ b/src/storage/sqlite.ts @@ -0,0 +1,385 @@ +import Database from 'better-sqlite3'; +import { Storage, Offer, IceCandidate, CreateOfferRequest, TopicInfo } from './types.ts'; +import { generateOfferHash } from './hash-id.ts'; + +/** + * SQLite storage adapter for topic-based offer management + * Supports both file-based and in-memory databases + */ +export class SQLiteStorage implements Storage { + private db: Database.Database; + + /** + * Creates a new SQLite storage instance + * @param path Path to SQLite database file, or ':memory:' for in-memory database + */ + constructor(path: string = ':memory:') { + this.db = new Database(path); + this.initializeDatabase(); + } + + /** + * Initializes database schema with new topic-based structure + */ + private initializeDatabase(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS offers ( + id TEXT PRIMARY KEY, + peer_id TEXT NOT NULL, + sdp TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + answerer_peer_id TEXT, + answer_sdp TEXT, + answered_at INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id); + CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at); + CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen); + CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id); + + CREATE TABLE IF NOT EXISTS offer_topics ( + offer_id TEXT NOT NULL, + topic TEXT NOT NULL, + PRIMARY KEY (offer_id, topic), + FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic); + CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id); + + CREATE TABLE IF NOT EXISTS ice_candidates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + offer_id TEXT NOT NULL, + peer_id TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')), + candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object + created_at INTEGER NOT NULL, + FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id); + CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id); + CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at); + `); + + // Enable foreign keys + this.db.pragma('foreign_keys = ON'); + } + + async createOffers(offers: CreateOfferRequest[]): Promise { + const created: Offer[] = []; + + // Generate hash-based IDs for all offers first + const offersWithIds = await Promise.all( + offers.map(async (offer) => ({ + ...offer, + id: offer.id || await generateOfferHash(offer.sdp, offer.topics), + })) + ); + + // Use transaction for atomic creation + const transaction = this.db.transaction((offersWithIds: (CreateOfferRequest & { id: string })[]) => { + const offerStmt = this.db.prepare(` + INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen) + VALUES (?, ?, ?, ?, ?, ?) + `); + + const topicStmt = this.db.prepare(` + INSERT INTO offer_topics (offer_id, topic) + VALUES (?, ?) + `); + + for (const offer of offersWithIds) { + const now = Date.now(); + + // Insert offer + offerStmt.run( + offer.id, + offer.peerId, + offer.sdp, + now, + offer.expiresAt, + now + ); + + // Insert topics + for (const topic of offer.topics) { + topicStmt.run(offer.id, topic); + } + + created.push({ + id: offer.id, + peerId: offer.peerId, + sdp: offer.sdp, + topics: offer.topics, + createdAt: now, + expiresAt: offer.expiresAt, + lastSeen: now, + }); + } + }); + + transaction(offersWithIds); + return created; + } + + async getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise { + let query = ` + SELECT DISTINCT o.* + FROM offers o + INNER JOIN offer_topics ot ON o.id = ot.offer_id + WHERE ot.topic = ? AND o.expires_at > ? + `; + + const params: any[] = [topic, Date.now()]; + + if (excludePeerIds && excludePeerIds.length > 0) { + const placeholders = excludePeerIds.map(() => '?').join(','); + query += ` AND o.peer_id NOT IN (${placeholders})`; + params.push(...excludePeerIds); + } + + query += ' ORDER BY o.last_seen DESC'; + + const stmt = this.db.prepare(query); + const rows = stmt.all(...params) as any[]; + + return Promise.all(rows.map(row => this.rowToOffer(row))); + } + + async getOffersByPeerId(peerId: string): Promise { + const stmt = this.db.prepare(` + SELECT * FROM offers + WHERE peer_id = ? AND expires_at > ? + ORDER BY last_seen DESC + `); + + const rows = stmt.all(peerId, Date.now()) as any[]; + return Promise.all(rows.map(row => this.rowToOffer(row))); + } + + async getOfferById(offerId: string): Promise { + const stmt = this.db.prepare(` + SELECT * FROM offers + WHERE id = ? AND expires_at > ? + `); + + const row = stmt.get(offerId, Date.now()) as any; + + if (!row) { + return null; + } + + return this.rowToOffer(row); + } + + async updateOfferLastSeen(offerId: string, lastSeen: number): Promise { + const stmt = this.db.prepare(` + UPDATE offers + SET last_seen = ? + WHERE id = ? AND expires_at > ? + `); + + stmt.run(lastSeen, offerId, Date.now()); + } + + async deleteOffer(offerId: string, ownerPeerId: string): Promise { + const stmt = this.db.prepare(` + DELETE FROM offers + WHERE id = ? AND peer_id = ? + `); + + const result = stmt.run(offerId, ownerPeerId); + return result.changes > 0; + } + + async deleteExpiredOffers(now: number): Promise { + const stmt = this.db.prepare('DELETE FROM offers WHERE expires_at < ?'); + const result = stmt.run(now); + return result.changes; + } + + async answerOffer( + offerId: string, + answererPeerId: string, + answerSdp: string + ): Promise<{ success: boolean; error?: string }> { + // Check if offer exists and is not expired + const offer = await this.getOfferById(offerId); + + if (!offer) { + return { + success: false, + error: 'Offer not found or expired' + }; + } + + // Check if offer already has an answerer + if (offer.answererPeerId) { + return { + success: false, + error: 'Offer already answered' + }; + } + + // Update offer with answer + const stmt = this.db.prepare(` + UPDATE offers + SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ? + WHERE id = ? AND answerer_peer_id IS NULL + `); + + const result = stmt.run(answererPeerId, answerSdp, Date.now(), offerId); + + if (result.changes === 0) { + return { + success: false, + error: 'Offer already answered (race condition)' + }; + } + + return { success: true }; + } + + async getAnsweredOffers(offererPeerId: string): Promise { + const stmt = this.db.prepare(` + SELECT * FROM offers + WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ? + ORDER BY answered_at DESC + `); + + const rows = stmt.all(offererPeerId, Date.now()) as any[]; + return Promise.all(rows.map(row => this.rowToOffer(row))); + } + + async addIceCandidates( + offerId: string, + peerId: string, + role: 'offerer' | 'answerer', + candidates: any[] + ): Promise { + const stmt = this.db.prepare(` + INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at) + VALUES (?, ?, ?, ?, ?) + `); + + const baseTimestamp = Date.now(); + const transaction = this.db.transaction((candidates: any[]) => { + for (let i = 0; i < candidates.length; i++) { + stmt.run( + offerId, + peerId, + role, + JSON.stringify(candidates[i]), // Store full object as JSON + baseTimestamp + i // Ensure unique timestamps to avoid "since" filtering issues + ); + } + }); + + transaction(candidates); + return candidates.length; + } + + async getIceCandidates( + offerId: string, + targetRole: 'offerer' | 'answerer', + since?: number + ): Promise { + let query = ` + SELECT * FROM ice_candidates + WHERE offer_id = ? AND role = ? + `; + + const params: any[] = [offerId, targetRole]; + + if (since !== undefined) { + query += ' AND created_at > ?'; + params.push(since); + } + + query += ' ORDER BY created_at ASC'; + + const stmt = this.db.prepare(query); + const rows = stmt.all(...params) as any[]; + + return rows.map(row => ({ + id: row.id, + offerId: row.offer_id, + peerId: row.peer_id, + role: row.role, + candidate: JSON.parse(row.candidate), // Parse JSON back to object + createdAt: row.created_at, + })); + } + + async getTopics(limit: number, offset: number): Promise<{ + topics: TopicInfo[]; + total: number; + }> { + // Get total count of topics with active offers + const countStmt = this.db.prepare(` + SELECT COUNT(DISTINCT ot.topic) as count + FROM offer_topics ot + INNER JOIN offers o ON ot.offer_id = o.id + WHERE o.expires_at > ? + `); + + const countRow = countStmt.get(Date.now()) as any; + const total = countRow.count; + + // Get topics with peer counts (paginated) + const topicsStmt = this.db.prepare(` + SELECT + ot.topic, + COUNT(DISTINCT o.peer_id) as active_peers + FROM offer_topics ot + INNER JOIN offers o ON ot.offer_id = o.id + WHERE o.expires_at > ? + GROUP BY ot.topic + ORDER BY active_peers DESC, ot.topic ASC + LIMIT ? OFFSET ? + `); + + const rows = topicsStmt.all(Date.now(), limit, offset) as any[]; + + const topics = rows.map(row => ({ + topic: row.topic, + activePeers: row.active_peers, + })); + + return { topics, total }; + } + + async close(): Promise { + this.db.close(); + } + + /** + * Helper method to convert database row to Offer object with topics + */ + private async rowToOffer(row: any): Promise { + // Get topics for this offer + const topicStmt = this.db.prepare(` + SELECT topic FROM offer_topics WHERE offer_id = ? + `); + + const topicRows = topicStmt.all(row.id) as any[]; + const topics = topicRows.map(t => t.topic); + + return { + id: row.id, + peerId: row.peer_id, + sdp: row.sdp, + topics, + createdAt: row.created_at, + expiresAt: row.expires_at, + lastSeen: row.last_seen, + answererPeerId: row.answerer_peer_id || undefined, + answerSdp: row.answer_sdp || undefined, + answeredAt: row.answered_at || undefined, + }; + } +} diff --git a/src/storage/types.ts b/src/storage/types.ts new file mode 100644 index 0000000..a136327 --- /dev/null +++ b/src/storage/types.ts @@ -0,0 +1,167 @@ +/** + * Represents a WebRTC signaling offer with topic-based discovery + */ +export interface Offer { + id: string; + peerId: string; + sdp: string; + topics: string[]; + createdAt: number; + expiresAt: number; + lastSeen: number; + answererPeerId?: string; + answerSdp?: string; + answeredAt?: number; +} + +/** + * Represents an ICE candidate for WebRTC signaling + * Stores the complete candidate object as plain JSON (no type enforcement) + */ +export interface IceCandidate { + id: number; + offerId: string; + peerId: string; + role: 'offerer' | 'answerer'; + candidate: any; // Full candidate object as JSON - don't enforce structure + createdAt: number; +} + +/** + * Represents a topic with active peer count + */ +export interface TopicInfo { + topic: string; + activePeers: number; +} + +/** + * Request to create a new offer + */ +export interface CreateOfferRequest { + id?: string; + peerId: string; + sdp: string; + topics: string[]; + expiresAt: number; +} + +/** + * Storage interface for offer management with topic-based discovery + * Implementations can use different backends (SQLite, D1, Memory, etc.) + */ +export interface Storage { + /** + * Creates one or more offers + * @param offers Array of offer creation requests + * @returns Array of created offers with IDs + */ + createOffers(offers: CreateOfferRequest[]): Promise; + + /** + * Retrieves offers by topic with optional peer ID exclusion + * @param topic Topic to search for + * @param excludePeerIds Optional array of peer IDs to exclude + * @returns Array of offers matching the topic + */ + getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise; + + /** + * Retrieves all offers from a specific peer + * @param peerId Peer identifier + * @returns Array of offers from the peer + */ + getOffersByPeerId(peerId: string): Promise; + + /** + * Retrieves a specific offer by ID + * @param offerId Offer identifier + * @returns The offer if found, null otherwise + */ + getOfferById(offerId: string): Promise; + + /** + * Updates the last_seen timestamp for an offer (heartbeat) + * @param offerId Offer identifier + * @param lastSeen New last_seen timestamp + */ + updateOfferLastSeen(offerId: string, lastSeen: number): Promise; + + /** + * Deletes an offer (with ownership verification) + * @param offerId Offer identifier + * @param ownerPeerId Peer ID of the owner (for verification) + * @returns true if deleted, false if not found or not owned + */ + deleteOffer(offerId: string, ownerPeerId: string): Promise; + + /** + * Deletes all expired offers + * @param now Current timestamp + * @returns Number of offers deleted + */ + deleteExpiredOffers(now: number): Promise; + + /** + * Answers an offer (locks it to the answerer) + * @param offerId Offer identifier + * @param answererPeerId Answerer's peer ID + * @param answerSdp WebRTC answer SDP + * @returns Success status and optional error message + */ + answerOffer(offerId: string, answererPeerId: string, answerSdp: string): Promise<{ + success: boolean; + error?: string; + }>; + + /** + * Retrieves all answered offers for a specific offerer + * @param offererPeerId Offerer's peer ID + * @returns Array of answered offers + */ + getAnsweredOffers(offererPeerId: string): Promise; + + /** + * Adds ICE candidates for an offer + * @param offerId Offer identifier + * @param peerId Peer ID posting the candidates + * @param role Role of the peer (offerer or answerer) + * @param candidates Array of candidate objects (stored as plain JSON) + * @returns Number of candidates added + */ + addIceCandidates( + offerId: string, + peerId: string, + role: 'offerer' | 'answerer', + candidates: any[] + ): Promise; + + /** + * Retrieves ICE candidates for an offer + * @param offerId Offer identifier + * @param targetRole Role to retrieve candidates for (offerer or answerer) + * @param since Optional timestamp - only return candidates after this time + * @returns Array of ICE candidates + */ + getIceCandidates( + offerId: string, + targetRole: 'offerer' | 'answerer', + since?: number + ): Promise; + + /** + * Retrieves topics with active peer counts (paginated) + * @param limit Maximum number of topics to return + * @param offset Number of topics to skip + * @returns Object with topics array and total count + */ + getTopics(limit: number, offset: number): Promise<{ + topics: TopicInfo[]; + total: number; + }>; + + /** + * Closes the storage connection and releases resources + */ + close(): Promise; +} diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..d077408 --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,74 @@ +import { createApp } from './app.ts'; +import { D1Storage } from './storage/d1.ts'; +import { generateSecretKey } from './crypto.ts'; +import { Config } from './config.ts'; + +/** + * Cloudflare Workers environment bindings + */ +export interface Env { + DB: D1Database; + AUTH_SECRET?: string; + OFFER_DEFAULT_TTL?: string; + OFFER_MAX_TTL?: string; + OFFER_MIN_TTL?: string; + MAX_OFFERS_PER_REQUEST?: string; + MAX_TOPICS_PER_OFFER?: string; + CORS_ORIGINS?: string; + VERSION?: string; +} + +/** + * Cloudflare Workers fetch handler + */ +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + // Initialize D1 storage + const storage = new D1Storage(env.DB); + + // Generate or use provided auth secret + const authSecret = env.AUTH_SECRET || generateSecretKey(); + + // Build config from environment + const config: Config = { + port: 0, // Not used in Workers + storageType: 'sqlite', // D1 is SQLite-compatible + storagePath: '', // Not used with D1 + corsOrigins: env.CORS_ORIGINS + ? env.CORS_ORIGINS.split(',').map(o => o.trim()) + : ['*'], + version: env.VERSION || 'unknown', + authSecret, + offerDefaultTtl: env.OFFER_DEFAULT_TTL ? parseInt(env.OFFER_DEFAULT_TTL, 10) : 60000, + offerMaxTtl: env.OFFER_MAX_TTL ? parseInt(env.OFFER_MAX_TTL, 10) : 86400000, + offerMinTtl: env.OFFER_MIN_TTL ? parseInt(env.OFFER_MIN_TTL, 10) : 60000, + cleanupInterval: 60000, // Not used in Workers (scheduled handler instead) + maxOffersPerRequest: env.MAX_OFFERS_PER_REQUEST ? parseInt(env.MAX_OFFERS_PER_REQUEST, 10) : 100, + maxTopicsPerOffer: env.MAX_TOPICS_PER_OFFER ? parseInt(env.MAX_TOPICS_PER_OFFER, 10) : 50, + }; + + // Create Hono app + const app = createApp(storage, config); + + // Handle request + return app.fetch(request, env, ctx); + }, + + /** + * Scheduled handler for cron triggers + * Runs periodically to clean up expired offers + */ + async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise { + const storage = new D1Storage(env.DB); + const now = Date.now(); + + try { + // Delete expired offers + const deletedCount = await storage.deleteExpiredOffers(now); + + console.log(`Cleaned up ${deletedCount} expired offers at ${new Date(now).toISOString()}`); + } catch (error) { + console.error('Error cleaning up offers:', error); + } + }, +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b5a6d23 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "types": ["@types/node", "@cloudflare/workers-types"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..c389757 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,45 @@ +name = "rondevu" +main = "src/worker.ts" +compatibility_date = "2024-01-01" +compatibility_flags = ["nodejs_compat"] + +# D1 Database binding +[[d1_databases]] +binding = "DB" +database_name = "rondevu-offers" +database_id = "b94e3f71-816d-455b-a89d-927fa49532d0" + +# Environment variables +[vars] +OFFER_DEFAULT_TTL = "60000" # Default offer TTL: 1 minute +OFFER_MAX_TTL = "86400000" # Max offer TTL: 24 hours +OFFER_MIN_TTL = "60000" # Min offer TTL: 1 minute +MAX_OFFERS_PER_REQUEST = "100" # Max offers per request +MAX_TOPICS_PER_OFFER = "50" # Max topics per offer +CORS_ORIGINS = "*" # Comma-separated list of allowed origins +VERSION = "0.1.0" # Semantic version + +# AUTH_SECRET should be set as a secret, not a var +# Run: npx wrangler secret put AUTH_SECRET +# Enter a 64-character hex string (32 bytes) + +# Build configuration +[build] +command = "" + +# For local development: +# Run: npx wrangler dev +# The local D1 database will be created automatically + +# For production deployment: +# 1. Create D1 database: npx wrangler d1 create rondevu-sessions +# 2. Update the 'database_id' field above with the returned ID +# 3. Initialize schema: npx wrangler d1 execute rondevu-sessions --remote --file=./migrations/schema.sql +# 4. Deploy: npx wrangler deploy + +[observability] +[observability.logs] +enabled = false +head_sampling_rate = 1 +invocation_logs = true +persist = true diff --git a/wrangler.toml.example b/wrangler.toml.example new file mode 100644 index 0000000..e32eb1e --- /dev/null +++ b/wrangler.toml.example @@ -0,0 +1,26 @@ +name = "rondevu" +main = "src/worker.ts" +compatibility_date = "2024-01-01" + +# KV Namespace binding +[[kv_namespaces]] +binding = "SESSIONS" +id = "" # Replace with your KV namespace ID + +# Environment variables +[vars] +SESSION_TIMEOUT = "300000" # 5 minutes in milliseconds +CORS_ORIGINS = "*" # Comma-separated list of allowed origins + +# Build configuration +[build] +command = "" + +# For local development +# Run: npx wrangler dev +# The local KV will be created automatically + +# For production deployment: +# 1. Create KV namespace: npx wrangler kv:namespace create SESSIONS +# 2. Update the 'id' field above with the returned namespace ID +# 3. Deploy: npx wrangler deploy