@rei-standard/amsg-client 1.1.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 ADDED
@@ -0,0 +1,28 @@
1
+ # @rei-standard/amsg-client
2
+
3
+ `@rei-standard/amsg-client` 是 ReiStandard 主动消息标准的浏览器端 SDK 包。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ npm install @rei-standard/amsg-client
9
+ ```
10
+
11
+ ## 使用
12
+
13
+ ```js
14
+ import { ReiClient } from '@rei-standard/amsg-client';
15
+
16
+ const client = new ReiClient({
17
+ baseUrl: '/api/v1',
18
+ userId: 'user-123'
19
+ });
20
+
21
+ await client.init();
22
+ ```
23
+
24
+ 主要能力:
25
+
26
+ - 自动处理 `schedule-message` / `update-message` 的加密请求
27
+ - 自动处理 `messages` 的解密响应
28
+ - Push 订阅辅助方法
package/dist/index.cjs ADDED
@@ -0,0 +1,232 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __export = (target, all) => {
6
+ for (var name in all)
7
+ __defProp(target, name, { get: all[name], enumerable: true });
8
+ };
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
+
19
+ // src/index.js
20
+ var src_exports = {};
21
+ __export(src_exports, {
22
+ ReiClient: () => ReiClient
23
+ });
24
+ module.exports = __toCommonJS(src_exports);
25
+ var ReiClient = class {
26
+ /**
27
+ * @param {ReiClientConfig} config
28
+ */
29
+ constructor(config) {
30
+ if (!config || !config.baseUrl) throw new Error("[rei-standard-amsg-client] baseUrl is required");
31
+ if (!config.userId) throw new Error("[rei-standard-amsg-client] userId is required");
32
+ this._baseUrl = config.baseUrl.replace(/\/+$/, "");
33
+ this._userId = config.userId;
34
+ this._masterKey = null;
35
+ this._userKey = null;
36
+ }
37
+ // ─── Initialisation ─────────────────────────────────────────────
38
+ /**
39
+ * Fetch the master key and derive the user-specific encryption key.
40
+ * Must be called before any encrypted request.
41
+ */
42
+ async init() {
43
+ const res = await fetch(`${this._baseUrl}/get-master-key`, {
44
+ method: "GET",
45
+ headers: { "X-User-Id": this._userId }
46
+ });
47
+ const json = await res.json();
48
+ if (!json.success) throw new Error(json.error?.message || "Failed to fetch master key");
49
+ this._masterKey = json.data.masterKey;
50
+ this._userKey = await this._deriveKey(this._masterKey, this._userId);
51
+ }
52
+ // ─── Public API ─────────────────────────────────────────────────
53
+ /**
54
+ * Schedule (or instantly send) a message.
55
+ * The payload is automatically encrypted before transmission.
56
+ *
57
+ * @param {Object} payload - Schedule message payload.
58
+ * @returns {Promise<Object>} API response body.
59
+ */
60
+ async scheduleMessage(payload) {
61
+ const encrypted = await this._encrypt(JSON.stringify(payload));
62
+ const res = await fetch(`${this._baseUrl}/schedule-message`, {
63
+ method: "POST",
64
+ headers: {
65
+ "Content-Type": "application/json",
66
+ "X-User-Id": this._userId,
67
+ "X-Payload-Encrypted": "true",
68
+ "X-Encryption-Version": "1"
69
+ },
70
+ body: JSON.stringify(encrypted)
71
+ });
72
+ return res.json();
73
+ }
74
+ /**
75
+ * Update an existing scheduled message.
76
+ *
77
+ * @param {string} uuid - Task UUID.
78
+ * @param {Object} updates - Fields to update.
79
+ * @returns {Promise<Object>}
80
+ */
81
+ async updateMessage(uuid, updates) {
82
+ const encrypted = await this._encrypt(JSON.stringify(updates));
83
+ const res = await fetch(`${this._baseUrl}/update-message?id=${encodeURIComponent(uuid)}`, {
84
+ method: "PUT",
85
+ headers: {
86
+ "Content-Type": "application/json",
87
+ "X-User-Id": this._userId,
88
+ "X-Payload-Encrypted": "true",
89
+ "X-Encryption-Version": "1"
90
+ },
91
+ body: JSON.stringify(encrypted)
92
+ });
93
+ return res.json();
94
+ }
95
+ /**
96
+ * Cancel / delete a scheduled message.
97
+ *
98
+ * @param {string} uuid - Task UUID.
99
+ * @returns {Promise<Object>}
100
+ */
101
+ async cancelMessage(uuid) {
102
+ const res = await fetch(`${this._baseUrl}/cancel-message?id=${encodeURIComponent(uuid)}`, {
103
+ method: "DELETE",
104
+ headers: { "X-User-Id": this._userId }
105
+ });
106
+ return res.json();
107
+ }
108
+ /**
109
+ * List the current user's messages with optional filters.
110
+ *
111
+ * @param {Object} [opts]
112
+ * @param {string} [opts.status]
113
+ * @param {number} [opts.limit]
114
+ * @param {number} [opts.offset]
115
+ * @returns {Promise<Object>}
116
+ */
117
+ async listMessages(opts = {}) {
118
+ const params = new URLSearchParams();
119
+ if (opts.status) params.set("status", opts.status);
120
+ if (opts.limit != null) params.set("limit", String(opts.limit));
121
+ if (opts.offset != null) params.set("offset", String(opts.offset));
122
+ const qs = params.toString();
123
+ const url = `${this._baseUrl}/messages${qs ? "?" + qs : ""}`;
124
+ const res = await fetch(url, {
125
+ method: "GET",
126
+ headers: {
127
+ "X-User-Id": this._userId,
128
+ "X-Response-Encrypted": "true",
129
+ "X-Encryption-Version": "1"
130
+ }
131
+ });
132
+ const json = await res.json();
133
+ if (!json?.success || json?.encrypted !== true) return json;
134
+ const decrypted = await this._decrypt(json.data);
135
+ return {
136
+ success: true,
137
+ encrypted: true,
138
+ version: json.version || 1,
139
+ data: decrypted
140
+ };
141
+ }
142
+ // ─── Push Subscription ──────────────────────────────────────────
143
+ /**
144
+ * Subscribe to Web Push notifications.
145
+ *
146
+ * @param {string} vapidPublicKey - The server's VAPID public key.
147
+ * @param {ServiceWorkerRegistration} registration - An active SW registration.
148
+ * @returns {Promise<PushSubscription>}
149
+ */
150
+ async subscribePush(vapidPublicKey, registration) {
151
+ const subscription = await registration.pushManager.subscribe({
152
+ userVisibleOnly: true,
153
+ applicationServerKey: this._urlBase64ToUint8Array(vapidPublicKey)
154
+ });
155
+ return subscription;
156
+ }
157
+ // ─── Crypto helpers (Web Crypto API) ────────────────────────────
158
+ /**
159
+ * Derive a user-specific AES-256-GCM key from the master key and userId.
160
+ * @private
161
+ */
162
+ async _deriveKey(masterKey, userId) {
163
+ const encoder = new TextEncoder();
164
+ const data = encoder.encode(masterKey + userId);
165
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
166
+ return new Uint8Array(hashBuffer);
167
+ }
168
+ /**
169
+ * Encrypt plaintext with AES-256-GCM.
170
+ * @private
171
+ * @param {string} plaintext
172
+ * @returns {Promise<{ iv: string, authTag: string, encryptedData: string }>}
173
+ */
174
+ async _encrypt(plaintext) {
175
+ if (!this._userKey) throw new Error("[rei-standard-amsg-client] Not initialised. Call init() first.");
176
+ const iv = crypto.getRandomValues(new Uint8Array(12));
177
+ const key = await crypto.subtle.importKey("raw", this._userKey, { name: "AES-GCM" }, false, ["encrypt"]);
178
+ const encoded = new TextEncoder().encode(plaintext);
179
+ const cipherBuf = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
180
+ const cipherArr = new Uint8Array(cipherBuf);
181
+ const encryptedData = cipherArr.slice(0, cipherArr.length - 16);
182
+ const authTag = cipherArr.slice(cipherArr.length - 16);
183
+ return {
184
+ iv: this._toBase64(iv),
185
+ authTag: this._toBase64(authTag),
186
+ encryptedData: this._toBase64(encryptedData)
187
+ };
188
+ }
189
+ /**
190
+ * Decrypt an encrypted API payload.
191
+ * @private
192
+ * @param {{ iv: string, authTag: string, encryptedData: string }} encryptedPayload
193
+ * @returns {Promise<Object>}
194
+ */
195
+ async _decrypt(encryptedPayload) {
196
+ if (!this._userKey) throw new Error("[rei-standard-amsg-client] Not initialised. Call init() first.");
197
+ const { iv, authTag, encryptedData } = encryptedPayload || {};
198
+ if (typeof iv !== "string" || typeof authTag !== "string" || typeof encryptedData !== "string") {
199
+ throw new Error("[rei-standard-amsg-client] Invalid encrypted payload");
200
+ }
201
+ const ivBytes = this._fromBase64(iv);
202
+ const authTagBytes = this._fromBase64(authTag);
203
+ const encryptedBytes = this._fromBase64(encryptedData);
204
+ const cipherBytes = new Uint8Array(encryptedBytes.length + authTagBytes.length);
205
+ cipherBytes.set(encryptedBytes);
206
+ cipherBytes.set(authTagBytes, encryptedBytes.length);
207
+ const key = await crypto.subtle.importKey("raw", this._userKey, { name: "AES-GCM" }, false, ["decrypt"]);
208
+ const plainBuffer = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivBytes }, key, cipherBytes);
209
+ return JSON.parse(new TextDecoder().decode(plainBuffer));
210
+ }
211
+ /** @private */
212
+ _toBase64(uint8) {
213
+ const binary = Array.from(uint8, (byte) => String.fromCharCode(byte)).join("");
214
+ return btoa(binary);
215
+ }
216
+ /** @private */
217
+ _fromBase64(base64) {
218
+ const raw = atob(base64);
219
+ const arr = new Uint8Array(raw.length);
220
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
221
+ return arr;
222
+ }
223
+ /** @private */
224
+ _urlBase64ToUint8Array(base64String) {
225
+ const padding = "=".repeat((4 - base64String.length % 4) % 4);
226
+ const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
227
+ const raw = atob(base64);
228
+ const arr = new Uint8Array(raw.length);
229
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
230
+ return arr;
231
+ }
232
+ };
@@ -0,0 +1,279 @@
1
+ /**
2
+ * ReiStandard Client SDK
3
+ * v1.1.0
4
+ *
5
+ * Lightweight browser client that handles:
6
+ * - AES-256-GCM encryption using the Web Crypto API
7
+ * - Push subscription management via the Push API
8
+ * - Convenient request helpers for all 7 endpoints
9
+ *
10
+ * Usage:
11
+ * import { ReiClient } from '@rei-standard/amsg-client';
12
+ *
13
+ * const client = new ReiClient({
14
+ * baseUrl: 'https://example.com/api/v1',
15
+ * userId: 'user-123',
16
+ * });
17
+ *
18
+ * // Fetch master key and initialise encryption
19
+ * await client.init();
20
+ *
21
+ * // Schedule a message (payload is auto-encrypted)
22
+ * await client.scheduleMessage({ ... });
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} ReiClientConfig
27
+ * @property {string} baseUrl - Base URL of the API (e.g. https://host/api/v1).
28
+ * @property {string} userId - Current user identifier.
29
+ */
30
+
31
+ class ReiClient {
32
+ /**
33
+ * @param {ReiClientConfig} config
34
+ */
35
+ constructor(config) {
36
+ if (!config || !config.baseUrl) throw new Error('[rei-standard-amsg-client] baseUrl is required');
37
+ if (!config.userId) throw new Error('[rei-standard-amsg-client] userId is required');
38
+
39
+ /** @private */
40
+ this._baseUrl = config.baseUrl.replace(/\/+$/, '');
41
+ /** @private */
42
+ this._userId = config.userId;
43
+ /** @private */
44
+ this._masterKey = null;
45
+ /** @private */
46
+ this._userKey = null;
47
+ }
48
+
49
+ // ─── Initialisation ─────────────────────────────────────────────
50
+
51
+ /**
52
+ * Fetch the master key and derive the user-specific encryption key.
53
+ * Must be called before any encrypted request.
54
+ */
55
+ async init() {
56
+ const res = await fetch(`${this._baseUrl}/get-master-key`, {
57
+ method: 'GET',
58
+ headers: { 'X-User-Id': this._userId }
59
+ });
60
+
61
+ const json = await res.json();
62
+ if (!json.success) throw new Error(json.error?.message || 'Failed to fetch master key');
63
+
64
+ this._masterKey = json.data.masterKey;
65
+ this._userKey = await this._deriveKey(this._masterKey, this._userId);
66
+ }
67
+
68
+ // ─── Public API ─────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Schedule (or instantly send) a message.
72
+ * The payload is automatically encrypted before transmission.
73
+ *
74
+ * @param {Object} payload - Schedule message payload.
75
+ * @returns {Promise<Object>} API response body.
76
+ */
77
+ async scheduleMessage(payload) {
78
+ const encrypted = await this._encrypt(JSON.stringify(payload));
79
+
80
+ const res = await fetch(`${this._baseUrl}/schedule-message`, {
81
+ method: 'POST',
82
+ headers: {
83
+ 'Content-Type': 'application/json',
84
+ 'X-User-Id': this._userId,
85
+ 'X-Payload-Encrypted': 'true',
86
+ 'X-Encryption-Version': '1'
87
+ },
88
+ body: JSON.stringify(encrypted)
89
+ });
90
+
91
+ return res.json();
92
+ }
93
+
94
+ /**
95
+ * Update an existing scheduled message.
96
+ *
97
+ * @param {string} uuid - Task UUID.
98
+ * @param {Object} updates - Fields to update.
99
+ * @returns {Promise<Object>}
100
+ */
101
+ async updateMessage(uuid, updates) {
102
+ const encrypted = await this._encrypt(JSON.stringify(updates));
103
+
104
+ const res = await fetch(`${this._baseUrl}/update-message?id=${encodeURIComponent(uuid)}`, {
105
+ method: 'PUT',
106
+ headers: {
107
+ 'Content-Type': 'application/json',
108
+ 'X-User-Id': this._userId,
109
+ 'X-Payload-Encrypted': 'true',
110
+ 'X-Encryption-Version': '1'
111
+ },
112
+ body: JSON.stringify(encrypted)
113
+ });
114
+
115
+ return res.json();
116
+ }
117
+
118
+ /**
119
+ * Cancel / delete a scheduled message.
120
+ *
121
+ * @param {string} uuid - Task UUID.
122
+ * @returns {Promise<Object>}
123
+ */
124
+ async cancelMessage(uuid) {
125
+ const res = await fetch(`${this._baseUrl}/cancel-message?id=${encodeURIComponent(uuid)}`, {
126
+ method: 'DELETE',
127
+ headers: { 'X-User-Id': this._userId }
128
+ });
129
+
130
+ return res.json();
131
+ }
132
+
133
+ /**
134
+ * List the current user's messages with optional filters.
135
+ *
136
+ * @param {Object} [opts]
137
+ * @param {string} [opts.status]
138
+ * @param {number} [opts.limit]
139
+ * @param {number} [opts.offset]
140
+ * @returns {Promise<Object>}
141
+ */
142
+ async listMessages(opts = {}) {
143
+ const params = new URLSearchParams();
144
+ if (opts.status) params.set('status', opts.status);
145
+ if (opts.limit != null) params.set('limit', String(opts.limit));
146
+ if (opts.offset != null) params.set('offset', String(opts.offset));
147
+
148
+ const qs = params.toString();
149
+ const url = `${this._baseUrl}/messages${qs ? '?' + qs : ''}`;
150
+
151
+ const res = await fetch(url, {
152
+ method: 'GET',
153
+ headers: {
154
+ 'X-User-Id': this._userId,
155
+ 'X-Response-Encrypted': 'true',
156
+ 'X-Encryption-Version': '1'
157
+ }
158
+ });
159
+
160
+ const json = await res.json();
161
+ if (!json?.success || json?.encrypted !== true) return json;
162
+
163
+ const decrypted = await this._decrypt(json.data);
164
+ return {
165
+ success: true,
166
+ encrypted: true,
167
+ version: json.version || 1,
168
+ data: decrypted
169
+ };
170
+ }
171
+
172
+ // ─── Push Subscription ──────────────────────────────────────────
173
+
174
+ /**
175
+ * Subscribe to Web Push notifications.
176
+ *
177
+ * @param {string} vapidPublicKey - The server's VAPID public key.
178
+ * @param {ServiceWorkerRegistration} registration - An active SW registration.
179
+ * @returns {Promise<PushSubscription>}
180
+ */
181
+ async subscribePush(vapidPublicKey, registration) {
182
+ const subscription = await registration.pushManager.subscribe({
183
+ userVisibleOnly: true,
184
+ applicationServerKey: this._urlBase64ToUint8Array(vapidPublicKey)
185
+ });
186
+ return subscription;
187
+ }
188
+
189
+ // ─── Crypto helpers (Web Crypto API) ────────────────────────────
190
+
191
+ /**
192
+ * Derive a user-specific AES-256-GCM key from the master key and userId.
193
+ * @private
194
+ */
195
+ async _deriveKey(masterKey, userId) {
196
+ const encoder = new TextEncoder();
197
+ const data = encoder.encode(masterKey + userId);
198
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
199
+ return new Uint8Array(hashBuffer);
200
+ }
201
+
202
+ /**
203
+ * Encrypt plaintext with AES-256-GCM.
204
+ * @private
205
+ * @param {string} plaintext
206
+ * @returns {Promise<{ iv: string, authTag: string, encryptedData: string }>}
207
+ */
208
+ async _encrypt(plaintext) {
209
+ if (!this._userKey) throw new Error('[rei-standard-amsg-client] Not initialised. Call init() first.');
210
+
211
+ const iv = crypto.getRandomValues(new Uint8Array(12));
212
+ const key = await crypto.subtle.importKey('raw', this._userKey, { name: 'AES-GCM' }, false, ['encrypt']);
213
+ const encoded = new TextEncoder().encode(plaintext);
214
+ const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);
215
+
216
+ // Web Crypto appends the 16-byte auth tag at the end of the ciphertext
217
+ const cipherArr = new Uint8Array(cipherBuf);
218
+ const encryptedData = cipherArr.slice(0, cipherArr.length - 16);
219
+ const authTag = cipherArr.slice(cipherArr.length - 16);
220
+
221
+ return {
222
+ iv: this._toBase64(iv),
223
+ authTag: this._toBase64(authTag),
224
+ encryptedData: this._toBase64(encryptedData)
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Decrypt an encrypted API payload.
230
+ * @private
231
+ * @param {{ iv: string, authTag: string, encryptedData: string }} encryptedPayload
232
+ * @returns {Promise<Object>}
233
+ */
234
+ async _decrypt(encryptedPayload) {
235
+ if (!this._userKey) throw new Error('[rei-standard-amsg-client] Not initialised. Call init() first.');
236
+
237
+ const { iv, authTag, encryptedData } = encryptedPayload || {};
238
+ if (typeof iv !== 'string' || typeof authTag !== 'string' || typeof encryptedData !== 'string') {
239
+ throw new Error('[rei-standard-amsg-client] Invalid encrypted payload');
240
+ }
241
+
242
+ const ivBytes = this._fromBase64(iv);
243
+ const authTagBytes = this._fromBase64(authTag);
244
+ const encryptedBytes = this._fromBase64(encryptedData);
245
+ const cipherBytes = new Uint8Array(encryptedBytes.length + authTagBytes.length);
246
+ cipherBytes.set(encryptedBytes);
247
+ cipherBytes.set(authTagBytes, encryptedBytes.length);
248
+
249
+ const key = await crypto.subtle.importKey('raw', this._userKey, { name: 'AES-GCM' }, false, ['decrypt']);
250
+ const plainBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivBytes }, key, cipherBytes);
251
+ return JSON.parse(new TextDecoder().decode(plainBuffer));
252
+ }
253
+
254
+ /** @private */
255
+ _toBase64(uint8) {
256
+ const binary = Array.from(uint8, byte => String.fromCharCode(byte)).join('');
257
+ return btoa(binary);
258
+ }
259
+
260
+ /** @private */
261
+ _fromBase64(base64) {
262
+ const raw = atob(base64);
263
+ const arr = new Uint8Array(raw.length);
264
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
265
+ return arr;
266
+ }
267
+
268
+ /** @private */
269
+ _urlBase64ToUint8Array(base64String) {
270
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
271
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
272
+ const raw = atob(base64);
273
+ const arr = new Uint8Array(raw.length);
274
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
275
+ return arr;
276
+ }
277
+ }
278
+
279
+ export { ReiClient };
@@ -0,0 +1,279 @@
1
+ /**
2
+ * ReiStandard Client SDK
3
+ * v1.1.0
4
+ *
5
+ * Lightweight browser client that handles:
6
+ * - AES-256-GCM encryption using the Web Crypto API
7
+ * - Push subscription management via the Push API
8
+ * - Convenient request helpers for all 7 endpoints
9
+ *
10
+ * Usage:
11
+ * import { ReiClient } from '@rei-standard/amsg-client';
12
+ *
13
+ * const client = new ReiClient({
14
+ * baseUrl: 'https://example.com/api/v1',
15
+ * userId: 'user-123',
16
+ * });
17
+ *
18
+ * // Fetch master key and initialise encryption
19
+ * await client.init();
20
+ *
21
+ * // Schedule a message (payload is auto-encrypted)
22
+ * await client.scheduleMessage({ ... });
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} ReiClientConfig
27
+ * @property {string} baseUrl - Base URL of the API (e.g. https://host/api/v1).
28
+ * @property {string} userId - Current user identifier.
29
+ */
30
+
31
+ class ReiClient {
32
+ /**
33
+ * @param {ReiClientConfig} config
34
+ */
35
+ constructor(config) {
36
+ if (!config || !config.baseUrl) throw new Error('[rei-standard-amsg-client] baseUrl is required');
37
+ if (!config.userId) throw new Error('[rei-standard-amsg-client] userId is required');
38
+
39
+ /** @private */
40
+ this._baseUrl = config.baseUrl.replace(/\/+$/, '');
41
+ /** @private */
42
+ this._userId = config.userId;
43
+ /** @private */
44
+ this._masterKey = null;
45
+ /** @private */
46
+ this._userKey = null;
47
+ }
48
+
49
+ // ─── Initialisation ─────────────────────────────────────────────
50
+
51
+ /**
52
+ * Fetch the master key and derive the user-specific encryption key.
53
+ * Must be called before any encrypted request.
54
+ */
55
+ async init() {
56
+ const res = await fetch(`${this._baseUrl}/get-master-key`, {
57
+ method: 'GET',
58
+ headers: { 'X-User-Id': this._userId }
59
+ });
60
+
61
+ const json = await res.json();
62
+ if (!json.success) throw new Error(json.error?.message || 'Failed to fetch master key');
63
+
64
+ this._masterKey = json.data.masterKey;
65
+ this._userKey = await this._deriveKey(this._masterKey, this._userId);
66
+ }
67
+
68
+ // ─── Public API ─────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Schedule (or instantly send) a message.
72
+ * The payload is automatically encrypted before transmission.
73
+ *
74
+ * @param {Object} payload - Schedule message payload.
75
+ * @returns {Promise<Object>} API response body.
76
+ */
77
+ async scheduleMessage(payload) {
78
+ const encrypted = await this._encrypt(JSON.stringify(payload));
79
+
80
+ const res = await fetch(`${this._baseUrl}/schedule-message`, {
81
+ method: 'POST',
82
+ headers: {
83
+ 'Content-Type': 'application/json',
84
+ 'X-User-Id': this._userId,
85
+ 'X-Payload-Encrypted': 'true',
86
+ 'X-Encryption-Version': '1'
87
+ },
88
+ body: JSON.stringify(encrypted)
89
+ });
90
+
91
+ return res.json();
92
+ }
93
+
94
+ /**
95
+ * Update an existing scheduled message.
96
+ *
97
+ * @param {string} uuid - Task UUID.
98
+ * @param {Object} updates - Fields to update.
99
+ * @returns {Promise<Object>}
100
+ */
101
+ async updateMessage(uuid, updates) {
102
+ const encrypted = await this._encrypt(JSON.stringify(updates));
103
+
104
+ const res = await fetch(`${this._baseUrl}/update-message?id=${encodeURIComponent(uuid)}`, {
105
+ method: 'PUT',
106
+ headers: {
107
+ 'Content-Type': 'application/json',
108
+ 'X-User-Id': this._userId,
109
+ 'X-Payload-Encrypted': 'true',
110
+ 'X-Encryption-Version': '1'
111
+ },
112
+ body: JSON.stringify(encrypted)
113
+ });
114
+
115
+ return res.json();
116
+ }
117
+
118
+ /**
119
+ * Cancel / delete a scheduled message.
120
+ *
121
+ * @param {string} uuid - Task UUID.
122
+ * @returns {Promise<Object>}
123
+ */
124
+ async cancelMessage(uuid) {
125
+ const res = await fetch(`${this._baseUrl}/cancel-message?id=${encodeURIComponent(uuid)}`, {
126
+ method: 'DELETE',
127
+ headers: { 'X-User-Id': this._userId }
128
+ });
129
+
130
+ return res.json();
131
+ }
132
+
133
+ /**
134
+ * List the current user's messages with optional filters.
135
+ *
136
+ * @param {Object} [opts]
137
+ * @param {string} [opts.status]
138
+ * @param {number} [opts.limit]
139
+ * @param {number} [opts.offset]
140
+ * @returns {Promise<Object>}
141
+ */
142
+ async listMessages(opts = {}) {
143
+ const params = new URLSearchParams();
144
+ if (opts.status) params.set('status', opts.status);
145
+ if (opts.limit != null) params.set('limit', String(opts.limit));
146
+ if (opts.offset != null) params.set('offset', String(opts.offset));
147
+
148
+ const qs = params.toString();
149
+ const url = `${this._baseUrl}/messages${qs ? '?' + qs : ''}`;
150
+
151
+ const res = await fetch(url, {
152
+ method: 'GET',
153
+ headers: {
154
+ 'X-User-Id': this._userId,
155
+ 'X-Response-Encrypted': 'true',
156
+ 'X-Encryption-Version': '1'
157
+ }
158
+ });
159
+
160
+ const json = await res.json();
161
+ if (!json?.success || json?.encrypted !== true) return json;
162
+
163
+ const decrypted = await this._decrypt(json.data);
164
+ return {
165
+ success: true,
166
+ encrypted: true,
167
+ version: json.version || 1,
168
+ data: decrypted
169
+ };
170
+ }
171
+
172
+ // ─── Push Subscription ──────────────────────────────────────────
173
+
174
+ /**
175
+ * Subscribe to Web Push notifications.
176
+ *
177
+ * @param {string} vapidPublicKey - The server's VAPID public key.
178
+ * @param {ServiceWorkerRegistration} registration - An active SW registration.
179
+ * @returns {Promise<PushSubscription>}
180
+ */
181
+ async subscribePush(vapidPublicKey, registration) {
182
+ const subscription = await registration.pushManager.subscribe({
183
+ userVisibleOnly: true,
184
+ applicationServerKey: this._urlBase64ToUint8Array(vapidPublicKey)
185
+ });
186
+ return subscription;
187
+ }
188
+
189
+ // ─── Crypto helpers (Web Crypto API) ────────────────────────────
190
+
191
+ /**
192
+ * Derive a user-specific AES-256-GCM key from the master key and userId.
193
+ * @private
194
+ */
195
+ async _deriveKey(masterKey, userId) {
196
+ const encoder = new TextEncoder();
197
+ const data = encoder.encode(masterKey + userId);
198
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
199
+ return new Uint8Array(hashBuffer);
200
+ }
201
+
202
+ /**
203
+ * Encrypt plaintext with AES-256-GCM.
204
+ * @private
205
+ * @param {string} plaintext
206
+ * @returns {Promise<{ iv: string, authTag: string, encryptedData: string }>}
207
+ */
208
+ async _encrypt(plaintext) {
209
+ if (!this._userKey) throw new Error('[rei-standard-amsg-client] Not initialised. Call init() first.');
210
+
211
+ const iv = crypto.getRandomValues(new Uint8Array(12));
212
+ const key = await crypto.subtle.importKey('raw', this._userKey, { name: 'AES-GCM' }, false, ['encrypt']);
213
+ const encoded = new TextEncoder().encode(plaintext);
214
+ const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);
215
+
216
+ // Web Crypto appends the 16-byte auth tag at the end of the ciphertext
217
+ const cipherArr = new Uint8Array(cipherBuf);
218
+ const encryptedData = cipherArr.slice(0, cipherArr.length - 16);
219
+ const authTag = cipherArr.slice(cipherArr.length - 16);
220
+
221
+ return {
222
+ iv: this._toBase64(iv),
223
+ authTag: this._toBase64(authTag),
224
+ encryptedData: this._toBase64(encryptedData)
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Decrypt an encrypted API payload.
230
+ * @private
231
+ * @param {{ iv: string, authTag: string, encryptedData: string }} encryptedPayload
232
+ * @returns {Promise<Object>}
233
+ */
234
+ async _decrypt(encryptedPayload) {
235
+ if (!this._userKey) throw new Error('[rei-standard-amsg-client] Not initialised. Call init() first.');
236
+
237
+ const { iv, authTag, encryptedData } = encryptedPayload || {};
238
+ if (typeof iv !== 'string' || typeof authTag !== 'string' || typeof encryptedData !== 'string') {
239
+ throw new Error('[rei-standard-amsg-client] Invalid encrypted payload');
240
+ }
241
+
242
+ const ivBytes = this._fromBase64(iv);
243
+ const authTagBytes = this._fromBase64(authTag);
244
+ const encryptedBytes = this._fromBase64(encryptedData);
245
+ const cipherBytes = new Uint8Array(encryptedBytes.length + authTagBytes.length);
246
+ cipherBytes.set(encryptedBytes);
247
+ cipherBytes.set(authTagBytes, encryptedBytes.length);
248
+
249
+ const key = await crypto.subtle.importKey('raw', this._userKey, { name: 'AES-GCM' }, false, ['decrypt']);
250
+ const plainBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivBytes }, key, cipherBytes);
251
+ return JSON.parse(new TextDecoder().decode(plainBuffer));
252
+ }
253
+
254
+ /** @private */
255
+ _toBase64(uint8) {
256
+ const binary = Array.from(uint8, byte => String.fromCharCode(byte)).join('');
257
+ return btoa(binary);
258
+ }
259
+
260
+ /** @private */
261
+ _fromBase64(base64) {
262
+ const raw = atob(base64);
263
+ const arr = new Uint8Array(raw.length);
264
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
265
+ return arr;
266
+ }
267
+
268
+ /** @private */
269
+ _urlBase64ToUint8Array(base64String) {
270
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
271
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
272
+ const raw = atob(base64);
273
+ const arr = new Uint8Array(raw.length);
274
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
275
+ return arr;
276
+ }
277
+ }
278
+
279
+ export { ReiClient };
package/dist/index.mjs ADDED
@@ -0,0 +1,212 @@
1
+ // src/index.js
2
+ var ReiClient = class {
3
+ /**
4
+ * @param {ReiClientConfig} config
5
+ */
6
+ constructor(config) {
7
+ if (!config || !config.baseUrl) throw new Error("[rei-standard-amsg-client] baseUrl is required");
8
+ if (!config.userId) throw new Error("[rei-standard-amsg-client] userId is required");
9
+ this._baseUrl = config.baseUrl.replace(/\/+$/, "");
10
+ this._userId = config.userId;
11
+ this._masterKey = null;
12
+ this._userKey = null;
13
+ }
14
+ // ─── Initialisation ─────────────────────────────────────────────
15
+ /**
16
+ * Fetch the master key and derive the user-specific encryption key.
17
+ * Must be called before any encrypted request.
18
+ */
19
+ async init() {
20
+ const res = await fetch(`${this._baseUrl}/get-master-key`, {
21
+ method: "GET",
22
+ headers: { "X-User-Id": this._userId }
23
+ });
24
+ const json = await res.json();
25
+ if (!json.success) throw new Error(json.error?.message || "Failed to fetch master key");
26
+ this._masterKey = json.data.masterKey;
27
+ this._userKey = await this._deriveKey(this._masterKey, this._userId);
28
+ }
29
+ // ─── Public API ─────────────────────────────────────────────────
30
+ /**
31
+ * Schedule (or instantly send) a message.
32
+ * The payload is automatically encrypted before transmission.
33
+ *
34
+ * @param {Object} payload - Schedule message payload.
35
+ * @returns {Promise<Object>} API response body.
36
+ */
37
+ async scheduleMessage(payload) {
38
+ const encrypted = await this._encrypt(JSON.stringify(payload));
39
+ const res = await fetch(`${this._baseUrl}/schedule-message`, {
40
+ method: "POST",
41
+ headers: {
42
+ "Content-Type": "application/json",
43
+ "X-User-Id": this._userId,
44
+ "X-Payload-Encrypted": "true",
45
+ "X-Encryption-Version": "1"
46
+ },
47
+ body: JSON.stringify(encrypted)
48
+ });
49
+ return res.json();
50
+ }
51
+ /**
52
+ * Update an existing scheduled message.
53
+ *
54
+ * @param {string} uuid - Task UUID.
55
+ * @param {Object} updates - Fields to update.
56
+ * @returns {Promise<Object>}
57
+ */
58
+ async updateMessage(uuid, updates) {
59
+ const encrypted = await this._encrypt(JSON.stringify(updates));
60
+ const res = await fetch(`${this._baseUrl}/update-message?id=${encodeURIComponent(uuid)}`, {
61
+ method: "PUT",
62
+ headers: {
63
+ "Content-Type": "application/json",
64
+ "X-User-Id": this._userId,
65
+ "X-Payload-Encrypted": "true",
66
+ "X-Encryption-Version": "1"
67
+ },
68
+ body: JSON.stringify(encrypted)
69
+ });
70
+ return res.json();
71
+ }
72
+ /**
73
+ * Cancel / delete a scheduled message.
74
+ *
75
+ * @param {string} uuid - Task UUID.
76
+ * @returns {Promise<Object>}
77
+ */
78
+ async cancelMessage(uuid) {
79
+ const res = await fetch(`${this._baseUrl}/cancel-message?id=${encodeURIComponent(uuid)}`, {
80
+ method: "DELETE",
81
+ headers: { "X-User-Id": this._userId }
82
+ });
83
+ return res.json();
84
+ }
85
+ /**
86
+ * List the current user's messages with optional filters.
87
+ *
88
+ * @param {Object} [opts]
89
+ * @param {string} [opts.status]
90
+ * @param {number} [opts.limit]
91
+ * @param {number} [opts.offset]
92
+ * @returns {Promise<Object>}
93
+ */
94
+ async listMessages(opts = {}) {
95
+ const params = new URLSearchParams();
96
+ if (opts.status) params.set("status", opts.status);
97
+ if (opts.limit != null) params.set("limit", String(opts.limit));
98
+ if (opts.offset != null) params.set("offset", String(opts.offset));
99
+ const qs = params.toString();
100
+ const url = `${this._baseUrl}/messages${qs ? "?" + qs : ""}`;
101
+ const res = await fetch(url, {
102
+ method: "GET",
103
+ headers: {
104
+ "X-User-Id": this._userId,
105
+ "X-Response-Encrypted": "true",
106
+ "X-Encryption-Version": "1"
107
+ }
108
+ });
109
+ const json = await res.json();
110
+ if (!json?.success || json?.encrypted !== true) return json;
111
+ const decrypted = await this._decrypt(json.data);
112
+ return {
113
+ success: true,
114
+ encrypted: true,
115
+ version: json.version || 1,
116
+ data: decrypted
117
+ };
118
+ }
119
+ // ─── Push Subscription ──────────────────────────────────────────
120
+ /**
121
+ * Subscribe to Web Push notifications.
122
+ *
123
+ * @param {string} vapidPublicKey - The server's VAPID public key.
124
+ * @param {ServiceWorkerRegistration} registration - An active SW registration.
125
+ * @returns {Promise<PushSubscription>}
126
+ */
127
+ async subscribePush(vapidPublicKey, registration) {
128
+ const subscription = await registration.pushManager.subscribe({
129
+ userVisibleOnly: true,
130
+ applicationServerKey: this._urlBase64ToUint8Array(vapidPublicKey)
131
+ });
132
+ return subscription;
133
+ }
134
+ // ─── Crypto helpers (Web Crypto API) ────────────────────────────
135
+ /**
136
+ * Derive a user-specific AES-256-GCM key from the master key and userId.
137
+ * @private
138
+ */
139
+ async _deriveKey(masterKey, userId) {
140
+ const encoder = new TextEncoder();
141
+ const data = encoder.encode(masterKey + userId);
142
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
143
+ return new Uint8Array(hashBuffer);
144
+ }
145
+ /**
146
+ * Encrypt plaintext with AES-256-GCM.
147
+ * @private
148
+ * @param {string} plaintext
149
+ * @returns {Promise<{ iv: string, authTag: string, encryptedData: string }>}
150
+ */
151
+ async _encrypt(plaintext) {
152
+ if (!this._userKey) throw new Error("[rei-standard-amsg-client] Not initialised. Call init() first.");
153
+ const iv = crypto.getRandomValues(new Uint8Array(12));
154
+ const key = await crypto.subtle.importKey("raw", this._userKey, { name: "AES-GCM" }, false, ["encrypt"]);
155
+ const encoded = new TextEncoder().encode(plaintext);
156
+ const cipherBuf = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
157
+ const cipherArr = new Uint8Array(cipherBuf);
158
+ const encryptedData = cipherArr.slice(0, cipherArr.length - 16);
159
+ const authTag = cipherArr.slice(cipherArr.length - 16);
160
+ return {
161
+ iv: this._toBase64(iv),
162
+ authTag: this._toBase64(authTag),
163
+ encryptedData: this._toBase64(encryptedData)
164
+ };
165
+ }
166
+ /**
167
+ * Decrypt an encrypted API payload.
168
+ * @private
169
+ * @param {{ iv: string, authTag: string, encryptedData: string }} encryptedPayload
170
+ * @returns {Promise<Object>}
171
+ */
172
+ async _decrypt(encryptedPayload) {
173
+ if (!this._userKey) throw new Error("[rei-standard-amsg-client] Not initialised. Call init() first.");
174
+ const { iv, authTag, encryptedData } = encryptedPayload || {};
175
+ if (typeof iv !== "string" || typeof authTag !== "string" || typeof encryptedData !== "string") {
176
+ throw new Error("[rei-standard-amsg-client] Invalid encrypted payload");
177
+ }
178
+ const ivBytes = this._fromBase64(iv);
179
+ const authTagBytes = this._fromBase64(authTag);
180
+ const encryptedBytes = this._fromBase64(encryptedData);
181
+ const cipherBytes = new Uint8Array(encryptedBytes.length + authTagBytes.length);
182
+ cipherBytes.set(encryptedBytes);
183
+ cipherBytes.set(authTagBytes, encryptedBytes.length);
184
+ const key = await crypto.subtle.importKey("raw", this._userKey, { name: "AES-GCM" }, false, ["decrypt"]);
185
+ const plainBuffer = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivBytes }, key, cipherBytes);
186
+ return JSON.parse(new TextDecoder().decode(plainBuffer));
187
+ }
188
+ /** @private */
189
+ _toBase64(uint8) {
190
+ const binary = Array.from(uint8, (byte) => String.fromCharCode(byte)).join("");
191
+ return btoa(binary);
192
+ }
193
+ /** @private */
194
+ _fromBase64(base64) {
195
+ const raw = atob(base64);
196
+ const arr = new Uint8Array(raw.length);
197
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
198
+ return arr;
199
+ }
200
+ /** @private */
201
+ _urlBase64ToUint8Array(base64String) {
202
+ const padding = "=".repeat((4 - base64String.length % 4) % 4);
203
+ const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
204
+ const raw = atob(base64);
205
+ const arr = new Uint8Array(raw.length);
206
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
207
+ return arr;
208
+ }
209
+ };
210
+ export {
211
+ ReiClient
212
+ };
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@rei-standard/amsg-client",
3
+ "version": "1.1.0",
4
+ "description": "ReiStandard Active Messaging browser client SDK",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "main": "./dist/index.cjs",
11
+ "module": "./dist/index.mjs",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.mjs",
17
+ "require": "./dist/index.cjs"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsup"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "devDependencies": {
30
+ "tsup": "^8.0.0",
31
+ "typescript": "^5.0.0"
32
+ }
33
+ }