pushnow-sdk 0.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,231 @@
1
+ # PushNow TypeScript SDK
2
+
3
+ [中文说明](README.zh-CN.md)
4
+
5
+ Browser-compatible SDK for the existing PushNow v2 encrypted API. Uses
6
+ `@hpke/core` 1.9.0 (P-256 / HKDF-SHA256 / AES-256-GCM) and native WebCrypto.
7
+ No Node polyfills, persistence or analytics.
8
+
9
+ ## Local Package
10
+
11
+ ```sh
12
+ cd sdk/typescript
13
+ npm ci
14
+ npm run build
15
+ npm pack
16
+ ```
17
+
18
+ Install from npm after publication:
19
+
20
+ ```sh
21
+ npm install pushnow-sdk
22
+ ```
23
+
24
+ For local development, install the resulting `pushnow-sdk-0.1.0.tgz` into a
25
+ consuming project, or use a local `file:` dependency. Package name: `pushnow-sdk`.
26
+
27
+ The ESM entry `dist/index.js` includes TypeScript declarations. For an unbundled
28
+ browser, serve `dist/browser.js` and import it as an ES module. It includes the
29
+ HPKE dependency and needs no import map. A bundler can use `@pushnow/sdk` directly;
30
+ `@pushnow/sdk/browser` selects the standalone bundle. There is no CommonJS entry.
31
+ Use HTTPS, or loopback HTTP for development, in a runtime with `crypto.subtle`,
32
+ `fetch`, `AbortController`, and `structuredClone` (modern browsers or Node 22+).
33
+
34
+ ## Authorize Once
35
+
36
+ First register, verify email and sign in to the App. Its encrypted account
37
+ archive and approving device must already be initialized. SDK sender approval
38
+ is separate from email/password login; the SDK does not implement account login.
39
+
40
+ ```ts
41
+ import {beginLogin, finishLogin} from 'pushnow-sdk';
42
+
43
+ const controller = new AbortController();
44
+ const pending = await beginLogin('https://api.pushnow.dev', 'My automation', {
45
+ signal: controller.signal,
46
+ });
47
+ // Display pending.authorization.user_code and pending.fingerprint.
48
+ // The user approves this sender in the signed-in App.
49
+ // Obtain the ACCOUNT identity fingerprint from that trusted App separately.
50
+ const config = await finishLogin(pending, {
51
+ expectedIdentityFingerprint: trustedAccountFingerprint,
52
+ signal: controller.signal,
53
+ });
54
+ ```
55
+
56
+ `pending.fingerprint` identifies the new sender. It is NOT the account identity
57
+ fingerprint. `expectedIdentityFingerprint` must be the independently verified
58
+ 64-character SHA-256 hex fingerprint of the account identity public key. Never
59
+ compute the expected value from the same untrusted grant and auto-accept it.
60
+ Alternatively, supply `confirmIdentity: async ({fingerprint, userID}) => boolean`
61
+ and require explicit user comparison with their trusted device. These options
62
+ are mutually exclusive. Missing verification or a mismatch fails closed.
63
+
64
+ Polling respects the server interval and handles HTTP 429; aborting cancels
65
+ both polling requests and waiting. Authorization grants are single-use. If a
66
+ grant is consumed but validation or a subsequent directory request fails, start
67
+ a fresh authorization. `finishLogin` checks origin, archive certificate,
68
+ account fingerprint, source certificate, sender private/public key match and
69
+ device certificates before returning the config.
70
+
71
+ ## Config and Account Binding
72
+
73
+ `AuthorizedConfig` uses the CLI-compatible fields:
74
+
75
+ ```ts
76
+ type AuthorizedConfig = {
77
+ api_url: string;
78
+ user_id: string;
79
+ source_id: string;
80
+ source_key: string;
81
+ identity_public_key: string;
82
+ sender_private_key: string;
83
+ archive: {id: string; public_key: string; certificate: string};
84
+ };
85
+ ```
86
+
87
+ A bearer token alone cannot encrypt messages. It authorizes HTTP requests but
88
+ does not contain the authenticated sender private key, pinned account identity
89
+ or certified archive public key. Only the receiving account devices have the
90
+ archive private key used to decrypt messages.
91
+
92
+ `validateConfig(input)` synchronously checks structure and returns a clean copy;
93
+ it does not prove ownership or independently confirm identity. For a signed-in
94
+ Web dashboard importing a trusted config, keep it in memory, check
95
+ `config.user_id === me.user.id` and `config.api_url === expectedAPIOrigin`, then
96
+ call `recipientsV2(config)`. Replacing `source_key` is supported, but a token for
97
+ another source/account fails directory binding checks. Never embed config in a
98
+ public JS bundle, URL, log, or plaintext persistent browser storage. Clear all
99
+ references on account logout/change. JavaScript cannot guarantee erasure of
100
+ strings or protect secrets from scripts running in the same page.
101
+
102
+ ## Send
103
+
104
+ ```ts
105
+ import {sendNotification} from 'pushnow-sdk';
106
+
107
+ const result = await sendNotification(config, {
108
+ title: 'Build completed',
109
+ body: 'Version 1.2 is ready.',
110
+ links: ['https://example.com/build/42'],
111
+ files: [{data: new Blob(['build output']), name: 'build.txt', mime: 'text/plain'}],
112
+ // image: {data: imageFile, name: imageFile.name, mime: imageFile.type},
113
+ // icon: {data: iconFile, name: iconFile.name, mime: iconFile.type},
114
+ }, {
115
+ // deviceIds: [selectedDeviceID], // omitted: all notification-enabled devices
116
+ // inboxOnly: true, // account inbox only; no APNs request
117
+ // sound: 'chime', // 'default' | 'silent' | 'chime'
118
+ // scheduledAt: new Date(Date.now() + 60_000).toISOString(),
119
+ // expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
120
+ });
121
+ ```
122
+
123
+ `files`, `image`, `icon` accept `{data: Blob | Uint8Array | ArrayBuffer, name,
124
+ mime?, id?}`. The SDK uploads AES-GCM ciphertext and embeds attachment descriptors
125
+ inside the encrypted message. Names, MIME types, keys and clear content are not
126
+ sent as plaintext metadata. The server sees attachment IDs, encrypted byte counts
127
+ and read capabilities, plus routing/schedule metadata. It does not receive
128
+ message text, filenames, or file encryption keys in plaintext.
129
+
130
+ `MessageContent` supports `title`, `body`, `links`, `attachments`, `image_id`,
131
+ `icon_id`. The last two refer to entries in `attachments`. High-level `image`
132
+ and `icon` upload files and set these IDs automatically. Maximum 20 attachments;
133
+ each encrypted file is at most 20 MiB, so plaintext is at most 20 MiB minus
134
+ 16 bytes. Full encrypted manifests are limited to 256 KiB. Preview text is
135
+ UTF-8 bounded; a preview image is omitted when its descriptor cannot fit.
136
+
137
+ `deviceIds: []` and `inboxOnly: true` both suppress notifications. Do not combine
138
+ `inboxOnly: true` with `deviceIds`. Explicit targets must belong to the account;
139
+ disabled devices are filtered. Inbox/history remains account-wide even when
140
+ only one device is notified. Sending to a single device does not make content
141
+ private from the other account devices.
142
+
143
+ Schedules must be future ISO timestamps within 30 days. An expiry must follow
144
+ the delivery time and be within 30 days. Offset timestamps are normalized to UTC.
145
+ `MessageOptions.sound` accepts `'default'`, `'silent'`, or `'chime'`. It is public
146
+ routing metadata on the prepared request, like `scheduled_at`, and is not part
147
+ of the encrypted content. Omit it to preserve the legacy wire and default sound
148
+ behavior. Invalid values are rejected before any HTTP request or file upload.
149
+ `'silent'` omits APNs `aps.sound`; it does not suppress the visible notification
150
+ (use `inboxOnly` for that). `'chime'` selects `pushnow-chime.wav`, bundled in the
151
+ new App version; an older App without the file falls back to the default sound.
152
+ Playback remains subject to iOS notification/sound permissions and Focus/Do Not
153
+ Disturb settings. Interruption levels and critical alerts are not supported.
154
+ Successful API acceptance is not proof of device-visible delivery or audible sound.
155
+
156
+ ## Low-Level Flow and Retries
157
+
158
+ ```ts
159
+ import {recipientsV2, uploadAttachment, prepareMessageV2, submitMessageV2} from 'pushnow-sdk';
160
+
161
+ const directory = await recipientsV2(config);
162
+ const attachment = await uploadAttachment(config, file, {name: file.name});
163
+ const prepared = await prepareMessageV2(config, directory, {
164
+ title: 'Report', body: 'Attached.', attachments: [attachment],
165
+ }, {deviceIds: [directory.devices[0].id]});
166
+ const result = await submitMessageV2(config, prepared);
167
+ // For an uncertain HTTP result, retry submitMessageV2(config, prepared).
168
+ ```
169
+
170
+ Preparation revalidates the entire directory, including account/source/private
171
+ key binding, even if callers provide the directory themselves. It returns an
172
+ immutable object with an in-memory account/source binding. Submission rejects a
173
+ different account/source or a serialized/cloned object that lost that binding.
174
+ Keep the original object for retries: retries send the exact original request
175
+ bytes, including the selected sound or its omission. Do not change its sound or
176
+ prepare again with the same message ID: fresh encryption yields different
177
+ ciphertext and an idempotency conflict.
178
+ Durable serialized outbox import is not provided by this SDK version.
179
+
180
+ The high-level helper performs no automatic send retries. Use the low-level
181
+ flow when you need to control retries. Cancelled/failed uploads may leave
182
+ reserved or uploaded orphan blobs for the backend's existing cleanup process.
183
+
184
+ ## Request Options and Redacted Logging
185
+
186
+ Every HTTP-producing export accepts `RequestOptions` as its final argument:
187
+
188
+ ```ts
189
+ const options = {
190
+ signal: controller.signal,
191
+ fetcher: fetch,
192
+ onRequest(event) {
193
+ // {method, path, status, durationMs, outcome}
194
+ // path is e.g. /v2/attachments/:id, not a URL containing identifiers.
195
+ requestEvents.push(event);
196
+ },
197
+ };
198
+ ```
199
+
200
+ `prepareMessageV2` is local-only and accepts `MessageOptions` instead.
201
+ `sendNotification` accepts the intersection of both option types. Logging fires
202
+ once per HTTP attempt and contains no headers, payloads, tokens, keys or raw
203
+ server errors. Throwing/rejecting logging callbacks does not change delivery.
204
+ Custom `fetcher` implementations necessarily receive credentials and encrypted
205
+ request bodies; they are trusted transport code, not a safe logging API.
206
+ Requests omit browser cookies, reject redirects and time out after 30 seconds.
207
+ `APIError.status` exposes HTTP failures without echoing response text.
208
+
209
+ ## Verification in This Workspace
210
+
211
+ ```sh
212
+ npm test
213
+ ```
214
+
215
+ The tests use existing CLI cryptography/fixtures, bundle the current backend,
216
+ apply every D1 migration and use real Worker HTTP, D1 and R2. They do not contact
217
+ production or APNs. Backend and CLI development dependencies must be installed
218
+ in this monorepo. The browser test needs `npx playwright install chromium --only-shell`.
219
+
220
+ The reusable parent-dashboard fixture is
221
+ `test/helpers/backend-fixture.mjs`:
222
+
223
+ ```js
224
+ const h = await startBackendFixture({corsOrigins: [dashboardOrigin]});
225
+ // h.apiURL, h.config, h.session.{accessToken,userId,deviceId}
226
+ // h.sessions, h.keyID, h.db, h.fixture, h.fetcher
227
+ // Existing /v1/me, membership, /v2/keys, /v2/logs use h.session.accessToken.
228
+ await h.close();
229
+ ```
230
+
231
+ Crypto dependency reference: [hpke-js](https://github.com/dajiaji/hpke-js).
@@ -0,0 +1,59 @@
1
+ # PushNow TypeScript SDK
2
+
3
+ [English](README.md)
4
+
5
+ 这是 PushNow v2 加密 API 的浏览器与 Node.js SDK。它使用 `@hpke/core` 1.9.0、P-256、HKDF-SHA256、AES-256-GCM 和原生 WebCrypto。包格式是 ESM,包含 TypeScript declarations,没有 CommonJS 入口。
6
+
7
+ ## 安装
8
+
9
+ 发布到 npm 后:
10
+
11
+ ```sh
12
+ npm install pushnow-sdk
13
+ ```
14
+
15
+ 本地开发:
16
+
17
+ ```sh
18
+ cd sdk/typescript
19
+ npm ci
20
+ npm run build
21
+ npm pack
22
+ ```
23
+
24
+ ## 授权
25
+
26
+ SDK sender 需要先在已登录的 PushNow App 中审批。`pending.fingerprint` 是新 sender 的指纹,不是账号根指纹。`expectedIdentityFingerprint` 必须来自可信设备,不能从同一个未验证 grant 自动计算并接受。
27
+
28
+ ```ts
29
+ import {beginLogin, finishLogin} from 'pushnow-sdk';
30
+
31
+ const pending = await beginLogin('https://api.pushnow.dev', 'My automation');
32
+ const config = await finishLogin(pending, {
33
+ expectedIdentityFingerprint: trustedAccountFingerprint,
34
+ });
35
+ ```
36
+
37
+ ## 发送通知
38
+
39
+ ```ts
40
+ import {sendNotification} from 'pushnow-sdk';
41
+
42
+ await sendNotification(config, {
43
+ title: 'Build completed',
44
+ body: 'Version 1.2 is ready.',
45
+ links: ['https://example.com/build/42'],
46
+ }, {
47
+ sound: 'chime',
48
+ });
49
+ ```
50
+
51
+ SDK 会在本地加密标题、正文、链接和附件。服务器只能看到路由、定时、附件密文字节数等必要元数据,看不到明文内容、文件名或附件密钥。
52
+
53
+ ## 测试
54
+
55
+ ```sh
56
+ npm test
57
+ ```
58
+
59
+ 测试覆盖 Worker fixture、浏览器 bundle、授权、加密互通、附件、定时、声音路由和脱敏错误。测试不代表生产 APNs 设备可见送达。
@@ -0,0 +1,6 @@
1
+ import type { AttachmentData, AttachmentDescriptor, AttachmentMetadata, AuthorizedConfig, RequestOptions } from './types.js';
2
+ export declare const maxAttachmentSize: number;
3
+ export declare function validateAttachmentMetadata(metadata: AttachmentMetadata): AttachmentMetadata;
4
+ export declare function validateAttachmentData(data: AttachmentData): void;
5
+ export declare function validateDescriptor(value: unknown): AttachmentDescriptor;
6
+ export declare function uploadAttachment(input: AuthorizedConfig, data: AttachmentData, metadata: AttachmentMetadata, options?: RequestOptions): Promise<AttachmentDescriptor>;
@@ -0,0 +1,57 @@
1
+ import { validateConfig } from './config.js';
2
+ import { base64, bytes, cryptoAPI, decode, object, sha256, uuid } from './encoding.js';
3
+ import { authenticated } from './http.js';
4
+ export const maxAttachmentSize = 20 * 1024 * 1024 - 16;
5
+ export function validateAttachmentMetadata(metadata) {
6
+ if (!metadata || typeof metadata.name !== 'string' || !metadata.name.trim() || metadata.name.length > 255 || /[\x00-\x1f]/.test(metadata.name))
7
+ throw new Error('Invalid attachment name');
8
+ if (metadata.mime !== undefined && (typeof metadata.mime !== 'string' || !/^[\w!#$&^.+-]+\/[\w!#$&^.+-]+(?:;[^\r\n]*)?$/.test(metadata.mime) || metadata.mime.length > 255))
9
+ throw new Error('Invalid attachment MIME type');
10
+ if (metadata.id !== undefined)
11
+ uuid(metadata.id);
12
+ return { ...metadata };
13
+ }
14
+ export function validateAttachmentData(data) {
15
+ const size = typeof Blob !== 'undefined' && data instanceof Blob ? data.size :
16
+ data instanceof Uint8Array || data instanceof ArrayBuffer ? data.byteLength : NaN;
17
+ if (!Number.isFinite(size))
18
+ throw new Error('Attachment must be a Blob, Uint8Array or ArrayBuffer');
19
+ if (size > maxAttachmentSize)
20
+ throw new Error('Attachment exceeds the 20 MiB encrypted size limit');
21
+ }
22
+ export function validateDescriptor(value) {
23
+ const d = object(value);
24
+ uuid(d.id);
25
+ validateAttachmentMetadata({ id: d.id, name: d.name, mime: d.mime });
26
+ if (typeof d.mime !== 'string' || !Number.isSafeInteger(d.size) || d.size < 0 || d.size > maxAttachmentSize ||
27
+ typeof d.read_token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(d.read_token) || typeof d.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(d.sha256))
28
+ throw new Error('Invalid attachment descriptor');
29
+ decode(d.key, 32);
30
+ decode(d.nonce, 12);
31
+ return { id: d.id, name: d.name, mime: d.mime, size: d.size, read_token: d.read_token,
32
+ key: d.key, nonce: d.nonce, sha256: d.sha256 };
33
+ }
34
+ export async function uploadAttachment(input, data, metadata, options = {}) {
35
+ const config = validateConfig(input), meta = validateAttachmentMetadata(metadata);
36
+ validateAttachmentData(data);
37
+ options.signal?.throwIfAborted();
38
+ const clear = typeof Blob !== 'undefined' && data instanceof Blob ? new Uint8Array(await data.arrayBuffer()) :
39
+ data instanceof Uint8Array ? new Uint8Array(data) : new Uint8Array(data.slice(0));
40
+ const key = cryptoAPI().getRandomValues(new Uint8Array(32)), nonce = cryptoAPI().getRandomValues(new Uint8Array(12));
41
+ const id = meta.id ?? cryptoAPI().randomUUID();
42
+ try {
43
+ const aes = await cryptoAPI().subtle.importKey('raw', key, 'AES-GCM', false, ['encrypt']);
44
+ const ciphertext = await cryptoAPI().subtle.encrypt({ name: 'AES-GCM', iv: nonce,
45
+ additionalData: bytes(JSON.stringify([2, 'attachment', config.user_id, config.source_id, id])) }, aes, clear);
46
+ const descriptor = { id, name: meta.name, mime: meta.mime ?? 'application/octet-stream', size: clear.length,
47
+ read_token: base64(cryptoAPI().getRandomValues(new Uint8Array(32))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
48
+ key: base64(key), nonce: base64(nonce), sha256: await sha256(clear) };
49
+ await authenticated(config, '/v2/attachments', { ...options, method: 'POST', body: { id, size: ciphertext.byteLength, read_token: descriptor.read_token } });
50
+ await authenticated(config, `/v2/attachments/${id}`, { ...options, method: 'PUT', binary: true, body: ciphertext });
51
+ return descriptor;
52
+ }
53
+ finally {
54
+ key.fill(0);
55
+ clear.fill(0);
56
+ }
57
+ }
package/dist/auth.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { AuthorizedConfig, FinishLoginOptions, PendingLogin, PendingAccountLogin, RequestOptions } from './types.js';
2
+ export declare function beginLogin(apiURL: string, name: string, options?: RequestOptions): Promise<PendingLogin>;
3
+ export declare function finishLogin(input: PendingLogin, options: FinishLoginOptions): Promise<AuthorizedConfig>;
4
+ /** Account login delegates initial approval to an online trusted device. */
5
+ export declare function beginAccountLogin(apiURL: string, accessToken: string, name: string, options?: RequestOptions): Promise<PendingAccountLogin>;
6
+ export declare function finishAccountLogin(input: PendingAccountLogin, options?: RequestOptions): Promise<AuthorizedConfig>;
package/dist/auth.js ADDED
@@ -0,0 +1,91 @@
1
+ import { validateAPIURL, validateConfig } from './config.js';
2
+ import { generateAgreementKey, openSenderGrant, senderPublicKey, verifyArchive } from './crypto.js';
3
+ import { fingerprint, object, uuid } from './encoding.js';
4
+ import { APIError, request } from './http.js';
5
+ import { recipientsV2 } from './recipients.js';
6
+ export async function beginLogin(apiURL, name, options = {}) {
7
+ if (typeof name !== 'string' || !name.trim() || name.trim().length > 80)
8
+ throw new Error('Sender name must have 1 to 80 characters');
9
+ options.signal?.throwIfAborted();
10
+ const api_url = validateAPIURL(apiURL), key = await generateAgreementKey();
11
+ const value = object(await request(api_url, '/v2/authorizations', { ...options, method: 'POST', body: { name: name.trim(), public_key: key.publicKey } }));
12
+ uuid(value.id);
13
+ if (typeof value.device_code !== 'string' || typeof value.user_code !== 'string' || typeof value.expires_at !== 'string' ||
14
+ !Number.isFinite(Date.parse(value.expires_at)) || Date.parse(value.expires_at) <= Date.now())
15
+ throw new Error('Invalid authorization response');
16
+ return { api_url, key, authorization: { id: value.id, device_code: value.device_code, user_code: value.user_code,
17
+ expires_at: value.expires_at, interval: typeof value.interval === 'number' && Number.isFinite(value.interval) ? Math.max(3, value.interval) : 3 },
18
+ fingerprint: await fingerprint(key.publicKey) };
19
+ }
20
+ async function wait(ms, signal) {
21
+ signal?.throwIfAborted();
22
+ await new Promise((resolve, reject) => {
23
+ const abort = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); reject(new DOMException('Login aborted', 'AbortError')); };
24
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, ms);
25
+ signal?.addEventListener('abort', abort, { once: true });
26
+ if (signal?.aborted)
27
+ abort();
28
+ });
29
+ }
30
+ export async function finishLogin(input, options) {
31
+ if (!options || (options.expectedIdentityFingerprint === undefined && typeof options.confirmIdentity !== 'function'))
32
+ throw new Error('Explicit account identity verification is required');
33
+ const expected = options.expectedIdentityFingerprint?.toLowerCase();
34
+ if (expected !== undefined && !/^[a-f0-9]{64}$/.test(expected))
35
+ throw new Error('Expected identity fingerprint must be 64 hexadecimal characters');
36
+ const pending = structuredClone(input), { authorization, key } = pending;
37
+ const api_url = validateAPIURL(pending.api_url);
38
+ if (await senderPublicKey(key.privateKey) !== key.publicKey)
39
+ throw new Error('Pending authorization key mismatch');
40
+ uuid(authorization.id);
41
+ while (Date.now() < Date.parse(authorization.expires_at)) {
42
+ options.signal?.throwIfAborted();
43
+ let response;
44
+ try {
45
+ response = object(await request(api_url, `/v2/authorizations/${encodeURIComponent(authorization.id)}/token`, { ...options, method: 'POST', body: { device_code: authorization.device_code } }));
46
+ }
47
+ catch (error) {
48
+ if (!(error instanceof APIError) || error.status !== 429)
49
+ throw error;
50
+ }
51
+ if (response?.status === 'approved') {
52
+ const grant = object(await openSenderGrant(pending, object(response.grant)));
53
+ const config = validateConfig({ ...grant, sender_private_key: key.privateKey });
54
+ if (config.api_url !== api_url)
55
+ throw new Error('Authorization API origin changed');
56
+ await verifyArchive(config, config.archive);
57
+ const accountFingerprint = await fingerprint(config.identity_public_key);
58
+ const confirmed = expected !== undefined ? expected === accountFingerprint :
59
+ await options.confirmIdentity?.({ fingerprint: accountFingerprint, userID: config.user_id });
60
+ if (confirmed !== true)
61
+ throw new Error('Account identity not confirmed; discard this authorization');
62
+ // The grant must bind this local private key to the claimed account/source before returning it.
63
+ await recipientsV2(config, options);
64
+ return config;
65
+ }
66
+ if (response && response.status !== 'pending')
67
+ throw new Error('Unexpected authorization state');
68
+ const remaining = Date.parse(authorization.expires_at) - Date.now();
69
+ if (remaining > 0)
70
+ await wait(Math.min(remaining, Math.max(3, Number(authorization.interval) || 3) * 1000), options.signal);
71
+ }
72
+ throw new Error('Authorization expired; start login again');
73
+ }
74
+ /** Account login delegates initial approval to an online trusted device. */
75
+ export async function beginAccountLogin(apiURL, accessToken, name, options = {}) {
76
+ if (!name.trim() || name.trim().length > 80)
77
+ throw new Error('Invalid sender name');
78
+ const api_url = validateAPIURL(apiURL), key = await generateAgreementKey();
79
+ const value = object(await request(api_url, '/v2/account-authorizations', { ...options, token: accessToken, method: 'POST', body: { name: name.trim(), public_key: key.publicKey } }));
80
+ uuid(value.id);
81
+ uuid(value.user_id);
82
+ if (typeof value.device_code !== 'string' || typeof value.user_code !== 'string' || typeof value.expires_at !== 'string' || !Number.isFinite(Date.parse(value.expires_at)) || Date.parse(value.expires_at) <= Date.now() || typeof value.identity_public_key !== 'string')
83
+ throw new Error('Invalid account authorization');
84
+ return { api_url, key, authorization: { id: value.id, device_code: value.device_code, user_code: value.user_code, expires_at: value.expires_at, interval: 3 }, fingerprint: await fingerprint(key.publicKey), accountUserID: value.user_id, expectedIdentityFingerprint: await fingerprint(value.identity_public_key) };
85
+ }
86
+ export async function finishAccountLogin(input, options = {}) {
87
+ const config = await finishLogin(input, { ...options, expectedIdentityFingerprint: input.expectedIdentityFingerprint });
88
+ if (config.user_id !== input.accountUserID)
89
+ throw new Error('Authorization account changed');
90
+ return config;
91
+ }