@rei-standard/amsg-client 2.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -2
- package/dist/index.cjs +96 -3
- package/dist/index.d.cts +138 -8
- package/dist/index.d.ts +138 -8
- package/dist/index.mjs +96 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,61 @@ await client.scheduleMessage({
|
|
|
38
38
|
});
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
## 发送即时消息
|
|
42
|
+
|
|
43
|
+
新代码用 `client.sendInstant(payload)`,走 [`@rei-standard/amsg-instant`](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/instant/README.md)。
|
|
44
|
+
|
|
45
|
+
### 加密模式(默认;兼容 amsg-server / amsg-instant 0.1.x)
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
const client = new ReiClient({
|
|
49
|
+
baseUrl: '/api/v1',
|
|
50
|
+
customBaseUrls: {
|
|
51
|
+
instant: 'https://instant.example.com', // 不传则用 baseUrl
|
|
52
|
+
},
|
|
53
|
+
userId: '550e8400-e29b-41d4-a716-446655440000',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await client.init();
|
|
57
|
+
|
|
58
|
+
await client.sendInstant({
|
|
59
|
+
contactName: 'Rei',
|
|
60
|
+
completePrompt: '你是 Rei,用一句话提醒用户带伞',
|
|
61
|
+
apiUrl: 'https://api.openai.com/v1/chat/completions',
|
|
62
|
+
apiKey: '...',
|
|
63
|
+
primaryModel: 'gpt-4o-mini',
|
|
64
|
+
pushSubscription: subscription.toJSON(),
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
> `customBaseUrls` 是按端点名(如 `instant`)覆盖 `baseUrl` 的通用机制;后续其他端点也可以用同一字段独立指定 base URL,不会再加新的命名字段。
|
|
69
|
+
|
|
70
|
+
### 明文模式(配 amsg-instant 0.2.x,单租户自部署)
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
const client = new ReiClient({
|
|
74
|
+
baseUrl: 'https://instant.example.com', // amsg-instant Worker URL
|
|
75
|
+
instantEncryption: false,
|
|
76
|
+
instantClientToken: 'shared-secret-xyz', // 可选;Worker 端配了再填
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// init() 在明文模式下是 no-op,调用与否都跑得通
|
|
80
|
+
await client.sendInstant({
|
|
81
|
+
contactName: 'Rei',
|
|
82
|
+
completePrompt: '你是 Rei,用一句话提醒用户带伞',
|
|
83
|
+
apiUrl: 'https://api.openai.com/v1/chat/completions',
|
|
84
|
+
apiKey: '...',
|
|
85
|
+
primaryModel: 'gpt-4o-mini',
|
|
86
|
+
pushSubscription: subscription.toJSON(),
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
> ⚠️ **`instantClientToken` 是弱共享密钥**:它会随前端 bundle 发出去,devtools 一开就能看到。它只防 URL 直接被脚本小子打,不防有心人。要真正的鉴权,用 amsg-instant 的 `tokenSigningKey`(HMAC JWT,配合后端签发短期 token)。
|
|
91
|
+
|
|
92
|
+
> ⚠️ **双模式陷阱**:`instantEncryption: false` 时 `init()` 变成 no-op,`scheduleMessage` / `listMessages` / `updateMessage` 这类**仍走加密**的方法会因 `userKey` 没初始化抛 "Not initialised"。如果同一前端既要 `sendInstant`(明文走 amsg-instant)又要 `scheduleMessage`(加密走 amsg-server),请改回 `instantEncryption: true`(默认)—— amsg-instant 0.1.x 与 amsg-server 用同一份 `userKey` 都吃得下。
|
|
93
|
+
|
|
94
|
+
旧路径 `scheduleMessage({ ...payload, messageType: 'instant' })` 仍然可用(兼容保留,多一次 DB 来回)。
|
|
95
|
+
|
|
41
96
|
## 导出 API(Exports)
|
|
42
97
|
|
|
43
98
|
- `ReiClient`
|
|
@@ -46,6 +101,7 @@ await client.scheduleMessage({
|
|
|
46
101
|
|
|
47
102
|
- `init()`
|
|
48
103
|
- `scheduleMessage(payload)`
|
|
104
|
+
- `sendInstant(payload)`
|
|
49
105
|
- `updateMessage(uuid, updates)`
|
|
50
106
|
- `cancelMessage(uuid)`
|
|
51
107
|
- `listMessages(opts)`
|
|
@@ -61,8 +117,8 @@ await client.scheduleMessage({
|
|
|
61
117
|
|
|
62
118
|
- 浏览器环境(需 `fetch`、`crypto.subtle`)
|
|
63
119
|
- Push 订阅需可用 Service Worker 与 Push API
|
|
64
|
-
- 需要可用的 `baseUrl`(示例:`/api/v1
|
|
65
|
-
- `userId` 必须是 UUID v4
|
|
120
|
+
- 需要可用的 `baseUrl`(示例:`/api/v1`;明文 instant 模式下可直接是 Worker URL)
|
|
121
|
+
- `userId` 必须是 UUID v4(明文 instant 模式 `instantEncryption: false` 下可省)
|
|
66
122
|
|
|
67
123
|
## 相关链接(绝对 URL)
|
|
68
124
|
|
package/dist/index.cjs
CHANGED
|
@@ -28,17 +28,51 @@ var ReiClient = class {
|
|
|
28
28
|
*/
|
|
29
29
|
constructor(config) {
|
|
30
30
|
if (!config || !config.baseUrl) throw new Error("[rei-standard-amsg-client] baseUrl is required");
|
|
31
|
-
|
|
31
|
+
const instantEncryption = config.instantEncryption !== false;
|
|
32
|
+
if (!config.userId && instantEncryption) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"[rei-standard-amsg-client] userId is required (omit only when instantEncryption: false)"
|
|
35
|
+
);
|
|
36
|
+
}
|
|
32
37
|
this._baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
33
|
-
this.
|
|
38
|
+
this._customBaseUrls = {};
|
|
39
|
+
if (config.customBaseUrls && typeof config.customBaseUrls === "object") {
|
|
40
|
+
for (const [name, url] of Object.entries(config.customBaseUrls)) {
|
|
41
|
+
if (typeof url === "string" && url) {
|
|
42
|
+
this._customBaseUrls[name] = url.replace(/\/+$/, "");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
this._userId = config.userId || "";
|
|
34
47
|
this._userKey = null;
|
|
48
|
+
this._instantEncryption = instantEncryption;
|
|
49
|
+
this._instantClientToken = typeof config.instantClientToken === "string" && config.instantClientToken ? config.instantClientToken : "";
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the base URL for a given endpoint, falling back to `baseUrl`.
|
|
53
|
+
*
|
|
54
|
+
* @private
|
|
55
|
+
* @param {string} endpointName
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
_resolveBaseUrl(endpointName) {
|
|
59
|
+
return this._customBaseUrls[endpointName] || this._baseUrl;
|
|
35
60
|
}
|
|
36
61
|
// ─── Initialisation ─────────────────────────────────────────────
|
|
37
62
|
/**
|
|
38
63
|
* Fetch the user-specific encryption key.
|
|
39
64
|
* Must be called before any encrypted request.
|
|
65
|
+
*
|
|
66
|
+
* In plaintext-instant mode (`instantEncryption: false`) this is a no-op:
|
|
67
|
+
* `sendInstant()` does not need a userKey. Note that if you also intend to
|
|
68
|
+
* call `scheduleMessage` / `listMessages` / `updateMessage` (which always
|
|
69
|
+
* use AES-256-GCM), you must construct with `instantEncryption: true`
|
|
70
|
+
* (the default) — those methods will throw "Not initialised" otherwise.
|
|
40
71
|
*/
|
|
41
72
|
async init() {
|
|
73
|
+
if (this._instantEncryption === false) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
42
76
|
const res = await fetch(`${this._baseUrl}/get-user-key`, {
|
|
43
77
|
method: "GET",
|
|
44
78
|
headers: { "X-User-Id": this._userId }
|
|
@@ -53,7 +87,14 @@ var ReiClient = class {
|
|
|
53
87
|
}
|
|
54
88
|
// ─── Public API ─────────────────────────────────────────────────
|
|
55
89
|
/**
|
|
56
|
-
* Schedule
|
|
90
|
+
* Schedule a message.
|
|
91
|
+
*
|
|
92
|
+
* Note: For `messageType: 'instant'`, prefer `sendInstant()` instead.
|
|
93
|
+
* That routes through `@rei-standard/amsg-instant` (stateless, no DB
|
|
94
|
+
* round-trip) rather than `amsg-server`'s schedule-message endpoint.
|
|
95
|
+
* This method still works for instant via amsg-server for backward
|
|
96
|
+
* compatibility — see CHANGELOG / README for details.
|
|
97
|
+
*
|
|
57
98
|
* The payload is automatically encrypted before transmission.
|
|
58
99
|
*
|
|
59
100
|
* @param {Object} payload - Schedule message payload.
|
|
@@ -73,6 +114,58 @@ var ReiClient = class {
|
|
|
73
114
|
});
|
|
74
115
|
return res.json();
|
|
75
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Send a one-shot instant message via `@rei-standard/amsg-instant`.
|
|
119
|
+
*
|
|
120
|
+
* Compared to `scheduleMessage({ messageType: 'instant', ... })`:
|
|
121
|
+
* - No DB round-trip on the server side (stateless)
|
|
122
|
+
* - Deployable to Cloudflare Workers / Deno Deploy / Vercel Edge
|
|
123
|
+
* - Rejects scheduled-only fields (`firstSendTime`, `recurrenceType`)
|
|
124
|
+
*
|
|
125
|
+
* Two transport modes (chosen by constructor `instantEncryption`):
|
|
126
|
+
*
|
|
127
|
+
* - **Encrypted (default)** — payload is AES-256-GCM encrypted with the
|
|
128
|
+
* `userKey` fetched by `init()`. Compatible with amsg-instant 0.1.x and
|
|
129
|
+
* with amsg-server's `schedule-message` instant path. Sends
|
|
130
|
+
* `X-User-Id` + `X-Payload-Encrypted: true` + `X-Encryption-Version: 1`.
|
|
131
|
+
*
|
|
132
|
+
* - **Plaintext** (`instantEncryption: false`) — payload is sent as raw
|
|
133
|
+
* JSON. Targets amsg-instant 0.2.x. Sends `X-Client-Token` if
|
|
134
|
+
* `instantClientToken` was configured.
|
|
135
|
+
*
|
|
136
|
+
* Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
|
|
137
|
+
*
|
|
138
|
+
* @param {Object} payload - Instant message payload.
|
|
139
|
+
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
140
|
+
* @param {{ authorization?: string }} [opts] - Optional auth header to forward.
|
|
141
|
+
* @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
|
|
142
|
+
*/
|
|
143
|
+
async sendInstant(payload, endpointPath = "/instant", opts = {}) {
|
|
144
|
+
const headers = { "Content-Type": "application/json" };
|
|
145
|
+
let body;
|
|
146
|
+
if (this._instantEncryption === false) {
|
|
147
|
+
body = JSON.stringify(payload);
|
|
148
|
+
if (this._instantClientToken) {
|
|
149
|
+
headers["X-Client-Token"] = this._instantClientToken;
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
const encrypted = await this._encrypt(JSON.stringify(payload));
|
|
153
|
+
headers["X-User-Id"] = this._userId;
|
|
154
|
+
headers["X-Payload-Encrypted"] = "true";
|
|
155
|
+
headers["X-Encryption-Version"] = "1";
|
|
156
|
+
body = JSON.stringify(encrypted);
|
|
157
|
+
}
|
|
158
|
+
if (opts.authorization) {
|
|
159
|
+
headers["Authorization"] = opts.authorization;
|
|
160
|
+
}
|
|
161
|
+
const path = endpointPath.startsWith("/") ? endpointPath : `/${endpointPath}`;
|
|
162
|
+
const res = await fetch(`${this._resolveBaseUrl("instant")}${path}`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers,
|
|
165
|
+
body
|
|
166
|
+
});
|
|
167
|
+
return res.json();
|
|
168
|
+
}
|
|
76
169
|
/**
|
|
77
170
|
* Update an existing scheduled message.
|
|
78
171
|
*
|
package/dist/index.d.cts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ReiStandard Client SDK
|
|
3
|
-
* v2.0.1
|
|
4
3
|
*
|
|
5
4
|
* Lightweight browser client that handles:
|
|
6
|
-
* - AES-256-GCM encryption using the Web Crypto API
|
|
5
|
+
* - AES-256-GCM encryption using the Web Crypto API (for amsg-server's
|
|
6
|
+
* schedule path and amsg-instant 0.1.x)
|
|
7
|
+
* - Optional plaintext mode for amsg-instant 0.2.x (instantEncryption: false)
|
|
7
8
|
* - Push subscription management via the Push API
|
|
8
|
-
* - Convenient request helpers for all 7 endpoints
|
|
9
9
|
*
|
|
10
10
|
* Usage:
|
|
11
11
|
* import { ReiClient } from '@rei-standard/amsg-client';
|
|
@@ -24,8 +24,32 @@
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* @typedef {Object} ReiClientConfig
|
|
27
|
-
* @property {string} baseUrl
|
|
28
|
-
*
|
|
27
|
+
* @property {string} baseUrl - Default base URL of the API (e.g. https://host/api/v1).
|
|
28
|
+
* In plaintext-instant mode (`instantEncryption: false`)
|
|
29
|
+
* this can be the amsg-instant Worker URL directly.
|
|
30
|
+
* @property {Record<string, string>} [customBaseUrls] - Optional per-endpoint base URL overrides.
|
|
31
|
+
* Key is the endpoint name (e.g. `instant`); value is
|
|
32
|
+
* the base URL to use for that endpoint instead of
|
|
33
|
+
* `baseUrl`. Useful when different endpoints live on
|
|
34
|
+
* different deployments (e.g. `instant` on Cloudflare
|
|
35
|
+
* Workers while the rest run on Netlify). Future
|
|
36
|
+
* endpoints (e.g. `schedule`, `messages`) can be
|
|
37
|
+
* overridden the same way without an API change.
|
|
38
|
+
* @property {string} [userId] - Current user identifier (UUID v4). Required for the
|
|
39
|
+
* encrypted path (default `instantEncryption: true`,
|
|
40
|
+
* and for `scheduleMessage` / `listMessages` /
|
|
41
|
+
* `updateMessage` always). Can be omitted only when
|
|
42
|
+
* `instantEncryption: false` AND you do not call any
|
|
43
|
+
* encrypted method.
|
|
44
|
+
* @property {boolean} [instantEncryption=true] - When `false`, `sendInstant()` posts plaintext JSON
|
|
45
|
+
* to amsg-instant 0.2.x. `init()` becomes a no-op.
|
|
46
|
+
* All other methods (`scheduleMessage` etc.) keep
|
|
47
|
+
* using AES-256-GCM regardless of this flag.
|
|
48
|
+
* @property {string} [instantClientToken] - When set, sent as the `X-Client-Token` header by
|
|
49
|
+
* `sendInstant()` in plaintext mode. Note: this is
|
|
50
|
+
* a *weak* shared secret — it ships inside any
|
|
51
|
+
* frontend bundle that uses it, so devtools can
|
|
52
|
+
* read it. Use for casual URL-direct abuse only.
|
|
29
53
|
*/
|
|
30
54
|
|
|
31
55
|
class ReiClient {
|
|
@@ -34,14 +58,46 @@ class ReiClient {
|
|
|
34
58
|
*/
|
|
35
59
|
constructor(config) {
|
|
36
60
|
if (!config || !config.baseUrl) throw new Error('[rei-standard-amsg-client] baseUrl is required');
|
|
37
|
-
|
|
61
|
+
|
|
62
|
+
const instantEncryption = config.instantEncryption !== false;
|
|
63
|
+
if (!config.userId && instantEncryption) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
'[rei-standard-amsg-client] userId is required (omit only when instantEncryption: false)'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
38
68
|
|
|
39
69
|
/** @private */
|
|
40
70
|
this._baseUrl = config.baseUrl.replace(/\/+$/, '');
|
|
41
71
|
/** @private */
|
|
42
|
-
this.
|
|
72
|
+
this._customBaseUrls = {};
|
|
73
|
+
if (config.customBaseUrls && typeof config.customBaseUrls === 'object') {
|
|
74
|
+
for (const [name, url] of Object.entries(config.customBaseUrls)) {
|
|
75
|
+
if (typeof url === 'string' && url) {
|
|
76
|
+
this._customBaseUrls[name] = url.replace(/\/+$/, '');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** @private */
|
|
81
|
+
this._userId = config.userId || '';
|
|
43
82
|
/** @private */
|
|
44
83
|
this._userKey = null;
|
|
84
|
+
/** @private */
|
|
85
|
+
this._instantEncryption = instantEncryption;
|
|
86
|
+
/** @private */
|
|
87
|
+
this._instantClientToken = typeof config.instantClientToken === 'string' && config.instantClientToken
|
|
88
|
+
? config.instantClientToken
|
|
89
|
+
: '';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the base URL for a given endpoint, falling back to `baseUrl`.
|
|
94
|
+
*
|
|
95
|
+
* @private
|
|
96
|
+
* @param {string} endpointName
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
_resolveBaseUrl(endpointName) {
|
|
100
|
+
return this._customBaseUrls[endpointName] || this._baseUrl;
|
|
45
101
|
}
|
|
46
102
|
|
|
47
103
|
// ─── Initialisation ─────────────────────────────────────────────
|
|
@@ -49,8 +105,18 @@ class ReiClient {
|
|
|
49
105
|
/**
|
|
50
106
|
* Fetch the user-specific encryption key.
|
|
51
107
|
* Must be called before any encrypted request.
|
|
108
|
+
*
|
|
109
|
+
* In plaintext-instant mode (`instantEncryption: false`) this is a no-op:
|
|
110
|
+
* `sendInstant()` does not need a userKey. Note that if you also intend to
|
|
111
|
+
* call `scheduleMessage` / `listMessages` / `updateMessage` (which always
|
|
112
|
+
* use AES-256-GCM), you must construct with `instantEncryption: true`
|
|
113
|
+
* (the default) — those methods will throw "Not initialised" otherwise.
|
|
52
114
|
*/
|
|
53
115
|
async init() {
|
|
116
|
+
if (this._instantEncryption === false) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
54
120
|
const res = await fetch(`${this._baseUrl}/get-user-key`, {
|
|
55
121
|
method: 'GET',
|
|
56
122
|
headers: { 'X-User-Id': this._userId }
|
|
@@ -70,7 +136,14 @@ class ReiClient {
|
|
|
70
136
|
// ─── Public API ─────────────────────────────────────────────────
|
|
71
137
|
|
|
72
138
|
/**
|
|
73
|
-
* Schedule
|
|
139
|
+
* Schedule a message.
|
|
140
|
+
*
|
|
141
|
+
* Note: For `messageType: 'instant'`, prefer `sendInstant()` instead.
|
|
142
|
+
* That routes through `@rei-standard/amsg-instant` (stateless, no DB
|
|
143
|
+
* round-trip) rather than `amsg-server`'s schedule-message endpoint.
|
|
144
|
+
* This method still works for instant via amsg-server for backward
|
|
145
|
+
* compatibility — see CHANGELOG / README for details.
|
|
146
|
+
*
|
|
74
147
|
* The payload is automatically encrypted before transmission.
|
|
75
148
|
*
|
|
76
149
|
* @param {Object} payload - Schedule message payload.
|
|
@@ -93,6 +166,63 @@ class ReiClient {
|
|
|
93
166
|
return res.json();
|
|
94
167
|
}
|
|
95
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Send a one-shot instant message via `@rei-standard/amsg-instant`.
|
|
171
|
+
*
|
|
172
|
+
* Compared to `scheduleMessage({ messageType: 'instant', ... })`:
|
|
173
|
+
* - No DB round-trip on the server side (stateless)
|
|
174
|
+
* - Deployable to Cloudflare Workers / Deno Deploy / Vercel Edge
|
|
175
|
+
* - Rejects scheduled-only fields (`firstSendTime`, `recurrenceType`)
|
|
176
|
+
*
|
|
177
|
+
* Two transport modes (chosen by constructor `instantEncryption`):
|
|
178
|
+
*
|
|
179
|
+
* - **Encrypted (default)** — payload is AES-256-GCM encrypted with the
|
|
180
|
+
* `userKey` fetched by `init()`. Compatible with amsg-instant 0.1.x and
|
|
181
|
+
* with amsg-server's `schedule-message` instant path. Sends
|
|
182
|
+
* `X-User-Id` + `X-Payload-Encrypted: true` + `X-Encryption-Version: 1`.
|
|
183
|
+
*
|
|
184
|
+
* - **Plaintext** (`instantEncryption: false`) — payload is sent as raw
|
|
185
|
+
* JSON. Targets amsg-instant 0.2.x. Sends `X-Client-Token` if
|
|
186
|
+
* `instantClientToken` was configured.
|
|
187
|
+
*
|
|
188
|
+
* Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
|
|
189
|
+
*
|
|
190
|
+
* @param {Object} payload - Instant message payload.
|
|
191
|
+
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
192
|
+
* @param {{ authorization?: string }} [opts] - Optional auth header to forward.
|
|
193
|
+
* @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
|
|
194
|
+
*/
|
|
195
|
+
async sendInstant(payload, endpointPath = '/instant', opts = {}) {
|
|
196
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
197
|
+
let body;
|
|
198
|
+
|
|
199
|
+
if (this._instantEncryption === false) {
|
|
200
|
+
body = JSON.stringify(payload);
|
|
201
|
+
if (this._instantClientToken) {
|
|
202
|
+
headers['X-Client-Token'] = this._instantClientToken;
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
const encrypted = await this._encrypt(JSON.stringify(payload));
|
|
206
|
+
headers['X-User-Id'] = this._userId;
|
|
207
|
+
headers['X-Payload-Encrypted'] = 'true';
|
|
208
|
+
headers['X-Encryption-Version'] = '1';
|
|
209
|
+
body = JSON.stringify(encrypted);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (opts.authorization) {
|
|
213
|
+
headers['Authorization'] = opts.authorization;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`;
|
|
217
|
+
const res = await fetch(`${this._resolveBaseUrl('instant')}${path}`, {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
headers,
|
|
220
|
+
body
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return res.json();
|
|
224
|
+
}
|
|
225
|
+
|
|
96
226
|
/**
|
|
97
227
|
* Update an existing scheduled message.
|
|
98
228
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ReiStandard Client SDK
|
|
3
|
-
* v2.0.1
|
|
4
3
|
*
|
|
5
4
|
* Lightweight browser client that handles:
|
|
6
|
-
* - AES-256-GCM encryption using the Web Crypto API
|
|
5
|
+
* - AES-256-GCM encryption using the Web Crypto API (for amsg-server's
|
|
6
|
+
* schedule path and amsg-instant 0.1.x)
|
|
7
|
+
* - Optional plaintext mode for amsg-instant 0.2.x (instantEncryption: false)
|
|
7
8
|
* - Push subscription management via the Push API
|
|
8
|
-
* - Convenient request helpers for all 7 endpoints
|
|
9
9
|
*
|
|
10
10
|
* Usage:
|
|
11
11
|
* import { ReiClient } from '@rei-standard/amsg-client';
|
|
@@ -24,8 +24,32 @@
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* @typedef {Object} ReiClientConfig
|
|
27
|
-
* @property {string} baseUrl
|
|
28
|
-
*
|
|
27
|
+
* @property {string} baseUrl - Default base URL of the API (e.g. https://host/api/v1).
|
|
28
|
+
* In plaintext-instant mode (`instantEncryption: false`)
|
|
29
|
+
* this can be the amsg-instant Worker URL directly.
|
|
30
|
+
* @property {Record<string, string>} [customBaseUrls] - Optional per-endpoint base URL overrides.
|
|
31
|
+
* Key is the endpoint name (e.g. `instant`); value is
|
|
32
|
+
* the base URL to use for that endpoint instead of
|
|
33
|
+
* `baseUrl`. Useful when different endpoints live on
|
|
34
|
+
* different deployments (e.g. `instant` on Cloudflare
|
|
35
|
+
* Workers while the rest run on Netlify). Future
|
|
36
|
+
* endpoints (e.g. `schedule`, `messages`) can be
|
|
37
|
+
* overridden the same way without an API change.
|
|
38
|
+
* @property {string} [userId] - Current user identifier (UUID v4). Required for the
|
|
39
|
+
* encrypted path (default `instantEncryption: true`,
|
|
40
|
+
* and for `scheduleMessage` / `listMessages` /
|
|
41
|
+
* `updateMessage` always). Can be omitted only when
|
|
42
|
+
* `instantEncryption: false` AND you do not call any
|
|
43
|
+
* encrypted method.
|
|
44
|
+
* @property {boolean} [instantEncryption=true] - When `false`, `sendInstant()` posts plaintext JSON
|
|
45
|
+
* to amsg-instant 0.2.x. `init()` becomes a no-op.
|
|
46
|
+
* All other methods (`scheduleMessage` etc.) keep
|
|
47
|
+
* using AES-256-GCM regardless of this flag.
|
|
48
|
+
* @property {string} [instantClientToken] - When set, sent as the `X-Client-Token` header by
|
|
49
|
+
* `sendInstant()` in plaintext mode. Note: this is
|
|
50
|
+
* a *weak* shared secret — it ships inside any
|
|
51
|
+
* frontend bundle that uses it, so devtools can
|
|
52
|
+
* read it. Use for casual URL-direct abuse only.
|
|
29
53
|
*/
|
|
30
54
|
|
|
31
55
|
class ReiClient {
|
|
@@ -34,14 +58,46 @@ class ReiClient {
|
|
|
34
58
|
*/
|
|
35
59
|
constructor(config) {
|
|
36
60
|
if (!config || !config.baseUrl) throw new Error('[rei-standard-amsg-client] baseUrl is required');
|
|
37
|
-
|
|
61
|
+
|
|
62
|
+
const instantEncryption = config.instantEncryption !== false;
|
|
63
|
+
if (!config.userId && instantEncryption) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
'[rei-standard-amsg-client] userId is required (omit only when instantEncryption: false)'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
38
68
|
|
|
39
69
|
/** @private */
|
|
40
70
|
this._baseUrl = config.baseUrl.replace(/\/+$/, '');
|
|
41
71
|
/** @private */
|
|
42
|
-
this.
|
|
72
|
+
this._customBaseUrls = {};
|
|
73
|
+
if (config.customBaseUrls && typeof config.customBaseUrls === 'object') {
|
|
74
|
+
for (const [name, url] of Object.entries(config.customBaseUrls)) {
|
|
75
|
+
if (typeof url === 'string' && url) {
|
|
76
|
+
this._customBaseUrls[name] = url.replace(/\/+$/, '');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** @private */
|
|
81
|
+
this._userId = config.userId || '';
|
|
43
82
|
/** @private */
|
|
44
83
|
this._userKey = null;
|
|
84
|
+
/** @private */
|
|
85
|
+
this._instantEncryption = instantEncryption;
|
|
86
|
+
/** @private */
|
|
87
|
+
this._instantClientToken = typeof config.instantClientToken === 'string' && config.instantClientToken
|
|
88
|
+
? config.instantClientToken
|
|
89
|
+
: '';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the base URL for a given endpoint, falling back to `baseUrl`.
|
|
94
|
+
*
|
|
95
|
+
* @private
|
|
96
|
+
* @param {string} endpointName
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
_resolveBaseUrl(endpointName) {
|
|
100
|
+
return this._customBaseUrls[endpointName] || this._baseUrl;
|
|
45
101
|
}
|
|
46
102
|
|
|
47
103
|
// ─── Initialisation ─────────────────────────────────────────────
|
|
@@ -49,8 +105,18 @@ class ReiClient {
|
|
|
49
105
|
/**
|
|
50
106
|
* Fetch the user-specific encryption key.
|
|
51
107
|
* Must be called before any encrypted request.
|
|
108
|
+
*
|
|
109
|
+
* In plaintext-instant mode (`instantEncryption: false`) this is a no-op:
|
|
110
|
+
* `sendInstant()` does not need a userKey. Note that if you also intend to
|
|
111
|
+
* call `scheduleMessage` / `listMessages` / `updateMessage` (which always
|
|
112
|
+
* use AES-256-GCM), you must construct with `instantEncryption: true`
|
|
113
|
+
* (the default) — those methods will throw "Not initialised" otherwise.
|
|
52
114
|
*/
|
|
53
115
|
async init() {
|
|
116
|
+
if (this._instantEncryption === false) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
54
120
|
const res = await fetch(`${this._baseUrl}/get-user-key`, {
|
|
55
121
|
method: 'GET',
|
|
56
122
|
headers: { 'X-User-Id': this._userId }
|
|
@@ -70,7 +136,14 @@ class ReiClient {
|
|
|
70
136
|
// ─── Public API ─────────────────────────────────────────────────
|
|
71
137
|
|
|
72
138
|
/**
|
|
73
|
-
* Schedule
|
|
139
|
+
* Schedule a message.
|
|
140
|
+
*
|
|
141
|
+
* Note: For `messageType: 'instant'`, prefer `sendInstant()` instead.
|
|
142
|
+
* That routes through `@rei-standard/amsg-instant` (stateless, no DB
|
|
143
|
+
* round-trip) rather than `amsg-server`'s schedule-message endpoint.
|
|
144
|
+
* This method still works for instant via amsg-server for backward
|
|
145
|
+
* compatibility — see CHANGELOG / README for details.
|
|
146
|
+
*
|
|
74
147
|
* The payload is automatically encrypted before transmission.
|
|
75
148
|
*
|
|
76
149
|
* @param {Object} payload - Schedule message payload.
|
|
@@ -93,6 +166,63 @@ class ReiClient {
|
|
|
93
166
|
return res.json();
|
|
94
167
|
}
|
|
95
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Send a one-shot instant message via `@rei-standard/amsg-instant`.
|
|
171
|
+
*
|
|
172
|
+
* Compared to `scheduleMessage({ messageType: 'instant', ... })`:
|
|
173
|
+
* - No DB round-trip on the server side (stateless)
|
|
174
|
+
* - Deployable to Cloudflare Workers / Deno Deploy / Vercel Edge
|
|
175
|
+
* - Rejects scheduled-only fields (`firstSendTime`, `recurrenceType`)
|
|
176
|
+
*
|
|
177
|
+
* Two transport modes (chosen by constructor `instantEncryption`):
|
|
178
|
+
*
|
|
179
|
+
* - **Encrypted (default)** — payload is AES-256-GCM encrypted with the
|
|
180
|
+
* `userKey` fetched by `init()`. Compatible with amsg-instant 0.1.x and
|
|
181
|
+
* with amsg-server's `schedule-message` instant path. Sends
|
|
182
|
+
* `X-User-Id` + `X-Payload-Encrypted: true` + `X-Encryption-Version: 1`.
|
|
183
|
+
*
|
|
184
|
+
* - **Plaintext** (`instantEncryption: false`) — payload is sent as raw
|
|
185
|
+
* JSON. Targets amsg-instant 0.2.x. Sends `X-Client-Token` if
|
|
186
|
+
* `instantClientToken` was configured.
|
|
187
|
+
*
|
|
188
|
+
* Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
|
|
189
|
+
*
|
|
190
|
+
* @param {Object} payload - Instant message payload.
|
|
191
|
+
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
192
|
+
* @param {{ authorization?: string }} [opts] - Optional auth header to forward.
|
|
193
|
+
* @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
|
|
194
|
+
*/
|
|
195
|
+
async sendInstant(payload, endpointPath = '/instant', opts = {}) {
|
|
196
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
197
|
+
let body;
|
|
198
|
+
|
|
199
|
+
if (this._instantEncryption === false) {
|
|
200
|
+
body = JSON.stringify(payload);
|
|
201
|
+
if (this._instantClientToken) {
|
|
202
|
+
headers['X-Client-Token'] = this._instantClientToken;
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
const encrypted = await this._encrypt(JSON.stringify(payload));
|
|
206
|
+
headers['X-User-Id'] = this._userId;
|
|
207
|
+
headers['X-Payload-Encrypted'] = 'true';
|
|
208
|
+
headers['X-Encryption-Version'] = '1';
|
|
209
|
+
body = JSON.stringify(encrypted);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (opts.authorization) {
|
|
213
|
+
headers['Authorization'] = opts.authorization;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`;
|
|
217
|
+
const res = await fetch(`${this._resolveBaseUrl('instant')}${path}`, {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
headers,
|
|
220
|
+
body
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return res.json();
|
|
224
|
+
}
|
|
225
|
+
|
|
96
226
|
/**
|
|
97
227
|
* Update an existing scheduled message.
|
|
98
228
|
*
|
package/dist/index.mjs
CHANGED
|
@@ -5,17 +5,51 @@ var ReiClient = class {
|
|
|
5
5
|
*/
|
|
6
6
|
constructor(config) {
|
|
7
7
|
if (!config || !config.baseUrl) throw new Error("[rei-standard-amsg-client] baseUrl is required");
|
|
8
|
-
|
|
8
|
+
const instantEncryption = config.instantEncryption !== false;
|
|
9
|
+
if (!config.userId && instantEncryption) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
"[rei-standard-amsg-client] userId is required (omit only when instantEncryption: false)"
|
|
12
|
+
);
|
|
13
|
+
}
|
|
9
14
|
this._baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
10
|
-
this.
|
|
15
|
+
this._customBaseUrls = {};
|
|
16
|
+
if (config.customBaseUrls && typeof config.customBaseUrls === "object") {
|
|
17
|
+
for (const [name, url] of Object.entries(config.customBaseUrls)) {
|
|
18
|
+
if (typeof url === "string" && url) {
|
|
19
|
+
this._customBaseUrls[name] = url.replace(/\/+$/, "");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
this._userId = config.userId || "";
|
|
11
24
|
this._userKey = null;
|
|
25
|
+
this._instantEncryption = instantEncryption;
|
|
26
|
+
this._instantClientToken = typeof config.instantClientToken === "string" && config.instantClientToken ? config.instantClientToken : "";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the base URL for a given endpoint, falling back to `baseUrl`.
|
|
30
|
+
*
|
|
31
|
+
* @private
|
|
32
|
+
* @param {string} endpointName
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
_resolveBaseUrl(endpointName) {
|
|
36
|
+
return this._customBaseUrls[endpointName] || this._baseUrl;
|
|
12
37
|
}
|
|
13
38
|
// ─── Initialisation ─────────────────────────────────────────────
|
|
14
39
|
/**
|
|
15
40
|
* Fetch the user-specific encryption key.
|
|
16
41
|
* Must be called before any encrypted request.
|
|
42
|
+
*
|
|
43
|
+
* In plaintext-instant mode (`instantEncryption: false`) this is a no-op:
|
|
44
|
+
* `sendInstant()` does not need a userKey. Note that if you also intend to
|
|
45
|
+
* call `scheduleMessage` / `listMessages` / `updateMessage` (which always
|
|
46
|
+
* use AES-256-GCM), you must construct with `instantEncryption: true`
|
|
47
|
+
* (the default) — those methods will throw "Not initialised" otherwise.
|
|
17
48
|
*/
|
|
18
49
|
async init() {
|
|
50
|
+
if (this._instantEncryption === false) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
19
53
|
const res = await fetch(`${this._baseUrl}/get-user-key`, {
|
|
20
54
|
method: "GET",
|
|
21
55
|
headers: { "X-User-Id": this._userId }
|
|
@@ -30,7 +64,14 @@ var ReiClient = class {
|
|
|
30
64
|
}
|
|
31
65
|
// ─── Public API ─────────────────────────────────────────────────
|
|
32
66
|
/**
|
|
33
|
-
* Schedule
|
|
67
|
+
* Schedule a message.
|
|
68
|
+
*
|
|
69
|
+
* Note: For `messageType: 'instant'`, prefer `sendInstant()` instead.
|
|
70
|
+
* That routes through `@rei-standard/amsg-instant` (stateless, no DB
|
|
71
|
+
* round-trip) rather than `amsg-server`'s schedule-message endpoint.
|
|
72
|
+
* This method still works for instant via amsg-server for backward
|
|
73
|
+
* compatibility — see CHANGELOG / README for details.
|
|
74
|
+
*
|
|
34
75
|
* The payload is automatically encrypted before transmission.
|
|
35
76
|
*
|
|
36
77
|
* @param {Object} payload - Schedule message payload.
|
|
@@ -50,6 +91,58 @@ var ReiClient = class {
|
|
|
50
91
|
});
|
|
51
92
|
return res.json();
|
|
52
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Send a one-shot instant message via `@rei-standard/amsg-instant`.
|
|
96
|
+
*
|
|
97
|
+
* Compared to `scheduleMessage({ messageType: 'instant', ... })`:
|
|
98
|
+
* - No DB round-trip on the server side (stateless)
|
|
99
|
+
* - Deployable to Cloudflare Workers / Deno Deploy / Vercel Edge
|
|
100
|
+
* - Rejects scheduled-only fields (`firstSendTime`, `recurrenceType`)
|
|
101
|
+
*
|
|
102
|
+
* Two transport modes (chosen by constructor `instantEncryption`):
|
|
103
|
+
*
|
|
104
|
+
* - **Encrypted (default)** — payload is AES-256-GCM encrypted with the
|
|
105
|
+
* `userKey` fetched by `init()`. Compatible with amsg-instant 0.1.x and
|
|
106
|
+
* with amsg-server's `schedule-message` instant path. Sends
|
|
107
|
+
* `X-User-Id` + `X-Payload-Encrypted: true` + `X-Encryption-Version: 1`.
|
|
108
|
+
*
|
|
109
|
+
* - **Plaintext** (`instantEncryption: false`) — payload is sent as raw
|
|
110
|
+
* JSON. Targets amsg-instant 0.2.x. Sends `X-Client-Token` if
|
|
111
|
+
* `instantClientToken` was configured.
|
|
112
|
+
*
|
|
113
|
+
* Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
|
|
114
|
+
*
|
|
115
|
+
* @param {Object} payload - Instant message payload.
|
|
116
|
+
* @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
|
|
117
|
+
* @param {{ authorization?: string }} [opts] - Optional auth header to forward.
|
|
118
|
+
* @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
|
|
119
|
+
*/
|
|
120
|
+
async sendInstant(payload, endpointPath = "/instant", opts = {}) {
|
|
121
|
+
const headers = { "Content-Type": "application/json" };
|
|
122
|
+
let body;
|
|
123
|
+
if (this._instantEncryption === false) {
|
|
124
|
+
body = JSON.stringify(payload);
|
|
125
|
+
if (this._instantClientToken) {
|
|
126
|
+
headers["X-Client-Token"] = this._instantClientToken;
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
const encrypted = await this._encrypt(JSON.stringify(payload));
|
|
130
|
+
headers["X-User-Id"] = this._userId;
|
|
131
|
+
headers["X-Payload-Encrypted"] = "true";
|
|
132
|
+
headers["X-Encryption-Version"] = "1";
|
|
133
|
+
body = JSON.stringify(encrypted);
|
|
134
|
+
}
|
|
135
|
+
if (opts.authorization) {
|
|
136
|
+
headers["Authorization"] = opts.authorization;
|
|
137
|
+
}
|
|
138
|
+
const path = endpointPath.startsWith("/") ? endpointPath : `/${endpointPath}`;
|
|
139
|
+
const res = await fetch(`${this._resolveBaseUrl("instant")}${path}`, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers,
|
|
142
|
+
body
|
|
143
|
+
});
|
|
144
|
+
return res.json();
|
|
145
|
+
}
|
|
53
146
|
/**
|
|
54
147
|
* Update an existing scheduled message.
|
|
55
148
|
*
|