pipane 0.1.6 → 0.1.8

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.
Files changed (58) hide show
  1. package/README.md +14 -3
  2. package/bin/pipane-rendezvous.js +21 -0
  3. package/bin/pipane.js +21 -1
  4. package/dist/build-info.json +5 -0
  5. package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
  6. package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
  7. package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
  8. package/dist/client/assets/index-C7_1ODks.js +2 -0
  9. package/dist/client/assets/index-DgI_gkjg.css +1 -0
  10. package/dist/client/assets/main-DEfSB8wO.js +2822 -0
  11. package/dist/client/assets/pairing-page-C0YByC2D.js +1 -0
  12. package/dist/client/assets/remote-backend-manager-DCSdS38m.js +1 -0
  13. package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
  14. package/dist/client/assets/webrtc-frame-transport-DGX5EO_A.js +1 -0
  15. package/dist/client/assets/ws-agent-adapter-DpkkZGPl.js +6 -0
  16. package/dist/client/index.html +2 -2
  17. package/dist/server/rendezvous/rendezvous-hub.js +426 -0
  18. package/dist/server/rendezvous/server.js +247 -0
  19. package/dist/server/rendezvous/trust-store.js +432 -0
  20. package/dist/server/server/auth-guard.js +61 -0
  21. package/dist/server/server/backend-connection-authorizer.js +29 -0
  22. package/dist/server/server/backend-identity.js +167 -0
  23. package/dist/server/server/backend-protocol-handler.js +132 -0
  24. package/dist/server/server/backend-trust-store.js +157 -0
  25. package/dist/server/server/backend-webrtc.js +245 -0
  26. package/dist/server/server/build-info.js +13 -0
  27. package/dist/server/server/conversation-file-access.js +97 -0
  28. package/dist/server/server/frame-connection.js +106 -0
  29. package/dist/server/server/frame-router.js +45 -0
  30. package/dist/server/server/ice-servers.js +24 -0
  31. package/dist/server/server/local-backend-api.js +389 -0
  32. package/dist/server/server/local-settings.js +17 -4
  33. package/dist/server/server/pi-rpc-protocol.js +407 -0
  34. package/dist/server/server/process-pool.js +27 -24
  35. package/dist/server/server/rendezvous-client.js +282 -0
  36. package/dist/server/server/rest-api.js +133 -176
  37. package/dist/server/server/server.js +167 -97
  38. package/dist/server/server/session-index.js +105 -28
  39. package/dist/server/server/session-jsonl.js +82 -5
  40. package/dist/server/server/session-path.js +145 -0
  41. package/dist/server/server/update-api.js +28 -0
  42. package/dist/server/server/update-check.js +33 -13
  43. package/dist/server/server/update-manager.js +233 -0
  44. package/dist/server/server/worktree-name.js +116 -31
  45. package/dist/server/server/ws-handler.js +267 -186
  46. package/dist/server/shared/backend-api.js +1 -0
  47. package/dist/server/shared/backend-protocol.js +164 -0
  48. package/dist/server/shared/data-channel-framing.js +137 -0
  49. package/dist/server/shared/node-trust-crypto.js +61 -0
  50. package/dist/server/shared/rendezvous-protocol.js +243 -0
  51. package/dist/server/shared/tool-runtime.js +1 -0
  52. package/dist/server/shared/trust-protocol.js +97 -0
  53. package/dist/server/shared/updates.js +4 -0
  54. package/dist/server/shared/ws-protocol.js +473 -1
  55. package/docs/protocol.md +129 -0
  56. package/package.json +21 -8
  57. package/dist/client/assets/index-Dl_wdLZH.css +0 -1
  58. package/dist/client/assets/index-hNqbnG06.js +0 -2482
