pal-explorer-cli 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +18 -0
- package/README.md +314 -0
- package/bin/pal.js +230 -0
- package/extensions/@palexplorer/analytics/README.md +45 -0
- package/extensions/@palexplorer/analytics/docs/MONETIZATION.md +14 -0
- package/extensions/@palexplorer/analytics/docs/PLAN.md +23 -0
- package/extensions/@palexplorer/analytics/docs/PRIVACY.md +38 -0
- package/extensions/@palexplorer/analytics/extension.json +27 -0
- package/extensions/@palexplorer/analytics/index.js +186 -0
- package/extensions/@palexplorer/analytics/test/analytics.test.js +82 -0
- package/extensions/@palexplorer/audit/extension.json +17 -0
- package/extensions/@palexplorer/audit/index.js +2 -0
- package/extensions/@palexplorer/auth-email/extension.json +17 -0
- package/extensions/@palexplorer/auth-email/index.js +102 -0
- package/extensions/@palexplorer/auth-oauth/extension.json +16 -0
- package/extensions/@palexplorer/auth-oauth/index.js +199 -0
- package/extensions/@palexplorer/chat/extension.json +17 -0
- package/extensions/@palexplorer/chat/index.js +2 -0
- package/extensions/@palexplorer/discovery/extension.json +16 -0
- package/extensions/@palexplorer/discovery/index.js +111 -0
- package/extensions/@palexplorer/email-notifications/extension.json +23 -0
- package/extensions/@palexplorer/email-notifications/index.js +242 -0
- package/extensions/@palexplorer/explorer-integration/extension.json +13 -0
- package/extensions/@palexplorer/explorer-integration/index.js +122 -0
- package/extensions/@palexplorer/groups/extension.json +17 -0
- package/extensions/@palexplorer/groups/index.js +2 -0
- package/extensions/@palexplorer/networks/extension.json +17 -0
- package/extensions/@palexplorer/networks/index.js +2 -0
- package/extensions/@palexplorer/share-links/extension.json +17 -0
- package/extensions/@palexplorer/share-links/index.js +2 -0
- package/extensions/@palexplorer/sync/extension.json +17 -0
- package/extensions/@palexplorer/sync/index.js +2 -0
- package/extensions/@palexplorer/user-mgmt/extension.json +17 -0
- package/extensions/@palexplorer/user-mgmt/index.js +2 -0
- package/extensions/@palexplorer/vfs/extension.json +17 -0
- package/extensions/@palexplorer/vfs/index.js +167 -0
- package/lib/capabilities.js +263 -0
- package/lib/commands/analytics.js +175 -0
- package/lib/commands/api-keys.js +131 -0
- package/lib/commands/audit.js +235 -0
- package/lib/commands/auth.js +137 -0
- package/lib/commands/backup.js +76 -0
- package/lib/commands/billing.js +148 -0
- package/lib/commands/chat.js +217 -0
- package/lib/commands/cloud-backup.js +231 -0
- package/lib/commands/comment.js +99 -0
- package/lib/commands/completion.js +203 -0
- package/lib/commands/compliance.js +218 -0
- package/lib/commands/config.js +136 -0
- package/lib/commands/connect.js +44 -0
- package/lib/commands/dept.js +294 -0
- package/lib/commands/device.js +146 -0
- package/lib/commands/download.js +226 -0
- package/lib/commands/explorer.js +178 -0
- package/lib/commands/extension.js +970 -0
- package/lib/commands/favorite.js +90 -0
- package/lib/commands/federation.js +270 -0
- package/lib/commands/file.js +533 -0
- package/lib/commands/group.js +271 -0
- package/lib/commands/gui-share.js +29 -0
- package/lib/commands/init.js +61 -0
- package/lib/commands/invite.js +59 -0
- package/lib/commands/list.js +59 -0
- package/lib/commands/log.js +116 -0
- package/lib/commands/nearby.js +108 -0
- package/lib/commands/network.js +251 -0
- package/lib/commands/notify.js +198 -0
- package/lib/commands/org.js +273 -0
- package/lib/commands/pal.js +180 -0
- package/lib/commands/permissions.js +216 -0
- package/lib/commands/pin.js +97 -0
- package/lib/commands/protocol.js +357 -0
- package/lib/commands/rbac.js +147 -0
- package/lib/commands/recover.js +36 -0
- package/lib/commands/register.js +171 -0
- package/lib/commands/relay.js +131 -0
- package/lib/commands/remote.js +368 -0
- package/lib/commands/revoke.js +50 -0
- package/lib/commands/scanner.js +280 -0
- package/lib/commands/schedule.js +344 -0
- package/lib/commands/scim.js +203 -0
- package/lib/commands/search.js +181 -0
- package/lib/commands/serve.js +438 -0
- package/lib/commands/server.js +350 -0
- package/lib/commands/share-link.js +199 -0
- package/lib/commands/share.js +323 -0
- package/lib/commands/sso.js +200 -0
- package/lib/commands/status.js +136 -0
- package/lib/commands/stream.js +562 -0
- package/lib/commands/su.js +187 -0
- package/lib/commands/sync.js +827 -0
- package/lib/commands/transfers.js +152 -0
- package/lib/commands/uninstall.js +188 -0
- package/lib/commands/update.js +204 -0
- package/lib/commands/user.js +276 -0
- package/lib/commands/vfs.js +84 -0
- package/lib/commands/web.js +52 -0
- package/lib/commands/webhook.js +180 -0
- package/lib/commands/whoami.js +59 -0
- package/lib/commands/workspace.js +121 -0
- package/lib/core/accessLog.js +54 -0
- package/lib/core/analytics.js +99 -0
- package/lib/core/backup.js +84 -0
- package/lib/core/billing.js +336 -0
- package/lib/core/bitfieldStore.js +53 -0
- package/lib/core/connectionManager.js +182 -0
- package/lib/core/dhtDiscovery.js +148 -0
- package/lib/core/discoveryClient.js +408 -0
- package/lib/core/extensionAnalyzer.js +357 -0
- package/lib/core/extensionSandbox.js +250 -0
- package/lib/core/extensionWorkerHost.js +166 -0
- package/lib/core/extensions.js +1082 -0
- package/lib/core/fileDiff.js +69 -0
- package/lib/core/groups.js +119 -0
- package/lib/core/identity.js +340 -0
- package/lib/core/mdnsService.js +126 -0
- package/lib/core/networks.js +81 -0
- package/lib/core/permissions.js +109 -0
- package/lib/core/pro.js +27 -0
- package/lib/core/resolver.js +74 -0
- package/lib/core/serverList.js +224 -0
- package/lib/core/sharePolicy.js +69 -0
- package/lib/core/shares.js +325 -0
- package/lib/core/signalingServer.js +441 -0
- package/lib/core/streamTransport.js +106 -0
- package/lib/core/su.js +55 -0
- package/lib/core/syncEngine.js +264 -0
- package/lib/core/syncState.js +159 -0
- package/lib/core/transfers.js +259 -0
- package/lib/core/users.js +225 -0
- package/lib/core/vfs.js +216 -0
- package/lib/core/webServer.js +702 -0
- package/lib/core/webrtcStream.js +396 -0
- package/lib/crypto/chatEncryption.js +57 -0
- package/lib/crypto/shareEncryption.js +195 -0
- package/lib/crypto/sharePassword.js +35 -0
- package/lib/crypto/streamEncryption.js +189 -0
- package/lib/package.json +1 -0
- package/lib/protocol/envelope.js +271 -0
- package/lib/protocol/handler.js +191 -0
- package/lib/protocol/index.js +27 -0
- package/lib/protocol/messages.js +247 -0
- package/lib/protocol/negotiation.js +127 -0
- package/lib/protocol/policy.js +142 -0
- package/lib/protocol/router.js +86 -0
- package/lib/protocol/sync.js +122 -0
- package/lib/utils/cli.js +15 -0
- package/lib/utils/config.js +123 -0
- package/lib/utils/configIntegrity.js +87 -0
- package/lib/utils/downloadDir.js +9 -0
- package/lib/utils/explorer.js +83 -0
- package/lib/utils/format.js +12 -0
- package/lib/utils/help.js +357 -0
- package/lib/utils/logger.js +103 -0
- package/lib/utils/torrent.js +203 -0
- package/package.json +71 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import sodium from 'sodium-native';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import config from '../utils/config.js';
|
|
6
|
+
|
|
7
|
+
const CHUNK_SIZE = 65536;
|
|
8
|
+
|
|
9
|
+
export function getEncryptedSeedDir(shareId) {
|
|
10
|
+
const baseDir = path.dirname(config.path);
|
|
11
|
+
return path.join(baseDir, 'encrypted-seeds', shareId);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getDecryptedDownloadDir(shareName) {
|
|
15
|
+
const baseDir = path.dirname(config.path);
|
|
16
|
+
return path.join(baseDir, 'decrypted-downloads', shareName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hashFileName(name, shareKey) {
|
|
20
|
+
const hmac = crypto.createHmac('sha256', shareKey);
|
|
21
|
+
hmac.update(name);
|
|
22
|
+
return hmac.digest('hex').slice(0, 32) + '.enc';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function encryptFileStream(inputPath, outputPath, shareKey) {
|
|
26
|
+
const state = Buffer.alloc(sodium.crypto_secretstream_xchacha20poly1305_STATEBYTES);
|
|
27
|
+
const header = Buffer.alloc(sodium.crypto_secretstream_xchacha20poly1305_HEADERBYTES);
|
|
28
|
+
sodium.crypto_secretstream_xchacha20poly1305_init_push(state, header, shareKey);
|
|
29
|
+
|
|
30
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
31
|
+
const input = fs.openSync(inputPath, 'r');
|
|
32
|
+
const output = fs.openSync(outputPath, 'w');
|
|
33
|
+
fs.writeSync(output, header);
|
|
34
|
+
|
|
35
|
+
const plainBuf = Buffer.alloc(CHUNK_SIZE);
|
|
36
|
+
const fileSize = fs.fstatSync(input).size;
|
|
37
|
+
let totalRead = 0;
|
|
38
|
+
|
|
39
|
+
while (true) {
|
|
40
|
+
const bytesRead = fs.readSync(input, plainBuf, 0, CHUNK_SIZE, null);
|
|
41
|
+
if (bytesRead === 0) break;
|
|
42
|
+
totalRead += bytesRead;
|
|
43
|
+
const isLast = totalRead >= fileSize;
|
|
44
|
+
const plain = plainBuf.subarray(0, bytesRead);
|
|
45
|
+
const cipher = Buffer.alloc(bytesRead + sodium.crypto_secretstream_xchacha20poly1305_ABYTES);
|
|
46
|
+
const tag = isLast
|
|
47
|
+
? sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
|
|
48
|
+
: sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
|
|
49
|
+
sodium.crypto_secretstream_xchacha20poly1305_push(state, cipher, plain, null, tag);
|
|
50
|
+
const lenBuf = Buffer.alloc(4);
|
|
51
|
+
lenBuf.writeUInt32LE(cipher.length, 0);
|
|
52
|
+
fs.writeSync(output, lenBuf);
|
|
53
|
+
fs.writeSync(output, cipher);
|
|
54
|
+
if (isLast) break;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fs.closeSync(input);
|
|
58
|
+
fs.closeSync(output);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function decryptFileStream(inputPath, outputPath, shareKey) {
|
|
62
|
+
const state = Buffer.alloc(sodium.crypto_secretstream_xchacha20poly1305_STATEBYTES);
|
|
63
|
+
const header = Buffer.alloc(sodium.crypto_secretstream_xchacha20poly1305_HEADERBYTES);
|
|
64
|
+
|
|
65
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
66
|
+
const input = fs.openSync(inputPath, 'r');
|
|
67
|
+
const tmpPath = outputPath + '.tmp';
|
|
68
|
+
const output = fs.openSync(tmpPath, 'w');
|
|
69
|
+
|
|
70
|
+
fs.readSync(input, header, 0, header.length, null);
|
|
71
|
+
sodium.crypto_secretstream_xchacha20poly1305_init_pull(state, header, shareKey);
|
|
72
|
+
|
|
73
|
+
const lenBuf = Buffer.alloc(4);
|
|
74
|
+
let fileOffset = header.length;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
while (true) {
|
|
78
|
+
const readLen = fs.readSync(input, lenBuf, 0, 4, fileOffset);
|
|
79
|
+
if (readLen < 4) break;
|
|
80
|
+
fileOffset += 4;
|
|
81
|
+
const chunkLen = lenBuf.readUInt32LE(0);
|
|
82
|
+
const maxChunk = CHUNK_SIZE + sodium.crypto_secretstream_xchacha20poly1305_ABYTES;
|
|
83
|
+
if (chunkLen === 0 || chunkLen > maxChunk) {
|
|
84
|
+
throw new Error('Corrupt ciphertext: invalid chunk length');
|
|
85
|
+
}
|
|
86
|
+
const cipher = Buffer.alloc(chunkLen);
|
|
87
|
+
fs.readSync(input, cipher, 0, chunkLen, fileOffset);
|
|
88
|
+
fileOffset += chunkLen;
|
|
89
|
+
const plain = Buffer.alloc(chunkLen - sodium.crypto_secretstream_xchacha20poly1305_ABYTES);
|
|
90
|
+
const tag = Buffer.alloc(1);
|
|
91
|
+
sodium.crypto_secretstream_xchacha20poly1305_pull(state, plain, tag, cipher, null);
|
|
92
|
+
fs.writeSync(output, plain);
|
|
93
|
+
if (tag[0] === sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL) break;
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
fs.closeSync(input);
|
|
97
|
+
fs.closeSync(output);
|
|
98
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
99
|
+
throw new Error('Decryption failed — data corrupted or wrong key');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
fs.closeSync(input);
|
|
103
|
+
fs.closeSync(output);
|
|
104
|
+
fs.renameSync(tmpPath, outputPath);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function encryptForSeed(sourcePath, destDir, shareKey) {
|
|
108
|
+
const manifest = [];
|
|
109
|
+
|
|
110
|
+
function walk(dir, relBase) {
|
|
111
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
const fullPath = path.join(dir, entry.name);
|
|
114
|
+
const rel = relBase ? path.join(relBase, entry.name) : entry.name;
|
|
115
|
+
if (entry.isDirectory()) {
|
|
116
|
+
walk(fullPath, rel);
|
|
117
|
+
} else if (entry.isFile()) {
|
|
118
|
+
const hashedName = hashFileName(rel, shareKey);
|
|
119
|
+
const encPath = path.join(destDir, hashedName);
|
|
120
|
+
encryptFileStream(fullPath, encPath, shareKey);
|
|
121
|
+
manifest.push({ hash: hashedName, path: rel });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
127
|
+
|
|
128
|
+
if (fs.statSync(sourcePath).isDirectory()) {
|
|
129
|
+
walk(sourcePath, '');
|
|
130
|
+
} else {
|
|
131
|
+
const name = path.basename(sourcePath);
|
|
132
|
+
const hashedName = hashFileName(name, shareKey);
|
|
133
|
+
const encPath = path.join(destDir, hashedName);
|
|
134
|
+
encryptFileStream(sourcePath, encPath, shareKey);
|
|
135
|
+
manifest.push({ hash: hashedName, path: name });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Derive a separate subkey for manifest encryption (key separation)
|
|
139
|
+
const manifestKey = Buffer.alloc(sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES);
|
|
140
|
+
sodium.crypto_kdf_derive_from_key(manifestKey, 1, Buffer.from('manifest'), shareKey);
|
|
141
|
+
|
|
142
|
+
const manifestJson = JSON.stringify(manifest);
|
|
143
|
+
const manifestBuf = Buffer.from(manifestJson);
|
|
144
|
+
const nonce = Buffer.alloc(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
145
|
+
sodium.randombytes_buf(nonce);
|
|
146
|
+
const ciphertext = Buffer.alloc(manifestBuf.length + sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES);
|
|
147
|
+
sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(ciphertext, manifestBuf, null, null, nonce, manifestKey);
|
|
148
|
+
|
|
149
|
+
const manifestPath = path.join(destDir, '.manifest.enc');
|
|
150
|
+
const manifestFile = Buffer.concat([nonce, ciphertext]);
|
|
151
|
+
fs.writeFileSync(manifestPath, manifestFile);
|
|
152
|
+
|
|
153
|
+
return manifest;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function decryptFromDownload(encryptedDir, destDir, shareKey) {
|
|
157
|
+
const manifestPath = path.join(encryptedDir, '.manifest.enc');
|
|
158
|
+
if (!fs.existsSync(manifestPath)) {
|
|
159
|
+
throw new Error('No encrypted manifest found — cannot decrypt');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Derive the same manifest subkey used during encryption
|
|
163
|
+
const manifestKey = Buffer.alloc(sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES);
|
|
164
|
+
sodium.crypto_kdf_derive_from_key(manifestKey, 1, Buffer.from('manifest'), shareKey);
|
|
165
|
+
|
|
166
|
+
const manifestFile = fs.readFileSync(manifestPath);
|
|
167
|
+
const nonce = manifestFile.subarray(0, sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
168
|
+
const ciphertext = manifestFile.subarray(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
169
|
+
const plaintext = Buffer.alloc(ciphertext.length - sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES);
|
|
170
|
+
sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(plaintext, null, ciphertext, null, nonce, manifestKey);
|
|
171
|
+
|
|
172
|
+
const manifest = JSON.parse(plaintext.toString());
|
|
173
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
174
|
+
|
|
175
|
+
for (const entry of manifest) {
|
|
176
|
+
const encPath = path.resolve(encryptedDir, entry.hash);
|
|
177
|
+
if (!encPath.startsWith(path.resolve(encryptedDir) + path.sep) && encPath !== path.resolve(encryptedDir)) {
|
|
178
|
+
throw new Error(`Path traversal detected in manifest hash: ${entry.hash}`);
|
|
179
|
+
}
|
|
180
|
+
if (!fs.existsSync(encPath)) continue;
|
|
181
|
+
const outPath = path.resolve(destDir, entry.path);
|
|
182
|
+
if (!outPath.startsWith(path.resolve(destDir))) {
|
|
183
|
+
throw new Error(`Path traversal detected in manifest: ${entry.path}`);
|
|
184
|
+
}
|
|
185
|
+
decryptFileStream(encPath, outPath, shareKey);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return manifest;
|
|
189
|
+
}
|
package/lib/package.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "type": "module" }
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import sodium from 'sodium-native';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
|
|
7
|
+
export const PROTOCOL_VERSION = 1;
|
|
8
|
+
const CLOCK_SKEW_MS = 5 * 60 * 1000; // ±5 minutes
|
|
9
|
+
|
|
10
|
+
const seenIds = new Map(); // id → expiry timestamp
|
|
11
|
+
const DEDUP_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
12
|
+
|
|
13
|
+
const REPLAY_CACHE_DIR = path.join(os.homedir(), '.config', 'pal-explorer-cli');
|
|
14
|
+
const REPLAY_CACHE_PATH = path.join(REPLAY_CACHE_DIR, 'replay-cache.json');
|
|
15
|
+
|
|
16
|
+
function loadReplayCache() {
|
|
17
|
+
try {
|
|
18
|
+
if (fs.existsSync(REPLAY_CACHE_PATH)) {
|
|
19
|
+
const data = JSON.parse(fs.readFileSync(REPLAY_CACHE_PATH, 'utf8'));
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
for (const [id, expiry] of Object.entries(data)) {
|
|
22
|
+
if (expiry > now) seenIds.set(id, expiry);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
} catch {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function saveReplayCache() {
|
|
29
|
+
try {
|
|
30
|
+
if (!fs.existsSync(REPLAY_CACHE_DIR)) {
|
|
31
|
+
fs.mkdirSync(REPLAY_CACHE_DIR, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
const obj = Object.fromEntries(seenIds);
|
|
34
|
+
fs.writeFileSync(REPLAY_CACHE_PATH, JSON.stringify(obj), 'utf8');
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
loadReplayCache();
|
|
39
|
+
if (typeof process !== 'undefined') {
|
|
40
|
+
process.on('exit', saveReplayCache);
|
|
41
|
+
process.on('SIGINT', () => { saveReplayCache(); process.exit(0); });
|
|
42
|
+
process.on('SIGTERM', () => { saveReplayCache(); process.exit(0); });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Error codes (Appendix B)
|
|
46
|
+
export const ErrorCodes = {
|
|
47
|
+
E_UNAUTHORIZED: 'E_UNAUTHORIZED',
|
|
48
|
+
E_EXPIRED: 'E_EXPIRED',
|
|
49
|
+
E_POLICY_VIOLATION: 'E_POLICY_VIOLATION',
|
|
50
|
+
E_REVOKED: 'E_REVOKED',
|
|
51
|
+
E_NOT_FOUND: 'E_NOT_FOUND',
|
|
52
|
+
E_RATE_LIMITED: 'E_RATE_LIMITED',
|
|
53
|
+
E_QUOTA_EXCEEDED: 'E_QUOTA_EXCEEDED',
|
|
54
|
+
E_VERSION_MISMATCH: 'E_VERSION_MISMATCH',
|
|
55
|
+
E_CLOCK_SKEW: 'E_CLOCK_SKEW',
|
|
56
|
+
E_REPLAY: 'E_REPLAY',
|
|
57
|
+
E_CAPABILITY: 'E_CAPABILITY',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// All known message types
|
|
61
|
+
const KNOWN_TYPES = new Set([
|
|
62
|
+
'identity.announce', 'identity.resolve', 'identity.response', 'heartbeat',
|
|
63
|
+
'share.offer', 'share.accept', 'share.key', 'share.revoke', 'share.policy.update',
|
|
64
|
+
'transfer.request', 'transfer.authorize', 'transfer.deny', 'transfer.progress',
|
|
65
|
+
'transfer.complete', 'transfer.receipt',
|
|
66
|
+
'sync.manifest', 'sync.diff', 'sync.request', 'sync.conflict',
|
|
67
|
+
'chat.message', 'chat.ack', 'chat.typing',
|
|
68
|
+
'route.probe', 'route.probe.response',
|
|
69
|
+
'relay.request', 'relay.allocated',
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
// All known capabilities
|
|
73
|
+
export const CAPABILITIES = {
|
|
74
|
+
SHARE: 'share',
|
|
75
|
+
SYNC: 'sync',
|
|
76
|
+
CHAT: 'chat',
|
|
77
|
+
RELAY: 'relay',
|
|
78
|
+
DELTA_SYNC: 'delta-sync',
|
|
79
|
+
RECEIPTS: 'receipts',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Capability requirements per message type
|
|
83
|
+
const CAPABILITY_REQUIREMENTS = {
|
|
84
|
+
'share.offer': CAPABILITIES.SHARE,
|
|
85
|
+
'share.accept': CAPABILITIES.SHARE,
|
|
86
|
+
'share.key': CAPABILITIES.SHARE,
|
|
87
|
+
'share.revoke': CAPABILITIES.SHARE,
|
|
88
|
+
'sync.manifest': CAPABILITIES.SYNC,
|
|
89
|
+
'sync.diff': CAPABILITIES.SYNC,
|
|
90
|
+
'sync.request': CAPABILITIES.SYNC,
|
|
91
|
+
'sync.conflict': CAPABILITIES.SYNC,
|
|
92
|
+
'chat.message': CAPABILITIES.CHAT,
|
|
93
|
+
'chat.ack': CAPABILITIES.CHAT,
|
|
94
|
+
'chat.typing': CAPABILITIES.CHAT,
|
|
95
|
+
'relay.request': CAPABILITIES.RELAY,
|
|
96
|
+
'share.policy.update': CAPABILITIES.SHARE,
|
|
97
|
+
'gossip.servers': CAPABILITIES.RELAY,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function sortKeys(value) {
|
|
101
|
+
if (value === null || typeof value !== 'object') return value;
|
|
102
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
103
|
+
const sorted = {};
|
|
104
|
+
for (const key of Object.keys(value).sort()) {
|
|
105
|
+
sorted[key] = sortKeys(value[key]);
|
|
106
|
+
}
|
|
107
|
+
return sorted;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function canonicalize(obj) {
|
|
111
|
+
return JSON.stringify(sortKeys(obj));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hashPayload(envelope) {
|
|
115
|
+
const { sig, ...rest } = envelope;
|
|
116
|
+
return crypto.createHash('sha256').update(canonicalize(rest)).digest();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function create(type, fromKeyPair, to, payload) {
|
|
120
|
+
const envelope = {
|
|
121
|
+
v: PROTOCOL_VERSION,
|
|
122
|
+
type,
|
|
123
|
+
from: fromKeyPair.publicKey.toString('hex'),
|
|
124
|
+
to: to ? (Buffer.isBuffer(to) ? to.toString('hex') : to) : null,
|
|
125
|
+
id: crypto.randomUUID(),
|
|
126
|
+
ts: new Date().toISOString(),
|
|
127
|
+
payload,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const message = hashPayload(envelope);
|
|
131
|
+
const sig = Buffer.alloc(sodium.crypto_sign_BYTES);
|
|
132
|
+
sodium.crypto_sign_detached(sig, message, fromKeyPair.privateKey);
|
|
133
|
+
envelope.sig = sig.toString('hex');
|
|
134
|
+
|
|
135
|
+
return envelope;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function createEncrypted(type, fromKeyPair, recipientPK, payload) {
|
|
139
|
+
const recipientPKBuf = Buffer.isBuffer(recipientPK) ? recipientPK : Buffer.from(recipientPK, 'hex');
|
|
140
|
+
|
|
141
|
+
// Convert Ed25519 PK to Curve25519 for encryption
|
|
142
|
+
const curve25519PK = Buffer.alloc(sodium.crypto_box_PUBLICKEYBYTES);
|
|
143
|
+
sodium.crypto_sign_ed25519_pk_to_curve25519(curve25519PK, recipientPKBuf);
|
|
144
|
+
|
|
145
|
+
const plaintext = Buffer.from(JSON.stringify(payload));
|
|
146
|
+
const cipher = Buffer.alloc(plaintext.length + sodium.crypto_box_SEALBYTES);
|
|
147
|
+
sodium.crypto_box_seal(cipher, plaintext, curve25519PK);
|
|
148
|
+
|
|
149
|
+
return create(type, fromKeyPair, recipientPKBuf, {
|
|
150
|
+
_enc: true,
|
|
151
|
+
_cipher: cipher.toString('hex'),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function verify(envelope, { localPublicKey, peerCapabilities } = {}) {
|
|
156
|
+
const errors = [];
|
|
157
|
+
|
|
158
|
+
if (envelope.v !== PROTOCOL_VERSION) {
|
|
159
|
+
errors.push(`${ErrorCodes.E_VERSION_MISMATCH}: expected ${PROTOCOL_VERSION}, got ${envelope.v}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!envelope.type || typeof envelope.type !== 'string') {
|
|
163
|
+
errors.push('E_INVALID: missing type');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!envelope.from || envelope.from.length !== 64) {
|
|
167
|
+
errors.push('E_INVALID: missing or invalid from');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!envelope.id) {
|
|
171
|
+
errors.push('E_INVALID: missing id');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!envelope.ts) {
|
|
175
|
+
errors.push('E_INVALID: missing timestamp');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Clock skew check
|
|
179
|
+
if (envelope.ts) {
|
|
180
|
+
const msgTime = new Date(envelope.ts).getTime();
|
|
181
|
+
const now = Date.now();
|
|
182
|
+
if (Math.abs(now - msgTime) > CLOCK_SKEW_MS) {
|
|
183
|
+
errors.push(`${ErrorCodes.E_CLOCK_SKEW}: timestamp outside ±5 min window`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Replay check
|
|
188
|
+
if (envelope.id) {
|
|
189
|
+
if (seenIds.has(envelope.id)) {
|
|
190
|
+
errors.push(`${ErrorCodes.E_REPLAY}: duplicate message ID`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Recipient check — if `to` is set, it must match local public key
|
|
195
|
+
if (envelope.to && localPublicKey) {
|
|
196
|
+
const localPK = Buffer.isBuffer(localPublicKey) ? localPublicKey.toString('hex') : localPublicKey;
|
|
197
|
+
if (envelope.to !== localPK) {
|
|
198
|
+
errors.push(`${ErrorCodes.E_UNAUTHORIZED}: message not addressed to this node`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Capability check — does sender have required capability?
|
|
203
|
+
if (envelope.type && peerCapabilities) {
|
|
204
|
+
const required = CAPABILITY_REQUIREMENTS[envelope.type];
|
|
205
|
+
if (required && !peerCapabilities.includes(required)) {
|
|
206
|
+
errors.push(`${ErrorCodes.E_CAPABILITY}: peer lacks required capability '${required}'`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Signature check
|
|
211
|
+
if (envelope.sig && envelope.from) {
|
|
212
|
+
try {
|
|
213
|
+
const pk = Buffer.from(envelope.from, 'hex');
|
|
214
|
+
const sig = Buffer.from(envelope.sig, 'hex');
|
|
215
|
+
const message = hashPayload(envelope);
|
|
216
|
+
const valid = sodium.crypto_sign_verify_detached(sig, message, pk);
|
|
217
|
+
if (!valid) errors.push(`${ErrorCodes.E_UNAUTHORIZED}: invalid signature`);
|
|
218
|
+
} catch (e) {
|
|
219
|
+
errors.push(`${ErrorCodes.E_UNAUTHORIZED}: signature verification failed: ${e.message}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (errors.length > 0) return { valid: false, errors };
|
|
224
|
+
|
|
225
|
+
// Mark as seen
|
|
226
|
+
seenIds.set(envelope.id, Date.now() + DEDUP_WINDOW_MS);
|
|
227
|
+
// Cap seenIds to prevent memory exhaustion
|
|
228
|
+
if (seenIds.size > 10000) {
|
|
229
|
+
pruneSeenIds();
|
|
230
|
+
if (seenIds.size > 10000) {
|
|
231
|
+
const entries = [...seenIds.entries()].sort((a, b) => a[1] - b[1]);
|
|
232
|
+
while (seenIds.size > 5000) {
|
|
233
|
+
seenIds.delete(entries.shift()[0]);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { valid: true, errors: [] };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function decrypt(envelope, recipientKeyPair) {
|
|
242
|
+
if (!envelope.payload?._enc) return envelope.payload;
|
|
243
|
+
|
|
244
|
+
const cipher = Buffer.from(envelope.payload._cipher, 'hex');
|
|
245
|
+
|
|
246
|
+
// Convert Ed25519 keys to Curve25519
|
|
247
|
+
const curve25519PK = Buffer.alloc(sodium.crypto_box_PUBLICKEYBYTES);
|
|
248
|
+
const curve25519SK = Buffer.alloc(sodium.crypto_box_SECRETKEYBYTES);
|
|
249
|
+
sodium.crypto_sign_ed25519_pk_to_curve25519(curve25519PK, recipientKeyPair.publicKey);
|
|
250
|
+
sodium.crypto_sign_ed25519_sk_to_curve25519(curve25519SK, recipientKeyPair.privateKey);
|
|
251
|
+
|
|
252
|
+
const plaintext = Buffer.alloc(cipher.length - sodium.crypto_box_SEALBYTES);
|
|
253
|
+
const ok = sodium.crypto_box_seal_open(plaintext, cipher, curve25519PK, curve25519SK);
|
|
254
|
+
if (!ok) throw new Error(`${ErrorCodes.E_UNAUTHORIZED}: decryption failed`);
|
|
255
|
+
|
|
256
|
+
return JSON.parse(plaintext.toString());
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function isKnownType(type) {
|
|
260
|
+
return KNOWN_TYPES.has(type) || type.startsWith('x-');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function pruneSeenIds() {
|
|
264
|
+
const now = Date.now();
|
|
265
|
+
for (const [id, expiry] of seenIds) {
|
|
266
|
+
if (expiry < now) seenIds.delete(id);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Run cleanup every 10 minutes
|
|
271
|
+
setInterval(pruneSeenIds, 10 * 60 * 1000).unref();
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { verify, decrypt, isKnownType } from './envelope.js';
|
|
2
|
+
import { handleTransferRequest } from './negotiation.js';
|
|
3
|
+
|
|
4
|
+
const listeners = new Map();
|
|
5
|
+
|
|
6
|
+
export function on(type, callback) {
|
|
7
|
+
if (!listeners.has(type)) listeners.set(type, []);
|
|
8
|
+
listeners.get(type).push(callback);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function off(type, callback) {
|
|
12
|
+
const cbs = listeners.get(type);
|
|
13
|
+
if (!cbs) return;
|
|
14
|
+
const idx = cbs.indexOf(callback);
|
|
15
|
+
if (idx !== -1) cbs.splice(idx, 1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function emit(type, data) {
|
|
19
|
+
const cbs = listeners.get(type) || [];
|
|
20
|
+
for (const cb of cbs) {
|
|
21
|
+
try { cb(data); } catch (e) { console.error(`[pal-protocol] handler error for ${type}:`, e.message); }
|
|
22
|
+
}
|
|
23
|
+
const wildcards = listeners.get('*') || [];
|
|
24
|
+
for (const cb of wildcards) {
|
|
25
|
+
try { cb({ type, ...data }); } catch {}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function handleIncoming(envelope, keyPair, context = {}) {
|
|
30
|
+
const verifyOpts = {};
|
|
31
|
+
if (keyPair?.publicKey) {
|
|
32
|
+
verifyOpts.localPublicKey = keyPair.publicKey;
|
|
33
|
+
}
|
|
34
|
+
if (context.peerCapabilities) {
|
|
35
|
+
verifyOpts.peerCapabilities = context.peerCapabilities;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const result = verify(envelope, verifyOpts);
|
|
39
|
+
if (!result.valid) {
|
|
40
|
+
emit('error', { envelope, errors: result.errors });
|
|
41
|
+
return { handled: false, errors: result.errors };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Decrypt if needed
|
|
45
|
+
let payload;
|
|
46
|
+
try {
|
|
47
|
+
payload = envelope.payload?._enc ? decrypt(envelope, keyPair) : envelope.payload;
|
|
48
|
+
} catch (e) {
|
|
49
|
+
emit('error', { envelope, errors: [e.message] });
|
|
50
|
+
return { handled: false, errors: [e.message] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const data = { from: envelope.from, id: envelope.id, ts: envelope.ts, ...payload };
|
|
54
|
+
|
|
55
|
+
switch (envelope.type) {
|
|
56
|
+
// ── Identity & Discovery ──
|
|
57
|
+
case 'identity.announce':
|
|
58
|
+
emit('identity.announce', data);
|
|
59
|
+
return { handled: true, action: 'identity_announced' };
|
|
60
|
+
|
|
61
|
+
case 'identity.resolve':
|
|
62
|
+
emit('identity.resolve', data);
|
|
63
|
+
return { handled: true, action: 'identity_resolve_requested' };
|
|
64
|
+
|
|
65
|
+
case 'identity.response':
|
|
66
|
+
emit('identity.response', data);
|
|
67
|
+
return { handled: true, action: 'identity_resolved' };
|
|
68
|
+
|
|
69
|
+
case 'heartbeat':
|
|
70
|
+
emit('heartbeat', data);
|
|
71
|
+
return { handled: true, action: 'heartbeat_received' };
|
|
72
|
+
|
|
73
|
+
// ── Share Negotiation ──
|
|
74
|
+
case 'share.offer':
|
|
75
|
+
emit('share.offer', data);
|
|
76
|
+
return { handled: true, action: 'share_offered' };
|
|
77
|
+
|
|
78
|
+
case 'share.accept':
|
|
79
|
+
emit('share.accept', data);
|
|
80
|
+
return { handled: true, action: 'share_accepted' };
|
|
81
|
+
|
|
82
|
+
case 'share.key':
|
|
83
|
+
emit('share.key', data);
|
|
84
|
+
return { handled: true, action: 'share_key_received' };
|
|
85
|
+
|
|
86
|
+
case 'share.revoke':
|
|
87
|
+
emit('share.revoke', data);
|
|
88
|
+
return { handled: true, action: 'share_revoked' };
|
|
89
|
+
|
|
90
|
+
case 'share.policy.update':
|
|
91
|
+
emit('share.policy.update', data);
|
|
92
|
+
return { handled: true, action: 'policy_updated' };
|
|
93
|
+
|
|
94
|
+
// ── Transfer Control ──
|
|
95
|
+
case 'transfer.request': {
|
|
96
|
+
if (context.share && keyPair) {
|
|
97
|
+
const response = await handleTransferRequest(envelope, keyPair, context.share);
|
|
98
|
+
emit('transfer.request', { ...data, response });
|
|
99
|
+
return { handled: true, action: response.ok ? 'transfer_authorized' : 'transfer_denied', envelope: response.envelope };
|
|
100
|
+
}
|
|
101
|
+
emit('transfer.request', data);
|
|
102
|
+
return { handled: true, action: 'transfer_requested' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case 'transfer.authorize':
|
|
106
|
+
emit('transfer.authorize', data);
|
|
107
|
+
return { handled: true, action: 'transfer_authorized' };
|
|
108
|
+
|
|
109
|
+
case 'transfer.deny':
|
|
110
|
+
emit('transfer.deny', data);
|
|
111
|
+
return { handled: true, action: 'transfer_denied' };
|
|
112
|
+
|
|
113
|
+
case 'transfer.progress':
|
|
114
|
+
emit('transfer.progress', data);
|
|
115
|
+
return { handled: true, action: 'transfer_progress' };
|
|
116
|
+
|
|
117
|
+
case 'transfer.complete':
|
|
118
|
+
emit('transfer.complete', data);
|
|
119
|
+
return { handled: true, action: 'transfer_completed' };
|
|
120
|
+
|
|
121
|
+
case 'transfer.receipt':
|
|
122
|
+
emit('transfer.receipt', data);
|
|
123
|
+
return { handled: true, action: 'receipt_received' };
|
|
124
|
+
|
|
125
|
+
// ── Sync ──
|
|
126
|
+
case 'sync.manifest':
|
|
127
|
+
emit('sync.manifest', data);
|
|
128
|
+
return { handled: true, action: 'manifest_received' };
|
|
129
|
+
|
|
130
|
+
case 'sync.diff':
|
|
131
|
+
emit('sync.diff', data);
|
|
132
|
+
return { handled: true, action: 'diff_received' };
|
|
133
|
+
|
|
134
|
+
case 'sync.request':
|
|
135
|
+
emit('sync.request', data);
|
|
136
|
+
return { handled: true, action: 'sync_requested' };
|
|
137
|
+
|
|
138
|
+
case 'sync.conflict':
|
|
139
|
+
emit('sync.conflict', data);
|
|
140
|
+
return { handled: true, action: 'conflict_detected' };
|
|
141
|
+
|
|
142
|
+
// ── Chat ──
|
|
143
|
+
case 'chat.message':
|
|
144
|
+
emit('chat.message', data);
|
|
145
|
+
return { handled: true, action: 'chat_received' };
|
|
146
|
+
|
|
147
|
+
case 'chat.ack':
|
|
148
|
+
emit('chat.ack', data);
|
|
149
|
+
return { handled: true, action: 'chat_acked' };
|
|
150
|
+
|
|
151
|
+
case 'chat.typing':
|
|
152
|
+
emit('chat.typing', data);
|
|
153
|
+
return { handled: true, action: 'typing_indicator' };
|
|
154
|
+
|
|
155
|
+
// ── Routing ──
|
|
156
|
+
case 'route.probe':
|
|
157
|
+
emit('route.probe', data);
|
|
158
|
+
return { handled: true, action: 'probe_received' };
|
|
159
|
+
|
|
160
|
+
case 'route.probe.response':
|
|
161
|
+
emit('route.probe.response', data);
|
|
162
|
+
return { handled: true, action: 'probe_response' };
|
|
163
|
+
|
|
164
|
+
case 'relay.request':
|
|
165
|
+
emit('relay.request', data);
|
|
166
|
+
return { handled: true, action: 'relay_requested' };
|
|
167
|
+
|
|
168
|
+
case 'relay.allocated':
|
|
169
|
+
emit('relay.allocated', data);
|
|
170
|
+
return { handled: true, action: 'relay_allocated' };
|
|
171
|
+
|
|
172
|
+
// ── Server Gossip ──
|
|
173
|
+
case 'gossip.servers':
|
|
174
|
+
emit('gossip.servers', data);
|
|
175
|
+
return { handled: true, action: 'gossip_received' };
|
|
176
|
+
|
|
177
|
+
default:
|
|
178
|
+
// Custom x- types (extensibility §13.1)
|
|
179
|
+
if (envelope.type.startsWith('x-')) {
|
|
180
|
+
emit(envelope.type, data);
|
|
181
|
+
return { handled: true, action: 'custom' };
|
|
182
|
+
}
|
|
183
|
+
// Forward compatibility — unknown types are silently ignored (§13.2)
|
|
184
|
+
if (!isKnownType(envelope.type)) {
|
|
185
|
+
emit('unknown', { type: envelope.type, ...data });
|
|
186
|
+
return { handled: false, errors: [] }; // not an error, just unknown
|
|
187
|
+
}
|
|
188
|
+
emit('unknown', { type: envelope.type, ...data });
|
|
189
|
+
return { handled: false, errors: ['Unknown message type'] };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// PAL/1.0 Protocol — Palexplorer proprietary control protocol
|
|
2
|
+
// Built on: BitTorrent (transport), Ed25519/XChaCha20 (crypto), HTTP/DHT/mDNS (discovery)
|
|
3
|
+
|
|
4
|
+
export { create, createEncrypted, verify, decrypt, isKnownType, ErrorCodes, CAPABILITIES, PROTOCOL_VERSION } from './envelope.js';
|
|
5
|
+
export * as messages from './messages.js';
|
|
6
|
+
export { validatePolicy, enforcePolicy, FREE_POLICY_LIMITS, PRO_POLICY_LIMITS } from './policy.js';
|
|
7
|
+
export { probeRoutes, selectRoute, buildRouteInfo, getRelayLimits, ROUTE_PRIORITY } from './router.js';
|
|
8
|
+
export {
|
|
9
|
+
initiateShare,
|
|
10
|
+
handleShareAccept,
|
|
11
|
+
handleTransferRequest,
|
|
12
|
+
wrapShareKey,
|
|
13
|
+
unwrapShareKey,
|
|
14
|
+
createReceipt,
|
|
15
|
+
} from './negotiation.js';
|
|
16
|
+
export {
|
|
17
|
+
buildProtocolManifest,
|
|
18
|
+
createManifestEnvelope,
|
|
19
|
+
computeDelta,
|
|
20
|
+
computeBlockHashes,
|
|
21
|
+
findChangedBlocks,
|
|
22
|
+
createSyncRequest,
|
|
23
|
+
createConflictNotification,
|
|
24
|
+
} from './sync.js';
|
|
25
|
+
export { handleIncoming, on, off } from './handler.js';
|
|
26
|
+
|
|
27
|
+
export const PROTOCOL_NAME = 'PAL/1.0';
|