@vex-chat/libvex 1.0.2 → 2.0.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/README.md +103 -41
- package/dist/Client.d.ts +449 -554
- package/dist/Client.d.ts.map +1 -0
- package/dist/Client.js +1542 -1484
- package/dist/Client.js.map +1 -1
- package/dist/Storage.d.ts +111 -0
- package/dist/Storage.d.ts.map +1 -0
- package/dist/Storage.js +2 -0
- package/dist/Storage.js.map +1 -0
- package/dist/__tests__/harness/memory-storage.d.ts +29 -27
- package/dist/__tests__/harness/memory-storage.d.ts.map +1 -0
- package/dist/__tests__/harness/memory-storage.js +120 -109
- package/dist/__tests__/harness/memory-storage.js.map +1 -1
- package/dist/codec.d.ts +44 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +51 -0
- package/dist/codec.js.map +1 -0
- package/dist/codecs.d.ts +201 -0
- package/dist/codecs.d.ts.map +1 -0
- package/dist/codecs.js +67 -0
- package/dist/codecs.js.map +1 -0
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/keystore/memory.d.ts +5 -4
- package/dist/keystore/memory.d.ts.map +1 -0
- package/dist/keystore/memory.js +9 -7
- package/dist/keystore/memory.js.map +1 -1
- package/dist/keystore/node.d.ts +6 -5
- package/dist/keystore/node.d.ts.map +1 -0
- package/dist/keystore/node.js +38 -19
- package/dist/keystore/node.js.map +1 -1
- package/dist/preset/common.d.ts +9 -0
- package/dist/preset/common.d.ts.map +1 -0
- package/dist/preset/common.js +2 -0
- package/dist/preset/common.js.map +1 -0
- package/dist/preset/node.d.ts +3 -5
- package/dist/preset/node.d.ts.map +1 -0
- package/dist/preset/node.js +5 -7
- package/dist/preset/node.js.map +1 -1
- package/dist/preset/test.d.ts +4 -4
- package/dist/preset/test.d.ts.map +1 -0
- package/dist/preset/test.js +8 -10
- package/dist/preset/test.js.map +1 -1
- package/dist/storage/node.d.ts +4 -3
- package/dist/storage/node.d.ts.map +1 -0
- package/dist/storage/node.js +4 -4
- package/dist/storage/node.js.map +1 -1
- package/dist/storage/schema.d.ts +55 -57
- package/dist/storage/schema.d.ts.map +1 -0
- package/dist/storage/sqlite.d.ts +33 -28
- package/dist/storage/sqlite.d.ts.map +1 -0
- package/dist/storage/sqlite.js +330 -290
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/transport/types.d.ts +23 -16
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/websocket.d.ts +26 -0
- package/dist/transport/websocket.d.ts.map +1 -0
- package/dist/transport/websocket.js +83 -0
- package/dist/transport/websocket.js.map +1 -0
- package/dist/types/crypto.d.ts +35 -0
- package/dist/types/crypto.d.ts.map +1 -0
- package/dist/types/crypto.js +9 -0
- package/dist/types/crypto.js.map +1 -0
- package/dist/types/identity.d.ts +17 -0
- package/dist/types/identity.d.ts.map +1 -0
- package/dist/types/identity.js +6 -0
- package/dist/types/identity.js.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/capitalize.d.ts +1 -0
- package/dist/utils/capitalize.d.ts.map +1 -0
- package/dist/utils/createLogger.d.ts +1 -0
- package/dist/utils/createLogger.d.ts.map +1 -0
- package/dist/utils/createLogger.js +4 -11
- package/dist/utils/createLogger.js.map +1 -1
- package/dist/utils/formatBytes.d.ts +1 -0
- package/dist/utils/formatBytes.d.ts.map +1 -0
- package/dist/utils/formatBytes.js +3 -1
- package/dist/utils/formatBytes.js.map +1 -1
- package/dist/utils/sqlSessionToCrypto.d.ts +4 -2
- package/dist/utils/sqlSessionToCrypto.d.ts.map +1 -0
- package/dist/utils/sqlSessionToCrypto.js +5 -5
- package/dist/utils/sqlSessionToCrypto.js.map +1 -1
- package/dist/utils/uint8uuid.d.ts +1 -4
- package/dist/utils/uint8uuid.d.ts.map +1 -0
- package/dist/utils/uint8uuid.js +1 -7
- package/dist/utils/uint8uuid.js.map +1 -1
- package/package.json +58 -87
- package/src/Client.ts +3304 -0
- package/{dist/IStorage.d.ts → src/Storage.ts} +70 -62
- package/src/__tests__/codec.test.ts +251 -0
- package/src/__tests__/ghost.png +0 -0
- package/src/__tests__/harness/fixtures.ts +22 -0
- package/src/__tests__/harness/memory-storage.ts +254 -0
- package/src/__tests__/harness/platform-transports.ts +17 -0
- package/src/__tests__/harness/poison-node-imports.ts +108 -0
- package/src/__tests__/harness/shared-suite.ts +446 -0
- package/src/__tests__/platform-browser.test.ts +19 -0
- package/src/__tests__/platform-node.test.ts +10 -0
- package/src/__tests__/triggered.png +0 -0
- package/src/codec.ts +68 -0
- package/src/codecs.ts +101 -0
- package/src/index.ts +33 -0
- package/src/keystore/memory.ts +30 -0
- package/src/keystore/node.ts +91 -0
- package/src/preset/common.ts +13 -0
- package/src/preset/node.ts +34 -0
- package/src/preset/test.ts +37 -0
- package/src/storage/node.ts +33 -0
- package/src/storage/schema.ts +94 -0
- package/src/storage/sqlite.ts +676 -0
- package/src/transport/types.ts +29 -0
- package/src/transport/websocket.ts +106 -0
- package/src/types/crypto.ts +39 -0
- package/src/types/identity.ts +18 -0
- package/src/types/index.ts +9 -0
- package/src/utils/capitalize.ts +6 -0
- package/src/utils/createLogger.ts +37 -0
- package/src/utils/formatBytes.ts +15 -0
- package/src/utils/sqlSessionToCrypto.ts +16 -0
- package/src/utils/uint8uuid.ts +7 -0
- package/dist/IStorage.js +0 -2
- package/dist/IStorage.js.map +0 -1
- package/dist/keystore/types.d.ts +0 -4
- package/dist/keystore/types.js +0 -2
- package/dist/keystore/types.js.map +0 -1
- package/dist/preset/expo.d.ts +0 -2
- package/dist/preset/expo.js +0 -37
- package/dist/preset/expo.js.map +0 -1
- package/dist/preset/tauri.d.ts +0 -2
- package/dist/preset/tauri.js +0 -35
- package/dist/preset/tauri.js.map +0 -1
- package/dist/preset/types.d.ts +0 -13
- package/dist/preset/types.js +0 -2
- package/dist/preset/types.js.map +0 -1
- package/dist/storage/expo.d.ts +0 -3
- package/dist/storage/expo.js +0 -18
- package/dist/storage/expo.js.map +0 -1
- package/dist/storage/tauri.d.ts +0 -3
- package/dist/storage/tauri.js +0 -21
- package/dist/storage/tauri.js.map +0 -1
- package/dist/transport/browser.d.ts +0 -17
- package/dist/transport/browser.js +0 -56
- package/dist/transport/browser.js.map +0 -1
- package/dist/utils/constants.d.ts +0 -8
- package/dist/utils/constants.js +0 -9
- package/dist/utils/constants.js.map +0 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test logger for integration tests.
|
|
3
|
+
*/
|
|
4
|
+
import type { Logger } from "../../transport/types.js";
|
|
5
|
+
|
|
6
|
+
export const testLogger: Logger = {
|
|
7
|
+
debug(_m: string) {},
|
|
8
|
+
error(m: string) {
|
|
9
|
+
console.error(`[test] ${m}`);
|
|
10
|
+
},
|
|
11
|
+
info(m: string) {
|
|
12
|
+
console.log(`[test] ${m}`);
|
|
13
|
+
},
|
|
14
|
+
warn(m: string) {
|
|
15
|
+
console.warn(`[test] ${m}`);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Plugin } from "vite";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Vite plugin that catches Node builtin imports AND Node-only globals
|
|
5
|
+
* in library source (src/) during transformation.
|
|
6
|
+
*
|
|
7
|
+
* Vitest runs in Node where builtins resolve natively and globals like
|
|
8
|
+
* Buffer/process exist — so tests pass even when the code would crash
|
|
9
|
+
* in a browser or Tauri webview. This plugin catches those at transform
|
|
10
|
+
* time, which vitest does invoke.
|
|
11
|
+
*
|
|
12
|
+
* Uses Node's own builtinModules list — no manual maintenance needed.
|
|
13
|
+
*/
|
|
14
|
+
import { builtinModules } from "node:module";
|
|
15
|
+
|
|
16
|
+
const nodeBuiltins = new Set([
|
|
17
|
+
...builtinModules,
|
|
18
|
+
...builtinModules.map((m) => `node:${m}`),
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// Node-only globals that don't exist in browsers/Tauri/RN.
|
|
22
|
+
// \bBuffer\b won't match ArrayBuffer or SharedArrayBuffer (no word boundary).
|
|
23
|
+
const NODE_GLOBALS = ["Buffer", "process", "__dirname", "__filename"];
|
|
24
|
+
|
|
25
|
+
// Matches: import ... from "events" or import ... from 'node:os'
|
|
26
|
+
// Also: export ... from "events"
|
|
27
|
+
const IMPORT_RE = /(?:import|export)\s+.*?\s+from\s+['"]([^'"]+)['"]/g;
|
|
28
|
+
// Matches: await import("events")
|
|
29
|
+
const DYNAMIC_IMPORT_RE = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
30
|
+
|
|
31
|
+
type Violation = { kind: "global" | "import"; line: number; name: string };
|
|
32
|
+
|
|
33
|
+
export function poisonNodeImports(): Plugin {
|
|
34
|
+
return {
|
|
35
|
+
enforce: "pre",
|
|
36
|
+
name: "poison-node-imports",
|
|
37
|
+
transform(code: string, id: string) {
|
|
38
|
+
// Only check library source — not tests or dependencies
|
|
39
|
+
if (!id.includes("/src/")) return null;
|
|
40
|
+
if (id.includes("__tests__")) return null;
|
|
41
|
+
if (id.includes("node_modules")) return null;
|
|
42
|
+
if (isNodeOnlyFile(id)) return null;
|
|
43
|
+
|
|
44
|
+
const violations = findViolations(code);
|
|
45
|
+
if (violations.length === 0) return null;
|
|
46
|
+
|
|
47
|
+
const file = id.replace(/^.*\/src\//, "src/");
|
|
48
|
+
const msgs = violations
|
|
49
|
+
.map((v) => ` line ${String(v.line)}: ${v.name} (${v.kind})`)
|
|
50
|
+
.join("\n");
|
|
51
|
+
throw new Error(
|
|
52
|
+
`[platform-guard] Node-only code in ${file}:\n${msgs}\n` +
|
|
53
|
+
`These would crash in browser/RN/Tauri. Use browser-safe alternatives or dynamic imports.`,
|
|
54
|
+
);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findViolations(code: string): Violation[] {
|
|
60
|
+
const results: Violation[] = [];
|
|
61
|
+
const lines = code.split("\n");
|
|
62
|
+
const strippedLines = stripComments(code).split("\n");
|
|
63
|
+
|
|
64
|
+
// Check imports against the original code (comments don't affect import syntax)
|
|
65
|
+
for (let i = 0; i < lines.length; i++) {
|
|
66
|
+
const lineText = lines[i];
|
|
67
|
+
for (const re of [IMPORT_RE, DYNAMIC_IMPORT_RE]) {
|
|
68
|
+
re.lastIndex = 0;
|
|
69
|
+
let match;
|
|
70
|
+
while ((match = re.exec(lineText ?? "")) !== null) {
|
|
71
|
+
const mod = match[1]?.replace(/\.js$/, "").replace(/\.ts$/, "");
|
|
72
|
+
if (mod !== undefined && nodeBuiltins.has(mod)) {
|
|
73
|
+
results.push({
|
|
74
|
+
kind: "import",
|
|
75
|
+
line: i + 1,
|
|
76
|
+
name: match[1] ?? "",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check globals against comment-stripped code
|
|
84
|
+
for (let i = 0; i < strippedLines.length; i++) {
|
|
85
|
+
const lineText = strippedLines[i] ?? "";
|
|
86
|
+
for (const g of NODE_GLOBALS) {
|
|
87
|
+
const re = new RegExp(`\\b${g}\\b`);
|
|
88
|
+
if (re.test(lineText)) {
|
|
89
|
+
results.push({ kind: "global", line: i + 1, name: g });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isNodeOnlyFile(id: string): boolean {
|
|
98
|
+
// These files are only loaded via dynamic import on the Node path.
|
|
99
|
+
if (id.includes("/storage/node")) return true;
|
|
100
|
+
if (id.includes("/utils/createLogger")) return true;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Strip comments to avoid false positives on globals in JSDoc / inline comments. */
|
|
105
|
+
function stripComments(code: string): string {
|
|
106
|
+
// Remove single-line comments, but not URLs (://), and multi-line comments
|
|
107
|
+
return code.replace(/\/\/(?!:).*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
108
|
+
}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared integration test body. Called by each platform entry file
|
|
3
|
+
* with a different adapter factory.
|
|
4
|
+
*
|
|
5
|
+
* Runs register → login → connect → send/receive DM against a real spire.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ClientOptions, Message } from "../../index.js";
|
|
9
|
+
import type { Storage } from "../../Storage.js";
|
|
10
|
+
import type { Logger } from "../../transport/types.js";
|
|
11
|
+
|
|
12
|
+
import { Client } from "../../index.js";
|
|
13
|
+
|
|
14
|
+
import { testFile, testImage } from "./fixtures.js";
|
|
15
|
+
|
|
16
|
+
export function platformSuite(
|
|
17
|
+
platformName: string,
|
|
18
|
+
logger: Logger,
|
|
19
|
+
makeStorage: (SK: string, opts: ClientOptions) => Promise<Storage>,
|
|
20
|
+
) {
|
|
21
|
+
describe.sequential(`platform: ${platformName}`, () => {
|
|
22
|
+
let client: Client;
|
|
23
|
+
const username = Client.randomUsername();
|
|
24
|
+
const password = "platform-test-pw";
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
const SK = Client.generateSecretKey();
|
|
28
|
+
const opts: ClientOptions = {
|
|
29
|
+
dbLogLevel: "error",
|
|
30
|
+
inMemoryDb: true,
|
|
31
|
+
logger,
|
|
32
|
+
logLevel: "error",
|
|
33
|
+
...apiUrlOverrideFromEnv(),
|
|
34
|
+
};
|
|
35
|
+
const storage = await makeStorage(SK, opts);
|
|
36
|
+
client = await Client.create(SK, opts, storage);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
try {
|
|
41
|
+
await client.close();
|
|
42
|
+
} catch {}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("register", async () => {
|
|
46
|
+
const [user, err] = await client.register(username, password);
|
|
47
|
+
expect(err).toBeNull();
|
|
48
|
+
expect(user!.username).toBe(username);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("login", async () => {
|
|
52
|
+
const result = await client.login(username, password);
|
|
53
|
+
expect(result.ok).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("connect (websocket auth)", async () => {
|
|
57
|
+
await connectAndWait(client, `[${platformName}] WS auth`);
|
|
58
|
+
expect(true).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("send and receive DM (self)", async () => {
|
|
62
|
+
const me = client.me.user();
|
|
63
|
+
const msgPromise = waitForMessage(
|
|
64
|
+
client,
|
|
65
|
+
(m) => m.direction === "incoming" && m.decrypted,
|
|
66
|
+
`[${platformName}] self-DM`,
|
|
67
|
+
);
|
|
68
|
+
void client.messages.send(me.userID, "platform-test");
|
|
69
|
+
const msg = await msgPromise;
|
|
70
|
+
expect(msg.message).toBe("platform-test");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("two-user DM", async () => {
|
|
74
|
+
const SK2 = Client.generateSecretKey();
|
|
75
|
+
const opts2: ClientOptions = {
|
|
76
|
+
dbLogLevel: "error",
|
|
77
|
+
inMemoryDb: true,
|
|
78
|
+
logger,
|
|
79
|
+
logLevel: "error",
|
|
80
|
+
...apiUrlOverrideFromEnv(),
|
|
81
|
+
};
|
|
82
|
+
const storage2 = await makeStorage(SK2, opts2);
|
|
83
|
+
const client2 = await Client.create(SK2, opts2, storage2);
|
|
84
|
+
const username2 = Client.randomUsername();
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const [user2, regErr] = await client2.register(
|
|
88
|
+
username2,
|
|
89
|
+
"test-pw-2",
|
|
90
|
+
);
|
|
91
|
+
expect(regErr).toBeNull();
|
|
92
|
+
|
|
93
|
+
const loginErr = await client2.login(username2, "test-pw-2");
|
|
94
|
+
expect(loginErr.ok).toBe(true);
|
|
95
|
+
|
|
96
|
+
await connectAndWait(client2, "client2");
|
|
97
|
+
|
|
98
|
+
// client sends to client2, client2 receives
|
|
99
|
+
const msgPromise = waitForMessage(
|
|
100
|
+
client2,
|
|
101
|
+
(m) => m.direction === "incoming" && m.decrypted,
|
|
102
|
+
`[${platformName}] two-user DM`,
|
|
103
|
+
15_000,
|
|
104
|
+
);
|
|
105
|
+
void client.messages.send(user2!.userID, "hello from user 1");
|
|
106
|
+
const msg = await msgPromise;
|
|
107
|
+
expect(msg.message).toBe("hello from user 1");
|
|
108
|
+
} finally {
|
|
109
|
+
await client2.close().catch(() => {});
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("group messaging in channel", async () => {
|
|
114
|
+
const SK2 = Client.generateSecretKey();
|
|
115
|
+
const opts2: ClientOptions = {
|
|
116
|
+
dbLogLevel: "error",
|
|
117
|
+
inMemoryDb: true,
|
|
118
|
+
logger,
|
|
119
|
+
logLevel: "error",
|
|
120
|
+
...apiUrlOverrideFromEnv(),
|
|
121
|
+
};
|
|
122
|
+
const storage2 = await makeStorage(SK2, opts2);
|
|
123
|
+
const client2 = await Client.create(SK2, opts2, storage2);
|
|
124
|
+
const username2 = Client.randomUsername();
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
// Register + login + connect user2
|
|
128
|
+
await client2.register(username2, "test-pw-2");
|
|
129
|
+
await client2.login(username2, "test-pw-2");
|
|
130
|
+
await connectAndWait(client2, "client2");
|
|
131
|
+
|
|
132
|
+
// user1 creates server + channel
|
|
133
|
+
const server = await client.servers.create("test-server");
|
|
134
|
+
expect(server).toBeTruthy();
|
|
135
|
+
const channels = await client.channels.retrieve(
|
|
136
|
+
server.serverID,
|
|
137
|
+
);
|
|
138
|
+
expect(channels.length).toBeGreaterThan(0);
|
|
139
|
+
const channel = channels[0];
|
|
140
|
+
if (!channel) throw new Error("No channel found");
|
|
141
|
+
|
|
142
|
+
// user1 creates invite, user2 redeems it
|
|
143
|
+
const invite = await client.invites.create(
|
|
144
|
+
server.serverID,
|
|
145
|
+
"1h",
|
|
146
|
+
);
|
|
147
|
+
expect(invite).toBeTruthy();
|
|
148
|
+
await client2.invites.redeem(invite.inviteID);
|
|
149
|
+
|
|
150
|
+
// user1 sends group message, user2 receives it
|
|
151
|
+
const msgPromise = waitForMessage(
|
|
152
|
+
client2,
|
|
153
|
+
(m) =>
|
|
154
|
+
m.direction === "incoming" &&
|
|
155
|
+
m.decrypted &&
|
|
156
|
+
m.group === channel.channelID,
|
|
157
|
+
"group message receive",
|
|
158
|
+
15_000,
|
|
159
|
+
);
|
|
160
|
+
void client.messages.group(channel.channelID, "hello channel");
|
|
161
|
+
const msg = await msgPromise;
|
|
162
|
+
expect(msg.message).toBe("hello channel");
|
|
163
|
+
|
|
164
|
+
// Cleanup
|
|
165
|
+
await client.servers.delete(server.serverID);
|
|
166
|
+
} finally {
|
|
167
|
+
await client2.close().catch(() => {});
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("loginWithDeviceKey (auto-login)", async () => {
|
|
172
|
+
// Simulate app restart: create a new Client with the same
|
|
173
|
+
// device key, authenticate without password.
|
|
174
|
+
const deviceKey = client.getKeys().private;
|
|
175
|
+
const deviceID = client.me.device().deviceID;
|
|
176
|
+
const opts2: ClientOptions = {
|
|
177
|
+
dbLogLevel: "error",
|
|
178
|
+
inMemoryDb: true,
|
|
179
|
+
logger,
|
|
180
|
+
logLevel: "error",
|
|
181
|
+
...apiUrlOverrideFromEnv(),
|
|
182
|
+
};
|
|
183
|
+
const storage2 = await makeStorage(deviceKey, opts2);
|
|
184
|
+
const client2 = await Client.create(deviceKey, opts2, storage2);
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const authErr = await client2.loginWithDeviceKey(deviceID);
|
|
188
|
+
expect(authErr).toBeNull();
|
|
189
|
+
|
|
190
|
+
await connectAndWait(client2, "device-key");
|
|
191
|
+
|
|
192
|
+
// Same user, same identity
|
|
193
|
+
expect(client2.me.user().userID).toBe(client.me.user().userID);
|
|
194
|
+
expect(client2.me.user().username).toBe(username);
|
|
195
|
+
} finally {
|
|
196
|
+
await client2.close().catch(() => {});
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("server CRUD", async () => {
|
|
201
|
+
const permissions = await client.permissions.retrieve();
|
|
202
|
+
expect(permissions).toEqual([]);
|
|
203
|
+
|
|
204
|
+
const server = await client.servers.create("Test Server");
|
|
205
|
+
const serverList = await client.servers.retrieve();
|
|
206
|
+
expect(serverList.some((s) => s.serverID === server.serverID)).toBe(
|
|
207
|
+
true,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const byID = await client.servers.retrieveByID(server.serverID);
|
|
211
|
+
expect(byID?.serverID).toBe(server.serverID);
|
|
212
|
+
|
|
213
|
+
await client.servers.delete(server.serverID);
|
|
214
|
+
const afterDelete = await client.servers.retrieve();
|
|
215
|
+
expect(
|
|
216
|
+
afterDelete.some((s) => s.serverID === server.serverID),
|
|
217
|
+
).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("channel CRUD", async () => {
|
|
221
|
+
const server = await client.servers.create("Channel Test Server");
|
|
222
|
+
const channel = await client.channels.create(
|
|
223
|
+
"Test Channel",
|
|
224
|
+
server.serverID,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const byID = await client.channels.retrieveByID(channel.channelID);
|
|
228
|
+
expect(byID?.channelID).toBe(channel.channelID);
|
|
229
|
+
|
|
230
|
+
await client.channels.delete(channel.channelID);
|
|
231
|
+
const channels = await client.channels.retrieve(server.serverID);
|
|
232
|
+
expect(
|
|
233
|
+
channels.some((c) => c.channelID === channel.channelID),
|
|
234
|
+
).toBe(false);
|
|
235
|
+
// Default channel still exists
|
|
236
|
+
expect(channels.length).toBe(1);
|
|
237
|
+
|
|
238
|
+
await client.servers.delete(server.serverID);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("invite create + redeem", async () => {
|
|
242
|
+
const server = await client.servers.create("Invite Test Server");
|
|
243
|
+
const invite = await client.invites.create(server.serverID, "1h");
|
|
244
|
+
expect(invite).toBeTruthy();
|
|
245
|
+
expect(invite.serverID).toBe(server.serverID);
|
|
246
|
+
|
|
247
|
+
await client.invites.redeem(invite.inviteID);
|
|
248
|
+
await client.servers.delete(server.serverID);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("message history retrieve + delete", async () => {
|
|
252
|
+
const me = client.me.user();
|
|
253
|
+
|
|
254
|
+
// Send a message and wait for it
|
|
255
|
+
const msgPromise = waitForMessage(
|
|
256
|
+
client,
|
|
257
|
+
(m) => m.direction === "incoming" && m.decrypted,
|
|
258
|
+
"history DM",
|
|
259
|
+
);
|
|
260
|
+
void client.messages.send(me.userID, "history-test");
|
|
261
|
+
await msgPromise;
|
|
262
|
+
|
|
263
|
+
const history = await client.messages.retrieve(me.userID);
|
|
264
|
+
expect(history.length).toBeGreaterThan(0);
|
|
265
|
+
|
|
266
|
+
await client.messages.delete(me.userID);
|
|
267
|
+
const afterDelete = await client.messages.retrieve(me.userID);
|
|
268
|
+
expect(afterDelete.length).toBe(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// TODO: multi-device fan-out requires sender to query fresh device
|
|
272
|
+
// list before sending. Currently the sender caches one device and
|
|
273
|
+
// the message only reaches device1.
|
|
274
|
+
test.todo("multi-device message sync", async () => {
|
|
275
|
+
const SK2 = Client.generateSecretKey();
|
|
276
|
+
const opts2: ClientOptions = {
|
|
277
|
+
dbLogLevel: "error",
|
|
278
|
+
inMemoryDb: true,
|
|
279
|
+
logger,
|
|
280
|
+
logLevel: "error",
|
|
281
|
+
...apiUrlOverrideFromEnv(),
|
|
282
|
+
};
|
|
283
|
+
const storage2 = await makeStorage(SK2, opts2);
|
|
284
|
+
const device2 = await Client.create(SK2, opts2, storage2);
|
|
285
|
+
|
|
286
|
+
// Sender: separate user
|
|
287
|
+
const SK3 = Client.generateSecretKey();
|
|
288
|
+
const opts3: ClientOptions = {
|
|
289
|
+
dbLogLevel: "error",
|
|
290
|
+
inMemoryDb: true,
|
|
291
|
+
logger,
|
|
292
|
+
logLevel: "error",
|
|
293
|
+
...apiUrlOverrideFromEnv(),
|
|
294
|
+
};
|
|
295
|
+
const storage3 = await makeStorage(SK3, opts3);
|
|
296
|
+
const sender = await Client.create(SK3, opts3, storage3);
|
|
297
|
+
const senderName = Client.randomUsername();
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
// Register device2 under same account
|
|
301
|
+
await device2.login(username, password);
|
|
302
|
+
await connectAndWait(device2, "device2");
|
|
303
|
+
|
|
304
|
+
// Register + connect sender
|
|
305
|
+
await sender.register(senderName, "sender-pw");
|
|
306
|
+
await sender.login(senderName, "sender-pw");
|
|
307
|
+
await connectAndWait(sender, "sender");
|
|
308
|
+
|
|
309
|
+
const targetUserID = client.me.user().userID;
|
|
310
|
+
|
|
311
|
+
// Both devices listen for the incoming DM
|
|
312
|
+
const received = { device1: false, device2: false };
|
|
313
|
+
|
|
314
|
+
const waitForBoth = new Promise<void>((resolve, reject) => {
|
|
315
|
+
const timer = setTimeout(() => {
|
|
316
|
+
reject(
|
|
317
|
+
new Error(
|
|
318
|
+
`multi-device sync timed out (d1=${String(received.device1)}, d2=${String(received.device2)})`,
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
}, 15_000);
|
|
322
|
+
const check = () => {
|
|
323
|
+
if (received.device1 && received.device2) {
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
resolve();
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
client.on("message", (msg: Message) => {
|
|
330
|
+
if (
|
|
331
|
+
msg.direction === "incoming" &&
|
|
332
|
+
msg.decrypted &&
|
|
333
|
+
msg.message === "sync-test"
|
|
334
|
+
) {
|
|
335
|
+
received.device1 = true;
|
|
336
|
+
check();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
device2.on("message", (msg: Message) => {
|
|
340
|
+
if (
|
|
341
|
+
msg.direction === "incoming" &&
|
|
342
|
+
msg.decrypted &&
|
|
343
|
+
msg.message === "sync-test"
|
|
344
|
+
) {
|
|
345
|
+
received.device2 = true;
|
|
346
|
+
check();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
void sender.messages.send(targetUserID, "sync-test");
|
|
352
|
+
await waitForBoth;
|
|
353
|
+
|
|
354
|
+
expect(received.device1).toBe(true);
|
|
355
|
+
expect(received.device2).toBe(true);
|
|
356
|
+
} finally {
|
|
357
|
+
await device2.close().catch(() => {});
|
|
358
|
+
await sender.close().catch(() => {});
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("file upload + download", async () => {
|
|
363
|
+
const [details, key] = await client.files.create(testFile);
|
|
364
|
+
expect(details.fileID).toBeTruthy();
|
|
365
|
+
|
|
366
|
+
const fetched = await client.files.retrieve(details.fileID, key);
|
|
367
|
+
expect(fetched).toBeTruthy();
|
|
368
|
+
expect(new Uint8Array(fetched!.data)).toEqual(testFile);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("emoji upload", async () => {
|
|
372
|
+
const server = await client.servers.create("Emoji Test Server");
|
|
373
|
+
const emoji = await client.emoji.create(
|
|
374
|
+
testImage,
|
|
375
|
+
"testmoji",
|
|
376
|
+
server.serverID,
|
|
377
|
+
);
|
|
378
|
+
expect(emoji).toBeTruthy();
|
|
379
|
+
|
|
380
|
+
const list = await client.emoji.retrieveList(server.serverID);
|
|
381
|
+
expect(list.some((e) => e.emojiID === emoji!.emojiID)).toBe(true);
|
|
382
|
+
|
|
383
|
+
await client.servers.delete(server.serverID);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("avatar upload", async () => {
|
|
387
|
+
await client.me.setAvatar(testImage);
|
|
388
|
+
expect(true).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function apiUrlOverrideFromEnv():
|
|
394
|
+
| Pick<ClientOptions, "host" | "unsafeHttp">
|
|
395
|
+
| undefined {
|
|
396
|
+
const raw = process.env["API_URL"]?.trim();
|
|
397
|
+
if (!raw) return undefined;
|
|
398
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
399
|
+
const u = new URL(raw);
|
|
400
|
+
return { host: u.host, unsafeHttp: u.protocol === "http:" };
|
|
401
|
+
}
|
|
402
|
+
return { host: raw, unsafeHttp: true };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function connectAndWait(
|
|
406
|
+
c: Client,
|
|
407
|
+
label: string,
|
|
408
|
+
timeout = 10_000,
|
|
409
|
+
): Promise<void> {
|
|
410
|
+
return new Promise((resolve, reject) => {
|
|
411
|
+
const timer = setTimeout(() => {
|
|
412
|
+
reject(new Error(`${label} connect timed out`));
|
|
413
|
+
}, timeout);
|
|
414
|
+
const onConnected = () => {
|
|
415
|
+
clearTimeout(timer);
|
|
416
|
+
c.off("connected", onConnected);
|
|
417
|
+
resolve();
|
|
418
|
+
};
|
|
419
|
+
c.on("connected", onConnected);
|
|
420
|
+
c.connect().catch((err: unknown) => {
|
|
421
|
+
clearTimeout(timer);
|
|
422
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function waitForMessage(
|
|
428
|
+
c: Client,
|
|
429
|
+
predicate: (m: Message) => boolean,
|
|
430
|
+
label: string,
|
|
431
|
+
timeout = 10_000,
|
|
432
|
+
): Promise<Message> {
|
|
433
|
+
return new Promise((resolve, reject) => {
|
|
434
|
+
const timer = setTimeout(() => {
|
|
435
|
+
reject(new Error(`${label} message timed out`));
|
|
436
|
+
}, timeout);
|
|
437
|
+
const onMsg = (msg: Message) => {
|
|
438
|
+
if (predicate(msg)) {
|
|
439
|
+
clearTimeout(timer);
|
|
440
|
+
c.off("message", onMsg);
|
|
441
|
+
resolve(msg);
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
c.on("message", onMsg);
|
|
445
|
+
});
|
|
446
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ClientOptions } from "../index.js";
|
|
2
|
+
|
|
3
|
+
import { MemoryStorage } from "./harness/memory-storage.js";
|
|
4
|
+
import { browserTestAdapters } from "./harness/platform-transports.js";
|
|
5
|
+
// Browser platform test — covers Tauri, Expo/RN, and web.
|
|
6
|
+
// Runs with the poison plugin (vitest.config.browser.ts) which catches
|
|
7
|
+
// Node builtins and globals at compile time. Uses MemoryStorage (no
|
|
8
|
+
// Node SQLite) and BrowserTestWS (Uint8Array binary).
|
|
9
|
+
import { platformSuite } from "./harness/shared-suite.js";
|
|
10
|
+
|
|
11
|
+
platformSuite(
|
|
12
|
+
"browser",
|
|
13
|
+
browserTestAdapters,
|
|
14
|
+
async (SK: string, _opts: ClientOptions) => {
|
|
15
|
+
const storage = new MemoryStorage(SK);
|
|
16
|
+
await storage.init();
|
|
17
|
+
return storage;
|
|
18
|
+
},
|
|
19
|
+
);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ClientOptions } from "../index.js";
|
|
2
|
+
|
|
3
|
+
import { createNodeStorage } from "../storage/node.js";
|
|
4
|
+
|
|
5
|
+
import { testLogger } from "./harness/platform-transports.js";
|
|
6
|
+
import { platformSuite } from "./harness/shared-suite.js";
|
|
7
|
+
|
|
8
|
+
platformSuite("node", testLogger, (SK: string, _opts: ClientOptions) =>
|
|
9
|
+
Promise.resolve(createNodeStorage(":memory:", SK)),
|
|
10
|
+
);
|
|
Binary file
|
package/src/codec.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-safe codec factory for msgpack encode/decode with optional Zod validation.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { MailWSSchema } from "@vex-chat/types";
|
|
6
|
+
* const MailCodec = createCodec(MailWSSchema);
|
|
7
|
+
*
|
|
8
|
+
* // SDK/Apps (trusted internal) — fast, typed, no Zod overhead:
|
|
9
|
+
* const msg = MailCodec.decode(data);
|
|
10
|
+
*
|
|
11
|
+
* // Spire (trust boundary) — validates at runtime:
|
|
12
|
+
* const msg = MailCodec.decodeSafe(data);
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { z } from "zod/v4";
|
|
16
|
+
|
|
17
|
+
import { Packr } from "msgpackr";
|
|
18
|
+
|
|
19
|
+
const _packr = new Packr({ moreTypes: false, useRecords: false });
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a type-safe codec for msgpack encode/decode.
|
|
23
|
+
*
|
|
24
|
+
* @param schema - A Zod schema to validate against
|
|
25
|
+
* @returns An object with encode, decode, encodeSafe, and decodeSafe methods
|
|
26
|
+
*/
|
|
27
|
+
export function createCodec<T extends z.ZodType>(schema: T) {
|
|
28
|
+
type Msg = z.infer<T>;
|
|
29
|
+
return {
|
|
30
|
+
/** Decode msgpack data — typed but not validated. Fast path for SDK. */
|
|
31
|
+
decode: (data: Uint8Array): Msg => schema.parse(decode(data)) as Msg,
|
|
32
|
+
|
|
33
|
+
/** Decode + validate with Zod. Safe path for trust boundaries (Spire). */
|
|
34
|
+
decodeSafe: (data: Uint8Array): Msg =>
|
|
35
|
+
schema.parse(decode(data)) as Msg,
|
|
36
|
+
|
|
37
|
+
/** Encode to msgpack — typed but not validated. */
|
|
38
|
+
encode: (msg: Msg): Uint8Array => encode(msg),
|
|
39
|
+
|
|
40
|
+
/** Validate + encode. Ensures outgoing messages match the schema. */
|
|
41
|
+
encodeSafe: (msg: Msg): Uint8Array => {
|
|
42
|
+
schema.parse(msg);
|
|
43
|
+
return encode(msg);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function decode(data: Uint8Array): unknown {
|
|
49
|
+
return _packr.decode(data) as unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Encode a value to msgpack. Returns a fresh Uint8Array copy
|
|
54
|
+
* (not a subarray of the internal pool buffer) to avoid browser
|
|
55
|
+
* XMLHttpRequest.send() corruption (axios issue #4068).
|
|
56
|
+
*/
|
|
57
|
+
function encode(value: unknown): Uint8Array {
|
|
58
|
+
const packed = _packr.encode(value);
|
|
59
|
+
return new Uint8Array(
|
|
60
|
+
packed.buffer.slice(
|
|
61
|
+
packed.byteOffset,
|
|
62
|
+
packed.byteOffset + packed.byteLength,
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Raw msgpack encode/decode without schema validation. */
|
|
68
|
+
export const msgpack = { decode, encode };
|