ab-ecosystem-cpass 1.0.5 → 1.0.6

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.
Files changed (44) hide show
  1. package/README.md +63 -1
  2. package/dist/client.d.ts +23 -0
  3. package/dist/client.js +32 -5
  4. package/dist/client.js.map +1 -1
  5. package/dist/index.d.ts +10 -0
  6. package/dist/index.js +22 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/internal/bytes.d.ts +38 -0
  9. package/dist/internal/bytes.js +84 -0
  10. package/dist/internal/bytes.js.map +1 -0
  11. package/dist/internal/device.d.ts +24 -0
  12. package/dist/internal/device.js +91 -0
  13. package/dist/internal/device.js.map +1 -0
  14. package/dist/internal/md5.d.ts +4 -0
  15. package/dist/internal/md5.js +109 -0
  16. package/dist/internal/md5.js.map +1 -0
  17. package/dist/internal/receiver-file.d.ts +106 -0
  18. package/dist/internal/receiver-file.js +128 -0
  19. package/dist/internal/receiver-file.js.map +1 -0
  20. package/dist/resources/audience/notifications.d.ts +171 -0
  21. package/dist/resources/audience/notifications.js +215 -0
  22. package/dist/resources/audience/notifications.js.map +1 -0
  23. package/dist/resources/campaign-audience.d.ts +132 -0
  24. package/dist/resources/campaign-audience.js +224 -0
  25. package/dist/resources/campaign-audience.js.map +1 -0
  26. package/dist/resources/campaign-report-exports.d.ts +14 -0
  27. package/dist/resources/campaign-report-exports.js +28 -0
  28. package/dist/resources/campaign-report-exports.js.map +1 -0
  29. package/dist/resources/campaign-reports.d.ts +15 -0
  30. package/dist/resources/campaign-reports.js +25 -0
  31. package/dist/resources/campaign-reports.js.map +1 -0
  32. package/dist/resources/campaigns.d.ts +239 -0
  33. package/dist/resources/campaigns.js +300 -0
  34. package/dist/resources/campaigns.js.map +1 -0
  35. package/dist/types/audience.d.ts +82 -0
  36. package/dist/types/campaign.d.ts +229 -0
  37. package/dist/types/campaign.js +3 -0
  38. package/dist/types/campaign.js.map +1 -0
  39. package/dist/types/index.d.ts +1 -0
  40. package/dist/types/index.js +2 -0
  41. package/dist/types/index.js.map +1 -1
  42. package/docs/audience/notifications.md +263 -0
  43. package/docs/campaign/campaigns.md +489 -0
  44. package/package.json +1 -1
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Campaign shapes. Declared here rather than imported from `@crm/contracts` so
3
+ * the published package has no private dependencies; they are kept in step with
4
+ * the zod schemas in `packages/contracts/src/campaign/`.
5
+ */
6
+ import type { IsoDate, JsonObject } from './common';
7
+ import type { ContactFilterEntry } from './contact-filter';
8
+ export type CampaignStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
9
+ /** What the campaign charges for. `payment` is the collect-a-payment flow. */
10
+ export type CampaignType = 'general' | 'payment';
11
+ export type CampaignTrigger = 'immediate' | 'schedule';
12
+ export type CampaignRepeatUnit = 'minutes' | 'hours' | 'days' | 'weeks' | 'months';
13
+ export interface CampaignRepeat {
14
+ every: number;
15
+ unit: CampaignRepeatUnit;
16
+ /** Inclusive: the last occurrence is the final one at or before this moment. */
17
+ until?: IsoDate;
18
+ }
19
+ /**
20
+ * A bulk recipient list held in an uploaded file.
21
+ *
22
+ * This is what `campaigns.createBulk()` produces, and it is the only audience
23
+ * mode whose contents are not in the campaign document: the numbers live in an
24
+ * encrypted file and the campaign holds a pointer to it.
25
+ */
26
+ export interface CampaignNumbersFilter {
27
+ /**
28
+ * HTTPS URL of the encrypted receiver file. Must be a public host -- the
29
+ * service that sends the campaign fetches it.
30
+ */
31
+ numbers: string;
32
+ /**
33
+ * The secret the file was encrypted under, 32-128 hex characters.
34
+ *
35
+ * Named `hash` for historical reasons; it is a key, not a digest. Whatever
36
+ * reads the file derives the AES key from it. Treat it as a credential: anyone
37
+ * holding this and the URL can read the recipient list.
38
+ */
39
+ hash: string;
40
+ }
41
+ /** Re-target by what happened to a previous campaign's messages. */
42
+ export interface CampaignRetargetStatusFilter {
43
+ type: 'status';
44
+ /** The campaign to re-target. */
45
+ cid: string;
46
+ /** Its numeric message id. A number, not a string. */
47
+ cmid: number;
48
+ status: 'all' | 'sent' | 'delivered' | 'read' | 'replied' | 'failed';
49
+ delivered?: boolean | null;
50
+ read?: boolean | null;
51
+ replied?: boolean | null;
52
+ /** Hours within which a reply counts. */
53
+ replied_in?: number | null;
54
+ reply_type?: string | null;
55
+ reply?: string[] | null;
56
+ }
57
+ /** Re-target an explicit set of previous recipients. */
58
+ export interface CampaignRetargetSelectionFilter {
59
+ type: 'selection';
60
+ records: string[];
61
+ }
62
+ /**
63
+ * One entry in a campaign's `filters`.
64
+ *
65
+ * The audience is one field with four modes, and the mode is decided by the
66
+ * **shape of the array** rather than by a selector:
67
+ *
68
+ * | `filters` | Audience |
69
+ * |---|---|
70
+ * | `[]` | every contact in the account |
71
+ * | `ContactFilterEntry[]` | a segment, built from contact conditions |
72
+ * | `[{numbers, hash}]` | a bulk list from an uploaded file |
73
+ * | `[{type: 'status' \| 'selection', …}]` | re-target of an earlier campaign |
74
+ *
75
+ * The modes cannot be mixed, and the three non-segment modes take exactly one
76
+ * entry -- everything downstream reads `filters[0]` and nothing else. The server
77
+ * rejects a mixture rather than guessing.
78
+ */
79
+ export type CampaignFilter = CampaignNumbersFilter | CampaignRetargetStatusFilter | CampaignRetargetSelectionFilter | ContactFilterEntry;
80
+ /** Which of the four modes a `filters` array expresses. */
81
+ export type CampaignAudienceKind = 'all' | 'segment' | 'numbers' | 'retarget';
82
+ export interface CampaignMessage {
83
+ id?: string | number;
84
+ channel: {
85
+ id: string;
86
+ version?: string;
87
+ category?: string;
88
+ };
89
+ category?: string;
90
+ /** The provider payload. Shape is per channel type, so it is left open. */
91
+ message: JsonObject;
92
+ /** Save a reply to a contact field. */
93
+ replyStatus?: {
94
+ save: boolean;
95
+ fieldId: string;
96
+ };
97
+ polls?: string[];
98
+ /** Anything else the panel put here is carried through. */
99
+ [key: string]: unknown;
100
+ }
101
+ /** Who launched a campaign and from where. Stamped by the server; never sent. */
102
+ export interface CampaignTriggerInfo {
103
+ userIp?: string;
104
+ userType?: string;
105
+ psuid?: string;
106
+ userRefId?: string;
107
+ [key: string]: unknown;
108
+ }
109
+ export interface Campaign {
110
+ id: string;
111
+ version: string;
112
+ crmVersion: string;
113
+ name: string;
114
+ /**
115
+ * The dispatch route, as the sending service understands it -- `general`,
116
+ * `bulk_campaign`, `retarget`, and others. Distinct from `campaignType`.
117
+ */
118
+ type: string;
119
+ campaignType: CampaignType;
120
+ trigger: CampaignTrigger;
121
+ triggerInfo: CampaignTriggerInfo;
122
+ channelType: string;
123
+ messagingPattern: string;
124
+ status: CampaignStatus;
125
+ filters: CampaignFilter[];
126
+ messages: CampaignMessage[];
127
+ showMessageInPanel: boolean;
128
+ isRepeating: boolean;
129
+ repeat?: CampaignRepeat;
130
+ parentBroadcastId?: string;
131
+ scheduleId?: string;
132
+ hidden: boolean;
133
+ scheduledAt?: IsoDate;
134
+ processedAt?: IsoDate;
135
+ completedAt?: IsoDate;
136
+ /**
137
+ * Recipients resolved at send time, not at creation.
138
+ *
139
+ * `0` until the campaign is processed. The audience is a stored query -- even a
140
+ * bulk list is a file the sender reads later -- so a scheduled campaign's final
141
+ * count can differ from what was shown when it was built.
142
+ */
143
+ receivers: number;
144
+ totalMessages: number;
145
+ error?: string;
146
+ metaInfo: JsonObject[];
147
+ createdAt: IsoDate;
148
+ updatedAt: IsoDate;
149
+ }
150
+ export interface CreateCampaignInput {
151
+ name: string;
152
+ /** Defaults to `general`. `createBulk()` sets `bulk_campaign` itself. */
153
+ type?: string;
154
+ campaignType?: CampaignType;
155
+ trigger?: CampaignTrigger;
156
+ channelType?: string;
157
+ messagingPattern?: string;
158
+ /** The audience. `[]` means every contact. See {@link CampaignFilter}. */
159
+ filters?: CampaignFilter[];
160
+ messages: CampaignMessage[];
161
+ showMessageInPanel?: boolean;
162
+ isRepeating?: boolean;
163
+ repeat?: CampaignRepeat;
164
+ parentBroadcastId?: string;
165
+ hidden?: boolean;
166
+ /** Required when `trigger` is `schedule`. */
167
+ scheduledAt?: IsoDate;
168
+ }
169
+ export type UpdateCampaignInput = Partial<CreateCampaignInput>;
170
+ /**
171
+ * Declared as a type alias rather than an interface, and that is load-bearing:
172
+ * only an alias of an object literal gets TypeScript's implicit index signature,
173
+ * which is what lets it be passed as a request's query record.
174
+ */
175
+ export type ListCampaignsQuery = {
176
+ limit: number;
177
+ cursor?: string;
178
+ status?: CampaignStatus;
179
+ campaignType?: CampaignType;
180
+ type?: string;
181
+ hidden?: boolean;
182
+ };
183
+ /** Delivery counts for one campaign. Every field is a running total. */
184
+ export interface CampaignMetrics {
185
+ queued: number;
186
+ sent: number;
187
+ delivered: number;
188
+ failed: number;
189
+ opened: number;
190
+ clicked: number;
191
+ unsubscribed: number;
192
+ }
193
+ export interface CampaignReport {
194
+ id: string;
195
+ campaignId: string;
196
+ metrics: CampaignMetrics;
197
+ /** When the metrics were last rolled up, which may lag the campaign. */
198
+ generatedAt: IsoDate;
199
+ createdAt: IsoDate;
200
+ updatedAt: IsoDate;
201
+ }
202
+ /** A report export shares the job lifecycle used across the platform. */
203
+ export type CampaignReportExportStatus = 'queued' | 'processing' | 'completed' | 'failed';
204
+ export interface CampaignReportExport {
205
+ id: string;
206
+ campaignId: string;
207
+ format: 'csv' | 'xlsx';
208
+ status: CampaignReportExportStatus;
209
+ rowCount: number;
210
+ /** Present once `status` is `completed`. Expires; see `expiresAt`. */
211
+ fileUrl?: string;
212
+ expiresAt?: IsoDate;
213
+ errorMessage?: string;
214
+ createdAt: IsoDate;
215
+ updatedAt: IsoDate;
216
+ }
217
+ export interface CreateCampaignReportExportInput {
218
+ campaignId: string;
219
+ /** Defaults to `csv`. */
220
+ format?: 'csv' | 'xlsx';
221
+ /** Narrows the rows by the period they fall in. */
222
+ from?: IsoDate;
223
+ to?: IsoDate;
224
+ }
225
+ /** A type alias, for the same reason as {@link ListCampaignsQuery}. */
226
+ export type GetCampaignReportQuery = {
227
+ from?: IsoDate;
228
+ to?: IsoDate;
229
+ };
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=campaign.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"campaign.js","sourceRoot":"","sources":["../../src/types/campaign.ts"],"names":[],"mappings":""}
@@ -4,3 +4,4 @@ export * from './audience';
4
4
  export * from './channels';
