17 Commits

Author SHA1 Message Date
85a3de65e2 Fix signature validation bug for serviceFqn with colons
The validateServicePublish function was incorrectly parsing the signature
message when serviceFqn contained colons (e.g., 'chat:2.0.0@user').

Old logic: Split by ':' and expected exactly 4 parts
Problem: serviceFqn 'chat:2.0.0@user' contains a colon, so we get 5 parts

Fixed:
- Allow parts.length >= 4
- Extract timestamp from the last part
- Reconstruct serviceFqn from all middle parts (parts[2] to parts[length-2])

This fixes the '403 Invalid signature for username' error that was
preventing service publication.
2025-12-09 22:59:02 +01:00
8111cb9cec v0.5.0: Service discovery and FQN format refactoring
- Changed service FQN format: service:version@username (colon instead of @)
- Added service discovery: direct lookup, random selection, paginated queries
- Updated parseServiceFqn to handle optional username for discovery
- Removed UUID privacy layer (service_index table)
- Updated storage interface with discovery methods (discoverServices, getRandomService, getServiceByFqn)
- Removed deprecated methods (getServiceByUuid, queryService, listServicesForUsername, findServicesByName, touchUsername, batchCreateServices)
- Updated API routes: /services/:fqn with three modes (direct, random, paginated)
- Changed offer/answer/ICE routes to offer-specific: /services/:fqn/offers/:offerId/*
- Added extracted fields to services table (service_name, version, username) for efficient discovery
- Created migration 0007 to update schema and migrate existing data
- Added discovery indexes for performance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 22:22:37 +01:00
b446adaee4 fix: better error handling for public key constraint
- Add try/catch in claimUsername to handle UNIQUE constraint
- Return meaningful error: 'This public key has already claimed a different username'
- Enable observability logs for better debugging
2025-12-08 21:31:36 +01:00
163e1f73d4 fix: update D1 schema to match v0.4.0 service-to-offers relationship
- Add service_id column to offers table
- Remove offer_id column from services table
- Add index for service_id in offers
2025-12-07 22:31:34 +01:00
1d47d47ef7 feat: add database migration for service-to-offers refactor
- Add service_id column to offers table
- Remove offer_id column from services table
- Update VERSION to 0.4.0 in wrangler.toml
2025-12-07 22:28:14 +01:00
1d70cd79e8 feat: refactor to service-based WebRTC signaling endpoints
BREAKING CHANGE: Replace offer-based endpoints with service-based signaling

- Add POST /services/:uuid/answer
- Add GET /services/:uuid/answer
- Add POST /services/:uuid/ice-candidates
- Add GET /services/:uuid/ice-candidates
- Remove all /offers/* endpoints (POST /offers, GET /offers/mine, etc.)
- Server auto-detects peer's offer when offerId is omitted
- Update README with new service-based API documentation
- Bump version to 0.4.0

This change simplifies the API by focusing on services rather than individual offers.
WebRTC signaling (answer/ICE) now operates at the service level, with automatic
offer detection when needed.
2025-12-07 22:17:24 +01:00
2aa1fee4d6 docs: update server README to remove outdated sections
- Remove obsolete POST /index/:username/query endpoint
- Remove non-existent PUT /offers/:offerId/heartbeat endpoint
- Update architecture diagram to reflect semver discovery
- Update database schema to show service-to-offers relationship
2025-12-07 22:07:16 +01:00
d564e2250f docs: Update README with semver matching and offers array 2025-12-07 22:00:40 +01:00
06ec5020f7 0.3.0 2025-12-07 21:59:15 +01:00
5c71f66a26 feat: Add semver-compatible service discovery with privacy
## Breaking Changes

### Removed Endpoints
- Removed GET /users/:username/services (service listing)
- Services are now completely hidden - cannot be enumerated

### Updated Endpoints
- GET /users/:username/services/:fqn now supports semver matching
- Requesting chat@1.0.0 will match chat@1.2.3, chat@1.5.0, etc.
- Will NOT match chat@2.0.0 (different major version)

## New Features

### Semantic Versioning Support
- Compatible version matching following semver rules (^1.0.0)
- Major version must match exactly
- For major version 0, minor must also match (0.x.y is unstable)
- Available version must be >= requested version
- Prerelease versions require exact match

### Privacy Improvements
- All services are now hidden by default
- No way to enumerate or list services for a username
- Must know exact service name to discover

## Implementation

### Server (src/)
- crypto.ts: Added parseVersion(), isVersionCompatible(), parseServiceFqn()
- storage/types.ts: Added findServicesByName() interface method
- storage/sqlite.ts: Implemented findServicesByName() with LIKE query
- storage/d1.ts: Implemented findServicesByName() with LIKE query
- app.ts: Updated GET /:username/services/:fqn with semver matching

### Semver Matching Logic
- Parse requested version: chat@1.0.0 → {name: "chat", version: "1.0.0"}
- Find all services with matching name: chat@*
- Filter to compatible versions using semver rules
- Return first match (most recently created)

## Examples

Request: chat@1.0.0
Matches: chat@1.0.0, chat@1.2.3, chat@1.9.5
Does NOT match: chat@0.9.0, chat@2.0.0, chat@1.0.0-beta

🤖 Generated with Claude Code
2025-12-07 21:56:19 +01:00
ca3db47009 Refactor: Consolidate service/offer architecture
## Breaking Changes

### Server
- Services can now have multiple offers instead of single offer
- POST /users/:username/services accepts `offers` array instead of `sdp`
- GET /users/:username/services/:fqn returns `offers` array in response
- GET /services/:uuid returns `offers` array in response
- Database schema: removed `offer_id` from services table, added `service_id` to offers table
- Added `batchCreateServices()` and `getOffersForService()` methods

### Client
- `PublishServiceOptions` interface: `offers` array instead of `sdp` string
- `Service` interface: `offers` array instead of `offerId` and `sdp`
- `ServiceRequest` interface: `offers` array instead of `sdp`
- RondevuSignaler.setOffer() sends offers array to server
- Updated to extract offerId from first offer in service response

## New Features
- Support for multiple simultaneous offers per service (connection pooling)
- Batch service creation endpoint for reduced server load
- Proper one-to-many relationship between services and offers

## Implementation Details

### Server Changes (src/storage/)
- sqlite.ts: Added service_id column to offers, removed offer_id from services
- d1.ts: Updated to match new interface
- types.ts: Updated interfaces for Service, Offer, CreateServiceRequest
- app.ts: Updated all service endpoints to handle offers array

### Client Changes (src/)
- api.ts: Added OfferRequest and ServiceOffer interfaces
- rondevu-service.ts: Updated PublishServiceOptions to use offers array
- rondevu-signaler.ts: Updated to send/receive offers array

## Migration Notes
- No backwards compatibility - this is a breaking change
- Services published with old API will not work with new server
- Clients must update to new API to work with updated server

🤖 Generated with Claude Code
2025-12-07 21:49:23 +01:00
3efed6e9d2 Fix service reconnection: return available offer from pool
Modified /services/:uuid endpoint to return an available (unanswered)
offer from the service's offer pool instead of always returning the
initial offer. This fixes reconnection failures where clients would
try to answer already-consumed offers.

Changes:
- Query all offers from the service's peer ID
- Return first unanswered offer
- Return 503 if no offers available

Fixes: "Offer already answered" errors on reconnection attempts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 13:47:00 +01:00
1257867dff fix: implement upsert behavior for service creation
When a service is republished (e.g., for TTL refresh), the old service
is now deleted before creating a new one, preventing UNIQUE constraint
errors on (username, service_fqn).

Changes:
- Query for existing service before creation
- Delete existing service if found
- Create new service with same username/serviceFqn

This enables the client's TTL auto-refresh feature to work correctly.
2025-12-06 13:04:45 +01:00
52cf734858 Remove legacy V1 code and clean up unused remnants
- Delete unused bloom.ts module (leftover from topic-based discovery)
- Remove maxTopicsPerOffer configuration (no longer used)
- Remove unused info field from Offer types
- Simplify generateOfferHash() to only hash SDP (remove topics param)
- Update outdated comments referencing deprecated features
- Remove backward compatibility topics field from answer responses

This completes the migration to V2 service-based architecture by
removing all remnants of the V1 topic-based system.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 12:06:02 +01:00
5622867411 Add upsert behavior to service creation
- Delete existing service before creating new one
- Prevents UNIQUE constraint error on (username, service_fqn)
- Enables seamless service republishing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 11:46:21 +01:00
ac0e064e34 Fix answer response field names for V2 API compatibility
- Change 'answererPeerId' to 'answererId'
- Change 'answerSdp' to 'sdp'
- Add 'topics' field (empty array) for client compatibility

This ensures the server response matches the expected format
in the client's AnsweredOffer interface.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 11:37:31 +01:00
e7cd90b905 Fix error handling scope issue in service creation
The error handler was referencing variables (username, serviceFqn, offers)
that were declared inside the try block. If an error occurred before these
were defined, the error handler itself would fail, resulting in non-JSON
responses that caused "JSON.parse: unexpected character" errors on the client.

Fixed by:
- Declaring variables at function scope
- Initializing offers as empty array
- Using destructuring assignment for username/serviceFqn

This ensures the error handler can always access these variables safely,
even if an early error occurs, and will always return proper JSON responses.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 19:56:06 +01:00
16 changed files with 933 additions and 712 deletions

237
README.md
View File

@@ -30,11 +30,11 @@ Username Claiming → Service Publishing → Service Discovery → WebRTC Connec
alice claims "alice" with Ed25519 signature alice claims "alice" with Ed25519 signature
alice publishes com.example.chat@1.0.0 → receives UUID abc123 alice publishes com.example.chat@1.0.0 with multiple offers → receives UUID abc123
bob queries alice's services → gets UUID abc123 bob requests alice/com.example.chat@1.0.0 → gets compatible service with available offer
bob connects to UUID abc123 → WebRTC connection established WebRTC connection established via offer/answer exchange
``` ```
## Quick Start ## Quick Start
@@ -77,15 +77,28 @@ Generates a cryptographically random 128-bit peer ID.
} }
``` ```
### Username Management ### User Management (RESTful)
#### `POST /usernames/claim` #### `GET /users/:username`
Check username availability and claim status
**Response:**
```json
{
"username": "alice",
"available": false,
"claimedAt": 1733404800000,
"expiresAt": 1765027200000,
"publicKey": "..."
}
```
#### `POST /users/:username`
Claim a username with cryptographic proof Claim a username with cryptographic proof
**Request:** **Request:**
```json ```json
{ {
"username": "alice",
"publicKey": "base64-encoded-ed25519-public-key", "publicKey": "base64-encoded-ed25519-public-key",
"signature": "base64-encoded-signature", "signature": "base64-encoded-signature",
"message": "claim:alice:1733404800000" "message": "claim:alice:1733404800000"
@@ -107,46 +120,37 @@ Claim a username with cryptographic proof
- Timestamp must be within 5 minutes (replay protection) - Timestamp must be within 5 minutes (replay protection)
- Expires after 365 days, auto-renewed on use - Expires after 365 days, auto-renewed on use
#### `GET /usernames/:username` #### `GET /users/:username/services/:fqn`
Check username availability and claim status Get service by username and FQN with semver-compatible matching
**Semver Matching:**
- Requesting `chat@1.0.0` matches any `1.x.x` version
- Major version must match exactly (`chat@1.0.0` will NOT match `chat@2.0.0`)
- For major version 0, minor must also match (`0.1.0` will NOT match `0.2.0`)
- Returns the most recently published compatible version
**Response:** **Response:**
```json ```json
{ {
"uuid": "abc123",
"serviceId": "service-id",
"username": "alice", "username": "alice",
"available": false, "serviceFqn": "chat.app@1.0.0",
"claimedAt": 1733404800000, "offerId": "offer-hash",
"expiresAt": 1765027200000, "sdp": "v=0...",
"publicKey": "..." "isPublic": true,
"metadata": {},
"createdAt": 1733404800000,
"expiresAt": 1733405100000
} }
``` ```
#### `GET /usernames/:username/services` **Note:** Returns a single available offer from the service. If all offers are in use, returns 503.
List all services for a username (privacy-preserving)
**Response:** ### Service Management (RESTful)
```json
{
"username": "alice",
"services": [
{
"uuid": "abc123",
"isPublic": false
},
{
"uuid": "def456",
"isPublic": true,
"serviceFqn": "com.example.public@1.0.0",
"metadata": { "description": "Public service" }
}
]
}
```
### Service Management #### `POST /users/:username/services`
Publish a service with multiple offers (requires authentication and username signature)
#### `POST /services`
Publish a service (requires authentication and username signature)
**Headers:** **Headers:**
- `Authorization: Bearer {peerId}:{secret}` - `Authorization: Bearer {peerId}:{secret}`
@@ -154,9 +158,11 @@ Publish a service (requires authentication and username signature)
**Request:** **Request:**
```json ```json
{ {
"username": "alice",
"serviceFqn": "com.example.chat@1.0.0", "serviceFqn": "com.example.chat@1.0.0",
"sdp": "v=0...", "offers": [
{ "sdp": "v=0..." },
{ "sdp": "v=0..." }
],
"ttl": 300000, "ttl": 300000,
"isPublic": false, "isPublic": false,
"metadata": { "description": "Chat service" }, "metadata": { "description": "Chat service" },
@@ -165,12 +171,30 @@ Publish a service (requires authentication and username signature)
} }
``` ```
**Response:** **Response (Full service details):**
```json ```json
{ {
"serviceId": "uuid-v4",
"uuid": "uuid-v4-for-index", "uuid": "uuid-v4-for-index",
"offerId": "offer-hash-id", "serviceId": "uuid-v4",
"username": "alice",
"serviceFqn": "com.example.chat@1.0.0",
"offers": [
{
"offerId": "offer-hash-1",
"sdp": "v=0...",
"createdAt": 1733404800000,
"expiresAt": 1733405100000
},
{
"offerId": "offer-hash-2",
"sdp": "v=0...",
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
],
"isPublic": false,
"metadata": { "description": "Chat service" },
"createdAt": 1733404800000,
"expiresAt": 1733405100000 "expiresAt": 1733405100000
} }
``` ```
@@ -203,7 +227,7 @@ Get service details by UUID
} }
``` ```
#### `DELETE /services/:serviceId` #### `DELETE /users/:username/services/:fqn`
Unpublish a service (requires authentication and ownership) Unpublish a service (requires authentication and ownership)
**Headers:** **Headers:**
@@ -216,58 +240,14 @@ Unpublish a service (requires authentication and ownership)
} }
``` ```
### Service Discovery ### WebRTC Signaling (Service-Based)
#### `POST /index/:username/query` #### `POST /services/:uuid/answer`
Query a service by FQN Answer a service offer (requires authentication)
**Request:**
```json
{
"serviceFqn": "com.example.chat@1.0.0"
}
```
**Response:**
```json
{
"uuid": "abc123",
"allowed": true
}
```
### Offer Management (Low-level)
#### `POST /offers`
Create one or more offers (requires authentication)
**Headers:** **Headers:**
- `Authorization: Bearer {peerId}:{secret}` - `Authorization: Bearer {peerId}:{secret}`
**Request:**
```json
{
"offers": [
{
"sdp": "v=0...",
"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:** **Request:**
```json ```json
{ {
@@ -275,21 +255,76 @@ Answer an offer (locks it to answerer)
} }
``` ```
#### `GET /offers/answers` **Response:**
Poll for answers to your offers ```json
{
"success": true,
"offerId": "offer-hash"
}
```
#### `POST /offers/:offerId/ice-candidates` #### `GET /services/:uuid/answer`
Post ICE candidates for an offer Get answer for a service (offerer polls this)
**Headers:**
- `Authorization: Bearer {peerId}:{secret}`
**Response:**
```json
{
"offerId": "offer-hash",
"answererId": "answerer-peer-id",
"sdp": "v=0...",
"answeredAt": 1733404800000
}
```
**Note:** Returns 404 if not yet answered
#### `POST /services/:uuid/ice-candidates`
Post ICE candidates for a service (requires authentication)
**Headers:**
- `Authorization: Bearer {peerId}:{secret}`
**Request:** **Request:**
```json ```json
{ {
"candidates": ["candidate:1 1 UDP..."] "candidates": ["candidate:1 1 UDP..."],
"offerId": "optional-offer-id"
} }
``` ```
#### `GET /offers/:offerId/ice-candidates?since=1234567890` **Response:**
Get ICE candidates from the other peer ```json
{
"count": 1,
"offerId": "offer-hash"
}
```
**Note:** If `offerId` is omitted, the server will auto-detect the peer's offer
#### `GET /services/:uuid/ice-candidates?since=1234567890&offerId=optional-offer-id`
Get ICE candidates from the other peer (requires authentication)
**Headers:**
- `Authorization: Bearer {peerId}:{secret}`
**Response:**
```json
{
"candidates": [
{
"candidate": "candidate:1 1 UDP...",
"createdAt": 1733404800000
}
],
"offerId": "offer-hash"
}
```
**Note:** Returns candidates from the opposite role (offerer gets answerer candidates and vice versa)
## Configuration ## Configuration
@@ -321,11 +356,19 @@ Environment variables:
- `id` (PK): Service ID (UUID) - `id` (PK): Service ID (UUID)
- `username` (FK): Owner username - `username` (FK): Owner username
- `service_fqn`: Fully qualified name (com.example.chat@1.0.0) - `service_fqn`: Fully qualified name (com.example.chat@1.0.0)
- `offer_id` (FK): WebRTC offer ID
- `is_public`: Public/private flag - `is_public`: Public/private flag
- `metadata`: JSON metadata - `metadata`: JSON metadata
- `created_at`, `expires_at`: Timestamps - `created_at`, `expires_at`: Timestamps
### offers
- `id` (PK): Offer ID (hash of SDP)
- `peer_id` (FK): Owner peer ID
- `service_id` (FK): Optional link to service (null for standalone offers)
- `sdp`: WebRTC offer SDP
- `answerer_peer_id`: Peer ID of answerer (null until answered)
- `answer_sdp`: WebRTC answer SDP (null until answered)
- `created_at`, `expires_at`, `last_seen`: Timestamps
### service_index (privacy layer) ### service_index (privacy layer)
- `uuid` (PK): Random UUID for discovery - `uuid` (PK): Random UUID for discovery
- `service_id` (FK): Links to service - `service_id` (FK): Links to service

View File

@@ -0,0 +1,40 @@
-- V0.4.0 Migration: Refactor service-to-offer relationship
-- Change from one-to-one (service has offer_id) to one-to-many (offer has service_id)
-- Step 1: Add service_id column to offers table
ALTER TABLE offers ADD COLUMN service_id TEXT;
-- Step 2: Create new services table without offer_id
CREATE TABLE services_new (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
service_fqn TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
is_public INTEGER NOT NULL DEFAULT 0,
metadata TEXT,
FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
UNIQUE(username, service_fqn)
);
-- Step 3: Copy data from old services table (if any exists)
INSERT INTO services_new (id, username, service_fqn, created_at, expires_at, is_public, metadata)
SELECT id, username, service_fqn, created_at, expires_at, is_public, metadata
FROM services;
-- Step 4: Drop old services table
DROP TABLE services;
-- Step 5: Rename new table to services
ALTER TABLE services_new RENAME TO services;
-- Step 6: Recreate indexes
CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn);
CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
-- Step 7: Add index for service_id in offers
CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id);
-- Step 8: Add foreign key constraint (D1 doesn't enforce FK in ALTER, but good for documentation)
-- FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE

View File

@@ -0,0 +1,54 @@
-- V0.4.1 Migration: Simplify schema and add service discovery
-- Remove privacy layer (service_index) and add extracted fields for discovery
-- Step 1: Drop service_index table (privacy layer removal)
DROP TABLE IF EXISTS service_index;
-- Step 2: Create new services table with extracted fields for discovery
CREATE TABLE services_new (
id TEXT PRIMARY KEY,
service_fqn TEXT NOT NULL,
service_name TEXT NOT NULL,
version TEXT NOT NULL,
username TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
UNIQUE(service_fqn)
);
-- Step 3: Migrate existing data (if any) - parse FQN to extract components
-- Note: This migration assumes FQN format is already "service:version@username"
-- If there's old data with different format, manual intervention may be needed
INSERT INTO services_new (id, service_fqn, service_name, version, username, created_at, expires_at)
SELECT
id,
service_fqn,
-- Extract service_name: everything before first ':'
substr(service_fqn, 1, instr(service_fqn, ':') - 1) as service_name,
-- Extract version: between ':' and '@'
substr(
service_fqn,
instr(service_fqn, ':') + 1,
instr(service_fqn, '@') - instr(service_fqn, ':') - 1
) as version,
username,
created_at,
expires_at
FROM services
WHERE service_fqn LIKE '%:%@%'; -- Only migrate properly formatted FQNs
-- Step 4: Drop old services table
DROP TABLE services;
-- Step 5: Rename new table to services
ALTER TABLE services_new RENAME TO services;
-- Step 6: Create indexes for efficient querying
CREATE INDEX idx_services_fqn ON services(service_fqn);
CREATE INDEX idx_services_discovery ON services(service_name, version);
CREATE INDEX idx_services_username ON services(username);
CREATE INDEX idx_services_expires ON services(expires_at);
-- Step 7: Create index on offers for available offer filtering
CREATE INDEX IF NOT EXISTS idx_offers_available ON offers(answerer_peer_id) WHERE answerer_peer_id IS NULL;

46
package-lock.json generated
View File

@@ -1,15 +1,16 @@
{ {
"name": "@xtr-dev/rondevu-server", "name": "@xtr-dev/rondevu-server",
"version": "0.1.5", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@xtr-dev/rondevu-server", "name": "@xtr-dev/rondevu-server",
"version": "0.1.5", "version": "0.4.0",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.6", "@hono/node-server": "^1.19.6",
"@noble/ed25519": "^3.0.0", "@noble/ed25519": "^3.0.0",
"@xtr-dev/rondevu-client": "^0.13.0",
"better-sqlite3": "^12.4.1", "better-sqlite3": "^12.4.1",
"hono": "^4.10.4" "hono": "^4.10.4"
}, },
@@ -23,9 +24,9 @@
} }
}, },
"node_modules/@cloudflare/workers-types": { "node_modules/@cloudflare/workers-types": {
"version": "4.20251115.0", "version": "4.20251209.0",
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251115.0.tgz", "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251209.0.tgz",
"integrity": "sha512-aM7jp7IfKhqKvfSaK1IhVTbSzxB6KQ4gX8e/W29tOuZk+YHlYXuRd/bMm4hWkfd7B1HWNWdsx1GTaEUoZIuVsw==", "integrity": "sha512-O+cbUVwgb4NgUB39R1cITbRshlAAPy1UQV0l8xEy2xcZ3wTh3fMl9f5oBwLsVmE9JRhIZx6llCLOBVf53eI5xA==",
"dev": true, "dev": true,
"license": "MIT OR Apache-2.0" "license": "MIT OR Apache-2.0"
}, },
@@ -485,9 +486,9 @@
} }
}, },
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
"version": "1.19.6", "version": "1.19.7",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
"integrity": "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==", "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.14.1" "node": ">=18.14.1"
@@ -572,15 +573,24 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.10.1", "version": "24.10.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.2.tgz",
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "integrity": "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@xtr-dev/rondevu-client": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.13.0.tgz",
"integrity": "sha512-oauCveLga4lploxpoW8U0Fd9Fyz+SAsNQzIDvAIG1fkAnAJu9eajmLsZ5JfzzDi7h2Ew1ClZ7MOrmlRfG4vaBg==",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^3.0.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -635,9 +645,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "12.4.1", "version": "12.5.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.5.0.tgz",
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==", "integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -645,7 +655,7 @@
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
}, },
"engines": { "engines": {
"node": "20.x || 22.x || 23.x || 24.x" "node": "20.x || 22.x || 23.x || 24.x || 25.x"
} }
}, },
"node_modules/bindings": { "node_modules/bindings": {
@@ -827,9 +837,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.10.6", "version": "4.10.8",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.10.6.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.8.tgz",
"integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==", "integrity": "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/rondevu-server", "name": "@xtr-dev/rondevu-server",
"version": "0.2.4", "version": "0.4.0",
"description": "DNS-like WebRTC signaling server with username claiming and service discovery", "description": "DNS-like WebRTC signaling server with username claiming and service discovery",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
@@ -22,6 +22,7 @@
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.6", "@hono/node-server": "^1.19.6",
"@noble/ed25519": "^3.0.0", "@noble/ed25519": "^3.0.0",
"@xtr-dev/rondevu-client": "^0.13.0",
"better-sqlite3": "^12.4.1", "better-sqlite3": "^12.4.1",
"hono": "^4.10.4" "hono": "^4.10.4"
} }

View File

@@ -3,11 +3,12 @@ import { cors } from 'hono/cors';
import { Storage } from './storage/types.ts'; import { Storage } from './storage/types.ts';
import { Config } from './config.ts'; import { Config } from './config.ts';
import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts'; import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts';
import { generatePeerId, encryptPeerId, validateUsernameClaim, validateServicePublish, validateServiceFqn } from './crypto.ts'; import { generatePeerId, encryptPeerId, validateUsernameClaim, validateServicePublish, validateServiceFqn, parseServiceFqn, isVersionCompatible } from './crypto.ts';
import type { Context } from 'hono'; import type { Context } from 'hono';
/** /**
* Creates the Hono application with username and service-based WebRTC signaling * Creates the Hono application with username and service-based WebRTC signaling
* RESTful API design - v0.11.0
*/ */
export function createApp(storage: Storage, config: Config) { export function createApp(storage: Storage, config: Config) {
const app = new Hono(); const app = new Hono();
@@ -61,7 +62,7 @@ export function createApp(storage: Storage, config: Config) {
/** /**
* POST /register * POST /register
* Register a new peer (still needed for peer ID generation) * Register a new peer
*/ */
app.post('/register', async (c) => { app.post('/register', async (c) => {
try { try {
@@ -78,58 +79,13 @@ export function createApp(storage: Storage, config: Config) {
} }
}); });
// ===== Username Management ===== // ===== User Management (RESTful) =====
/** /**
* POST /usernames/claim * GET /users/:username
* Claim a username with cryptographic proof
*/
app.post('/usernames/claim', async (c) => {
try {
const body = await c.req.json();
const { username, publicKey, signature, message } = body;
if (!username || !publicKey || !signature || !message) {
return c.json({ error: 'Missing required parameters: username, publicKey, signature, message' }, 400);
}
// Validate claim
const validation = await validateUsernameClaim(username, publicKey, signature, message);
if (!validation.valid) {
return c.json({ error: validation.error }, 400);
}
// Attempt to claim username
try {
const claimed = await storage.claimUsername({
username,
publicKey,
signature,
message
});
return c.json({
username: claimed.username,
claimedAt: claimed.claimedAt,
expiresAt: claimed.expiresAt
}, 200);
} catch (err: any) {
if (err.message?.includes('already claimed')) {
return c.json({ error: 'Username already claimed by different public key' }, 409);
}
throw err;
}
} catch (err) {
console.error('Error claiming username:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* GET /usernames/:username
* Check if username is available or get claim info * Check if username is available or get claim info
*/ */
app.get('/usernames/:username', async (c) => { app.get('/users/:username', async (c) => {
try { try {
const username = c.req.param('username'); const username = c.req.param('username');
@@ -156,46 +112,215 @@ export function createApp(storage: Storage, config: Config) {
}); });
/** /**
* GET /usernames/:username/services * POST /users/:username
* List services for a username (privacy-preserving) * Claim a username with cryptographic proof
*/ */
app.get('/usernames/:username/services', async (c) => { app.post('/users/:username', async (c) => {
try { try {
const username = c.req.param('username'); const username = c.req.param('username');
const body = await c.req.json();
const { publicKey, signature, message } = body;
const services = await storage.listServicesForUsername(username); if (!publicKey || !signature || !message) {
return c.json({ error: 'Missing required parameters: publicKey, signature, message' }, 400);
}
return c.json({ // Validate claim
username, const validation = await validateUsernameClaim(username, publicKey, signature, message);
services if (!validation.valid) {
}, 200); return c.json({ error: validation.error }, 400);
}
// Attempt to claim username
try {
const claimed = await storage.claimUsername({
username,
publicKey,
signature,
message
});
return c.json({
username: claimed.username,
claimedAt: claimed.claimedAt,
expiresAt: claimed.expiresAt
}, 201);
} catch (err: any) {
if (err.message?.includes('already claimed')) {
return c.json({ error: 'Username already claimed by different public key' }, 409);
}
throw err;
}
} catch (err) { } catch (err) {
console.error('Error listing services:', err); console.error('Error claiming username:', err);
return c.json({ error: 'Internal server error' }, 500); return c.json({ error: 'Internal server error' }, 500);
} }
}); });
// ===== Service Management ===== // ===== Service Discovery and Management =====
/**
* GET /services/:fqn
* Get service by FQN with optional discovery
* Supports three modes:
* 1. Direct lookup: /services/chat:1.0.0@alice - Returns specific user's offer
* 2. Random discovery: /services/chat:1.0.0 - Returns random available offer
* 3. Paginated discovery: /services/chat:1.0.0?limit=10&offset=0 - Returns array of available offers
*/
app.get('/services/:fqn', async (c) => {
try {
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
const limit = c.req.query('limit');
const offset = c.req.query('offset');
// Parse the requested FQN
const parsed = parseServiceFqn(serviceFqn);
if (!parsed) {
return c.json({ error: 'Invalid service FQN format. Use service:version or service:version@username' }, 400);
}
const { serviceName, version, username } = parsed;
// Mode 1: Direct lookup with username
if (username) {
// Find service by exact FQN
const service = await storage.getServiceByFqn(serviceFqn);
if (!service) {
return c.json({ error: 'Service not found' }, 404);
}
// Get available offer from this service
const serviceOffers = await storage.getOffersForService(service.id);
const availableOffer = serviceOffers.find(offer => !offer.answererPeerId);
if (!availableOffer) {
return c.json({
error: 'No available offers',
message: 'All offers from this service are currently in use.'
}, 503);
}
return c.json({
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offerId: availableOffer.id,
sdp: availableOffer.sdp,
createdAt: service.createdAt,
expiresAt: service.expiresAt
}, 200);
}
// Mode 2 & 3: Discovery without username
if (limit || offset) {
// Paginated discovery
const limitNum = limit ? Math.min(parseInt(limit, 10), 100) : 10;
const offsetNum = offset ? parseInt(offset, 10) : 0;
const services = await storage.discoverServices(serviceName, version, limitNum, offsetNum);
if (services.length === 0) {
return c.json({
error: 'No services found',
message: `No available services found for ${serviceName}:${version}`
}, 404);
}
// Get available offers for each service
const servicesWithOffers = await Promise.all(
services.map(async (service) => {
const offers = await storage.getOffersForService(service.id);
const availableOffer = offers.find(offer => !offer.answererPeerId);
return availableOffer ? {
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offerId: availableOffer.id,
sdp: availableOffer.sdp,
createdAt: service.createdAt,
expiresAt: service.expiresAt
} : null;
})
);
const availableServices = servicesWithOffers.filter(s => s !== null);
return c.json({
services: availableServices,
count: availableServices.length,
limit: limitNum,
offset: offsetNum
}, 200);
} else {
// Random discovery
const service = await storage.getRandomService(serviceName, version);
if (!service) {
return c.json({
error: 'No services found',
message: `No available services found for ${serviceName}:${version}`
}, 404);
}
// Get available offer
const offers = await storage.getOffersForService(service.id);
const availableOffer = offers.find(offer => !offer.answererPeerId);
if (!availableOffer) {
return c.json({
error: 'No available offers',
message: 'Service found but no available offers.'
}, 503);
}
return c.json({
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offerId: availableOffer.id,
sdp: availableOffer.sdp,
createdAt: service.createdAt,
expiresAt: service.expiresAt
}, 200);
}
} catch (err) {
console.error('Error getting service:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/** /**
* POST /services * POST /services
* Publish a service * Publish a service with one or more offers
* Service FQN must include username: service:version@username
*/ */
app.post('/services', authMiddleware, async (c) => { app.post('/services', authMiddleware, async (c) => {
let serviceFqn: string | undefined;
let createdOffers: any[] = [];
try { try {
const body = await c.req.json(); const body = await c.req.json();
const { username, serviceFqn, sdp, ttl, isPublic, metadata, signature, message } = body; serviceFqn = body.serviceFqn;
const { offers, ttl, signature, message } = body;
if (!username || !serviceFqn || !sdp) { if (!serviceFqn || !offers || !Array.isArray(offers) || offers.length === 0) {
return c.json({ error: 'Missing required parameters: username, serviceFqn, sdp' }, 400); return c.json({ error: 'Missing required parameters: serviceFqn, offers (must be non-empty array)' }, 400);
} }
// Validate service FQN // Validate and parse service FQN
const fqnValidation = validateServiceFqn(serviceFqn); const fqnValidation = validateServiceFqn(serviceFqn);
if (!fqnValidation.valid) { if (!fqnValidation.valid) {
return c.json({ error: fqnValidation.error }, 400); return c.json({ error: fqnValidation.error }, 400);
} }
const parsed = parseServiceFqn(serviceFqn);
if (!parsed || !parsed.username) {
return c.json({ error: 'Service FQN must include username (format: service:version@username)' }, 400);
}
const username = parsed.username;
// Verify username ownership (signature required) // Verify username ownership (signature required)
if (!signature || !message) { if (!signature || !message) {
return c.json({ error: 'Missing signature or message for username verification' }, 400); return c.json({ error: 'Missing signature or message for username verification' }, 400);
@@ -212,13 +337,21 @@ export function createApp(storage: Storage, config: Config) {
return c.json({ error: 'Invalid signature for username' }, 403); return c.json({ error: 'Invalid signature for username' }, 403);
} }
// Validate SDP // Delete existing service if one exists (upsert behavior)
if (typeof sdp !== 'string' || sdp.length === 0) { const existingService = await storage.getServiceByFqn(serviceFqn);
return c.json({ error: 'Invalid SDP' }, 400); if (existingService) {
await storage.deleteService(existingService.id, username);
} }
if (sdp.length > 64 * 1024) { // Validate all offers
return c.json({ error: 'SDP too large (max 64KB)' }, 400); for (const offer of offers) {
if (!offer.sdp || typeof offer.sdp !== 'string' || offer.sdp.length === 0) {
return c.json({ error: 'Invalid SDP in offers array' }, 400);
}
if (offer.sdp.length > 64 * 1024) {
return c.json({ error: 'SDP too large (max 64KB)' }, 400);
}
} }
// Calculate expiry // Calculate expiry
@@ -229,33 +362,34 @@ export function createApp(storage: Storage, config: Config) {
); );
const expiresAt = Date.now() + offerTtl; const expiresAt = Date.now() + offerTtl;
// Create offer first // Prepare offer requests
const offers = await storage.createOffers([{ const offerRequests = offers.map(offer => ({
peerId, peerId,
sdp, sdp: offer.sdp,
expiresAt expiresAt
}]); }));
if (offers.length === 0) { // Create service with offers
return c.json({ error: 'Failed to create offer' }, 500);
}
const offer = offers[0];
// Create service
const result = await storage.createService({ const result = await storage.createService({
username,
serviceFqn, serviceFqn,
offerId: offer.id,
expiresAt, expiresAt,
isPublic: isPublic || false, offers: offerRequests
metadata: metadata ? JSON.stringify(metadata) : undefined
}); });
createdOffers = result.offers;
// Return full service details with all offers
return c.json({ return c.json({
serviceFqn: result.service.serviceFqn,
username: result.service.username,
serviceId: result.service.id, serviceId: result.service.id,
uuid: result.indexUuid, offers: result.offers.map(o => ({
offerId: offer.id, offerId: o.id,
sdp: o.sdp,
createdAt: o.createdAt,
expiresAt: o.expiresAt
})),
createdAt: result.service.createdAt,
expiresAt: result.service.expiresAt expiresAt: result.service.expiresAt
}, 201); }, 201);
} catch (err) { } catch (err) {
@@ -263,9 +397,8 @@ export function createApp(storage: Storage, config: Config) {
console.error('Error details:', { console.error('Error details:', {
message: (err as Error).message, message: (err as Error).message,
stack: (err as Error).stack, stack: (err as Error).stack,
username,
serviceFqn, serviceFqn,
offerId: offers[0]?.id offerIds: createdOffers.map(o => o.id)
}); });
return c.json({ return c.json({
error: 'Internal server error', error: 'Internal server error',
@@ -275,58 +408,28 @@ export function createApp(storage: Storage, config: Config) {
}); });
/** /**
* GET /services/:uuid * DELETE /services/:fqn
* Get service details by index UUID * Delete a service by FQN (must include username)
*/ */
app.get('/services/:uuid', async (c) => { app.delete('/services/:fqn', authMiddleware, async (c) => {
try { try {
const uuid = c.req.param('uuid'); const serviceFqn = decodeURIComponent(c.req.param('fqn'));
const service = await storage.getServiceByUuid(uuid); // Parse and validate FQN
const parsed = parseServiceFqn(serviceFqn);
if (!parsed || !parsed.username) {
return c.json({ error: 'Service FQN must include username (format: service:version@username)' }, 400);
}
const username = parsed.username;
// Find service by FQN
const service = await storage.getServiceByFqn(serviceFqn);
if (!service) { if (!service) {
return c.json({ error: 'Service not found' }, 404); return c.json({ error: 'Service not found' }, 404);
} }
// Get associated offer const deleted = await storage.deleteService(service.id, username);
const offer = await storage.getOfferById(service.offerId);
if (!offer) {
return c.json({ error: 'Associated offer not found' }, 404);
}
return c.json({
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offerId: service.offerId,
sdp: offer.sdp,
isPublic: service.isPublic,
metadata: service.metadata ? JSON.parse(service.metadata) : undefined,
createdAt: service.createdAt,
expiresAt: service.expiresAt
}, 200);
} catch (err) {
console.error('Error getting service:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* DELETE /services/:serviceId
* Delete a service (requires ownership)
*/
app.delete('/services/:serviceId', authMiddleware, async (c) => {
try {
const serviceId = c.req.param('serviceId');
const body = await c.req.json();
const { username } = body;
if (!username) {
return c.json({ error: 'Missing required parameter: username' }, 400);
}
const deleted = await storage.deleteService(serviceId, username);
if (!deleted) { if (!deleted) {
return c.json({ error: 'Service not found or not owned by this username' }, 404); return c.json({ error: 'Service not found or not owned by this username' }, 404);
@@ -339,157 +442,18 @@ export function createApp(storage: Storage, config: Config) {
} }
}); });
/** // ===== WebRTC Signaling (Offer-Specific) =====
* POST /index/:username/query
* Query service by FQN (returns UUID)
*/
app.post('/index/:username/query', async (c) => {
try {
const username = c.req.param('username');
const body = await c.req.json();
const { serviceFqn } = body;
if (!serviceFqn) {
return c.json({ error: 'Missing required parameter: serviceFqn' }, 400);
}
const uuid = await storage.queryService(username, serviceFqn);
if (!uuid) {
return c.json({ error: 'Service not found' }, 404);
}
return c.json({
uuid,
allowed: true
}, 200);
} catch (err) {
console.error('Error querying service:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
// ===== Offer Management (Core WebRTC) =====
/** /**
* POST /offers * POST /services/:fqn/offers/:offerId/answer
* Create offers (direct, no service - for testing/advanced users) * Answer a specific offer from a service
*/ */
app.post('/offers', authMiddleware, async (c) => { app.post('/services/:fqn/offers/:offerId/answer', 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 (max ${config.maxOffersPerRequest})` }, 400);
}
const peerId = getAuthenticatedPeerId(c);
// Validate and prepare offers
const validated = offers.map((offer: any) => {
const { sdp, ttl, secret } = offer;
if (typeof sdp !== 'string' || sdp.length === 0) {
throw new Error('Invalid SDP in offer');
}
if (sdp.length > 64 * 1024) {
throw new Error('SDP too large (max 64KB)');
}
const offerTtl = Math.min(
Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
config.offerMaxTtl
);
return {
peerId,
sdp,
expiresAt: Date.now() + offerTtl,
secret: secret ? String(secret).substring(0, 128) : undefined
};
});
const created = await storage.createOffers(validated);
return c.json({
offers: created.map(offer => ({
id: offer.id,
peerId: offer.peerId,
expiresAt: offer.expiresAt,
createdAt: offer.createdAt,
hasSecret: !!offer.secret
}))
}, 201);
} catch (err: any) {
console.error('Error creating offers:', err);
return c.json({ error: err.message || 'Internal server error' }, 500);
}
});
/**
* GET /offers/mine
* Get authenticated peer's offers
*/
app.get('/offers/mine', authMiddleware, async (c) => {
try {
const peerId = getAuthenticatedPeerId(c);
const offers = await storage.getOffersByPeerId(peerId);
return c.json({
offers: offers.map(offer => ({
id: offer.id,
sdp: offer.sdp,
createdAt: offer.createdAt,
expiresAt: offer.expiresAt,
lastSeen: offer.lastSeen,
hasSecret: !!offer.secret,
answererPeerId: offer.answererPeerId,
answered: !!offer.answererPeerId
}))
}, 200);
} catch (err) {
console.error('Error getting offers:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* DELETE /offers/:offerId
* Delete an offer
*/
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 owned by this peer' }, 404);
}
return c.json({ success: true }, 200);
} catch (err) {
console.error('Error deleting offer:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* POST /offers/:offerId/answer
* Answer an offer
*/
app.post('/offers/:offerId/answer', authMiddleware, async (c) => {
try { try {
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
const offerId = c.req.param('offerId'); const offerId = c.req.param('offerId');
const body = await c.req.json(); const body = await c.req.json();
const { sdp, secret } = body; const { sdp } = body;
if (!sdp) { if (!sdp) {
return c.json({ error: 'Missing required parameter: sdp' }, 400); return c.json({ error: 'Missing required parameter: sdp' }, 400);
@@ -503,15 +467,24 @@ export function createApp(storage: Storage, config: Config) {
return c.json({ error: 'SDP too large (max 64KB)' }, 400); return c.json({ error: 'SDP too large (max 64KB)' }, 400);
} }
// Verify offer exists
const offer = await storage.getOfferById(offerId);
if (!offer) {
return c.json({ error: 'Offer not found' }, 404);
}
const answererPeerId = getAuthenticatedPeerId(c); const answererPeerId = getAuthenticatedPeerId(c);
const result = await storage.answerOffer(offerId, answererPeerId, sdp, secret); const result = await storage.answerOffer(offerId, answererPeerId, sdp);
if (!result.success) { if (!result.success) {
return c.json({ error: result.error }, 400); return c.json({ error: result.error }, 400);
} }
return c.json({ success: true }, 200); return c.json({
success: true,
offerId: offerId
}, 200);
} catch (err) { } catch (err) {
console.error('Error answering offer:', err); console.error('Error answering offer:', err);
return c.json({ error: 'Internal server error' }, 500); return c.json({ error: 'Internal server error' }, 500);
@@ -519,36 +492,49 @@ export function createApp(storage: Storage, config: Config) {
}); });
/** /**
* GET /offers/answers * GET /services/:fqn/offers/:offerId/answer
* Get answers for authenticated peer's offers * Get answer for a specific offer (offerer polls this)
*/ */
app.get('/offers/answers', authMiddleware, async (c) => { app.get('/services/:fqn/offers/:offerId/answer', authMiddleware, async (c) => {
try { try {
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
const offerId = c.req.param('offerId');
const peerId = getAuthenticatedPeerId(c); const peerId = getAuthenticatedPeerId(c);
const offers = await storage.getAnsweredOffers(peerId);
// Get the offer
const offer = await storage.getOfferById(offerId);
if (!offer) {
return c.json({ error: 'Offer not found' }, 404);
}
// Verify ownership
if (offer.peerId !== peerId) {
return c.json({ error: 'Not authorized to access this offer' }, 403);
}
if (!offer.answerSdp) {
return c.json({ error: 'Offer not yet answered' }, 404);
}
return c.json({ return c.json({
answers: offers.map(offer => ({ offerId: offer.id,
offerId: offer.id, answererId: offer.answererPeerId,
answererPeerId: offer.answererPeerId, sdp: offer.answerSdp,
answerSdp: offer.answerSdp, answeredAt: offer.answeredAt
answeredAt: offer.answeredAt
}))
}, 200); }, 200);
} catch (err) { } catch (err) {
console.error('Error getting answers:', err); console.error('Error getting offer answer:', err);
return c.json({ error: 'Internal server error' }, 500); return c.json({ error: 'Internal server error' }, 500);
} }
}); });
// ===== ICE Candidate Exchange =====
/** /**
* POST /offers/:offerId/ice-candidates * POST /services/:fqn/offers/:offerId/ice-candidates
* Add ICE candidates for an offer * Add ICE candidates for a specific offer
*/ */
app.post('/offers/:offerId/ice-candidates', authMiddleware, async (c) => { app.post('/services/:fqn/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
try { try {
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
const offerId = c.req.param('offerId'); const offerId = c.req.param('offerId');
const body = await c.req.json(); const body = await c.req.json();
const { candidates } = body; const { candidates } = body;
@@ -565,12 +551,12 @@ export function createApp(storage: Storage, config: Config) {
return c.json({ error: 'Offer not found' }, 404); return c.json({ error: 'Offer not found' }, 404);
} }
// Determine role // Determine role (offerer or answerer)
const role = offer.peerId === peerId ? 'offerer' : 'answerer'; const role = offer.peerId === peerId ? 'offerer' : 'answerer';
const count = await storage.addIceCandidates(offerId, peerId, role, candidates); const count = await storage.addIceCandidates(offerId, peerId, role, candidates);
return c.json({ count }, 200); return c.json({ count, offerId }, 200);
} catch (err) { } catch (err) {
console.error('Error adding ICE candidates:', err); console.error('Error adding ICE candidates:', err);
return c.json({ error: 'Internal server error' }, 500); return c.json({ error: 'Internal server error' }, 500);
@@ -578,11 +564,12 @@ export function createApp(storage: Storage, config: Config) {
}); });
/** /**
* GET /offers/:offerId/ice-candidates * GET /services/:fqn/offers/:offerId/ice-candidates
* Get ICE candidates for an offer * Get ICE candidates for a specific offer
*/ */
app.get('/offers/:offerId/ice-candidates', authMiddleware, async (c) => { app.get('/services/:fqn/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
try { try {
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
const offerId = c.req.param('offerId'); const offerId = c.req.param('offerId');
const since = c.req.query('since'); const since = c.req.query('since');
const peerId = getAuthenticatedPeerId(c); const peerId = getAuthenticatedPeerId(c);
@@ -603,7 +590,8 @@ export function createApp(storage: Storage, config: Config) {
candidates: candidates.map(c => ({ candidates: candidates.map(c => ({
candidate: c.candidate, candidate: c.candidate,
createdAt: c.createdAt createdAt: c.createdAt
})) })),
offerId
}, 200); }, 200);
} catch (err) { } catch (err) {
console.error('Error getting ICE candidates:', err); console.error('Error getting ICE candidates:', err);

View File

@@ -1,66 +0,0 @@
/**
* 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;
}
}

View File

@@ -16,7 +16,6 @@ export interface Config {
offerMinTtl: number; offerMinTtl: number;
cleanupInterval: number; cleanupInterval: number;
maxOffersPerRequest: number; maxOffersPerRequest: number;
maxTopicsPerOffer: number;
} }
/** /**
@@ -45,7 +44,6 @@ export function loadConfig(): Config {
offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10), offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10),
offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10), offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10),
cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || '60000', 10), cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || '60000', 10),
maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || '100', 10), maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || '100', 10)
maxTopicsPerOffer: parseInt(process.env.MAX_TOPICS_PER_OFFER || '50', 10),
}; };
} }

View File

@@ -192,31 +192,32 @@ export function validateUsername(username: string): { valid: boolean; error?: st
} }
/** /**
* Validates service FQN format (service-name@version) * Validates service FQN format (service:version@username or service:version)
* Service name: reverse domain notation (com.example.service) * Service name: lowercase alphanumeric with dots/dashes (e.g., chat, file-share, com.example.chat)
* Version: semantic versioning (1.0.0, 2.1.3-beta, etc.) * Version: semantic versioning (1.0.0, 2.1.3-beta, etc.)
* Username: optional, lowercase alphanumeric with dashes
*/ */
export function validateServiceFqn(fqn: string): { valid: boolean; error?: string } { export function validateServiceFqn(fqn: string): { valid: boolean; error?: string } {
if (typeof fqn !== 'string') { if (typeof fqn !== 'string') {
return { valid: false, error: 'Service FQN must be a string' }; return { valid: false, error: 'Service FQN must be a string' };
} }
// Split into service name and version // Parse the FQN
const parts = fqn.split('@'); const parsed = parseServiceFqn(fqn);
if (parts.length !== 2) { if (!parsed) {
return { valid: false, error: 'Service FQN must be in format: service-name@version' }; return { valid: false, error: 'Service FQN must be in format: service:version[@username]' };
} }
const [serviceName, version] = parts; const { serviceName, version, username } = parsed;
// Validate service name (reverse domain notation) // Validate service name (alphanumeric with dots/dashes)
const serviceNameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; const serviceNameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
if (!serviceNameRegex.test(serviceName)) { if (!serviceNameRegex.test(serviceName)) {
return { valid: false, error: 'Service name must be reverse domain notation (e.g., com.example.service)' }; return { valid: false, error: 'Service name must be lowercase alphanumeric with optional dots/dashes' };
} }
if (serviceName.length < 3 || serviceName.length > 128) { if (serviceName.length < 1 || serviceName.length > 128) {
return { valid: false, error: 'Service name must be 3-128 characters' }; return { valid: false, error: 'Service name must be 1-128 characters' };
} }
// Validate version (semantic versioning) // Validate version (semantic versioning)
@@ -225,9 +226,97 @@ export function validateServiceFqn(fqn: string): { valid: boolean; error?: strin
return { valid: false, error: 'Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)' }; return { valid: false, error: 'Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)' };
} }
// Validate username if present
if (username) {
const usernameCheck = validateUsername(username);
if (!usernameCheck.valid) {
return usernameCheck;
}
}
return { valid: true }; return { valid: true };
} }
/**
* Parse semantic version string into components
*/
export function parseVersion(version: string): { major: number; minor: number; patch: number; prerelease?: string } | null {
const match = version.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-z0-9.-]+)?$/);
if (!match) return null;
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
prerelease: match[4]?.substring(1), // Remove leading dash
};
}
/**
* Check if two versions are compatible (same major version)
* Following semver rules: ^1.0.0 matches 1.x.x but not 2.x.x
*/
export function isVersionCompatible(requested: string, available: string): boolean {
const req = parseVersion(requested);
const avail = parseVersion(available);
if (!req || !avail) return false;
// Major version must match
if (req.major !== avail.major) return false;
// If major is 0, minor must also match (0.x.y is unstable)
if (req.major === 0 && req.minor !== avail.minor) return false;
// Available version must be >= requested version
if (avail.minor < req.minor) return false;
if (avail.minor === req.minor && avail.patch < req.patch) return false;
// Prerelease versions are only compatible with exact matches
if (req.prerelease && req.prerelease !== avail.prerelease) return false;
return true;
}
/**
* Parse service FQN into components
* Formats supported:
* - service:version@username (e.g., "chat:1.0.0@alice")
* - service:version (e.g., "chat:1.0.0") for discovery
*/
export function parseServiceFqn(fqn: string): { serviceName: string; version: string; username: string | null } | null {
if (!fqn || typeof fqn !== 'string') return null;
// Check if username is present
const atIndex = fqn.lastIndexOf('@');
let serviceVersion: string;
let username: string | null = null;
if (atIndex > 0) {
// Format: service:version@username
serviceVersion = fqn.substring(0, atIndex);
username = fqn.substring(atIndex + 1);
} else {
// Format: service:version (no username)
serviceVersion = fqn;
}
// Split service:version
const colonIndex = serviceVersion.indexOf(':');
if (colonIndex <= 0) return null; // No colon or colon at start
const serviceName = serviceVersion.substring(0, colonIndex);
const version = serviceVersion.substring(colonIndex + 1);
if (!serviceName || !version) return null;
return {
serviceName,
version,
username,
};
}
/** /**
* Validates timestamp is within acceptable range (prevents replay attacks) * Validates timestamp is within acceptable range (prevents replay attacks)
*/ */
@@ -336,16 +425,24 @@ export async function validateServicePublish(
} }
// Parse message format: "publish:{username}:{serviceFqn}:{timestamp}" // Parse message format: "publish:{username}:{serviceFqn}:{timestamp}"
// Note: serviceFqn can contain colons (e.g., "chat:2.0.0@user"), so we need careful parsing
const parts = message.split(':'); const parts = message.split(':');
if (parts.length !== 4 || parts[0] !== 'publish' || parts[1] !== username || parts[2] !== serviceFqn) { if (parts.length < 4 || parts[0] !== 'publish' || parts[1] !== username) {
return { valid: false, error: 'Invalid message format (expected: publish:{username}:{serviceFqn}:{timestamp})' }; return { valid: false, error: 'Invalid message format (expected: publish:{username}:{serviceFqn}:{timestamp})' };
} }
const timestamp = parseInt(parts[3], 10); // The timestamp is the last part
const timestamp = parseInt(parts[parts.length - 1], 10);
if (isNaN(timestamp)) { if (isNaN(timestamp)) {
return { valid: false, error: 'Invalid timestamp in message' }; return { valid: false, error: 'Invalid timestamp in message' };
} }
// The serviceFqn is everything between username and timestamp
const extractedServiceFqn = parts.slice(2, parts.length - 1).join(':');
if (extractedServiceFqn !== serviceFqn) {
return { valid: false, error: `Service FQN mismatch (expected: ${serviceFqn}, got: ${extractedServiceFqn})` };
}
// Validate timestamp // Validate timestamp
const timestampCheck = validateTimestamp(timestamp); const timestampCheck = validateTimestamp(timestamp);
if (!timestampCheck.valid) { if (!timestampCheck.valid) {

View File

@@ -20,7 +20,6 @@ async function main() {
offerMinTtl: `${config.offerMinTtl}ms`, offerMinTtl: `${config.offerMinTtl}ms`,
cleanupInterval: `${config.cleanupInterval}ms`, cleanupInterval: `${config.cleanupInterval}ms`,
maxOffersPerRequest: config.maxOffersPerRequest, maxOffersPerRequest: config.maxOffersPerRequest,
maxTopicsPerOffer: config.maxTopicsPerOffer,
corsOrigins: config.corsOrigins, corsOrigins: config.corsOrigins,
version: config.version, version: config.version,
}); });

View File

@@ -8,9 +8,9 @@ import {
ClaimUsernameRequest, ClaimUsernameRequest,
Service, Service,
CreateServiceRequest, CreateServiceRequest,
ServiceInfo,
} from './types.ts'; } from './types.ts';
import { generateOfferHash } from './hash-id.ts'; import { generateOfferHash } from './hash-id.ts';
import { parseServiceFqn } from '../crypto.ts';
const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; // 365 days const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; // 365 days
@@ -34,10 +34,11 @@ export class D1Storage implements Storage {
*/ */
async initializeDatabase(): Promise<void> { async initializeDatabase(): Promise<void> {
await this.db.exec(` await this.db.exec(`
-- Offers table (no topics) -- WebRTC signaling offers
CREATE TABLE IF NOT EXISTS offers ( CREATE TABLE IF NOT EXISTS offers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
peer_id TEXT NOT NULL, peer_id TEXT NOT NULL,
service_id TEXT,
sdp TEXT NOT NULL, sdp TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
@@ -49,6 +50,7 @@ export class D1Storage implements Storage {
); );
CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id); CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id);
CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id);
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at); 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_last_seen ON offers(last_seen);
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id); CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
@@ -82,39 +84,23 @@ export class D1Storage implements Storage {
CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at); CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at);
CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key); CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key);
-- Services table -- Services table (new schema with extracted fields for discovery)
CREATE TABLE IF NOT EXISTS services ( CREATE TABLE IF NOT EXISTS services (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL,
service_fqn TEXT NOT NULL, service_fqn TEXT NOT NULL,
offer_id TEXT NOT NULL, service_name TEXT NOT NULL,
version TEXT NOT NULL,
username TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
is_public INTEGER NOT NULL DEFAULT 0,
metadata TEXT,
FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE, FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE, UNIQUE(service_fqn)
UNIQUE(username, service_fqn)
); );
CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn); CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn);
CREATE INDEX IF NOT EXISTS idx_services_discovery ON services(service_name, version);
CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at); CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
CREATE INDEX IF NOT EXISTS idx_services_offer ON services(offer_id);
-- Service index table (privacy layer)
CREATE TABLE IF NOT EXISTS service_index (
uuid TEXT PRIMARY KEY,
service_id TEXT NOT NULL,
username TEXT NOT NULL,
service_fqn TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_service_index_username ON service_index(username);
CREATE INDEX IF NOT EXISTS idx_service_index_expires ON service_index(expires_at);
`); `);
} }
@@ -125,7 +111,7 @@ export class D1Storage implements Storage {
// D1 doesn't support true transactions yet, so we do this sequentially // D1 doesn't support true transactions yet, so we do this sequentially
for (const offer of offers) { for (const offer of offers) {
const id = offer.id || await generateOfferHash(offer.sdp, []); const id = offer.id || await generateOfferHash(offer.sdp);
const now = Date.now(); const now = Date.now();
await this.db.prepare(` await this.db.prepare(`
@@ -321,36 +307,44 @@ export class D1Storage implements Storage {
const now = Date.now(); const now = Date.now();
const expiresAt = now + YEAR_IN_MS; const expiresAt = now + YEAR_IN_MS;
// Try to insert or update try {
const result = await this.db.prepare(` // Try to insert or update
INSERT INTO usernames (username, public_key, claimed_at, expires_at, last_used, metadata) const result = await this.db.prepare(`
VALUES (?, ?, ?, ?, ?, NULL) INSERT INTO usernames (username, public_key, claimed_at, expires_at, last_used, metadata)
ON CONFLICT(username) DO UPDATE SET VALUES (?, ?, ?, ?, ?, NULL)
expires_at = ?, ON CONFLICT(username) DO UPDATE SET
last_used = ? expires_at = ?,
WHERE public_key = ? last_used = ?
`).bind( WHERE public_key = ?
request.username, `).bind(
request.publicKey, request.username,
now, request.publicKey,
expiresAt, now,
now, expiresAt,
expiresAt, now,
now, expiresAt,
request.publicKey now,
).run(); request.publicKey
).run();
if ((result.meta.changes || 0) === 0) { if ((result.meta.changes || 0) === 0) {
throw new Error('Username already claimed by different public key'); throw new Error('Username already claimed by different public key');
}
return {
username: request.username,
publicKey: request.publicKey,
claimedAt: now,
expiresAt,
lastUsed: now,
};
} catch (err: any) {
// Handle UNIQUE constraint on public_key
if (err.message?.includes('UNIQUE constraint failed: usernames.public_key')) {
throw new Error('This public key has already claimed a different username');
}
throw err;
} }
return {
username: request.username,
publicKey: request.publicKey,
claimedAt: now,
expiresAt,
lastUsed: now,
};
} }
async getUsername(username: string): Promise<Username | null> { async getUsername(username: string): Promise<Username | null> {
@@ -375,18 +369,6 @@ export class D1Storage implements Storage {
}; };
} }
async touchUsername(username: string): Promise<boolean> {
const now = Date.now();
const expiresAt = now + YEAR_IN_MS;
const result = await this.db.prepare(`
UPDATE usernames
SET last_used = ?, expires_at = ?
WHERE username = ? AND expires_at > ?
`).bind(now, expiresAt, username, now).run();
return (result.meta.changes || 0) > 0;
}
async deleteExpiredUsernames(now: number): Promise<number> { async deleteExpiredUsernames(now: number): Promise<number> {
const result = await this.db.prepare(` const result = await this.db.prepare(`
@@ -400,58 +382,80 @@ export class D1Storage implements Storage {
async createService(request: CreateServiceRequest): Promise<{ async createService(request: CreateServiceRequest): Promise<{
service: Service; service: Service;
indexUuid: string; offers: Offer[];
}> { }> {
const serviceId = crypto.randomUUID(); const serviceId = crypto.randomUUID();
const indexUuid = crypto.randomUUID();
const now = Date.now(); const now = Date.now();
// Insert service // Parse FQN to extract components
await this.db.prepare(` const parsed = parseServiceFqn(request.serviceFqn);
INSERT INTO services (id, username, service_fqn, offer_id, created_at, expires_at, is_public, metadata) if (!parsed) {
VALUES (?, ?, ?, ?, ?, ?, ?, ?) throw new Error(`Invalid service FQN: ${request.serviceFqn}`);
`).bind( }
serviceId, if (!parsed.username) {
request.username, throw new Error(`Service FQN must include username: ${request.serviceFqn}`);
request.serviceFqn, }
request.offerId,
now,
request.expiresAt,
request.isPublic ? 1 : 0,
request.metadata || null
).run();
// Insert service index const { serviceName, version, username } = parsed;
// Insert service with extracted fields
await this.db.prepare(` await this.db.prepare(`
INSERT INTO service_index (uuid, service_id, username, service_fqn, created_at, expires_at) INSERT INTO services (id, service_fqn, service_name, version, username, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`).bind( `).bind(
indexUuid,
serviceId, serviceId,
request.username,
request.serviceFqn, request.serviceFqn,
serviceName,
version,
username,
now, now,
request.expiresAt request.expiresAt
).run(); ).run();
// Touch username to extend expiry // Create offers with serviceId
await this.touchUsername(request.username); const offerRequests = request.offers.map(offer => ({
...offer,
serviceId,
}));
const offers = await this.createOffers(offerRequests);
// Touch username to extend expiry (inline logic)
const expiresAt = now + YEAR_IN_MS;
await this.db.prepare(`
UPDATE usernames
SET last_used = ?, expires_at = ?
WHERE username = ? AND expires_at > ?
`).bind(now, expiresAt, username, now).run();
return { return {
service: { service: {
id: serviceId, id: serviceId,
username: request.username,
serviceFqn: request.serviceFqn, serviceFqn: request.serviceFqn,
offerId: request.offerId, serviceName,
version,
username,
createdAt: now, createdAt: now,
expiresAt: request.expiresAt, expiresAt: request.expiresAt,
isPublic: request.isPublic || false,
metadata: request.metadata,
}, },
indexUuid, offers,
}; };
} }
async getOffersForService(serviceId: string): Promise<Offer[]> {
const result = await this.db.prepare(`
SELECT * FROM offers
WHERE service_id = ? AND expires_at > ?
ORDER BY created_at ASC
`).bind(serviceId, Date.now()).all();
if (!result.results) {
return [];
}
return result.results.map(row => this.rowToOffer(row as any));
}
async getServiceById(serviceId: string): Promise<Service | null> { async getServiceById(serviceId: string): Promise<Service | null> {
const result = await this.db.prepare(` const result = await this.db.prepare(`
SELECT * FROM services SELECT * FROM services
@@ -465,12 +469,11 @@ export class D1Storage implements Storage {
return this.rowToService(result as any); return this.rowToService(result as any);
} }
async getServiceByUuid(uuid: string): Promise<Service | null> { async getServiceByFqn(serviceFqn: string): Promise<Service | null> {
const result = await this.db.prepare(` const result = await this.db.prepare(`
SELECT s.* FROM services s SELECT * FROM services
INNER JOIN service_index si ON s.id = si.service_id WHERE service_fqn = ? AND expires_at > ?
WHERE si.uuid = ? AND s.expires_at > ? `).bind(serviceFqn, Date.now()).first();
`).bind(uuid, Date.now()).first();
if (!result) { if (!result) {
return null; return null;
@@ -479,35 +482,56 @@ export class D1Storage implements Storage {
return this.rowToService(result as any); return this.rowToService(result as any);
} }
async listServicesForUsername(username: string): Promise<ServiceInfo[]> {
async discoverServices(
serviceName: string,
version: string,
limit: number,
offset: number
): Promise<Service[]> {
// Query for unique services with available offers
// We join with offers and filter for available ones (answerer_peer_id IS NULL)
const result = await this.db.prepare(` const result = await this.db.prepare(`
SELECT si.uuid, s.is_public, s.service_fqn, s.metadata SELECT DISTINCT s.* FROM services s
FROM service_index si INNER JOIN offers o ON o.service_id = s.id
INNER JOIN services s ON si.service_id = s.id WHERE s.service_name = ?
WHERE si.username = ? AND si.expires_at > ? AND s.version = ?
AND s.expires_at > ?
AND o.answerer_peer_id IS NULL
AND o.expires_at > ?
ORDER BY s.created_at DESC ORDER BY s.created_at DESC
`).bind(username, Date.now()).all(); LIMIT ? OFFSET ?
`).bind(serviceName, version, Date.now(), Date.now(), limit, offset).all();
if (!result.results) { if (!result.results) {
return []; return [];
} }
return result.results.map((row: any) => ({ return result.results.map(row => this.rowToService(row as any));
uuid: row.uuid,
isPublic: row.is_public === 1,
serviceFqn: row.is_public === 1 ? row.service_fqn : undefined,
metadata: row.is_public === 1 ? row.metadata || undefined : undefined,
}));
} }
async queryService(username: string, serviceFqn: string): Promise<string | null> { async getRandomService(serviceName: string, version: string): Promise<Service | null> {
// Get a random service with an available offer
const result = await this.db.prepare(` const result = await this.db.prepare(`
SELECT si.uuid FROM service_index si SELECT s.* FROM services s
INNER JOIN services s ON si.service_id = s.id INNER JOIN offers o ON o.service_id = s.id
WHERE si.username = ? AND si.service_fqn = ? AND si.expires_at > ? WHERE s.service_name = ?
`).bind(username, serviceFqn, Date.now()).first(); AND s.version = ?
AND s.expires_at > ?
AND o.answerer_peer_id IS NULL
AND o.expires_at > ?
ORDER BY RANDOM()
LIMIT 1
`).bind(serviceName, version, Date.now(), Date.now()).first();
return result ? (result as any).uuid : null; if (!result) {
return null;
}
return this.rowToService(result as any);
} }
async deleteService(serviceId: string, username: string): Promise<boolean> { async deleteService(serviceId: string, username: string): Promise<boolean> {
@@ -558,13 +582,12 @@ export class D1Storage implements Storage {
private rowToService(row: any): Service { private rowToService(row: any): Service {
return { return {
id: row.id, id: row.id,
username: row.username,
serviceFqn: row.service_fqn, serviceFqn: row.service_fqn,
offerId: row.offer_id, serviceName: row.service_name,
version: row.version,
username: row.username,
createdAt: row.created_at, createdAt: row.created_at,
expiresAt: row.expires_at, expiresAt: row.expires_at,
isPublic: row.is_public === 1,
metadata: row.metadata || undefined,
}; };
} }
} }

View File

@@ -1,22 +1,17 @@
/** /**
* Generates a content-based offer ID using SHA-256 hash * Generates a content-based offer ID using SHA-256 hash
* Creates deterministic IDs based on offer content (sdp, topics) * Creates deterministic IDs based on offer SDP content
* PeerID is not included as it's inferred from authentication * PeerID is not included as it's inferred from authentication
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
* *
* @param sdp - The WebRTC SDP offer * @param sdp - The WebRTC SDP offer
* @param topics - Array of topic strings * @returns SHA-256 hash of the SDP content
* @returns SHA-256 hash of the sanitized offer content
*/ */
export async function generateOfferHash( export async function generateOfferHash(sdp: string): Promise<string> {
sdp: string,
topics: string[]
): Promise<string> {
// Sanitize and normalize the offer content // Sanitize and normalize the offer content
// Only include core offer content (not peerId - that's inferred from auth) // Only include core offer content (not peerId - that's inferred from auth)
const sanitizedOffer = { const sanitizedOffer = {
sdp, sdp
topics: [...topics].sort(), // Sort topics for consistency
}; };
// Create non-prettified JSON string // Create non-prettified JSON string

View File

@@ -36,10 +36,11 @@ export class SQLiteStorage implements Storage {
*/ */
private initializeDatabase(): void { private initializeDatabase(): void {
this.db.exec(` this.db.exec(`
-- Offers table (no topics) -- WebRTC signaling offers
CREATE TABLE IF NOT EXISTS offers ( CREATE TABLE IF NOT EXISTS offers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
peer_id TEXT NOT NULL, peer_id TEXT NOT NULL,
service_id TEXT,
sdp TEXT NOT NULL, sdp TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
@@ -47,10 +48,12 @@ export class SQLiteStorage implements Storage {
secret TEXT, secret TEXT,
answerer_peer_id TEXT, answerer_peer_id TEXT,
answer_sdp TEXT, answer_sdp TEXT,
answered_at INTEGER answered_at INTEGER,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id); CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id);
CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id);
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at); 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_last_seen ON offers(last_seen);
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id); CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
@@ -84,25 +87,22 @@ export class SQLiteStorage implements Storage {
CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at); CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at);
CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key); CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key);
-- Services table -- Services table (one service can have multiple offers)
CREATE TABLE IF NOT EXISTS services ( CREATE TABLE IF NOT EXISTS services (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
service_fqn TEXT NOT NULL, service_fqn TEXT NOT NULL,
offer_id TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
is_public INTEGER NOT NULL DEFAULT 0, is_public INTEGER NOT NULL DEFAULT 0,
metadata TEXT, metadata TEXT,
FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE, FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE,
UNIQUE(username, service_fqn) UNIQUE(username, service_fqn)
); );
CREATE INDEX IF NOT EXISTS idx_services_username ON services(username); CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn); CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn);
CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at); CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
CREATE INDEX IF NOT EXISTS idx_services_offer ON services(offer_id);
-- Service index table (privacy layer) -- Service index table (privacy layer)
CREATE TABLE IF NOT EXISTS service_index ( CREATE TABLE IF NOT EXISTS service_index (
@@ -132,15 +132,15 @@ export class SQLiteStorage implements Storage {
const offersWithIds = await Promise.all( const offersWithIds = await Promise.all(
offers.map(async (offer) => ({ offers.map(async (offer) => ({
...offer, ...offer,
id: offer.id || await generateOfferHash(offer.sdp, []), id: offer.id || await generateOfferHash(offer.sdp),
})) }))
); );
// Use transaction for atomic creation // Use transaction for atomic creation
const transaction = this.db.transaction((offersWithIds: (CreateOfferRequest & { id: string })[]) => { const transaction = this.db.transaction((offersWithIds: (CreateOfferRequest & { id: string })[]) => {
const offerStmt = this.db.prepare(` const offerStmt = this.db.prepare(`
INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen, secret) INSERT INTO offers (id, peer_id, service_id, sdp, created_at, expires_at, last_seen, secret)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`); `);
for (const offer of offersWithIds) { for (const offer of offersWithIds) {
@@ -150,6 +150,7 @@ export class SQLiteStorage implements Storage {
offerStmt.run( offerStmt.run(
offer.id, offer.id,
offer.peerId, offer.peerId,
offer.serviceId || null,
offer.sdp, offer.sdp,
now, now,
offer.expiresAt, offer.expiresAt,
@@ -160,6 +161,7 @@ export class SQLiteStorage implements Storage {
created.push({ created.push({
id: offer.id, id: offer.id,
peerId: offer.peerId, peerId: offer.peerId,
serviceId: offer.serviceId || undefined,
sdp: offer.sdp, sdp: offer.sdp,
createdAt: now, createdAt: now,
expiresAt: offer.expiresAt, expiresAt: offer.expiresAt,
@@ -426,23 +428,31 @@ export class SQLiteStorage implements Storage {
async createService(request: CreateServiceRequest): Promise<{ async createService(request: CreateServiceRequest): Promise<{
service: Service; service: Service;
indexUuid: string; indexUuid: string;
offers: Offer[];
}> { }> {
const serviceId = randomUUID(); const serviceId = randomUUID();
const indexUuid = randomUUID(); const indexUuid = randomUUID();
const now = Date.now(); const now = Date.now();
// Create offers with serviceId
const offerRequests: CreateOfferRequest[] = request.offers.map(offer => ({
...offer,
serviceId,
}));
const offers = await this.createOffers(offerRequests);
const transaction = this.db.transaction(() => { const transaction = this.db.transaction(() => {
// Insert service // Insert service (no offer_id column anymore)
const serviceStmt = this.db.prepare(` const serviceStmt = this.db.prepare(`
INSERT INTO services (id, username, service_fqn, offer_id, created_at, expires_at, is_public, metadata) INSERT INTO services (id, username, service_fqn, created_at, expires_at, is_public, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`); `);
serviceStmt.run( serviceStmt.run(
serviceId, serviceId,
request.username, request.username,
request.serviceFqn, request.serviceFqn,
request.offerId,
now, now,
request.expiresAt, request.expiresAt,
request.isPublic ? 1 : 0, request.isPublic ? 1 : 0,
@@ -475,16 +485,31 @@ export class SQLiteStorage implements Storage {
id: serviceId, id: serviceId,
username: request.username, username: request.username,
serviceFqn: request.serviceFqn, serviceFqn: request.serviceFqn,
offerId: request.offerId,
createdAt: now, createdAt: now,
expiresAt: request.expiresAt, expiresAt: request.expiresAt,
isPublic: request.isPublic || false, isPublic: request.isPublic || false,
metadata: request.metadata, metadata: request.metadata,
}, },
indexUuid, indexUuid,
offers,
}; };
} }
async batchCreateServices(requests: CreateServiceRequest[]): Promise<Array<{
service: Service;
indexUuid: string;
offers: Offer[];
}>> {
const results = [];
for (const request of requests) {
const result = await this.createService(request);
results.push(result);
}
return results;
}
async getServiceById(serviceId: string): Promise<Service | null> { async getServiceById(serviceId: string): Promise<Service | null> {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
SELECT * FROM services SELECT * FROM services
@@ -547,6 +572,18 @@ export class SQLiteStorage implements Storage {
return row ? row.uuid : null; return row ? row.uuid : null;
} }
async findServicesByName(username: string, serviceName: string): Promise<Service[]> {
const stmt = this.db.prepare(`
SELECT * FROM services
WHERE username = ? AND service_fqn LIKE ? AND expires_at > ?
ORDER BY created_at DESC
`);
const rows = stmt.all(username, `${serviceName}@%`, Date.now()) as any[];
return rows.map(row => this.rowToService(row));
}
async deleteService(serviceId: string, username: string): Promise<boolean> { async deleteService(serviceId: string, username: string): Promise<boolean> {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
DELETE FROM services DELETE FROM services
@@ -576,6 +613,7 @@ export class SQLiteStorage implements Storage {
return { return {
id: row.id, id: row.id,
peerId: row.peer_id, peerId: row.peer_id,
serviceId: row.service_id || undefined,
sdp: row.sdp, sdp: row.sdp,
createdAt: row.created_at, createdAt: row.created_at,
expiresAt: row.expires_at, expiresAt: row.expires_at,
@@ -595,11 +633,24 @@ export class SQLiteStorage implements Storage {
id: row.id, id: row.id,
username: row.username, username: row.username,
serviceFqn: row.service_fqn, serviceFqn: row.service_fqn,
offerId: row.offer_id,
createdAt: row.created_at, createdAt: row.created_at,
expiresAt: row.expires_at, expiresAt: row.expires_at,
isPublic: row.is_public === 1, isPublic: row.is_public === 1,
metadata: row.metadata || undefined, metadata: row.metadata || undefined,
}; };
} }
/**
* Get all offers for a service
*/
async getOffersForService(serviceId: string): Promise<Offer[]> {
const stmt = this.db.prepare(`
SELECT * FROM offers
WHERE service_id = ? AND expires_at > ?
ORDER BY created_at ASC
`);
const rows = stmt.all(serviceId, Date.now()) as any[];
return rows.map(row => this.rowToOffer(row));
}
} }

View File

@@ -1,15 +1,15 @@
/** /**
* Represents a WebRTC signaling offer (no topics) * Represents a WebRTC signaling offer
*/ */
export interface Offer { export interface Offer {
id: string; id: string;
peerId: string; peerId: string;
serviceId?: string; // Optional link to service (null for standalone offers)
sdp: string; sdp: string;
createdAt: number; createdAt: number;
expiresAt: number; expiresAt: number;
lastSeen: number; lastSeen: number;
secret?: string; secret?: string;
info?: string;
answererPeerId?: string; answererPeerId?: string;
answerSdp?: string; answerSdp?: string;
answeredAt?: number; answeredAt?: number;
@@ -34,10 +34,10 @@ export interface IceCandidate {
export interface CreateOfferRequest { export interface CreateOfferRequest {
id?: string; id?: string;
peerId: string; peerId: string;
serviceId?: string; // Optional link to service
sdp: string; sdp: string;
expiresAt: number; expiresAt: number;
secret?: string; secret?: string;
info?: string;
} }
/** /**
@@ -63,51 +63,26 @@ export interface ClaimUsernameRequest {
} }
/** /**
* Represents a published service * Represents a published service (can have multiple offers)
* New format: service:version@username (e.g., chat:1.0.0@alice)
*/ */
export interface Service { export interface Service {
id: string; // UUID v4 id: string; // UUID v4
username: string; serviceFqn: string; // Full FQN: chat:1.0.0@alice
serviceFqn: string; // com.example.chat@1.0.0 serviceName: string; // Extracted: chat
offerId: string; // Links to offers table version: string; // Extracted: 1.0.0
username: string; // Extracted: alice
createdAt: number; createdAt: number;
expiresAt: number; expiresAt: number;
isPublic: boolean;
metadata?: string; // JSON service description
} }
/** /**
* Request to create a service * Request to create a single service
*/ */
export interface CreateServiceRequest { export interface CreateServiceRequest {
username: string; serviceFqn: string; // Full FQN with username: chat:1.0.0@alice
serviceFqn: string;
offerId: string;
expiresAt: number; expiresAt: number;
isPublic?: boolean; offers: CreateOfferRequest[]; // Multiple offers per service
metadata?: string;
}
/**
* Represents a service index entry (privacy layer)
*/
export interface ServiceIndex {
uuid: string; // Random UUID for privacy
serviceId: string;
username: string;
serviceFqn: string;
createdAt: number;
expiresAt: number;
}
/**
* Service info for discovery (privacy-aware)
*/
export interface ServiceInfo {
uuid: string;
isPublic: boolean;
serviceFqn?: string; // Only present if public
metadata?: string; // Only present if public
} }
/** /**
@@ -219,13 +194,6 @@ export interface Storage {
*/ */
getUsername(username: string): Promise<Username | null>; getUsername(username: string): Promise<Username | null>;
/**
* Updates the last_used timestamp for a username (extends expiry)
* @param username Username to update
* @returns true if updated, false if not found
*/
touchUsername(username: string): Promise<boolean>;
/** /**
* Deletes all expired usernames * Deletes all expired usernames
* @param now Current timestamp * @param now Current timestamp
@@ -236,15 +204,23 @@ export interface Storage {
// ===== Service Management ===== // ===== Service Management =====
/** /**
* Creates a new service * Creates a new service with offers
* @param request Service creation request * @param request Service creation request (includes offers)
* @returns Created service with generated ID and index UUID * @returns Created service with generated ID and created offers
*/ */
createService(request: CreateServiceRequest): Promise<{ createService(request: CreateServiceRequest): Promise<{
service: Service; service: Service;
indexUuid: string; offers: Offer[];
}>; }>;
/**
* Gets all offers for a service
* @param serviceId Service ID
* @returns Array of offers for the service
*/
getOffersForService(serviceId: string): Promise<Offer[]>;
/** /**
* Gets a service by its service ID * Gets a service by its service ID
* @param serviceId Service ID * @param serviceId Service ID
@@ -253,26 +229,40 @@ export interface Storage {
getServiceById(serviceId: string): Promise<Service | null>; getServiceById(serviceId: string): Promise<Service | null>;
/** /**
* Gets a service by its index UUID * Gets a service by its fully qualified name (FQN)
* @param uuid Index UUID * @param serviceFqn Full service FQN (e.g., "chat:1.0.0@alice")
* @returns Service if found, null otherwise * @returns Service if found, null otherwise
*/ */
getServiceByUuid(uuid: string): Promise<Service | null>; getServiceByFqn(serviceFqn: string): Promise<Service | null>;
/** /**
* Lists all services for a username (with privacy filtering) * Discovers services by name and version with pagination
* @param username Username to query * Returns unique available offers (where answerer_peer_id IS NULL)
* @returns Array of service info (UUIDs only for private services) * @param serviceName Service name (e.g., 'chat')
* @param version Version string for semver matching (e.g., '1.0.0')
* @param limit Maximum number of unique services to return
* @param offset Number of services to skip
* @returns Array of services with available offers
*/ */
listServicesForUsername(username: string): Promise<ServiceInfo[]>; discoverServices(
serviceName: string,
version: string,
limit: number,
offset: number
): Promise<Service[]>;
/** /**
* Queries a service by username and FQN * Gets a random available service by name and version
* @param username Username * Returns a single random offer that is available (answerer_peer_id IS NULL)
* @param serviceFqn Service FQN * @param serviceName Service name (e.g., 'chat')
* @returns Service index UUID if found, null otherwise * @param version Version string for semver matching (e.g., '1.0.0')
* @returns Random service with available offer, or null if none found
*/ */
queryService(username: string, serviceFqn: string): Promise<string | null>; getRandomService(serviceName: string, version: string): Promise<Service | null>;
/** /**
* Deletes a service (with ownership verification) * Deletes a service (with ownership verification)

View File

@@ -13,7 +13,6 @@ export interface Env {
OFFER_MAX_TTL?: string; OFFER_MAX_TTL?: string;
OFFER_MIN_TTL?: string; OFFER_MIN_TTL?: string;
MAX_OFFERS_PER_REQUEST?: string; MAX_OFFERS_PER_REQUEST?: string;
MAX_TOPICS_PER_OFFER?: string;
CORS_ORIGINS?: string; CORS_ORIGINS?: string;
VERSION?: string; VERSION?: string;
} }
@@ -43,8 +42,7 @@ export default {
offerMaxTtl: env.OFFER_MAX_TTL ? parseInt(env.OFFER_MAX_TTL, 10) : 86400000, offerMaxTtl: env.OFFER_MAX_TTL ? parseInt(env.OFFER_MAX_TTL, 10) : 86400000,
offerMinTtl: env.OFFER_MIN_TTL ? parseInt(env.OFFER_MIN_TTL, 10) : 60000, offerMinTtl: env.OFFER_MIN_TTL ? parseInt(env.OFFER_MIN_TTL, 10) : 60000,
cleanupInterval: 60000, // Not used in Workers (scheduled handler instead) cleanupInterval: 60000, // Not used in Workers (scheduled handler instead)
maxOffersPerRequest: env.MAX_OFFERS_PER_REQUEST ? parseInt(env.MAX_OFFERS_PER_REQUEST, 10) : 100, 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 // Create Hono app

View File

@@ -17,7 +17,7 @@ OFFER_MIN_TTL = "60000" # Min offer TTL: 1 minute
MAX_OFFERS_PER_REQUEST = "100" # Max offers per request MAX_OFFERS_PER_REQUEST = "100" # Max offers per request
MAX_TOPICS_PER_OFFER = "50" # Max topics per offer MAX_TOPICS_PER_OFFER = "50" # Max topics per offer
CORS_ORIGINS = "*" # Comma-separated list of allowed origins CORS_ORIGINS = "*" # Comma-separated list of allowed origins
VERSION = "0.1.0" # Semantic version VERSION = "0.4.0" # Semantic version
# AUTH_SECRET should be set as a secret, not a var # AUTH_SECRET should be set as a secret, not a var
# Run: npx wrangler secret put AUTH_SECRET # Run: npx wrangler secret put AUTH_SECRET
@@ -39,7 +39,7 @@ command = ""
[observability] [observability]
[observability.logs] [observability.logs]
enabled = false enabled = true
head_sampling_rate = 1 head_sampling_rate = 1
invocation_logs = true invocation_logs = true
persist = true persist = true