@xtr-dev/rondevu-server 0.4.0 → 0.5.1
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 +136 -282
- package/dist/index.js +694 -733
- 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 -591
- package/src/config.ts +0 -13
- package/src/crypto.ts +103 -139
- 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
|
@@ -474,101 +474,35 @@ var wNAF = (n) => {
|
|
|
474
474
|
};
|
|
475
475
|
|
|
476
476
|
// src/crypto.ts
|
|
477
|
+
var import_node_buffer = require("node:buffer");
|
|
477
478
|
hashes.sha512Async = async (message) => {
|
|
478
479
|
return new Uint8Array(await crypto.subtle.digest("SHA-512", message));
|
|
479
480
|
};
|
|
480
|
-
var ALGORITHM = "AES-GCM";
|
|
481
|
-
var IV_LENGTH = 12;
|
|
482
|
-
var KEY_LENGTH = 32;
|
|
483
481
|
var USERNAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
|
484
482
|
var USERNAME_MIN_LENGTH = 3;
|
|
485
483
|
var USERNAME_MAX_LENGTH = 32;
|
|
486
484
|
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
485
|
function base64ToBytes(base64) {
|
|
510
|
-
|
|
511
|
-
return Uint8Array.from(binString, (char) => char.codePointAt(0));
|
|
486
|
+
return new Uint8Array(import_node_buffer.Buffer.from(base64, "base64"));
|
|
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
|
-
|
|
569
|
-
|
|
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" };
|
|
497
|
+
}
|
|
498
|
+
if (isNaN(timestamp)) {
|
|
499
|
+
return { valid: false, error: "Invalid timestamp in message" };
|
|
571
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,22 +523,28 @@ 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
|
}
|
|
610
550
|
function parseVersion(version) {
|
|
@@ -630,11 +570,25 @@ function isVersionCompatible(requested, available) {
|
|
|
630
570
|
return true;
|
|
631
571
|
}
|
|
632
572
|
function parseServiceFqn(fqn) {
|
|
633
|
-
|
|
634
|
-
|
|
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;
|
|
635
588
|
return {
|
|
636
|
-
serviceName
|
|
637
|
-
version
|
|
589
|
+
serviceName,
|
|
590
|
+
version,
|
|
591
|
+
username
|
|
638
592
|
};
|
|
639
593
|
}
|
|
640
594
|
function validateTimestamp(timestamp) {
|
|
@@ -661,89 +615,516 @@ async function verifyEd25519Signature(publicKey, signature, message) {
|
|
|
661
615
|
return false;
|
|
662
616
|
}
|
|
663
617
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
+
}
|
|
680
648
|
}
|
|
681
|
-
const
|
|
682
|
-
|
|
649
|
+
const isValid = await verifyEd25519Signature(
|
|
650
|
+
usernameRecord.publicKey,
|
|
651
|
+
signature,
|
|
652
|
+
message
|
|
653
|
+
);
|
|
654
|
+
if (!isValid) {
|
|
683
655
|
return { valid: false, error: "Invalid signature" };
|
|
684
656
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
const usernameCheck = validateUsername(username);
|
|
689
|
-
if (!usernameCheck.valid) {
|
|
690
|
-
return usernameCheck;
|
|
691
|
-
}
|
|
692
|
-
const parts = message.split(":");
|
|
693
|
-
if (parts.length !== 4 || parts[0] !== "publish" || parts[1] !== username || parts[2] !== serviceFqn) {
|
|
694
|
-
return { valid: false, error: "Invalid message format (expected: publish:{username}:{serviceFqn}:{timestamp})" };
|
|
695
|
-
}
|
|
696
|
-
const timestamp = parseInt(parts[3], 10);
|
|
697
|
-
if (isNaN(timestamp)) {
|
|
698
|
-
return { valid: false, error: "Invalid timestamp in message" };
|
|
699
|
-
}
|
|
700
|
-
const timestampCheck = validateTimestamp(timestamp);
|
|
701
|
-
if (!timestampCheck.valid) {
|
|
702
|
-
return timestampCheck;
|
|
703
|
-
}
|
|
704
|
-
const signatureValid = await verifyEd25519Signature(publicKey, signature, message);
|
|
705
|
-
if (!signatureValid) {
|
|
706
|
-
return { valid: false, error: "Invalid signature" };
|
|
657
|
+
const validation = validateAuthMessage(username, message);
|
|
658
|
+
if (!validation.valid) {
|
|
659
|
+
return { valid: false, error: validation.error };
|
|
707
660
|
}
|
|
708
661
|
return { valid: true };
|
|
709
662
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
return
|
|
714
|
-
const authHeader = c.req.header("Authorization");
|
|
715
|
-
if (!authHeader) {
|
|
716
|
-
return c.json({ error: "Missing Authorization header" }, 401);
|
|
717
|
-
}
|
|
718
|
-
const parts = authHeader.split(" ");
|
|
719
|
-
if (parts.length !== 2 || parts[0] !== "Bearer") {
|
|
720
|
-
return c.json({ error: "Invalid Authorization header format. Expected: Bearer {peerId}:{secret}" }, 401);
|
|
721
|
-
}
|
|
722
|
-
const credentials = parts[1].split(":");
|
|
723
|
-
if (credentials.length !== 2) {
|
|
724
|
-
return c.json({ error: "Invalid credentials format. Expected: {peerId}:{secret}" }, 401);
|
|
725
|
-
}
|
|
726
|
-
const [peerId, encryptedSecret] = credentials;
|
|
727
|
-
const isValid = await validateCredentials(peerId, encryptedSecret, authSecret);
|
|
728
|
-
if (!isValid) {
|
|
729
|
-
return c.json({ error: "Invalid credentials" }, 401);
|
|
730
|
-
}
|
|
731
|
-
c.set("peerId", peerId);
|
|
732
|
-
await next();
|
|
733
|
-
};
|
|
663
|
+
function extractUsername(message) {
|
|
664
|
+
const parts = message.split(":");
|
|
665
|
+
if (parts.length < 2) return null;
|
|
666
|
+
return parts[1];
|
|
734
667
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
+
};
|
|
680
|
+
}
|
|
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);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const fqnValidation = validateServiceFqn(serviceFqn);
|
|
705
|
+
if (!fqnValidation.valid) {
|
|
706
|
+
throw new Error(fqnValidation.error || "Invalid service FQN");
|
|
707
|
+
}
|
|
708
|
+
const parsed = parseServiceFqn(serviceFqn);
|
|
709
|
+
if (!parsed) {
|
|
710
|
+
throw new Error("Failed to parse service FQN");
|
|
711
|
+
}
|
|
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
|
+
}
|
|
746
|
+
}
|
|
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);
|
|
757
|
+
if (!service) {
|
|
758
|
+
throw new Error("Service not found");
|
|
759
|
+
}
|
|
760
|
+
const availableOffer2 = await findAvailableOffer(service);
|
|
761
|
+
if (!availableOffer2) {
|
|
762
|
+
throw new Error("Service has no available offers");
|
|
763
|
+
}
|
|
764
|
+
return buildServiceResponse(service, availableOffer2);
|
|
765
|
+
}
|
|
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`);
|
|
813
|
+
}
|
|
814
|
+
if (!offer.sdp || typeof offer.sdp !== "string") {
|
|
815
|
+
throw new Error(`Invalid offer at index ${index}: missing or invalid SDP`);
|
|
816
|
+
}
|
|
817
|
+
if (!offer.sdp.trim()) {
|
|
818
|
+
throw new Error(`Invalid offer at index ${index}: SDP cannot be empty`);
|
|
819
|
+
}
|
|
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) => ({
|
|
843
|
+
offerId: offer.id,
|
|
844
|
+
sdp: offer.sdp,
|
|
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");
|
|
860
|
+
}
|
|
861
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
862
|
+
if (!auth.valid) {
|
|
863
|
+
throw new Error(auth.error);
|
|
864
|
+
}
|
|
865
|
+
const parsed = parseServiceFqn(serviceFqn);
|
|
866
|
+
if (!parsed || !parsed.username) {
|
|
867
|
+
throw new Error("Service FQN must include username");
|
|
868
|
+
}
|
|
869
|
+
const service = await storage.getServiceByFqn(serviceFqn);
|
|
870
|
+
if (!service) {
|
|
871
|
+
throw new Error("Service not found");
|
|
872
|
+
}
|
|
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");
|
|
876
|
+
}
|
|
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
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
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`);
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
const offer = await storage.getOfferById(offerId);
|
|
1022
|
+
if (!offer) {
|
|
1023
|
+
throw new Error("Offer not found");
|
|
1024
|
+
}
|
|
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
|
+
};
|
|
739
1066
|
}
|
|
740
|
-
|
|
1067
|
+
};
|
|
1068
|
+
async function handleRpc(requests, storage, config) {
|
|
1069
|
+
const responses = [];
|
|
1070
|
+
for (const request of requests) {
|
|
1071
|
+
try {
|
|
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;
|
|
1079
|
+
}
|
|
1080
|
+
if (!message || typeof message !== "string") {
|
|
1081
|
+
responses.push({
|
|
1082
|
+
success: false,
|
|
1083
|
+
error: "Missing or invalid message"
|
|
1084
|
+
});
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
if (!signature || typeof signature !== "string") {
|
|
1088
|
+
responses.push({
|
|
1089
|
+
success: false,
|
|
1090
|
+
error: "Missing or invalid signature"
|
|
1091
|
+
});
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
const handler = handlers[method];
|
|
1095
|
+
if (!handler) {
|
|
1096
|
+
responses.push({
|
|
1097
|
+
success: false,
|
|
1098
|
+
error: `Unknown method: ${method}`
|
|
1099
|
+
});
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
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
|
+
});
|
|
1114
|
+
} catch (err2) {
|
|
1115
|
+
responses.push({
|
|
1116
|
+
success: false,
|
|
1117
|
+
error: err2.message || "Internal server error"
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return responses;
|
|
741
1122
|
}
|
|
742
1123
|
|
|
743
1124
|
// src/app.ts
|
|
1125
|
+
var MAX_BATCH_SIZE = 100;
|
|
744
1126
|
function createApp(storage, config) {
|
|
745
1127
|
const app = new import_hono.Hono();
|
|
746
|
-
const authMiddleware = createAuthMiddleware(config.authSecret);
|
|
747
1128
|
app.use("/*", (0, import_cors.cors)({
|
|
748
1129
|
origin: (origin) => {
|
|
749
1130
|
if (config.corsOrigins.length === 1 && config.corsOrigins[0] === "*") {
|
|
@@ -754,458 +1135,62 @@ function createApp(storage, config) {
|
|
|
754
1135
|
}
|
|
755
1136
|
return config.corsOrigins[0];
|
|
756
1137
|
},
|
|
757
|
-
allowMethods: ["GET", "POST", "
|
|
758
|
-
allowHeaders: ["Content-Type", "Origin"
|
|
1138
|
+
allowMethods: ["GET", "POST", "OPTIONS"],
|
|
1139
|
+
allowHeaders: ["Content-Type", "Origin"],
|
|
759
1140
|
exposeHeaders: ["Content-Type"],
|
|
760
|
-
|
|
761
|
-
|
|
1141
|
+
credentials: false,
|
|
1142
|
+
maxAge: 86400
|
|
762
1143
|
}));
|
|
763
1144
|
app.get("/", (c) => {
|
|
764
1145
|
return c.json({
|
|
765
1146
|
version: config.version,
|
|
766
1147
|
name: "Rondevu",
|
|
767
|
-
description: "
|
|
768
|
-
});
|
|
1148
|
+
description: "WebRTC signaling with RPC interface and Ed25519 authentication"
|
|
1149
|
+
}, 200);
|
|
769
1150
|
});
|
|
770
1151
|
app.get("/health", (c) => {
|
|
771
1152
|
return c.json({
|
|
772
1153
|
status: "ok",
|
|
773
1154
|
timestamp: Date.now(),
|
|
774
1155
|
version: config.version
|
|
775
|
-
});
|
|
1156
|
+
}, 200);
|
|
776
1157
|
});
|
|
777
|
-
app.post("/
|
|
1158
|
+
app.post("/rpc", async (c) => {
|
|
778
1159
|
try {
|
|
779
|
-
const peerId = generatePeerId();
|
|
780
|
-
const secret = await encryptPeerId(peerId, config.authSecret);
|
|
781
|
-
return c.json({
|
|
782
|
-
peerId,
|
|
783
|
-
secret
|
|
784
|
-
}, 200);
|
|
785
|
-
} catch (err2) {
|
|
786
|
-
console.error("Error registering peer:", err2);
|
|
787
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
788
|
-
}
|
|
789
|
-
});
|
|
790
|
-
app.get("/users/:username", async (c) => {
|
|
791
|
-
try {
|
|
792
|
-
const username = c.req.param("username");
|
|
793
|
-
const claimed = await storage.getUsername(username);
|
|
794
|
-
if (!claimed) {
|
|
795
|
-
return c.json({
|
|
796
|
-
username,
|
|
797
|
-
available: true
|
|
798
|
-
}, 200);
|
|
799
|
-
}
|
|
800
|
-
return c.json({
|
|
801
|
-
username: claimed.username,
|
|
802
|
-
available: false,
|
|
803
|
-
claimedAt: claimed.claimedAt,
|
|
804
|
-
expiresAt: claimed.expiresAt,
|
|
805
|
-
publicKey: claimed.publicKey
|
|
806
|
-
}, 200);
|
|
807
|
-
} catch (err2) {
|
|
808
|
-
console.error("Error checking username:", err2);
|
|
809
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
810
|
-
}
|
|
811
|
-
});
|
|
812
|
-
app.post("/users/:username", async (c) => {
|
|
813
|
-
try {
|
|
814
|
-
const username = c.req.param("username");
|
|
815
1160
|
const body = await c.req.json();
|
|
816
|
-
const
|
|
817
|
-
if (
|
|
818
|
-
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);
|
|
819
1164
|
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
return c.json({ error: validation.error }, 400);
|
|
823
|
-
}
|
|
824
|
-
try {
|
|
825
|
-
const claimed = await storage.claimUsername({
|
|
826
|
-
username,
|
|
827
|
-
publicKey,
|
|
828
|
-
signature,
|
|
829
|
-
message
|
|
830
|
-
});
|
|
831
|
-
return c.json({
|
|
832
|
-
username: claimed.username,
|
|
833
|
-
claimedAt: claimed.claimedAt,
|
|
834
|
-
expiresAt: claimed.expiresAt
|
|
835
|
-
}, 201);
|
|
836
|
-
} catch (err2) {
|
|
837
|
-
if (err2.message?.includes("already claimed")) {
|
|
838
|
-
return c.json({ error: "Username already claimed by different public key" }, 409);
|
|
839
|
-
}
|
|
840
|
-
throw err2;
|
|
1165
|
+
if (requests.length > MAX_BATCH_SIZE) {
|
|
1166
|
+
return c.json({ error: `Too many requests in batch (max ${MAX_BATCH_SIZE})` }, 400);
|
|
841
1167
|
}
|
|
1168
|
+
const responses = await handleRpc(requests, storage, config);
|
|
1169
|
+
return c.json(Array.isArray(body) ? responses : responses[0], 200);
|
|
842
1170
|
} catch (err2) {
|
|
843
|
-
console.error("
|
|
844
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
845
|
-
}
|
|
846
|
-
});
|
|
847
|
-
app.get("/users/:username/services/:fqn", async (c) => {
|
|
848
|
-
try {
|
|
849
|
-
const username = c.req.param("username");
|
|
850
|
-
const serviceFqn = decodeURIComponent(c.req.param("fqn"));
|
|
851
|
-
const parsed = parseServiceFqn(serviceFqn);
|
|
852
|
-
if (!parsed) {
|
|
853
|
-
return c.json({ error: "Invalid service FQN format" }, 400);
|
|
854
|
-
}
|
|
855
|
-
const { serviceName, version: requestedVersion } = parsed;
|
|
856
|
-
const matchingServices = await storage.findServicesByName(username, serviceName);
|
|
857
|
-
if (matchingServices.length === 0) {
|
|
858
|
-
return c.json({ error: "Service not found" }, 404);
|
|
859
|
-
}
|
|
860
|
-
const compatibleServices = matchingServices.filter((service2) => {
|
|
861
|
-
const serviceParsed = parseServiceFqn(service2.serviceFqn);
|
|
862
|
-
if (!serviceParsed) return false;
|
|
863
|
-
return isVersionCompatible(requestedVersion, serviceParsed.version);
|
|
864
|
-
});
|
|
865
|
-
if (compatibleServices.length === 0) {
|
|
866
|
-
return c.json({
|
|
867
|
-
error: "No compatible version found",
|
|
868
|
-
message: `Requested ${serviceFqn}, but no compatible versions available`
|
|
869
|
-
}, 404);
|
|
870
|
-
}
|
|
871
|
-
const service = compatibleServices[0];
|
|
872
|
-
const uuid = await storage.queryService(username, service.serviceFqn);
|
|
873
|
-
if (!uuid) {
|
|
874
|
-
return c.json({ error: "Service index not found" }, 500);
|
|
875
|
-
}
|
|
876
|
-
const serviceOffers = await storage.getOffersForService(service.id);
|
|
877
|
-
if (serviceOffers.length === 0) {
|
|
878
|
-
return c.json({ error: "No offers found for this service" }, 404);
|
|
879
|
-
}
|
|
880
|
-
const availableOffer = serviceOffers.find((offer) => !offer.answererPeerId);
|
|
881
|
-
if (!availableOffer) {
|
|
882
|
-
return c.json({
|
|
883
|
-
error: "No available offers",
|
|
884
|
-
message: "All offers from this service are currently in use. Please try again later."
|
|
885
|
-
}, 503);
|
|
886
|
-
}
|
|
1171
|
+
console.error("RPC error:", err2);
|
|
887
1172
|
return c.json({
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
serviceFqn: service.serviceFqn,
|
|
892
|
-
offerId: availableOffer.id,
|
|
893
|
-
sdp: availableOffer.sdp,
|
|
894
|
-
isPublic: service.isPublic,
|
|
895
|
-
metadata: service.metadata ? JSON.parse(service.metadata) : void 0,
|
|
896
|
-
createdAt: service.createdAt,
|
|
897
|
-
expiresAt: service.expiresAt
|
|
898
|
-
}, 200);
|
|
899
|
-
} catch (err2) {
|
|
900
|
-
console.error("Error getting service:", err2);
|
|
901
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
902
|
-
}
|
|
903
|
-
});
|
|
904
|
-
app.post("/users/:username/services", authMiddleware, async (c) => {
|
|
905
|
-
let serviceFqn;
|
|
906
|
-
let createdOffers = [];
|
|
907
|
-
try {
|
|
908
|
-
const username = c.req.param("username");
|
|
909
|
-
const body = await c.req.json();
|
|
910
|
-
serviceFqn = body.serviceFqn;
|
|
911
|
-
const { offers, ttl, isPublic, metadata, signature, message } = body;
|
|
912
|
-
if (!serviceFqn || !offers || !Array.isArray(offers) || offers.length === 0) {
|
|
913
|
-
return c.json({ error: "Missing required parameters: serviceFqn, offers (must be non-empty array)" }, 400);
|
|
914
|
-
}
|
|
915
|
-
const fqnValidation = validateServiceFqn(serviceFqn);
|
|
916
|
-
if (!fqnValidation.valid) {
|
|
917
|
-
return c.json({ error: fqnValidation.error }, 400);
|
|
918
|
-
}
|
|
919
|
-
if (!signature || !message) {
|
|
920
|
-
return c.json({ error: "Missing signature or message for username verification" }, 400);
|
|
921
|
-
}
|
|
922
|
-
const usernameRecord = await storage.getUsername(username);
|
|
923
|
-
if (!usernameRecord) {
|
|
924
|
-
return c.json({ error: "Username not claimed" }, 404);
|
|
925
|
-
}
|
|
926
|
-
const signatureValidation = await validateServicePublish(username, serviceFqn, usernameRecord.publicKey, signature, message);
|
|
927
|
-
if (!signatureValidation.valid) {
|
|
928
|
-
return c.json({ error: "Invalid signature for username" }, 403);
|
|
929
|
-
}
|
|
930
|
-
const existingUuid = await storage.queryService(username, serviceFqn);
|
|
931
|
-
if (existingUuid) {
|
|
932
|
-
const existingService = await storage.getServiceByUuid(existingUuid);
|
|
933
|
-
if (existingService) {
|
|
934
|
-
await storage.deleteService(existingService.id, username);
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
for (const offer of offers) {
|
|
938
|
-
if (!offer.sdp || typeof offer.sdp !== "string" || offer.sdp.length === 0) {
|
|
939
|
-
return c.json({ error: "Invalid SDP in offers array" }, 400);
|
|
940
|
-
}
|
|
941
|
-
if (offer.sdp.length > 64 * 1024) {
|
|
942
|
-
return c.json({ error: "SDP too large (max 64KB)" }, 400);
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
946
|
-
const offerTtl = Math.min(
|
|
947
|
-
Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
|
|
948
|
-
config.offerMaxTtl
|
|
949
|
-
);
|
|
950
|
-
const expiresAt = Date.now() + offerTtl;
|
|
951
|
-
const offerRequests = offers.map((offer) => ({
|
|
952
|
-
peerId,
|
|
953
|
-
sdp: offer.sdp,
|
|
954
|
-
expiresAt
|
|
955
|
-
}));
|
|
956
|
-
const result = await storage.createService({
|
|
957
|
-
username,
|
|
958
|
-
serviceFqn,
|
|
959
|
-
expiresAt,
|
|
960
|
-
isPublic: isPublic || false,
|
|
961
|
-
metadata: metadata ? JSON.stringify(metadata) : void 0,
|
|
962
|
-
offers: offerRequests
|
|
963
|
-
});
|
|
964
|
-
createdOffers = result.offers;
|
|
965
|
-
return c.json({
|
|
966
|
-
uuid: result.indexUuid,
|
|
967
|
-
serviceFqn,
|
|
968
|
-
username,
|
|
969
|
-
serviceId: result.service.id,
|
|
970
|
-
offers: result.offers.map((o) => ({
|
|
971
|
-
offerId: o.id,
|
|
972
|
-
sdp: o.sdp,
|
|
973
|
-
createdAt: o.createdAt,
|
|
974
|
-
expiresAt: o.expiresAt
|
|
975
|
-
})),
|
|
976
|
-
isPublic: result.service.isPublic,
|
|
977
|
-
metadata,
|
|
978
|
-
createdAt: result.service.createdAt,
|
|
979
|
-
expiresAt: result.service.expiresAt
|
|
980
|
-
}, 201);
|
|
981
|
-
} catch (err2) {
|
|
982
|
-
console.error("Error creating service:", err2);
|
|
983
|
-
console.error("Error details:", {
|
|
984
|
-
message: err2.message,
|
|
985
|
-
stack: err2.stack,
|
|
986
|
-
username: c.req.param("username"),
|
|
987
|
-
serviceFqn,
|
|
988
|
-
offerIds: createdOffers.map((o) => o.id)
|
|
989
|
-
});
|
|
990
|
-
return c.json({
|
|
991
|
-
error: "Internal server error",
|
|
992
|
-
details: err2.message
|
|
993
|
-
}, 500);
|
|
994
|
-
}
|
|
995
|
-
});
|
|
996
|
-
app.delete("/users/:username/services/:fqn", authMiddleware, async (c) => {
|
|
997
|
-
try {
|
|
998
|
-
const username = c.req.param("username");
|
|
999
|
-
const serviceFqn = decodeURIComponent(c.req.param("fqn"));
|
|
1000
|
-
const uuid = await storage.queryService(username, serviceFqn);
|
|
1001
|
-
if (!uuid) {
|
|
1002
|
-
return c.json({ error: "Service not found" }, 404);
|
|
1003
|
-
}
|
|
1004
|
-
const service = await storage.getServiceByUuid(uuid);
|
|
1005
|
-
if (!service) {
|
|
1006
|
-
return c.json({ error: "Service not found" }, 404);
|
|
1007
|
-
}
|
|
1008
|
-
const deleted = await storage.deleteService(service.id, username);
|
|
1009
|
-
if (!deleted) {
|
|
1010
|
-
return c.json({ error: "Service not found or not owned by this username" }, 404);
|
|
1011
|
-
}
|
|
1012
|
-
return c.json({ success: true }, 200);
|
|
1013
|
-
} catch (err2) {
|
|
1014
|
-
console.error("Error deleting service:", err2);
|
|
1015
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
1016
|
-
}
|
|
1017
|
-
});
|
|
1018
|
-
app.get("/services/:uuid", async (c) => {
|
|
1019
|
-
try {
|
|
1020
|
-
const uuid = c.req.param("uuid");
|
|
1021
|
-
const service = await storage.getServiceByUuid(uuid);
|
|
1022
|
-
if (!service) {
|
|
1023
|
-
return c.json({ error: "Service not found" }, 404);
|
|
1024
|
-
}
|
|
1025
|
-
const serviceOffers = await storage.getOffersForService(service.id);
|
|
1026
|
-
if (serviceOffers.length === 0) {
|
|
1027
|
-
return c.json({ error: "No offers found for this service" }, 404);
|
|
1028
|
-
}
|
|
1029
|
-
const availableOffer = serviceOffers.find((offer) => !offer.answererPeerId);
|
|
1030
|
-
if (!availableOffer) {
|
|
1031
|
-
return c.json({
|
|
1032
|
-
error: "No available offers",
|
|
1033
|
-
message: "All offers from this service are currently in use. Please try again later."
|
|
1034
|
-
}, 503);
|
|
1035
|
-
}
|
|
1036
|
-
return c.json({
|
|
1037
|
-
uuid,
|
|
1038
|
-
serviceId: service.id,
|
|
1039
|
-
username: service.username,
|
|
1040
|
-
serviceFqn: service.serviceFqn,
|
|
1041
|
-
offerId: availableOffer.id,
|
|
1042
|
-
sdp: availableOffer.sdp,
|
|
1043
|
-
isPublic: service.isPublic,
|
|
1044
|
-
metadata: service.metadata ? JSON.parse(service.metadata) : void 0,
|
|
1045
|
-
createdAt: service.createdAt,
|
|
1046
|
-
expiresAt: service.expiresAt
|
|
1047
|
-
}, 200);
|
|
1048
|
-
} catch (err2) {
|
|
1049
|
-
console.error("Error getting service:", err2);
|
|
1050
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
1051
|
-
}
|
|
1052
|
-
});
|
|
1053
|
-
app.post("/services/:uuid/answer", authMiddleware, async (c) => {
|
|
1054
|
-
try {
|
|
1055
|
-
const uuid = c.req.param("uuid");
|
|
1056
|
-
const body = await c.req.json();
|
|
1057
|
-
const { sdp } = body;
|
|
1058
|
-
if (!sdp) {
|
|
1059
|
-
return c.json({ error: "Missing required parameter: sdp" }, 400);
|
|
1060
|
-
}
|
|
1061
|
-
if (typeof sdp !== "string" || sdp.length === 0) {
|
|
1062
|
-
return c.json({ error: "Invalid SDP" }, 400);
|
|
1063
|
-
}
|
|
1064
|
-
if (sdp.length > 64 * 1024) {
|
|
1065
|
-
return c.json({ error: "SDP too large (max 64KB)" }, 400);
|
|
1066
|
-
}
|
|
1067
|
-
const service = await storage.getServiceByUuid(uuid);
|
|
1068
|
-
if (!service) {
|
|
1069
|
-
return c.json({ error: "Service not found" }, 404);
|
|
1070
|
-
}
|
|
1071
|
-
const serviceOffers = await storage.getOffersForService(service.id);
|
|
1072
|
-
const availableOffer = serviceOffers.find((offer) => !offer.answererPeerId);
|
|
1073
|
-
if (!availableOffer) {
|
|
1074
|
-
return c.json({ error: "No available offers" }, 503);
|
|
1075
|
-
}
|
|
1076
|
-
const answererPeerId = getAuthenticatedPeerId(c);
|
|
1077
|
-
const result = await storage.answerOffer(availableOffer.id, answererPeerId, sdp);
|
|
1078
|
-
if (!result.success) {
|
|
1079
|
-
return c.json({ error: result.error }, 400);
|
|
1080
|
-
}
|
|
1081
|
-
return c.json({
|
|
1082
|
-
success: true,
|
|
1083
|
-
offerId: availableOffer.id
|
|
1084
|
-
}, 200);
|
|
1085
|
-
} catch (err2) {
|
|
1086
|
-
console.error("Error answering service:", err2);
|
|
1087
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
1088
|
-
}
|
|
1089
|
-
});
|
|
1090
|
-
app.get("/services/:uuid/answer", authMiddleware, async (c) => {
|
|
1091
|
-
try {
|
|
1092
|
-
const uuid = c.req.param("uuid");
|
|
1093
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
1094
|
-
const service = await storage.getServiceByUuid(uuid);
|
|
1095
|
-
if (!service) {
|
|
1096
|
-
return c.json({ error: "Service not found" }, 404);
|
|
1097
|
-
}
|
|
1098
|
-
const serviceOffers = await storage.getOffersForService(service.id);
|
|
1099
|
-
const myOffer = serviceOffers.find((offer) => offer.peerId === peerId && offer.answererPeerId);
|
|
1100
|
-
if (!myOffer || !myOffer.answerSdp) {
|
|
1101
|
-
return c.json({ error: "Offer not yet answered" }, 404);
|
|
1102
|
-
}
|
|
1103
|
-
return c.json({
|
|
1104
|
-
offerId: myOffer.id,
|
|
1105
|
-
answererId: myOffer.answererPeerId,
|
|
1106
|
-
sdp: myOffer.answerSdp,
|
|
1107
|
-
answeredAt: myOffer.answeredAt
|
|
1108
|
-
}, 200);
|
|
1109
|
-
} catch (err2) {
|
|
1110
|
-
console.error("Error getting service answer:", err2);
|
|
1111
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
1112
|
-
}
|
|
1113
|
-
});
|
|
1114
|
-
app.post("/services/:uuid/ice-candidates", authMiddleware, async (c) => {
|
|
1115
|
-
try {
|
|
1116
|
-
const uuid = c.req.param("uuid");
|
|
1117
|
-
const body = await c.req.json();
|
|
1118
|
-
const { candidates, offerId } = body;
|
|
1119
|
-
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
1120
|
-
return c.json({ error: "Missing or invalid required parameter: candidates" }, 400);
|
|
1121
|
-
}
|
|
1122
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
1123
|
-
const service = await storage.getServiceByUuid(uuid);
|
|
1124
|
-
if (!service) {
|
|
1125
|
-
return c.json({ error: "Service not found" }, 404);
|
|
1126
|
-
}
|
|
1127
|
-
let targetOfferId = offerId;
|
|
1128
|
-
if (!targetOfferId) {
|
|
1129
|
-
const serviceOffers = await storage.getOffersForService(service.id);
|
|
1130
|
-
const myOffer = serviceOffers.find(
|
|
1131
|
-
(offer2) => offer2.peerId === peerId || offer2.answererPeerId === peerId
|
|
1132
|
-
);
|
|
1133
|
-
if (!myOffer) {
|
|
1134
|
-
return c.json({ error: "No offer found for this peer" }, 404);
|
|
1135
|
-
}
|
|
1136
|
-
targetOfferId = myOffer.id;
|
|
1137
|
-
}
|
|
1138
|
-
const offer = await storage.getOfferById(targetOfferId);
|
|
1139
|
-
if (!offer) {
|
|
1140
|
-
return c.json({ error: "Offer not found" }, 404);
|
|
1141
|
-
}
|
|
1142
|
-
const role = offer.peerId === peerId ? "offerer" : "answerer";
|
|
1143
|
-
const count = await storage.addIceCandidates(targetOfferId, peerId, role, candidates);
|
|
1144
|
-
return c.json({ count, offerId: targetOfferId }, 200);
|
|
1145
|
-
} catch (err2) {
|
|
1146
|
-
console.error("Error adding ICE candidates to service:", err2);
|
|
1147
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
1173
|
+
success: false,
|
|
1174
|
+
error: "Invalid request format"
|
|
1175
|
+
}, 400);
|
|
1148
1176
|
}
|
|
1149
1177
|
});
|
|
1150
|
-
app.
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
const offerId = c.req.query("offerId");
|
|
1155
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
1156
|
-
const service = await storage.getServiceByUuid(uuid);
|
|
1157
|
-
if (!service) {
|
|
1158
|
-
return c.json({ error: "Service not found" }, 404);
|
|
1159
|
-
}
|
|
1160
|
-
let targetOfferId = offerId;
|
|
1161
|
-
if (!targetOfferId) {
|
|
1162
|
-
const serviceOffers = await storage.getOffersForService(service.id);
|
|
1163
|
-
const myOffer = serviceOffers.find(
|
|
1164
|
-
(offer2) => offer2.peerId === peerId || offer2.answererPeerId === peerId
|
|
1165
|
-
);
|
|
1166
|
-
if (!myOffer) {
|
|
1167
|
-
return c.json({ error: "No offer found for this peer" }, 404);
|
|
1168
|
-
}
|
|
1169
|
-
targetOfferId = myOffer.id;
|
|
1170
|
-
}
|
|
1171
|
-
const offer = await storage.getOfferById(targetOfferId);
|
|
1172
|
-
if (!offer) {
|
|
1173
|
-
return c.json({ error: "Offer not found" }, 404);
|
|
1174
|
-
}
|
|
1175
|
-
const targetRole = offer.peerId === peerId ? "answerer" : "offerer";
|
|
1176
|
-
const sinceTimestamp = since ? parseInt(since, 10) : void 0;
|
|
1177
|
-
const candidates = await storage.getIceCandidates(targetOfferId, targetRole, sinceTimestamp);
|
|
1178
|
-
return c.json({
|
|
1179
|
-
candidates: candidates.map((c2) => ({
|
|
1180
|
-
candidate: c2.candidate,
|
|
1181
|
-
createdAt: c2.createdAt
|
|
1182
|
-
})),
|
|
1183
|
-
offerId: targetOfferId
|
|
1184
|
-
}, 200);
|
|
1185
|
-
} catch (err2) {
|
|
1186
|
-
console.error("Error getting ICE candidates for service:", err2);
|
|
1187
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
1188
|
-
}
|
|
1178
|
+
app.all("*", (c) => {
|
|
1179
|
+
return c.json({
|
|
1180
|
+
error: "Not found. Use POST /rpc for all API calls."
|
|
1181
|
+
}, 404);
|
|
1189
1182
|
});
|
|
1190
1183
|
return app;
|
|
1191
1184
|
}
|
|
1192
1185
|
|
|
1193
1186
|
// src/config.ts
|
|
1194
1187
|
function loadConfig() {
|
|
1195
|
-
let authSecret = process.env.AUTH_SECRET;
|
|
1196
|
-
if (!authSecret) {
|
|
1197
|
-
authSecret = generateSecretKey();
|
|
1198
|
-
console.warn("WARNING: No AUTH_SECRET provided. Generated temporary secret:", authSecret);
|
|
1199
|
-
console.warn("All peer credentials will be invalidated on server restart.");
|
|
1200
|
-
console.warn("Set AUTH_SECRET environment variable to persist credentials across restarts.");
|
|
1201
|
-
}
|
|
1202
1188
|
return {
|
|
1203
1189
|
port: parseInt(process.env.PORT || "3000", 10),
|
|
1204
1190
|
storageType: process.env.STORAGE_TYPE || "sqlite",
|
|
1205
1191
|
storagePath: process.env.STORAGE_PATH || ":memory:",
|
|
1206
1192
|
corsOrigins: process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(",").map((o) => o.trim()) : ["*"],
|
|
1207
1193
|
version: process.env.VERSION || "unknown",
|
|
1208
|
-
authSecret,
|
|
1209
1194
|
offerDefaultTtl: parseInt(process.env.OFFER_DEFAULT_TTL || "60000", 10),
|
|
1210
1195
|
offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || "86400000", 10),
|
|
1211
1196
|
offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || "60000", 10),
|
|
@@ -1251,30 +1236,29 @@ var SQLiteStorage = class {
|
|
|
1251
1236
|
-- WebRTC signaling offers
|
|
1252
1237
|
CREATE TABLE IF NOT EXISTS offers (
|
|
1253
1238
|
id TEXT PRIMARY KEY,
|
|
1254
|
-
|
|
1239
|
+
username TEXT NOT NULL,
|
|
1255
1240
|
service_id TEXT,
|
|
1256
1241
|
sdp TEXT NOT NULL,
|
|
1257
1242
|
created_at INTEGER NOT NULL,
|
|
1258
1243
|
expires_at INTEGER NOT NULL,
|
|
1259
1244
|
last_seen INTEGER NOT NULL,
|
|
1260
|
-
|
|
1261
|
-
answerer_peer_id TEXT,
|
|
1245
|
+
answerer_username TEXT,
|
|
1262
1246
|
answer_sdp TEXT,
|
|
1263
1247
|
answered_at INTEGER,
|
|
1264
1248
|
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
|
1265
1249
|
);
|
|
1266
1250
|
|
|
1267
|
-
CREATE INDEX IF NOT EXISTS
|
|
1251
|
+
CREATE INDEX IF NOT EXISTS idx_offers_username ON offers(username);
|
|
1268
1252
|
CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id);
|
|
1269
1253
|
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
|
|
1270
1254
|
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
|
|
1271
|
-
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(
|
|
1255
|
+
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username);
|
|
1272
1256
|
|
|
1273
1257
|
-- ICE candidates table
|
|
1274
1258
|
CREATE TABLE IF NOT EXISTS ice_candidates (
|
|
1275
1259
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1276
1260
|
offer_id TEXT NOT NULL,
|
|
1277
|
-
|
|
1261
|
+
username TEXT NOT NULL,
|
|
1278
1262
|
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
|
|
1279
1263
|
candidate TEXT NOT NULL,
|
|
1280
1264
|
created_at INTEGER NOT NULL,
|
|
@@ -1282,7 +1266,7 @@ var SQLiteStorage = class {
|
|
|
1282
1266
|
);
|
|
1283
1267
|
|
|
1284
1268
|
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
|
|
1285
|
-
CREATE INDEX IF NOT EXISTS
|
|
1269
|
+
CREATE INDEX IF NOT EXISTS idx_ice_username ON ice_candidates(username);
|
|
1286
1270
|
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
|
|
1287
1271
|
|
|
1288
1272
|
-- Usernames table
|
|
@@ -1299,36 +1283,23 @@ var SQLiteStorage = class {
|
|
|
1299
1283
|
CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at);
|
|
1300
1284
|
CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key);
|
|
1301
1285
|
|
|
1302
|
-
-- Services table (
|
|
1286
|
+
-- Services table (new schema with extracted fields for discovery)
|
|
1303
1287
|
CREATE TABLE IF NOT EXISTS services (
|
|
1304
1288
|
id TEXT PRIMARY KEY,
|
|
1305
|
-
username TEXT NOT NULL,
|
|
1306
1289
|
service_fqn TEXT NOT NULL,
|
|
1290
|
+
service_name TEXT NOT NULL,
|
|
1291
|
+
version TEXT NOT NULL,
|
|
1292
|
+
username TEXT NOT NULL,
|
|
1307
1293
|
created_at INTEGER NOT NULL,
|
|
1308
1294
|
expires_at INTEGER NOT NULL,
|
|
1309
|
-
is_public INTEGER NOT NULL DEFAULT 0,
|
|
1310
|
-
metadata TEXT,
|
|
1311
1295
|
FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
|
|
1312
|
-
UNIQUE(
|
|
1296
|
+
UNIQUE(service_fqn)
|
|
1313
1297
|
);
|
|
1314
1298
|
|
|
1315
|
-
CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
|
|
1316
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);
|
|
1317
1302
|
CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
|
|
1318
|
-
|
|
1319
|
-
-- Service index table (privacy layer)
|
|
1320
|
-
CREATE TABLE IF NOT EXISTS service_index (
|
|
1321
|
-
uuid TEXT PRIMARY KEY,
|
|
1322
|
-
service_id TEXT NOT NULL,
|
|
1323
|
-
username TEXT NOT NULL,
|
|
1324
|
-
service_fqn TEXT NOT NULL,
|
|
1325
|
-
created_at INTEGER NOT NULL,
|
|
1326
|
-
expires_at INTEGER NOT NULL,
|
|
1327
|
-
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
|
1328
|
-
);
|
|
1329
|
-
|
|
1330
|
-
CREATE INDEX IF NOT EXISTS idx_service_index_username ON service_index(username);
|
|
1331
|
-
CREATE INDEX IF NOT EXISTS idx_service_index_expires ON service_index(expires_at);
|
|
1332
1303
|
`);
|
|
1333
1304
|
this.db.pragma("foreign_keys = ON");
|
|
1334
1305
|
}
|
|
@@ -1343,43 +1314,42 @@ var SQLiteStorage = class {
|
|
|
1343
1314
|
);
|
|
1344
1315
|
const transaction = this.db.transaction((offersWithIds2) => {
|
|
1345
1316
|
const offerStmt = this.db.prepare(`
|
|
1346
|
-
INSERT INTO offers (id,
|
|
1347
|
-
VALUES (?, ?, ?, ?, ?, ?,
|
|
1317
|
+
INSERT INTO offers (id, username, service_id, sdp, created_at, expires_at, last_seen)
|
|
1318
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1348
1319
|
`);
|
|
1349
1320
|
for (const offer of offersWithIds2) {
|
|
1350
1321
|
const now = Date.now();
|
|
1351
1322
|
offerStmt.run(
|
|
1352
1323
|
offer.id,
|
|
1353
|
-
offer.
|
|
1324
|
+
offer.username,
|
|
1354
1325
|
offer.serviceId || null,
|
|
1355
1326
|
offer.sdp,
|
|
1356
1327
|
now,
|
|
1357
1328
|
offer.expiresAt,
|
|
1358
|
-
now
|
|
1359
|
-
offer.secret || null
|
|
1329
|
+
now
|
|
1360
1330
|
);
|
|
1361
1331
|
created.push({
|
|
1362
1332
|
id: offer.id,
|
|
1363
|
-
|
|
1333
|
+
username: offer.username,
|
|
1364
1334
|
serviceId: offer.serviceId || void 0,
|
|
1335
|
+
serviceFqn: offer.serviceFqn,
|
|
1365
1336
|
sdp: offer.sdp,
|
|
1366
1337
|
createdAt: now,
|
|
1367
1338
|
expiresAt: offer.expiresAt,
|
|
1368
|
-
lastSeen: now
|
|
1369
|
-
secret: offer.secret
|
|
1339
|
+
lastSeen: now
|
|
1370
1340
|
});
|
|
1371
1341
|
}
|
|
1372
1342
|
});
|
|
1373
1343
|
transaction(offersWithIds);
|
|
1374
1344
|
return created;
|
|
1375
1345
|
}
|
|
1376
|
-
async
|
|
1346
|
+
async getOffersByUsername(username) {
|
|
1377
1347
|
const stmt = this.db.prepare(`
|
|
1378
1348
|
SELECT * FROM offers
|
|
1379
|
-
WHERE
|
|
1349
|
+
WHERE username = ? AND expires_at > ?
|
|
1380
1350
|
ORDER BY last_seen DESC
|
|
1381
1351
|
`);
|
|
1382
|
-
const rows = stmt.all(
|
|
1352
|
+
const rows = stmt.all(username, Date.now());
|
|
1383
1353
|
return rows.map((row) => this.rowToOffer(row));
|
|
1384
1354
|
}
|
|
1385
1355
|
async getOfferById(offerId) {
|
|
@@ -1393,12 +1363,12 @@ var SQLiteStorage = class {
|
|
|
1393
1363
|
}
|
|
1394
1364
|
return this.rowToOffer(row);
|
|
1395
1365
|
}
|
|
1396
|
-
async deleteOffer(offerId,
|
|
1366
|
+
async deleteOffer(offerId, ownerUsername) {
|
|
1397
1367
|
const stmt = this.db.prepare(`
|
|
1398
1368
|
DELETE FROM offers
|
|
1399
|
-
WHERE id = ? AND
|
|
1369
|
+
WHERE id = ? AND username = ?
|
|
1400
1370
|
`);
|
|
1401
|
-
const result = stmt.run(offerId,
|
|
1371
|
+
const result = stmt.run(offerId, ownerUsername);
|
|
1402
1372
|
return result.changes > 0;
|
|
1403
1373
|
}
|
|
1404
1374
|
async deleteExpiredOffers(now) {
|
|
@@ -1406,7 +1376,7 @@ var SQLiteStorage = class {
|
|
|
1406
1376
|
const result = stmt.run(now);
|
|
1407
1377
|
return result.changes;
|
|
1408
1378
|
}
|
|
1409
|
-
async answerOffer(offerId,
|
|
1379
|
+
async answerOffer(offerId, answererUsername, answerSdp) {
|
|
1410
1380
|
const offer = await this.getOfferById(offerId);
|
|
1411
1381
|
if (!offer) {
|
|
1412
1382
|
return {
|
|
@@ -1414,13 +1384,7 @@ var SQLiteStorage = class {
|
|
|
1414
1384
|
error: "Offer not found or expired"
|
|
1415
1385
|
};
|
|
1416
1386
|
}
|
|
1417
|
-
if (offer.
|
|
1418
|
-
return {
|
|
1419
|
-
success: false,
|
|
1420
|
-
error: "Invalid or missing secret"
|
|
1421
|
-
};
|
|
1422
|
-
}
|
|
1423
|
-
if (offer.answererPeerId) {
|
|
1387
|
+
if (offer.answererUsername) {
|
|
1424
1388
|
return {
|
|
1425
1389
|
success: false,
|
|
1426
1390
|
error: "Offer already answered"
|
|
@@ -1428,10 +1392,10 @@ var SQLiteStorage = class {
|
|
|
1428
1392
|
}
|
|
1429
1393
|
const stmt = this.db.prepare(`
|
|
1430
1394
|
UPDATE offers
|
|
1431
|
-
SET
|
|
1432
|
-
WHERE id = ? AND
|
|
1395
|
+
SET answerer_username = ?, answer_sdp = ?, answered_at = ?
|
|
1396
|
+
WHERE id = ? AND answerer_username IS NULL
|
|
1433
1397
|
`);
|
|
1434
|
-
const result = stmt.run(
|
|
1398
|
+
const result = stmt.run(answererUsername, answerSdp, Date.now(), offerId);
|
|
1435
1399
|
if (result.changes === 0) {
|
|
1436
1400
|
return {
|
|
1437
1401
|
success: false,
|
|
@@ -1440,19 +1404,19 @@ var SQLiteStorage = class {
|
|
|
1440
1404
|
}
|
|
1441
1405
|
return { success: true };
|
|
1442
1406
|
}
|
|
1443
|
-
async getAnsweredOffers(
|
|
1407
|
+
async getAnsweredOffers(offererUsername) {
|
|
1444
1408
|
const stmt = this.db.prepare(`
|
|
1445
1409
|
SELECT * FROM offers
|
|
1446
|
-
WHERE
|
|
1410
|
+
WHERE username = ? AND answerer_username IS NOT NULL AND expires_at > ?
|
|
1447
1411
|
ORDER BY answered_at DESC
|
|
1448
1412
|
`);
|
|
1449
|
-
const rows = stmt.all(
|
|
1413
|
+
const rows = stmt.all(offererUsername, Date.now());
|
|
1450
1414
|
return rows.map((row) => this.rowToOffer(row));
|
|
1451
1415
|
}
|
|
1452
1416
|
// ===== ICE Candidate Management =====
|
|
1453
|
-
async addIceCandidates(offerId,
|
|
1417
|
+
async addIceCandidates(offerId, username, role, candidates) {
|
|
1454
1418
|
const stmt = this.db.prepare(`
|
|
1455
|
-
INSERT INTO ice_candidates (offer_id,
|
|
1419
|
+
INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
|
|
1456
1420
|
VALUES (?, ?, ?, ?, ?)
|
|
1457
1421
|
`);
|
|
1458
1422
|
const baseTimestamp = Date.now();
|
|
@@ -1460,7 +1424,7 @@ var SQLiteStorage = class {
|
|
|
1460
1424
|
for (let i = 0; i < candidates2.length; i++) {
|
|
1461
1425
|
stmt.run(
|
|
1462
1426
|
offerId,
|
|
1463
|
-
|
|
1427
|
+
username,
|
|
1464
1428
|
role,
|
|
1465
1429
|
JSON.stringify(candidates2[i]),
|
|
1466
1430
|
baseTimestamp + i
|
|
@@ -1486,7 +1450,7 @@ var SQLiteStorage = class {
|
|
|
1486
1450
|
return rows.map((row) => ({
|
|
1487
1451
|
id: row.id,
|
|
1488
1452
|
offerId: row.offer_id,
|
|
1489
|
-
|
|
1453
|
+
username: row.username,
|
|
1490
1454
|
role: row.role,
|
|
1491
1455
|
candidate: JSON.parse(row.candidate),
|
|
1492
1456
|
createdAt: row.created_at
|
|
@@ -1562,63 +1526,74 @@ var SQLiteStorage = class {
|
|
|
1562
1526
|
// ===== Service Management =====
|
|
1563
1527
|
async createService(request) {
|
|
1564
1528
|
const serviceId = (0, import_node_crypto.randomUUID)();
|
|
1565
|
-
const indexUuid = (0, import_node_crypto.randomUUID)();
|
|
1566
1529
|
const now = Date.now();
|
|
1567
|
-
const
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
}
|
|
1571
|
-
|
|
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;
|
|
1572
1538
|
const transaction = this.db.transaction(() => {
|
|
1573
|
-
const
|
|
1574
|
-
|
|
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)
|
|
1575
1553
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1576
|
-
`)
|
|
1577
|
-
serviceStmt.run(
|
|
1554
|
+
`).run(
|
|
1578
1555
|
serviceId,
|
|
1579
|
-
request.username,
|
|
1580
|
-
request.serviceFqn,
|
|
1581
|
-
now,
|
|
1582
|
-
request.expiresAt,
|
|
1583
|
-
request.isPublic ? 1 : 0,
|
|
1584
|
-
request.metadata || null
|
|
1585
|
-
);
|
|
1586
|
-
const indexStmt = this.db.prepare(`
|
|
1587
|
-
INSERT INTO service_index (uuid, service_id, username, service_fqn, created_at, expires_at)
|
|
1588
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
1589
|
-
`);
|
|
1590
|
-
indexStmt.run(
|
|
1591
|
-
indexUuid,
|
|
1592
|
-
serviceId,
|
|
1593
|
-
request.username,
|
|
1594
1556
|
request.serviceFqn,
|
|
1557
|
+
serviceName,
|
|
1558
|
+
version,
|
|
1559
|
+
username,
|
|
1595
1560
|
now,
|
|
1596
1561
|
request.expiresAt
|
|
1597
1562
|
);
|
|
1598
|
-
|
|
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);
|
|
1599
1569
|
});
|
|
1600
1570
|
transaction();
|
|
1571
|
+
const offerRequests = request.offers.map((offer) => ({
|
|
1572
|
+
...offer,
|
|
1573
|
+
serviceId
|
|
1574
|
+
}));
|
|
1575
|
+
const offers = await this.createOffers(offerRequests);
|
|
1601
1576
|
return {
|
|
1602
1577
|
service: {
|
|
1603
1578
|
id: serviceId,
|
|
1604
|
-
username: request.username,
|
|
1605
1579
|
serviceFqn: request.serviceFqn,
|
|
1580
|
+
serviceName,
|
|
1581
|
+
version,
|
|
1582
|
+
username,
|
|
1606
1583
|
createdAt: now,
|
|
1607
|
-
expiresAt: request.expiresAt
|
|
1608
|
-
isPublic: request.isPublic || false,
|
|
1609
|
-
metadata: request.metadata
|
|
1584
|
+
expiresAt: request.expiresAt
|
|
1610
1585
|
},
|
|
1611
|
-
indexUuid,
|
|
1612
1586
|
offers
|
|
1613
1587
|
};
|
|
1614
1588
|
}
|
|
1615
|
-
async
|
|
1616
|
-
const
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
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));
|
|
1622
1597
|
}
|
|
1623
1598
|
async getServiceById(serviceId) {
|
|
1624
1599
|
const stmt = this.db.prepare(`
|
|
@@ -1631,51 +1606,49 @@ var SQLiteStorage = class {
|
|
|
1631
1606
|
}
|
|
1632
1607
|
return this.rowToService(row);
|
|
1633
1608
|
}
|
|
1634
|
-
async
|
|
1609
|
+
async getServiceByFqn(serviceFqn) {
|
|
1635
1610
|
const stmt = this.db.prepare(`
|
|
1636
|
-
SELECT
|
|
1637
|
-
|
|
1638
|
-
WHERE si.uuid = ? AND s.expires_at > ?
|
|
1611
|
+
SELECT * FROM services
|
|
1612
|
+
WHERE service_fqn = ? AND expires_at > ?
|
|
1639
1613
|
`);
|
|
1640
|
-
const row = stmt.get(
|
|
1614
|
+
const row = stmt.get(serviceFqn, Date.now());
|
|
1641
1615
|
if (!row) {
|
|
1642
1616
|
return null;
|
|
1643
1617
|
}
|
|
1644
1618
|
return this.rowToService(row);
|
|
1645
1619
|
}
|
|
1646
|
-
async
|
|
1620
|
+
async discoverServices(serviceName, version, limit, offset) {
|
|
1647
1621
|
const stmt = this.db.prepare(`
|
|
1648
|
-
SELECT
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
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 > ?
|
|
1652
1629
|
ORDER BY s.created_at DESC
|
|
1630
|
+
LIMIT ? OFFSET ?
|
|
1653
1631
|
`);
|
|
1654
|
-
const rows = stmt.all(
|
|
1655
|
-
return rows.map((row) => (
|
|
1656
|
-
uuid: row.uuid,
|
|
1657
|
-
isPublic: row.is_public === 1,
|
|
1658
|
-
serviceFqn: row.is_public === 1 ? row.service_fqn : void 0,
|
|
1659
|
-
metadata: row.is_public === 1 ? row.metadata || void 0 : void 0
|
|
1660
|
-
}));
|
|
1661
|
-
}
|
|
1662
|
-
async queryService(username, serviceFqn) {
|
|
1663
|
-
const stmt = this.db.prepare(`
|
|
1664
|
-
SELECT si.uuid FROM service_index si
|
|
1665
|
-
INNER JOIN services s ON si.service_id = s.id
|
|
1666
|
-
WHERE si.username = ? AND si.service_fqn = ? AND si.expires_at > ?
|
|
1667
|
-
`);
|
|
1668
|
-
const row = stmt.get(username, serviceFqn, Date.now());
|
|
1669
|
-
return row ? row.uuid : null;
|
|
1632
|
+
const rows = stmt.all(serviceName, version, Date.now(), Date.now(), limit, offset);
|
|
1633
|
+
return rows.map((row) => this.rowToService(row));
|
|
1670
1634
|
}
|
|
1671
|
-
async
|
|
1635
|
+
async getRandomService(serviceName, version) {
|
|
1672
1636
|
const stmt = this.db.prepare(`
|
|
1673
|
-
SELECT
|
|
1674
|
-
|
|
1675
|
-
|
|
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
|
|
1676
1646
|
`);
|
|
1677
|
-
const
|
|
1678
|
-
|
|
1647
|
+
const row = stmt.get(serviceName, version, Date.now(), Date.now());
|
|
1648
|
+
if (!row) {
|
|
1649
|
+
return null;
|
|
1650
|
+
}
|
|
1651
|
+
return this.rowToService(row);
|
|
1679
1652
|
}
|
|
1680
1653
|
async deleteService(serviceId, username) {
|
|
1681
1654
|
const stmt = this.db.prepare(`
|
|
@@ -1700,14 +1673,14 @@ var SQLiteStorage = class {
|
|
|
1700
1673
|
rowToOffer(row) {
|
|
1701
1674
|
return {
|
|
1702
1675
|
id: row.id,
|
|
1703
|
-
|
|
1676
|
+
username: row.username,
|
|
1704
1677
|
serviceId: row.service_id || void 0,
|
|
1678
|
+
serviceFqn: row.service_fqn || void 0,
|
|
1705
1679
|
sdp: row.sdp,
|
|
1706
1680
|
createdAt: row.created_at,
|
|
1707
1681
|
expiresAt: row.expires_at,
|
|
1708
1682
|
lastSeen: row.last_seen,
|
|
1709
|
-
|
|
1710
|
-
answererPeerId: row.answerer_peer_id || void 0,
|
|
1683
|
+
answererUsername: row.answerer_username || void 0,
|
|
1711
1684
|
answerSdp: row.answer_sdp || void 0,
|
|
1712
1685
|
answeredAt: row.answered_at || void 0
|
|
1713
1686
|
};
|
|
@@ -1718,26 +1691,14 @@ var SQLiteStorage = class {
|
|
|
1718
1691
|
rowToService(row) {
|
|
1719
1692
|
return {
|
|
1720
1693
|
id: row.id,
|
|
1721
|
-
username: row.username,
|
|
1722
1694
|
serviceFqn: row.service_fqn,
|
|
1695
|
+
serviceName: row.service_name,
|
|
1696
|
+
version: row.version,
|
|
1697
|
+
username: row.username,
|
|
1723
1698
|
createdAt: row.created_at,
|
|
1724
|
-
expiresAt: row.expires_at
|
|
1725
|
-
isPublic: row.is_public === 1,
|
|
1726
|
-
metadata: row.metadata || void 0
|
|
1699
|
+
expiresAt: row.expires_at
|
|
1727
1700
|
};
|
|
1728
1701
|
}
|
|
1729
|
-
/**
|
|
1730
|
-
* Get all offers for a service
|
|
1731
|
-
*/
|
|
1732
|
-
async getOffersForService(serviceId) {
|
|
1733
|
-
const stmt = this.db.prepare(`
|
|
1734
|
-
SELECT * FROM offers
|
|
1735
|
-
WHERE service_id = ? AND expires_at > ?
|
|
1736
|
-
ORDER BY created_at ASC
|
|
1737
|
-
`);
|
|
1738
|
-
const rows = stmt.all(serviceId, Date.now());
|
|
1739
|
-
return rows.map((row) => this.rowToOffer(row));
|
|
1740
|
-
}
|
|
1741
1702
|
};
|
|
1742
1703
|
|
|
1743
1704
|
// src/index.ts
|