@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 +28 -0
- package/dist/index.cjs +232 -0
- package/dist/index.d.cts +279 -0
- package/dist/index.d.ts +279 -0
- package/dist/index.mjs +212 -0
- package/package.json +33 -0
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
|
+
};
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|