@xtr-dev/rondevu-server 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ADVANCED.md +502 -0
- package/README.md +139 -251
- package/dist/index.js +715 -770
- package/dist/index.js.map +4 -4
- package/migrations/0006_service_offer_refactor.sql +40 -0
- package/migrations/0007_simplify_schema.sql +54 -0
- package/migrations/0008_peer_id_to_username.sql +67 -0
- package/migrations/fresh_schema.sql +81 -0
- package/package.json +2 -1
- package/src/app.ts +38 -677
- package/src/config.ts +0 -13
- package/src/crypto.ts +98 -133
- package/src/rpc.ts +725 -0
- package/src/storage/d1.ts +169 -182
- package/src/storage/sqlite.ts +142 -168
- package/src/storage/types.ts +51 -95
- package/src/worker.ts +0 -6
- package/wrangler.toml +3 -3
- package/src/middleware/auth.ts +0 -51
package/dist/index.js
CHANGED
|
@@ -477,98 +477,32 @@ var wNAF = (n) => {
|
|
|
477
477
|
hashes.sha512Async = async (message) => {
|
|
478
478
|
return new Uint8Array(await crypto.subtle.digest("SHA-512", message));
|
|
479
479
|
};
|
|
480
|
-
var ALGORITHM = "AES-GCM";
|
|
481
|
-
var IV_LENGTH = 12;
|
|
482
|
-
var KEY_LENGTH = 32;
|
|
483
480
|
var USERNAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
|
484
481
|
var USERNAME_MIN_LENGTH = 3;
|
|
485
482
|
var USERNAME_MAX_LENGTH = 32;
|
|
486
483
|
var TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1e3;
|
|
487
|
-
function generatePeerId() {
|
|
488
|
-
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
489
|
-
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
490
|
-
}
|
|
491
|
-
function generateSecretKey() {
|
|
492
|
-
const bytes = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
|
|
493
|
-
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
494
|
-
}
|
|
495
|
-
function hexToBytes2(hex) {
|
|
496
|
-
const bytes = new Uint8Array(hex.length / 2);
|
|
497
|
-
for (let i = 0; i < hex.length; i += 2) {
|
|
498
|
-
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
499
|
-
}
|
|
500
|
-
return bytes;
|
|
501
|
-
}
|
|
502
|
-
function bytesToBase64(bytes) {
|
|
503
|
-
const binString = Array.from(
|
|
504
|
-
bytes,
|
|
505
|
-
(byte) => String.fromCodePoint(byte)
|
|
506
|
-
).join("");
|
|
507
|
-
return btoa(binString);
|
|
508
|
-
}
|
|
509
484
|
function base64ToBytes(base64) {
|
|
510
485
|
const binString = atob(base64);
|
|
511
486
|
return Uint8Array.from(binString, (char) => char.codePointAt(0));
|
|
512
487
|
}
|
|
513
|
-
|
|
514
|
-
const
|
|
515
|
-
if (
|
|
516
|
-
|
|
517
|
-
}
|
|
518
|
-
const key = await crypto.subtle.importKey(
|
|
519
|
-
"raw",
|
|
520
|
-
keyBytes,
|
|
521
|
-
{ name: ALGORITHM, length: 256 },
|
|
522
|
-
false,
|
|
523
|
-
["encrypt"]
|
|
524
|
-
);
|
|
525
|
-
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
526
|
-
const encoder = new TextEncoder();
|
|
527
|
-
const data = encoder.encode(peerId);
|
|
528
|
-
const encrypted = await crypto.subtle.encrypt(
|
|
529
|
-
{ name: ALGORITHM, iv },
|
|
530
|
-
key,
|
|
531
|
-
data
|
|
532
|
-
);
|
|
533
|
-
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
|
534
|
-
combined.set(iv, 0);
|
|
535
|
-
combined.set(new Uint8Array(encrypted), iv.length);
|
|
536
|
-
return bytesToBase64(combined);
|
|
537
|
-
}
|
|
538
|
-
async function decryptPeerId(encryptedSecret, secretKeyHex) {
|
|
539
|
-
try {
|
|
540
|
-
const keyBytes = hexToBytes2(secretKeyHex);
|
|
541
|
-
if (keyBytes.length !== KEY_LENGTH) {
|
|
542
|
-
throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
|
|
543
|
-
}
|
|
544
|
-
const combined = base64ToBytes(encryptedSecret);
|
|
545
|
-
const iv = combined.slice(0, IV_LENGTH);
|
|
546
|
-
const ciphertext = combined.slice(IV_LENGTH);
|
|
547
|
-
const key = await crypto.subtle.importKey(
|
|
548
|
-
"raw",
|
|
549
|
-
keyBytes,
|
|
550
|
-
{ name: ALGORITHM, length: 256 },
|
|
551
|
-
false,
|
|
552
|
-
["decrypt"]
|
|
553
|
-
);
|
|
554
|
-
const decrypted = await crypto.subtle.decrypt(
|
|
555
|
-
{ name: ALGORITHM, iv },
|
|
556
|
-
key,
|
|
557
|
-
ciphertext
|
|
558
|
-
);
|
|
559
|
-
const decoder = new TextDecoder();
|
|
560
|
-
return decoder.decode(decrypted);
|
|
561
|
-
} catch (err2) {
|
|
562
|
-
throw new Error("Failed to decrypt peer ID: invalid secret or secret key");
|
|
488
|
+
function validateAuthMessage(expectedUsername, message) {
|
|
489
|
+
const parts = message.split(":");
|
|
490
|
+
if (parts.length < 3) {
|
|
491
|
+
return { valid: false, error: "Invalid message format: must have at least action:username:timestamp" };
|
|
563
492
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
return decryptedPeerId === peerId;
|
|
569
|
-
} catch {
|
|
570
|
-
return false;
|
|
493
|
+
const messageUsername = parts[1];
|
|
494
|
+
const timestamp = parseInt(parts[parts.length - 1], 10);
|
|
495
|
+
if (messageUsername !== expectedUsername) {
|
|
496
|
+
return { valid: false, error: "Username in message does not match authenticated username" };
|
|
571
497
|
}
|
|
498
|
+
if (isNaN(timestamp)) {
|
|
499
|
+
return { valid: false, error: "Invalid timestamp in message" };
|
|
500
|
+
}
|
|
501
|
+
const timestampCheck = validateTimestamp(timestamp);
|
|
502
|
+
if (!timestampCheck.valid) {
|
|
503
|
+
return timestampCheck;
|
|
504
|
+
}
|
|
505
|
+
return { valid: true };
|
|
572
506
|
}
|
|
573
507
|
function validateUsername(username) {
|
|
574
508
|
if (typeof username !== "string") {
|
|
@@ -589,24 +523,74 @@ function validateServiceFqn(fqn) {
|
|
|
589
523
|
if (typeof fqn !== "string") {
|
|
590
524
|
return { valid: false, error: "Service FQN must be a string" };
|
|
591
525
|
}
|
|
592
|
-
const
|
|
593
|
-
if (
|
|
594
|
-
return { valid: false, error: "Service FQN must be in format: service
|
|
526
|
+
const parsed = parseServiceFqn(fqn);
|
|
527
|
+
if (!parsed) {
|
|
528
|
+
return { valid: false, error: "Service FQN must be in format: service:version[@username]" };
|
|
595
529
|
}
|
|
596
|
-
const
|
|
597
|
-
const serviceNameRegex = /^[a-z0-9]([a-z0-9
|
|
530
|
+
const { serviceName, version, username } = parsed;
|
|
531
|
+
const serviceNameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
|
|
598
532
|
if (!serviceNameRegex.test(serviceName)) {
|
|
599
|
-
return { valid: false, error: "Service name must be
|
|
533
|
+
return { valid: false, error: "Service name must be lowercase alphanumeric with optional dots/dashes" };
|
|
600
534
|
}
|
|
601
|
-
if (serviceName.length <
|
|
602
|
-
return { valid: false, error: "Service name must be
|
|
535
|
+
if (serviceName.length < 1 || serviceName.length > 128) {
|
|
536
|
+
return { valid: false, error: "Service name must be 1-128 characters" };
|
|
603
537
|
}
|
|
604
538
|
const versionRegex = /^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$/;
|
|
605
539
|
if (!versionRegex.test(version)) {
|
|
606
540
|
return { valid: false, error: "Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)" };
|
|
607
541
|
}
|
|
542
|
+
if (username) {
|
|
543
|
+
const usernameCheck = validateUsername(username);
|
|
544
|
+
if (!usernameCheck.valid) {
|
|
545
|
+
return usernameCheck;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
608
548
|
return { valid: true };
|
|
609
549
|
}
|
|
550
|
+
function parseVersion(version) {
|
|
551
|
+
const match = version.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-z0-9.-]+)?$/);
|
|
552
|
+
if (!match) return null;
|
|
553
|
+
return {
|
|
554
|
+
major: parseInt(match[1], 10),
|
|
555
|
+
minor: parseInt(match[2], 10),
|
|
556
|
+
patch: parseInt(match[3], 10),
|
|
557
|
+
prerelease: match[4]?.substring(1)
|
|
558
|
+
// Remove leading dash
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function isVersionCompatible(requested, available) {
|
|
562
|
+
const req = parseVersion(requested);
|
|
563
|
+
const avail = parseVersion(available);
|
|
564
|
+
if (!req || !avail) return false;
|
|
565
|
+
if (req.major !== avail.major) return false;
|
|
566
|
+
if (req.major === 0 && req.minor !== avail.minor) return false;
|
|
567
|
+
if (avail.minor < req.minor) return false;
|
|
568
|
+
if (avail.minor === req.minor && avail.patch < req.patch) return false;
|
|
569
|
+
if (req.prerelease && req.prerelease !== avail.prerelease) return false;
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
function parseServiceFqn(fqn) {
|
|
573
|
+
if (!fqn || typeof fqn !== "string") return null;
|
|
574
|
+
const atIndex = fqn.lastIndexOf("@");
|
|
575
|
+
let serviceVersion;
|
|
576
|
+
let username = null;
|
|
577
|
+
if (atIndex > 0) {
|
|
578
|
+
serviceVersion = fqn.substring(0, atIndex);
|
|
579
|
+
username = fqn.substring(atIndex + 1);
|
|
580
|
+
} else {
|
|
581
|
+
serviceVersion = fqn;
|
|
582
|
+
}
|
|
583
|
+
const colonIndex = serviceVersion.indexOf(":");
|
|
584
|
+
if (colonIndex <= 0) return null;
|
|
585
|
+
const serviceName = serviceVersion.substring(0, colonIndex);
|
|
586
|
+
const version = serviceVersion.substring(colonIndex + 1);
|
|
587
|
+
if (!serviceName || !version) return null;
|
|
588
|
+
return {
|
|
589
|
+
serviceName,
|
|
590
|
+
version,
|
|
591
|
+
username
|
|
592
|
+
};
|
|
593
|
+
}
|
|
610
594
|
function validateTimestamp(timestamp) {
|
|
611
595
|
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
|
|
612
596
|
return { valid: false, error: "Timestamp must be a finite number" };
|
|
@@ -631,605 +615,582 @@ async function verifyEd25519Signature(publicKey, signature, message) {
|
|
|
631
615
|
return false;
|
|
632
616
|
}
|
|
633
617
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
618
|
+
|
|
619
|
+
// src/rpc.ts
|
|
620
|
+
var MAX_PAGE_SIZE = 100;
|
|
621
|
+
async function verifyAuth(username, message, signature, publicKey, storage) {
|
|
622
|
+
let usernameRecord = await storage.getUsername(username);
|
|
623
|
+
if (!usernameRecord) {
|
|
624
|
+
if (!publicKey) {
|
|
625
|
+
return {
|
|
626
|
+
valid: false,
|
|
627
|
+
error: `Username "${username}" is not claimed and no public key provided for auto-claim.`
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
const usernameValidation = validateUsername(username);
|
|
631
|
+
if (!usernameValidation.valid) {
|
|
632
|
+
return usernameValidation;
|
|
633
|
+
}
|
|
634
|
+
const signatureValid = await verifyEd25519Signature(publicKey, signature, message);
|
|
635
|
+
if (!signatureValid) {
|
|
636
|
+
return { valid: false, error: "Invalid signature for auto-claim" };
|
|
637
|
+
}
|
|
638
|
+
const expiresAt = Date.now() + 365 * 24 * 60 * 60 * 1e3;
|
|
639
|
+
await storage.claimUsername({
|
|
640
|
+
username,
|
|
641
|
+
publicKey,
|
|
642
|
+
expiresAt
|
|
643
|
+
});
|
|
644
|
+
usernameRecord = await storage.getUsername(username);
|
|
645
|
+
if (!usernameRecord) {
|
|
646
|
+
return { valid: false, error: "Failed to claim username" };
|
|
647
|
+
}
|
|
650
648
|
}
|
|
651
|
-
const
|
|
652
|
-
|
|
649
|
+
const isValid = await verifyEd25519Signature(
|
|
650
|
+
usernameRecord.publicKey,
|
|
651
|
+
signature,
|
|
652
|
+
message
|
|
653
|
+
);
|
|
654
|
+
if (!isValid) {
|
|
653
655
|
return { valid: false, error: "Invalid signature" };
|
|
654
656
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
const usernameCheck = validateUsername(username);
|
|
659
|
-
if (!usernameCheck.valid) {
|
|
660
|
-
return usernameCheck;
|
|
661
|
-
}
|
|
662
|
-
const parts = message.split(":");
|
|
663
|
-
if (parts.length !== 4 || parts[0] !== "publish" || parts[1] !== username || parts[2] !== serviceFqn) {
|
|
664
|
-
return { valid: false, error: "Invalid message format (expected: publish:{username}:{serviceFqn}:{timestamp})" };
|
|
665
|
-
}
|
|
666
|
-
const timestamp = parseInt(parts[3], 10);
|
|
667
|
-
if (isNaN(timestamp)) {
|
|
668
|
-
return { valid: false, error: "Invalid timestamp in message" };
|
|
669
|
-
}
|
|
670
|
-
const timestampCheck = validateTimestamp(timestamp);
|
|
671
|
-
if (!timestampCheck.valid) {
|
|
672
|
-
return timestampCheck;
|
|
673
|
-
}
|
|
674
|
-
const signatureValid = await verifyEd25519Signature(publicKey, signature, message);
|
|
675
|
-
if (!signatureValid) {
|
|
676
|
-
return { valid: false, error: "Invalid signature" };
|
|
657
|
+
const validation = validateAuthMessage(username, message);
|
|
658
|
+
if (!validation.valid) {
|
|
659
|
+
return { valid: false, error: validation.error };
|
|
677
660
|
}
|
|
678
661
|
return { valid: true };
|
|
679
662
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
return
|
|
684
|
-
const authHeader = c.req.header("Authorization");
|
|
685
|
-
if (!authHeader) {
|
|
686
|
-
return c.json({ error: "Missing Authorization header" }, 401);
|
|
687
|
-
}
|
|
688
|
-
const parts = authHeader.split(" ");
|
|
689
|
-
if (parts.length !== 2 || parts[0] !== "Bearer") {
|
|
690
|
-
return c.json({ error: "Invalid Authorization header format. Expected: Bearer {peerId}:{secret}" }, 401);
|
|
691
|
-
}
|
|
692
|
-
const credentials = parts[1].split(":");
|
|
693
|
-
if (credentials.length !== 2) {
|
|
694
|
-
return c.json({ error: "Invalid credentials format. Expected: {peerId}:{secret}" }, 401);
|
|
695
|
-
}
|
|
696
|
-
const [peerId, encryptedSecret] = credentials;
|
|
697
|
-
const isValid = await validateCredentials(peerId, encryptedSecret, authSecret);
|
|
698
|
-
if (!isValid) {
|
|
699
|
-
return c.json({ error: "Invalid credentials" }, 401);
|
|
700
|
-
}
|
|
701
|
-
c.set("peerId", peerId);
|
|
702
|
-
await next();
|
|
703
|
-
};
|
|
704
|
-
}
|
|
705
|
-
function getAuthenticatedPeerId(c) {
|
|
706
|
-
const peerId = c.get("peerId");
|
|
707
|
-
if (!peerId) {
|
|
708
|
-
throw new Error("No authenticated peer ID in context");
|
|
709
|
-
}
|
|
710
|
-
return peerId;
|
|
663
|
+
function extractUsername(message) {
|
|
664
|
+
const parts = message.split(":");
|
|
665
|
+
if (parts.length < 2) return null;
|
|
666
|
+
return parts[1];
|
|
711
667
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
}
|
|
725
|
-
return config.corsOrigins[0];
|
|
726
|
-
},
|
|
727
|
-
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
728
|
-
allowHeaders: ["Content-Type", "Origin", "Authorization"],
|
|
729
|
-
exposeHeaders: ["Content-Type"],
|
|
730
|
-
maxAge: 600,
|
|
731
|
-
credentials: true
|
|
732
|
-
}));
|
|
733
|
-
app.get("/", (c) => {
|
|
734
|
-
return c.json({
|
|
735
|
-
version: config.version,
|
|
736
|
-
name: "Rondevu",
|
|
737
|
-
description: "DNS-like WebRTC signaling with username claiming and service discovery"
|
|
738
|
-
});
|
|
739
|
-
});
|
|
740
|
-
app.get("/health", (c) => {
|
|
741
|
-
return c.json({
|
|
742
|
-
status: "ok",
|
|
743
|
-
timestamp: Date.now(),
|
|
744
|
-
version: config.version
|
|
745
|
-
});
|
|
746
|
-
});
|
|
747
|
-
app.post("/register", async (c) => {
|
|
748
|
-
try {
|
|
749
|
-
const peerId = generatePeerId();
|
|
750
|
-
const secret = await encryptPeerId(peerId, config.authSecret);
|
|
751
|
-
return c.json({
|
|
752
|
-
peerId,
|
|
753
|
-
secret
|
|
754
|
-
}, 200);
|
|
755
|
-
} catch (err2) {
|
|
756
|
-
console.error("Error registering peer:", err2);
|
|
757
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
668
|
+
var handlers = {
|
|
669
|
+
/**
|
|
670
|
+
* Check if username is available
|
|
671
|
+
*/
|
|
672
|
+
async getUser(params, message, signature, publicKey, storage, config) {
|
|
673
|
+
const { username } = params;
|
|
674
|
+
const claimed = await storage.getUsername(username);
|
|
675
|
+
if (!claimed) {
|
|
676
|
+
return {
|
|
677
|
+
username,
|
|
678
|
+
available: true
|
|
679
|
+
};
|
|
758
680
|
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
681
|
+
return {
|
|
682
|
+
username: claimed.username,
|
|
683
|
+
available: false,
|
|
684
|
+
claimedAt: claimed.claimedAt,
|
|
685
|
+
expiresAt: claimed.expiresAt,
|
|
686
|
+
publicKey: claimed.publicKey
|
|
687
|
+
};
|
|
688
|
+
},
|
|
689
|
+
/**
|
|
690
|
+
* Get service by FQN - Supports 3 modes:
|
|
691
|
+
* 1. Direct lookup: FQN includes @username
|
|
692
|
+
* 2. Paginated discovery: FQN without @username, with limit/offset
|
|
693
|
+
* 3. Random discovery: FQN without @username, no limit
|
|
694
|
+
*/
|
|
695
|
+
async getService(params, message, signature, publicKey, storage, config) {
|
|
696
|
+
const { serviceFqn, limit, offset } = params;
|
|
697
|
+
const username = extractUsername(message);
|
|
698
|
+
if (username) {
|
|
699
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
700
|
+
if (!auth.valid) {
|
|
701
|
+
throw new Error(auth.error);
|
|
769
702
|
}
|
|
770
|
-
return c.json({
|
|
771
|
-
username: claimed.username,
|
|
772
|
-
available: false,
|
|
773
|
-
claimedAt: claimed.claimedAt,
|
|
774
|
-
expiresAt: claimed.expiresAt,
|
|
775
|
-
publicKey: claimed.publicKey
|
|
776
|
-
}, 200);
|
|
777
|
-
} catch (err2) {
|
|
778
|
-
console.error("Error checking username:", err2);
|
|
779
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
780
703
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
const username = c.req.param("username");
|
|
785
|
-
const body = await c.req.json();
|
|
786
|
-
const { publicKey, signature, message } = body;
|
|
787
|
-
if (!publicKey || !signature || !message) {
|
|
788
|
-
return c.json({ error: "Missing required parameters: publicKey, signature, message" }, 400);
|
|
789
|
-
}
|
|
790
|
-
const validation = await validateUsernameClaim(username, publicKey, signature, message);
|
|
791
|
-
if (!validation.valid) {
|
|
792
|
-
return c.json({ error: validation.error }, 400);
|
|
793
|
-
}
|
|
794
|
-
try {
|
|
795
|
-
const claimed = await storage.claimUsername({
|
|
796
|
-
username,
|
|
797
|
-
publicKey,
|
|
798
|
-
signature,
|
|
799
|
-
message
|
|
800
|
-
});
|
|
801
|
-
return c.json({
|
|
802
|
-
username: claimed.username,
|
|
803
|
-
claimedAt: claimed.claimedAt,
|
|
804
|
-
expiresAt: claimed.expiresAt
|
|
805
|
-
}, 201);
|
|
806
|
-
} catch (err2) {
|
|
807
|
-
if (err2.message?.includes("already claimed")) {
|
|
808
|
-
return c.json({ error: "Username already claimed by different public key" }, 409);
|
|
809
|
-
}
|
|
810
|
-
throw err2;
|
|
811
|
-
}
|
|
812
|
-
} catch (err2) {
|
|
813
|
-
console.error("Error claiming username:", err2);
|
|
814
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
704
|
+
const fqnValidation = validateServiceFqn(serviceFqn);
|
|
705
|
+
if (!fqnValidation.valid) {
|
|
706
|
+
throw new Error(fqnValidation.error || "Invalid service FQN");
|
|
815
707
|
}
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
const username = c.req.param("username");
|
|
820
|
-
const services = await storage.listServicesForUsername(username);
|
|
821
|
-
return c.json({
|
|
822
|
-
username,
|
|
823
|
-
services
|
|
824
|
-
}, 200);
|
|
825
|
-
} catch (err2) {
|
|
826
|
-
console.error("Error listing services:", err2);
|
|
827
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
708
|
+
const parsed = parseServiceFqn(serviceFqn);
|
|
709
|
+
if (!parsed) {
|
|
710
|
+
throw new Error("Failed to parse service FQN");
|
|
828
711
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
712
|
+
const filterCompatibleServices = (services) => {
|
|
713
|
+
return services.filter((s) => {
|
|
714
|
+
const serviceVersion = parseServiceFqn(s.serviceFqn);
|
|
715
|
+
return serviceVersion && isVersionCompatible(parsed.version, serviceVersion.version);
|
|
716
|
+
});
|
|
717
|
+
};
|
|
718
|
+
const findAvailableOffer = async (service) => {
|
|
719
|
+
const offers = await storage.getOffersForService(service.id);
|
|
720
|
+
return offers.find((o) => !o.answererUsername);
|
|
721
|
+
};
|
|
722
|
+
const buildServiceResponse = (service, offer) => ({
|
|
723
|
+
serviceId: service.id,
|
|
724
|
+
username: service.username,
|
|
725
|
+
serviceFqn: service.serviceFqn,
|
|
726
|
+
offerId: offer.id,
|
|
727
|
+
sdp: offer.sdp,
|
|
728
|
+
createdAt: service.createdAt,
|
|
729
|
+
expiresAt: service.expiresAt
|
|
730
|
+
});
|
|
731
|
+
if (limit !== void 0) {
|
|
732
|
+
const pageLimit = Math.min(Math.max(1, limit), MAX_PAGE_SIZE);
|
|
733
|
+
const pageOffset = Math.max(0, offset || 0);
|
|
734
|
+
const allServices2 = await storage.getServicesByName(parsed.service, parsed.version);
|
|
735
|
+
const compatibleServices2 = filterCompatibleServices(allServices2);
|
|
736
|
+
const usernameSet = /* @__PURE__ */ new Set();
|
|
737
|
+
const uniqueServices = [];
|
|
738
|
+
for (const service of compatibleServices2) {
|
|
739
|
+
if (!usernameSet.has(service.username)) {
|
|
740
|
+
usernameSet.add(service.username);
|
|
741
|
+
const availableOffer2 = await findAvailableOffer(service);
|
|
742
|
+
if (availableOffer2) {
|
|
743
|
+
uniqueServices.push(buildServiceResponse(service, availableOffer2));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
837
746
|
}
|
|
838
|
-
const
|
|
747
|
+
const paginatedServices = uniqueServices.slice(pageOffset, pageOffset + pageLimit);
|
|
748
|
+
return {
|
|
749
|
+
services: paginatedServices,
|
|
750
|
+
count: paginatedServices.length,
|
|
751
|
+
limit: pageLimit,
|
|
752
|
+
offset: pageOffset
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
if (parsed.username) {
|
|
756
|
+
const service = await storage.getServiceByFqn(serviceFqn);
|
|
839
757
|
if (!service) {
|
|
840
|
-
|
|
758
|
+
throw new Error("Service not found");
|
|
841
759
|
}
|
|
842
|
-
const
|
|
843
|
-
if (!
|
|
844
|
-
|
|
760
|
+
const availableOffer2 = await findAvailableOffer(service);
|
|
761
|
+
if (!availableOffer2) {
|
|
762
|
+
throw new Error("Service has no available offers");
|
|
845
763
|
}
|
|
846
|
-
|
|
847
|
-
const availableOffer = peerOffers.find((offer) => !offer.answererPeerId);
|
|
848
|
-
if (!availableOffer) {
|
|
849
|
-
return c.json({
|
|
850
|
-
error: "No available offers",
|
|
851
|
-
message: "All offers from this service are currently in use. Please try again later."
|
|
852
|
-
}, 503);
|
|
853
|
-
}
|
|
854
|
-
return c.json({
|
|
855
|
-
uuid,
|
|
856
|
-
serviceId: service.id,
|
|
857
|
-
username: service.username,
|
|
858
|
-
serviceFqn: service.serviceFqn,
|
|
859
|
-
offerId: availableOffer.id,
|
|
860
|
-
sdp: availableOffer.sdp,
|
|
861
|
-
isPublic: service.isPublic,
|
|
862
|
-
metadata: service.metadata ? JSON.parse(service.metadata) : void 0,
|
|
863
|
-
createdAt: service.createdAt,
|
|
864
|
-
expiresAt: service.expiresAt
|
|
865
|
-
}, 200);
|
|
866
|
-
} catch (err2) {
|
|
867
|
-
console.error("Error getting service:", err2);
|
|
868
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
764
|
+
return buildServiceResponse(service, availableOffer2);
|
|
869
765
|
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
766
|
+
const allServices = await storage.getServicesByName(parsed.service, parsed.version);
|
|
767
|
+
const compatibleServices = filterCompatibleServices(allServices);
|
|
768
|
+
if (compatibleServices.length === 0) {
|
|
769
|
+
throw new Error("No services found");
|
|
770
|
+
}
|
|
771
|
+
const randomService = compatibleServices[Math.floor(Math.random() * compatibleServices.length)];
|
|
772
|
+
const availableOffer = await findAvailableOffer(randomService);
|
|
773
|
+
if (!availableOffer) {
|
|
774
|
+
throw new Error("Service has no available offers");
|
|
775
|
+
}
|
|
776
|
+
return buildServiceResponse(randomService, availableOffer);
|
|
777
|
+
},
|
|
778
|
+
/**
|
|
779
|
+
* Publish a service
|
|
780
|
+
*/
|
|
781
|
+
async publishService(params, message, signature, publicKey, storage, config) {
|
|
782
|
+
const { serviceFqn, offers, ttl } = params;
|
|
783
|
+
const username = extractUsername(message);
|
|
784
|
+
if (!username) {
|
|
785
|
+
throw new Error("Username required for service publishing");
|
|
786
|
+
}
|
|
787
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
788
|
+
if (!auth.valid) {
|
|
789
|
+
throw new Error(auth.error);
|
|
790
|
+
}
|
|
791
|
+
const fqnValidation = validateServiceFqn(serviceFqn);
|
|
792
|
+
if (!fqnValidation.valid) {
|
|
793
|
+
throw new Error(fqnValidation.error || "Invalid service FQN");
|
|
794
|
+
}
|
|
795
|
+
const parsed = parseServiceFqn(serviceFqn);
|
|
796
|
+
if (!parsed || !parsed.username) {
|
|
797
|
+
throw new Error("Service FQN must include username");
|
|
798
|
+
}
|
|
799
|
+
if (parsed.username !== username) {
|
|
800
|
+
throw new Error("Service FQN username must match authenticated username");
|
|
801
|
+
}
|
|
802
|
+
if (!offers || !Array.isArray(offers) || offers.length === 0) {
|
|
803
|
+
throw new Error("Must provide at least one offer");
|
|
804
|
+
}
|
|
805
|
+
if (offers.length > config.maxOffersPerRequest) {
|
|
806
|
+
throw new Error(
|
|
807
|
+
`Too many offers (max ${config.maxOffersPerRequest})`
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
offers.forEach((offer, index) => {
|
|
811
|
+
if (!offer || typeof offer !== "object") {
|
|
812
|
+
throw new Error(`Invalid offer at index ${index}: must be an object`);
|
|
906
813
|
}
|
|
907
|
-
if (sdp
|
|
908
|
-
|
|
814
|
+
if (!offer.sdp || typeof offer.sdp !== "string") {
|
|
815
|
+
throw new Error(`Invalid offer at index ${index}: missing or invalid SDP`);
|
|
909
816
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
|
|
913
|
-
config.offerMaxTtl
|
|
914
|
-
);
|
|
915
|
-
const expiresAt = Date.now() + offerTtl;
|
|
916
|
-
offers = await storage.createOffers([{
|
|
917
|
-
peerId,
|
|
918
|
-
sdp,
|
|
919
|
-
expiresAt
|
|
920
|
-
}]);
|
|
921
|
-
if (offers.length === 0) {
|
|
922
|
-
return c.json({ error: "Failed to create offer" }, 500);
|
|
817
|
+
if (!offer.sdp.trim()) {
|
|
818
|
+
throw new Error(`Invalid offer at index ${index}: SDP cannot be empty`);
|
|
923
819
|
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
820
|
+
});
|
|
821
|
+
const now = Date.now();
|
|
822
|
+
const offerTtl = ttl !== void 0 ? Math.min(
|
|
823
|
+
Math.max(ttl, config.offerMinTtl),
|
|
824
|
+
config.offerMaxTtl
|
|
825
|
+
) : config.offerDefaultTtl;
|
|
826
|
+
const expiresAt = now + offerTtl;
|
|
827
|
+
const offerRequests = offers.map((offer) => ({
|
|
828
|
+
username,
|
|
829
|
+
serviceFqn,
|
|
830
|
+
sdp: offer.sdp,
|
|
831
|
+
expiresAt
|
|
832
|
+
}));
|
|
833
|
+
const result = await storage.createService({
|
|
834
|
+
serviceFqn,
|
|
835
|
+
expiresAt,
|
|
836
|
+
offers: offerRequests
|
|
837
|
+
});
|
|
838
|
+
return {
|
|
839
|
+
serviceId: result.service.id,
|
|
840
|
+
username: result.service.username,
|
|
841
|
+
serviceFqn: result.service.serviceFqn,
|
|
842
|
+
offers: result.offers.map((offer) => ({
|
|
938
843
|
offerId: offer.id,
|
|
939
844
|
sdp: offer.sdp,
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
error: "Internal server error",
|
|
956
|
-
details: err2.message
|
|
957
|
-
}, 500);
|
|
845
|
+
createdAt: offer.createdAt,
|
|
846
|
+
expiresAt: offer.expiresAt
|
|
847
|
+
})),
|
|
848
|
+
createdAt: result.service.createdAt,
|
|
849
|
+
expiresAt: result.service.expiresAt
|
|
850
|
+
};
|
|
851
|
+
},
|
|
852
|
+
/**
|
|
853
|
+
* Delete a service
|
|
854
|
+
*/
|
|
855
|
+
async deleteService(params, message, signature, publicKey, storage, config) {
|
|
856
|
+
const { serviceFqn } = params;
|
|
857
|
+
const username = extractUsername(message);
|
|
858
|
+
if (!username) {
|
|
859
|
+
throw new Error("Username required");
|
|
958
860
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
const username = c.req.param("username");
|
|
963
|
-
const serviceFqn = decodeURIComponent(c.req.param("fqn"));
|
|
964
|
-
const uuid = await storage.queryService(username, serviceFqn);
|
|
965
|
-
if (!uuid) {
|
|
966
|
-
return c.json({ error: "Service not found" }, 404);
|
|
967
|
-
}
|
|
968
|
-
const service = await storage.getServiceByUuid(uuid);
|
|
969
|
-
if (!service) {
|
|
970
|
-
return c.json({ error: "Service not found" }, 404);
|
|
971
|
-
}
|
|
972
|
-
const deleted = await storage.deleteService(service.id, username);
|
|
973
|
-
if (!deleted) {
|
|
974
|
-
return c.json({ error: "Service not found or not owned by this username" }, 404);
|
|
975
|
-
}
|
|
976
|
-
return c.json({ success: true }, 200);
|
|
977
|
-
} catch (err2) {
|
|
978
|
-
console.error("Error deleting service:", err2);
|
|
979
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
861
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
862
|
+
if (!auth.valid) {
|
|
863
|
+
throw new Error(auth.error);
|
|
980
864
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
const uuid = c.req.param("uuid");
|
|
985
|
-
const service = await storage.getServiceByUuid(uuid);
|
|
986
|
-
if (!service) {
|
|
987
|
-
return c.json({ error: "Service not found" }, 404);
|
|
988
|
-
}
|
|
989
|
-
const initialOffer = await storage.getOfferById(service.offerId);
|
|
990
|
-
if (!initialOffer) {
|
|
991
|
-
return c.json({ error: "Associated offer not found" }, 404);
|
|
992
|
-
}
|
|
993
|
-
const peerOffers = await storage.getOffersByPeerId(initialOffer.peerId);
|
|
994
|
-
const availableOffer = peerOffers.find((offer) => !offer.answererPeerId);
|
|
995
|
-
if (!availableOffer) {
|
|
996
|
-
return c.json({
|
|
997
|
-
error: "No available offers",
|
|
998
|
-
message: "All offers from this service are currently in use. Please try again later."
|
|
999
|
-
}, 503);
|
|
1000
|
-
}
|
|
1001
|
-
return c.json({
|
|
1002
|
-
uuid,
|
|
1003
|
-
serviceId: service.id,
|
|
1004
|
-
username: service.username,
|
|
1005
|
-
serviceFqn: service.serviceFqn,
|
|
1006
|
-
offerId: availableOffer.id,
|
|
1007
|
-
sdp: availableOffer.sdp,
|
|
1008
|
-
isPublic: service.isPublic,
|
|
1009
|
-
metadata: service.metadata ? JSON.parse(service.metadata) : void 0,
|
|
1010
|
-
createdAt: service.createdAt,
|
|
1011
|
-
expiresAt: service.expiresAt
|
|
1012
|
-
}, 200);
|
|
1013
|
-
} catch (err2) {
|
|
1014
|
-
console.error("Error getting service:", err2);
|
|
1015
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
865
|
+
const parsed = parseServiceFqn(serviceFqn);
|
|
866
|
+
if (!parsed || !parsed.username) {
|
|
867
|
+
throw new Error("Service FQN must include username");
|
|
1016
868
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
const body = await c.req.json();
|
|
1021
|
-
const { offers } = body;
|
|
1022
|
-
if (!Array.isArray(offers) || offers.length === 0) {
|
|
1023
|
-
return c.json({ error: "Missing or invalid required parameter: offers (must be non-empty array)" }, 400);
|
|
1024
|
-
}
|
|
1025
|
-
if (offers.length > config.maxOffersPerRequest) {
|
|
1026
|
-
return c.json({ error: `Too many offers (max ${config.maxOffersPerRequest})` }, 400);
|
|
1027
|
-
}
|
|
1028
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
1029
|
-
const validated = offers.map((offer) => {
|
|
1030
|
-
const { sdp, ttl, secret } = offer;
|
|
1031
|
-
if (typeof sdp !== "string" || sdp.length === 0) {
|
|
1032
|
-
throw new Error("Invalid SDP in offer");
|
|
1033
|
-
}
|
|
1034
|
-
if (sdp.length > 64 * 1024) {
|
|
1035
|
-
throw new Error("SDP too large (max 64KB)");
|
|
1036
|
-
}
|
|
1037
|
-
const offerTtl = Math.min(
|
|
1038
|
-
Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
|
|
1039
|
-
config.offerMaxTtl
|
|
1040
|
-
);
|
|
1041
|
-
return {
|
|
1042
|
-
peerId,
|
|
1043
|
-
sdp,
|
|
1044
|
-
expiresAt: Date.now() + offerTtl,
|
|
1045
|
-
secret: secret ? String(secret).substring(0, 128) : void 0
|
|
1046
|
-
};
|
|
1047
|
-
});
|
|
1048
|
-
const created = await storage.createOffers(validated);
|
|
1049
|
-
return c.json({
|
|
1050
|
-
offers: created.map((offer) => ({
|
|
1051
|
-
id: offer.id,
|
|
1052
|
-
peerId: offer.peerId,
|
|
1053
|
-
expiresAt: offer.expiresAt,
|
|
1054
|
-
createdAt: offer.createdAt,
|
|
1055
|
-
hasSecret: !!offer.secret
|
|
1056
|
-
}))
|
|
1057
|
-
}, 201);
|
|
1058
|
-
} catch (err2) {
|
|
1059
|
-
console.error("Error creating offers:", err2);
|
|
1060
|
-
return c.json({ error: err2.message || "Internal server error" }, 500);
|
|
869
|
+
const service = await storage.getServiceByFqn(serviceFqn);
|
|
870
|
+
if (!service) {
|
|
871
|
+
throw new Error("Service not found");
|
|
1061
872
|
}
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
1066
|
-
const offers = await storage.getOffersByPeerId(peerId);
|
|
1067
|
-
return c.json({
|
|
1068
|
-
offers: offers.map((offer) => ({
|
|
1069
|
-
id: offer.id,
|
|
1070
|
-
sdp: offer.sdp,
|
|
1071
|
-
createdAt: offer.createdAt,
|
|
1072
|
-
expiresAt: offer.expiresAt,
|
|
1073
|
-
lastSeen: offer.lastSeen,
|
|
1074
|
-
hasSecret: !!offer.secret,
|
|
1075
|
-
answererPeerId: offer.answererPeerId,
|
|
1076
|
-
answered: !!offer.answererPeerId
|
|
1077
|
-
}))
|
|
1078
|
-
}, 200);
|
|
1079
|
-
} catch (err2) {
|
|
1080
|
-
console.error("Error getting offers:", err2);
|
|
1081
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
873
|
+
const deleted = await storage.deleteService(service.id, username);
|
|
874
|
+
if (!deleted) {
|
|
875
|
+
throw new Error("Service not found or not owned by this username");
|
|
1082
876
|
}
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
877
|
+
return { success: true };
|
|
878
|
+
},
|
|
879
|
+
/**
|
|
880
|
+
* Answer an offer
|
|
881
|
+
*/
|
|
882
|
+
async answerOffer(params, message, signature, publicKey, storage, config) {
|
|
883
|
+
const { serviceFqn, offerId, sdp } = params;
|
|
884
|
+
const username = extractUsername(message);
|
|
885
|
+
if (!username) {
|
|
886
|
+
throw new Error("Username required");
|
|
887
|
+
}
|
|
888
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
889
|
+
if (!auth.valid) {
|
|
890
|
+
throw new Error(auth.error);
|
|
891
|
+
}
|
|
892
|
+
if (!sdp || typeof sdp !== "string" || sdp.length === 0) {
|
|
893
|
+
throw new Error("Invalid SDP");
|
|
894
|
+
}
|
|
895
|
+
if (sdp.length > 64 * 1024) {
|
|
896
|
+
throw new Error("SDP too large (max 64KB)");
|
|
897
|
+
}
|
|
898
|
+
const offer = await storage.getOfferById(offerId);
|
|
899
|
+
if (!offer) {
|
|
900
|
+
throw new Error("Offer not found");
|
|
901
|
+
}
|
|
902
|
+
if (offer.answererUsername) {
|
|
903
|
+
throw new Error("Offer already answered");
|
|
904
|
+
}
|
|
905
|
+
await storage.answerOffer(offerId, username, sdp);
|
|
906
|
+
return { success: true, offerId };
|
|
907
|
+
},
|
|
908
|
+
/**
|
|
909
|
+
* Get answer for an offer
|
|
910
|
+
*/
|
|
911
|
+
async getOfferAnswer(params, message, signature, publicKey, storage, config) {
|
|
912
|
+
const { serviceFqn, offerId } = params;
|
|
913
|
+
const username = extractUsername(message);
|
|
914
|
+
if (!username) {
|
|
915
|
+
throw new Error("Username required");
|
|
916
|
+
}
|
|
917
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
918
|
+
if (!auth.valid) {
|
|
919
|
+
throw new Error(auth.error);
|
|
920
|
+
}
|
|
921
|
+
const offer = await storage.getOfferById(offerId);
|
|
922
|
+
if (!offer) {
|
|
923
|
+
throw new Error("Offer not found");
|
|
924
|
+
}
|
|
925
|
+
if (offer.username !== username) {
|
|
926
|
+
throw new Error("Not authorized to access this offer");
|
|
927
|
+
}
|
|
928
|
+
if (!offer.answererUsername || !offer.answerSdp) {
|
|
929
|
+
throw new Error("Offer not yet answered");
|
|
930
|
+
}
|
|
931
|
+
return {
|
|
932
|
+
sdp: offer.answerSdp,
|
|
933
|
+
offerId: offer.id,
|
|
934
|
+
answererId: offer.answererUsername,
|
|
935
|
+
answeredAt: offer.answeredAt
|
|
936
|
+
};
|
|
937
|
+
},
|
|
938
|
+
/**
|
|
939
|
+
* Combined polling for answers and ICE candidates
|
|
940
|
+
*/
|
|
941
|
+
async poll(params, message, signature, publicKey, storage, config) {
|
|
942
|
+
const { since } = params;
|
|
943
|
+
const username = extractUsername(message);
|
|
944
|
+
if (!username) {
|
|
945
|
+
throw new Error("Username required");
|
|
946
|
+
}
|
|
947
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
948
|
+
if (!auth.valid) {
|
|
949
|
+
throw new Error(auth.error);
|
|
950
|
+
}
|
|
951
|
+
const sinceTimestamp = since || 0;
|
|
952
|
+
const answeredOffers = await storage.getAnsweredOffers(username);
|
|
953
|
+
const filteredAnswers = answeredOffers.filter(
|
|
954
|
+
(offer) => offer.answeredAt && offer.answeredAt > sinceTimestamp
|
|
955
|
+
);
|
|
956
|
+
const allOffers = await storage.getOffersByUsername(username);
|
|
957
|
+
const iceCandidatesByOffer = {};
|
|
958
|
+
for (const offer of allOffers) {
|
|
959
|
+
const offererCandidates = await storage.getIceCandidates(
|
|
960
|
+
offer.id,
|
|
961
|
+
"offerer",
|
|
962
|
+
sinceTimestamp
|
|
963
|
+
);
|
|
964
|
+
const answererCandidates = await storage.getIceCandidates(
|
|
965
|
+
offer.id,
|
|
966
|
+
"answerer",
|
|
967
|
+
sinceTimestamp
|
|
968
|
+
);
|
|
969
|
+
const allCandidates = [
|
|
970
|
+
...offererCandidates.map((c) => ({
|
|
971
|
+
...c,
|
|
972
|
+
role: "offerer"
|
|
973
|
+
})),
|
|
974
|
+
...answererCandidates.map((c) => ({
|
|
975
|
+
...c,
|
|
976
|
+
role: "answerer"
|
|
977
|
+
}))
|
|
978
|
+
];
|
|
979
|
+
if (allCandidates.length > 0) {
|
|
980
|
+
const isOfferer = offer.username === username;
|
|
981
|
+
const filtered = allCandidates.filter(
|
|
982
|
+
(c) => isOfferer ? c.role === "answerer" : c.role === "offerer"
|
|
983
|
+
);
|
|
984
|
+
if (filtered.length > 0) {
|
|
985
|
+
iceCandidatesByOffer[offer.id] = filtered;
|
|
986
|
+
}
|
|
1090
987
|
}
|
|
1091
|
-
return c.json({
|
|
1092
|
-
id: offer.id,
|
|
1093
|
-
peerId: offer.peerId,
|
|
1094
|
-
sdp: offer.sdp,
|
|
1095
|
-
createdAt: offer.createdAt,
|
|
1096
|
-
expiresAt: offer.expiresAt,
|
|
1097
|
-
answererPeerId: offer.answererPeerId,
|
|
1098
|
-
answered: !!offer.answererPeerId,
|
|
1099
|
-
answerSdp: offer.answerSdp
|
|
1100
|
-
}, 200);
|
|
1101
|
-
} catch (err2) {
|
|
1102
|
-
console.error("Error getting offer:", err2);
|
|
1103
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
1104
988
|
}
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
989
|
+
return {
|
|
990
|
+
answers: filteredAnswers.map((offer) => ({
|
|
991
|
+
offerId: offer.id,
|
|
992
|
+
serviceId: offer.serviceId,
|
|
993
|
+
answererId: offer.answererUsername,
|
|
994
|
+
sdp: offer.answerSdp,
|
|
995
|
+
answeredAt: offer.answeredAt
|
|
996
|
+
})),
|
|
997
|
+
iceCandidates: iceCandidatesByOffer
|
|
998
|
+
};
|
|
999
|
+
},
|
|
1000
|
+
/**
|
|
1001
|
+
* Add ICE candidates
|
|
1002
|
+
*/
|
|
1003
|
+
async addIceCandidates(params, message, signature, publicKey, storage, config) {
|
|
1004
|
+
const { serviceFqn, offerId, candidates } = params;
|
|
1005
|
+
const username = extractUsername(message);
|
|
1006
|
+
if (!username) {
|
|
1007
|
+
throw new Error("Username required");
|
|
1008
|
+
}
|
|
1009
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
1010
|
+
if (!auth.valid) {
|
|
1011
|
+
throw new Error(auth.error);
|
|
1012
|
+
}
|
|
1013
|
+
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
1014
|
+
throw new Error("Missing or invalid required parameter: candidates");
|
|
1015
|
+
}
|
|
1016
|
+
candidates.forEach((candidate, index) => {
|
|
1017
|
+
if (!candidate || typeof candidate !== "object") {
|
|
1018
|
+
throw new Error(`Invalid candidate at index ${index}: must be an object`);
|
|
1113
1019
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1020
|
+
});
|
|
1021
|
+
const offer = await storage.getOfferById(offerId);
|
|
1022
|
+
if (!offer) {
|
|
1023
|
+
throw new Error("Offer not found");
|
|
1118
1024
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1025
|
+
const role = offer.username === username ? "offerer" : "answerer";
|
|
1026
|
+
const count = await storage.addIceCandidates(
|
|
1027
|
+
offerId,
|
|
1028
|
+
username,
|
|
1029
|
+
role,
|
|
1030
|
+
candidates
|
|
1031
|
+
);
|
|
1032
|
+
return { count, offerId };
|
|
1033
|
+
},
|
|
1034
|
+
/**
|
|
1035
|
+
* Get ICE candidates
|
|
1036
|
+
*/
|
|
1037
|
+
async getIceCandidates(params, message, signature, publicKey, storage, config) {
|
|
1038
|
+
const { serviceFqn, offerId, since } = params;
|
|
1039
|
+
const username = extractUsername(message);
|
|
1040
|
+
if (!username) {
|
|
1041
|
+
throw new Error("Username required");
|
|
1042
|
+
}
|
|
1043
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
1044
|
+
if (!auth.valid) {
|
|
1045
|
+
throw new Error(auth.error);
|
|
1046
|
+
}
|
|
1047
|
+
const sinceTimestamp = since || 0;
|
|
1048
|
+
const offer = await storage.getOfferById(offerId);
|
|
1049
|
+
if (!offer) {
|
|
1050
|
+
throw new Error("Offer not found");
|
|
1051
|
+
}
|
|
1052
|
+
const isOfferer = offer.username === username;
|
|
1053
|
+
const role = isOfferer ? "answerer" : "offerer";
|
|
1054
|
+
const candidates = await storage.getIceCandidates(
|
|
1055
|
+
offerId,
|
|
1056
|
+
role,
|
|
1057
|
+
sinceTimestamp
|
|
1058
|
+
);
|
|
1059
|
+
return {
|
|
1060
|
+
candidates: candidates.map((c) => ({
|
|
1061
|
+
candidate: c.candidate,
|
|
1062
|
+
createdAt: c.createdAt
|
|
1063
|
+
})),
|
|
1064
|
+
offerId
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
async function handleRpc(requests, storage, config) {
|
|
1069
|
+
const responses = [];
|
|
1070
|
+
for (const request of requests) {
|
|
1121
1071
|
try {
|
|
1122
|
-
const
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1072
|
+
const { method, message, signature, publicKey, params } = request;
|
|
1073
|
+
if (!method || typeof method !== "string") {
|
|
1074
|
+
responses.push({
|
|
1075
|
+
success: false,
|
|
1076
|
+
error: "Missing or invalid method"
|
|
1077
|
+
});
|
|
1078
|
+
continue;
|
|
1127
1079
|
}
|
|
1128
|
-
if (typeof
|
|
1129
|
-
|
|
1080
|
+
if (!message || typeof message !== "string") {
|
|
1081
|
+
responses.push({
|
|
1082
|
+
success: false,
|
|
1083
|
+
error: "Missing or invalid message"
|
|
1084
|
+
});
|
|
1085
|
+
continue;
|
|
1130
1086
|
}
|
|
1131
|
-
if (
|
|
1132
|
-
|
|
1087
|
+
if (!signature || typeof signature !== "string") {
|
|
1088
|
+
responses.push({
|
|
1089
|
+
success: false,
|
|
1090
|
+
error: "Missing or invalid signature"
|
|
1091
|
+
});
|
|
1092
|
+
continue;
|
|
1133
1093
|
}
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1094
|
+
const handler = handlers[method];
|
|
1095
|
+
if (!handler) {
|
|
1096
|
+
responses.push({
|
|
1097
|
+
success: false,
|
|
1098
|
+
error: `Unknown method: ${method}`
|
|
1099
|
+
});
|
|
1100
|
+
continue;
|
|
1138
1101
|
}
|
|
1139
|
-
|
|
1102
|
+
const result = await handler(
|
|
1103
|
+
params || {},
|
|
1104
|
+
message,
|
|
1105
|
+
signature,
|
|
1106
|
+
publicKey,
|
|
1107
|
+
storage,
|
|
1108
|
+
config
|
|
1109
|
+
);
|
|
1110
|
+
responses.push({
|
|
1111
|
+
success: true,
|
|
1112
|
+
result
|
|
1113
|
+
});
|
|
1140
1114
|
} catch (err2) {
|
|
1141
|
-
|
|
1142
|
-
|
|
1115
|
+
responses.push({
|
|
1116
|
+
success: false,
|
|
1117
|
+
error: err2.message || "Internal server error"
|
|
1118
|
+
});
|
|
1143
1119
|
}
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1120
|
+
}
|
|
1121
|
+
return responses;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// src/app.ts
|
|
1125
|
+
var MAX_BATCH_SIZE = 100;
|
|
1126
|
+
function createApp(storage, config) {
|
|
1127
|
+
const app = new import_hono.Hono();
|
|
1128
|
+
app.use("/*", (0, import_cors.cors)({
|
|
1129
|
+
origin: (origin) => {
|
|
1130
|
+
if (config.corsOrigins.length === 1 && config.corsOrigins[0] === "*") {
|
|
1131
|
+
return origin;
|
|
1155
1132
|
}
|
|
1156
|
-
if (
|
|
1157
|
-
return
|
|
1133
|
+
if (config.corsOrigins.includes(origin)) {
|
|
1134
|
+
return origin;
|
|
1158
1135
|
}
|
|
1159
|
-
return
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1136
|
+
return config.corsOrigins[0];
|
|
1137
|
+
},
|
|
1138
|
+
allowMethods: ["GET", "POST", "OPTIONS"],
|
|
1139
|
+
allowHeaders: ["Content-Type", "Origin"],
|
|
1140
|
+
exposeHeaders: ["Content-Type"],
|
|
1141
|
+
credentials: false,
|
|
1142
|
+
maxAge: 86400
|
|
1143
|
+
}));
|
|
1144
|
+
app.get("/", (c) => {
|
|
1145
|
+
return c.json({
|
|
1146
|
+
version: config.version,
|
|
1147
|
+
name: "Rondevu",
|
|
1148
|
+
description: "WebRTC signaling with RPC interface and Ed25519 authentication"
|
|
1149
|
+
}, 200);
|
|
1169
1150
|
});
|
|
1170
|
-
app.
|
|
1151
|
+
app.get("/health", (c) => {
|
|
1152
|
+
return c.json({
|
|
1153
|
+
status: "ok",
|
|
1154
|
+
timestamp: Date.now(),
|
|
1155
|
+
version: config.version
|
|
1156
|
+
}, 200);
|
|
1157
|
+
});
|
|
1158
|
+
app.post("/rpc", async (c) => {
|
|
1171
1159
|
try {
|
|
1172
|
-
const offerId = c.req.param("offerId");
|
|
1173
1160
|
const body = await c.req.json();
|
|
1174
|
-
const
|
|
1175
|
-
if (
|
|
1176
|
-
return c.json({ error: "
|
|
1161
|
+
const requests = Array.isArray(body) ? body : [body];
|
|
1162
|
+
if (requests.length === 0) {
|
|
1163
|
+
return c.json({ error: "Empty request array" }, 400);
|
|
1177
1164
|
}
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
if (!offer) {
|
|
1181
|
-
return c.json({ error: "Offer not found" }, 404);
|
|
1165
|
+
if (requests.length > MAX_BATCH_SIZE) {
|
|
1166
|
+
return c.json({ error: `Too many requests in batch (max ${MAX_BATCH_SIZE})` }, 400);
|
|
1182
1167
|
}
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1185
|
-
return c.json({ count }, 200);
|
|
1168
|
+
const responses = await handleRpc(requests, storage, config);
|
|
1169
|
+
return c.json(Array.isArray(body) ? responses : responses[0], 200);
|
|
1186
1170
|
} catch (err2) {
|
|
1187
|
-
console.error("
|
|
1188
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
1189
|
-
}
|
|
1190
|
-
});
|
|
1191
|
-
app.get("/offers/:offerId/ice-candidates", authMiddleware, async (c) => {
|
|
1192
|
-
try {
|
|
1193
|
-
const offerId = c.req.param("offerId");
|
|
1194
|
-
const since = c.req.query("since");
|
|
1195
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
1196
|
-
const offer = await storage.getOfferById(offerId);
|
|
1197
|
-
if (!offer) {
|
|
1198
|
-
return c.json({ error: "Offer not found" }, 404);
|
|
1199
|
-
}
|
|
1200
|
-
const targetRole = offer.peerId === peerId ? "answerer" : "offerer";
|
|
1201
|
-
const sinceTimestamp = since ? parseInt(since, 10) : void 0;
|
|
1202
|
-
const candidates = await storage.getIceCandidates(offerId, targetRole, sinceTimestamp);
|
|
1171
|
+
console.error("RPC error:", err2);
|
|
1203
1172
|
return c.json({
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
}))
|
|
1208
|
-
}, 200);
|
|
1209
|
-
} catch (err2) {
|
|
1210
|
-
console.error("Error getting ICE candidates:", err2);
|
|
1211
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
1173
|
+
success: false,
|
|
1174
|
+
error: "Invalid request format"
|
|
1175
|
+
}, 400);
|
|
1212
1176
|
}
|
|
1213
1177
|
});
|
|
1178
|
+
app.all("*", (c) => {
|
|
1179
|
+
return c.json({
|
|
1180
|
+
error: "Not found. Use POST /rpc for all API calls."
|
|
1181
|
+
}, 404);
|
|
1182
|
+
});
|
|
1214
1183
|
return app;
|
|
1215
1184
|
}
|
|
1216
1185
|
|
|
1217
1186
|
// src/config.ts
|
|
1218
1187
|
function loadConfig() {
|
|
1219
|
-
let authSecret = process.env.AUTH_SECRET;
|
|
1220
|
-
if (!authSecret) {
|
|
1221
|
-
authSecret = generateSecretKey();
|
|
1222
|
-
console.warn("WARNING: No AUTH_SECRET provided. Generated temporary secret:", authSecret);
|
|
1223
|
-
console.warn("All peer credentials will be invalidated on server restart.");
|
|
1224
|
-
console.warn("Set AUTH_SECRET environment variable to persist credentials across restarts.");
|
|
1225
|
-
}
|
|
1226
1188
|
return {
|
|
1227
1189
|
port: parseInt(process.env.PORT || "3000", 10),
|
|
1228
1190
|
storageType: process.env.STORAGE_TYPE || "sqlite",
|
|
1229
1191
|
storagePath: process.env.STORAGE_PATH || ":memory:",
|
|
1230
1192
|
corsOrigins: process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(",").map((o) => o.trim()) : ["*"],
|
|
1231
1193
|
version: process.env.VERSION || "unknown",
|
|
1232
|
-
authSecret,
|
|
1233
1194
|
offerDefaultTtl: parseInt(process.env.OFFER_DEFAULT_TTL || "60000", 10),
|
|
1234
1195
|
offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || "86400000", 10),
|
|
1235
1196
|
offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || "60000", 10),
|
|
@@ -1275,30 +1236,29 @@ var SQLiteStorage = class {
|
|
|
1275
1236
|
-- WebRTC signaling offers
|
|
1276
1237
|
CREATE TABLE IF NOT EXISTS offers (
|
|
1277
1238
|
id TEXT PRIMARY KEY,
|
|
1278
|
-
|
|
1239
|
+
username TEXT NOT NULL,
|
|
1279
1240
|
service_id TEXT,
|
|
1280
1241
|
sdp TEXT NOT NULL,
|
|
1281
1242
|
created_at INTEGER NOT NULL,
|
|
1282
1243
|
expires_at INTEGER NOT NULL,
|
|
1283
1244
|
last_seen INTEGER NOT NULL,
|
|
1284
|
-
|
|
1285
|
-
answerer_peer_id TEXT,
|
|
1245
|
+
answerer_username TEXT,
|
|
1286
1246
|
answer_sdp TEXT,
|
|
1287
1247
|
answered_at INTEGER,
|
|
1288
1248
|
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
|
1289
1249
|
);
|
|
1290
1250
|
|
|
1291
|
-
CREATE INDEX IF NOT EXISTS
|
|
1251
|
+
CREATE INDEX IF NOT EXISTS idx_offers_username ON offers(username);
|
|
1292
1252
|
CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id);
|
|
1293
1253
|
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
|
|
1294
1254
|
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
|
|
1295
|
-
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(
|
|
1255
|
+
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username);
|
|
1296
1256
|
|
|
1297
1257
|
-- ICE candidates table
|
|
1298
1258
|
CREATE TABLE IF NOT EXISTS ice_candidates (
|
|
1299
1259
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1300
1260
|
offer_id TEXT NOT NULL,
|
|
1301
|
-
|
|
1261
|
+
username TEXT NOT NULL,
|
|
1302
1262
|
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
|
|
1303
1263
|
candidate TEXT NOT NULL,
|
|
1304
1264
|
created_at INTEGER NOT NULL,
|
|
@@ -1306,7 +1266,7 @@ var SQLiteStorage = class {
|
|
|
1306
1266
|
);
|
|
1307
1267
|
|
|
1308
1268
|
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
|
|
1309
|
-
CREATE INDEX IF NOT EXISTS
|
|
1269
|
+
CREATE INDEX IF NOT EXISTS idx_ice_username ON ice_candidates(username);
|
|
1310
1270
|
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
|
|
1311
1271
|
|
|
1312
1272
|
-- Usernames table
|
|
@@ -1323,36 +1283,23 @@ var SQLiteStorage = class {
|
|
|
1323
1283
|
CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at);
|
|
1324
1284
|
CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key);
|
|
1325
1285
|
|
|
1326
|
-
-- Services table (
|
|
1286
|
+
-- Services table (new schema with extracted fields for discovery)
|
|
1327
1287
|
CREATE TABLE IF NOT EXISTS services (
|
|
1328
1288
|
id TEXT PRIMARY KEY,
|
|
1329
|
-
username TEXT NOT NULL,
|
|
1330
1289
|
service_fqn TEXT NOT NULL,
|
|
1290
|
+
service_name TEXT NOT NULL,
|
|
1291
|
+
version TEXT NOT NULL,
|
|
1292
|
+
username TEXT NOT NULL,
|
|
1331
1293
|
created_at INTEGER NOT NULL,
|
|
1332
1294
|
expires_at INTEGER NOT NULL,
|
|
1333
|
-
is_public INTEGER NOT NULL DEFAULT 0,
|
|
1334
|
-
metadata TEXT,
|
|
1335
1295
|
FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
|
|
1336
|
-
UNIQUE(
|
|
1296
|
+
UNIQUE(service_fqn)
|
|
1337
1297
|
);
|
|
1338
1298
|
|
|
1339
|
-
CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
|
|
1340
1299
|
CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn);
|
|
1300
|
+
CREATE INDEX IF NOT EXISTS idx_services_discovery ON services(service_name, version);
|
|
1301
|
+
CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
|
|
1341
1302
|
CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
|
|
1342
|
-
|
|
1343
|
-
-- Service index table (privacy layer)
|
|
1344
|
-
CREATE TABLE IF NOT EXISTS service_index (
|
|
1345
|
-
uuid TEXT PRIMARY KEY,
|
|
1346
|
-
service_id TEXT NOT NULL,
|
|
1347
|
-
username TEXT NOT NULL,
|
|
1348
|
-
service_fqn TEXT NOT NULL,
|
|
1349
|
-
created_at INTEGER NOT NULL,
|
|
1350
|
-
expires_at INTEGER NOT NULL,
|
|
1351
|
-
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
|
1352
|
-
);
|
|
1353
|
-
|
|
1354
|
-
CREATE INDEX IF NOT EXISTS idx_service_index_username ON service_index(username);
|
|
1355
|
-
CREATE INDEX IF NOT EXISTS idx_service_index_expires ON service_index(expires_at);
|
|
1356
1303
|
`);
|
|
1357
1304
|
this.db.pragma("foreign_keys = ON");
|
|
1358
1305
|
}
|
|
@@ -1362,49 +1309,47 @@ var SQLiteStorage = class {
|
|
|
1362
1309
|
const offersWithIds = await Promise.all(
|
|
1363
1310
|
offers.map(async (offer) => ({
|
|
1364
1311
|
...offer,
|
|
1365
|
-
serviceId: offer.serviceId || null,
|
|
1366
1312
|
id: offer.id || await generateOfferHash(offer.sdp)
|
|
1367
1313
|
}))
|
|
1368
1314
|
);
|
|
1369
1315
|
const transaction = this.db.transaction((offersWithIds2) => {
|
|
1370
1316
|
const offerStmt = this.db.prepare(`
|
|
1371
|
-
INSERT INTO offers (id,
|
|
1372
|
-
VALUES (?, ?, ?, ?, ?, ?,
|
|
1317
|
+
INSERT INTO offers (id, username, service_id, sdp, created_at, expires_at, last_seen)
|
|
1318
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1373
1319
|
`);
|
|
1374
1320
|
for (const offer of offersWithIds2) {
|
|
1375
1321
|
const now = Date.now();
|
|
1376
1322
|
offerStmt.run(
|
|
1377
1323
|
offer.id,
|
|
1378
|
-
offer.
|
|
1324
|
+
offer.username,
|
|
1379
1325
|
offer.serviceId || null,
|
|
1380
1326
|
offer.sdp,
|
|
1381
1327
|
now,
|
|
1382
1328
|
offer.expiresAt,
|
|
1383
|
-
now
|
|
1384
|
-
offer.secret || null
|
|
1329
|
+
now
|
|
1385
1330
|
);
|
|
1386
1331
|
created.push({
|
|
1387
1332
|
id: offer.id,
|
|
1388
|
-
|
|
1389
|
-
serviceId: offer.serviceId,
|
|
1333
|
+
username: offer.username,
|
|
1334
|
+
serviceId: offer.serviceId || void 0,
|
|
1335
|
+
serviceFqn: offer.serviceFqn,
|
|
1390
1336
|
sdp: offer.sdp,
|
|
1391
1337
|
createdAt: now,
|
|
1392
1338
|
expiresAt: offer.expiresAt,
|
|
1393
|
-
lastSeen: now
|
|
1394
|
-
secret: offer.secret
|
|
1339
|
+
lastSeen: now
|
|
1395
1340
|
});
|
|
1396
1341
|
}
|
|
1397
1342
|
});
|
|
1398
1343
|
transaction(offersWithIds);
|
|
1399
1344
|
return created;
|
|
1400
1345
|
}
|
|
1401
|
-
async
|
|
1346
|
+
async getOffersByUsername(username) {
|
|
1402
1347
|
const stmt = this.db.prepare(`
|
|
1403
1348
|
SELECT * FROM offers
|
|
1404
|
-
WHERE
|
|
1349
|
+
WHERE username = ? AND expires_at > ?
|
|
1405
1350
|
ORDER BY last_seen DESC
|
|
1406
1351
|
`);
|
|
1407
|
-
const rows = stmt.all(
|
|
1352
|
+
const rows = stmt.all(username, Date.now());
|
|
1408
1353
|
return rows.map((row) => this.rowToOffer(row));
|
|
1409
1354
|
}
|
|
1410
1355
|
async getOfferById(offerId) {
|
|
@@ -1418,12 +1363,12 @@ var SQLiteStorage = class {
|
|
|
1418
1363
|
}
|
|
1419
1364
|
return this.rowToOffer(row);
|
|
1420
1365
|
}
|
|
1421
|
-
async deleteOffer(offerId,
|
|
1366
|
+
async deleteOffer(offerId, ownerUsername) {
|
|
1422
1367
|
const stmt = this.db.prepare(`
|
|
1423
1368
|
DELETE FROM offers
|
|
1424
|
-
WHERE id = ? AND
|
|
1369
|
+
WHERE id = ? AND username = ?
|
|
1425
1370
|
`);
|
|
1426
|
-
const result = stmt.run(offerId,
|
|
1371
|
+
const result = stmt.run(offerId, ownerUsername);
|
|
1427
1372
|
return result.changes > 0;
|
|
1428
1373
|
}
|
|
1429
1374
|
async deleteExpiredOffers(now) {
|
|
@@ -1431,7 +1376,7 @@ var SQLiteStorage = class {
|
|
|
1431
1376
|
const result = stmt.run(now);
|
|
1432
1377
|
return result.changes;
|
|
1433
1378
|
}
|
|
1434
|
-
async answerOffer(offerId,
|
|
1379
|
+
async answerOffer(offerId, answererUsername, answerSdp) {
|
|
1435
1380
|
const offer = await this.getOfferById(offerId);
|
|
1436
1381
|
if (!offer) {
|
|
1437
1382
|
return {
|
|
@@ -1439,13 +1384,7 @@ var SQLiteStorage = class {
|
|
|
1439
1384
|
error: "Offer not found or expired"
|
|
1440
1385
|
};
|
|
1441
1386
|
}
|
|
1442
|
-
if (offer.
|
|
1443
|
-
return {
|
|
1444
|
-
success: false,
|
|
1445
|
-
error: "Invalid or missing secret"
|
|
1446
|
-
};
|
|
1447
|
-
}
|
|
1448
|
-
if (offer.answererPeerId) {
|
|
1387
|
+
if (offer.answererUsername) {
|
|
1449
1388
|
return {
|
|
1450
1389
|
success: false,
|
|
1451
1390
|
error: "Offer already answered"
|
|
@@ -1453,10 +1392,10 @@ var SQLiteStorage = class {
|
|
|
1453
1392
|
}
|
|
1454
1393
|
const stmt = this.db.prepare(`
|
|
1455
1394
|
UPDATE offers
|
|
1456
|
-
SET
|
|
1457
|
-
WHERE id = ? AND
|
|
1395
|
+
SET answerer_username = ?, answer_sdp = ?, answered_at = ?
|
|
1396
|
+
WHERE id = ? AND answerer_username IS NULL
|
|
1458
1397
|
`);
|
|
1459
|
-
const result = stmt.run(
|
|
1398
|
+
const result = stmt.run(answererUsername, answerSdp, Date.now(), offerId);
|
|
1460
1399
|
if (result.changes === 0) {
|
|
1461
1400
|
return {
|
|
1462
1401
|
success: false,
|
|
@@ -1465,19 +1404,19 @@ var SQLiteStorage = class {
|
|
|
1465
1404
|
}
|
|
1466
1405
|
return { success: true };
|
|
1467
1406
|
}
|
|
1468
|
-
async getAnsweredOffers(
|
|
1407
|
+
async getAnsweredOffers(offererUsername) {
|
|
1469
1408
|
const stmt = this.db.prepare(`
|
|
1470
1409
|
SELECT * FROM offers
|
|
1471
|
-
WHERE
|
|
1410
|
+
WHERE username = ? AND answerer_username IS NOT NULL AND expires_at > ?
|
|
1472
1411
|
ORDER BY answered_at DESC
|
|
1473
1412
|
`);
|
|
1474
|
-
const rows = stmt.all(
|
|
1413
|
+
const rows = stmt.all(offererUsername, Date.now());
|
|
1475
1414
|
return rows.map((row) => this.rowToOffer(row));
|
|
1476
1415
|
}
|
|
1477
1416
|
// ===== ICE Candidate Management =====
|
|
1478
|
-
async addIceCandidates(offerId,
|
|
1417
|
+
async addIceCandidates(offerId, username, role, candidates) {
|
|
1479
1418
|
const stmt = this.db.prepare(`
|
|
1480
|
-
INSERT INTO ice_candidates (offer_id,
|
|
1419
|
+
INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
|
|
1481
1420
|
VALUES (?, ?, ?, ?, ?)
|
|
1482
1421
|
`);
|
|
1483
1422
|
const baseTimestamp = Date.now();
|
|
@@ -1485,7 +1424,7 @@ var SQLiteStorage = class {
|
|
|
1485
1424
|
for (let i = 0; i < candidates2.length; i++) {
|
|
1486
1425
|
stmt.run(
|
|
1487
1426
|
offerId,
|
|
1488
|
-
|
|
1427
|
+
username,
|
|
1489
1428
|
role,
|
|
1490
1429
|
JSON.stringify(candidates2[i]),
|
|
1491
1430
|
baseTimestamp + i
|
|
@@ -1511,7 +1450,7 @@ var SQLiteStorage = class {
|
|
|
1511
1450
|
return rows.map((row) => ({
|
|
1512
1451
|
id: row.id,
|
|
1513
1452
|
offerId: row.offer_id,
|
|
1514
|
-
|
|
1453
|
+
username: row.username,
|
|
1515
1454
|
role: row.role,
|
|
1516
1455
|
candidate: JSON.parse(row.candidate),
|
|
1517
1456
|
createdAt: row.created_at
|
|
@@ -1587,63 +1526,74 @@ var SQLiteStorage = class {
|
|
|
1587
1526
|
// ===== Service Management =====
|
|
1588
1527
|
async createService(request) {
|
|
1589
1528
|
const serviceId = (0, import_node_crypto.randomUUID)();
|
|
1590
|
-
const indexUuid = (0, import_node_crypto.randomUUID)();
|
|
1591
1529
|
const now = Date.now();
|
|
1592
|
-
const
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1530
|
+
const parsed = parseServiceFqn(request.serviceFqn);
|
|
1531
|
+
if (!parsed) {
|
|
1532
|
+
throw new Error(`Invalid service FQN: ${request.serviceFqn}`);
|
|
1533
|
+
}
|
|
1534
|
+
if (!parsed.username) {
|
|
1535
|
+
throw new Error(`Service FQN must include username: ${request.serviceFqn}`);
|
|
1536
|
+
}
|
|
1537
|
+
const { serviceName, version, username } = parsed;
|
|
1597
1538
|
const transaction = this.db.transaction(() => {
|
|
1598
|
-
const
|
|
1599
|
-
|
|
1539
|
+
const existingService = this.db.prepare(`
|
|
1540
|
+
SELECT id FROM services
|
|
1541
|
+
WHERE service_name = ? AND version = ? AND username = ?
|
|
1542
|
+
`).get(serviceName, version, username);
|
|
1543
|
+
if (existingService) {
|
|
1544
|
+
this.db.prepare(`
|
|
1545
|
+
DELETE FROM offers WHERE service_id = ?
|
|
1546
|
+
`).run(existingService.id);
|
|
1547
|
+
this.db.prepare(`
|
|
1548
|
+
DELETE FROM services WHERE id = ?
|
|
1549
|
+
`).run(existingService.id);
|
|
1550
|
+
}
|
|
1551
|
+
this.db.prepare(`
|
|
1552
|
+
INSERT INTO services (id, service_fqn, service_name, version, username, created_at, expires_at)
|
|
1600
1553
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1601
|
-
`)
|
|
1602
|
-
serviceStmt.run(
|
|
1554
|
+
`).run(
|
|
1603
1555
|
serviceId,
|
|
1604
|
-
request.username,
|
|
1605
|
-
request.serviceFqn,
|
|
1606
|
-
now,
|
|
1607
|
-
request.expiresAt,
|
|
1608
|
-
request.isPublic ? 1 : 0,
|
|
1609
|
-
request.metadata || null
|
|
1610
|
-
);
|
|
1611
|
-
const indexStmt = this.db.prepare(`
|
|
1612
|
-
INSERT INTO service_index (uuid, service_id, username, service_fqn, created_at, expires_at)
|
|
1613
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
1614
|
-
`);
|
|
1615
|
-
indexStmt.run(
|
|
1616
|
-
indexUuid,
|
|
1617
|
-
serviceId,
|
|
1618
|
-
request.username,
|
|
1619
1556
|
request.serviceFqn,
|
|
1557
|
+
serviceName,
|
|
1558
|
+
version,
|
|
1559
|
+
username,
|
|
1620
1560
|
now,
|
|
1621
1561
|
request.expiresAt
|
|
1622
1562
|
);
|
|
1623
|
-
|
|
1563
|
+
const expiresAt = now + YEAR_IN_MS;
|
|
1564
|
+
this.db.prepare(`
|
|
1565
|
+
UPDATE usernames
|
|
1566
|
+
SET last_used = ?, expires_at = ?
|
|
1567
|
+
WHERE username = ? AND expires_at > ?
|
|
1568
|
+
`).run(now, expiresAt, username, now);
|
|
1624
1569
|
});
|
|
1625
1570
|
transaction();
|
|
1571
|
+
const offerRequests = request.offers.map((offer) => ({
|
|
1572
|
+
...offer,
|
|
1573
|
+
serviceId
|
|
1574
|
+
}));
|
|
1575
|
+
const offers = await this.createOffers(offerRequests);
|
|
1626
1576
|
return {
|
|
1627
1577
|
service: {
|
|
1628
1578
|
id: serviceId,
|
|
1629
|
-
username: request.username,
|
|
1630
1579
|
serviceFqn: request.serviceFqn,
|
|
1580
|
+
serviceName,
|
|
1581
|
+
version,
|
|
1582
|
+
username,
|
|
1631
1583
|
createdAt: now,
|
|
1632
|
-
expiresAt: request.expiresAt
|
|
1633
|
-
isPublic: request.isPublic || false,
|
|
1634
|
-
metadata: request.metadata
|
|
1584
|
+
expiresAt: request.expiresAt
|
|
1635
1585
|
},
|
|
1636
|
-
indexUuid,
|
|
1637
1586
|
offers
|
|
1638
1587
|
};
|
|
1639
1588
|
}
|
|
1640
|
-
async
|
|
1641
|
-
const
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1589
|
+
async getOffersForService(serviceId) {
|
|
1590
|
+
const stmt = this.db.prepare(`
|
|
1591
|
+
SELECT * FROM offers
|
|
1592
|
+
WHERE service_id = ? AND expires_at > ?
|
|
1593
|
+
ORDER BY created_at ASC
|
|
1594
|
+
`);
|
|
1595
|
+
const rows = stmt.all(serviceId, Date.now());
|
|
1596
|
+
return rows.map((row) => this.rowToOffer(row));
|
|
1647
1597
|
}
|
|
1648
1598
|
async getServiceById(serviceId) {
|
|
1649
1599
|
const stmt = this.db.prepare(`
|
|
@@ -1656,42 +1606,49 @@ var SQLiteStorage = class {
|
|
|
1656
1606
|
}
|
|
1657
1607
|
return this.rowToService(row);
|
|
1658
1608
|
}
|
|
1659
|
-
async
|
|
1609
|
+
async getServiceByFqn(serviceFqn) {
|
|
1660
1610
|
const stmt = this.db.prepare(`
|
|
1661
|
-
SELECT
|
|
1662
|
-
|
|
1663
|
-
WHERE si.uuid = ? AND s.expires_at > ?
|
|
1611
|
+
SELECT * FROM services
|
|
1612
|
+
WHERE service_fqn = ? AND expires_at > ?
|
|
1664
1613
|
`);
|
|
1665
|
-
const row = stmt.get(
|
|
1614
|
+
const row = stmt.get(serviceFqn, Date.now());
|
|
1666
1615
|
if (!row) {
|
|
1667
1616
|
return null;
|
|
1668
1617
|
}
|
|
1669
1618
|
return this.rowToService(row);
|
|
1670
1619
|
}
|
|
1671
|
-
async
|
|
1620
|
+
async discoverServices(serviceName, version, limit, offset) {
|
|
1672
1621
|
const stmt = this.db.prepare(`
|
|
1673
|
-
SELECT
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1622
|
+
SELECT DISTINCT s.* FROM services s
|
|
1623
|
+
INNER JOIN offers o ON o.service_id = s.id
|
|
1624
|
+
WHERE s.service_name = ?
|
|
1625
|
+
AND s.version = ?
|
|
1626
|
+
AND s.expires_at > ?
|
|
1627
|
+
AND o.answerer_username IS NULL
|
|
1628
|
+
AND o.expires_at > ?
|
|
1677
1629
|
ORDER BY s.created_at DESC
|
|
1630
|
+
LIMIT ? OFFSET ?
|
|
1678
1631
|
`);
|
|
1679
|
-
const rows = stmt.all(
|
|
1680
|
-
return rows.map((row) => (
|
|
1681
|
-
uuid: row.uuid,
|
|
1682
|
-
isPublic: row.is_public === 1,
|
|
1683
|
-
serviceFqn: row.is_public === 1 ? row.service_fqn : void 0,
|
|
1684
|
-
metadata: row.is_public === 1 ? row.metadata || void 0 : void 0
|
|
1685
|
-
}));
|
|
1632
|
+
const rows = stmt.all(serviceName, version, Date.now(), Date.now(), limit, offset);
|
|
1633
|
+
return rows.map((row) => this.rowToService(row));
|
|
1686
1634
|
}
|
|
1687
|
-
async
|
|
1635
|
+
async getRandomService(serviceName, version) {
|
|
1688
1636
|
const stmt = this.db.prepare(`
|
|
1689
|
-
SELECT
|
|
1690
|
-
INNER JOIN
|
|
1691
|
-
WHERE
|
|
1637
|
+
SELECT s.* FROM services s
|
|
1638
|
+
INNER JOIN offers o ON o.service_id = s.id
|
|
1639
|
+
WHERE s.service_name = ?
|
|
1640
|
+
AND s.version = ?
|
|
1641
|
+
AND s.expires_at > ?
|
|
1642
|
+
AND o.answerer_username IS NULL
|
|
1643
|
+
AND o.expires_at > ?
|
|
1644
|
+
ORDER BY RANDOM()
|
|
1645
|
+
LIMIT 1
|
|
1692
1646
|
`);
|
|
1693
|
-
const row = stmt.get(
|
|
1694
|
-
|
|
1647
|
+
const row = stmt.get(serviceName, version, Date.now(), Date.now());
|
|
1648
|
+
if (!row) {
|
|
1649
|
+
return null;
|
|
1650
|
+
}
|
|
1651
|
+
return this.rowToService(row);
|
|
1695
1652
|
}
|
|
1696
1653
|
async deleteService(serviceId, username) {
|
|
1697
1654
|
const stmt = this.db.prepare(`
|
|
@@ -1716,14 +1673,14 @@ var SQLiteStorage = class {
|
|
|
1716
1673
|
rowToOffer(row) {
|
|
1717
1674
|
return {
|
|
1718
1675
|
id: row.id,
|
|
1719
|
-
|
|
1676
|
+
username: row.username,
|
|
1720
1677
|
serviceId: row.service_id || void 0,
|
|
1678
|
+
serviceFqn: row.service_fqn || void 0,
|
|
1721
1679
|
sdp: row.sdp,
|
|
1722
1680
|
createdAt: row.created_at,
|
|
1723
1681
|
expiresAt: row.expires_at,
|
|
1724
1682
|
lastSeen: row.last_seen,
|
|
1725
|
-
|
|
1726
|
-
answererPeerId: row.answerer_peer_id || void 0,
|
|
1683
|
+
answererUsername: row.answerer_username || void 0,
|
|
1727
1684
|
answerSdp: row.answer_sdp || void 0,
|
|
1728
1685
|
answeredAt: row.answered_at || void 0
|
|
1729
1686
|
};
|
|
@@ -1734,26 +1691,14 @@ var SQLiteStorage = class {
|
|
|
1734
1691
|
rowToService(row) {
|
|
1735
1692
|
return {
|
|
1736
1693
|
id: row.id,
|
|
1737
|
-
username: row.username,
|
|
1738
1694
|
serviceFqn: row.service_fqn,
|
|
1695
|
+
serviceName: row.service_name,
|
|
1696
|
+
version: row.version,
|
|
1697
|
+
username: row.username,
|
|
1739
1698
|
createdAt: row.created_at,
|
|
1740
|
-
expiresAt: row.expires_at
|
|
1741
|
-
isPublic: row.is_public === 1,
|
|
1742
|
-
metadata: row.metadata || void 0
|
|
1699
|
+
expiresAt: row.expires_at
|
|
1743
1700
|
};
|
|
1744
1701
|
}
|
|
1745
|
-
/**
|
|
1746
|
-
* Get all offers for a service
|
|
1747
|
-
*/
|
|
1748
|
-
async getOffersForService(serviceId) {
|
|
1749
|
-
const stmt = this.db.prepare(`
|
|
1750
|
-
SELECT * FROM offers
|
|
1751
|
-
WHERE service_id = ? AND expires_at > ?
|
|
1752
|
-
ORDER BY created_at ASC
|
|
1753
|
-
`);
|
|
1754
|
-
const rows = stmt.all(serviceId, Date.now());
|
|
1755
|
-
return rows.map((row) => this.rowToOffer(row));
|
|
1756
|
-
}
|
|
1757
1702
|
};
|
|
1758
1703
|
|
|
1759
1704
|
// src/index.ts
|