diff --git a/.changeset/amsg-client-client-state.md b/.changeset/amsg-client-client-state.md new file mode 100644 index 0000000..77cb8bb --- /dev/null +++ b/.changeset/amsg-client-client-state.md @@ -0,0 +1,11 @@ +--- +"@rei-standard/amsg-client": minor +--- + +新增 client_state 三方法,对接单用户 worker 的云端状态镜像(amsg-server 2.6.0 的 `/client-state` 端点)。 + +- `putClientState(entries)`:批量 upsert。一次请求发完全部变更(照顾 iOS 切后台前只有几秒的存活窗口);请求体走既有加密链路(需先 `init()`),服务端按 `updatedAt` 最后写赢,重发旧批次无害。 +- `getClientState(namespace)`:取一个 namespace 的全部条目,自动解密响应 envelope(同 `listMessages`)。 +- `clearClientState()`:清空该用户全部云端状态(给设置页「清除云端状态」这类入口用)。 + +配了 `serverToken` 时三个方法都带 `X-Client-Token`。 diff --git a/packages/rei-standard-amsg/client/src/index.js b/packages/rei-standard-amsg/client/src/index.js index 43683ee..8900c34 100644 --- a/packages/rei-standard-amsg/client/src/index.js +++ b/packages/rei-standard-amsg/client/src/index.js @@ -990,6 +990,104 @@ export class ReiClient { }; } + // ─── Client state (single-user cloud mirror) ──────────────────── + + /** + * Batch-upsert client-state entries (single-user worker, + * amsg-server 2.6.0+ `/client-state`). + * + * The worker keeps one live copy per (namespace, key); this client is + * the only writer. Send everything that changed in ONE call — e.g. + * inside the few-seconds window before iOS backgrounds the page — the + * server upserts the whole batch in a single DB round trip. Upserts + * are last-write-wins on `updatedAt`: entries older than the stored + * row are skipped, so re-sending a stale batch is harmless. + * + * The payload is encrypted like every other amsg-server call + * (requires `init()`); the worker re-encrypts each value at rest + * under the per-user key. + * + * @param {Array<{ namespace: string, key: string, value: string, updatedAt: number }>} entries + * - `value`: pre-serialized string (the SDK does not stringify it for you). + * - `updatedAt`: epoch milliseconds. + * @returns {Promise} `{ success, data?: { upserted, skipped }, error? }` + */ + async putClientState(entries) { + if (!Array.isArray(entries) || entries.length === 0) { + throw new TypeError('[rei-standard-amsg-client] entries must be a non-empty array'); + } + const json = JSON.stringify({ entries }); + this._assertPayloadSize(json, 'putClientState'); + const encrypted = await this._encrypt(json); + + const res = await fetch(`${this._baseUrl}/client-state`, { + method: 'PUT', + headers: this._withServerToken({ + 'Content-Type': 'application/json', + 'X-User-Id': this._userId, + 'X-Payload-Encrypted': 'true', + 'X-Encryption-Version': '1' + }), + body: JSON.stringify(encrypted) + }); + + return res.json(); + } + + /** + * Read every entry of one client-state namespace. + * + * The response rides the encrypted-response envelope (same as + * `listMessages`); this method decrypts it and returns plaintext + * values. + * + * @param {string} namespace + * @returns {Promise} `{ success, data?: { namespace, entries }, error? }` + * where `entries` is `Array<{ namespace, key, value, updatedAt }>`. + */ + async getClientState(namespace) { + if (typeof namespace !== 'string' || !namespace.trim()) { + throw new TypeError('[rei-standard-amsg-client] namespace must be a non-empty string'); + } + const res = await fetch( + `${this._baseUrl}/client-state?namespace=${encodeURIComponent(namespace)}`, + { + method: 'GET', + headers: this._withServerToken({ + 'X-User-Id': this._userId, + 'X-Response-Encrypted': 'true', + 'X-Encryption-Version': '1' + }) + } + ); + + const json = await res.json(); + if (!json?.success || json?.encrypted !== true) return json; + + const decrypted = await this._decrypt(json.data); + return { + success: true, + encrypted: true, + version: json.version || 1, + data: decrypted + }; + } + + /** + * Wipe every client-state entry of this user, across all namespaces — + * e.g. behind a "clear cloud state" settings action. + * + * @returns {Promise} `{ success, data?: { deleted }, error? }` + */ + async clearClientState() { + const res = await fetch(`${this._baseUrl}/client-state`, { + method: 'DELETE', + headers: this._withServerToken({ 'X-User-Id': this._userId }) + }); + + return res.json(); + } + // ─── Push Subscription ────────────────────────────────────────── /** diff --git a/packages/rei-standard-amsg/client/test/client-state.test.mjs b/packages/rei-standard-amsg/client/test/client-state.test.mjs new file mode 100644 index 0000000..42840aa --- /dev/null +++ b/packages/rei-standard-amsg/client/test/client-state.test.mjs @@ -0,0 +1,162 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ReiClient } from '../src/index.js'; + +const USER = '550e8400-e29b-41d4-a716-446655440000'; +const USER_KEY_HEX = 'ab'.repeat(32); // 64 hex chars → 32-byte AES key + +/** Build a client and feed it a userKey via a mocked init() round trip. */ +async function makeInitializedClient(config = {}) { + const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER, ...config }); + const original = globalThis.fetch; + globalThis.fetch = async () => new Response( + JSON.stringify({ success: true, data: { userKey: USER_KEY_HEX } }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + try { + await client.init(); + } finally { + globalThis.fetch = original; + } + return client; +} + +test('putClientState() PUTs an encrypted batch and returns the server verdict', async () => { + const client = await makeInitializedClient({ serverToken: 's3cret' }); + const entries = [ + { namespace: 'profile', key: 'display', value: '{"name":"A"}', updatedAt: 1700000000000 }, + { namespace: 'profile', key: 'locale', value: '"zh-CN"', updatedAt: 1700000000001 } + ]; + + const captured = []; + const original = globalThis.fetch; + globalThis.fetch = async (url, init) => { + captured.push({ url: String(url), method: init.method, headers: init.headers, body: init.body }); + return new Response(JSON.stringify({ success: true, data: { upserted: 2, skipped: 0 } }), { + status: 200, headers: { 'Content-Type': 'application/json' } + }); + }; + let result; + try { + result = await client.putClientState(entries); + } finally { + globalThis.fetch = original; + } + + assert.equal(captured.length, 1); + assert.equal(captured[0].url, 'https://w.dev/client-state'); + assert.equal(captured[0].method, 'PUT'); + assert.equal(captured[0].headers['X-User-Id'], USER); + assert.equal(captured[0].headers['X-Payload-Encrypted'], 'true'); + assert.equal(captured[0].headers['X-Encryption-Version'], '1'); + assert.equal(captured[0].headers['X-Client-Token'], 's3cret'); + + // Body is the encrypted envelope, not plaintext — and decrypts back to the batch. + const body = JSON.parse(captured[0].body); + assert.equal(typeof body.iv, 'string'); + assert.equal(typeof body.authTag, 'string'); + assert.equal(typeof body.encryptedData, 'string'); + assert.ok(!captured[0].body.includes('zh-CN'), 'plaintext must not appear in the request body'); + const roundtripped = await client._decrypt(body); + assert.deepEqual(roundtripped, { entries }); + + assert.deepEqual(result, { success: true, data: { upserted: 2, skipped: 0 } }); +}); + +test('getClientState() decrypts the encrypted-response envelope', async () => { + const client = await makeInitializedClient({ serverToken: 's3cret' }); + const payload = { + namespace: 'char:42:persona', + entries: [{ namespace: 'char:42:persona', key: 'card', value: 'hello', updatedAt: 1700000000000 }] + }; + const encrypted = await client._encrypt(JSON.stringify(payload)); + + const captured = []; + const original = globalThis.fetch; + globalThis.fetch = async (url, init) => { + captured.push({ url: String(url), method: init.method, headers: init.headers }); + return new Response( + JSON.stringify({ success: true, encrypted: true, version: 1, data: encrypted }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + }; + let result; + try { + result = await client.getClientState('char:42:persona'); + } finally { + globalThis.fetch = original; + } + + assert.equal(captured[0].url, `https://w.dev/client-state?namespace=${encodeURIComponent('char:42:persona')}`); + assert.equal(captured[0].method, 'GET'); + assert.equal(captured[0].headers['X-User-Id'], USER); + assert.equal(captured[0].headers['X-Response-Encrypted'], 'true'); + assert.equal(captured[0].headers['X-Client-Token'], 's3cret'); + assert.equal(result.success, true); + assert.deepEqual(result.data, payload); +}); + +test('getClientState() passes a non-success response through untouched', async () => { + const client = await makeInitializedClient(); + const failure = { success: false, error: { code: 'NAMESPACE_REQUIRED', message: '必须提供 namespace 查询参数' } }; + + const original = globalThis.fetch; + globalThis.fetch = async () => new Response(JSON.stringify(failure), { + status: 400, headers: { 'Content-Type': 'application/json' } + }); + let result; + try { + result = await client.getClientState('profile'); + } finally { + globalThis.fetch = original; + } + assert.deepEqual(result, failure); +}); + +test('clearClientState() DELETEs /client-state with user + token headers', async () => { + const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER, serverToken: 's3cret' }); + + const captured = []; + const original = globalThis.fetch; + globalThis.fetch = async (url, init) => { + captured.push({ url: String(url), method: init.method, headers: init.headers }); + return new Response(JSON.stringify({ success: true, data: { deleted: 5 } }), { + status: 200, headers: { 'Content-Type': 'application/json' } + }); + }; + let result; + try { + result = await client.clearClientState(); + } finally { + globalThis.fetch = original; + } + + assert.equal(captured[0].url, 'https://w.dev/client-state'); + assert.equal(captured[0].method, 'DELETE'); + assert.equal(captured[0].headers['X-User-Id'], USER); + assert.equal(captured[0].headers['X-Client-Token'], 's3cret'); + assert.deepEqual(result, { success: true, data: { deleted: 5 } }); +}); + +test('putClientState() before init() throws Not initialised', async () => { + const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER }); + await assert.rejects( + () => client.putClientState([{ namespace: 'a', key: 'b', value: 'c', updatedAt: 1 }]), + /Not initialised/ + ); +}); + +test('putClientState() / getClientState() reject invalid arguments before any network call', async () => { + const client = await makeInitializedClient(); + const original = globalThis.fetch; + globalThis.fetch = async () => { throw new Error('must not reach the network'); }; + try { + await assert.rejects(() => client.putClientState(), /entries must be a non-empty array/); + await assert.rejects(() => client.putClientState([]), /entries must be a non-empty array/); + await assert.rejects(() => client.putClientState('nope'), /entries must be a non-empty array/); + await assert.rejects(() => client.getClientState(), /namespace must be a non-empty string/); + await assert.rejects(() => client.getClientState(' '), /namespace must be a non-empty string/); + } finally { + globalThis.fetch = original; + } +});