5
5
  export * from './inbox';
6
6
  export * from './scheduled-message';
7
+ export * from './campaign';
@@ -21,4 +21,6 @@ __exportStar(require("./channels"), exports);
21
21
  __exportStar(require("./inbox"), exports);
22
22
  // After './inbox': a scheduled message's body is a `MessageContent`.
23
23
  __exportStar(require("./scheduled-message"), exports);
24
+ // After './contact-filter': a campaign's audience may be a list of its entries.
25
+ __exportStar(require("./campaign"), exports);
24
26
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,2CAAyB;AACzB,mDAAiC;AACjC,6CAA2B;AAC3B,6CAA2B;AAC3B,0CAAwB;AACxB,qEAAqE;AACrE,sDAAoC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,2CAAyB;AACzB,mDAAiC;AACjC,6CAA2B;AAC3B,6CAA2B;AAC3B,0CAAwB;AACxB,qEAAqE;AACrE,sDAAoC;AACpC,gFAAgF;AAChF,6CAA2B"}
@@ -0,0 +1,263 @@
1
+ # Push notifications
2
+
3
+ `crm.notifications` — `/v1/audience/devices`
4
+
5
+ Which browsers and apps a user wants notifications delivered to. A subscription
6
+ is one device — one browser profile, one app installation — held against the
7
+ acting user, and the only party who can see or revoke it is that user.
8
+
9
+ | Action | Route | Scope |
10
+ | --- | --- | --- |
11
+ | [`subscribe`](#subscribe) | `POST /v1/audience/devices` | `devices:write` |
12
+ | [`unsubscribe`](#unsubscribe) | `POST /v1/audience/devices/unregister` | `devices:write` |
13
+ | [`devices`](#devices) | `GET /v1/audience/devices` | `devices:read` |
14
+
15
+ ## What the SDK does, and what you do
16
+
17
+ A push subscription has two halves and only one of them is an API call.
18
+
19
+ **In the browser**, which this SDK deliberately does not wrap:
20
+
21
+ ```js
22
+ const permission = await Notification.requestPermission(); // 'granted' | 'denied' | 'default'
23
+ const token = await getToken(messaging, { vapidKey }); // firebase/messaging
24
+ ```
25
+
26
+ Wrapping those two would mean a hard dependency on `firebase/*` — in a package
27
+ that also runs on Node — to pass arguments that come from your own Firebase
28
+ project.
29
+
30
+ **Here**, which is the part that is an API call:
31
+
32
+ ```ts
33
+ if (permission === 'granted') {
34
+ await crm.notifications.subscribe({ token });
35
+ }
36
+ ```
37
+
38
+ ## Where the subscription lives
39
+
40
+ On the platform, against the acting user, **in this product** — not in the core
41
+ platform. Core owns identity and the agency's Firebase credentials; messages
42
+ arrive here, so the tokens and the sending live here too, and core is never on
43
+ the delivery path.
44
+
45
+ Nothing you send names a Firebase project. The platform resolves it from the
46
+ client the call arrives on and stamps it onto the row, because a registration
47
+ token is only sendable through the project that minted it and nothing in the
48
+ token itself says which. That resolution is a cached read, so it costs nothing in
49
+ steady state — but it is why `subscribe` is the one method here that can answer
50
+ `503`.
51
+
52
+ ## The device object
53
+
54
+ ```ts
55
+ interface Device {
56
+ id: string;
57
+ platform: 'web' | 'android' | 'ios';
58
+ label: string; // what you sent as device_label; '' when you sent none
59
+ clientId: string | null; // the reseller frontend it registered from
60
+ lastSeenAt: string; // ISO; last successful send, or last subscribe
61
+ createdAt: string;
62
+ }
63
+ ```
64
+
65
+ The **registration token is never returned**. It is a send credential: anything
66
+ holding one can push to that browser through the agency's Firebase project. The
67
+ list exists so a user can recognise and revoke their own devices, which needs the
68
+ label and the last-seen time and nothing more.
69
+
70
+ ---
71
+
72
+ ## subscribe
73
+
74
+ ```ts
75
+ crm.notifications.subscribe(input: SubscribeNotificationsInput): Promise<Device>
76
+ ```
77
+
78
+ `POST /v1/audience/devices` · `devices:write` · **201**
79
+
80
+ ### Payload
81
+
82
+ | Field | Type | Required | Notes |
83
+ | --- | --- | --- | --- |
84
+ | `token` | string | yes | The FCM registration token for this instance, from `getToken()`. 20–4096 characters — a sanity check, not a format; Google publishes no shape for these. Trimmed. |
85
+ | `platform` | `'web' \| 'android' \| 'ios'` | defaulted | `'web'` when the SDK can see a DOM. Required otherwise — see below. |
86
+ | `device_label` | string | no | Up to 200 characters; longer is trimmed, not refused. Defaults to something like `Chrome on Windows`, derived from the user agent. Cosmetic, never matched on. |
87
+ | `tpc_id` | string | no | The reseller client (`tpc_…`) this frontend belongs to. Rarely needed. |
88
+
89
+ What goes on the wire, with everything defaulted:
90
+
91
+ ```http
92
+ POST /v1/audience/devices
93
+ authorization: Bearer <ecosystem token>
94
+ client-id: tpc_8Kd3mXp7qR2wL9zB
95
+ content-type: application/json
96
+
97
+ {
98
+ "token": "fNx8…:APA91bH…",
99
+ "platform": "web",
100
+ "device_label": "Chrome on Windows"
101
+ }
102
+ ```
103
+
104
+ ```ts
105
+ const device = await crm.notifications.subscribe({ token });
106
+ // → { id: '…', platform: 'web', label: 'Chrome on Windows', clientId: 'tpc_…', … }
107
+ ```
108
+
109
+ There is **no user field**, and that is deliberate: the acting user comes from
110
+ the verified token the client was constructed with. A body that could name its
111
+ own user would let any caller point someone else's notifications at their own
112
+ browser.
113
+
114
+ ### Call it on every app load, not once
115
+
116
+ It is an idempotent upsert keyed on the token, so re-sending one the platform
117
+ already holds costs a single indexed update. That is the intended usage, because
118
+ two things change underneath you:
119
+
120
+ - FCM rotates registration tokens on its own schedule, and the old one stops
121
+ being delivered to without anyone being told.
122
+ - A device unseen for **60 days** is pruned, and "seen" means a successful send
123
+ or a call to this method. Someone who receives no notifications for two months
124
+ would otherwise quietly fall off the list.
125
+
126
+ Re-subscribing a token already on file against **another** user moves it rather
127
+ than duplicating it. One browser has one person signed in, and the previous
128
+ owner's notifications have to stop reaching it the moment someone else logs in
129
+ there.
130
+
131
+ ### `platform` off-browser
132
+
133
+ The SDK defaults it from `document`, not from `navigator` — which is the
134
+ distinction that matters. React Native defines a `navigator` and no `document`,
135
+ so a navigator-based guess would register every phone as a browser, and the send
136
+ path would fill in the `webpush` block instead of `android`/`apns`: a
137
+ notification FCM accepts and the device never shows. Outside a browser the SDK
138
+ asks instead of guessing:
139
+
140
+ ```ts
141
+ await crm.notifications.subscribe({ token, platform: 'android' });
142
+ ```
143
+
144
+ ### `tpc_id`
145
+
146
+ Only ever a confirmation. The `clientId` the `CrmClient` already sends is
147
+ verified by the gateway and wins; this field says which reseller frontend is
148
+ registering, which decides whose Firebase project and which deep-link host apply.
149
+ A value that contradicts the verified client is refused with `403` rather than
150
+ ignored — the two disagreeing means either a frontend bug or an attempt to file a
151
+ device under another tenant, and silently preferring one would hide the first and
152
+ partially succeed at the second. The white-label frontend has no client and sends
153
+ nothing.
154
+
155
+ ### Errors
156
+
157
+ | Status | Code | Meaning |
158
+ | --- | --- | --- |
159
+ | 400 | `validation_failed` | Token missing or implausible, or `platform` could not be defaulted. Thrown by the SDK before a request is made. |
160
+ | 403 | `forbidden` | `tpc_id` names a different client than the token was issued for. |
161
+ | 503 | `push_not_configured` | The agency has no Firebase notification resource. Nothing the caller can fix; push is simply off for this account. |
162
+ | 503 | `push_unavailable` | The platform could not read the configuration from core. Transient. |
163
+
164
+ Both `503`s want the same handling as success: try again on the next app load,
165
+ which the advice above means you are doing anyway.
166
+
167
+ ---
168
+
169
+ ## unsubscribe
170
+
171
+ ```ts
172
+ crm.notifications.unsubscribe(token: string): Promise<void>
173
+ ```
174
+
175
+ `POST /v1/audience/devices/unregister` · `devices:write` · **204**
176
+
177
+ ```ts
178
+ await crm.notifications.unsubscribe(token);
179
+ ```
180
+
181
+ ### Payload
182
+
183
+ | Field | Type | Required | Notes |
184
+ | --- | --- | --- | --- |
185
+ | `token` | string | yes | The same registration token. Trimmed. |
186
+
187
+ `204` with no body **whether or not a row went** — you are asking for a
188
+ guarantee about the future, and a token that is already gone provides it. So this
189
+ is safe to call unconditionally on logout without first checking whether the
190
+ device was ever subscribed.
191
+
192
+ Scoped to the caller's own devices. A token identifies a browser rather than a
193
+ person, so by the time one user logs out the row may already belong to whoever
194
+ logged in after them; an unscoped delete would cut off the person now holding it.
195
+
196
+ A `POST` with the token in the body rather than `DELETE /devices/:token`: a
197
+ registration token is hundreds of characters and may contain `:` and `/`, and
198
+ putting one in a path invites proxy length limits and writes a live send
199
+ credential into every access log along the way.
200
+
201
+ ### Also delete it on the device
202
+
203
+ This withdraws the platform's permission to send to that token. If the user is
204
+ turning notifications **off** — rather than logging out — also drop the token in
205
+ the browser, or the next `getToken()` hands back the same one and the
206
+ subscription can come back by accident:
207
+
208
+ ```ts
209
+ await crm.notifications.unsubscribe(token);
210
+ await deleteToken(messaging); // firebase/messaging
211
+ ```
212
+
213
+ ---
214
+
215
+ ## devices
216
+
217
+ ```ts
218
+ crm.notifications.devices(query?: Partial<ListDevicesQuery>): Promise<Paginated<Device>>
219
+ ```
220
+
221
+ `GET /v1/audience/devices` · `devices:read` · **200**
222
+
223
+ ### Query
224
+
225
+ | Field | Type | Default | Notes |
226
+ | --- | --- | --- | --- |
227
+ | `page` | number | `1` | 1-based. |
228
+ | `rows` | number | `25` | 1–100. |
229
+
230
+ Sorted by `lastSeenAt` descending. This is what a "notification settings" screen
231
+ lists.
232
+
233
+ ```ts
234
+ const { data } = await crm.notifications.devices();
235
+ data.map((d) => `${d.label || 'This device'} · last seen ${d.lastSeenAt}`);
236
+ ```
237
+
238
+ No `search`: the endpoint shares the common list query so the server would accept
239
+ one, but the service does not apply it — a device list is one person's handful of
240
+ rows. It is left out of the SDK type rather than accepted and silently dropped.
241
+
242
+ Only ever the acting user's own devices. There is no account-wide view and no
243
+ device id in the path: a device is one person's browser.
244
+
245
+ Worth knowing: a notification is sent to the **20 most recently seen** devices
246
+ of a user. A longer list is itself the answer to "why did my old laptop stop
247
+ buzzing".
248
+
249
+ ### Telling whether *this* browser is subscribed
250
+
251
+ You can't, from here — tokens are not returned, by design. Decide it locally
252
+ instead. The browser knows its own `Notification.permission` and its own token,
253
+ and because `subscribe` is an idempotent upsert, the cheap and correct answer to
254
+ "am I still subscribed?" is to subscribe again:
255
+
256
+ ```ts
257
+ if (Notification.permission === 'granted') {
258
+ await crm.notifications.subscribe({ token: await getToken(messaging, { vapidKey }) });
259
+ }
260
+ ```
261
+
262
+ That also repairs the two cases a stored flag gets wrong: a rotated token, and a
263
+ row pruned for staleness.