@privateclaw/privateclaw-relay 0.1.2 → 0.1.4

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.
@@ -0,0 +1,354 @@
1
+ import { applyTranslations, bindLocaleSelect, getLocale, onLocaleChange } from "./i18n.js?v=20260316-3";
2
+
3
+ const POLICY_BUNDLES = {
4
+ en: {
5
+ privacy: {
6
+ documentTitle: "Privacy | PrivateClaw",
7
+ kicker: "Privacy",
8
+ title: "Privacy at a glance",
9
+ body: "PrivateClaw is built around encrypted, session-based chat. This page explains what data the app, relay, provider, and website may handle.",
10
+ updatedLabel: "Last updated",
11
+ updatedValue: "2026-03-14",
12
+ sections: [
13
+ {
14
+ title: "Scope",
15
+ paragraphs: [
16
+ "This page covers the PrivateClaw website, mobile app, web chat, relay, and provider distributed by GG AI Studio.",
17
+ "If you connect to a relay, provider, or OpenClaw deployment run by somebody else, that operator is responsible for their own handling and retention practices.",
18
+ ],
19
+ },
20
+ {
21
+ title: "Data PrivateClaw may handle",
22
+ items: [
23
+ "session invite data such as session ID, relay endpoint, expiry time, and session key",
24
+ "messages, attachments, and room activity that you choose to send or receive",
25
+ "temporary local files needed to preview or open media and documents",
26
+ "connection and routing metadata such as reconnect attempts, timestamps, and encrypted envelope sizes",
27
+ ],
28
+ },
29
+ {
30
+ title: "Permissions and infrastructure",
31
+ items: [
32
+ "Camera access is used only for scanning QR invites.",
33
+ "File and media access is used only when you choose attachments or open received files.",
34
+ "Network access is used to establish encrypted sessions and exchange encrypted traffic with your selected relay and provider.",
35
+ ],
36
+ },
37
+ {
38
+ title: "Encryption and third parties",
39
+ paragraphs: [
40
+ "PrivateClaw is designed so the relay mostly forwards encrypted payloads. A relay operator may still observe limited metadata such as session IDs, timing, and message sizes.",
41
+ "The selected provider and OpenClaw deployment decrypt content in order to answer your requests. The project does not include advertising SDKs or analytics SDKs by default.",
42
+ ],
43
+ },
44
+ {
45
+ title: "Your choices and contact",
46
+ paragraphs: [
47
+ "You can deny camera permission and paste an invite manually, choose whether to send files, clear app data, or self-host the stack if you want tighter infrastructure control.",
48
+ "For project questions or policy feedback, use GitHub Issues: https://github.com/topcheer/PrivateClaw/issues",
49
+ ],
50
+ },
51
+ ],
52
+ },
53
+ terms: {
54
+ documentTitle: "Terms | PrivateClaw",
55
+ kicker: "Terms",
56
+ title: "Terms of use",
57
+ body: "These terms cover the PrivateClaw website, apps, relay, and provider published by GG AI Studio.",
58
+ updatedLabel: "Last updated",
59
+ updatedValue: "2026-03-14",
60
+ sections: [
61
+ {
62
+ title: "Acceptance and scope",
63
+ paragraphs: [
64
+ "By using PrivateClaw, you agree to these terms. PrivateClaw is an independent project and is not affiliated with OpenClaw.",
65
+ "If you operate your own relay, provider, or OpenClaw deployment, you are also responsible for your own local policies and compliance obligations.",
66
+ ],
67
+ },
68
+ {
69
+ title: "Your responsibilities",
70
+ items: [
71
+ "Use the product lawfully and do not use it to harm others or violate their rights.",
72
+ "Only share messages, files, and invites that you are allowed to share.",
73
+ "Protect invite links and session QR codes because anyone with them may be able to join the room before it expires.",
74
+ ],
75
+ },
76
+ {
77
+ title: "Availability and beta status",
78
+ paragraphs: [
79
+ "PrivateClaw may change, improve, or remove features over time. Some parts of the product are still in beta and may be interrupted or limited.",
80
+ "We do not promise uninterrupted availability for the website, relay, or any hosted community channels.",
81
+ ],
82
+ },
83
+ {
84
+ title: "Third-party services",
85
+ paragraphs: [
86
+ "Your use may also involve third-party software and services such as OpenClaw, self-hosted infrastructure, Apple, Google, Telegram, or your cloud provider.",
87
+ "Those services keep their own terms and privacy rules, and you are responsible for reviewing them where relevant.",
88
+ ],
89
+ },
90
+ {
91
+ title: "Disclaimers and contact",
92
+ paragraphs: [
93
+ "PrivateClaw is provided on an “as is” and “as available” basis to the extent allowed by law. To the extent permitted by law, GG AI Studio disclaims warranties and limits liability for indirect or consequential loss.",
94
+ "For support or legal contact about this project, use GitHub Issues first: https://github.com/topcheer/PrivateClaw/issues",
95
+ ],
96
+ },
97
+ ],
98
+ },
99
+ },
100
+ "zh-CN": {
101
+ privacy: {
102
+ documentTitle: "隐私 | PrivateClaw",
103
+ kicker: "隐私",
104
+ title: "关于隐私的简要说明",
105
+ body: "PrivateClaw 围绕加密的会话式聊天构建。这个页面说明 App、网页聊天、relay、provider 和网站可能会处理哪些数据。",
106
+ updatedLabel: "最后更新",
107
+ updatedValue: "2026-03-14",
108
+ sections: [
109
+ {
110
+ title: "适用范围",
111
+ paragraphs: [
112
+ "这个页面覆盖 GG AI Studio 发布的 PrivateClaw 网站、移动 App、网页聊天、relay 和 provider。",
113
+ "如果你连接的是其他人运营的 relay、provider 或 OpenClaw 部署,那么对方需要对自己的数据处理和保留策略负责。",
114
+ ],
115
+ },
116
+ {
117
+ title: "PrivateClaw 可能处理的数据",
118
+ items: [
119
+ "会话邀请数据,例如 session ID、relay 地址、过期时间和 session key",
120
+ "你主动发送或接收的消息、附件和房间活动",
121
+ "为了预览或打开媒体、文档而在本地创建的临时文件",
122
+ "用于维持连接的元数据,例如重连记录、时间戳和加密信封大小",
123
+ ],
124
+ },
125
+ {
126
+ title: "权限与基础设施",
127
+ items: [
128
+ "相机权限只用于扫描二维码邀请。",
129
+ "文件和媒体访问只在你主动选择附件或打开收到的文件时使用。",
130
+ "网络访问只用于建立加密会话,并与所选 relay / provider 交换加密流量。",
131
+ ],
132
+ },
133
+ {
134
+ title: "加密与第三方",
135
+ paragraphs: [
136
+ "PrivateClaw 的设计目标是让 relay 主要只转发加密载荷。relay 运营方仍可能观察到 session ID、连接时间和消息大小等有限元数据。",
137
+ "被选中的 provider 和 OpenClaw 部署需要解密内容才能处理你的请求。项目默认不包含广告 SDK 或分析 SDK。",
138
+ ],
139
+ },
140
+ {
141
+ title: "你的选择与联系我们",
142
+ paragraphs: [
143
+ "你可以拒绝相机权限并手动粘贴邀请,决定是否发送文件,清除本地数据,或者自托管整套服务来获得更强的基础设施控制权。",
144
+ "如果你对项目或隐私政策有疑问,请通过 GitHub Issues 联系我们:https://github.com/topcheer/PrivateClaw/issues",
145
+ ],
146
+ },
147
+ ],
148
+ },
149
+ terms: {
150
+ documentTitle: "条款 | PrivateClaw",
151
+ kicker: "条款",
152
+ title: "使用条款",
153
+ body: "这些条款适用于 GG AI Studio 发布的 PrivateClaw 网站、App、relay 和 provider。",
154
+ updatedLabel: "最后更新",
155
+ updatedValue: "2026-03-14",
156
+ sections: [
157
+ {
158
+ title: "接受与范围",
159
+ paragraphs: [
160
+ "当你使用 PrivateClaw 时,表示你同意这些条款。PrivateClaw 是独立项目,与 OpenClaw 没有附属关系。",
161
+ "如果你运营自己的 relay、provider 或 OpenClaw 部署,你也需要自行承担本地合规和政策责任。",
162
+ ],
163
+ },
164
+ {
165
+ title: "你的责任",
166
+ items: [
167
+ "请合法使用产品,不要用它伤害他人或侵犯他人权利。",
168
+ "只分享你有权分享的消息、文件和邀请。",
169
+ "请妥善保管邀请链接和二维码,因为在过期前拿到它们的人可能都能加入房间。",
170
+ ],
171
+ },
172
+ {
173
+ title: "可用性与 Beta 状态",
174
+ paragraphs: [
175
+ "PrivateClaw 的功能可能会持续变化、改进或下线。产品中的部分能力仍处于 beta 阶段,可能会受到限制或中断。",
176
+ "我们不承诺网站、relay 或社区入口始终持续可用。",
177
+ ],
178
+ },
179
+ {
180
+ title: "第三方服务",
181
+ paragraphs: [
182
+ "你的使用过程还可能涉及 OpenClaw、自托管基础设施、Apple、Google、Telegram 或你的云服务商等第三方软件和服务。",
183
+ "这些服务有各自的条款与隐私规则,你需要在相关场景下自行查阅。",
184
+ ],
185
+ },
186
+ {
187
+ title: "免责声明与联系",
188
+ paragraphs: [
189
+ "在法律允许的范围内,PrivateClaw 按“现状”和“可用”基础提供。GG AI Studio 在法律允许的范围内不对间接损失或后果性损失承担责任。",
190
+ "如果你需要支持或法律联系入口,请优先使用 GitHub Issues:https://github.com/topcheer/PrivateClaw/issues",
191
+ ],
192
+ },
193
+ ],
194
+ },
195
+ },
196
+ "zh-Hant": {
197
+ privacy: {
198
+ documentTitle: "隱私 | PrivateClaw",
199
+ kicker: "隱私",
200
+ title: "關於隱私的簡要說明",
201
+ body: "PrivateClaw 圍繞加密的會話式聊天打造。這個頁面說明 App、網頁聊天、relay、provider 和網站可能會處理哪些資料。",
202
+ updatedLabel: "最後更新",
203
+ updatedValue: "2026-03-14",
204
+ sections: [
205
+ {
206
+ title: "適用範圍",
207
+ paragraphs: [
208
+ "這個頁面涵蓋 GG AI Studio 發布的 PrivateClaw 網站、行動 App、網頁聊天、relay 和 provider。",
209
+ "如果你連接的是其他人營運的 relay、provider 或 OpenClaw 部署,那麼對方需要對自己的資料處理與保留策略負責。",
210
+ ],
211
+ },
212
+ {
213
+ title: "PrivateClaw 可能處理的資料",
214
+ items: [
215
+ "會話邀請資料,例如 session ID、relay 位址、到期時間和 session key",
216
+ "你主動送出或接收的訊息、附件和房間活動",
217
+ "為了預覽或開啟媒體、文件而在本機建立的暫存檔",
218
+ "用於維持連線的中繼資料,例如重連紀錄、時間戳與加密封包大小",
219
+ ],
220
+ },
221
+ {
222
+ title: "權限與基礎設施",
223
+ items: [
224
+ "相機權限只用於掃描 QR 邀請。",
225
+ "檔案與媒體存取只在你主動選擇附件或開啟收到的檔案時使用。",
226
+ "網路存取只用於建立加密會話,並與所選 relay / provider 交換加密流量。",
227
+ ],
228
+ },
229
+ {
230
+ title: "加密與第三方",
231
+ paragraphs: [
232
+ "PrivateClaw 的設計目標是讓 relay 主要只轉送加密內容。relay 營運方仍可能觀察到 session ID、連線時間和訊息大小等有限中繼資料。",
233
+ "被選中的 provider 與 OpenClaw 部署需要解密內容才能處理你的請求。專案預設不包含廣告 SDK 或分析 SDK。",
234
+ ],
235
+ },
236
+ {
237
+ title: "你的選擇與聯絡我們",
238
+ paragraphs: [
239
+ "你可以拒絕相機權限並手動貼上邀請,自行決定是否傳送檔案、清除本機資料,或自架整套服務來獲得更高的基礎設施控制權。",
240
+ "如果你對專案或隱私政策有疑問,請透過 GitHub Issues 聯絡我們:https://github.com/topcheer/PrivateClaw/issues",
241
+ ],
242
+ },
243
+ ],
244
+ },
245
+ terms: {
246
+ documentTitle: "條款 | PrivateClaw",
247
+ kicker: "條款",
248
+ title: "使用條款",
249
+ body: "這些條款適用於 GG AI Studio 發布的 PrivateClaw 網站、App、relay 和 provider。",
250
+ updatedLabel: "最後更新",
251
+ updatedValue: "2026-03-14",
252
+ sections: [
253
+ {
254
+ title: "接受與範圍",
255
+ paragraphs: [
256
+ "當你使用 PrivateClaw 時,代表你同意這些條款。PrivateClaw 是獨立專案,與 OpenClaw 沒有附屬關係。",
257
+ "如果你營運自己的 relay、provider 或 OpenClaw 部署,你也需要自行承擔本地合規與政策責任。",
258
+ ],
259
+ },
260
+ {
261
+ title: "你的責任",
262
+ items: [
263
+ "請合法使用產品,不要用它傷害他人或侵犯他人權利。",
264
+ "只分享你有權分享的訊息、檔案和邀請。",
265
+ "請妥善保管邀請連結與 QR 碼,因為在到期前拿到它們的人可能都能加入房間。",
266
+ ],
267
+ },
268
+ {
269
+ title: "可用性與 Beta 狀態",
270
+ paragraphs: [
271
+ "PrivateClaw 的功能可能會持續變動、改善或下線。產品中的部分能力仍處於 beta 階段,可能會受到限制或中斷。",
272
+ "我們不承諾網站、relay 或社群入口會持續可用。",
273
+ ],
274
+ },
275
+ {
276
+ title: "第三方服務",
277
+ paragraphs: [
278
+ "你的使用過程還可能涉及 OpenClaw、自架基礎設施、Apple、Google、Telegram 或你的雲端服務商等第三方軟體與服務。",
279
+ "這些服務有各自的條款與隱私規則,你需要在相關情境下自行查閱。",
280
+ ],
281
+ },
282
+ {
283
+ title: "免責聲明與聯絡方式",
284
+ paragraphs: [
285
+ "在法律允許的範圍內,PrivateClaw 依「現況」與「可用」基礎提供。GG AI Studio 在法律允許的範圍內不對間接損失或後果性損失承擔責任。",
286
+ "如果你需要支援或法律聯絡入口,請優先使用 GitHub Issues:https://github.com/topcheer/PrivateClaw/issues",
287
+ ],
288
+ },
289
+ ],
290
+ },
291
+ },
292
+ };
293
+
294
+ const localeSelect = document.getElementById("locale-select");
295
+ const pageType = document.body.dataset.policyPage;
296
+ const policyKicker = document.getElementById("policy-kicker");
297
+ const policyTitle = document.getElementById("policy-title");
298
+ const policyBody = document.getElementById("policy-body");
299
+ const policyUpdatedLabel = document.getElementById("policy-updated-label");
300
+ const policyUpdatedValue = document.getElementById("policy-updated-value");
301
+ const policySections = document.getElementById("policy-sections");
302
+
303
+ bindLocaleSelect(localeSelect);
304
+
305
+ function getPolicy(locale, type) {
306
+ return POLICY_BUNDLES[locale]?.[type] ?? POLICY_BUNDLES.en[type];
307
+ }
308
+
309
+ function renderPolicy() {
310
+ applyTranslations();
311
+ const policy = getPolicy(getLocale(), pageType);
312
+ if (!policy) {
313
+ throw new Error(`Unknown policy page: ${pageType}`);
314
+ }
315
+
316
+ document.title = policy.documentTitle;
317
+ policyKicker.textContent = policy.kicker;
318
+ policyTitle.textContent = policy.title;
319
+ policyBody.textContent = policy.body;
320
+ policyUpdatedLabel.textContent = policy.updatedLabel;
321
+ policyUpdatedValue.textContent = policy.updatedValue;
322
+
323
+ policySections.replaceChildren();
324
+ for (const section of policy.sections) {
325
+ const article = document.createElement("article");
326
+ article.className = "policy-section glass-panel";
327
+
328
+ const title = document.createElement("h2");
329
+ title.textContent = section.title;
330
+ article.append(title);
331
+
332
+ for (const paragraph of section.paragraphs ?? []) {
333
+ const p = document.createElement("p");
334
+ p.textContent = paragraph;
335
+ article.append(p);
336
+ }
337
+
338
+ if (Array.isArray(section.items) && section.items.length > 0) {
339
+ const list = document.createElement("ul");
340
+ list.className = "policy-list";
341
+ for (const item of section.items) {
342
+ const listItem = document.createElement("li");
343
+ listItem.textContent = item;
344
+ list.append(listItem);
345
+ }
346
+ article.append(list);
347
+ }
348
+
349
+ policySections.append(article);
350
+ }
351
+ }
352
+
353
+ onLocaleChange(renderPolicy);
354
+ renderPolicy();
@@ -0,0 +1,332 @@
1
+ export const PRIVATECLAW_INVITE_SCHEME = "privateclaw://connect";
2
+ export const DEFAULT_PRIVATECLAW_RELAY_HOST = "relay.privateclaw.us";
3
+
4
+ const encoder = new TextEncoder();
5
+ const decoder = new TextDecoder();
6
+
7
+ function assertWebCrypto() {
8
+ if (!globalThis.crypto?.subtle || !globalThis.crypto?.getRandomValues) {
9
+ throw new Error("browser_crypto_unavailable");
10
+ }
11
+ }
12
+
13
+ export function bytesToBase64Url(bytes) {
14
+ const chunkSize = 0x8000;
15
+ let binary = "";
16
+ for (let index = 0; index < bytes.length; index += chunkSize) {
17
+ const chunk = bytes.subarray(index, index + chunkSize);
18
+ binary += String.fromCharCode(...chunk);
19
+ }
20
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
21
+ }
22
+
23
+ export function bytesToBase64(bytes) {
24
+ const chunkSize = 0x8000;
25
+ let binary = "";
26
+ for (let index = 0; index < bytes.length; index += chunkSize) {
27
+ const chunk = bytes.subarray(index, index + chunkSize);
28
+ binary += String.fromCharCode(...chunk);
29
+ }
30
+ return btoa(binary);
31
+ }
32
+
33
+ export function base64UrlToBytes(value) {
34
+ if (typeof value !== "string" || value.trim() === "") {
35
+ throw new Error("invalid_base64url");
36
+ }
37
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
38
+ const paddingLength = (4 - (normalized.length % 4 || 4)) % 4;
39
+ const padded = normalized + "=".repeat(paddingLength);
40
+ const binary = atob(padded);
41
+ const bytes = new Uint8Array(binary.length);
42
+ for (let index = 0; index < binary.length; index += 1) {
43
+ bytes[index] = binary.charCodeAt(index);
44
+ }
45
+ return bytes;
46
+ }
47
+
48
+ export function decodeUtf8(bytes) {
49
+ return decoder.decode(bytes);
50
+ }
51
+
52
+ export function encodeUtf8(value) {
53
+ return encoder.encode(value);
54
+ }
55
+
56
+ function assertInviteShape(value) {
57
+ if (!value || typeof value !== "object") {
58
+ throw new Error("malformed_invite");
59
+ }
60
+ const invite = value;
61
+ const requiredFields = ["sessionId", "sessionKey", "appWsUrl", "expiresAt"];
62
+ for (const field of requiredFields) {
63
+ if (typeof invite[field] !== "string" || invite[field].trim() === "") {
64
+ throw new Error(`invite_missing_${field}`);
65
+ }
66
+ }
67
+ if (invite.version !== 1) {
68
+ throw new Error("unsupported_invite_version");
69
+ }
70
+ }
71
+
72
+ function parseInviteJson(serialized) {
73
+ const parsed = JSON.parse(serialized);
74
+ assertInviteShape(parsed);
75
+ return parsed;
76
+ }
77
+
78
+ function decodeInvitePayload(payload) {
79
+ return parseInviteJson(decodeUtf8(base64UrlToBytes(payload)));
80
+ }
81
+
82
+ export function decodeInviteString(input) {
83
+ if (typeof input !== "string" || input.trim() === "") {
84
+ throw new Error("empty_invite");
85
+ }
86
+
87
+ const trimmed = input.trim();
88
+ const embeddedMatch = trimmed.match(/privateclaw:\/\/connect\?payload=[A-Za-z0-9_-]+/);
89
+ if (embeddedMatch && embeddedMatch[0] !== trimmed) {
90
+ return decodeInviteString(embeddedMatch[0]);
91
+ }
92
+
93
+ if (trimmed.startsWith(PRIVATECLAW_INVITE_SCHEME)) {
94
+ const url = new URL(trimmed);
95
+ const payload = url.searchParams.get("payload");
96
+ if (!payload) {
97
+ throw new Error("missing_payload");
98
+ }
99
+ return decodeInvitePayload(payload);
100
+ }
101
+
102
+ if (trimmed.startsWith("{")) {
103
+ return parseInviteJson(trimmed);
104
+ }
105
+
106
+ return decodeInvitePayload(trimmed);
107
+ }
108
+
109
+ export function encodeInviteToUri(invite) {
110
+ assertInviteShape(invite);
111
+ const payload = bytesToBase64Url(encodeUtf8(JSON.stringify(invite)));
112
+ return `${PRIVATECLAW_INVITE_SCHEME}?payload=${payload}`;
113
+ }
114
+
115
+ export function getInviteRelayLabel(invite) {
116
+ const explicitLabel =
117
+ typeof invite?.relayLabel === "string" ? invite.relayLabel.trim() : "";
118
+ if (explicitLabel) {
119
+ return explicitLabel;
120
+ }
121
+
122
+ try {
123
+ const relayUrl = new URL(String(invite?.appWsUrl || ""));
124
+ if (!relayUrl.host) {
125
+ return null;
126
+ }
127
+ const useDefaultPort =
128
+ relayUrl.port === "" ||
129
+ (relayUrl.protocol === "wss:" && relayUrl.port === "443") ||
130
+ (relayUrl.protocol === "ws:" && relayUrl.port === "80");
131
+ return useDefaultPort ? relayUrl.host : `${relayUrl.hostname}:${relayUrl.port}`;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ export function inviteUsesDefaultRelay(invite) {
138
+ try {
139
+ const relayUrl = new URL(String(invite?.appWsUrl || ""));
140
+ if (!relayUrl.host) {
141
+ return getInviteRelayLabel(invite) === DEFAULT_PRIVATECLAW_RELAY_HOST;
142
+ }
143
+ const useDefaultPort =
144
+ relayUrl.port === "" ||
145
+ (relayUrl.protocol === "wss:" && relayUrl.port === "443") ||
146
+ (relayUrl.protocol === "ws:" && relayUrl.port === "80");
147
+ return relayUrl.hostname === DEFAULT_PRIVATECLAW_RELAY_HOST && useDefaultPort;
148
+ } catch {
149
+ return getInviteRelayLabel(invite) === DEFAULT_PRIVATECLAW_RELAY_HOST;
150
+ }
151
+ }
152
+
153
+ export function inviteUsesNonDefaultRelay(invite) {
154
+ return !inviteUsesDefaultRelay(invite);
155
+ }
156
+
157
+ export function createMessageId(prefix = "client") {
158
+ assertWebCrypto();
159
+ const randomBytes = new Uint32Array(2);
160
+ globalThis.crypto.getRandomValues(randomBytes);
161
+ return `${prefix}-${Date.now()}-${randomBytes[0].toString(16)}${randomBytes[1].toString(16)}`;
162
+ }
163
+
164
+ export function createIdentity() {
165
+ if (typeof globalThis.crypto?.randomUUID === "function") {
166
+ return {
167
+ appId: globalThis.crypto.randomUUID(),
168
+ displayName: null,
169
+ };
170
+ }
171
+
172
+ return {
173
+ appId: createMessageId("app"),
174
+ displayName: null,
175
+ };
176
+ }
177
+
178
+ export async function createCryptoContext({ sessionId, sessionKey }) {
179
+ assertWebCrypto();
180
+ if (typeof sessionId !== "string" || sessionId.trim() === "") {
181
+ throw new Error("invalid_session_id");
182
+ }
183
+
184
+ const rawKey = base64UrlToBytes(sessionKey);
185
+ if (rawKey.byteLength !== 32) {
186
+ throw new Error("invalid_session_key_length");
187
+ }
188
+
189
+ const cryptoKey = await globalThis.crypto.subtle.importKey(
190
+ "raw",
191
+ rawKey,
192
+ { name: "AES-GCM" },
193
+ false,
194
+ ["encrypt", "decrypt"],
195
+ );
196
+ const aad = encodeUtf8(sessionId);
197
+
198
+ return {
199
+ async encrypt(payload) {
200
+ const iv = new Uint8Array(12);
201
+ globalThis.crypto.getRandomValues(iv);
202
+ const plaintext = encodeUtf8(JSON.stringify(payload));
203
+ const combined = new Uint8Array(
204
+ await globalThis.crypto.subtle.encrypt(
205
+ {
206
+ name: "AES-GCM",
207
+ iv,
208
+ additionalData: aad,
209
+ tagLength: 128,
210
+ },
211
+ cryptoKey,
212
+ plaintext,
213
+ ),
214
+ );
215
+ const tagLength = 16;
216
+ const ciphertext = combined.subarray(0, combined.length - tagLength);
217
+ const tag = combined.subarray(combined.length - tagLength);
218
+ return {
219
+ version: 1,
220
+ messageId: createMessageId("envelope"),
221
+ iv: bytesToBase64Url(iv),
222
+ ciphertext: bytesToBase64Url(ciphertext),
223
+ tag: bytesToBase64Url(tag),
224
+ sentAt: new Date().toISOString(),
225
+ };
226
+ },
227
+ async decrypt(envelope) {
228
+ if (!envelope || typeof envelope !== "object" || envelope.version !== 1) {
229
+ throw new Error("unsupported_envelope_version");
230
+ }
231
+ const iv = base64UrlToBytes(String(envelope.iv || ""));
232
+ const ciphertext = base64UrlToBytes(String(envelope.ciphertext || ""));
233
+ const tag = base64UrlToBytes(String(envelope.tag || ""));
234
+ const combined = new Uint8Array(ciphertext.length + tag.length);
235
+ combined.set(ciphertext, 0);
236
+ combined.set(tag, ciphertext.length);
237
+ const plaintext = new Uint8Array(
238
+ await globalThis.crypto.subtle.decrypt(
239
+ {
240
+ name: "AES-GCM",
241
+ iv,
242
+ additionalData: aad,
243
+ tagLength: 128,
244
+ },
245
+ cryptoKey,
246
+ combined,
247
+ ),
248
+ );
249
+ const decoded = JSON.parse(decodeUtf8(plaintext));
250
+ if (!decoded || typeof decoded !== "object") {
251
+ throw new Error("invalid_payload_shape");
252
+ }
253
+ return decoded;
254
+ },
255
+ };
256
+ }
257
+
258
+ export function inferMimeType(filename) {
259
+ const extension = filename.includes(".") ? filename.split(".").pop().toLowerCase() : "";
260
+ switch (extension) {
261
+ case "png":
262
+ return "image/png";
263
+ case "jpg":
264
+ case "jpeg":
265
+ return "image/jpeg";
266
+ case "gif":
267
+ return "image/gif";
268
+ case "webp":
269
+ return "image/webp";
270
+ case "mp3":
271
+ return "audio/mpeg";
272
+ case "wav":
273
+ return "audio/wav";
274
+ case "m4a":
275
+ return "audio/mp4";
276
+ case "mp4":
277
+ return "video/mp4";
278
+ case "mov":
279
+ return "video/quicktime";
280
+ case "pdf":
281
+ return "application/pdf";
282
+ case "doc":
283
+ return "application/msword";
284
+ case "docx":
285
+ return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
286
+ case "xls":
287
+ return "application/vnd.ms-excel";
288
+ case "xlsx":
289
+ return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
290
+ case "ppt":
291
+ return "application/vnd.ms-powerpoint";
292
+ case "pptx":
293
+ return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
294
+ case "txt":
295
+ return "text/plain";
296
+ case "md":
297
+ case "markdown":
298
+ return "text/markdown";
299
+ case "csv":
300
+ return "text/csv";
301
+ case "json":
302
+ return "application/json";
303
+ case "xml":
304
+ return "application/xml";
305
+ default:
306
+ return "application/octet-stream";
307
+ }
308
+ }
309
+
310
+ export async function readFileAsAttachment(file) {
311
+ const buffer = await file.arrayBuffer();
312
+ const bytes = new Uint8Array(buffer);
313
+ return {
314
+ id: createMessageId("attachment"),
315
+ name: file.name,
316
+ mimeType: file.type || inferMimeType(file.name),
317
+ sizeBytes: file.size,
318
+ dataBase64: bytesToBase64(bytes),
319
+ };
320
+ }
321
+
322
+ export function decodeBase64(value) {
323
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
324
+ const paddingLength = (4 - (normalized.length % 4 || 4)) % 4;
325
+ const padded = normalized + "=".repeat(paddingLength);
326
+ const binary = atob(padded);
327
+ const bytes = new Uint8Array(binary.length);
328
+ for (let index = 0; index < binary.length; index += 1) {
329
+ bytes[index] = binary.charCodeAt(index);
330
+ }
331
+ return bytes;
332
+ }