9 Commits

Author SHA1 Message Date
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
13 changed files with 553 additions and 303 deletions

View File

@@ -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,21 +120,7 @@ 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`
Check username availability and claim status
**Response:**
```json
{
"username": "alice",
"available": false,
"claimedAt": 1733404800000,
"expiresAt": 1765027200000,
"publicKey": "..."
}
```
#### `GET /usernames/:username/services`
List all services for a username (privacy-preserving) List all services for a username (privacy-preserving)
**Response:** **Response:**
@@ -143,9 +142,28 @@ List all services for a username (privacy-preserving)
} }
``` ```
### Service Management #### `GET /users/:username/services/:fqn`
Get specific service by username and FQN (single request)
#### `POST /services` **Response:**
```json
{
"uuid": "abc123",
"serviceId": "service-id",
"username": "alice",
"serviceFqn": "chat.app@1.0.0",
"offerId": "offer-hash",
"sdp": "v=0...",
"isPublic": true,
"metadata": {},
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
```
### Service Management (RESTful)
#### `POST /users/:username/services`
Publish a service (requires authentication and username signature) Publish a service (requires authentication and username signature)
**Headers:** **Headers:**
@@ -154,7 +172,6 @@ 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...", "sdp": "v=0...",
"ttl": 300000, "ttl": 300000,
@@ -165,12 +182,18 @@ 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",
"serviceId": "uuid-v4",
"username": "alice",
"serviceFqn": "com.example.chat@1.0.0",
"offerId": "offer-hash-id", "offerId": "offer-hash-id",
"sdp": "v=0...",
"isPublic": false,
"metadata": { "description": "Chat service" },
"createdAt": 1733404800000,
"expiresAt": 1733405100000 "expiresAt": 1733405100000
} }
``` ```
@@ -203,7 +226,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:**
@@ -275,8 +298,8 @@ Answer an offer (locks it to answerer)
} }
``` ```
#### `GET /offers/answers` #### `GET /offers/:offerId/answer`
Poll for answers to your offers Get answer for a specific offer
#### `POST /offers/:offerId/ice-candidates` #### `POST /offers/:offerId/ice-candidates`
Post ICE candidates for an offer Post ICE candidates for an offer

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@xtr-dev/rondevu-server", "name": "@xtr-dev/rondevu-server",
"version": "0.1.5", "version": "0.3.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.3.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",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/rondevu-server", "name": "@xtr-dev/rondevu-server",
"version": "0.2.4", "version": "0.3.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": {

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,38 +112,150 @@ 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);
}
// 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({ return c.json({
username, username: claimed.username,
services claimedAt: claimed.claimedAt,
}, 200); 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 ===== /**
* GET /users/:username/services/:fqn
* Get service by username and FQN with semver-compatible matching
*/
app.get('/users/:username/services/:fqn', async (c) => {
try {
const username = c.req.param('username');
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
// Parse the requested FQN
const parsed = parseServiceFqn(serviceFqn);
if (!parsed) {
return c.json({ error: 'Invalid service FQN format' }, 400);
}
const { serviceName, version: requestedVersion } = parsed;
// Find all services with matching service name
const matchingServices = await storage.findServicesByName(username, serviceName);
if (matchingServices.length === 0) {
return c.json({ error: 'Service not found' }, 404);
}
// Filter to compatible versions
const compatibleServices = matchingServices.filter(service => {
const serviceParsed = parseServiceFqn(service.serviceFqn);
if (!serviceParsed) return false;
return isVersionCompatible(requestedVersion, serviceParsed.version);
});
if (compatibleServices.length === 0) {
return c.json({
error: 'No compatible version found',
message: `Requested ${serviceFqn}, but no compatible versions available`
}, 404);
}
// Use the first compatible service (most recently created)
const service = compatibleServices[0];
// Get the UUID for this service
const uuid = await storage.queryService(username, service.serviceFqn);
if (!uuid) {
return c.json({ error: 'Service index not found' }, 500);
}
// Get all offers for this service
const serviceOffers = await storage.getOffersForService(service.id);
if (serviceOffers.length === 0) {
return c.json({ error: 'No offers found for this service' }, 404);
}
// Find an unanswered offer
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. Please try again later.'
}, 503);
}
return c.json({
uuid: uuid,
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offerId: availableOffer.id,
sdp: availableOffer.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);
}
});
/** /**
* POST /services * POST /users/:username/services
* Publish a service * Publish a service with one or more offers (RESTful endpoint)
*/ */
app.post('/services', authMiddleware, async (c) => { app.post('/users/:username/services', authMiddleware, async (c) => {
try { let serviceFqn: string | undefined;
const body = await c.req.json(); let createdOffers: any[] = [];
const { username, serviceFqn, sdp, ttl, isPublic, metadata, signature, message } = body;
if (!username || !serviceFqn || !sdp) { try {
return c.json({ error: 'Missing required parameters: username, serviceFqn, sdp' }, 400); const username = c.req.param('username');
const body = await c.req.json();
serviceFqn = body.serviceFqn;
const { offers, ttl, isPublic, metadata, signature, message } = body;
if (!serviceFqn || !offers || !Array.isArray(offers) || offers.length === 0) {
return c.json({ error: 'Missing required parameters: serviceFqn, offers (must be non-empty array)' }, 400);
} }
// Validate service FQN // Validate service FQN
@@ -212,14 +280,25 @@ 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 existingUuid = await storage.queryService(username, serviceFqn);
return c.json({ error: 'Invalid SDP' }, 400); if (existingUuid) {
const existingService = await storage.getServiceByUuid(existingUuid);
if (existingService) {
await storage.deleteService(existingService.id, username);
}
} }
if (sdp.length > 64 * 1024) { // Validate all offers
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); return c.json({ error: 'SDP too large (max 64KB)' }, 400);
} }
}
// Calculate expiry // Calculate expiry
const peerId = getAuthenticatedPeerId(c); const peerId = getAuthenticatedPeerId(c);
@@ -229,33 +308,40 @@ 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, username,
serviceFqn, serviceFqn,
offerId: offer.id,
expiresAt, expiresAt,
isPublic: isPublic || false, isPublic: isPublic || false,
metadata: metadata ? JSON.stringify(metadata) : undefined metadata: metadata ? JSON.stringify(metadata) : undefined,
offers: offerRequests
}); });
createdOffers = result.offers;
// Return full service details with all offers
return c.json({ return c.json({
serviceId: result.service.id,
uuid: result.indexUuid, uuid: result.indexUuid,
offerId: offer.id, serviceFqn: serviceFqn,
username: username,
serviceId: result.service.id,
offers: result.offers.map(o => ({
offerId: o.id,
sdp: o.sdp,
createdAt: o.createdAt,
expiresAt: o.expiresAt
})),
isPublic: result.service.isPublic,
metadata: metadata,
createdAt: result.service.createdAt,
expiresAt: result.service.expiresAt expiresAt: result.service.expiresAt
}, 201); }, 201);
} catch (err) { } catch (err) {
@@ -263,9 +349,9 @@ 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, username: c.req.param('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 +361,26 @@ export function createApp(storage: Storage, config: Config) {
}); });
/** /**
* GET /services/:uuid * DELETE /users/:username/services/:fqn
* Get service details by index UUID * Delete a service by username and FQN (RESTful)
*/ */
app.get('/services/:uuid', async (c) => { app.delete('/users/:username/services/:fqn', authMiddleware, async (c) => {
try { try {
const uuid = c.req.param('uuid'); const username = c.req.param('username');
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
// Find service by username and FQN
const uuid = await storage.queryService(username, serviceFqn);
if (!uuid) {
return c.json({ error: 'Service not found' }, 404);
}
const service = await storage.getServiceByUuid(uuid); const service = await storage.getServiceByUuid(uuid);
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,32 +393,53 @@ export function createApp(storage: Storage, config: Config) {
} }
}); });
// ===== Service Management (Legacy - for UUID-based access) =====
/** /**
* POST /index/:username/query * GET /services/:uuid
* Query service by FQN (returns UUID) * Get service details by index UUID (kept for privacy)
*/ */
app.post('/index/:username/query', async (c) => { app.get('/services/:uuid', async (c) => {
try { try {
const username = c.req.param('username'); const uuid = c.req.param('uuid');
const body = await c.req.json();
const { serviceFqn } = body;
if (!serviceFqn) { const service = await storage.getServiceByUuid(uuid);
return c.json({ error: 'Missing required parameter: serviceFqn' }, 400);
}
const uuid = await storage.queryService(username, serviceFqn); if (!service) {
if (!uuid) {
return c.json({ error: 'Service not found' }, 404); return c.json({ error: 'Service not found' }, 404);
} }
// Get all offers for this service
const serviceOffers = await storage.getOffersForService(service.id);
if (serviceOffers.length === 0) {
return c.json({ error: 'No offers found for this service' }, 404);
}
// Find an unanswered offer
const availableOffer = serviceOffers.find(offer => !offer.answererPeerId);
if (!availableOffer) {
return c.json({ return c.json({
uuid, error: 'No available offers',
allowed: true message: 'All offers from this service are currently in use. Please try again later.'
}, 503);
}
return c.json({
uuid: uuid,
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offerId: availableOffer.id,
sdp: availableOffer.sdp,
isPublic: service.isPublic,
metadata: service.metadata ? JSON.parse(service.metadata) : undefined,
createdAt: service.createdAt,
expiresAt: service.expiresAt
}, 200); }, 200);
} catch (err) { } catch (err) {
console.error('Error querying service:', err); console.error('Error getting service:', err);
return c.json({ error: 'Internal server error' }, 500); return c.json({ error: 'Internal server error' }, 500);
} }
}); });
@@ -459,6 +534,35 @@ export function createApp(storage: Storage, config: Config) {
} }
}); });
/**
* GET /offers/:offerId
* Get offer details (added for completeness)
*/
app.get('/offers/:offerId', authMiddleware, async (c) => {
try {
const offerId = c.req.param('offerId');
const offer = await storage.getOfferById(offerId);
if (!offer) {
return c.json({ error: 'Offer not found' }, 404);
}
return c.json({
id: offer.id,
peerId: offer.peerId,
sdp: offer.sdp,
createdAt: offer.createdAt,
expiresAt: offer.expiresAt,
answererPeerId: offer.answererPeerId,
answered: !!offer.answererPeerId,
answerSdp: offer.answerSdp
}, 200);
} catch (err) {
console.error('Error getting offer:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/** /**
* DELETE /offers/:offerId * DELETE /offers/:offerId
* Delete an offer * Delete an offer
@@ -519,24 +623,38 @@ export function createApp(storage: Storage, config: Config) {
}); });
/** /**
* GET /offers/answers * GET /offers/:offerId/answer
* Get answers for authenticated peer's offers * Get answer for a specific offer (RESTful endpoint)
*/ */
app.get('/offers/answers', authMiddleware, async (c) => { app.get('/offers/:offerId/answer', authMiddleware, async (c) => {
try { try {
const offerId = c.req.param('offerId');
const peerId = getAuthenticatedPeerId(c); const peerId = getAuthenticatedPeerId(c);
const offers = await storage.getAnsweredOffers(peerId);
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 view this answer' }, 403);
}
// Check if answered
if (!offer.answererPeerId || !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,
answererPeerId: offer.answererPeerId, answererId: offer.answererPeerId,
answerSdp: offer.answerSdp, sdp: offer.answerSdp,
answeredAt: offer.answeredAt answeredAt: offer.answeredAt
}))
}, 200); }, 200);
} catch (err) { } catch (err) {
console.error('Error getting answers:', err); console.error('Error getting answer:', err);
return c.json({ error: 'Internal server error' }, 500); return c.json({ error: 'Internal server error' }, 500);
} }
}); });

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

@@ -228,6 +228,60 @@ export function validateServiceFqn(fqn: string): { valid: boolean; error?: strin
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 service name and version
*/
export function parseServiceFqn(fqn: string): { serviceName: string; version: string } | null {
const parts = fqn.split('@');
if (parts.length !== 2) return null;
return {
serviceName: parts[0],
version: parts[1],
};
}
/** /**
* Validates timestamp is within acceptable range (prevents replay attacks) * Validates timestamp is within acceptable range (prevents replay attacks)
*/ */

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

@@ -34,7 +34,7 @@ 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,
@@ -125,7 +125,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(`
@@ -401,6 +401,7 @@ export class D1Storage implements Storage {
async createService(request: CreateServiceRequest): Promise<{ async createService(request: CreateServiceRequest): Promise<{
service: Service; service: Service;
indexUuid: string; indexUuid: string;
offers: Offer[];
}> { }> {
const serviceId = crypto.randomUUID(); const serviceId = crypto.randomUUID();
const indexUuid = crypto.randomUUID(); const indexUuid = crypto.randomUUID();
@@ -408,13 +409,12 @@ export class D1Storage implements Storage {
// Insert service // Insert service
await this.db.prepare(` await 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 (?, ?, ?, ?, ?, ?, ?)
`).bind( `).bind(
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,
@@ -434,6 +434,13 @@ export class D1Storage implements Storage {
request.expiresAt request.expiresAt
).run(); ).run();
// Create offers with serviceId
const offerRequests = request.offers.map(offer => ({
...offer,
serviceId,
}));
const offers = await this.createOffers(offerRequests);
// Touch username to extend expiry // Touch username to extend expiry
await this.touchUsername(request.username); await this.touchUsername(request.username);
@@ -442,16 +449,43 @@ export class D1Storage 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 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
@@ -510,6 +544,20 @@ export class D1Storage implements Storage {
return result ? (result as any).uuid : null; return result ? (result as any).uuid : null;
} }
async findServicesByName(username: string, serviceName: string): Promise<Service[]> {
const result = await this.db.prepare(`
SELECT * FROM services
WHERE username = ? AND service_fqn LIKE ? AND expires_at > ?
ORDER BY created_at DESC
`).bind(username, `${serviceName}@%`, Date.now()).all();
if (!result.results) {
return [];
}
return result.results.map(row => this.rowToService(row as any));
}
async deleteService(serviceId: string, username: string): Promise<boolean> { async deleteService(serviceId: string, username: string): Promise<boolean> {
const result = await this.db.prepare(` const result = await this.db.prepare(`
DELETE FROM services DELETE FROM services
@@ -560,7 +608,6 @@ export class D1Storage 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,

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,13 +63,12 @@ export interface ClaimUsernameRequest {
} }
/** /**
* Represents a published service * Represents a published service (can have multiple offers)
*/ */
export interface Service { export interface Service {
id: string; // UUID v4 id: string; // UUID v4
username: string; username: string;
serviceFqn: string; // com.example.chat@1.0.0 serviceFqn: string; // com.example.chat@1.0.0
offerId: string; // Links to offers table
createdAt: number; createdAt: number;
expiresAt: number; expiresAt: number;
isPublic: boolean; isPublic: boolean;
@@ -77,15 +76,22 @@ export interface Service {
} }
/** /**
* Request to create a service * Request to create a single service
*/ */
export interface CreateServiceRequest { export interface CreateServiceRequest {
username: string; username: string;
serviceFqn: string; serviceFqn: string;
offerId: string;
expiresAt: number; expiresAt: number;
isPublic?: boolean; isPublic?: boolean;
metadata?: string; metadata?: string;
offers: CreateOfferRequest[]; // Multiple offers per service
}
/**
* Request to create multiple services in batch
*/
export interface BatchCreateServicesRequest {
services: CreateServiceRequest[];
} }
/** /**
@@ -236,15 +242,34 @@ 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, index UUID, and created offers
*/ */
createService(request: CreateServiceRequest): Promise<{ createService(request: CreateServiceRequest): Promise<{
service: Service; service: Service;
indexUuid: string; indexUuid: string;
offers: Offer[];
}>; }>;
/**
* Creates multiple services with offers in batch
* @param requests Array of service creation requests
* @returns Array of created services with IDs, UUIDs, and offers
*/
batchCreateServices(requests: CreateServiceRequest[]): Promise<Array<{
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
@@ -274,6 +299,14 @@ export interface Storage {
*/ */
queryService(username: string, serviceFqn: string): Promise<string | null>; queryService(username: string, serviceFqn: string): Promise<string | null>;
/**
* Finds all services by username and service name (without version)
* @param username Username
* @param serviceName Service name (e.g., 'com.example.chat')
* @returns Array of services with matching service name
*/
findServicesByName(username: string, serviceName: string): Promise<Service[]>;
/** /**
* Deletes a service (with ownership verification) * Deletes a service (with ownership verification)
* @param serviceId Service ID * @param serviceId Service ID

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