@mpgd/game-services 0.33.1 → 0.35.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,229 @@
1
+ import { isValidNotificationTimeZone, NotificationError, notificationKeyIssue, notificationReservedKeyPrefix, } from '@mpgd/platform/notifications';
2
+ /**
3
+ * Bulk sends (notifications v2 RFC, open question 5; BK1): the request and
4
+ * answer types of `NotificationService.deliverBulk`, and the small pieces it
5
+ * is built from. A bulk send is many single sends that share provider calls:
6
+ * each recipient keeps its own key, ledger row, gate check, and answer.
7
+ */
8
+ /** Most recipients one `deliverBulk` call takes. */
9
+ export const maxNotificationBulkRecipients = 10_000;
10
+ /** The send key of one recipient of a bulk send. */
11
+ export function notificationBulkRecipientKey(idempotencyKey, player) {
12
+ return `${idempotencyKey}:${encodeURIComponent(player)}`;
13
+ }
14
+ /**
15
+ * A well-formed bulk request, or `NOTIFICATIONS_INVALID_INPUT` for the whole
16
+ * call. Each recipient's own request (its template data, data, locale, and
17
+ * link, and its derived key) is checked later as a single send's is, and
18
+ * fails that recipient alone.
19
+ */
20
+ export function parseNotificationBulkRequest(raw) {
21
+ if (!isPlainObject(raw)) {
22
+ throw invalid('A bulk send is an object.');
23
+ }
24
+ const known = new Set([
25
+ 'idempotencyKey',
26
+ 'provider',
27
+ 'topic',
28
+ 'recipients',
29
+ 'templateData',
30
+ 'data',
31
+ 'locale',
32
+ 'link',
33
+ 'batchSize',
34
+ ]);
35
+ const unknown = Object.keys(raw).find((key) => !known.has(key));
36
+ if (unknown !== undefined) {
37
+ throw invalid(`Invalid bulk send: ${unknown}: not a bulk send field.`);
38
+ }
39
+ if (typeof raw.idempotencyKey !== 'string') {
40
+ throw invalid('Invalid bulk send: idempotencyKey: expected a string.');
41
+ }
42
+ const keyIssue = notificationKeyIssue(raw.idempotencyKey);
43
+ if (keyIssue !== undefined) {
44
+ throw invalid(`Invalid bulk send: idempotencyKey: ${keyIssue}.`);
45
+ }
46
+ const reserved = notificationReservedKeyPrefix(raw.idempotencyKey);
47
+ if (reserved !== undefined) {
48
+ throw invalid(`Invalid bulk send: idempotencyKey: the ${reserved} prefix is reserved for the scheduler.`);
49
+ }
50
+ for (const name of ['provider', 'topic']) {
51
+ if (typeof raw[name] !== 'string' || raw[name].length === 0 || raw[name].length > 2048) {
52
+ throw invalid(`Invalid bulk send: ${name}: expected a non-empty string.`);
53
+ }
54
+ }
55
+ if (raw.batchSize !== undefined
56
+ && !(typeof raw.batchSize === 'number' && Number.isSafeInteger(raw.batchSize) && raw.batchSize >= 1)) {
57
+ throw invalid('Invalid bulk send: batchSize: expected a positive integer.');
58
+ }
59
+ const recipients = raw.recipients;
60
+ if (!Array.isArray(recipients) || recipients.length === 0 || recipients.length > maxNotificationBulkRecipients) {
61
+ throw invalid(`Invalid bulk send: recipients: expected 1 to ${maxNotificationBulkRecipients} recipients.`);
62
+ }
63
+ const recipientFields = new Set(['player', 'templateData', 'data', 'locale', 'link']);
64
+ const players = new Set();
65
+ const keys = [];
66
+ for (const [index, recipient] of recipients.entries()) {
67
+ if (!isPlainObject(recipient)) {
68
+ throw invalid(`Invalid bulk send: recipients[${index}]: expected an object.`);
69
+ }
70
+ const field = Object.keys(recipient).find((key) => !recipientFields.has(key));
71
+ if (field !== undefined) {
72
+ throw invalid(`Invalid bulk send: recipients[${index}].${field}: not a recipient field.`);
73
+ }
74
+ const player = recipient.player;
75
+ if (typeof player !== 'string' || player.length === 0 || player.length > 2048) {
76
+ throw invalid(`Invalid bulk send: recipients[${index}].player: expected a non-empty string.`);
77
+ }
78
+ if (players.has(player)) {
79
+ // One send per player: a second would be a second key for the same push.
80
+ throw invalid(`Invalid bulk send: recipients[${index}].player: a player is named once.`);
81
+ }
82
+ players.add(player);
83
+ try {
84
+ keys.push(notificationBulkRecipientKey(raw.idempotencyKey, player));
85
+ }
86
+ catch {
87
+ // `encodeURIComponent` refuses a lone surrogate, which no key may hold.
88
+ throw invalid(`Invalid bulk send: recipients[${index}].player: holds a lone surrogate.`);
89
+ }
90
+ }
91
+ return { input: raw, keys };
92
+ }
93
+ /**
94
+ * A provider's bulk capability as the registry accepts it: whole numbers,
95
+ * a minimum no larger than the maximum, and a hold window of two `HH:MM`
96
+ * times in a valid time zone. Throws a `TypeError` naming the issue.
97
+ */
98
+ export function assertNotificationBulkCapability(capability, at) {
99
+ const { maxRecipients, minRecipients, perRecipientOutcomes, holdWindow } = capability;
100
+ if (!Number.isSafeInteger(maxRecipients) || maxRecipients < 1) {
101
+ throw new TypeError(`${at}.bulk.maxRecipients must be a positive integer.`);
102
+ }
103
+ if (minRecipients !== undefined
104
+ && (!Number.isSafeInteger(minRecipients) || minRecipients < 1 || minRecipients > maxRecipients)) {
105
+ throw new TypeError(`${at}.bulk.minRecipients must be a positive integer no larger than maxRecipients.`);
106
+ }
107
+ if (typeof perRecipientOutcomes !== 'boolean') {
108
+ throw new TypeError(`${at}.bulk.perRecipientOutcomes must be a boolean.`);
109
+ }
110
+ if (holdWindow !== undefined) {
111
+ if (minutesOf(holdWindow.start) === undefined
112
+ || minutesOf(holdWindow.end) === undefined
113
+ || holdWindow.start === holdWindow.end
114
+ || !isValidNotificationTimeZone(holdWindow.timeZone)) {
115
+ throw new TypeError(`${at}.bulk.holdWindow needs two different HH:MM times and a valid time zone.`);
116
+ }
117
+ }
118
+ }
119
+ /** Whether `at` (epoch milliseconds) falls inside a provider's hold window. */
120
+ export function isInsideNotificationHoldWindow(window, at) {
121
+ if (window === undefined) {
122
+ return false;
123
+ }
124
+ const start = minutesOf(window.start);
125
+ const end = minutesOf(window.end);
126
+ if (start === undefined || end === undefined) {
127
+ return false;
128
+ }
129
+ const parts = new Intl.DateTimeFormat('en-GB', {
130
+ timeZone: window.timeZone,
131
+ hour: '2-digit',
132
+ minute: '2-digit',
133
+ hourCycle: 'h23',
134
+ }).formatToParts(new Date(at));
135
+ const hour = Number(parts.find((part) => part.type === 'hour')?.value);
136
+ const minute = Number(parts.find((part) => part.type === 'minute')?.value);
137
+ const now = hour * 60 + minute;
138
+ return start < end ? now >= start && now < end : now >= start || now < end;
139
+ }
140
+ function minutesOf(time) {
141
+ const match = /^([01]\d|2[0-3]):([0-5]\d)$/u.exec(time);
142
+ return match === null ? undefined : Number(match[1]) * 60 + Number(match[2]);
143
+ }
144
+ /** A counting semaphore: at most `concurrency` holders at once, served in order. */
145
+ export function createNotificationLimiter(concurrency) {
146
+ let active = 0;
147
+ const waiting = [];
148
+ return {
149
+ acquire() {
150
+ if (active < concurrency) {
151
+ active += 1;
152
+ return Promise.resolve();
153
+ }
154
+ return new Promise((resolve) => {
155
+ waiting.push(resolve);
156
+ });
157
+ },
158
+ release() {
159
+ const next = waiting.shift();
160
+ if (next === undefined) {
161
+ active -= 1;
162
+ }
163
+ else {
164
+ // The slot passes to the next waiter.
165
+ next();
166
+ }
167
+ },
168
+ };
169
+ }
170
+ /**
171
+ * Gathers the provider calls of a known number of participants into one
172
+ * flush. Each participant either arrives with its item, and waits for its
173
+ * answer, or leaves without one; once every participant did one or the
174
+ * other, `flush` runs once with the arrived items and answers each in
175
+ * order. A participant that holds a concurrency slot gives it up before it
176
+ * waits (see `deliverBulk`), so the barrier never waits on a participant
177
+ * that cannot run.
178
+ */
179
+ export function createNotificationBatchBarrier(participants, flush) {
180
+ const arrived = [];
181
+ let left = 0;
182
+ let flushed = false;
183
+ const maybeFlush = () => {
184
+ if (flushed || arrived.length + left < participants) {
185
+ return;
186
+ }
187
+ flushed = true;
188
+ if (arrived.length === 0) {
189
+ return;
190
+ }
191
+ const items = arrived.map((entry) => entry.item);
192
+ flush(items).then((answers) => {
193
+ arrived.forEach((entry, index) => {
194
+ const answer = answers[index];
195
+ if (answer === undefined) {
196
+ entry.reject(new TypeError('The batch flush answered fewer items than it took.'));
197
+ }
198
+ else {
199
+ entry.resolve(answer);
200
+ }
201
+ });
202
+ }, (error) => {
203
+ for (const entry of arrived) {
204
+ entry.reject(error);
205
+ }
206
+ });
207
+ };
208
+ return {
209
+ arrive(item) {
210
+ if (flushed) {
211
+ return Promise.reject(new TypeError('The batch was already flushed.'));
212
+ }
213
+ return new Promise((resolve, reject) => {
214
+ arrived.push({ item, resolve, reject });
215
+ maybeFlush();
216
+ });
217
+ },
218
+ leave() {
219
+ left += 1;
220
+ maybeFlush();
221
+ },
222
+ };
223
+ }
224
+ function invalid(message) {
225
+ return new NotificationError('NOTIFICATIONS_INVALID_INPUT', { message });
226
+ }
227
+ function isPlainObject(value) {
228
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
229
+ }
@@ -96,18 +96,36 @@ export interface CreateGameServicesNotificationsClientInput {
96
96
  }