@@ -0,0 +1 @@
1
+ export const MAX_UPLOAD_FILE_BYTES = 20 * 1024 * 1024;
@@ -0,0 +1,164 @@
1
+ /** Semantic hidden-backend request protocol carried beside application v1 frames. */
2
+ export const BACKEND_PROTOCOL_VERSION = 2;
3
+ export const BACKEND_PROTOCOL_MAX_FRAME_BYTES = 3 * 1024 * 1024;
4
+ export function encodeBackendFrame(frame) {
5
+ return JSON.stringify(frame);
6
+ }
7
+ export function decodeBackendRequest(raw) {
8
+ const envelope = parseEnvelope(raw);
9
+ if (!envelope.ok)
10
+ return envelope;
11
+ const value = envelope.value;
12
+ const requestId = typeof value.id === "string" ? value.id : null;
13
+ const method = typeof value.method === "string" ? value.method : "";
14
+ if (value.kind !== "request" || !isNonEmptyString(value.id) || !isNonEmptyString(value.method) || !isRecord(value.params)) {
15
+ return invalid("invalid_request", "Backend request envelope is malformed", requestId, method);
16
+ }
17
+ if (!isBackendMethod(value.method)) {
18
+ return invalid("unknown_method", `Unknown backend method: ${value.method}`, value.id, value.method);
19
+ }
20
+ if (!validateParams(value.method, value.params)) {
21
+ return invalid("invalid_request", `Invalid parameters for ${value.method}`, value.id, value.method);
22
+ }
23
+ return { ok: true, value: value };
24
+ }
25
+ export function decodeBackendResponse(raw) {
26
+ const envelope = parseEnvelope(raw);
27
+ if (!envelope.ok)
28
+ return envelope;
29
+ const value = envelope.value;
30
+ const requestId = typeof value.id === "string" ? value.id : null;
31
+ const method = typeof value.method === "string" ? value.method : "";
32
+ if (value.kind !== "response" || !isNonEmptyString(value.id) || !isNonEmptyString(value.method) || typeof value.success !== "boolean") {
33
+ return invalid("invalid_request", "Backend response envelope is malformed", requestId, method);
34
+ }
35
+ if (value.success) {
36
+ if (!("result" in value))
37
+ return invalid("invalid_request", "Successful response is missing result", value.id, value.method);
38
+ }
39
+ else if (!isApiError(value.error)) {
40
+ return invalid("invalid_request", "Failed response is missing a valid error", value.id, value.method);
41
+ }
42
+ return { ok: true, value: value };
43
+ }
44
+ export function isBackendProtocolFrame(raw) {
45
+ // `encodeBackendFrame` emits the version first. Keep v1 streaming frames on
46
+ // their hot path without parsing large snapshots twice.
47
+ const prefix = raw.slice(0, 128);
48
+ return /"v"\s*:\s*2(?:\s*[,}])/u.test(prefix)
49
+ && /"kind"\s*:\s*"(?:request|response|event)"/u.test(prefix);
50
+ }
51
+ function parseEnvelope(raw) {
52
+ if (new TextEncoder().encode(raw).byteLength > BACKEND_PROTOCOL_MAX_FRAME_BYTES) {
53
+ return invalid("invalid_request", "Backend frame exceeds the size limit", null, "");
54
+ }
55
+ let value;
56
+ try {
57
+ value = JSON.parse(raw);
58
+ }
59
+ catch {
60
+ return invalid("invalid_json", "Backend frame is not valid JSON", null, "");
61
+ }
62
+ if (!isRecord(value))
63
+ return invalid("invalid_request", "Backend frame must be an object", null, "");
64
+ if (value.v !== BACKEND_PROTOCOL_VERSION) {
65
+ return invalid("unsupported_version", `Unsupported backend protocol version: ${String(value.v)}`, null, "");
66
+ }
67
+ return { ok: true, value };
68
+ }
69
+ function validateParams(method, params) {
70
+ switch (method) {
71
+ case "backend.capabilities":
72
+ case "sessions.list":
73
+ case "settings.get":
74
+ case "updates.get":
75
+ return hasOnlyKeys(params, []);
76
+ case "sessions.delete":
77
+ case "sessions.forkMessages":
78
+ case "sessions.raw":
79
+ return hasOnlyKeys(params, ["sessionPath"]) && isNonEmptyString(params.sessionPath);
80
+ case "files.read":
81
+ return hasOnlyKeys(params, ["sessionPath", "path"])
82
+ && isNonEmptyString(params.sessionPath)
83
+ && isNonEmptyString(params.path);
84
+ case "files.upload.create":
85
+ return hasOnlyKeys(params, ["fileName", "mimeType", "size"])
86
+ && isNonEmptyString(params.fileName)
87
+ && isNonEmptyString(params.mimeType)
88
+ && Number.isSafeInteger(params.size)
89
+ && params.size >= 0;
90
+ case "files.upload.append":
91
+ return hasOnlyKeys(params, ["uploadId", "offset", "data"])
92
+ && isNonEmptyString(params.uploadId)
93
+ && Number.isSafeInteger(params.offset)
94
+ && params.offset >= 0
95
+ && isNonEmptyString(params.data);
96
+ case "files.upload.complete":
97
+ return hasOnlyKeys(params, ["uploadId"]) && isNonEmptyString(params.uploadId);
98
+ case "host.browse":
99
+ return hasOnlyKeys(params, ["path"]) && typeof params.path === "string";
100
+ case "host.mkdir":
101
+ return hasOnlyKeys(params, ["parentPath", "name"])
102
+ && isNonEmptyString(params.parentPath)
103
+ && isNonEmptyString(params.name);
104
+ case "settings.validate":
105
+ case "settings.save":
106
+ return hasOnlyKeys(params, ["content"]) && typeof params.content === "string";
107
+ case "settings.patch":
108
+ return hasOnlyKeys(params, ["patch"]) && isRecord(params.patch);
109
+ case "updates.run":
110
+ return hasOnlyKeys(params, ["target"])
111
+ && (params.target === "pipane" || params.target === "pi" || params.target === "extensions");
112
+ }
113
+ }
114
+ function isBackendMethod(value) {
115
+ return [
116
+ "backend.capabilities",
117
+ "sessions.list",
118
+ "sessions.delete",
119
+ "sessions.forkMessages",
120
+ "sessions.raw",
121
+ "files.read",
122
+ "files.upload.create",
123
+ "files.upload.append",
124
+ "files.upload.complete",
125
+ "host.browse",
126
+ "host.mkdir",
127
+ "settings.get",
128
+ "settings.validate",
129
+ "settings.patch",
130
+ "settings.save",
131
+ "updates.get",
132
+ "updates.run",
133
+ ].includes(value);
134
+ }
135
+ function isApiError(value) {
136
+ return isRecord(value)
137
+ && isBackendErrorCode(value.code)
138
+ && isNonEmptyString(value.message);
139
+ }
140
+ function isBackendErrorCode(value) {
141
+ return typeof value === "string" && [
142
+ "invalid_json",
143
+ "invalid_request",
144
+ "unsupported_version",
145
+ "unknown_method",
146
+ "not_found",
147
+ "forbidden",
148
+ "conflict",
149
+ "internal_error",
150
+ ].includes(value);
151
+ }
152
+ function invalid(code, message, requestId, method) {
153
+ return { ok: false, error: { code, message }, requestId, method };
154
+ }
155
+ function hasOnlyKeys(value, keys) {
156
+ const actual = Object.keys(value);
157
+ return actual.length === keys.length && actual.every((key) => keys.includes(key));
158
+ }
159
+ function isRecord(value) {
160
+ return typeof value === "object" && value !== null && !Array.isArray(value);
161
+ }
162
+ function isNonEmptyString(value) {
163
+ return typeof value === "string" && value.length > 0;
164
+ }
@@ -0,0 +1,137 @@
1
+ const CHUNK_MARKER = 1;
2
+ const CHUNK_PREFIX = `{"__pipaneDataChannelChunk":${CHUNK_MARKER},`;
3
+ /** Keep physical messages below conservative browser/libdatachannel SCTP limits. */
4
+ export const DATA_CHANNEL_CHUNK_PAYLOAD_BYTES = 12_000;
5
+ export const MAX_DATA_CHANNEL_MESSAGE_BYTES = 16 * 1024;
6
+ export const MAX_DATA_CHANNEL_FRAME_BYTES = 64 * 1024 * 1024;
7
+ export const MAX_DATA_CHANNEL_PENDING_FRAMES = 4;
8
+ export const MAX_DATA_CHANNEL_QUEUED_BYTES = 96 * 1024 * 1024;
9
+ export const DATA_CHANNEL_BUFFER_HIGH_WATER_BYTES = 1024 * 1024;
10
+ export const DATA_CHANNEL_BUFFER_LOW_WATER_BYTES = 256 * 1024;
11
+ const textEncoder = new TextEncoder();
12
+ const fatalTextDecoder = new TextDecoder("utf-8", { fatal: true });
13
+ const MAX_CHUNKS_PER_FRAME = Math.ceil(MAX_DATA_CHANNEL_FRAME_BYTES / DATA_CHANNEL_CHUNK_PAYLOAD_BYTES);
14
+ const CHUNK_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/u;
15
+ const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
16
+ /** Encode one logical text frame into conservatively sized DataChannel messages. */
17
+ export function encodeDataChannelFrame(frame, id) {
18
+ if (!CHUNK_ID_PATTERN.test(id))
19
+ throw new Error("DataChannel frame id is invalid");
20
+ const bytes = textEncoder.encode(frame);
21
+ if (bytes.byteLength > MAX_DATA_CHANNEL_FRAME_BYTES)
22
+ throw new Error("DataChannel frame exceeds the reassembly limit");
23
+ if (bytes.byteLength <= DATA_CHANNEL_CHUNK_PAYLOAD_BYTES)
24
+ return [frame];
25
+ const total = Math.ceil(bytes.byteLength / DATA_CHANNEL_CHUNK_PAYLOAD_BYTES);
26
+ const messages = [];
27
+ for (let index = 0; index < total; index++) {
28
+ const start = index * DATA_CHANNEL_CHUNK_PAYLOAD_BYTES;
29
+ const chunk = {
30
+ __pipaneDataChannelChunk: CHUNK_MARKER,
31
+ id,
32
+ index,
33
+ total,
34
+ data: encodeBase64(bytes.subarray(start, start + DATA_CHANNEL_CHUNK_PAYLOAD_BYTES)),
35
+ };
36
+ const message = JSON.stringify(chunk);
37
+ if (textEncoder.encode(message).byteLength > MAX_DATA_CHANNEL_MESSAGE_BYTES) {
38
+ throw new Error("Encoded DataChannel chunk exceeds the physical message limit");
39
+ }
40
+ messages.push(message);
41
+ }
42
+ return messages;
43
+ }
44
+ /** Reassemble carrier chunks while passing ordinary application frames through unchanged. */
45
+ export class DataChannelFrameDecoder {
46
+ pending = new Map();
47
+ accept(message) {
48
+ if (!message.startsWith(CHUNK_PREFIX))
49
+ return message;
50
+ try {
51
+ const chunk = parseChunk(message);
52
+ let pending = this.pending.get(chunk.id);
53
+ if (!pending) {
54
+ if (chunk.index !== 0)
55
+ throw new Error("DataChannel chunk sequence does not start at zero");
56
+ if (this.pending.size >= MAX_DATA_CHANNEL_PENDING_FRAMES)
57
+ throw new Error("Too many DataChannel frames are pending");
58
+ pending = { total: chunk.total, nextIndex: 0, byteLength: 0, chunks: [] };
59
+ this.pending.set(chunk.id, pending);
60
+ }
61
+ if (pending.total !== chunk.total || chunk.index !== pending.nextIndex) {
62
+ throw new Error("DataChannel chunks are inconsistent or out of order");
63
+ }
64
+ const bytes = decodeBase64(chunk.data);
65
+ if (bytes.byteLength === 0 || bytes.byteLength > DATA_CHANNEL_CHUNK_PAYLOAD_BYTES) {
66
+ throw new Error("DataChannel chunk payload size is invalid");
67
+ }
68
+ pending.byteLength += bytes.byteLength;
69
+ if (pending.byteLength > MAX_DATA_CHANNEL_FRAME_BYTES)
70
+ throw new Error("DataChannel frame exceeds the reassembly limit");
71
+ pending.chunks.push(bytes);
72
+ pending.nextIndex++;
73
+ if (pending.nextIndex < pending.total)
74
+ return undefined;
75
+ this.pending.delete(chunk.id);
76
+ const assembled = new Uint8Array(pending.byteLength);
77
+ let offset = 0;
78
+ for (const part of pending.chunks) {
79
+ assembled.set(part, offset);
80
+ offset += part.byteLength;
81
+ }
82
+ return fatalTextDecoder.decode(assembled);
83
+ }
84
+ catch (error) {
85
+ this.pending.clear();
86
+ throw error;
87
+ }
88
+ }
89
+ reset() {
90
+ this.pending.clear();
91
+ }
92
+ }
93
+ function parseChunk(message) {
94
+ let value;
95
+ try {
96
+ value = JSON.parse(message);
97
+ }
98
+ catch {
99
+ throw new Error("DataChannel chunk is not valid JSON");
100
+ }
101
+ if (!value || typeof value !== "object" || Array.isArray(value))
102
+ throw new Error("DataChannel chunk must be an object");
103
+ const chunk = value;
104
+ if (chunk.__pipaneDataChannelChunk !== CHUNK_MARKER
105
+ || typeof chunk.id !== "string" || !CHUNK_ID_PATTERN.test(chunk.id)
106
+ || !Number.isSafeInteger(chunk.index) || chunk.index < 0
107
+ || !Number.isSafeInteger(chunk.total) || chunk.total < 2 || chunk.total > MAX_CHUNKS_PER_FRAME
108
+ || chunk.index >= chunk.total
109
+ || typeof chunk.data !== "string") {
110
+ throw new Error("DataChannel chunk envelope is invalid");
111
+ }
112
+ return chunk;
113
+ }
114
+ function encodeBase64(bytes) {
115
+ let binary = "";
116
+ for (let offset = 0; offset < bytes.byteLength; offset += 4096) {
117
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 4096));
118
+ }
119
+ return btoa(binary);
120
+ }
121
+ function decodeBase64(value) {
122
+ if (!BASE64_PATTERN.test(value))
123
+ throw new Error("DataChannel chunk payload is not valid base64");
124
+ let binary;
125
+ try {
126
+ binary = atob(value);
127
+ }
128
+ catch {
129
+ throw new Error("DataChannel chunk payload is not valid base64");
130
+ }
131
+ const bytes = new Uint8Array(binary.length);
132
+ for (let index = 0; index < binary.length; index++)
133
+ bytes[index] = binary.charCodeAt(index);
134
+ if (encodeBase64(bytes) !== value)
135
+ throw new Error("DataChannel chunk payload is not canonical base64");
136
+ return bytes;
137
+ }
@@ -0,0 +1,61 @@
1
+ import { createHash, createPublicKey, verify } from "node:crypto";
2
+ import { connectionTicketSignaturePayload, parseConnectionTicketClaims, } from "./trust-protocol.js";
3
+ const DEVICE_ID_PREFIX = "d_";
4
+ export function deriveDeviceId(publicKey) {
5
+ const bytes = decodeP256PublicKey(publicKey);
6
+ return `${DEVICE_ID_PREFIX}${createHash("sha256").update(bytes).digest("base64url")}`;
7
+ }
8
+ export function verifyDeviceSignature(publicKey, payload, signature) {
9
+ try {
10
+ const key = createPublicKey({ key: decodeP256PublicKey(publicKey), format: "der", type: "spki" });
11
+ return verify("sha256", Buffer.from(payload), { key, dsaEncoding: "ieee-p1363" }, Buffer.from(signature, "base64url"));
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ export function verifyConnectionTicket(ticket, publicKey, now = Date.now()) {
18
+ const parts = ticket.split(".");
19
+ if (parts.length !== 2 || !parts[0] || !parts[1])
20
+ throw new Error("Malformed connection ticket");
21
+ const [encodedClaims, encodedSignature] = parts;
22
+ let claims;
23
+ try {
24
+ const bytes = Buffer.from(encodedClaims, "base64url");
25
+ if (bytes.toString("base64url") !== encodedClaims)
26
+ throw new Error("non-canonical payload");
27
+ claims = JSON.parse(bytes.toString("utf8"));
28
+ }
29
+ catch {
30
+ throw new Error("Malformed connection ticket claims");
31
+ }
32
+ const parsed = parseConnectionTicketClaims(claims);
33
+ if (!parsed)
34
+ throw new Error("Invalid connection ticket claims");
35
+ if (!verifyDeviceSignature(publicKey, connectionTicketSignaturePayload(encodedClaims), encodedSignature)) {
36
+ throw new Error("Invalid connection ticket signature");
37
+ }
38
+ if (parsed.expiresAt <= now)
39
+ throw new Error("Connection ticket expired");
40
+ if (parsed.issuedAt > now + 30_000)
41
+ throw new Error("Connection ticket was issued in the future");
42
+ return parsed;
43
+ }
44
+ export function decodeP256PublicKey(publicKey) {
45
+ if (!/^[A-Za-z0-9_-]+$/.test(publicKey))
46
+ throw new Error("Invalid public key encoding");
47
+ const bytes = Buffer.from(publicKey, "base64url");
48
+ if (bytes.length === 0 || bytes.toString("base64url") !== publicKey)
49
+ throw new Error("Invalid public key encoding");
50
+ const key = createPublicKey({ key: bytes, format: "der", type: "spki" });
51
+ assertP256Key(key);
52
+ const canonical = Buffer.from(key.export({ type: "spki", format: "der" }));
53
+ if (!canonical.equals(bytes))
54
+ throw new Error("Public key is not canonical");
55
+ return bytes;
56
+ }
57
+ export function assertP256Key(key) {
58
+ if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") {
59
+ throw new Error("Identity must use P-256");
60
+ }
61
+ }
@@ -0,0 +1,243 @@
1
+ export const RENDEZVOUS_PROTOCOL_VERSION = 2;
2
+ export function rendezvousWebSocketUrl(baseUrl, role) {
3
+ const url = new URL(baseUrl);
4
+ if (url.protocol === "http:")
5
+ url.protocol = "ws:";
6
+ else if (url.protocol === "https:")
7
+ url.protocol = "wss:";
8
+ else if (url.protocol !== "ws:" && url.protocol !== "wss:") {
9
+ throw new Error(`Unsupported rendezvous URL protocol: ${url.protocol}`);
10
+ }
11
+ url.pathname = `/v${RENDEZVOUS_PROTOCOL_VERSION}/rendezvous/${role}`;
12
+ url.search = "";
13
+ url.hash = "";
14
+ return url.toString();
15
+ }
16
+ export function backendRegistrationPayload(nonce) {
17
+ return `pipane-rendezvous-v${RENDEZVOUS_PROTOCOL_VERSION}\n${nonce}`;
18
+ }
19
+ export function encodeRendezvousMessage(message) {
20
+ return JSON.stringify(message);
21
+ }
22
+ export function decodeBackendCommand(raw) {
23
+ return decodeCommand(raw, "backend");
24
+ }
25
+ export function decodeBrowserCommand(raw) {
26
+ return decodeCommand(raw, "browser");
27
+ }
28
+ export function decodeBackendMessage(raw) {
29
+ return decodeServerMessage(raw, "backend");
30
+ }
31
+ export function decodeBrowserMessage(raw) {
32
+ return decodeServerMessage(raw, "browser");
33
+ }
34
+ function decodeCommand(raw, role) {
35
+ const envelope = parseEnvelope(raw);
36
+ if (!envelope.ok)
37
+ return envelope;
38
+ const value = envelope.value;
39
+ switch (value.type) {
40
+ case "register_backend":
41
+ if (role !== "backend")
42
+ return unknownMessage(value.type);
43
+ if (!isString(value.publicKey) || !isString(value.signature) || !isBackendMetadata(value.metadata)) {
44
+ return invalidMessage("register_backend requires publicKey, signature, and valid metadata");
45
+ }
46
+ break;
47
+ case "open_pairing":
48
+ if (role !== "backend")
49
+ return unknownMessage(value.type);
50
+ if (!isString(value.pairId) || !isPositiveInteger(value.expiresAt)) {
51
+ return invalidMessage("open_pairing requires pairId and expiresAt");
52
+ }
53
+ break;
54
+ case "confirm_pairing":
55
+ if (role !== "backend")
56
+ return unknownMessage(value.type);
57
+ if (!isString(value.connectionId))
58
+ return invalidMessage("confirm_pairing requires connectionId");
59
+ break;
60
+ case "connect_backend":
61
+ if (role !== "browser")
62
+ return unknownMessage(value.type);
63
+ if (!isString(value.backendId) || !isString(value.ticket)) {
64
+ return invalidMessage("connect_backend requires backendId and ticket");
65
+ }
66
+ break;
67
+ case "signal":
68
+ if (!isString(value.connectionId) || !isIceSignal(value.signal)) {
69
+ return invalidMessage("signal requires connectionId and a valid ICE signal");
70
+ }
71
+ break;
72
+ case "connection_binding":
73
+ if (role !== "backend")
74
+ return unknownMessage(value.type);
75
+ if (!isString(value.connectionId) || !isBackendIdentityBinding(value.binding)) {
76
+ return invalidMessage("connection_binding requires connectionId and a valid binding");
77
+ }
78
+ break;
79
+ case "close_connection":
80
+ if (!isString(value.connectionId) || !isOptionalString(value.reason)) {
81
+ return invalidMessage("close_connection requires connectionId and an optional reason");
82
+ }
83
+ break;
84
+ default:
85
+ return unknownMessage(value.type);
86
+ }
87
+ return { ok: true, value: value };
88
+ }
89
+ function decodeServerMessage(raw, role) {
90
+ const envelope = parseEnvelope(raw);
91
+ if (!envelope.ok)
92
+ return envelope;
93
+ const value = envelope.value;
94
+ switch (value.type) {
95
+ case "challenge":
96
+ if (role !== "backend" || !isString(value.nonce))
97
+ return invalidMessage("invalid backend challenge");
98
+ break;
99
+ case "registered":
100
+ if (role !== "backend" || !isString(value.backendId) || !isString(value.ticketPublicKey) || !isIceServers(value.iceServers)) {
101
+ return invalidMessage("invalid backend registration");
102
+ }
103
+ break;
104
+ case "pairing_opened":
105
+ if (role !== "backend" || !isString(value.pairId) || !isPositiveInteger(value.expiresAt)) {
106
+ return invalidMessage("invalid pairing acknowledgement");
107
+ }
108
+ break;
109
+ case "pairing_confirmed":
110
+ if (role !== "backend"
111
+ || !isString(value.connectionId)
112
+ || !isString(value.pairId)
113
+ || !isString(value.accountId)
114
+ || !isString(value.deviceId)) {
115
+ return invalidMessage("invalid pairing confirmation");
116
+ }
117
+ break;
118
+ case "connection_request":
119
+ if (role !== "backend" || !isString(value.connectionId) || !isString(value.ticket) || !isIceServers(value.iceServers)) {
120
+ return invalidMessage("invalid connection request");
121
+ }
122
+ break;
123
+ case "backend_connected":
124
+ if (role !== "browser" || !isString(value.backendId) || !isString(value.connectionId)) {
125
+ return invalidMessage("invalid browser connection acknowledgement");
126
+ }
127
+ break;
128
+ case "signal":
129
+ if (!isString(value.connectionId) || !isIceSignal(value.signal))
130
+ return invalidMessage("invalid ICE signal");
131
+ break;
132
+ case "connection_binding":
133
+ if (role !== "browser" || !isString(value.connectionId) || !isBackendIdentityBinding(value.binding)) {
134
+ return invalidMessage("invalid backend identity binding");
135
+ }
136
+ break;
137
+ case "connection_closed":
138
+ if (!isString(value.connectionId) || !isString(value.reason))
139
+ return invalidMessage("invalid connection closure");
140
+ break;
141
+ case "authorization_revoked":
142
+ if (role !== "backend" || !isString(value.accountId) || !isOptionalString(value.deviceId)) {
143
+ return invalidMessage("invalid authorization revocation");
144
+ }
145
+ break;
146
+ case "error":
147
+ if (!isErrorCode(value.code) || !isString(value.message) || !isOptionalString(value.connectionId)) {
148
+ return invalidMessage("invalid rendezvous error");
149
+ }
150
+ break;
151
+ default:
152
+ return unknownMessage(value.type);
153
+ }
154
+ return { ok: true, value: value };
155
+ }
156
+ function parseEnvelope(raw) {
157
+ let value;
158
+ try {
159
+ value = JSON.parse(raw);
160
+ }
161
+ catch {
162
+ return { ok: false, error: { code: "invalid_json", message: "Message is not valid JSON" } };
163
+ }
164
+ if (!isRecord(value) || !isString(value.type))
165
+ return invalidMessage("Message must be an object with a type");
166
+ if (value.protocolVersion !== RENDEZVOUS_PROTOCOL_VERSION) {
167
+ return {
168
+ ok: false,
169
+ error: {
170
+ code: "unsupported_version",
171
+ message: `Unsupported rendezvous protocol version: ${String(value.protocolVersion)}`,
172
+ },
173
+ };
174
+ }
175
+ return { ok: true, value };
176
+ }
177
+ function isBackendMetadata(value) {
178
+ return isRecord(value)
179
+ && isOptionalString(value.name)
180
+ && isString(value.softwareVersion)
181
+ && Array.isArray(value.protocolVersions)
182
+ && value.protocolVersions.length > 0
183
+ && value.protocolVersions.every((version) => Number.isSafeInteger(version) && version > 0);
184
+ }
185
+ function isIceSignal(value) {
186
+ if (!isRecord(value) || !isString(value.kind))
187
+ return false;
188
+ if (value.kind === "description") {
189
+ return (value.type === "offer" || value.type === "answer") && isString(value.sdp);
190
+ }
191
+ if (value.kind === "candidate") {
192
+ return isString(value.candidate)
193
+ && (value.sdpMid === null || isString(value.sdpMid))
194
+ && (value.sdpMLineIndex === null || (Number.isSafeInteger(value.sdpMLineIndex) && value.sdpMLineIndex >= 0));
195
+ }
196
+ return false;
197
+ }
198
+ function isBackendIdentityBinding(value) {
199
+ return isRecord(value)
200
+ && value.version === 1
201
+ && isString(value.backendId)
202
+ && isString(value.publicKey)
203
+ && isString(value.connectionId)
204
+ && isString(value.offerSha256)
205
+ && isString(value.answerSha256)
206
+ && isString(value.dtlsFingerprint)
207
+ && isPositiveInteger(value.expiresAt)
208
+ && isString(value.signature);
209
+ }
210
+ function isIceServers(value) {
211
+ return Array.isArray(value) && value.every((server) => isRecord(server)
212
+ && (isString(server.urls) || (Array.isArray(server.urls) && server.urls.length > 0 && server.urls.every(isString)))
213
+ && isOptionalString(server.username)
214
+ && isOptionalString(server.credential));
215
+ }
216
+ function isErrorCode(value) {
217
+ return value === "invalid_json"
218
+ || value === "invalid_message"
219
+ || value === "unsupported_version"
220
+ || value === "unknown_message"
221
+ || value === "backend_offline"
222
+ || value === "unauthorized_connection"
223
+ || value === "invalid_ticket"
224
+ || value === "invalid_pairing";
225
+ }
226
+ function invalidMessage(message) {
227
+ return { ok: false, error: { code: "invalid_message", message } };
228
+ }
229
+ function unknownMessage(type) {
230
+ return { ok: false, error: { code: "unknown_message", message: `Unknown rendezvous message: ${String(type)}` } };
231
+ }
232
+ function isRecord(value) {
233
+ return !!value && typeof value === "object" && !Array.isArray(value);
234
+ }
235
+ function isString(value) {
236
+ return typeof value === "string" && value.length > 0;
237
+ }
238
+ function isOptionalString(value) {
239
+ return value === undefined || isString(value);
240
+ }
241
+ function isPositiveInteger(value) {
242
+ return Number.isSafeInteger(value) && value > 0;
243
+ }
@@ -0,0 +1 @@
1
+ export {};