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.
@@ -0,0 +1,5 @@
1
+ import type { Archive, AuthorizedConfig } from './types.js';
2
+ export declare function validateAPIURL(value: string): string;
3
+ export declare function validateArchive(value: unknown): Archive;
4
+ /** Structural validation only. finishLogin pins identity; recipientsV2 verifies signed bindings. */
5
+ export declare function validateConfig(value: unknown): AuthorizedConfig;
package/dist/config.js ADDED
@@ -0,0 +1,33 @@
1
+ import { decode, object, uuid } from './encoding.js';
2
+ export function validateAPIURL(value) {
3
+ const url = new URL(value);
4
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/' ||
5
+ (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
6
+ throw new Error('Use an HTTPS API origin (HTTP is allowed only on localhost)');
7
+ }
8
+ return url.origin;
9
+ }
10
+ export function validateArchive(value) {
11
+ const archive = object(value);
12
+ uuid(archive.id);
13
+ decode(archive.public_key, 65);
14
+ decode(archive.certificate, 64);
15
+ return { id: archive.id, public_key: archive.public_key, certificate: archive.certificate };
16
+ }
17
+ /** Structural validation only. finishLogin pins identity; recipientsV2 verifies signed bindings. */
18
+ export function validateConfig(value) {
19
+ const config = object(value);
20
+ for (const field of ['api_url', 'user_id', 'source_id', 'source_key', 'identity_public_key', 'sender_private_key']) {
21
+ if (typeof config[field] !== 'string' || !config[field] || /[\r\n]/.test(config[field]))
22
+ throw new Error(`Missing or invalid config field: ${field}`);
23
+ }
24
+ uuid(config.user_id);
25
+ uuid(config.source_id);
26
+ if (!/^[\x21-\x7e]{1,2048}$/.test(config.source_key))
27
+ throw new Error('Invalid source credential');
28
+ decode(config.identity_public_key, 65);
29
+ decode(config.sender_private_key, 32);
30
+ return { api_url: validateAPIURL(config.api_url), user_id: config.user_id, source_id: config.source_id,
31
+ source_key: config.source_key, identity_public_key: config.identity_public_key,
32
+ sender_private_key: config.sender_private_key, archive: validateArchive(config.archive) };
33
+ }
@@ -0,0 +1,10 @@
1
+ import { CipherSuite } from '@hpke/core';
2
+ import type { Archive, AuthorizedConfig, Envelope, PendingLogin } from './types.js';
3
+ export declare const suite: CipherSuite;
4
+ export declare const v2AAD: (purpose: string, config: AuthorizedConfig, messageID: string, archiveID: string) => Uint8Array<ArrayBuffer>;
5
+ export declare function generateAgreementKey(): Promise<PendingLogin['key']>;
6
+ export declare function senderPublicKey(privateKey: string): Promise<string>;
7
+ export declare function verifyCertificate(root: string, kind: 'source' | 'device' | 'archive', userID: string, id: string, publicKey: string, signature: string): Promise<boolean>;
8
+ export declare function verifyArchive(config: AuthorizedConfig, value: unknown): Promise<Archive>;
9
+ export declare function sealV2(config: AuthorizedConfig, archive: Archive, purpose: string, messageID: string, plaintext: unknown): Promise<Envelope>;
10
+ export declare function openSenderGrant(pending: PendingLogin, grant: Envelope): Promise<unknown>;
package/dist/crypto.js ADDED
@@ -0,0 +1,59 @@
1
+ import { Aes256Gcm, CipherSuite, DhkemP256HkdfSha256, HkdfSha256 } from '@hpke/core';
2
+ import { base64, bytes, cryptoAPI, decode } from './encoding.js';
3
+ import { validateArchive } from './config.js';
4
+ export const suite = new CipherSuite({ kem: new DhkemP256HkdfSha256(), kdf: new HkdfSha256(), aead: new Aes256Gcm() });
5
+ export const v2AAD = (purpose, config, messageID, archiveID) => bytes(JSON.stringify([2, purpose, config.user_id, config.source_id, messageID, archiveID]));
6
+ export async function generateAgreementKey() {
7
+ cryptoAPI();
8
+ const pair = await suite.kem.generateKeyPair();
9
+ return { privateKey: base64(await suite.kem.serializePrivateKey(pair.privateKey)), publicKey: base64(await suite.kem.serializePublicKey(pair.publicKey)) };
10
+ }
11
+ export async function senderPublicKey(privateKey) {
12
+ const key = await suite.kem.deserializePrivateKey(decode(privateKey, 32));
13
+ const jwk = await cryptoAPI().subtle.exportKey('jwk', key);
14
+ if (!jwk.x || !jwk.y)
15
+ throw new Error('Sender key cannot be derived');
16
+ const publicJWK = { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y };
17
+ const pub = await cryptoAPI().subtle.importKey('jwk', publicJWK, { name: 'ECDH', namedCurve: 'P-256' }, true, []);
18
+ return base64(await cryptoAPI().subtle.exportKey('raw', pub));
19
+ }
20
+ export async function verifyCertificate(root, kind, userID, id, publicKey, signature) {
21
+ try {
22
+ const key = await cryptoAPI().subtle.importKey('raw', decode(root, 65), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
23
+ await cryptoAPI().subtle.importKey('raw', decode(publicKey, 65), { name: 'ECDH', namedCurve: 'P-256' }, false, []);
24
+ return await cryptoAPI().subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, decode(signature, 64), bytes(`pushnow-${kind}-v1\n${userID}\n${id}\n${publicKey}`));
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ export async function verifyArchive(config, value) {
31
+ const archive = validateArchive(value);
32
+ if (archive.id !== config.archive.id || archive.public_key !== config.archive.public_key ||
33
+ !await verifyCertificate(config.identity_public_key, 'archive', config.user_id, archive.id, archive.public_key, archive.certificate))
34
+ throw new Error('Invalid or changed account archive');
35
+ return archive;
36
+ }
37
+ export async function sealV2(config, archive, purpose, messageID, plaintext) {
38
+ cryptoAPI();
39
+ const sender = await suite.createSenderContext({ recipientPublicKey: await suite.kem.deserializePublicKey(decode(archive.public_key, 65)),
40
+ senderKey: await suite.kem.deserializePrivateKey(decode(config.sender_private_key, 32)), info: bytes('pushnow-v2') });
41
+ return { enc: base64(sender.enc), ciphertext: base64(await sender.seal(bytes(JSON.stringify(plaintext)), v2AAD(purpose, config, messageID, archive.id))) };
42
+ }
43
+ export async function openSenderGrant(pending, grant) {
44
+ const ciphertext = decode(grant.ciphertext);
45
+ if (ciphertext.length > 8192 || ciphertext.length < 16)
46
+ throw new Error('Invalid authorization grant size');
47
+ const recipient = await suite.createRecipientContext({ recipientKey: await suite.kem.deserializePrivateKey(decode(pending.key.privateKey, 32)),
48
+ enc: decode(grant.enc, 65), info: bytes('pushnow-sender-grant-v2') });
49
+ const plaintext = new Uint8Array(await recipient.open(ciphertext, bytes(JSON.stringify([2, 'sender-grant', pending.authorization.id, pending.key.publicKey]))));
50
+ try {
51
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(plaintext));
52
+ }
53
+ catch {
54
+ throw new Error('Invalid authorization grant');
55
+ }
56
+ finally {
57
+ plaintext.fill(0);
58
+ }
59
+ }
@@ -0,0 +1,8 @@
1
+ export declare const bytes: (value: string) => Uint8Array<ArrayBuffer>;
2
+ export declare function base64(value: ArrayBuffer | Uint8Array): string;
3
+ export declare function decode(value: unknown, size?: number): Uint8Array<ArrayBuffer>;
4
+ export declare function cryptoAPI(): Crypto;
5
+ export declare function sha256(data: Uint8Array<ArrayBuffer>): Promise<string>;
6
+ export declare const fingerprint: (key: string) => Promise<string>;
7
+ export declare function uuid(value: unknown): asserts value is string;
8
+ export declare function object(value: unknown): Record<string, unknown>;
@@ -0,0 +1,34 @@
1
+ export const bytes = (value) => new TextEncoder().encode(value);
2
+ export function base64(value) {
3
+ const data = value instanceof Uint8Array ? value : new Uint8Array(value);
4
+ let result = '';
5
+ for (let start = 0; start < data.length; start += 8192)
6
+ result += String.fromCharCode(...data.subarray(start, start + 8192));
7
+ return btoa(result);
8
+ }
9
+ export function decode(value, size) {
10
+ if (typeof value !== 'string' || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value))
11
+ throw new Error('Invalid Base64 encoding');
12
+ const decoded = Uint8Array.from(atob(value), c => c.charCodeAt(0));
13
+ if ((size !== undefined && decoded.length !== size) || base64(decoded) !== value)
14
+ throw new Error('Invalid key or encoding');
15
+ return decoded;
16
+ }
17
+ export function cryptoAPI() {
18
+ if (!globalThis.crypto?.subtle)
19
+ throw new Error('WebCrypto is required; use a secure HTTPS context');
20
+ return globalThis.crypto;
21
+ }
22
+ export async function sha256(data) {
23
+ return [...new Uint8Array(await cryptoAPI().subtle.digest('SHA-256', data))].map(n => n.toString(16).padStart(2, '0')).join('');
24
+ }
25
+ export const fingerprint = (key) => sha256(decode(key, 65));
26
+ export function uuid(value) {
27
+ if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value))
28
+ throw new Error('Invalid UUID');
29
+ }
30
+ export function object(value) {
31
+ if (!value || typeof value !== 'object' || Array.isArray(value))
32
+ throw new Error('Expected an object');
33
+ return value;
34
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { AuthorizedConfig, RequestEvent, RequestOptions } from './types.js';
2
+ export declare class APIError extends Error {
3
+ readonly status: number;
4
+ constructor(status: number);
5
+ }
6
+ type HTTPOptions = RequestOptions & {
7
+ token?: string;
8
+ method?: RequestEvent['method'];
9
+ body?: unknown;
10
+ binary?: boolean;
11
+ messageID?: string;
12
+ };
13
+ export declare function request(apiURL: string, path: string, options?: HTTPOptions): Promise<unknown>;
14
+ export declare function authenticated(config: AuthorizedConfig, path: string, options?: Omit<HTTPOptions, 'token'>): Promise<unknown>;
15
+ export {};
package/dist/http.js ADDED
@@ -0,0 +1,101 @@
1
+ import { validateAPIURL, validateConfig } from './config.js';
2
+ export class APIError extends Error {
3
+ status;
4
+ constructor(status) {
5
+ super(`PushNow API request failed (HTTP ${status})`);
6
+ this.status = status;
7
+ this.name = 'APIError';
8
+ }
9
+ }
10
+ function pathTemplate(path) {
11
+ return path.replace(/\/authorizations\/[^/]+\/token$/, '/authorizations/:id/token').replace(/\/attachments\/[^/]+$/, '/attachments/:id');
12
+ }
13
+ async function json(response) {
14
+ if (!response.body)
15
+ throw new Error('Missing API response');
16
+ const reader = response.body.getReader(), chunks = [];
17
+ let size = 0;
18
+ try {
19
+ while (true) {
20
+ const part = await reader.read();
21
+ if (part.done)
22
+ break;
23
+ size += part.value.length;
24
+ if (size > 2 * 1024 * 1024) {
25
+ await reader.cancel();
26
+ throw new Error('API response is too large');
27
+ }
28
+ chunks.push(part.value);
29
+ }
30
+ }
31
+ finally {
32
+ reader.releaseLock();
33
+ }
34
+ const data = new Uint8Array(size);
35
+ let offset = 0;
36
+ for (const chunk of chunks) {
37
+ data.set(chunk, offset);
38
+ offset += chunk.length;
39
+ }
40
+ try {
41
+ return JSON.parse(new TextDecoder().decode(data));
42
+ }
43
+ catch {
44
+ throw new Error('Invalid API JSON response');
45
+ }
46
+ }
47
+ export async function request(apiURL, path, options = {}) {
48
+ const origin = validateAPIURL(apiURL), method = options.method ?? 'GET';
49
+ const started = Date.now(), controller = new AbortController();
50
+ let status = null, outcome = 'network_error';
51
+ const abort = () => controller.abort();
52
+ options.signal?.addEventListener('abort', abort, { once: true });
53
+ if (options.signal?.aborted)
54
+ controller.abort();
55
+ const timer = setTimeout(abort, 30000);
56
+ try {
57
+ controller.signal.throwIfAborted();
58
+ const headers = {};
59
+ if (options.token)
60
+ headers.authorization = `Bearer ${options.token}`;
61
+ if (options.body !== undefined)
62
+ headers['content-type'] = options.binary ? 'application/octet-stream' : 'application/json';
63
+ if (options.messageID)
64
+ headers['idempotency-key'] = options.messageID;
65
+ const response = await (options.fetcher ?? fetch)(new URL(path, origin), { method, headers, redirect: 'error',
66
+ credentials: 'omit', cache: 'no-store', signal: controller.signal,
67
+ ...(options.body === undefined ? {} : { body: options.binary ? options.body : JSON.stringify(options.body) }) });
68
+ status = response.status;
69
+ if (!response.ok) {
70
+ outcome = 'http_error';
71
+ await response.body?.cancel();
72
+ throw new APIError(status);
73
+ }
74
+ const result = status === 204 ? null : await json(response);
75
+ outcome = 'success';
76
+ return result;
77
+ }
78
+ catch (error) {
79
+ if (controller.signal.aborted) {
80
+ outcome = 'aborted';
81
+ throw new DOMException('Request aborted or timed out', 'AbortError');
82
+ }
83
+ if (error instanceof APIError)
84
+ throw error;
85
+ // Fetch/JSON error messages can include URLs or response data. Do not forward them.
86
+ throw new Error('PushNow request failed or returned an invalid response');
87
+ }
88
+ finally {
89
+ clearTimeout(timer);
90
+ options.signal?.removeEventListener('abort', abort);
91
+ try {
92
+ void Promise.resolve(options.onRequest?.(Object.freeze({ method, path: pathTemplate(path), status,
93
+ durationMs: Math.max(0, Date.now() - started), outcome }))).catch(() => { });
94
+ }
95
+ catch { /* Observability must not change delivery. */ }
96
+ }
97
+ }
98
+ export function authenticated(config, path, options = {}) {
99
+ const validated = validateConfig(config);
100
+ return request(validated.api_url, path, { ...options, token: validated.source_key });
101
+ }
@@ -0,0 +1,9 @@
1
+ export { beginLogin, finishLogin, beginAccountLogin, finishAccountLogin } from './auth.js';
2
+ export { validateConfig } from './config.js';
3
+ export { recipientsV2 } from './recipients.js';
4
+ export { prepareMessageV2, submitMessageV2 } from './messages.js';
5
+ export { uploadAttachment } from './attachments.js';
6
+ export { sendNotification } from './send.js';
7
+ export { APIError } from './http.js';
8
+ export { fingerprint } from './encoding.js';
9
+ export type * from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { beginLogin, finishLogin, beginAccountLogin, finishAccountLogin } from './auth.js';
2
+ export { validateConfig } from './config.js';
3
+ export { recipientsV2 } from './recipients.js';
4
+ export { prepareMessageV2, submitMessageV2 } from './messages.js';
5
+ export { uploadAttachment } from './attachments.js';
6
+ export { sendNotification } from './send.js';
7
+ export { APIError } from './http.js';
8
+ export { fingerprint } from './encoding.js';
@@ -0,0 +1,5 @@
1
+ import type { AuthorizedConfig, MessageContent, MessageOptions, PreparedMessage, RecipientDirectory, RequestOptions, SubmitResult } from './types.js';
2
+ export declare function validateContent(input: MessageContent): Required<Pick<MessageContent, 'title' | 'body' | 'links' | 'attachments'>> & MessageContent;
3
+ export declare function validateMessageOptions(options: MessageOptions): MessageOptions;
4
+ export declare function prepareMessageV2(input: AuthorizedConfig, inputDirectory: RecipientDirectory, plaintext: MessageContent, inputOptions?: MessageOptions): Promise<PreparedMessage>;
5
+ export declare function submitMessageV2(input: AuthorizedConfig, message: PreparedMessage, options?: RequestOptions): Promise<SubmitResult>;
@@ -0,0 +1,132 @@
1
+ import { validateConfig } from './config.js';
2
+ import { sealV2 } from './crypto.js';
3
+ import { bytes, cryptoAPI, decode, object, uuid } from './encoding.js';
4
+ import { authenticated } from './http.js';
5
+ import { validateDescriptor } from './attachments.js';
6
+ import { verifyDirectory } from './recipients.js';
7
+ const bindings = new WeakMap();
8
+ const binding = (config) => JSON.stringify([config.api_url, config.user_id, config.source_id, config.identity_public_key, config.sender_private_key, config.archive.id, config.archive.public_key]);
9
+ export function validateContent(input) {
10
+ const value = object(input);
11
+ if (Object.keys(value).some(k => !['title', 'body', 'links', 'attachments', 'image_id', 'icon_id'].includes(k)))
12
+ throw new Error('Unsupported message content field; pass routing fields such as sound in MessageOptions');
13
+ if (typeof value.title !== 'string' || typeof value.body !== 'string')
14
+ throw new Error('Message needs title and body');
15
+ const links = value.links ?? [], attachments = value.attachments ?? [];
16
+ if (!Array.isArray(links) || links.some(link => typeof link !== 'string') || !Array.isArray(attachments) || attachments.length > 20)
17
+ throw new Error('Invalid links or attachment count');
18
+ const descriptors = attachments.map(validateDescriptor), ids = new Set(descriptors.map(d => d.id));
19
+ if (ids.size !== descriptors.length)
20
+ throw new Error('Duplicate attachment');
21
+ for (const id of [value.image_id, value.icon_id])
22
+ if (id !== undefined) {
23
+ uuid(id);
24
+ if (!ids.has(id))
25
+ throw new Error('Image and icon must refer to an attached file');
26
+ }
27
+ const full = { title: value.title, body: value.body, links: [...links], attachments: descriptors,
28
+ ...(value.image_id === undefined ? {} : { image_id: value.image_id }), ...(value.icon_id === undefined ? {} : { icon_id: value.icon_id }) };
29
+ if (bytes(JSON.stringify(full)).length + 16 > 256 * 1024)
30
+ throw new Error('Encrypted message manifest exceeds 256 KiB');
31
+ return full;
32
+ }
33
+ function timestamp(value) {
34
+ if (value === undefined)
35
+ return undefined;
36
+ if (typeof value !== 'string')
37
+ throw new Error('Invalid ISO timestamp');
38
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?(Z|[+-]\d{2}:\d{2})$/.exec(value);
39
+ if (!match)
40
+ throw new Error('Invalid ISO timestamp');
41
+ const local = `${match[1]}T${match[2]}.${(match[3] ?? '').padEnd(3, '0')}Z`;
42
+ if (!Number.isFinite(Date.parse(value)) || !Number.isFinite(Date.parse(local)) || new Date(local).toISOString() !== local)
43
+ throw new Error('Invalid ISO timestamp');
44
+ return new Date(value).toISOString();
45
+ }
46
+ export function validateMessageOptions(options) {
47
+ if (options.sound !== undefined && !['default', 'silent', 'chime'].includes(options.sound))
48
+ throw new Error('Invalid sound; use default, silent or chime');
49
+ if (options.inboxOnly !== undefined && typeof options.inboxOnly !== 'boolean')
50
+ throw new Error('inboxOnly must be boolean');
51
+ if (options.inboxOnly && options.deviceIds !== undefined)
52
+ throw new Error('Choose inboxOnly or deviceIds, not both');
53
+ if (options.deviceIds !== undefined) {
54
+ if (!Array.isArray(options.deviceIds) || options.deviceIds.length > 100)
55
+ throw new Error('Invalid target count');
56
+ options.deviceIds.forEach(uuid);
57
+ if (new Set(options.deviceIds).size !== options.deviceIds.length)
58
+ throw new Error('Duplicate target');
59
+ }
60
+ if (options.messageID !== undefined)
61
+ uuid(options.messageID);
62
+ if (options.sourceKind !== undefined && !["web", "cli", "api", "subscription"].includes(options.sourceKind))
63
+ throw new Error("Invalid source kind");
64
+ const scheduledAt = timestamp(options.scheduledAt), expiresAt = timestamp(options.expiresAt), now = Date.now(), end = now + 30 * 86400000;
65
+ if (scheduledAt && (Date.parse(scheduledAt) <= now || Date.parse(scheduledAt) > end))
66
+ throw new Error('Schedule must be in the next 30 days');
67
+ if (expiresAt && (Date.parse(expiresAt) <= (scheduledAt ? Date.parse(scheduledAt) : now) || Date.parse(expiresAt) > end))
68
+ throw new Error('Expiry must follow delivery and be within 30 days');
69
+ return { ...options, deviceIds: options.deviceIds && [...options.deviceIds], scheduledAt, expiresAt };
70
+ }
71
+ function truncateUTF8(value, limit) {
72
+ let result = '', size = 0;
73
+ for (const char of value) {
74
+ const n = bytes(char).length;
75
+ if (size + n > limit)
76
+ break;
77
+ result += char;
78
+ size += n;
79
+ }
80
+ return result;
81
+ }
82
+ export async function prepareMessageV2(input, inputDirectory, plaintext, inputOptions = {}) {
83
+ const config = validateConfig(input), full = validateContent(plaintext), options = validateMessageOptions(inputOptions);
84
+ const directory = await verifyDirectory(config, inputDirectory), messageID = options.messageID ?? cryptoAPI().randomUUID();
85
+ let notify;
86
+ if (options.inboxOnly)
87
+ notify = [];
88
+ else if (options.deviceIds !== undefined) {
89
+ const selected = directory.devices.filter(d => options.deviceIds.includes(d.id));
90
+ if (selected.length !== options.deviceIds.length)
91
+ throw new Error('Unknown notification device');
92
+ notify = selected.filter(d => d.notifications_enabled).map(d => d.id);
93
+ }
94
+ const encrypted = await sealV2(config, directory.archive, 'message', messageID, full);
95
+ const previewData = { title: truncateUTF8(full.title, 400), body: truncateUTF8(full.body, 700) };
96
+ const image = full.attachments.find(a => a.id === full.image_id);
97
+ if (image && bytes(JSON.stringify(image)).length <= 600)
98
+ previewData.image = image;
99
+ // Budget for the longest supported APNs filename, including legacy/default requests.
100
+ const previewSize = (envelope) => bytes(JSON.stringify({ aps: { alert: { title: 'PushNow', body: 'You have a new encrypted reminder.' }, 'mutable-content': 1, sound: 'pushnow-chime.wav' },
101
+ secure_v2: { message_id: messageID, user_id: config.user_id, source_id: config.source_id, archive_id: directory.archive.id,
102
+ device_id: '00000000-0000-0000-0000-000000000000', ...envelope, source_public_key: directory.source_public_key,
103
+ source_certificate: directory.source_certificate, created_at: new Date().toISOString(), read_at: null } })).length;
104
+ let preview = await sealV2(config, directory.archive, 'preview', messageID, previewData);
105
+ if (previewSize(preview) > 3900 || decode(preview.ciphertext).length > 2400) {
106
+ delete previewData.image;
107
+ preview = await sealV2(config, directory.archive, 'preview', messageID, previewData);
108
+ }
109
+ if (previewSize(preview) > 3900 || decode(preview.ciphertext).length > 2400)
110
+ throw new Error('Encrypted preview is too large');
111
+ const message = { message_id: messageID, archive_id: directory.archive.id, ...encrypted, preview,
112
+ attachment_ids: full.attachments.map(a => a.id), ...(notify === undefined ? {} : { notify_device_ids: notify }),
113
+ ...(options.sourceKind === undefined ? {} : { source_kind: options.sourceKind }),
114
+ ...(options.scheduledAt === undefined ? {} : { scheduled_at: options.scheduledAt }), ...(options.expiresAt === undefined ? {} : { expires_at: options.expiresAt }),
115
+ ...(options.sound === undefined ? {} : { sound: options.sound }) };
116
+ Object.freeze(message.preview);
117
+ Object.freeze(message.attachment_ids);
118
+ if (message.notify_device_ids)
119
+ Object.freeze(message.notify_device_ids);
120
+ Object.freeze(message);
121
+ bindings.set(message, binding(config));
122
+ return message;
123
+ }
124
+ export async function submitMessageV2(input, message, options = {}) {
125
+ const config = validateConfig(input);
126
+ if (bindings.get(message) !== binding(config))
127
+ throw new Error('Use the original prepared message with its authorized account and source');
128
+ const result = object(await authenticated(config, '/v2/messages', { ...options, method: 'POST', messageID: message.message_id, body: message }));
129
+ if (result.message_id !== message.message_id || typeof result.deduplicated !== 'boolean')
130
+ throw new Error('Invalid message response');
131
+ return { message_id: result.message_id, deduplicated: result.deduplicated };
132
+ }
@@ -0,0 +1,3 @@
1
+ import type { AuthorizedConfig, RecipientDirectory, RequestOptions } from './types.js';
2
+ export declare function verifyDirectory(input: AuthorizedConfig, value: unknown): Promise<RecipientDirectory>;
3
+ export declare function recipientsV2(input: AuthorizedConfig, options?: RequestOptions): Promise<RecipientDirectory>;
@@ -0,0 +1,32 @@
1
+ import { validateConfig } from './config.js';
2
+ import { senderPublicKey, verifyArchive, verifyCertificate } from './crypto.js';
3
+ import { object, uuid } from './encoding.js';
4
+ import { authenticated } from './http.js';
5
+ export async function verifyDirectory(input, value) {
6
+ const config = validateConfig(input), directory = object(structuredClone(value));
7
+ if (directory.user_id !== config.user_id || directory.source_id !== config.source_id || directory.identity_public_key !== config.identity_public_key)
8
+ throw new Error('Account or source identity changed');
9
+ if (typeof directory.source_public_key !== 'string' || typeof directory.source_certificate !== 'string' ||
10
+ !await verifyCertificate(config.identity_public_key, 'source', config.user_id, config.source_id, directory.source_public_key, directory.source_certificate))
11
+ throw new Error('Invalid source certificate');
12
+ if (await senderPublicKey(config.sender_private_key) !== directory.source_public_key)
13
+ throw new Error('Sender key does not match source');
14
+ await verifyArchive(config, directory.archive);
15
+ if (!Array.isArray(directory.devices) || directory.devices.length > 1000)
16
+ throw new Error('Invalid device directory');
17
+ const ids = new Set();
18
+ for (const value of directory.devices) {
19
+ const d = object(value);
20
+ uuid(d.id);
21
+ if (ids.has(d.id) || d.user_id !== config.user_id || d.status !== 'active' || typeof d.notifications_enabled !== 'boolean' ||
22
+ typeof d.name !== 'string' || typeof d.platform !== 'string' || typeof d.public_key !== 'string' || typeof d.certificate !== 'string' ||
23
+ !await verifyCertificate(config.identity_public_key, 'device', config.user_id, d.id, d.public_key, d.certificate))
24
+ throw new Error('Invalid device certificate or metadata');
25
+ ids.add(d.id);
26
+ }
27
+ return directory;
28
+ }
29
+ export async function recipientsV2(input, options = {}) {
30
+ const config = validateConfig(input);
31
+ return verifyDirectory(config, await authenticated(config, '/v2/recipients', options));
32
+ }
package/dist/send.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { AuthorizedConfig, NotificationInput, SendOptions, SubmitResult } from './types.js';
2
+ export declare function sendNotification(input: AuthorizedConfig, notification: NotificationInput, inputOptions?: SendOptions): Promise<SubmitResult>;
package/dist/send.js ADDED
@@ -0,0 +1,37 @@
1
+ import { uploadAttachment, validateAttachmentData, validateAttachmentMetadata } from './attachments.js';
2
+ import { validateConfig } from './config.js';
3
+ import { prepareMessageV2, submitMessageV2, validateContent, validateMessageOptions } from './messages.js';
4
+ import { recipientsV2 } from './recipients.js';
5
+ export async function sendNotification(input, notification, inputOptions = {}) {
6
+ const config = validateConfig(input), options = { ...inputOptions, ...validateMessageOptions(inputOptions) };
7
+ const { files = [], image, icon, ...content } = notification;
8
+ const full = validateContent(content);
9
+ if (!Array.isArray(files))
10
+ throw new Error('files must be an array');
11
+ if ((image && full.image_id) || (icon && full.icon_id))
12
+ throw new Error('Choose a file upload or an existing attachment ID');
13
+ const uploads = [...files, ...(image ? [image] : []), ...(icon ? [icon] : [])].map(file => {
14
+ const meta = validateAttachmentMetadata(file);
15
+ validateAttachmentData(file.data);
16
+ return { ...meta, data: file.data };
17
+ });
18
+ if (full.attachments.length + uploads.length > 20)
19
+ throw new Error('Maximum 20 attachments per notification');
20
+ const knownIDs = [...full.attachments.map(a => a.id), ...uploads.flatMap(a => a.id ? [a.id] : [])];
21
+ if (new Set(knownIDs).size !== knownIDs.length)
22
+ throw new Error('Duplicate attachment');
23
+ const directory = await recipientsV2(config, options);
24
+ if (options.deviceIds?.some(id => !directory.devices.some(d => d.id === id)))
25
+ throw new Error('Unknown notification device');
26
+ for (let index = 0; index < uploads.length; index++) {
27
+ const file = uploads[index];
28
+ const descriptor = await uploadAttachment(config, file.data, file, options);
29
+ full.attachments.push(descriptor);
30
+ if (image && index === files.length)
31
+ full.image_id = descriptor.id;
32
+ if (icon && index === files.length + (image ? 1 : 0))
33
+ full.icon_id = descriptor.id;
34
+ }
35
+ const message = await prepareMessageV2(config, directory, full, options);
36
+ return submitMessageV2(config, message, options);
37
+ }