97
97
  /** The default `timeoutMs`: a request still unanswered after this is aborted. */
98
98
  export declare const defaultGameServicesNotificationsTimeoutMs = 15000;
99
+ /** A provider entry of the registry this client could not read, and why. */
100
+ export interface GameServicesNotificationUnreadableProvider {
101
+ /** The entry's `provider` id, when the entry has a readable non-empty one. */
102
+ readonly provider?: string;
103
+ /** Why the entry was left out (the validator's text, at most 512 characters). */
104
+ readonly issue: string;
105
+ }
99
106
  /** The registry the Worker publishes at `GET /providers`. */
100
107
  export interface GameServicesNotificationProviders {
101
108
  readonly protocolVersion: string;
109
+ /** The provider entries this client can read. */
102
110
  readonly providers: readonly NotificationDeliveryProviderEntry[];
103
111
  /** Each served target and the provider id that delivers to it. */
104
112
  readonly targets: Readonly<Record<string, string>>;
113
+ /**
114
+ * The provider entries left out of `providers` because this client cannot
115
+ * read them, in registry order; absent when there are none.
116
+ */
117
+ readonly unreadable?: readonly GameServicesNotificationUnreadableProvider[];
105
118
  }
106
119
  export interface GameServicesNotificationsClient extends NotificationRuntimeBackend {
107
120
  /**
108
121
  * The registry the Worker publishes (`GET /providers`, no session needed):
109
122
  * pass `providers.find((entry) => entry.provider === provider.provider)` as
110
- * the runtime's `registryEntry`.
123
+ * the runtime's `registryEntry`. A provider entry this client cannot read
124
+ * (malformed, or shaped by a Worker this client does not know) is left out
125
+ * of `providers`, so the other entries still load, and listed in
126
+ * `unreadable` with its id when readable. A target whose provider is
127
+ * listed there (or any unreadable entry without an id) should be reported,
128
+ * not read as a target the Worker does not serve.
111
129
  */
112
130
  getProviders(): Promise<GameServicesNotificationProviders>;
113
131
  }
