@mpgd/platform 0.22.0 → 0.23.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,1658 @@
1
+ import { normalizeRootRelativeRoute } from './routes.js';
2
+ /**
3
+ * Whether a topic's mapping declares that the host needs no agreement for it
4
+ * (an Apps in Toss `service` template without an agreement statement): it
5
+ * reads `granted`, never prompts, is never recorded, and passes gate step 3.
6
+ * A provider's answer that the recipient has not agreed is still recorded,
7
+ * but it does not stop sends, because nothing could ask the player again:
8
+ * the host decides each send.
9
+ */
10
+ export function notificationTopicNeedsNoAgreement(topic) {
11
+ return topic.mapping?.['agreement'] === 'none';
12
+ }
13
+ /** The longest route a topic or a local reminder may name. */
14
+ const maxNotificationRouteLength = 2048;
15
+ /**
16
+ * Whether `route` is a root-relative route on the game's origin, at most 2048
17
+ * characters, and already in the form `normalizeRootRelativeRoute` from
18
+ * `@mpgd/platform/routes` returns. That is the rule share links and v1
19
+ * notification deep links follow, so neither a URL parser nor a client that
20
+ * percent-decodes the path once reads the route as another host
21
+ * (`//evil.example/x`, `/%2e//evil.example/x`, `/%2F%2Fevil.example/x`).
22
+ * A route with another normalized form is refused rather than rewritten, so
23
+ * the route a topic declares is the route a player opens: dot segments
24
+ * (`/a/../b`), a backslash in the path, an empty `?` or `#`, and characters a
25
+ * URL serializes percent-encoded (`/é`, `/a b`) all change the form.
26
+ */
27
+ export function isRootRelativeNotificationRoute(route) {
28
+ return route.length <= maxNotificationRouteLength
29
+ && normalizeRootRelativeRoute(route) === route;
30
+ }
31
+ /**
32
+ * The error for a route `isRootRelativeNotificationRoute` refuses, about
33
+ * `subject` (`The local reminder route`), or `undefined` when it passes. A
34
+ * route with another normalized form names that form, so the caller can
35
+ * write it instead.
36
+ */
37
+ export function notificationRouteError(subject, route) {
38
+ if (isRootRelativeNotificationRoute(route)) {
39
+ return undefined;
40
+ }
41
+ const normalized = route.length <= maxNotificationRouteLength
42
+ ? normalizeRootRelativeRoute(route)
43
+ : undefined;
44
+ return normalized === undefined
45
+ ? `${subject} is not root-relative: ${JSON.stringify(route)}`
46
+ : `${subject} is not in normalized form: ${JSON.stringify(route)}; write ${JSON.stringify(normalized)}`;
47
+ }
48
+ /**
49
+ * The rules a topic definition must meet on its own (RFC "Topic catalog"):
50
+ * a trimmed id that is not `__proto__`, channels that do not repeat, no
51
+ * `local` channel for advertising, root-relative `route` and `optOutRoute`, an
52
+ * `optOutRoute` for advertising, and quiet hours that do not start and end at
53
+ * the same time. `path` prefixes each issue's path.
54
+ */
55
+ export function notificationTopicDefinitionIssues(topic, path = 'topic') {
56
+ const issues = [];
57
+ if (topic.id.trim() !== topic.id || hasControlCharacter(topic.id)) {
58
+ issues.push({
59
+ path: `${path}.id`,
60
+ message: 'topic ids must be trimmed, without control characters',
61
+ });
62
+ }
63
+ if (topic.id === '__proto__') {
64
+ // Topic ids key plain objects (snapshots, outcomes), where this one cannot be an own key.
65
+ issues.push({ path: `${path}.id`, message: 'the topic id __proto__ is reserved' });
66
+ }
67
+ if (new Set(topic.channels).size !== topic.channels.length) {
68
+ issues.push({ path: `${path}.channels`, message: 'channels must not repeat' });
69
+ }
70
+ if (topic.purpose === 'advertising' && topic.channels.includes('local')) {
71
+ issues.push({
72
+ path: `${path}.channels`,
73
+ message: 'advertising topics may not use the local channel: the server could not withdraw, label, or hold a device-scheduled advertisement',
74
+ });
75
+ }
76
+ if (!isRootRelativeNotificationRoute(topic.route)) {
77
+ issues.push({ path: `${path}.route`, message: rootRelativeMessage(topic.route) });
78
+ }
79
+ if (topic.optOutRoute !== undefined && !isRootRelativeNotificationRoute(topic.optOutRoute)) {
80
+ issues.push({ path: `${path}.optOutRoute`, message: rootRelativeMessage(topic.optOutRoute) });
81
+ }
82
+ if (topic.purpose === 'advertising' && topic.optOutRoute === undefined) {
83
+ issues.push({
84
+ path: `${path}.optOutRoute`,
85
+ message: 'advertising topics need an optOutRoute, the screen where the player withdraws advertising consent',
86
+ });
87
+ }
88
+ if (topic.quietHours !== undefined && topic.quietHours.start === topic.quietHours.end) {
89
+ issues.push({
90
+ path: `${path}.quietHours`,
91
+ message: 'quiet hours must not start and end at the same time',
92
+ });
93
+ }
94
+ return issues;
95
+ }
96
+ function rootRelativeMessage(route) {
97
+ const normalized = route.length <= maxNotificationRouteLength
98
+ ? normalizeRootRelativeRoute(route)
99
+ : undefined;
100
+ return normalized === undefined
101
+ ? `${JSON.stringify(route)} must be a root-relative path on the game's origin, such as /?entry=continue`
102
+ : `${JSON.stringify(route)} must be written in its normalized form, ${JSON.stringify(normalized)}`;
103
+ }
104
+ function hasControlCharacter(value) {
105
+ return /[\u0000-\u001f\u007f]/u.test(value);
106
+ }
107
+ export const recordableNotificationPermissionStates = Object.freeze([
108
+ 'granted',
109
+ 'prompt-with-rationale',
110
+ 'denied',
111
+ 'blocked',
112
+ ]);
113
+ /** Whether the runtime records this state: a decided state, never prompt, unknown, action-required, or unsupported. */
114
+ export function isRecordableNotificationPermissionState(state) {
115
+ return recordableNotificationPermissionStates.includes(state);
116
+ }
117
+ /** The consent kind that holds delivery permission for a permission scope. */
118
+ export function notificationPermissionConsentKind(scope) {
119
+ return scope === 'app' ? 'delivery-permission' : 'host-agreement';
120
+ }
121
+ /** A topic's state in a snapshot: its own entry, else the app state, else `unknown`. */
122
+ export function notificationPermissionStateOf(snapshot, topic) {
123
+ return Object.prototype.hasOwnProperty.call(snapshot.topics, topic)
124
+ ? (snapshot.topics[topic] ?? snapshot.app ?? 'unknown')
125
+ : (snapshot.app ?? 'unknown');
126
+ }
127
+ /**
128
+ * The latest record of `kind` for exactly `topic` in `records` (oldest first):
129
+ * with `topic`, only records naming that topic match; without it, only
130
+ * records without a topic (app-scope permission, advertising, night) match.
131
+ */
132
+ export function latestNotificationConsent(records, kind, topic) {
133
+ for (let index = records.length - 1; index >= 0; index -= 1) {
134
+ const record = records[index];
135
+ if (record !== undefined && record.kind === kind && record.topic === topic) {
136
+ return record;
137
+ }
138
+ }
139
+ return undefined;
140
+ }
141
+ export const notificationDefaultOptOutLine = '무료 수신거부: 수신거부 버튼';
142
+ export const notificationDefaultDeploymentTimeZone = 'Asia/Seoul';
143
+ /**
144
+ * How gate step 8 reads the deployment under `kr`; the catalog validator
145
+ * reports its `issues`, so both read the settings the same way. The sender's
146
+ * contact and name and the opt-out line are trimmed. A missing sender or a
147
+ * blank contact leaves `sender` absent, which suppresses every advertisement
148
+ * as `advertising-sender-missing`; a blank name is left out of the sender
149
+ * line, and a blank opt-out line gives way to the default. `path` prefixes
150
+ * each issue's path.
151
+ */
152
+ export function resolveNotificationAdvertisingSettings(deployment, path = 'deployment') {
153
+ const issues = [];
154
+ const declared = deployment?.advertisingSender;
155
+ const contact = deploymentText(declared?.contact);
156
+ const name = deploymentText(declared?.name);
157
+ const optOutLine = deploymentText(deployment?.advertisingOptOutLine);
158
+ if (declared === undefined) {
159
+ issues.push({
160
+ path: `${path}.advertisingSender`,
161
+ message: 'the Korean advertising rules need the advertising sender; without it every advertising send is suppressed',
162
+ });
163
+ }
164
+ else if (contact === undefined) {
165
+ issues.push({
166
+ path: `${path}.advertisingSender.contact`,
167
+ message: 'the advertising sender contact is blank, so every advertising send is suppressed',
168
+ });
169
+ }
170
+ if (declared?.name !== undefined && name === undefined) {
171
+ issues.push({
172
+ path: `${path}.advertisingSender.name`,
173
+ message: 'the advertising sender name is blank, so the sender line leaves it out; omit name only when the sender is the app',
174
+ });
175
+ }
176
+ if (deployment?.advertisingOptOutLine !== undefined && optOutLine === undefined) {
177
+ issues.push({
178
+ path: `${path}.advertisingOptOutLine`,
179
+ message: 'the opt-out line is blank, so advertising carries the default line; leave it out to use the default',
180
+ });
181
+ }
182
+ return {
183
+ ...(contact === undefined ? {} : { sender: name === undefined ? { contact } : { name, contact } }),
184
+ optOutLine: optOutLine ?? notificationDefaultOptOutLine,
185
+ issues,
186
+ };
187
+ }
188
+ /** A deployment text trimmed, or `undefined` when it is absent or blank. */
189
+ function deploymentText(value) {
190
+ const trimmed = value?.trim();
191
+ return trimmed === undefined || trimmed === '' ? undefined : trimmed;
192
+ }
193
+ /**
194
+ * Reference registry entries for the RFC provider profiles. They are reference
195
+ * values: a project registers its own entries under its own provider ids, and
196
+ * the P-phase providers own the final values.
197
+ */
198
+ export const referenceNotificationProviders = deepFreeze({
199
+ fcm: {
200
+ provider: 'fcm',
201
+ purposes: ['service', 'requested', 'advertising'],
202
+ addressing: 'device',
203
+ content: 'free-form',
204
+ permission: { scope: 'app', serverReadable: false, allowance: 'persistent' },
205
+ expiry: true,
206
+ actions: true,
207
+ link: false,
208
+ mappingFields: [],
209
+ mapping: { channelId: { required: false } },
210
+ },
211
+ 'apps-in-toss': {
212
+ provider: 'apps-in-toss',
213
+ purposes: ['service', 'requested'],
214
+ addressing: 'platform-user',
215
+ content: 'host-template',
216
+ permission: { scope: 'topic', serverReadable: false, allowance: 'persistent' },
217
+ limits: [{ count: 10, windowSeconds: 60 }],
218
+ rate: { count: 15_000, windowSeconds: 60 },
219
+ expiry: false,
220
+ actions: false,
221
+ link: false,
222
+ mappingFields: ['templateSetCode', 'agreement'],
223
+ mapping: {
224
+ templateSetCode: { required: true },
225
+ agreement: { required: true, values: ['required', 'none'] },
226
+ },
227
+ },
228
+ devvit: {
229
+ provider: 'devvit',
230
+ purposes: ['service', 'requested'],
231
+ addressing: 'platform-user',
232
+ content: 'free-form',
233
+ permission: { scope: 'app', serverReadable: true, allowance: 'persistent' },
234
+ limits: [{ count: 2, windowSeconds: 86_400 }],
235
+ rate: { count: 25_000, windowSeconds: 86_400 },
236
+ expiry: false,
237
+ actions: false,
238
+ link: true,
239
+ mappingFields: ['title', 'body'],
240
+ mapping: {
241
+ title: { required: true, maxLength: 60 },
242
+ body: { required: true, maxLength: 100 },
243
+ link: { required: false, pattern: '^t[13]_[A-Za-z0-9]+$' },
244
+ },
245
+ },
246
+ });
247
+ /** Reference target names and the provider each one enables. */
248
+ export const referenceNotificationTargetProviders = Object.freeze({
249
+ android: 'fcm',
250
+ ios: 'fcm',
251
+ ait: 'apps-in-toss',
252
+ reddit: 'devvit',
253
+ });
254
+ /**
255
+ * Where a client provider's capabilities disagree with the provider's registry
256
+ * entry, or `undefined` when they agree. The runtime compares them at start,
257
+ * and a mismatch makes the provider `configuration-required`.
258
+ */
259
+ export function notificationRegistryMismatch(capabilities, entry) {
260
+ const remote = capabilities.remote;
261
+ if (!remote.supported) {
262
+ return 'the provider declares no remote delivery';
263
+ }
264
+ if (remote.addressing !== entry.addressing) {
265
+ return `addressing ${remote.addressing} differs from the registry's ${entry.addressing}`;
266
+ }
267
+ if (remote.content !== entry.content) {
268
+ return `content ${remote.content} differs from the registry's ${entry.content}`;
269
+ }
270
+ const purposes = [...remote.purposes].sort().join(',');
271
+ if (purposes !== [...entry.purposes].sort().join(',')) {
272
+ return `purposes ${purposes} differ from the registry's ${[...entry.purposes].sort().join(',')}`;
273
+ }
274
+ const { permission } = capabilities;
275
+ if (permission.scope !== entry.permission.scope
276
+ || permission.serverReadable !== entry.permission.serverReadable
277
+ || permission.allowance !== entry.permission.allowance) {
278
+ return 'permission scope, server readability, or allowance differs from the registry';
279
+ }
280
+ return undefined;
281
+ }
282
+ export const notificationSuppressionReasons = Object.freeze([
283
+ 'topic-unknown',
284
+ 'channel-unsupported',
285
+ 'purpose-unsupported',
286
+ 'permission-denied',
287
+ 'agreement-missing',
288
+ 'allowance-exhausted',
289
+ 'request-missing',
290
+ 'preference-off',
291
+ 'advertising-consent-missing',
292
+ 'night-consent-missing',
293
+ 'quiet-hours',
294
+ 'frequency-capped',
295
+ 'provider-limit',
296
+ 'advertising-sender-missing',
297
+ ]);
298
+ /** The advertising opt-out action under the Korean rules. */
299
+ export const notificationAdvertisingOptOutActionId = 'advertising-opt-out';
300
+ /**
301
+ * The policy gate, evaluated per recipient immediately before the provider
302
+ * call: after any queue, and for a scheduled reminder or a deferred send when
303
+ * it comes due (the caller looks up the idempotency key first). The steps run
304
+ * in order and the first failure decides:
305
+ *
306
+ * 1. the topic is declared (`topic-unknown`), has the `remote` channel
307
+ * (`channel-unsupported`), and the entry delivers its purpose, advertising
308
+ * only as free-form content and, under `kr`, with an expiry and the opt-out
309
+ * action (`purpose-unsupported`);
310
+ * 2. the recipient resolves (`unavailable: no-endpoint`), with a link where
311
+ * the entry needs one (`unavailable: link-missing`);
312
+ * 3. delivery permission is positively `granted`: the send-time host answer on
313
+ * a server-readable provider, the endpoint's registered permission for
314
+ * device addressing, or a latest `delivery-permission` record of `granted`
315
+ * for an app-scope platform-user provider; a topic-scope provider also
316
+ * needs a latest `host-agreement` of `granted` unless the topic needs no
317
+ * agreement (then the host decides each send, after a provider's denial
318
+ * too), and a per-send provider an unreserved acceptance
319
+ * (`permission-denied`, `agreement-missing`, `allowance-exhausted`);
320
+ * 4. the topic preference, only `topic-preference` records, the latest winning
321
+ * (`request-missing`, `preference-off`);
322
+ * 5. advertising consent (`advertising-consent-missing`);
323
+ * 6. the night window under `kr` and the topic's quiet hours in the
324
+ * recipient's zone (`deferred` until the holding window ends, `suppressed`
325
+ * for a topic that is not deferrable, or `night-consent-missing` when the
326
+ * windows leave no time to send);
327
+ * 7. the topic frequency cap and the entry's limits over rolling windows
328
+ * (`frequency-capped`, `provider-limit`; a limit window shorter than a day
329
+ * defers instead);
330
+ * 8. under `kr`, advertising needs the deployment's sender with a contact
331
+ * that is not blank (`advertising-sender-missing`) and is rendered with the
332
+ * label, the sender line, the opt-out line, and the opt-out action, the
333
+ * settings read by `resolveNotificationAdvertisingSettings`.
334
+ *
335
+ * Caller errors throw a `TypeError` instead of deciding: a missing
336
+ * `serverPermission`, an advertising topic without the deployment's
337
+ * `advertisingRules` (the gate fails closed rather than guess them), an
338
+ * unknown time zone, missing copy or template data, an advertising topic
339
+ * without `optOutRoute`, and a route or opt-out route that is not
340
+ * root-relative.
341
+ */
342
+ export function evaluateNotificationDelivery(input) {
343
+ const { entry, topic } = input;
344
+ if (topic === undefined) {
345
+ return { status: 'suppressed', reason: 'topic-unknown' };
346
+ }
347
+ if (!topic.channels.includes('remote')) {
348
+ return { status: 'suppressed', reason: 'channel-unsupported' };
349
+ }
350
+ const rules = input.deployment?.advertisingRules;
351
+ if (topic.purpose === 'advertising' && rules === undefined) {
352
+ // The Korean rules must be chosen on purpose: a deployment that forgot them fails closed.
353
+ throw new TypeError(`Advertising topic ${topic.id} needs the deployment's advertisingRules ('kr' or 'none').`);
354
+ }
355
+ if (!entry.purposes.includes(topic.purpose)
356
+ || (topic.purpose === 'advertising'
357
+ && (entry.content !== 'free-form' || (rules === 'kr' && (!entry.expiry || !entry.actions))))) {
358
+ return { status: 'suppressed', reason: 'purpose-unsupported' };
359
+ }
360
+ if (!input.recipientResolved) {
361
+ return { status: 'unavailable', reason: 'no-endpoint' };
362
+ }
363
+ if (entry.link && input.link === undefined && typeof topic.mapping?.['link'] !== 'string') {
364
+ return { status: 'unavailable', reason: 'link-missing' };
365
+ }
366
+ const permission = deliveryPermissionRefusal(input, topic);
367
+ if (permission !== undefined) {
368
+ return { status: 'suppressed', reason: permission };
369
+ }
370
+ const preference = topicPreferenceRefusal(topic, input.consent);
371
+ if (preference !== undefined) {
372
+ return { status: 'suppressed', reason: preference };
373
+ }
374
+ if (topic.purpose === 'advertising'
375
+ && latestNotificationConsent(input.consent, 'advertising')?.state !== 'granted') {
376
+ return { status: 'suppressed', reason: 'advertising-consent-missing' };
377
+ }
378
+ const timeZone = input.timeZone
379
+ ?? input.deployment?.timeZone
380
+ ?? notificationDefaultDeploymentTimeZone;
381
+ const windows = resolveTimeWindows(topic, input.consent, input.at, timeZone, rules ?? 'none');
382
+ if (windows.status === 'suppressed') {
383
+ return { status: 'suppressed', reason: windows.reason };
384
+ }
385
+ if (windows.status === 'held') {
386
+ return { status: 'deferred', reason: windows.reason, until: windows.until };
387
+ }
388
+ const limit = limitDecision(input, topic);
389
+ if (limit !== undefined) {
390
+ return limit;
391
+ }
392
+ const advertising = topic.purpose === 'advertising' && rules === 'kr'
393
+ ? resolveNotificationAdvertisingSettings(input.deployment)
394
+ : undefined;
395
+ if (advertising !== undefined && advertising.sender === undefined) {
396
+ return { status: 'suppressed', reason: 'advertising-sender-missing' };
397
+ }
398
+ const content = resolveNotificationContent({
399
+ topic,
400
+ content: entry.content,
401
+ defaultLocale: input.defaultLocale,
402
+ ...(input.locale === undefined ? {} : { locale: input.locale }),
403
+ ...(input.templateData === undefined ? {} : { templateData: input.templateData }),
404
+ });
405
+ if (advertising?.sender === undefined) {
406
+ return { status: 'allowed', content };
407
+ }
408
+ const nightConsent = latestNotificationConsent(input.consent, 'night')?.state === 'granted';
409
+ return {
410
+ status: 'allowed',
411
+ content: renderNotificationAdvertisement(topic, content, advertising.sender, advertising.optOutLine),
412
+ ...(nightConsent ? {} : { expiresAt: nextNightWindowStart(input.at) }),
413
+ };
414
+ }
415
+ function deliveryPermissionRefusal(input, topic) {
416
+ const { entry } = input;
417
+ if (entry.permission.serverReadable) {
418
+ if (input.serverPermission === undefined) {
419
+ throw new TypeError(`Provider ${entry.provider} is server-readable; the gate needs the host answer at send time.`);
420
+ }
421
+ if (input.serverPermission !== 'granted') {
422
+ return 'permission-denied';
423
+ }
424
+ }
425
+ else if (entry.addressing === 'device') {
426
+ if (input.endpointPermission !== 'granted') {
427
+ return 'permission-denied';
428
+ }
429
+ }
430
+ else if (entry.permission.scope === 'app'
431
+ && latestNotificationConsent(input.consent, 'delivery-permission')?.state !== 'granted') {
432
+ return 'permission-denied';
433
+ }
434
+ // A topic that needs no agreement goes on even after a provider's denial:
435
+ // nothing could ask the player again, and the host decides each send.
436
+ if (entry.permission.scope === 'topic' && !notificationTopicNeedsNoAgreement(topic)) {
437
+ if (latestNotificationConsent(input.consent, 'host-agreement', topic.id)?.state !== 'granted') {
438
+ return 'agreement-missing';
439
+ }
440
+ }
441
+ if (entry.permission.allowance === 'per-send') {
442
+ const acceptanceKind = notificationPermissionConsentKind(entry.permission.scope);
443
+ const accepted = input.consent.filter((record) => record.kind === acceptanceKind
444
+ && record.state === 'granted'
445
+ && (entry.permission.scope === 'app' ? record.topic === undefined : record.topic === topic.id))
446
+ .length;
447
+ const used = input.history.filter((sent) => sent.provider === entry.provider
448
+ && (entry.permission.scope === 'app' || sent.topic === topic.id)).length;
449
+ if (accepted <= used) {
450
+ return 'allowance-exhausted';
451
+ }
452
+ }
453
+ return undefined;
454
+ }
455
+ /** Gate step 4: only `topic-preference` records decide, and the latest one wins. */
456
+ function topicPreferenceRefusal(topic, consent) {
457
+ const latest = latestNotificationConsent(consent, 'topic-preference', topic.id);
458
+ if (topic.purpose === 'requested') {
459
+ if (latest === undefined) {
460
+ return 'request-missing';
461
+ }
462
+ return latest.state === 'granted' ? undefined : 'preference-off';
463
+ }
464
+ return latest?.state === 'withdrawn' ? 'preference-off' : undefined;
465
+ }
466
+ /** The window holding a send at `at`: the night window for advertising without night consent under `kr`, then quiet hours. */
467
+ function windowHold(topic, consent, at, timeZone, rules) {
468
+ if (rules === 'kr'
469
+ && topic.purpose === 'advertising'
470
+ && latestNotificationConsent(consent, 'night')?.state !== 'granted') {
471
+ const night = checkNotificationNightWindow(at);
472
+ if (night.inside) {
473
+ return { reason: 'night-window', until: night.until };
474
+ }
475
+ }
476
+ if (topic.quietHours !== undefined) {
477
+ const quiet = checkNotificationQuietHours(at, topic.quietHours, timeZone);
478
+ if (quiet.inside) {
479
+ return { reason: 'quiet-hours', until: quiet.until };
480
+ }
481
+ }
482
+ return undefined;
483
+ }
484
+ /** Window hops before the windows count as covering every hour (two windows settle in far fewer). */
485
+ const maxWindowHops = 6;
486
+ /**
487
+ * Gate step 6. A topic that is not deferrable is suppressed by the window
488
+ * holding it, and a send whose windows together leave no time to send (quiet
489
+ * hours that close exactly where the night window opens and the reverse) is
490
+ * `night-consent-missing`, so the scheduler can never defer it forever.
491
+ */
492
+ function resolveTimeWindows(topic, consent, at, timeZone, rules) {
493
+ const first = windowHold(topic, consent, at, timeZone, rules);
494
+ if (first === undefined) {
495
+ return { status: 'clear' };
496
+ }
497
+ if (topic.deferrable === false) {
498
+ return {
499
+ status: 'suppressed',
500
+ reason: first.reason === 'night-window' ? 'night-consent-missing' : 'quiet-hours',
501
+ };
502
+ }
503
+ // Follow each window's end until a time no window holds.
504
+ let current = first.until;
505
+ for (let hop = 1; hop < maxWindowHops; hop += 1) {
506
+ const hold = windowHold(topic, consent, current, timeZone, rules);
507
+ if (hold === undefined) {
508
+ return { status: 'held', reason: first.reason, until: first.until };
509
+ }
510
+ current = hold.until;
511
+ }
512
+ return { status: 'suppressed', reason: 'night-consent-missing' };
513
+ }
514
+ /**
515
+ * Gate step 7. A send at `s` counts for a send at `t` when `t − window < s ≤
516
+ * t`. The topic's daily cap and limits of a day or longer suppress; a shorter
517
+ * provider limit defers until the oldest counted send leaves the window.
518
+ * `at` reads strictly even when nothing is counted. Each counted send is read
519
+ * once, and the count walks from the newest one to the longest window.
520
+ */
521
+ function limitDecision(input, topic) {
522
+ const at = parseInstant(input.at);
523
+ const limits = input.entry.limits ?? [];
524
+ const capped = topic.frequency !== undefined;
525
+ if (!capped && limits.length === 0) {
526
+ return undefined;
527
+ }
528
+ const longest = Math.max(capped ? dayMs : 0, ...limits.map((limit) => limit.windowSeconds * 1000));
529
+ const sends = countedSends(input.history, {
530
+ topic: capped ? topic.id : undefined,
531
+ provider: limits.length > 0 ? input.entry.provider : undefined,
532
+ });
533
+ // The topic's and the provider's sends inside the longest window.
534
+ const topicSends = [];
535
+ const providerSends = [];
536
+ for (let index = sends.length - 1; index >= 0; index -= 1) {
537
+ const sent = sends[index];
538
+ if (sent === undefined) {
539
+ continue;
540
+ }
541
+ if (sent.at <= at - longest) {
542
+ break;
543
+ }
544
+ if (sent.at > at) {
545
+ continue;
546
+ }
547
+ if (sent.topic) {
548
+ topicSends.push(sent.at);
549
+ }
550
+ if (sent.provider) {
551
+ providerSends.push(sent.at);
552
+ }
553
+ }
554
+ // Oldest first again, as the deferral below reads them.
555
+ topicSends.reverse();
556
+ providerSends.reverse();
557
+ const counted = (sends, windowMs) => sends.filter((sentAt) => sentAt > at - windowMs);
558
+ if (topic.frequency !== undefined) {
559
+ const sends = counted(topicSends, dayMs);
560
+ if (sends.length >= topic.frequency.maxPerDay) {
561
+ return { status: 'suppressed', reason: 'frequency-capped' };
562
+ }
563
+ }
564
+ // Every limit that is full: one of a day or longer suppresses, whatever the
565
+ // order of the entry's limits; shorter ones defer until all have room.
566
+ let until;
567
+ for (const limit of limits) {
568
+ const windowMs = limit.windowSeconds * 1000;
569
+ const sends = counted(providerSends, windowMs);
570
+ if (sends.length < limit.count) {
571
+ continue;
572
+ }
573
+ if (windowMs >= dayMs) {
574
+ return { status: 'suppressed', reason: 'provider-limit' };
575
+ }
576
+ // Room opens when enough of the oldest counted sends have left the window.
577
+ const leaving = (sends[sends.length - limit.count] ?? sends[0] ?? at) + windowMs;
578
+ until = until === undefined ? leaving : Math.max(until, leaving);
579
+ }
580
+ return until === undefined
581
+ ? undefined
582
+ : { status: 'deferred', reason: 'provider-limit', until: formatNotificationTimestamp(until) };
583
+ }
584
+ /**
585
+ * The sends step 7 counts, oldest first: the topic's when it has a daily cap,
586
+ * and the provider's when it has limits, each read once. The history comes
587
+ * oldest first; the same pass finds one that does not, and its copy is sorted.
588
+ */
589
+ function countedSends(history, counted) {
590
+ const sends = [];
591
+ let ordered = true;
592
+ for (const sent of history) {
593
+ const topic = counted.topic !== undefined && sent.topic === counted.topic;
594
+ const provider = counted.provider !== undefined && sent.provider === counted.provider;
595
+ if (!topic && !provider) {
596
+ continue;
597
+ }
598
+ const at = parseInstant(sent.at);
599
+ const previous = sends[sends.length - 1];
600
+ if (previous !== undefined && at < previous.at) {
601
+ ordered = false;
602
+ }
603
+ sends.push({ at, topic, provider });
604
+ }
605
+ // `sends` is a copy already, so it sorts in place.
606
+ return ordered ? sends : sends.sort((left, right) => left.at - right.at);
607
+ }
608
+ /** The next start of the night window (21:00 Asia/Seoul) after `at`. */
609
+ function nextNightWindowStart(at) {
610
+ const millis = parseInstant(at);
611
+ const wall = seoulZone.wall(millis);
612
+ const start = parseClockTime(notificationNightWindow.start);
613
+ const day = wall.minute < start ? wall.day : wall.day + 1;
614
+ return formatNotificationTimestamp(Date.UTC(wall.year, wall.month - 1, day, 0, start) - seoulOffsetMs);
615
+ }
616
+ // Content (RFC "Topic catalog" and gate step 8).
617
+ const placeholderPattern = /\{\{\s*([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}/gu;
618
+ /** A brace, full-width ones included, which a text holds only inside a `{{name}}` placeholder. */
619
+ const bracePattern = /[{}\uFF5B\uFF5D]/u;
620
+ /**
621
+ * A text's `{{name}}` placeholders, as `fillNotificationTemplate` reads them,
622
+ * and whether a brace is left once they are taken out. `{name}`, `{{name}`,
623
+ * `{{a.b}}`, and full-width braces fill nothing and would reach the player as
624
+ * written.
625
+ */
626
+ export function readNotificationTemplate(text) {
627
+ return {
628
+ placeholders: [...text.matchAll(placeholderPattern)].map((match) => match[1] ?? ''),
629
+ strayBrace: bracePattern.test(text.replace(placeholderPattern, '')),
630
+ };
631
+ }
632
+ /** Fills `{{name}}` placeholders from `data`; a placeholder without a value is a caller error. */
633
+ export function fillNotificationTemplate(text, data = {}) {
634
+ return text.replace(placeholderPattern, (_match, name) => {
635
+ const value = Object.prototype.hasOwnProperty.call(data, name) ? data[name] : undefined;
636
+ if (value === undefined) {
637
+ throw new TypeError(`Template data has no value for {{${name}}}.`);
638
+ }
639
+ return String(value);
640
+ });
641
+ }
642
+ /**
643
+ * The topic's copy for `locale`: that locale, then its language (`en` for
644
+ * `en-US` or `en_US`), then `defaultLocale`, each matched without regard to
645
+ * case as locale tags are; `undefined` when the topic has none of them.
646
+ */
647
+ export function findNotificationCopy(topic, locale, defaultLocale) {
648
+ const copy = topic.copy;
649
+ if (copy === undefined) {
650
+ return undefined;
651
+ }
652
+ const keys = Object.keys(copy);
653
+ const candidates = locale === undefined
654
+ ? [defaultLocale]
655
+ : [locale, locale.split(/[-_]/u, 1)[0] ?? locale, defaultLocale];
656
+ for (const candidate of candidates) {
657
+ const key = Object.prototype.hasOwnProperty.call(copy, candidate)
658
+ ? candidate
659
+ : keys.find((name) => name.toLowerCase() === candidate.toLowerCase());
660
+ if (key !== undefined) {
661
+ return copy[key];
662
+ }
663
+ }
664
+ return undefined;
665
+ }
666
+ /**
667
+ * Content precedence (RFC "Topic catalog"): the target mapping when it
668
+ * carries its own `title` and `body`, the host template when
669
+ * `remote.content` is `host-template`, and otherwise the catalog copy for the
670
+ * locale (`findNotificationCopy`). No copy, a missing template value, and a
671
+ * route that `isRootRelativeNotificationRoute` refuses are caller errors, so
672
+ * a catalog that skipped validation still never sends another host's link.
673
+ */
674
+ export function resolveNotificationContent(input) {
675
+ const { topic, templateData } = input;
676
+ assertRootRelativeRoute(topic.id, 'route', topic.route);
677
+ const mappedTitle = topic.mapping?.['title'];
678
+ const mappedBody = topic.mapping?.['body'];
679
+ if (typeof mappedTitle === 'string' && typeof mappedBody === 'string') {
680
+ return {
681
+ title: fillNotificationTemplate(mappedTitle, templateData),
682
+ body: fillNotificationTemplate(mappedBody, templateData),
683
+ route: topic.route,
684
+ source: 'mapping',
685
+ };
686
+ }
687
+ if (input.content === 'host-template') {
688
+ return { route: topic.route, source: 'host-template' };
689
+ }
690
+ const copy = findNotificationCopy(topic, input.locale, input.defaultLocale);
691
+ if (copy === undefined) {
692
+ throw new TypeError(`Topic ${topic.id} has no copy for ${input.locale ?? input.defaultLocale} `
693
+ + `or the default locale ${input.defaultLocale}.`);
694
+ }
695
+ return {
696
+ title: fillNotificationTemplate(copy.title, templateData),
697
+ body: fillNotificationTemplate(copy.body, templateData),
698
+ route: topic.route,
699
+ source: 'catalog',
700
+ };
701
+ }
702
+ /** The advertising label every advertising title and body starts with under `kr`. */
703
+ export const notificationAdvertisingPrefix = '(광고)';
704
+ /**
705
+ * `(광고)`, one space, and the text without leading whitespace. Text that
706
+ * already starts with the label keeps it once, followed by one space.
707
+ */
708
+ export function applyNotificationAdvertisingLabel(text) {
709
+ const trimmed = text.trimStart();
710
+ const rest = trimmed.startsWith(notificationAdvertisingPrefix)
711
+ ? trimmed.slice(notificationAdvertisingPrefix.length).trimStart()
712
+ : trimmed;
713
+ return `${notificationAdvertisingPrefix} ${rest}`;
714
+ }
715
+ /**
716
+ * Gate step 8 under `kr`: the title starts with `(광고) `; the body starts with
717
+ * `(광고) ` and the sender line (`name contact`, or `contact` alone), then the
718
+ * copy on the next line, then the opt-out line on the last line; and the
719
+ * message carries the opt-out action that opens the topic's `optOutRoute`.
720
+ * The sender and the opt-out line are read as
721
+ * `resolveNotificationAdvertisingSettings` reads them; a blank contact is a
722
+ * caller error.
723
+ */
724
+ export function renderNotificationAdvertisement(topic, content, sender, optOutLine = notificationDefaultOptOutLine) {
725
+ if (topic.optOutRoute === undefined) {
726
+ throw new TypeError(`Advertising topic ${topic.id} declares no optOutRoute.`);
727
+ }
728
+ assertRootRelativeRoute(topic.id, 'optOutRoute', topic.optOutRoute);
729
+ const settings = resolveNotificationAdvertisingSettings({
730
+ advertisingSender: sender,
731
+ advertisingOptOutLine: optOutLine,
732
+ });
733
+ if (settings.sender === undefined) {
734
+ throw new TypeError(`Advertising topic ${topic.id} needs a sender contact that is not blank.`);
735
+ }
736
+ const { name, contact } = settings.sender;
737
+ const senderLine = name === undefined ? contact : `${name} ${contact}`;
738
+ return {
739
+ ...content,
740
+ ...(content.title === undefined
741
+ ? {}
742
+ : { title: applyNotificationAdvertisingLabel(content.title) }),
743
+ ...(content.body === undefined
744
+ ? {}
745
+ : {
746
+ body: `${notificationAdvertisingPrefix} ${senderLine}\n${content.body}\n${settings.optOutLine}`,
747
+ }),
748
+ actions: [
749
+ {
750
+ id: notificationAdvertisingOptOutActionId,
751
+ title: '수신거부',
752
+ route: topic.optOutRoute,
753
+ },
754
+ ],
755
+ };
756
+ }
757
+ function assertRootRelativeRoute(topic, field, route) {
758
+ const error = notificationRouteError(`Topic ${topic} has a ${field} that`, route);
759
+ if (error !== undefined) {
760
+ throw new TypeError(error);
761
+ }
762
+ }
763
+ // Inputs and idempotency (RFC "Reminders" input bounds and "Policy gate").
764
+ /** v1's input bounds, which v2 keeps. */
765
+ export const notificationInputBounds = Object.freeze({
766
+ keyLength: 2048,
767
+ entries: 128,
768
+ stringLength: 16_384,
769
+ });
770
+ /** Key prefixes the scheduler reserves; a caller's send key may not start with one. */
771
+ export const notificationReservedKeyPrefixes = Object.freeze(['reminder:', 'notice:']);
772
+ /** Why `key` is not a valid send or reminder key, or `undefined`. */
773
+ export function notificationKeyIssue(key) {
774
+ if (key.length === 0 || key.length > notificationInputBounds.keyLength) {
775
+ return `keys hold 1 to ${notificationInputBounds.keyLength} characters`;
776
+ }
777
+ if (hasControlCharacter(key) || /[\uD800-\uDFFF]/u.test(key)) {
778
+ return 'keys hold no control characters or lone surrogates';
779
+ }
780
+ return undefined;
781
+ }
782
+ /** The reserved prefix `key` starts with, if any. */
783
+ export function notificationReservedKeyPrefix(key) {
784
+ return notificationReservedKeyPrefixes.find((prefix) => key.startsWith(prefix));
785
+ }
786
+ /**
787
+ * Why `data` is not valid template data or routing data, or `undefined`: at
788
+ * most 128 entries of strings, finite numbers, or booleans, each string at most
789
+ * 16,384 characters. With `stringsOnly`, every value must be a string (routing
790
+ * `data`).
791
+ */
792
+ export function notificationTemplateDataIssue(data, options = {}) {
793
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
794
+ return 'expected an object of entries';
795
+ }
796
+ const entries = Object.entries(data);
797
+ if (entries.length > notificationInputBounds.entries) {
798
+ return `at most ${notificationInputBounds.entries} entries`;
799
+ }
800
+ for (const [name, value] of entries) {
801
+ if (name.length === 0 || name.length > notificationInputBounds.keyLength || hasControlCharacter(name)) {
802
+ return `entry name ${JSON.stringify(name)} is not valid`;
803
+ }
804
+ if (typeof value === 'string') {
805
+ if (value.length > notificationInputBounds.stringLength) {
806
+ return `entry ${name} is longer than ${notificationInputBounds.stringLength} characters`;
807
+ }
808
+ continue;
809
+ }
810
+ if (options.stringsOnly) {
811
+ return `entry ${name} must be a string`;
812
+ }
813
+ if (!(typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)))) {
814
+ return `entry ${name} must be a string, a finite number, or a boolean`;
815
+ }
816
+ }
817
+ return undefined;
818
+ }
819
+ /**
820
+ * The fingerprint a stored key is bound to: the player, provider, topic,
821
+ * template data, routing data, locale, and link, in a canonical JSON form. The
822
+ * resolved address is left out, so a replay after a token rotation still
823
+ * matches.
824
+ */
825
+ export function notificationSendFingerprint(request) {
826
+ return canonicalJson({
827
+ player: request.player,
828
+ provider: request.provider ?? null,
829
+ topic: request.topic,
830
+ templateData: request.templateData ?? null,
831
+ data: request.data ?? null,
832
+ locale: request.locale ?? null,
833
+ link: request.link ?? null,
834
+ });
835
+ }
836
+ function canonicalJson(value) {
837
+ if (Array.isArray(value)) {
838
+ return `[${value.map(canonicalJson).join(',')}]`;
839
+ }
840
+ if (typeof value === 'object' && value !== null) {
841
+ return `{${Object.keys(value)
842
+ .sort()
843
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
844
+ .join(',')}}`;
845
+ }
846
+ return JSON.stringify(value);
847
+ }
848
+ /**
849
+ * Reminder routing: local when the topic has the `local` channel, is not
850
+ * `advertising`, and the provider supports local notifications; else remote
851
+ * when the topic has the `remote` channel and the provider supports remote
852
+ * delivery; else none. A reminder never takes both routes, and an advertising
853
+ * topic never takes the local route even when a catalog that skipped
854
+ * validation declares it.
855
+ */
856
+ export function routeNotificationReminder(topic, capabilities) {
857
+ if (topic.channels.includes('local')
858
+ && topic.purpose !== 'advertising'
859
+ && capabilities.local.supported) {
860
+ return 'local';
861
+ }
862
+ if (topic.channels.includes('remote') && capabilities.remote.supported) {
863
+ return 'remote';
864
+ }
865
+ return 'none';
866
+ }
867
+ /**
868
+ * The gate steps a client can evaluate before scheduling a local reminder, in
869
+ * order: delivery permission (`permission-required`), the topic preference,
870
+ * the topic's quiet hours, and `frequency.maxPerDay` over the topic's pending
871
+ * local reminders within any 24 hours (`frequency-capped`). As at gate step
872
+ * 6, a `fireAt` inside the quiet hours moves to the first time outside them,
873
+ * hopping over a window that a clock change makes end inside another, and a
874
+ * topic with `deferrable: false` is not scheduled there (`quiet-hours`).
875
+ * Without quiet hours `fireAt` is kept. The night window does not apply,
876
+ * because advertising never takes the local route.
877
+ */
878
+ export function evaluateLocalNotificationReminder(input) {
879
+ if (input.permission !== 'granted') {
880
+ return { status: 'permission-required' };
881
+ }
882
+ const preference = topicPreferenceRefusal(input.topic, input.consent);
883
+ if (preference !== undefined) {
884
+ return { status: preference };
885
+ }
886
+ let fireAt = normalizeNotificationTimestamp(input.fireAt);
887
+ const { quietHours } = input.topic;
888
+ if (quietHours !== undefined) {
889
+ let quiet = checkNotificationQuietHours(fireAt, quietHours, input.timeZone);
890
+ if (quiet.inside && input.topic.deferrable === false) {
891
+ return { status: 'quiet-hours' };
892
+ }
893
+ // As at gate step 6: follow each window's end until a time outside them.
894
+ for (let hop = 1; quiet.inside; hop += 1) {
895
+ if (hop >= maxWindowHops) {
896
+ // No time outside the windows: the gate would never send it either.
897
+ return { status: 'quiet-hours' };
898
+ }
899
+ fireAt = quiet.until;
900
+ quiet = checkNotificationQuietHours(fireAt, quietHours, input.timeZone);
901
+ }
902
+ }
903
+ const maxPerDay = input.topic.frequency?.maxPerDay;
904
+ if (maxPerDay !== undefined && exceedsDailyCount(fireAt, input.pending ?? [], maxPerDay)) {
905
+ return { status: 'frequency-capped' };
906
+ }
907
+ return { status: 'schedule', fireAt };
908
+ }
909
+ /** Whether some 24-hour window around `fireAt` would hold more than `max` reminders with it. */
910
+ function exceedsDailyCount(fireAt, pending, max) {
911
+ const at = parseInstant(fireAt);
912
+ const times = pending.map((entry) => parseInstant(entry.fireAt));
913
+ // Windows [start, start + 24h) that contain `at` start at `at` or at an earlier reminder.
914
+ const starts = [at, ...times.filter((time) => time <= at && time > at - dayMs)];
915
+ return starts.some((start) => times.filter((time) => time >= start && time < start + dayMs).length + 1 > max);
916
+ }
917
+ /**
918
+ * Gate steps 3 to 5 at the time a server reminder is set, against the
919
+ * backend's records or the host read: `undefined` when they pass, else the
920
+ * reminder status to answer (`permission-required` for a permission, agreement,
921
+ * or allowance refusal). The scheduler runs the whole gate again at send time.
922
+ */
923
+ export function evaluateNotificationReminderConsent(input) {
924
+ const gateInput = {
925
+ entry: input.entry,
926
+ topic: input.topic,
927
+ recipientResolved: true,
928
+ consent: input.consent,
929
+ ...(input.serverPermission === undefined ? {} : { serverPermission: input.serverPermission }),
930
+ ...(input.endpointPermission === undefined
931
+ ? {}
932
+ : { endpointPermission: input.endpointPermission }),
933
+ history: input.history,
934
+ at: '1970-01-01T00:00:00Z',
935
+ defaultLocale: 'und',
936
+ };
937
+ if (deliveryPermissionRefusal(gateInput, input.topic) !== undefined) {
938
+ return 'permission-required';
939
+ }
940
+ const preference = topicPreferenceRefusal(input.topic, input.consent);
941
+ if (preference !== undefined) {
942
+ return preference;
943
+ }
944
+ if (input.topic.purpose === 'advertising'
945
+ && latestNotificationConsent(input.consent, 'advertising')?.state !== 'granted') {
946
+ return 'advertising-consent-missing';
947
+ }
948
+ return undefined;
949
+ }
950
+ /**
951
+ * Idempotency key of a server reminder's send:
952
+ * `reminder:<player>:<key>:<fireAt>`, with the player and key percent-encoded
953
+ * (`encodeURIComponent`) so ids containing `:` cannot collide, and `fireAt` in
954
+ * the canonical timestamp form.
955
+ */
956
+ export function createNotificationReminderIdempotencyKey(input) {
957
+ for (const [name, value] of [
958
+ ['player', input.player],
959
+ ['key', input.key],
960
+ ]) {
961
+ if (value.length === 0 || /[\uD800-\uDFFF]/u.test(value)) {
962
+ throw new TypeError(`Reminder idempotency keys need a well-formed, non-empty ${name}.`);
963
+ }
964
+ }
965
+ return `reminder:${encodeURIComponent(input.player)}:${encodeURIComponent(input.key)}:`
966
+ + normalizeNotificationTimestamp(input.fireAt);
967
+ }
968
+ // Time windows.
969
+ const minuteMs = 60_000;
970
+ const hourMs = 60 * minuteMs;
971
+ const dayMs = 24 * hourMs;
972
+ /** Korea has kept UTC+9 without daylight saving time since 1988. */
973
+ const seoulOffsetMs = 9 * hourMs;
974
+ /** The Korean advertising night window: 21:00 inclusive to 08:00 exclusive, Asia/Seoul. */
975
+ export const notificationNightWindow = Object.freeze({
976
+ timeZone: 'Asia/Seoul',
977
+ start: '21:00',
978
+ end: '08:00',
979
+ });
980
+ /** Whether `at` falls in the night window, and the next 08:00 Asia/Seoul when it does. */
981
+ export function checkNotificationNightWindow(at) {
982
+ return checkWindow(parseInstant(at), parseClockTime(notificationNightWindow.start), parseClockTime(notificationNightWindow.end), seoulZone);
983
+ }
984
+ /**
985
+ * Whether `at` falls in the quiet hours in `timeZone`, and the window's next
986
+ * end when it does. An unknown zone throws a `TypeError`.
987
+ */
988
+ export function checkNotificationQuietHours(at, quietHours, timeZone) {
989
+ return checkWindow(parseInstant(at), parseClockTime(quietHours.start), parseClockTime(quietHours.end), timeZone === notificationNightWindow.timeZone ? seoulZone : intlZone(timeZone));
990
+ }
991
+ /** Whether `Intl` knows `timeZone`, so quiet hours can be read in it. */
992
+ export function isValidNotificationTimeZone(timeZone) {
993
+ try {
994
+ intlZone(timeZone);
995
+ return true;
996
+ }
997
+ catch {
998
+ return false;
999
+ }
1000
+ }
1001
+ /** The Asia/Seoul calendar date of an instant, `YYYY-MM-DD`. */
1002
+ export function toSeoulCalendarDate(at) {
1003
+ const wall = seoulZone.wall(parseInstant(at));
1004
+ return formatCalendarDate(Date.UTC(wall.year, wall.month - 1, wall.day));
1005
+ }
1006
+ /**
1007
+ * ISO 8601 UTC without a `.000` millisecond part, as the vectors write
1008
+ * timestamps. The input needs a date, a time with seconds, and an offset (`Z`
1009
+ * or `±HH:MM`): a zone-less time would depend on the process's time zone.
1010
+ */
1011
+ export function normalizeNotificationTimestamp(at) {
1012
+ return formatNotificationTimestamp(parseInstant(at));
1013
+ }
1014
+ function formatNotificationTimestamp(millis) {
1015
+ const iso = new Date(millis).toISOString();
1016
+ return iso.endsWith('.000Z') ? `${iso.slice(0, -5)}Z` : iso;
1017
+ }
1018
+ const isoInstantPattern = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:[Zz]|([+-])(\d{2}):(\d{2}))$/u;
1019
+ /**
1020
+ * An RFC 3339 instant with an explicit offset (`T` and `Z` in either case).
1021
+ * A calendar field out of range, such as 31 February or a leap second, is
1022
+ * refused rather than rolled over into the next day.
1023
+ */
1024
+ function parseInstant(at) {
1025
+ const millis = instantOrNaN(at);
1026
+ if (Number.isNaN(millis)) {
1027
+ throw new TypeError(`Invalid ISO-8601 timestamp (a date, a time, and an offset): ${at}`);
1028
+ }
1029
+ return millis;
1030
+ }
1031
+ function instantOrNaN(at) {
1032
+ const match = isoInstantPattern.exec(at);
1033
+ return match === null ? Number.NaN : instantOf(match);
1034
+ }
1035
+ /**
1036
+ * Whether `at` is an RFC 3339 instant as every notifications timestamp must
1037
+ * be: a date that exists, `T` (not a space), a time, and an offset. Fractions
1038
+ * past milliseconds are dropped when it is read.
1039
+ */
1040
+ export function isNotificationTimestamp(at) {
1041
+ return !Number.isNaN(instantOrNaN(at));
1042
+ }
1043
+ /**
1044
+ * The check every notifications validator runs after typia's, whose formats
1045
+ * accept a day the month does not have (31 April) and a space for `T`: each
1046
+ * `date-time` field `fields` names must pass `isNotificationTimestamp`, and
1047
+ * each `date` field `isNotificationCalendarDate`. Answers the first field
1048
+ * that does not, or `undefined`.
1049
+ */
1050
+ export function notificationTimeFieldIssue(value, fields, path = '') {
1051
+ if (Array.isArray(value)) {
1052
+ for (const [index, item] of value.entries()) {
1053
+ const issue = notificationTimeFieldIssue(item, fields, `${path}[${index}]`);
1054
+ if (issue !== undefined) {
1055
+ return issue;
1056
+ }
1057
+ }
1058
+ return undefined;
1059
+ }
1060
+ if (typeof value !== 'object' || value === null) {
1061
+ return undefined;
1062
+ }
1063
+ for (const [key, format] of Object.entries(fields)) {
1064
+ if (!Object.prototype.hasOwnProperty.call(value, key)) {
1065
+ continue;
1066
+ }
1067
+ const field = value[key];
1068
+ const at = path === '' ? key : `${path}.${key}`;
1069
+ if (typeof format !== 'string') {
1070
+ const issue = notificationTimeFieldIssue(field, format, at);
1071
+ if (issue !== undefined) {
1072
+ return issue;
1073
+ }
1074
+ }
1075
+ else if (typeof field === 'string'
1076
+ && !(format === 'date' ? isNotificationCalendarDate(field) : isNotificationTimestamp(field))) {
1077
+ return format === 'date'
1078
+ ? `${at} must be a date that exists, YYYY-MM-DD; got ${JSON.stringify(field)}`
1079
+ : `${at} must be RFC 3339 with a date that exists, T, and an offset; got ${JSON.stringify(field)}`;
1080
+ }
1081
+ }
1082
+ return undefined;
1083
+ }
1084
+ const noticeTimeFields = { record: { at: 'date-time' }, dueBy: 'date' };
1085
+ /**
1086
+ * The time fields of each validated type with any, for
1087
+ * `notificationTimeFieldIssue`. A test keeps them equal to the formats in the
1088
+ * published JSON Schemas.
1089
+ */
1090
+ export const notificationTimeFields = {
1091
+ LocalNotificationRequest: { fireAt: 'date-time' },
1092
+ LocalNotificationEntry: { fireAt: 'date-time' },
1093
+ NotificationBridgeListLocalResult: { pending: { fireAt: 'date-time' } },
1094
+ NotificationConsentRecord: { at: 'date-time' },
1095
+ NotificationNotice: noticeTimeFields,
1096
+ RecordNotificationNoticeInput: noticeTimeFields,
1097
+ NotificationConformanceVector: {
1098
+ context: { now: 'date-time' },
1099
+ steps: {
1100
+ at: 'date-time',
1101
+ input: { fireAt: 'date-time', ...noticeTimeFields },
1102
+ returns: { until: 'date-time', ...noticeTimeFields },
1103
+ },
1104
+ expect: {
1105
+ deliveries: { until: 'date-time' },
1106
+ providerCalls: { expiresAt: 'date-time' },
1107
+ local: { operations: { fireAt: 'date-time' }, pending: { fireAt: 'date-time' } },
1108
+ reminders: { fireAt: 'date-time' },
1109
+ notices: noticeTimeFields,
1110
+ },
1111
+ },
1112
+ };
1113
+ function instantOf(match) {
1114
+ const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number);
1115
+ const offsetHours = Number(match[9] ?? 0);
1116
+ const offsetMinutes = Number(match[10] ?? 0);
1117
+ if (year === undefined
1118
+ || month === undefined
1119
+ || day === undefined
1120
+ || hour === undefined
1121
+ || minute === undefined
1122
+ || second === undefined
1123
+ || month < 1
1124
+ || month > 12
1125
+ || day < 1
1126
+ || day > daysInMonth(year, month)
1127
+ || hour > 23
1128
+ || minute > 59
1129
+ || second > 59
1130
+ || offsetHours > 23
1131
+ || offsetMinutes > 59) {
1132
+ return Number.NaN;
1133
+ }
1134
+ const date = new Date(0);
1135
+ date.setUTCFullYear(year, month - 1, day);
1136
+ date.setUTCHours(hour, minute, second, Number((match[7] ?? '0').slice(0, 3).padEnd(3, '0')));
1137
+ const offsetMs = (offsetHours * 60 + offsetMinutes) * minuteMs;
1138
+ return date.getTime() - (match[8] === '-' ? -offsetMs : offsetMs);
1139
+ }
1140
+ function daysInMonth(year, month) {
1141
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
1142
+ return [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0;
1143
+ }
1144
+ function parseClockTime(value) {
1145
+ const match = /^([01][0-9]|2[0-3]):([0-5][0-9])$/u.exec(value);
1146
+ if (match === null) {
1147
+ throw new TypeError(`Invalid clock time ${value}; expected HH:MM.`);
1148
+ }
1149
+ return Number(match[1]) * 60 + Number(match[2]);
1150
+ }
1151
+ function formatCalendarDate(utcMidnight) {
1152
+ return new Date(utcMidnight).toISOString().slice(0, 10);
1153
+ }
1154
+ function fixedOffsetZone(offsetMs) {
1155
+ return {
1156
+ wall(millis) {
1157
+ const local = new Date(millis + offsetMs);
1158
+ return {
1159
+ year: local.getUTCFullYear(),
1160
+ month: local.getUTCMonth() + 1,
1161
+ day: local.getUTCDate(),
1162
+ minute: local.getUTCHours() * 60 + local.getUTCMinutes(),
1163
+ };
1164
+ },
1165
+ instants(year, month, day, minute) {
1166
+ return [Date.UTC(year, month - 1, day, 0, minute) - offsetMs];
1167
+ },
1168
+ shiftedInstant(year, month, day, minute) {
1169
+ return Date.UTC(year, month - 1, day, 0, minute) - offsetMs;
1170
+ },
1171
+ };
1172
+ }
1173
+ const seoulZone = fixedOffsetZone(seoulOffsetMs);
1174
+ /** Formatters by zone, least recently used first. */
1175
+ const intlFormatters = new Map();
1176
+ /** Zone names come from clients; past this many the least recently used zone goes. */
1177
+ const intlFormatterLimit = 64;
1178
+ /** A zone read through `Intl`, which every supported runtime ships with time zone data. */
1179
+ function intlZone(timeZone) {
1180
+ let formatter = intlFormatters.get(timeZone);
1181
+ if (formatter !== undefined) {
1182
+ // Most recently used last.
1183
+ intlFormatters.delete(timeZone);
1184
+ intlFormatters.set(timeZone, formatter);
1185
+ }
1186
+ else {
1187
+ try {
1188
+ formatter = new Intl.DateTimeFormat('en-US', {
1189
+ timeZone,
1190
+ hourCycle: 'h23',
1191
+ year: 'numeric',
1192
+ month: '2-digit',
1193
+ day: '2-digit',
1194
+ hour: '2-digit',
1195
+ minute: '2-digit',
1196
+ second: '2-digit',
1197
+ });
1198
+ }
1199
+ catch {
1200
+ throw new TypeError(`Unknown time zone: ${timeZone}`);
1201
+ }
1202
+ if (intlFormatters.size >= intlFormatterLimit) {
1203
+ const oldest = intlFormatters.keys().next();
1204
+ if (oldest.done !== true) {
1205
+ intlFormatters.delete(oldest.value);
1206
+ }
1207
+ }
1208
+ intlFormatters.set(timeZone, formatter);
1209
+ }
1210
+ const zoneFormatter = formatter;
1211
+ const read = (millis) => {
1212
+ const fields = {};
1213
+ for (const part of zoneFormatter.formatToParts(new Date(millis))) {
1214
+ fields[part.type] = Number(part.value);
1215
+ }
1216
+ const hour = (fields.hour ?? 0) % 24;
1217
+ return {
1218
+ year: fields.year ?? 1970,
1219
+ month: fields.month ?? 1,
1220
+ day: fields.day ?? 1,
1221
+ minute: hour * 60 + (fields.minute ?? 0),
1222
+ second: fields.second ?? 0,
1223
+ };
1224
+ };
1225
+ const offsetAt = (millis) => {
1226
+ const wall = read(millis);
1227
+ const wallAsUtc = Date.UTC(wall.year, wall.month - 1, wall.day, 0, wall.minute, wall.second);
1228
+ return wallAsUtc - Math.floor(millis / 1000) * 1000;
1229
+ };
1230
+ return {
1231
+ wall: read,
1232
+ instants(year, month, day, minute) {
1233
+ const wallAsUtc = Date.UTC(year, month - 1, day, 0, minute);
1234
+ // The offsets a day either side cover any change on this date.
1235
+ const offsets = new Set([
1236
+ offsetAt(wallAsUtc - dayMs),
1237
+ offsetAt(wallAsUtc),
1238
+ offsetAt(wallAsUtc + dayMs),
1239
+ ]);
1240
+ const matching = [...offsets]
1241
+ .map((offset) => wallAsUtc - offset)
1242
+ .filter((instant) => {
1243
+ const wall = read(instant);
1244
+ return wall.year === year && wall.month === month && wall.day === day
1245
+ && wall.minute === minute;
1246
+ });
1247
+ return [...new Set(matching)].sort((left, right) => left - right);
1248
+ },
1249
+ shiftedInstant(year, month, day, minute) {
1250
+ const wallAsUtc = Date.UTC(year, month - 1, day, 0, minute);
1251
+ return wallAsUtc - offsetAt(wallAsUtc - dayMs);
1252
+ },
1253
+ };
1254
+ }
1255
+ function checkWindow(millis, start, end, zone) {
1256
+ if (start === end) {
1257
+ return { inside: false };
1258
+ }
1259
+ const wall = zone.wall(millis);
1260
+ const overnight = start > end;
1261
+ const inside = overnight
1262
+ ? wall.minute >= start || wall.minute < end
1263
+ : wall.minute >= start && wall.minute < end;
1264
+ if (!inside) {
1265
+ return { inside: false };
1266
+ }
1267
+ const endDate = new Date(Date.UTC(wall.year, wall.month - 1, wall.day + (overnight && wall.minute >= start ? 1 : 0)));
1268
+ const year = endDate.getUTCFullYear();
1269
+ const month = endDate.getUTCMonth() + 1;
1270
+ const day = endDate.getUTCDate();
1271
+ // The earliest end after `at`: in a repeated hour the end can occur twice,
1272
+ // and the first occurrence may already be behind a send inside the window.
1273
+ const until = zone.instants(year, month, day, end).find((instant) => instant > millis)
1274
+ ?? zone.shiftedInstant(year, month, day, end);
1275
+ // A window always ends after a time inside it; the guard keeps a scheduler
1276
+ // from replaying a send at the same instant should a zone disagree.
1277
+ return {
1278
+ inside: true,
1279
+ until: formatNotificationTimestamp(until > millis ? until : millis + minuteMs),
1280
+ };
1281
+ }
1282
+ /** Whether two notices are one: the same kind, record kind, record instant, and `dueBy`. */
1283
+ export function isSameNotificationNotice(left, right) {
1284
+ return left.kind === right.kind
1285
+ && left.record.kind === right.record.kind
1286
+ && left.dueBy === right.dueBy
1287
+ && parseInstant(left.record.at) === parseInstant(right.record.at);
1288
+ }
1289
+ /** Every `NotificationNoticeVariant`, in this order; a test keeps the two equal. */
1290
+ export const notificationNoticeVariants = Object.freeze([
1291
+ 'consent-result.advertising.granted',
1292
+ 'consent-result.advertising.refused',
1293
+ 'consent-result.advertising.withdrawn',
1294
+ 'consent-result.night.granted',
1295
+ 'consent-result.night.refused',
1296
+ 'consent-result.night.withdrawn',
1297
+ 'reconfirmation.advertising',
1298
+ 'reconfirmation.night',
1299
+ ]);
1300
+ /**
1301
+ * The variants `notices.copy` may leave out, each with the variant whose text
1302
+ * stands in for it: a refusal is given in the withdrawal's text.
1303
+ */
1304
+ export const notificationNoticeVariantFallbacks = Object.freeze({
1305
+ 'consent-result.advertising.refused': 'consent-result.advertising.withdrawn',
1306
+ 'consent-result.night.refused': 'consent-result.night.withdrawn',
1307
+ });
1308
+ function isPlainObject(value) {
1309
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1310
+ }
1311
+ /**
1312
+ * The rules `notices.copy` follows wherever it is read (the catalog
1313
+ * validator, a backend, and the vector loader), each issue under `path`,
1314
+ * with locale and variant keys in brackets (`["ko-KR"]["reconfirmation.night"]`):
1315
+ *
1316
+ * - an object of locales that includes `defaultLocale`, matched without
1317
+ * regard to case;
1318
+ * - per locale, a title and a body for each notice variant, a refusal's
1319
+ * optional (`notificationNoticeVariantFallbacks`), and no other key;
1320
+ * - each title and body a string that is not blank, with no placeholder but
1321
+ * `{{consentDate}}` and no brace outside one; and `{{consentDate}}` in the
1322
+ * title or the body, because a notice states the date of the act it
1323
+ * reports on.
1324
+ */
1325
+ export function notificationNoticeCopyIssues(copy, defaultLocale, path = 'notices.copy') {
1326
+ if (copy === undefined) {
1327
+ return [{
1328
+ path,
1329
+ message: 'a notice states what the player did; give a title and a body for each notice variant in each locale',
1330
+ }];
1331
+ }
1332
+ if (!isPlainObject(copy)) {
1333
+ return [{
1334
+ path,
1335
+ message: 'expected NotificationNoticeCopy: each locale mapped to a title and a body per notice variant',
1336
+ }];
1337
+ }
1338
+ const issues = [];
1339
+ const variants = notificationNoticeVariants;
1340
+ // The same lookup a send makes: a locale matches a copy key without regard to case.
1341
+ if (!Object.keys(copy).some((locale) => locale.toLowerCase() === defaultLocale.toLowerCase())) {
1342
+ issues.push({ path, message: `must include the default locale ${defaultLocale}` });
1343
+ }
1344
+ for (const [locale, texts] of Object.entries(copy)) {
1345
+ const at = `${path}[${JSON.stringify(locale)}]`;
1346
+ if (!isPlainObject(texts)) {
1347
+ issues.push({
1348
+ path: at,
1349
+ message: 'expected the notice copy of one locale: a title and a body per notice variant',
1350
+ });
1351
+ continue;
1352
+ }
1353
+ for (const variant of notificationNoticeVariants) {
1354
+ if (texts[variant] === undefined && notificationNoticeVariantFallbacks[variant] === undefined) {
1355
+ issues.push({
1356
+ path: `${at}[${JSON.stringify(variant)}]`,
1357
+ message: `every locale needs a title and a body for each notice variant; ${locale} lacks ${variant}`,
1358
+ });
1359
+ }
1360
+ }
1361
+ for (const [variant, text] of Object.entries(texts)) {
1362
+ const variantPath = `${at}[${JSON.stringify(variant)}]`;
1363
+ if (!variants.includes(variant)) {
1364
+ issues.push({
1365
+ path: variantPath,
1366
+ message: `is not a notice variant; expected one of ${variants.join(', ')}`,
1367
+ });
1368
+ }
1369
+ else if (text !== undefined) {
1370
+ issues.push(...noticeTextIssues(text, variantPath));
1371
+ }
1372
+ }
1373
+ }
1374
+ return issues;
1375
+ }
1376
+ /**
1377
+ * One variant's title and body: strings, not blank, and filled only with
1378
+ * `{{consentDate}}`, which one of them uses.
1379
+ */
1380
+ function noticeTextIssues(text, path) {
1381
+ if (!isPlainObject(text)) {
1382
+ return [{ path, message: 'expected NotificationTopicCopy: a title and a body' }];
1383
+ }
1384
+ const issues = [];
1385
+ for (const key of Object.keys(text)) {
1386
+ if (key !== 'title' && key !== 'body') {
1387
+ issues.push({
1388
+ path: `${path}.${key}`,
1389
+ message: "is not part of a notice's text; a notice has only a title and a body",
1390
+ });
1391
+ }
1392
+ }
1393
+ let dated = false;
1394
+ for (const field of ['title', 'body']) {
1395
+ const value = text[field];
1396
+ if (typeof value !== 'string') {
1397
+ issues.push({ path: `${path}.${field}`, message: `expected a string ${field}` });
1398
+ continue;
1399
+ }
1400
+ if (value.trim().length === 0) {
1401
+ issues.push({ path: `${path}.${field}`, message: `a notice's ${field} must not be blank` });
1402
+ }
1403
+ const { placeholders, strayBrace } = readNotificationTemplate(value);
1404
+ for (const name of placeholders) {
1405
+ if (name === 'consentDate') {
1406
+ dated = true;
1407
+ }
1408
+ else {
1409
+ issues.push({
1410
+ path: `${path}.${field}`,
1411
+ message: `notices are filled only with {{consentDate}}; {{${name}}} would have no value`,
1412
+ });
1413
+ }
1414
+ }
1415
+ if (strayBrace) {
1416
+ issues.push({
1417
+ path: `${path}.${field}`,
1418
+ message: 'a brace outside a {{name}} placeholder reaches the player as written; write {{consentDate}}',
1419
+ });
1420
+ }
1421
+ }
1422
+ if (!dated) {
1423
+ issues.push({
1424
+ path,
1425
+ message: 'a notice states the date of the act it reports on; its title or body needs {{consentDate}}',
1426
+ });
1427
+ }
1428
+ return issues;
1429
+ }
1430
+ /**
1431
+ * The variant a notice is given in. `act` is what its record was: a consent,
1432
+ * the withdrawal of a consent, or a refusal; a re-confirmation reports on a
1433
+ * consent.
1434
+ */
1435
+ function noticeVariantOf(kind, recordKind, act) {
1436
+ return kind === 'reconfirmation'
1437
+ ? `reconfirmation.${recordKind}`
1438
+ : `consent-result.${recordKind}.${act}`;
1439
+ }
1440
+ /**
1441
+ * One variant's copy per locale, shaped as a topic's `copy`, so the notice
1442
+ * topic sends it with the lookup and default-locale fallback of any topic
1443
+ * (`findNotificationCopy`). A locale without a refusal's text gives the
1444
+ * withdrawal's (`notificationNoticeVariantFallbacks`); a locale without the
1445
+ * variant, which a validated catalog never has, is left out rather than
1446
+ * found empty.
1447
+ */
1448
+ export function notificationNoticeVariantCopy(copy, variant) {
1449
+ const fallback = notificationNoticeVariantFallbacks[variant];
1450
+ return Object.fromEntries(Object.entries(copy).flatMap(([locale, variants]) => {
1451
+ const text = variants[variant]
1452
+ ?? (fallback === undefined ? undefined : variants[fallback]);
1453
+ return text === undefined ? [] : [[locale, text]];
1454
+ }));
1455
+ }
1456
+ /**
1457
+ * Whether a provider can carry a consent notice: it sends free-form content
1458
+ * and needs no `title` or `body` in a topic's mapping, which it would send in
1459
+ * place of the notice's copy. The catalog validator keeps the notice topic
1460
+ * off any other provider, and a scheduler pushes no notice through one.
1461
+ */
1462
+ export function notificationProviderCarriesNotices(entry) {
1463
+ return entry.content === 'free-form'
1464
+ && !entry.mappingFields.includes('title')
1465
+ && !entry.mappingFields.includes('body');
1466
+ }
1467
+ /**
1468
+ * The notice topic as the gate renders one notice from it: its delivery
1469
+ * settings, any `title` or `body` dropped from its mapping so nothing stands
1470
+ * in for the copy, and the variant's `notices.copy` in place of a copy of its
1471
+ * own (`notificationNoticeVariantCopy`).
1472
+ */
1473
+ export function notificationNoticeTopic(topic, copy, variant) {
1474
+ const { mapping, copy: _ownCopy, ...delivery } = topic;
1475
+ const { title: _title, body: _body, ...rest } = mapping ?? {};
1476
+ return {
1477
+ ...delivery,
1478
+ ...(mapping === undefined ? {} : { mapping: rest }),
1479
+ copy: notificationNoticeVariantCopy(copy, variant),
1480
+ };
1481
+ }
1482
+ /** Result notice: 14 days after the record's Asia/Seoul date, counted from the next day. */
1483
+ export function notificationConsentResultDueBy(at) {
1484
+ return consentResultDueByOf(parseInstant(at));
1485
+ }
1486
+ function consentResultDueByOf(instant) {
1487
+ const wall = seoulZone.wall(instant);
1488
+ return formatCalendarDate(Date.UTC(wall.year, wall.month - 1, wall.day + 14));
1489
+ }
1490
+ /**
1491
+ * The `ordinal`-th re-confirmation notice of a consent: the day before the
1492
+ * Asia/Seoul date `2 × ordinal` years after the consent date. A 29 February
1493
+ * consent date counts as 28 February.
1494
+ */
1495
+ export function notificationReconfirmationDueBy(at, ordinal = 1) {
1496
+ if (!Number.isInteger(ordinal) || ordinal < 1) {
1497
+ throw new TypeError('A re-confirmation ordinal is a whole number from 1.');
1498
+ }
1499
+ return reconfirmationDueByOf(parseInstant(at), ordinal);
1500
+ }
1501
+ function reconfirmationDueByOf(instant, ordinal) {
1502
+ const wall = seoulZone.wall(instant);
1503
+ const day = wall.month === 2 && wall.day === 29 ? 28 : wall.day;
1504
+ return formatCalendarDate(Date.UTC(wall.year + 2 * ordinal, wall.month - 1, day - 1));
1505
+ }
1506
+ /** Days before its `dueBy` from which a re-confirmation is listed. */
1507
+ export const notificationReconfirmationListDays = 60;
1508
+ /** A notice's name as one string, with the record time as an instant in milliseconds. */
1509
+ function noticeNameOf(kind, recordKind, instant, dueBy) {
1510
+ return JSON.stringify([kind, recordKind, instant, dueBy]);
1511
+ }
1512
+ /**
1513
+ * The notices a player's advertising and night records owe at `at`, ordered
1514
+ * by `dueBy` and then `kind`, each with the variant of `notices.copy` it is
1515
+ * given in. `records` are in the order they were written. A notice that
1516
+ * `given` names is not listed:
1517
+ *
1518
+ * - a `consent-result` notice for every record, which reports a refusal for
1519
+ * a `withdrawn` record whose latest earlier record of its kind is not a
1520
+ * consent;
1521
+ * - per kind whose latest record is a consent, the first re-confirmation of
1522
+ * that consent not given yet, from `notificationReconfirmationListDays`
1523
+ * days before its `dueBy`. Each one follows the one before it, so silence
1524
+ * keeps the series going, a new consent restarts it, and a withdrawal ends
1525
+ * it.
1526
+ *
1527
+ * A listed notice stays listed after its `dueBy` until it is given.
1528
+ */
1529
+ export function listNotificationNotices(records, given, at) {
1530
+ const today = toSeoulCalendarDate(at);
1531
+ // Given notices by name, each record time parsed once.
1532
+ const givenNames = new Set(given.map(({ kind, record, dueBy }) => noticeNameOf(kind, record.kind, parseInstant(record.at), dueBy)));
1533
+ // Each advertising or night record parsed once.
1534
+ const parsed = new Map();
1535
+ const notices = [];
1536
+ // Each kind's latest record so far: a withdrawal that follows no consent is a refusal.
1537
+ const latestState = new Map();
1538
+ for (const record of records) {
1539
+ if (record.kind !== 'advertising' && record.kind !== 'night') {
1540
+ continue;
1541
+ }
1542
+ const act = record.state === 'withdrawn' && latestState.get(record.kind) !== 'granted'
1543
+ ? 'refused'
1544
+ : record.state;
1545
+ latestState.set(record.kind, record.state);
1546
+ const instant = parseInstant(record.at);
1547
+ const noticeRecord = {
1548
+ kind: record.kind,
1549
+ state: record.state,
1550
+ at: formatNotificationTimestamp(instant),
1551
+ textVersion: record.textVersion,
1552
+ };
1553
+ parsed.set(record, { instant, record: noticeRecord });
1554
+ const dueBy = consentResultDueByOf(instant);
1555
+ if (!givenNames.has(noticeNameOf('consent-result', record.kind, instant, dueBy))) {
1556
+ // Each notice gets its own copy, so changing one never changes another.
1557
+ notices.push({
1558
+ kind: 'consent-result',
1559
+ record: { ...noticeRecord },
1560
+ dueBy,
1561
+ variant: noticeVariantOf('consent-result', record.kind, act),
1562
+ });
1563
+ }
1564
+ }
1565
+ for (const kind of ['advertising', 'night']) {
1566
+ const latest = latestNotificationConsent(records, kind);
1567
+ const consent = latest === undefined ? undefined : parsed.get(latest);
1568
+ if (consent === undefined || consent.record.state !== 'granted') {
1569
+ continue;
1570
+ }
1571
+ // Each given notice has its own due date, so this ends after the last one given.
1572
+ let ordinal = 1;
1573
+ let dueBy = reconfirmationDueByOf(consent.instant, ordinal);
1574
+ while (givenNames.has(noticeNameOf('reconfirmation', kind, consent.instant, dueBy))) {
1575
+ ordinal += 1;
1576
+ dueBy = reconfirmationDueByOf(consent.instant, ordinal);
1577
+ }
1578
+ if (calendarDaysBefore(dueBy, notificationReconfirmationListDays) <= today) {
1579
+ notices.push({
1580
+ kind: 'reconfirmation',
1581
+ record: { ...consent.record },
1582
+ dueBy,
1583
+ variant: noticeVariantOf('reconfirmation', kind, 'granted'),
1584
+ });
1585
+ }
1586
+ }
1587
+ return notices.sort((left, right) => left.dueBy.localeCompare(right.dueBy) || left.kind.localeCompare(right.kind));
1588
+ }
1589
+ /** Days before `dueBy` from which the scheduler sends a notice nobody recorded. */
1590
+ export const notificationNoticeLeadDays = 7;
1591
+ /**
1592
+ * The first Asia/Seoul calendar date the scheduler sends an unrecorded notice:
1593
+ * `notificationNoticeLeadDays` days before `dueBy`.
1594
+ */
1595
+ export function notificationNoticeSendFrom(dueBy) {
1596
+ return calendarDaysBefore(dueBy, notificationNoticeLeadDays);
1597
+ }
1598
+ /** Whether `date` is a calendar date `YYYY-MM-DD` that exists, such as a notice's `dueBy`. */
1599
+ export function isNotificationCalendarDate(date) {
1600
+ return !Number.isNaN(calendarDateMillis(date));
1601
+ }
1602
+ /** UTC midnight of a `YYYY-MM-DD` date; `NaN` when it does not match or does not exist. */
1603
+ function calendarDateMillis(date) {
1604
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(date);
1605
+ if (match === null) {
1606
+ return Number.NaN;
1607
+ }
1608
+ const [year, month, day] = match.slice(1, 4).map(Number);
1609
+ if (year === undefined
1610
+ || month === undefined
1611
+ || day === undefined
1612
+ || month < 1
1613
+ || month > 12
1614
+ || day < 1
1615
+ || day > daysInMonth(year, month)) {
1616
+ return Number.NaN;
1617
+ }
1618
+ const midnight = new Date(0);
1619
+ midnight.setUTCFullYear(year, month - 1, day);
1620
+ return midnight.getTime();
1621
+ }
1622
+ /** The calendar date `days` days before `date`; a date that does not exist is a caller error. */
1623
+ function calendarDaysBefore(date, days) {
1624
+ const millis = calendarDateMillis(date);
1625
+ if (Number.isNaN(millis)) {
1626
+ throw new TypeError(`Invalid calendar date ${date}; expected YYYY-MM-DD.`);
1627
+ }
1628
+ return formatCalendarDate(millis - days * dayMs);
1629
+ }
1630
+ /**
1631
+ * Idempotency key of a scheduled notice:
1632
+ * `notice:<player>:<kind>:<record kind>:<record at>:<dueBy>`, with the player
1633
+ * percent-encoded, the record's time in the canonical form, and `dueBy` as
1634
+ * `YYYY-MM-DD`. Each notice has its own key: every re-confirmation of one
1635
+ * consent, and the notices of an advertising and a night record written at
1636
+ * one instant.
1637
+ */
1638
+ export function createNotificationNoticeIdempotencyKey(input) {
1639
+ if (input.player.length === 0 || /[\uD800-\uDFFF]/u.test(input.player)) {
1640
+ throw new TypeError('Notice idempotency keys need a well-formed, non-empty player.');
1641
+ }
1642
+ if ((input.kind !== 'consent-result' && input.kind !== 'reconfirmation')
1643
+ || (input.record.kind !== 'advertising' && input.record.kind !== 'night')
1644
+ || !isNotificationCalendarDate(input.dueBy)) {
1645
+ throw new TypeError('Notice idempotency keys need a notice kind, an advertising or night record, and a dueBy date.');
1646
+ }
1647
+ return `notice:${encodeURIComponent(input.player)}:${input.kind}:${input.record.kind}:`
1648
+ + `${normalizeNotificationTimestamp(input.record.at)}:${input.dueBy}`;
1649
+ }
1650
+ function deepFreeze(value) {
1651
+ if (typeof value === 'object' && value !== null) {
1652
+ for (const nested of Object.values(value)) {
1653
+ deepFreeze(nested);
1654
+ }
1655
+ Object.freeze(value);
1656
+ }
1657
+ return value;
1658
+ }