meshingly-mcp 0.1.0 → 0.2.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.
@@ -1,80 +1,80 @@
1
-
2
-
3
-
4
-
5
-
6
-
7
-
8
-
9
-
10
- const DEFAULT_RETRY = { attempts: 5, baseDelayMs: 500, maxDelayMs: 15_000 };
11
-
12
- export class BrokerError extends Error {
13
- status ;
14
- constructor(status , message ) {
15
- super(message);
16
- this.name = "BrokerError";
17
- this.status = status;
18
- }
19
- }
20
-
21
- const isRetryable = (status ) => status === 429 || status >= 500;
22
- const sleep = (ms ) => new Promise((resolve) => setTimeout(resolve, ms));
23
-
24
- export class BrokerClient {
25
- baseUrl ;
26
- channelId ;
27
- authToken ;
28
- retry ;
29
-
30
- constructor(opts ) {
31
- this.baseUrl = opts.serverUrl.replace(/\/+$/, "");
32
- this.channelId = opts.channelId;
33
- this.authToken = opts.authToken;
34
- this.retry = { ...DEFAULT_RETRY, ...opts.retry };
35
- }
36
-
37
- push(msg ) {
38
- return this.request("POST", `/v1/channels/${this.channelId}/messages`, msg);
39
- }
40
-
41
- poll(opts ) {
42
- const q = new URLSearchParams({ after: String(opts.after), limit: String(opts.limit ?? 50) });
43
- if (opts.excludeSession) q.set("exclude", opts.excludeSession);
44
- if (opts.waitSeconds) q.set("wait", String(opts.waitSeconds));
45
- return this.request("GET", `/v1/channels/${this.channelId}/messages?${q}`, undefined, (opts.waitSeconds ?? 0) * 1000);
46
- }
47
-
48
- /** Retries network errors, 429 and 5xx with exponential backoff + jitter. Pushes are idempotent by message id. */
49
- async request (method , path , body , extraTimeoutMs = 0) {
50
- let lastError ;
51
- for (let attempt = 0; attempt < this.retry.attempts; attempt++) {
52
- try {
53
- const res = await fetch(this.baseUrl + path, {
54
- method,
55
- headers: { authorization: `Bearer ${this.authToken}`, "content-type": "application/json" },
56
- body: body === undefined ? undefined : JSON.stringify(body),
57
- signal: AbortSignal.timeout(15_000 + extraTimeoutMs),
58
- });
59
- if (res.ok) return (await res.json()) ;
60
- const text = await res.text().catch(() => "");
61
- const err = new BrokerError(res.status, `Broker responded ${res.status}: ${text.slice(0, 200)}`);
62
- if (!isRetryable(res.status)) throw err;
63
- lastError = err;
64
- const retryAfter = Number(res.headers.get("retry-after"));
65
- await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : this.backoff(attempt));
66
- continue;
67
- } catch (err) {
68
- if (err instanceof BrokerError && !isRetryable(err.status)) throw err;
69
- lastError = err;
70
- }
71
- if (attempt < this.retry.attempts - 1) await sleep(this.backoff(attempt));
72
- }
73
- throw lastError instanceof Error ? lastError : new Error("Broker request failed");
74
- }
75
-
76
- backoff(attempt ) {
77
- const exp = Math.min(this.retry.maxDelayMs, this.retry.baseDelayMs * 2 ** attempt);
78
- return exp / 2 + Math.random() * (exp / 2);
79
- }
80
- }
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+ const DEFAULT_RETRY = { attempts: 5, baseDelayMs: 500, maxDelayMs: 15_000 };
11
+
12
+ export class BrokerError extends Error {
13
+ status ;
14
+ constructor(status , message ) {
15
+ super(message);
16
+ this.name = "BrokerError";
17
+ this.status = status;
18
+ }
19
+ }
20
+
21
+ const isRetryable = (status ) => status === 429 || status >= 500;
22
+ const sleep = (ms ) => new Promise((resolve) => setTimeout(resolve, ms));
23
+
24
+ export class BrokerClient {
25
+ baseUrl ;
26
+ channelId ;
27
+ authToken ;
28
+ retry ;
29
+
30
+ constructor(opts ) {
31
+ this.baseUrl = opts.serverUrl.replace(/\/+$/, "");
32
+ this.channelId = opts.channelId;
33
+ this.authToken = opts.authToken;
34
+ this.retry = { ...DEFAULT_RETRY, ...opts.retry };
35
+ }
36
+
37
+ push(msg ) {
38
+ return this.request("POST", `/v1/channels/${this.channelId}/messages`, msg);
39
+ }
40
+
41
+ poll(opts ) {
42
+ const q = new URLSearchParams({ after: String(opts.after), limit: String(opts.limit ?? 50) });
43
+ if (opts.excludeSession) q.set("exclude", opts.excludeSession);
44
+ if (opts.waitSeconds) q.set("wait", String(opts.waitSeconds));
45
+ return this.request("GET", `/v1/channels/${this.channelId}/messages?${q}`, undefined, (opts.waitSeconds ?? 0) * 1000);
46
+ }
47
+
48
+ /** Retries network errors, 429 and 5xx with exponential backoff + jitter. Pushes are idempotent by message id. */
49
+ async request (method , path , body , extraTimeoutMs = 0) {
50
+ let lastError ;
51
+ for (let attempt = 0; attempt < this.retry.attempts; attempt++) {
52
+ try {
53
+ const res = await fetch(this.baseUrl + path, {
54
+ method,
55
+ headers: { authorization: `Bearer ${this.authToken}`, "content-type": "application/json" },
56
+ body: body === undefined ? undefined : JSON.stringify(body),
57
+ signal: AbortSignal.timeout(15_000 + extraTimeoutMs),
58
+ });
59
+ if (res.ok) return (await res.json()) ;
60
+ const text = await res.text().catch(() => "");
61
+ const err = new BrokerError(res.status, `Broker responded ${res.status}: ${text.slice(0, 200)}`);
62
+ if (!isRetryable(res.status)) throw err;
63
+ lastError = err;
64
+ const retryAfter = Number(res.headers.get("retry-after"));
65
+ await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : this.backoff(attempt));
66
+ continue;
67
+ } catch (err) {
68
+ if (err instanceof BrokerError && !isRetryable(err.status)) throw err;
69
+ lastError = err;
70
+ }
71
+ if (attempt < this.retry.attempts - 1) await sleep(this.backoff(attempt));
72
+ }
73
+ throw lastError instanceof Error ? lastError : new Error("Broker request failed");
74
+ }
75
+
76
+ backoff(attempt ) {
77
+ const exp = Math.min(this.retry.maxDelayMs, this.retry.baseDelayMs * 2 ** attempt);
78
+ return exp / 2 + Math.random() * (exp / 2);
79
+ }
80
+ }
@@ -1,155 +1,155 @@
1
- // Browser-side collaboration crypto. The server only ever receives a random code, a salt, an auth key
2
- // derived from the password, and the crypt key encrypted under a second password-derived key.
3
- // Plain TypeScript on WebCrypto so Node tests can run the exact same code.
4
-
5
- const CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; // 32 symbols: one random byte maps evenly
6
- export const KDF_ITERATIONS = 600_000;
7
-
8
- function toB64u(buf ) {
9
- const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
10
- let bin = "";
11
- for (const b of bytes) bin += String.fromCharCode(b);
12
- return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
13
- }
14
-
15
- function fromB64u(s ) {
16
- const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
17
- return Uint8Array.from(bin, (c) => c.charCodeAt(0));
18
- }
19
-
20
- function randomSymbols(count ) {
21
- const bytes = crypto.getRandomValues(new Uint8Array(count));
22
- return Array.from(bytes, (b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join("");
23
- }
24
-
25
- /** 10 symbols (50 bits). Only locates the collaboration; the password is what protects it. */
26
- export const generateCode = () => randomSymbols(10);
27
-
28
- /** 16 symbols (80 bits), grouped for reading aloud or pasting. */
29
- export const generatePassword = () => randomSymbols(16).replace(/(.{4})(?=.)/g, "$1-");
30
-
31
- export const generateMasterKey = () => toB64u(crypto.getRandomValues(new Uint8Array(32)));
32
-
33
- /** Forgives case, spaces and dashes so people can type what they were given. */
34
- export const normalizeSecret = (input ) => input.toUpperCase().replace(/[^A-Z0-9]/g, "");
35
-
36
- export const formatCode = (code ) => `${code.slice(0, 5)}-${code.slice(5)}`;
37
-
38
-
39
-
40
-
41
-
42
-
43
-
44
-
45
- export async function deriveKeys(password , salt , iterations ) {
46
- const material = await crypto.subtle.importKey("raw", new TextEncoder().encode(normalizeSecret(password)), "PBKDF2", false, [
47
- "deriveBits",
48
- ]);
49
- const bits = new Uint8Array(
50
- await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: fromB64u(salt), iterations }, material, 512),
51
- );
52
- const wrapKey = await crypto.subtle.importKey("raw", bits.slice(32), "AES-GCM", false, ["encrypt", "decrypt"]);
53
- return { authKey: toB64u(bits.slice(0, 32)), wrapKey };
54
- }
55
-
56
-
57
-
58
-
59
-
60
-
61
- async function seal(bundle , wrapKey , code ) {
62
- const iv = crypto.getRandomValues(new Uint8Array(12));
63
- const ct = await crypto.subtle.encrypt(
64
- { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(code) },
65
- wrapKey,
66
- new TextEncoder().encode(JSON.stringify(bundle)),
67
- );
68
- const out = new Uint8Array(iv.length + ct.byteLength);
69
- out.set(iv);
70
- out.set(new Uint8Array(ct), iv.length);
71
- return toB64u(out);
72
- }
73
-
74
- async function open(sealed , wrapKey , code ) {
75
- const raw = fromB64u(sealed);
76
- const plain = await crypto.subtle.decrypt(
77
- { name: "AES-GCM", iv: raw.slice(0, 12), additionalData: new TextEncoder().encode(code) },
78
- wrapKey,
79
- raw.slice(12),
80
- );
81
- return JSON.parse(new TextDecoder().decode(plain)) ;
82
- }
83
-
84
-
85
-
86
-
87
-
88
-
89
-
90
- /** Errors carry a kind instead of text so the UI can show them in the visitor's language. */
91
-
92
-
93
- export class CollabError extends Error {
94
- kind ;
95
- status ;
96
- data ;
97
-
98
- constructor(kind , status = 0, data = {}) {
99
- super(kind);
100
- this.name = "CollabError";
101
- this.kind = kind;
102
- this.status = status;
103
- this.data = data;
104
- }
105
- }
106
-
107
- async function call (url , init ) {
108
- let res ;
109
- try {
110
- res = await fetch(url, init);
111
- } catch {
112
- throw new CollabError("network");
113
- }
114
- const data = (await res.json().catch(() => ({}))) ;
115
- if (!res.ok) throw new CollabError(
116
- res.status === 403 ? "wrong" : res.status === 404 ? "notFound" : res.status === 423 ? "locked" : res.status === 409 ? "taken" : "server",
117
- res.status,
118
- data,
119
- );
120
- return data ;
121
- }
122
-
123
- const post = (body ) => ({
124
- method: "POST",
125
- headers: { "content-type": "application/json" },
126
- body: JSON.stringify(body),
127
- });
128
-
129
- /** Generates code, password and key locally, then registers only the sealed bundle. Retries if the code collides. */
130
- export async function createCollaboration(baseUrl , name ) {
131
- for (let attempt = 0; attempt < 3; attempt++) {
132
- const code = generateCode();
133
- const password = generatePassword();
134
- const salt = toB64u(crypto.getRandomValues(new Uint8Array(16)));
135
- const bundle = { key: generateMasterKey(), name };
136
- const { authKey, wrapKey } = await deriveKeys(password, salt, KDF_ITERATIONS);
137
- try {
138
- await call(`${baseUrl}/v1/collabs`, post({ code, salt, iterations: KDF_ITERATIONS, authKey, bundle: await seal(bundle, wrapKey, code) }));
139
- return { code, password, bundle };
140
- } catch (err) {
141
- if (!(err instanceof CollabError && err.status === 409)) throw err;
142
- }
143
- }
144
- throw new CollabError("taken");
145
- }
146
-
147
- /** Fetches the sealed bundle with proof of the password and opens it locally. */
148
- export async function joinCollaboration(baseUrl , codeInput , password ) {
149
- const code = normalizeSecret(codeInput);
150
- if (code.length !== 10) throw new CollabError("codeFormat");
151
- const params = await call (`${baseUrl}/v1/collabs/${code}`);
152
- const { authKey, wrapKey } = await deriveKeys(password, params.salt, params.iterations);
153
- const joined = await call (`${baseUrl}/v1/collabs/${code}/join`, post({ authKey }));
154
- return open(joined.bundle, wrapKey, code);
155
- }
1
+ // Browser-side collaboration crypto. The server only ever receives a random code, a salt, an auth key
2
+ // derived from the password, and the crypt key encrypted under a second password-derived key.
3
+ // Plain TypeScript on WebCrypto so Node tests can run the exact same code.
4
+
5
+ const CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; // 32 symbols: one random byte maps evenly
6
+ export const KDF_ITERATIONS = 600_000;
7
+
8
+ function toB64u(buf ) {
9
+ const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
10
+ let bin = "";
11
+ for (const b of bytes) bin += String.fromCharCode(b);
12
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
13
+ }
14
+
15
+ function fromB64u(s ) {
16
+ const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
17
+ return Uint8Array.from(bin, (c) => c.charCodeAt(0));
18
+ }
19
+
20
+ function randomSymbols(count ) {
21
+ const bytes = crypto.getRandomValues(new Uint8Array(count));
22
+ return Array.from(bytes, (b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join("");
23
+ }
24
+
25
+ /** 10 symbols (50 bits). Only locates the collaboration; the password is what protects it. */
26
+ export const generateCode = () => randomSymbols(10);
27
+
28
+ /** 16 symbols (80 bits), grouped for reading aloud or pasting. */
29
+ export const generatePassword = () => randomSymbols(16).replace(/(.{4})(?=.)/g, "$1-");
30
+
31
+ export const generateMasterKey = () => toB64u(crypto.getRandomValues(new Uint8Array(32)));
32
+
33
+ /** Forgives case, spaces and dashes so people can type what they were given. */
34
+ export const normalizeSecret = (input ) => input.toUpperCase().replace(/[^A-Z0-9]/g, "");
35
+
36
+ export const formatCode = (code ) => `${code.slice(0, 5)}-${code.slice(5)}`;
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+ export async function deriveKeys(password , salt , iterations ) {
46
+ const material = await crypto.subtle.importKey("raw", new TextEncoder().encode(normalizeSecret(password)), "PBKDF2", false, [
47
+ "deriveBits",
48
+ ]);
49
+ const bits = new Uint8Array(
50
+ await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: fromB64u(salt), iterations }, material, 512),
51
+ );
52
+ const wrapKey = await crypto.subtle.importKey("raw", bits.slice(32), "AES-GCM", false, ["encrypt", "decrypt"]);
53
+ return { authKey: toB64u(bits.slice(0, 32)), wrapKey };
54
+ }
55
+
56
+
57
+
58
+
59
+
60
+
61
+ async function seal(bundle , wrapKey , code ) {
62
+ const iv = crypto.getRandomValues(new Uint8Array(12));
63
+ const ct = await crypto.subtle.encrypt(
64
+ { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(code) },
65
+ wrapKey,
66
+ new TextEncoder().encode(JSON.stringify(bundle)),
67
+ );
68
+ const out = new Uint8Array(iv.length + ct.byteLength);
69
+ out.set(iv);
70
+ out.set(new Uint8Array(ct), iv.length);
71
+ return toB64u(out);
72
+ }
73
+
74
+ async function open(sealed , wrapKey , code ) {
75
+ const raw = fromB64u(sealed);
76
+ const plain = await crypto.subtle.decrypt(
77
+ { name: "AES-GCM", iv: raw.slice(0, 12), additionalData: new TextEncoder().encode(code) },
78
+ wrapKey,
79
+ raw.slice(12),
80
+ );
81
+ return JSON.parse(new TextDecoder().decode(plain)) ;
82
+ }
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+ /** Errors carry a kind instead of text so the UI can show them in the visitor's language. */
91
+
92
+
93
+ export class CollabError extends Error {
94
+ kind ;
95
+ status ;
96
+ data ;
97
+
98
+ constructor(kind , status = 0, data = {}) {
99
+ super(kind);
100
+ this.name = "CollabError";
101
+ this.kind = kind;
102
+ this.status = status;
103
+ this.data = data;
104
+ }
105
+ }
106
+
107
+ async function call (url , init ) {
108
+ let res ;
109
+ try {
110
+ res = await fetch(url, init);
111
+ } catch {
112
+ throw new CollabError("network");
113
+ }
114
+ const data = (await res.json().catch(() => ({}))) ;
115
+ if (!res.ok) throw new CollabError(
116
+ res.status === 403 ? "wrong" : res.status === 404 ? "notFound" : res.status === 423 ? "locked" : res.status === 409 ? "taken" : "server",
117
+ res.status,
118
+ data,
119
+ );
120
+ return data ;
121
+ }
122
+
123
+ const post = (body ) => ({
124
+ method: "POST",
125
+ headers: { "content-type": "application/json" },
126
+ body: JSON.stringify(body),
127
+ });
128
+
129
+ /** Generates code, password and key locally, then registers only the sealed bundle. Retries if the code collides. */
130
+ export async function createCollaboration(baseUrl , name ) {
131
+ for (let attempt = 0; attempt < 3; attempt++) {
132
+ const code = generateCode();
133
+ const password = generatePassword();
134
+ const salt = toB64u(crypto.getRandomValues(new Uint8Array(16)));
135
+ const bundle = { key: generateMasterKey(), name };
136
+ const { authKey, wrapKey } = await deriveKeys(password, salt, KDF_ITERATIONS);
137
+ try {
138
+ await call(`${baseUrl}/v1/collabs`, post({ code, salt, iterations: KDF_ITERATIONS, authKey, bundle: await seal(bundle, wrapKey, code) }));
139
+ return { code, password, bundle };
140
+ } catch (err) {
141
+ if (!(err instanceof CollabError && err.status === 409)) throw err;
142
+ }
143
+ }
144
+ throw new CollabError("taken");
145
+ }
146
+
147
+ /** Fetches the sealed bundle with proof of the password and opens it locally. */
148
+ export async function joinCollaboration(baseUrl , codeInput , password ) {
149
+ const code = normalizeSecret(codeInput);
150
+ if (code.length !== 10) throw new CollabError("codeFormat");
151
+ const params = await call (`${baseUrl}/v1/collabs/${code}`);
152
+ const { authKey, wrapKey } = await deriveKeys(password, params.salt, params.iterations);
153
+ const joined = await call (`${baseUrl}/v1/collabs/${code}/join`, post({ authKey }));
154
+ return open(joined.bundle, wrapKey, code);
155
+ }