@@ -1,5 +1,6 @@
1
1
  import { assertNotificationConsentRecord, assertNotificationDeliveryProviderEntry, assertNotificationNotice, isNotificationErrorCode, NotificationError, } from '@mpgd/platform/notifications';
2
2
  import { GameServicesHeaderResolutionError, resolveGameServicesRequestHeaders, } from '../request-headers.js';
3
+ import { describeError } from './text.js';
3
4
  /**
4
5
  * The game-services notifications client: the notifications runtime's
5
6
  * backend (`NotificationRuntimeBackend` from
@@ -342,14 +343,35 @@ export function createGameServicesNotificationsClient(input) {
342
343
  || !Object.values(targets).every((provider) => typeof provider === 'string')) {
343
344
  throw contractBroken('/providers', 200, 'the registry is not { protocolVersion, providers, targets }');
344
345
  }
346
+ // One entry the client cannot read does not break the others: it is
347
+ // left out of `providers` and listed in `unreadable`.
348
+ const readable = [];
349
+ const unreadable = [];
350
+ for (const entry of providers) {
351
+ try {
352
+ readable.push(assertNotificationDeliveryProviderEntry(entry));
353
+ }
354
+ catch (error) {
355
+ const id = typeof entry === 'object' && entry !== null && !Array.isArray(entry)
356
+ ? entry['provider']
357
+ : undefined;
358
+ unreadable.push({
359
+ ...(typeof id === 'string' && id.length > 0 ? { provider: id } : {}),
360
+ issue: describeError(error).slice(0, unreadableIssueLength),
361
+ });
362
+ }
363
+ }
345
364
  return {
346
365
  protocolVersion,
347
- providers: providers.map((entry) => checked('/providers', () => assertNotificationDeliveryProviderEntry(entry))),
366
+ providers: readable,
348
367
  targets: { ...targets },
368
+ ...(unreadable.length === 0 ? {} : { unreadable }),
349
369
  };
350
370
  },
351
371
  };
352
372
  }
373
+ /** The longest `issue` an unreadable registry entry carries. */
374
+ const unreadableIssueLength = 512;
353
375
  function globalFetch() {
354
376
  const fetcher = globalThis.fetch;
355
377
  if (fetcher === undefined) {
@@ -386,7 +408,7 @@ function checked(route, read) {
386
408
  return read();
387
409
  }
388
410
  catch (error) {
389
- throw contractBroken(route, 200, error instanceof Error ? error.message : String(error));
411
+ throw contractBroken(route, 200, describeError(error));
390
412
  }
391
413
  }
392
414
  function errorOf(route, response, body) {
@@ -0,0 +1,87 @@
1
+ import { type NotificationNoticeVariant, type NotificationResolvedCopy, type NotificationTemplateData, type NotificationTopicCopy, type NotificationTopicDeclaration } from '@mpgd/platform/notifications';
2
+ /**
3
+ * Server-side copy for remote sends (notifications v2 RFC, "Remote copy",
4
+ * open question 4). A send's title and body come from a copy resolver: by
5
+ * default the catalog's `copy` for the recipient's locale
6
+ * (`resolveCatalogNotificationCopy`), or a project's own resolver passed to
7
+ * `createNotificationService` as `resolveCopy`. The service asks the
8
+ * resolver only where the content comes from copy
9
+ * (`notificationContentSource` is `catalog`): a target mapping's own title
10
+ * and body (Devvit) and a host template (Apps in Toss) never reach it.
11
+ *
12
+ * The text never comes from the request: a send or a reminder carries no
13
+ * title or body, only template data for the `{{name}}` placeholders of the
14
+ * text the resolver answers. The policy gate then renders the answer exactly
15
+ * as it renders catalog copy, so under the Korean rules an advertising send
16
+ * still gets the `(광고)` label, the sender line, the opt-out line, and the
17
+ * opt-out action.
18
+ */
19
+ /** What a copy resolver is asked: one topic, for one recipient's locale, on one provider. */
20
+ export interface NotificationCopyResolverInput {
21
+ /**
22
+ * The topic as declared for the recipient's target, with its mapping and
23
+ * its catalog `copy`; for a consent notice, the notice topic with the
24
+ * variant's `notices.copy` as its `copy`.
25
+ */
26
+ readonly topic: NotificationTopicDeclaration;
27
+ /** The provider the send goes through. */
28
+ readonly provider: string;
29
+ /**
30
+ * The send's locale, else the recipient's registered locale; absent when
31
+ * neither names one. A resolver falls back from it as the catalog lookup
32
+ * does: the exact locale, then its language, then `defaultLocale`.
33
+ */
34
+ readonly locale?: string;
35
+ /** The catalog's `defaultLocale`. */
36
+ readonly defaultLocale: string;
37
+ /**
38
+ * The send's template data, which fills the answer's placeholders after
39
+ * the resolver answers. It may come from a player's reminder: a resolver
40
+ * must not copy it into the text it answers.
41
+ */
42
+ readonly templateData?: NotificationTemplateData;
43
+ /** Set for a consent notice the scheduler pushes: the variant whose text it sends. */
44
+ readonly notice?: {
45
+ readonly variant: NotificationNoticeVariant;
46
+ };
47
+ }
48
+ /**
49
+ * Chooses the title and body templates of one send for one locale, or
50
+ * answers `undefined` when it has none: the send is then skipped as
51
+ * `unavailable: copy-missing`, never sent with empty text. The answer's
52
+ * `{{name}}` placeholders are filled from the send's template data, as
53
+ * catalog copy is. A resolver that throws fails the attempt, and the send
54
+ * is retried: a `NotificationError` keeps its own code, and any other throw
55
+ * is a copy outage (`NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`).
56
+ */
57
+ export type NotificationCopyResolver = (input: NotificationCopyResolverInput) => NotificationTopicCopy | undefined | Promise<NotificationTopicCopy | undefined>;
58
+ /**
59
+ * The default resolver: the topic's catalog `copy` for the locale, then its
60
+ * language (`en` for `en-US`), then the catalog's `defaultLocale`, each
61
+ * matched without regard to case (`findNotificationCopy`). A project's
62
+ * resolver can fall back to it for the topics or locales it does not cover:
63
+ * `(input) => mine(input) ?? resolveCatalogNotificationCopy(input)`.
64
+ */
65
+ export declare const resolveCatalogNotificationCopy: NotificationCopyResolver;
66
+ /** A resolver's answer as a send uses it, with why an answer was not usable. */
67
+ export interface NotificationCopyResolution extends NotificationResolvedCopy {
68
+ /** Set when the resolver answered something that cannot be sent; `copy` is then absent. */
69
+ readonly issue?: string;
70
+ }
71
+ /**
72
+ * Asks `resolver` once and checks the answer: a title and a body that are
73
+ * strings and not blank, and for a consent notice the notice text rules
74
+ * (`notificationNoticeTextIssues`: only `{{consentDate}}`, which one of them
75
+ * uses). An answer that fails is no copy, with the reason in `issue`. The
76
+ * result's `copy` is what the gate takes as `resolvedCopy`.
77
+ */
78
+ export declare function resolveNotificationCopy(resolver: NotificationCopyResolver, input: NotificationCopyResolverInput): Promise<NotificationCopyResolution>;
79
+ /**
80
+ * Resolves one topic for several locales, asking `resolver` once per
81
+ * distinct locale (an absent locale counts as one): a bulk send groups its
82
+ * recipients by locale and sends each group the copy resolved for it. The
83
+ * map is keyed by the locales as given.
84
+ */
85
+ export declare function resolveNotificationCopyByLocale(resolver: NotificationCopyResolver, input: Omit<NotificationCopyResolverInput, 'locale'> & {
86
+ readonly locales: Iterable<string | undefined>;
87
+ }): Promise<Map<string | undefined, NotificationCopyResolution>>;
@@ -0,0 +1,70 @@
1
+ import { findNotificationCopy, notificationNoticeTextIssues, } from '@mpgd/platform/notifications';
2
+ /**
3
+ * The default resolver: the topic's catalog `copy` for the locale, then its
4
+ * language (`en` for `en-US`), then the catalog's `defaultLocale`, each
5
+ * matched without regard to case (`findNotificationCopy`). A project's
6
+ * resolver can fall back to it for the topics or locales it does not cover:
7
+ * `(input) => mine(input) ?? resolveCatalogNotificationCopy(input)`.
8
+ */
9
+ export const resolveCatalogNotificationCopy = (input) => findNotificationCopy(input.topic, input.locale, input.defaultLocale);
10
+ /**
11
+ * Asks `resolver` once and checks the answer: a title and a body that are
12
+ * strings and not blank, and for a consent notice the notice text rules
13
+ * (`notificationNoticeTextIssues`: only `{{consentDate}}`, which one of them
14
+ * uses). An answer that fails is no copy, with the reason in `issue`. The
15
+ * result's `copy` is what the gate takes as `resolvedCopy`.
16
+ */
17
+ export async function resolveNotificationCopy(resolver, input) {
18
+ const answer = await resolver(input);
19
+ if (answer === undefined || answer === null) {
20
+ return {};
21
+ }
22
+ const issue = copyIssue(answer, input);
23
+ if (issue !== undefined) {
24
+ return { issue };
25
+ }
26
+ const { title, body } = answer;
27
+ return { copy: { title, body } };
28
+ }
29
+ /**
30
+ * Resolves one topic for several locales, asking `resolver` once per
31
+ * distinct locale (an absent locale counts as one): a bulk send groups its
32
+ * recipients by locale and sends each group the copy resolved for it. The
33
+ * map is keyed by the locales as given.
34
+ */
35
+ export async function resolveNotificationCopyByLocale(resolver, input) {
36
+ const { locales, ...shared } = input;
37
+ const resolved = new Map();
38
+ for (const locale of locales) {
39
+ if (!resolved.has(locale)) {
40
+ resolved.set(locale, await resolveNotificationCopy(resolver, {
41
+ ...shared,
42
+ ...(locale === undefined ? {} : { locale }),
43
+ }));
44
+ }
45
+ }
46
+ return resolved;
47
+ }
48
+ function copyIssue(answer, input) {
49
+ if (typeof answer !== 'object' || answer === null || Array.isArray(answer)) {
50
+ return 'the copy resolver answered something other than a title and a body';
51
+ }
52
+ const { title, body } = answer;
53
+ if (typeof title !== 'string' || typeof body !== 'string') {
54
+ return 'the copy resolver answered no string title and body';
55
+ }
56
+ if (title.trim().length === 0 || body.trim().length === 0) {
57
+ return 'the copy resolver answered a blank title or body';
58
+ }
59
+ if (input.notice !== undefined) {
60
+ // Every notice variant follows the same text rules (`notificationNoticeCopyIssues`
61
+ // applies them to each one); the path names the answer as notices.copy would.
62
+ const issues = notificationNoticeTextIssues({ title, body }, `notices.copy[${JSON.stringify(input.locale ?? input.defaultLocale)}][${JSON.stringify(input.notice.variant)}]`);
63
+ if (issues.length > 0) {
64
+ return `the copy resolver's notice text breaks the notice rules: ${issues
65
+ .map((issue) => `${issue.path}: ${issue.message}`)
66
+ .join('; ')}`;
67
+ }
68
+ }
69
+ return undefined;
70
+ }
@@ -89,10 +89,17 @@ export interface NotificationRemoteDeliveryRequest {
89
89
  * answer). The claim is kept until its lease ends.
90
90
  *
91
91
  * A provider that throws is treated as `ambiguous`.
92
+ *
93
+ * A batch may also answer `not-sent` for a request it made no call for (its
94
+ * calls ran past `startWithinMs`): the row goes back as it was, due again
95
+ * shortly, without a rate-limit backoff or the 24-hour give-up, since no
96
+ * provider refused anything.
92
97
  */
93
98
  export type NotificationRemoteDeliveryAnswer = {
94
99
  readonly status: 'accepted';
95
100
  readonly providerMessageId?: string;
101
+ } | {
102
+ readonly status: 'not-sent';
96
103
  } | {
97
104
  readonly status: 'unregistered';
98
105
  } | {
@@ -106,8 +113,69 @@ export type NotificationRemoteDeliveryAnswer = {
106
113
  } | {
107
114
  readonly status: 'ambiguous';
108
115
  readonly providerCode?: string;
116
+ /**
117
+ * A bulk call that reached some of its recipients and did not say which
118
+ * (BK1). A replay would reach those recipients again, so the delivery is
119
+ * not replayed: it stays in flight until its key's retention ends, and
120
+ * the backend reports it. Only a batch answer sets it.
121
+ */
122
+ readonly unattributed?: true;
109
123
  };
110
124
  /**
125
+ * A provider's bulk path (RFC open question 5, BK1): one provider call for
126
+ * several recipients of one topic, each with its own ledger key and answer.
127
+ * A provider without it sends one recipient per call.
128
+ */
129
+ export interface NotificationRemoteBulkCapability {
130
+ /** The most recipients one `sendBatch` call takes. */
131
+ readonly maxRecipients: number;
132
+ /**
133
+ * The fewest recipients the bulk API is meant for; a smaller batch goes
134
+ * one recipient per `send` call. 2 by default.
135
+ */
136
+ readonly minRecipients?: number;
137
+ /**
138
+ * Whether the provider's answer names each recipient. `false` means it
139
+ * answers the batch as a whole (counts only): the provider then gives
140
+ * every recipient the same answer, `accepted` only when the counts prove
141
+ * each one, and `ambiguous` with `unattributed` when they prove only that
142
+ * some were reached.
143
+ */
144
+ readonly perRecipientOutcomes: boolean;
145
+ /**
146
+ * A daily window, in a time zone, in which the host holds bulk messages
147
+ * until it ends (Apps in Toss holds `send-bulk-message` from 21:00 to
148
+ * 08:00). The service sends one recipient per call inside it, so the
149
+ * gate's timing stands.
150
+ */
151
+ /**
152
+ * The provider's call group of a request: requests with different keys
153
+ * never share a call (Devvit takes one title and body a call). The
154
+ * service makes one `sendBatch` per group and runs the gate again for
155
+ * each request just before its group's call, so a later call never sends
156
+ * on a stale gate check. One group when absent.
157
+ */
158
+ callKey?(request: NotificationRemoteDeliveryRequest): string;
159
+ readonly holdWindow?: {
160
+ /** `HH:MM`, inclusive. */
161
+ readonly start: string;
162
+ /** `HH:MM`, exclusive; earlier than `start` when the window spans midnight. */
163
+ readonly end: string;
164
+ /** An IANA time zone. */
165
+ readonly timeZone: string;
166
+ };
167
+ }
168
+ /** How long a provider may keep starting calls for one `sendBatch`. */
169
+ export interface NotificationRemoteBatchOptions {
170
+ /**
171
+ * Milliseconds from the start of `sendBatch` after which the provider
172
+ * starts no further call (one batch may take several, such as one per
173
+ * text): the requests' claims must outlive the calls. A request it did not
174
+ * start answers `not-sent`.
175
+ */
176
+ readonly startWithinMs?: number;
177
+ }
178
+ /**
111
179
  * The provider code of a `rejected` answer for a message that would arrive
112
180
  * after its expiry: nothing was sent, and it is routine, not a setup error.
113
181
  */
@@ -117,6 +185,17 @@ export interface NotificationRemoteDeliveryProvider {
117
185
  /** Equals the registry entry's `provider`. */
118
186
  readonly id: NotificationProviderId;
119
187
  send(request: NotificationRemoteDeliveryRequest): Promise<NotificationRemoteDeliveryAnswer>;
188
+ /** The bulk path, declared with `sendBatch`; without it every recipient is one `send`. */
189
+ readonly bulk?: NotificationRemoteBulkCapability;
190
+ /**
191
+ * One provider call for `requests` (at most `bulk.maxRecipients`, each a
192
+ * different recipient of the same send kind): the answers in the order of
193
+ * the requests, one per request. The service runs the gate for each
194
+ * request before the call and settles each answer on that request's own
195
+ * ledger key; an answer that is missing or not one of the five kinds is
196
+ * `ambiguous`, as a throw is for every request.
197
+ */
198
+ sendBatch?(requests: readonly NotificationRemoteDeliveryRequest[], options?: NotificationRemoteBatchOptions): Promise<readonly NotificationRemoteDeliveryAnswer[]>;
120
199
  /**
121
200
  * The host's permission at send time, for a provider whose registry entry
122
201
  * is `serverReadable` (Devvit `isOptedIn`). The gate requires it there.
@@ -3,8 +3,11 @@
3
3
  * "Server"): the provider registry, the endpoint registry, consent records
4
4
  * and the notices API, sends through the policy gate, server reminders and
5
5
  * the scheduler (consent notice pushes, paced due rows), and the remote
6
- * delivery provider contract, with the FCM provider.
6
+ * delivery provider contract, with the FCM provider, and the copy resolver
7
+ * port that chooses a send's localized title and body.
7
8
  */
9
+ export * from './bulk.js';
10
+ export * from './copy.js';
8
11
  export * from './delivery-provider.js';
9
12
  export * from './fcm.js';
10
13
  export * from './registry.js';
@@ -3,8 +3,11 @@
3
3
  * "Server"): the provider registry, the endpoint registry, consent records
4
4
  * and the notices API, sends through the policy gate, server reminders and
5
5
  * the scheduler (consent notice pushes, paced due rows), and the remote
6
- * delivery provider contract, with the FCM provider.
6
+ * delivery provider contract, with the FCM provider, and the copy resolver
7
+ * port that chooses a send's localized title and body.
7
8
  */
9
+ export * from './bulk.js';
10
+ export * from './copy.js';
8
11
  export * from './delivery-provider.js';
9
12
  export * from './fcm.js';
10
13
  export * from './registry.js';
@@ -1,5 +1,6 @@
1
1
  import { assertNotificationTopicCatalog, resolveNotificationTopicsForTarget, } from '@mpgd/catalog';
2
2
  import { assertNotificationDeliveryProviderEntry, assertNotificationDeployment, referenceNotificationTargetProviders, } from '@mpgd/platform/notifications';
3
+ import { assertNotificationBulkCapability } from './bulk.js';
3
4
  import { describeError as describe } from './text.js';
4
5
  /**
5
6
  * The server-side provider registry (notifications v2 RFC, "Provider
@@ -50,6 +51,13 @@ export function createNotificationProviderRegistry(registrations) {
50
51
  if (entry.permission.serverReadable && registration.delivery.readPermission === undefined) {
51
52
  throw new TypeError(`${at}: provider ${entry.provider} is server-readable but its delivery provider has no readPermission.`);
52
53
  }
54
+ const { bulk, sendBatch } = registration.delivery;
55
+ if ((bulk === undefined) !== (sendBatch === undefined)) {
56
+ throw new TypeError(`${at}: provider ${entry.provider} declares a bulk capability and sendBatch together, or neither.`);
57
+ }
58
+ if (bulk !== undefined) {
59
+ assertNotificationBulkCapability(bulk, at);
60
+ }
53
61
  for (const limit of entry.limits ?? []) {
54
62
  if (limit.windowSeconds > maxHistoryWindowSeconds) {
55
63
  throw new TypeError(`${at}: a limit window of ${limit.windowSeconds} seconds is longer than the ${notificationHistoryRetentionDays}-day delivery history.`);