commit fe912e6a94d35b2accd2711765cb582b523afae8 Author: Bas van den Aakster Date: Sun Nov 16 16:34:28 2025 +0100 feat: Implement content-based offer IDs with SHA-256 hashing - 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 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