ab-ecosystem-cpass 1.0.5 → 1.0.7
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 +62 -1
- package/dist/client.d.ts +24 -0
- package/dist/client.js +32 -5
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.js +20 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/bytes.d.ts +38 -0
- package/dist/internal/bytes.js +84 -0
- package/dist/internal/bytes.js.map +1 -0
- package/dist/internal/device.d.ts +24 -0
- package/dist/internal/device.js +91 -0
- package/dist/internal/device.js.map +1 -0
- package/dist/internal/md5.d.ts +4 -0
- package/dist/internal/md5.js +109 -0
- package/dist/internal/md5.js.map +1 -0
- package/dist/internal/receiver-file.d.ts +106 -0
- package/dist/internal/receiver-file.js +128 -0
- package/dist/internal/receiver-file.js.map +1 -0
- package/dist/resources/audience/notifications.d.ts +171 -0
- package/dist/resources/audience/notifications.js +215 -0
- package/dist/resources/audience/notifications.js.map +1 -0
- package/dist/resources/campaign-audience.d.ts +132 -0
- package/dist/resources/campaign-audience.js +224 -0
- package/dist/resources/campaign-audience.js.map +1 -0
- package/dist/resources/campaign-messages.d.ts +150 -0
- package/dist/resources/campaign-messages.js +172 -0
- package/dist/resources/campaign-messages.js.map +1 -0
- package/dist/resources/campaigns.d.ts +239 -0
- package/dist/resources/campaigns.js +300 -0
- package/dist/resources/campaigns.js.map +1 -0
- package/dist/types/audience.d.ts +82 -0
- package/dist/types/campaign.d.ts +319 -0
- package/dist/types/campaign.js +3 -0
- package/dist/types/campaign.js.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -1
- package/docs/audience/notifications.md +263 -0
- package/docs/campaign/campaign-messages.md +273 -0
- package/docs/campaign/campaigns.md +449 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"md5.js","sourceRoot":"","sources":["../../src/internal/md5.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** One recipient and the variables their message is rendered with. */
|
|
2
|
+
export interface CampaignReceiver {
|
|
3
|
+
/** The recipient's number, digits only, no `+` and no separators. */
|
|
4
|
+
waba: string;
|
|
5
|
+
/**
|
|
6
|
+
* Per-recipient template variables, as the manager expects them: a flat list of
|
|
7
|
+
* `{id, value}` rather than an object, because the ids come from CSV headers
|
|
8
|
+
* and are not safe as object keys.
|
|
9
|
+
*/
|
|
10
|
+
custom_fields: {
|
|
11
|
+
id: string;
|
|
12
|
+
value: string;
|
|
13
|
+
}[];
|
|
14
|
+
}
|
|
15
|
+
/** An encrypted receiver file, ready to upload. */
|
|
16
|
+
export interface ReceiverFile {
|
|
17
|
+
/** Base64 AES ciphertext. This is the entire file body. */
|
|
18
|
+
body: string;
|
|
19
|
+
/** The secret the file was encrypted under. Stored on the campaign as `hash`. */
|
|
20
|
+
secret: string;
|
|
21
|
+
filename: string;
|
|
22
|
+
contentType: 'text/plain';
|
|
23
|
+
/** Byte length of `body`, which the upload ticket is signed against. */
|
|
24
|
+
size: number;
|
|
25
|
+
/** How many recipients the file contains. */
|
|
26
|
+
receivers: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A fresh secret for one receiver file.
|
|
30
|
+
*
|
|
31
|
+
* Sixteen random bytes as hex -- thirty-two characters, which is the shape
|
|
32
|
+
* already in the field and what the `hash` field on a stored campaign holds.
|
|
33
|
+
*
|
|
34
|
+
* The legacy frontend generated this as 16 random bytes, hex-encoded, then
|
|
35
|
+
* applied `.toString('base64url').replace(/_/gi,'U').replace(/-/gi,'D')`. That
|
|
36
|
+
* chain is a **no-op**: `String.prototype.toString` ignores its argument and
|
|
37
|
+
* returns the string unchanged, and the hex alphabet contains no `_` or `-` for
|
|
38
|
+
* the replacements to find. So the value that reaches the manager has always been
|
|
39
|
+
* plain hex, and this produces the identical thing without the dead code.
|
|
40
|
+
*
|
|
41
|
+
* One secret per file, never reused. That is what keeps the fixed IV above from
|
|
42
|
+
* being the flaw it would otherwise be.
|
|
43
|
+
*/
|
|
44
|
+
export declare const generateReceiverSecret: () => string;
|
|
45
|
+
/**
|
|
46
|
+
* Encrypts the receiver list exactly as the campaign manager expects to read it.
|
|
47
|
+
*
|
|
48
|
+
* ```
|
|
49
|
+
* key = md5(secret) -- 32 lower-case hex characters
|
|
50
|
+
* key bytes = the ASCII bytes of that hex -- 32 bytes, so AES-256
|
|
51
|
+
* iv = ASCII bytes of '678025308de70905'
|
|
52
|
+
* ciphertext = AES-256-CBC, PKCS#7 padded
|
|
53
|
+
* file body = base64(ciphertext)
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* Two parts of that are counter-intuitive and both are deliberate, because they
|
|
57
|
+
* mirror what `CryptoJS.AES.encrypt(text, keyWordArray, {iv})` does:
|
|
58
|
+
*
|
|
59
|
+
* - **The key is the hex *text*, not the digest bytes.** `md5()` returns a
|
|
60
|
+
* 32-character string and it is parsed as UTF-8, which lands on 32 bytes and
|
|
61
|
+
* therefore AES-256. Using the 16 raw digest bytes instead would be AES-128 and
|
|
62
|
+
* would not decrypt.
|
|
63
|
+
* - **There is no `Salted__` header.** CryptoJS only writes one when it derives a
|
|
64
|
+
* key from a passphrase. Given a key directly, as here, its default formatter
|
|
65
|
+
* emits bare base64 of the ciphertext.
|
|
66
|
+
*
|
|
67
|
+
* Web Crypto applies PKCS#7 padding to AES-CBC itself, which is the same padding
|
|
68
|
+
* CryptoJS defaults to -- so the outputs are byte-identical.
|
|
69
|
+
*/
|
|
70
|
+
export declare const encryptReceivers: (plaintext: string, secret: string) => Promise<string>;
|
|
71
|
+
/**
|
|
72
|
+
* The two plaintext encodings a receiver file can hold.
|
|
73
|
+
*
|
|
74
|
+
* They are not interchangeable and the reading side picks by campaign type, so
|
|
75
|
+
* the wrong one produces a file that decrypts cleanly and then parses to
|
|
76
|
+
* nothing:
|
|
77
|
+
*
|
|
78
|
+
* - **`json`** -- `[{waba, custom_fields: [{id, value}]}, …]`. What a bulk
|
|
79
|
+
* campaign's single file holds.
|
|
80
|
+
* - **`table`** -- a `||`-delimited table with a header row. What each bucket of
|
|
81
|
+
* a group campaign holds:
|
|
82
|
+
*
|
|
83
|
+
* ```
|
|
84
|
+
* waba||first_name||amount
|
|
85
|
+
* 919000000001||Priya||1299
|
|
86
|
+
* 919000000002||Ravi||499
|
|
87
|
+
* ```
|
|
88
|
+
*
|
|
89
|
+
* The table form is narrower than the JSON one: every row has to carry the same
|
|
90
|
+
* columns in the same order, and a value cannot contain a newline -- so newlines
|
|
91
|
+
* are stripped from values rather than escaped, which is what the panel did.
|
|
92
|
+
* Columns are taken from the first receiver, because a header can only be
|
|
93
|
+
* written once.
|
|
94
|
+
*/
|
|
95
|
+
export type ReceiverEncoding = 'json' | 'table';
|
|
96
|
+
/** The `||` table a group campaign's bucket file holds. */
|
|
97
|
+
export declare const encodeReceiverTable: (receivers: readonly CampaignReceiver[]) => string;
|
|
98
|
+
/**
|
|
99
|
+
* Turns a receiver list into the encrypted file that gets uploaded.
|
|
100
|
+
*
|
|
101
|
+
* The filename is cosmetic: the media service stores the object under a uuid and
|
|
102
|
+
* keeps only the extension and the display name. The `im_` prefix and `.txt`
|
|
103
|
+
* extension match what is already in the bucket, so an operator browsing storage
|
|
104
|
+
* sees one consistent kind of object.
|
|
105
|
+
*/
|
|
106
|
+
export declare const buildReceiverFile: (receivers: CampaignReceiver[], encoding?: ReceiverEncoding) => Promise<ReceiverFile>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildReceiverFile = exports.encodeReceiverTable = exports.encryptReceivers = exports.generateReceiverSecret = void 0;
|
|
4
|
+
const bytes_1 = require("./bytes");
|
|
5
|
+
const md5_1 = require("./md5");
|
|
6
|
+
/**
|
|
7
|
+
* The fixed initialisation vector the receiver file format uses.
|
|
8
|
+
*
|
|
9
|
+
* A constant IV is a real weakness -- it means two files encrypted under the same
|
|
10
|
+
* secret have identical leading blocks, so an observer can tell that two
|
|
11
|
+
* campaigns share a prefix of recipients. It is here because **the reading side
|
|
12
|
+
* is fixed**: the campaign manager decrypts with exactly this IV, and a file
|
|
13
|
+
* encrypted any other way is a file nothing can read.
|
|
14
|
+
*
|
|
15
|
+
* What makes it tolerable rather than merely unavoidable is that the secret is
|
|
16
|
+
* fresh per file (see {@link generateReceiverSecret}), so the IV is never reused
|
|
17
|
+
* under the same key -- which is the specific attack a static IV usually enables.
|
|
18
|
+
*
|
|
19
|
+
* If the format is ever revised, the fix is a random IV prepended to the
|
|
20
|
+
* ciphertext, and it has to land on the manager before it lands here.
|
|
21
|
+
*/
|
|
22
|
+
const FIXED_IV = '678025308de70905';
|
|
23
|
+
/**
|
|
24
|
+
* A fresh secret for one receiver file.
|
|
25
|
+
*
|
|
26
|
+
* Sixteen random bytes as hex -- thirty-two characters, which is the shape
|
|
27
|
+
* already in the field and what the `hash` field on a stored campaign holds.
|
|
28
|
+
*
|
|
29
|
+
* The legacy frontend generated this as 16 random bytes, hex-encoded, then
|
|
30
|
+
* applied `.toString('base64url').replace(/_/gi,'U').replace(/-/gi,'D')`. That
|
|
31
|
+
* chain is a **no-op**: `String.prototype.toString` ignores its argument and
|
|
32
|
+
* returns the string unchanged, and the hex alphabet contains no `_` or `-` for
|
|
33
|
+
* the replacements to find. So the value that reaches the manager has always been
|
|
34
|
+
* plain hex, and this produces the identical thing without the dead code.
|
|
35
|
+
*
|
|
36
|
+
* One secret per file, never reused. That is what keeps the fixed IV above from
|
|
37
|
+
* being the flaw it would otherwise be.
|
|
38
|
+
*/
|
|
39
|
+
const generateReceiverSecret = () => (0, bytes_1.toHex)((0, bytes_1.randomBytes)(16));
|
|
40
|
+
exports.generateReceiverSecret = generateReceiverSecret;
|
|
41
|
+
/**
|
|
42
|
+
* Encrypts the receiver list exactly as the campaign manager expects to read it.
|
|
43
|
+
*
|
|
44
|
+
* ```
|
|
45
|
+
* key = md5(secret) -- 32 lower-case hex characters
|
|
46
|
+
* key bytes = the ASCII bytes of that hex -- 32 bytes, so AES-256
|
|
47
|
+
* iv = ASCII bytes of '678025308de70905'
|
|
48
|
+
* ciphertext = AES-256-CBC, PKCS#7 padded
|
|
49
|
+
* file body = base64(ciphertext)
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* Two parts of that are counter-intuitive and both are deliberate, because they
|
|
53
|
+
* mirror what `CryptoJS.AES.encrypt(text, keyWordArray, {iv})` does:
|
|
54
|
+
*
|
|
55
|
+
* - **The key is the hex *text*, not the digest bytes.** `md5()` returns a
|
|
56
|
+
* 32-character string and it is parsed as UTF-8, which lands on 32 bytes and
|
|
57
|
+
* therefore AES-256. Using the 16 raw digest bytes instead would be AES-128 and
|
|
58
|
+
* would not decrypt.
|
|
59
|
+
* - **There is no `Salted__` header.** CryptoJS only writes one when it derives a
|
|
60
|
+
* key from a passphrase. Given a key directly, as here, its default formatter
|
|
61
|
+
* emits bare base64 of the ciphertext.
|
|
62
|
+
*
|
|
63
|
+
* Web Crypto applies PKCS#7 padding to AES-CBC itself, which is the same padding
|
|
64
|
+
* CryptoJS defaults to -- so the outputs are byte-identical.
|
|
65
|
+
*/
|
|
66
|
+
const encryptReceivers = async (plaintext, secret) => {
|
|
67
|
+
const crypto = (0, bytes_1.subtle)();
|
|
68
|
+
const key = await crypto.importKey('raw', (0, bytes_1.toArrayBuffer)((0, bytes_1.utf8)((0, md5_1.md5)(secret))), { name: 'AES-CBC' }, false, [
|
|
69
|
+
'encrypt',
|
|
70
|
+
]);
|
|
71
|
+
const ciphertext = await crypto.encrypt({ name: 'AES-CBC', iv: (0, bytes_1.toArrayBuffer)((0, bytes_1.utf8)(FIXED_IV)) }, key, (0, bytes_1.toArrayBuffer)((0, bytes_1.utf8)(plaintext)));
|
|
72
|
+
return (0, bytes_1.toBase64)(new Uint8Array(ciphertext));
|
|
73
|
+
};
|
|
74
|
+
exports.encryptReceivers = encryptReceivers;
|
|
75
|
+
const COLUMN_SEPARATOR = '||';
|
|
76
|
+
/** The `||` table a group campaign's bucket file holds. */
|
|
77
|
+
const encodeReceiverTable = (receivers) => {
|
|
78
|
+
const first = receivers[0];
|
|
79
|
+
if (!first)
|
|
80
|
+
return '';
|
|
81
|
+
const columns = first.custom_fields.map((field) => field.id);
|
|
82
|
+
const header = ['waba', ...columns].join(COLUMN_SEPARATOR);
|
|
83
|
+
const rows = receivers.map((receiver) => {
|
|
84
|
+
// Addressed by column rather than by position: a row whose variables arrived
|
|
85
|
+
// in a different order would otherwise silently shift every value one place.
|
|
86
|
+
const byId = new Map(receiver.custom_fields.map((field) => [field.id, field.value]));
|
|
87
|
+
return [receiver.waba, ...columns.map((column) => flatten(byId.get(column) ?? ''))].join(COLUMN_SEPARATOR);
|
|
88
|
+
});
|
|
89
|
+
// Trailing newline: the panel's writer ended every row with one, so the last
|
|
90
|
+
// line is terminated rather than merely ending.
|
|
91
|
+
return `${[header, ...rows].join('\n')}\n`;
|
|
92
|
+
};
|
|
93
|
+
exports.encodeReceiverTable = encodeReceiverTable;
|
|
94
|
+
/**
|
|
95
|
+
* A value as a table cell: no newlines, no separator.
|
|
96
|
+
*
|
|
97
|
+
* Newlines are stripped because the format has no escape for them -- one would
|
|
98
|
+
* end the row early and shift every later value into the wrong column. The
|
|
99
|
+
* separator is stripped for the same reason. Both are replaced by a space rather
|
|
100
|
+
* than removed, so two words do not run together.
|
|
101
|
+
*/
|
|
102
|
+
const flatten = (value) => value.replace(/[\r\n]+/g, ' ').split(COLUMN_SEPARATOR).join(' ');
|
|
103
|
+
/**
|
|
104
|
+
* Turns a receiver list into the encrypted file that gets uploaded.
|
|
105
|
+
*
|
|
106
|
+
* The filename is cosmetic: the media service stores the object under a uuid and
|
|
107
|
+
* keeps only the extension and the display name. The `im_` prefix and `.txt`
|
|
108
|
+
* extension match what is already in the bucket, so an operator browsing storage
|
|
109
|
+
* sees one consistent kind of object.
|
|
110
|
+
*/
|
|
111
|
+
const buildReceiverFile = async (receivers, encoding = 'json') => {
|
|
112
|
+
const secret = (0, exports.generateReceiverSecret)();
|
|
113
|
+
const plaintext = encoding === 'table' ? (0, exports.encodeReceiverTable)(receivers) : JSON.stringify(receivers);
|
|
114
|
+
const body = await (0, exports.encryptReceivers)(plaintext, secret);
|
|
115
|
+
return {
|
|
116
|
+
body,
|
|
117
|
+
secret,
|
|
118
|
+
filename: `im_${Date.now().toString(36)}${(0, bytes_1.toHex)((0, bytes_1.randomBytes)(4))}.txt`,
|
|
119
|
+
contentType: 'text/plain',
|
|
120
|
+
// The ciphertext is base64, so every character is one byte. Measuring the
|
|
121
|
+
// string rather than re-encoding it is exact here, and the upload signature
|
|
122
|
+
// covers this number.
|
|
123
|
+
size: body.length,
|
|
124
|
+
receivers: receivers.length,
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
exports.buildReceiverFile = buildReceiverFile;
|
|
128
|
+
//# sourceMappingURL=receiver-file.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"receiver-file.js","sourceRoot":"","sources":["../../src/internal/receiver-file.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { Device, ListDevicesQuery, Paginated, SubscribeNotificationsInput } from '../../types';
|
|
2
|
+
import type { HttpClient } from '../../http';
|
|
3
|
+
/**
|
|
4
|
+
* Push notification subscriptions: which browsers and apps a user wants
|
|
5
|
+
* notifications delivered to.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* // after the browser has granted permission and Firebase has a token
|
|
9
|
+
* await crm.notifications.subscribe({ token });
|
|
10
|
+
*
|
|
11
|
+
* // logout, or the user turning notifications off
|
|
12
|
+
* await crm.notifications.unsubscribe(token);
|
|
13
|
+
*
|
|
14
|
+
* // what the user sees in "notification settings"
|
|
15
|
+
* const { data } = await crm.notifications.devices();
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* ## What this does and does not cover
|
|
19
|
+
*
|
|
20
|
+
* A push subscription has two halves and only one of them is an API call:
|
|
21
|
+
*
|
|
22
|
+
* 1. **In the browser** -- ask for permission (`Notification.requestPermission()`),
|
|
23
|
+
* then get a registration token for this instance
|
|
24
|
+
* (`getToken(messaging, { vapidKey })` from `firebase/messaging`). This SDK
|
|
25
|
+
* deliberately does none of that: it would mean a hard dependency on
|
|
26
|
+
* `firebase/*`, in a package that also runs on Node, to wrap two calls whose
|
|
27
|
+
* arguments come from your own Firebase project.
|
|
28
|
+
* 2. **Here** -- hand that token to the platform so the send path can reach it,
|
|
29
|
+
* which is what {@link subscribe} does, and withdraw it on logout, which is
|
|
30
|
+
* {@link unsubscribe}.
|
|
31
|
+
*
|
|
32
|
+
* ## Where a subscription lives
|
|
33
|
+
*
|
|
34
|
+
* On the platform, against the acting user, in this product -- not in the core
|
|
35
|
+
* platform. Core owns identity and the agency's Firebase credentials; messages
|
|
36
|
+
* arrive here, so the tokens and the sending live here too, and core is not on
|
|
37
|
+
* the delivery path. Nothing in the subscription call names the Firebase project:
|
|
38
|
+
* the platform resolves it from the client the call arrives on and stamps it onto
|
|
39
|
+
* the row, because a token is only sendable through the project that minted it
|
|
40
|
+
* and nothing in the token says which.
|
|
41
|
+
*/
|
|
42
|
+
export declare class Notifications {
|
|
43
|
+
private readonly http;
|
|
44
|
+
constructor(http: HttpClient);
|
|
45
|
+
/**
|
|
46
|
+
* Subscribes this browser or app instance to notifications.
|
|
47
|
+
*
|
|
48
|
+
* ```http
|
|
49
|
+
* POST /v1/audience/devices
|
|
50
|
+
* authorization: Bearer <ecosystem token>
|
|
51
|
+
* client-id: tpc_8Kd3mXp7qR2wL9zB
|
|
52
|
+
* content-type: application/json
|
|
53
|
+
*
|
|
54
|
+
* {
|
|
55
|
+
* "token": "fNx8...:APA91bH...",
|
|
56
|
+
* "platform": "web",
|
|
57
|
+
* "device_label": "Chrome on Windows",
|
|
58
|
+
* "tpc_id": "tpc_8Kd3mXp7qR2wL9zB"
|
|
59
|
+
* }
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* Answers `201` with the stored {@link Device}, token omitted. Of those four
|
|
63
|
+
* fields the SDK fills in two for you: `platform` defaults to `'web'` when it
|
|
64
|
+
* can see a DOM, and `device_label` to something like `Chrome on Windows` from
|
|
65
|
+
* the user agent.
|
|
66
|
+
*
|
|
67
|
+
* ## Call it on every app load, not once
|
|
68
|
+
*
|
|
69
|
+
* It is an idempotent upsert keyed on the token, so re-sending one the platform
|
|
70
|
+
* already holds costs a single indexed update. That is the intended usage,
|
|
71
|
+
* because two things change underneath you:
|
|
72
|
+
*
|
|
73
|
+
* - FCM rotates registration tokens on its own schedule, and the old one stops
|
|
74
|
+
* being delivered to without anyone being told.
|
|
75
|
+
* - A device unseen for 60 days is pruned, and "seen" means a successful send
|
|
76
|
+
* or a call to this method. A user who receives no notifications for two
|
|
77
|
+
* months would otherwise fall off the list.
|
|
78
|
+
*
|
|
79
|
+
* Re-registering a token that is already on file against **another** user
|
|
80
|
+
* moves it rather than duplicating it: one browser has one person signed in,
|
|
81
|
+
* and the previous owner's notifications must stop reaching it the moment
|
|
82
|
+
* someone else logs in there.
|
|
83
|
+
*
|
|
84
|
+
* ## Identity
|
|
85
|
+
*
|
|
86
|
+
* The user comes from the verified token this client was built with, and there
|
|
87
|
+
* is no field that could say otherwise. `tpc_id` is the one tenant field
|
|
88
|
+
* accepted, because it is not an identity claim -- it says which reseller
|
|
89
|
+
* frontend is registering, which decides whose Firebase project and which deep
|
|
90
|
+
* link host apply. It is cross-checked against the verified `client-id`, so it
|
|
91
|
+
* can confirm the surface but never move the device to another tenant: a value
|
|
92
|
+
* that contradicts the token is refused with `403` rather than ignored.
|
|
93
|
+
*
|
|
94
|
+
* ## Failures worth handling
|
|
95
|
+
*
|
|
96
|
+
* - `400 validation_failed` -- the token is missing or implausible, or
|
|
97
|
+
* `platform` could not be defaulted (see {@link SubscribeNotificationsInput}).
|
|
98
|
+
* - `403 forbidden` -- `tpc_id` names a different client than the token was
|
|
99
|
+
* issued for.
|
|
100
|
+
* - `503 push_not_configured` -- the agency has no Firebase notification
|
|
101
|
+
* resource set up. Nothing the caller can fix; notifications are simply off
|
|
102
|
+
* for this account.
|
|
103
|
+
* - `503 push_unavailable` -- the platform could not reach core to read the
|
|
104
|
+
* configuration. Transient.
|
|
105
|
+
*
|
|
106
|
+
* Both `503`s deserve the same handling as a successful call: try again on the
|
|
107
|
+
* next app load, which the advice above means you are doing anyway.
|
|
108
|
+
*
|
|
109
|
+
* Scope: `devices:write`.
|
|
110
|
+
*/
|
|
111
|
+
subscribe(input: SubscribeNotificationsInput): Promise<Device>;
|
|
112
|
+
/**
|
|
113
|
+
* Stops notifications reaching one device. What logout calls.
|
|
114
|
+
*
|
|
115
|
+
* ```http
|
|
116
|
+
* POST /v1/audience/devices/unregister
|
|
117
|
+
* { "token": "fNx8...:APA91bH..." }
|
|
118
|
+
* ```
|
|
119
|
+
*
|
|
120
|
+
* `204`, no body, whether or not a row went -- the caller is asking for a
|
|
121
|
+
* guarantee about the future, and a token that is already gone provides it. So
|
|
122
|
+
* this is safe to call unconditionally on logout without first checking whether
|
|
123
|
+
* the device was ever subscribed.
|
|
124
|
+
*
|
|
125
|
+
* Scoped to the caller's own devices. A token identifies a browser rather than
|
|
126
|
+
* a person, so by the time one user logs out the row may already belong to
|
|
127
|
+
* whoever logged in after them; an unscoped delete would cut off the person now
|
|
128
|
+
* holding it.
|
|
129
|
+
*
|
|
130
|
+
* A `POST` with the token in the body, not `DELETE /devices/:token`: a
|
|
131
|
+
* registration token is hundreds of characters and may contain `:` and `/`, and
|
|
132
|
+
* putting one in a path would invite proxy length limits and write a live send
|
|
133
|
+
* credential into every access log on the way.
|
|
134
|
+
*
|
|
135
|
+
* ## Also delete it on the device
|
|
136
|
+
*
|
|
137
|
+
* This revokes the platform's permission to send to the token. If the user is
|
|
138
|
+
* turning notifications *off* -- rather than logging out -- also call
|
|
139
|
+
* `deleteToken(messaging)` in the browser, otherwise the next `getToken()`
|
|
140
|
+
* hands back the same token and the subscription can come back by accident.
|
|
141
|
+
*
|
|
142
|
+
* Scope: `devices:write`.
|
|
143
|
+
*/
|
|
144
|
+
unsubscribe(token: string): Promise<void>;
|
|
145
|
+
/**
|
|
146
|
+
* The caller's own subscribed devices, newest contact first.
|
|
147
|
+
*
|
|
148
|
+
* ```http
|
|
149
|
+
* GET /v1/audience/devices?page=1&rows=25
|
|
150
|
+
* ```
|
|
151
|
+
*
|
|
152
|
+
* Offset paginated, `rows` 1..100 and default 25, sorted by `lastSeenAt`
|
|
153
|
+
* descending. This is what a "notification settings" screen lists, and the 20
|
|
154
|
+
* most recently seen devices are also the only ones a notification is sent to,
|
|
155
|
+
* so a longer list is itself the answer to "why did my old laptop stop
|
|
156
|
+
* buzzing".
|
|
157
|
+
*
|
|
158
|
+
* Only ever the acting user's own devices -- there is no account-wide view and
|
|
159
|
+
* no device id in the path. A device is one person's browser, and the only
|
|
160
|
+
* party with a reason to see or revoke it is that person.
|
|
161
|
+
*
|
|
162
|
+
* Tokens are not included, so this cannot tell you whether *this* browser is
|
|
163
|
+
* subscribed. Decide that locally: the browser knows its own
|
|
164
|
+
* `Notification.permission` and its own token, and {@link subscribe} being
|
|
165
|
+
* idempotent means the cheap answer to "am I still subscribed" is to subscribe
|
|
166
|
+
* again.
|
|
167
|
+
*
|
|
168
|
+
* Scope: `devices:read`.
|
|
169
|
+
*/
|
|
170
|
+
devices(query?: Partial<ListDevicesQuery>): Promise<Paginated<Device>>;
|
|
171
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Notifications = void 0;
|
|
4
|
+
const errors_1 = require("../../errors");
|
|
5
|
+
const device_1 = require("../../internal/device");
|
|
6
|
+
const paths_1 = require("../paths");
|
|
7
|
+
const BASE = `${paths_1.AUDIENCE}/devices`;
|
|
8
|
+
/** The bounds the server applies, checked here so a bad token fails without a round trip. */
|
|
9
|
+
const TOKEN_LENGTH = { min: 20, max: 4096 };
|
|
10
|
+
/** What the server accepts as a label, so a long one is trimmed rather than refused. */
|
|
11
|
+
const MAX_LABEL = 200;
|
|
12
|
+
/**
|
|
13
|
+
* Push notification subscriptions: which browsers and apps a user wants
|
|
14
|
+
* notifications delivered to.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* // after the browser has granted permission and Firebase has a token
|
|
18
|
+
* await crm.notifications.subscribe({ token });
|
|
19
|
+
*
|
|
20
|
+
* // logout, or the user turning notifications off
|
|
21
|
+
* await crm.notifications.unsubscribe(token);
|
|
22
|
+
*
|
|
23
|
+
* // what the user sees in "notification settings"
|
|
24
|
+
* const { data } = await crm.notifications.devices();
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* ## What this does and does not cover
|
|
28
|
+
*
|
|
29
|
+
* A push subscription has two halves and only one of them is an API call:
|
|
30
|
+
*
|
|
31
|
+
* 1. **In the browser** -- ask for permission (`Notification.requestPermission()`),
|
|
32
|
+
* then get a registration token for this instance
|
|
33
|
+
* (`getToken(messaging, { vapidKey })` from `firebase/messaging`). This SDK
|
|
34
|
+
* deliberately does none of that: it would mean a hard dependency on
|
|
35
|
+
* `firebase/*`, in a package that also runs on Node, to wrap two calls whose
|
|
36
|
+
* arguments come from your own Firebase project.
|
|
37
|
+
* 2. **Here** -- hand that token to the platform so the send path can reach it,
|
|
38
|
+
* which is what {@link subscribe} does, and withdraw it on logout, which is
|
|
39
|
+
* {@link unsubscribe}.
|
|
40
|
+
*
|
|
41
|
+
* ## Where a subscription lives
|
|
42
|
+
*
|
|
43
|
+
* On the platform, against the acting user, in this product -- not in the core
|
|
44
|
+
* platform. Core owns identity and the agency's Firebase credentials; messages
|
|
45
|
+
* arrive here, so the tokens and the sending live here too, and core is not on
|
|
46
|
+
* the delivery path. Nothing in the subscription call names the Firebase project:
|
|
47
|
+
* the platform resolves it from the client the call arrives on and stamps it onto
|
|
48
|
+
* the row, because a token is only sendable through the project that minted it
|
|
49
|
+
* and nothing in the token says which.
|
|
50
|
+
*/
|
|
51
|
+
class Notifications {
|
|
52
|
+
http;
|
|
53
|
+
constructor(http) {
|
|
54
|
+
this.http = http;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Subscribes this browser or app instance to notifications.
|
|
58
|
+
*
|
|
59
|
+
* ```http
|
|
60
|
+
* POST /v1/audience/devices
|
|
61
|
+
* authorization: Bearer <ecosystem token>
|
|
62
|
+
* client-id: tpc_8Kd3mXp7qR2wL9zB
|
|
63
|
+
* content-type: application/json
|
|
64
|
+
*
|
|
65
|
+
* {
|
|
66
|
+
* "token": "fNx8...:APA91bH...",
|
|
67
|
+
* "platform": "web",
|
|
68
|
+
* "device_label": "Chrome on Windows",
|
|
69
|
+
* "tpc_id": "tpc_8Kd3mXp7qR2wL9zB"
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* Answers `201` with the stored {@link Device}, token omitted. Of those four
|
|
74
|
+
* fields the SDK fills in two for you: `platform` defaults to `'web'` when it
|
|
75
|
+
* can see a DOM, and `device_label` to something like `Chrome on Windows` from
|
|
76
|
+
* the user agent.
|
|
77
|
+
*
|
|
78
|
+
* ## Call it on every app load, not once
|
|
79
|
+
*
|
|
80
|
+
* It is an idempotent upsert keyed on the token, so re-sending one the platform
|
|
81
|
+
* already holds costs a single indexed update. That is the intended usage,
|
|
82
|
+
* because two things change underneath you:
|
|
83
|
+
*
|
|
84
|
+
* - FCM rotates registration tokens on its own schedule, and the old one stops
|
|
85
|
+
* being delivered to without anyone being told.
|
|
86
|
+
* - A device unseen for 60 days is pruned, and "seen" means a successful send
|
|
87
|
+
* or a call to this method. A user who receives no notifications for two
|
|
88
|
+
* months would otherwise fall off the list.
|
|
89
|
+
*
|
|
90
|
+
* Re-registering a token that is already on file against **another** user
|
|
91
|
+
* moves it rather than duplicating it: one browser has one person signed in,
|
|
92
|
+
* and the previous owner's notifications must stop reaching it the moment
|
|
93
|
+
* someone else logs in there.
|
|
94
|
+
*
|
|
95
|
+
* ## Identity
|
|
96
|
+
*
|
|
97
|
+
* The user comes from the verified token this client was built with, and there
|
|
98
|
+
* is no field that could say otherwise. `tpc_id` is the one tenant field
|
|
99
|
+
* accepted, because it is not an identity claim -- it says which reseller
|
|
100
|
+
* frontend is registering, which decides whose Firebase project and which deep
|
|
101
|
+
* link host apply. It is cross-checked against the verified `client-id`, so it
|
|
102
|
+
* can confirm the surface but never move the device to another tenant: a value
|
|
103
|
+
* that contradicts the token is refused with `403` rather than ignored.
|
|
104
|
+
*
|
|
105
|
+
* ## Failures worth handling
|
|
106
|
+
*
|
|
107
|
+
* - `400 validation_failed` -- the token is missing or implausible, or
|
|
108
|
+
* `platform` could not be defaulted (see {@link SubscribeNotificationsInput}).
|
|
109
|
+
* - `403 forbidden` -- `tpc_id` names a different client than the token was
|
|
110
|
+
* issued for.
|
|
111
|
+
* - `503 push_not_configured` -- the agency has no Firebase notification
|
|
112
|
+
* resource set up. Nothing the caller can fix; notifications are simply off
|
|
113
|
+
* for this account.
|
|
114
|
+
* - `503 push_unavailable` -- the platform could not reach core to read the
|
|
115
|
+
* configuration. Transient.
|
|
116
|
+
*
|
|
117
|
+
* Both `503`s deserve the same handling as a successful call: try again on the
|
|
118
|
+
* next app load, which the advice above means you are doing anyway.
|
|
119
|
+
*
|
|
120
|
+
* Scope: `devices:write`.
|
|
121
|
+
*/
|
|
122
|
+
subscribe(input) {
|
|
123
|
+
const token = (input.token ?? '').trim();
|
|
124
|
+
if (token.length < TOKEN_LENGTH.min || token.length > TOKEN_LENGTH.max) {
|
|
125
|
+
throw new errors_1.CrmApiError(400, 'validation_failed', `token must be a Firebase registration token of ${TOKEN_LENGTH.min}..${TOKEN_LENGTH.max} characters`);
|
|
126
|
+
}
|
|
127
|
+
const platform = input.platform ?? (0, device_1.detectPlatform)();
|
|
128
|
+
if (!platform) {
|
|
129
|
+
// Not defaulted to 'web' outside a browser on purpose: a mobile runtime
|
|
130
|
+
// registered as web gets the webpush half of the payload, which FCM
|
|
131
|
+
// accepts and the device never displays.
|
|
132
|
+
throw new errors_1.CrmApiError(400, 'validation_failed', 'platform is required outside a browser: pass "android", "ios", or "web" explicitly');
|
|
133
|
+
}
|
|
134
|
+
const label = (input.device_label ?? (0, device_1.describeDevice)() ?? '').trim().slice(0, MAX_LABEL);
|
|
135
|
+
return this.http.request('POST', BASE, {
|
|
136
|
+
body: {
|
|
137
|
+
token,
|
|
138
|
+
platform,
|
|
139
|
+
// Both omitted when empty rather than sent as '': the server treats an
|
|
140
|
+
// absent optional and an empty one the same way, and a body carrying only
|
|
141
|
+
// what it means is easier to read in a log.
|
|
142
|
+
...(label ? { device_label: label } : {}),
|
|
143
|
+
...(input.tpc_id ? { tpc_id: input.tpc_id.trim() } : {}),
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Stops notifications reaching one device. What logout calls.
|
|
149
|
+
*
|
|
150
|
+
* ```http
|
|
151
|
+
* POST /v1/audience/devices/unregister
|
|
152
|
+
* { "token": "fNx8...:APA91bH..." }
|
|
153
|
+
* ```
|
|
154
|
+
*
|
|
155
|
+
* `204`, no body, whether or not a row went -- the caller is asking for a
|
|
156
|
+
* guarantee about the future, and a token that is already gone provides it. So
|
|
157
|
+
* this is safe to call unconditionally on logout without first checking whether
|
|
158
|
+
* the device was ever subscribed.
|
|
159
|
+
*
|
|
160
|
+
* Scoped to the caller's own devices. A token identifies a browser rather than
|
|
161
|
+
* a person, so by the time one user logs out the row may already belong to
|
|
162
|
+
* whoever logged in after them; an unscoped delete would cut off the person now
|
|
163
|
+
* holding it.
|
|
164
|
+
*
|
|
165
|
+
* A `POST` with the token in the body, not `DELETE /devices/:token`: a
|
|
166
|
+
* registration token is hundreds of characters and may contain `:` and `/`, and
|
|
167
|
+
* putting one in a path would invite proxy length limits and write a live send
|
|
168
|
+
* credential into every access log on the way.
|
|
169
|
+
*
|
|
170
|
+
* ## Also delete it on the device
|
|
171
|
+
*
|
|
172
|
+
* This revokes the platform's permission to send to the token. If the user is
|
|
173
|
+
* turning notifications *off* -- rather than logging out -- also call
|
|
174
|
+
* `deleteToken(messaging)` in the browser, otherwise the next `getToken()`
|
|
175
|
+
* hands back the same token and the subscription can come back by accident.
|
|
176
|
+
*
|
|
177
|
+
* Scope: `devices:write`.
|
|
178
|
+
*/
|
|
179
|
+
unsubscribe(token) {
|
|
180
|
+
const value = (token ?? '').trim();
|
|
181
|
+
if (!value)
|
|
182
|
+
throw new errors_1.CrmApiError(400, 'validation_failed', 'token is required');
|
|
183
|
+
return this.http.request('POST', `${BASE}/unregister`, { body: { token: value } });
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The caller's own subscribed devices, newest contact first.
|
|
187
|
+
*
|
|
188
|
+
* ```http
|
|
189
|
+
* GET /v1/audience/devices?page=1&rows=25
|
|
190
|
+
* ```
|
|
191
|
+
*
|
|
192
|
+
* Offset paginated, `rows` 1..100 and default 25, sorted by `lastSeenAt`
|
|
193
|
+
* descending. This is what a "notification settings" screen lists, and the 20
|
|
194
|
+
* most recently seen devices are also the only ones a notification is sent to,
|
|
195
|
+
* so a longer list is itself the answer to "why did my old laptop stop
|
|
196
|
+
* buzzing".
|
|
197
|
+
*
|
|
198
|
+
* Only ever the acting user's own devices -- there is no account-wide view and
|
|
199
|
+
* no device id in the path. A device is one person's browser, and the only
|
|
200
|
+
* party with a reason to see or revoke it is that person.
|
|
201
|
+
*
|
|
202
|
+
* Tokens are not included, so this cannot tell you whether *this* browser is
|
|
203
|
+
* subscribed. Decide that locally: the browser knows its own
|
|
204
|
+
* `Notification.permission` and its own token, and {@link subscribe} being
|
|
205
|
+
* idempotent means the cheap answer to "am I still subscribed" is to subscribe
|
|
206
|
+
* again.
|
|
207
|
+
*
|
|
208
|
+
* Scope: `devices:read`.
|
|
209
|
+
*/
|
|
210
|
+
devices(query = {}) {
|
|
211
|
+
return this.http.request('GET', BASE, { query });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
exports.Notifications = Notifications;
|
|
215
|
+
//# sourceMappingURL=notifications.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notifications.js","sourceRoot":"","sources":["../../../src/resources/audience/notifications.ts"],"names":[],"mappings":";;;AAEA,yCAA2C;AAC3C,kDAAuE;AACvE,oCAAoC;AAEpC,MAAM,IAAI,GAAG,GAAG,gBAAQ,UAAU,CAAC;AAEnC,6FAA6F;AAC7F,MAAM,YAAY,GAAG,EAAE,GAAG,EAAE,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC;AAE5C,wFAAwF;AACxF,MAAM,SAAS,GAAG,GAAG,CAAC;AAEtB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,MAAa,aAAa;IACK;IAA7B,YAA6B,IAAgB;QAAhB,SAAI,GAAJ,IAAI,CAAY;IAAG,CAAC;IAEjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiEG;IACH,SAAS,CAAC,KAAkC;QAC1C,MAAM,KAAK,GAAG,CAAC,KAAK,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QAEzC,IAAI,KAAK,CAAC,MAAM,GAAG,YAAY,CAAC,GAAG,IAAI,KAAK,CAAC,MAAM,GAAG,YAAY,CAAC,GAAG,EAAE,CAAC;YACvE,MAAM,IAAI,oBAAW,CACnB,GAAG,EACH,mBAAmB,EACnB,kDAAkD,YAAY,CAAC,GAAG,KAAK,YAAY,CAAC,GAAG,aAAa,CACrG,CAAC;QACJ,CAAC;QAED,MAAM,QAAQ,GAAG,KAAK,CAAC,QAAQ,IAAI,IAAA,uBAAc,GAAE,CAAC;QAEpD,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,wEAAwE;YACxE,oEAAoE;YACpE,yCAAyC;YACzC,MAAM,IAAI,oBAAW,CACnB,GAAG,EACH,mBAAmB,EACnB,oFAAoF,CACrF,CAAC;QACJ,CAAC;QAED,MAAM,KAAK,GAAG,CAAC,KAAK,CAAC,YAAY,IAAI,IAAA,uBAAc,GAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC;QAExF,OAAO,IAAI,CAAC,IAAI,CAAC,OAAO,CAAS,MAAM,EAAE,IAAI,EAAE;YAC7C,IAAI,EAAE;gBACJ,KAAK;gBACL,QAAQ;gBACR,uEAAuE;gBACvE,0EAA0E;gBAC1E,4CAA4C;gBAC5C,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBACzC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aACzD;SACF,CAAC,CAAC;IACL,CAAC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA+BG;IACH,WAAW,CAAC,KAAa;QACvB,MAAM,KAAK,GAAG,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QAEnC,IAAI,CAAC,KAAK;YAAE,MAAM,IAAI,oBAAW,CAAC,GAAG,EAAE,mBAAmB,EAAE,mBAAmB,CAAC,CAAC;QAEjF,OAAO,IAAI,CAAC,IAAI,CAAC,OAAO,CAAO,MAAM,EAAE,GAAG,IAAI,aAAa,EAAE,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;IAC3F,CAAC;IAED;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;IACH,OAAO,CAAC,QAAmC,EAAE;QAC3C,OAAO,IAAI,CAAC,IAAI,CAAC,OAAO,CAAoB,KAAK,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;IACtE,CAAC;CACF;AAhLD,sCAgLC"}
|