@mpgd/game-services 0.3.2 → 0.4.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.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export * from './client';
2
2
  export * from './contract';
3
+ export * from './notification-delivery';
4
+ export * from './progress-link';
3
5
  export * from './server';
4
6
  export * from './types';
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export * from './client';
2
2
  export * from './contract';
3
+ export * from './notification-delivery';
4
+ export * from './progress-link';
3
5
  export * from './server';
4
6
  export * from './types';
@@ -0,0 +1,106 @@
1
+ import type { NotificationTopic, PlatformTarget } from '@mpgd/platform';
2
+ export type NotificationTemplateValue = string | number | boolean;
3
+ export type NotificationTemplateData = Readonly<Record<string, NotificationTemplateValue>>;
4
+ export interface NotificationDeliveryRequest {
5
+ readonly target: PlatformTarget;
6
+ readonly topic: NotificationTopic;
7
+ readonly recipient: string;
8
+ readonly idempotencyKey: string;
9
+ readonly deepLink: string;
10
+ readonly templateData: NotificationTemplateData;
11
+ }
12
+ export interface DeliveryReceipt {
13
+ readonly providerMessageId: string;
14
+ readonly acceptedAt: string;
15
+ }
16
+ export interface NotificationDeliveryProvider {
17
+ readonly target: PlatformTarget;
18
+ /**
19
+ * MUST durably deduplicate concurrent and repeated calls by idempotencyKey
20
+ * before sending externally, using a provider key or a durable outbox.
21
+ */
22
+ deliverIdempotently(request: NotificationDeliveryRequest): Promise<DeliveryReceipt>;
23
+ }
24
+ /** Throw only when the provider can prove that no external send was attempted. */
25
+ export declare class NotificationDeliveryNotSentError extends Error {
26
+ readonly name = "NotificationDeliveryNotSentError";
27
+ }
28
+ export interface NotificationDeepLinkPolicy {
29
+ /** Exact HTTP(S) origins permitted for absolute links. Root-relative links are always allowed. */
30
+ readonly allowedOrigins?: readonly string[];
31
+ }
32
+ export interface NotificationDeliveryClaimOptions extends NotificationDeepLinkPolicy {
33
+ readonly claimedAt: string;
34
+ readonly leaseDurationMs: number;
35
+ }
36
+ export type NotificationDeliveryClaimResult = {
37
+ readonly status: 'claimed';
38
+ readonly claimToken: string;
39
+ readonly leaseExpiresAt: string;
40
+ } | {
41
+ readonly status: 'in-flight';
42
+ } | {
43
+ readonly status: 'completed';
44
+ readonly receipt: DeliveryReceipt;
45
+ };
46
+ export interface NotificationDeliveryLedger {
47
+ /**
48
+ * Atomically claims or returns an existing delivery for the idempotency key.
49
+ * Expired claims must be reclaimable so a crashed worker cannot block delivery forever.
50
+ * Implementations MUST bind the key to the full normalized request and reject payload reuse.
51
+ */
52
+ claim(request: NotificationDeliveryRequest, options: NotificationDeliveryClaimOptions): Promise<NotificationDeliveryClaimResult>;
53
+ /**
54
+ * MUST atomically fence on the current claimToken and idempotently persist one stable receipt.
55
+ * A stale token must never overwrite a reclaimed or completed delivery.
56
+ */
57
+ complete(input: {
58
+ readonly idempotencyKey: string;
59
+ readonly claimToken: string;
60
+ readonly receipt: DeliveryReceipt;
61
+ }): Promise<void>;
62
+ /** MUST delete only the current in-flight claim; stale tokens must not affect newer state. */
63
+ release(input: {
64
+ readonly idempotencyKey: string;
65
+ readonly claimToken: string;
66
+ }): Promise<void>;
67
+ }
68
+ export type NotificationDeliveryResult = {
69
+ readonly status: 'delivered';
70
+ readonly alreadyProcessed: boolean;
71
+ readonly receipt: DeliveryReceipt;
72
+ } | {
73
+ readonly status: 'in-flight';
74
+ readonly alreadyProcessed: true;
75
+ } | {
76
+ readonly status: 'unavailable';
77
+ readonly alreadyProcessed: false;
78
+ };
79
+ export interface NotificationDeliveryService {
80
+ deliver(request: NotificationDeliveryRequest): Promise<NotificationDeliveryResult>;
81
+ }
82
+ /** Process-local test helper. Production services must provide a durable ledger. */
83
+ export declare class InMemoryNotificationDeliveryLedger implements NotificationDeliveryLedger {
84
+ private readonly deliveriesByIdempotencyKey;
85
+ private nextClaimId;
86
+ claim(input: NotificationDeliveryRequest, options: NotificationDeliveryClaimOptions): Promise<NotificationDeliveryClaimResult>;
87
+ complete(input: {
88
+ readonly idempotencyKey: string;
89
+ readonly claimToken: string;
90
+ readonly receipt: DeliveryReceipt;
91
+ }): Promise<void>;
92
+ release(input: {
93
+ readonly idempotencyKey: string;
94
+ readonly claimToken: string;
95
+ }): Promise<void>;
96
+ }
97
+ export declare function createInMemoryNotificationDeliveryLedger(): InMemoryNotificationDeliveryLedger;
98
+ export declare function createNotificationDeliveryService(input: {
99
+ readonly providers: readonly NotificationDeliveryProvider[];
100
+ readonly ledger: NotificationDeliveryLedger;
101
+ readonly deepLinkPolicy?: NotificationDeepLinkPolicy;
102
+ readonly claimLeaseMs?: number;
103
+ readonly now?: () => string;
104
+ }): NotificationDeliveryService;
105
+ export declare function normalizeNotificationDeliveryRequest(input: unknown, policy?: NotificationDeepLinkPolicy): NotificationDeliveryRequest;
106
+ export declare function normalizeDeliveryReceipt(input: unknown): DeliveryReceipt;
@@ -0,0 +1,444 @@
1
+ import { assertOwnEnumerablePropertyLimit } from './validation';
2
+ const platformTargets = new Set([
3
+ 'browser',
4
+ 'android',
5
+ 'ios',
6
+ 'ait',
7
+ 'reddit',
8
+ 'telegram',
9
+ 'tauri',
10
+ ]);
11
+ const notificationTopics = new Set([
12
+ 'daily-ready',
13
+ 'streak-at-risk',
14
+ 'friend-challenge',
15
+ ]);
16
+ const defaultClaimLeaseMs = 5 * 60 * 1000;
17
+ const maxCompletionAttempts = 3;
18
+ const maxNotificationTemplateDataEntries = 128;
19
+ /** Throw only when the provider can prove that no external send was attempted. */
20
+ export class NotificationDeliveryNotSentError extends Error {
21
+ name = 'NotificationDeliveryNotSentError';
22
+ }
23
+ /** Process-local test helper. Production services must provide a durable ledger. */
24
+ export class InMemoryNotificationDeliveryLedger {
25
+ deliveriesByIdempotencyKey = new Map();
26
+ nextClaimId = 1;
27
+ async claim(input, options) {
28
+ const policy = options.allowedOrigins === undefined
29
+ ? {}
30
+ : { allowedOrigins: options.allowedOrigins };
31
+ const request = normalizeNotificationDeliveryRequest(input, policy);
32
+ const claimedAt = normalizeTimestamp(options.claimedAt, 'claimedAt');
33
+ const leaseDurationMs = normalizeLeaseDuration(options.leaseDurationMs);
34
+ const leaseExpiresAt = new Date(Date.parse(claimedAt) + leaseDurationMs).toISOString();
35
+ const fingerprint = createNotificationDeliveryFingerprint(request);
36
+ const existing = this.deliveriesByIdempotencyKey.get(request.idempotencyKey);
37
+ if (existing !== undefined) {
38
+ if (existing.fingerprint !== fingerprint) {
39
+ throw new Error('idempotencyKey cannot be reused for another notification delivery.');
40
+ }
41
+ if (existing.status === 'completed') {
42
+ return {
43
+ status: 'completed',
44
+ receipt: existing.receipt,
45
+ };
46
+ }
47
+ if (Date.parse(existing.leaseExpiresAt) > Date.parse(claimedAt)) {
48
+ return { status: 'in-flight' };
49
+ }
50
+ }
51
+ const claimToken = `notification-claim-${String(this.nextClaimId)}`;
52
+ this.nextClaimId += 1;
53
+ this.deliveriesByIdempotencyKey.set(request.idempotencyKey, {
54
+ status: 'claiming',
55
+ fingerprint,
56
+ claimToken,
57
+ leaseExpiresAt,
58
+ });
59
+ return {
60
+ status: 'claimed',
61
+ claimToken,
62
+ leaseExpiresAt,
63
+ };
64
+ }
65
+ async complete(input) {
66
+ const idempotencyKey = normalizeIdentifier(input.idempotencyKey, 'idempotencyKey');
67
+ const claimToken = normalizeIdentifier(input.claimToken, 'claimToken');
68
+ const receipt = normalizeDeliveryReceipt(input.receipt);
69
+ const existing = this.deliveriesByIdempotencyKey.get(idempotencyKey);
70
+ if (existing === undefined) {
71
+ throw new Error('Cannot complete an unclaimed notification delivery.');
72
+ }
73
+ if (existing.claimToken !== claimToken) {
74
+ throw new Error('claimToken does not own this notification delivery.');
75
+ }
76
+ if (existing.status === 'completed') {
77
+ if (!deliveryReceiptsEqual(existing.receipt, receipt)) {
78
+ throw new Error('A completed notification delivery cannot change its receipt.');
79
+ }
80
+ return;
81
+ }
82
+ this.deliveriesByIdempotencyKey.set(idempotencyKey, {
83
+ status: 'completed',
84
+ fingerprint: existing.fingerprint,
85
+ claimToken,
86
+ receipt,
87
+ });
88
+ }
89
+ async release(input) {
90
+ const idempotencyKey = normalizeIdentifier(input.idempotencyKey, 'idempotencyKey');
91
+ const claimToken = normalizeIdentifier(input.claimToken, 'claimToken');
92
+ const existing = this.deliveriesByIdempotencyKey.get(idempotencyKey);
93
+ if (existing === undefined) {
94
+ return;
95
+ }
96
+ if (existing.claimToken !== claimToken) {
97
+ throw new Error('claimToken does not own this notification delivery.');
98
+ }
99
+ if (existing.status === 'completed') {
100
+ throw new Error('A completed notification delivery cannot be released.');
101
+ }
102
+ this.deliveriesByIdempotencyKey.delete(idempotencyKey);
103
+ }
104
+ }
105
+ export function createInMemoryNotificationDeliveryLedger() {
106
+ return new InMemoryNotificationDeliveryLedger();
107
+ }
108
+ export function createNotificationDeliveryService(input) {
109
+ const providersByTarget = new Map();
110
+ const allowedDeepLinkOrigins = normalizeAllowedDeepLinkOrigins(input.deepLinkPolicy?.allowedOrigins);
111
+ const ledger = input.ledger;
112
+ const claimLeaseMs = normalizeLeaseDuration(input.claimLeaseMs ?? defaultClaimLeaseMs);
113
+ const now = input.now ?? (() => new Date().toISOString());
114
+ for (const provider of input.providers) {
115
+ const target = normalizePlatformTarget(provider.target);
116
+ if (providersByTarget.has(target)) {
117
+ throw new Error(`Only one notification provider can be registered for ${target}.`);
118
+ }
119
+ providersByTarget.set(target, provider);
120
+ }
121
+ const claimDelivery = async (request) => {
122
+ const claimedAt = normalizeTimestamp(now(), 'now()');
123
+ const result = await ledger.claim(request, {
124
+ claimedAt,
125
+ leaseDurationMs: claimLeaseMs,
126
+ allowedOrigins: [...allowedDeepLinkOrigins],
127
+ });
128
+ return normalizeNotificationDeliveryClaimResult(result, claimedAt);
129
+ };
130
+ const completeDelivery = async (request, initialClaimToken, receipt) => {
131
+ let claimToken = initialClaimToken;
132
+ let firstCompletionError;
133
+ // Bound total persistence attempts (the initial claim plus at most two reclaimed claims).
134
+ for (let attempt = 0; attempt < maxCompletionAttempts; attempt += 1) {
135
+ try {
136
+ await ledger.complete({
137
+ idempotencyKey: request.idempotencyKey,
138
+ claimToken,
139
+ receipt,
140
+ });
141
+ return undefined;
142
+ }
143
+ catch (completionError) {
144
+ if (firstCompletionError === undefined) {
145
+ firstCompletionError = completionError;
146
+ }
147
+ else {
148
+ attachSecondaryError(firstCompletionError, completionError);
149
+ }
150
+ let reconciledClaim;
151
+ try {
152
+ reconciledClaim = await claimDelivery(request);
153
+ }
154
+ catch (reconciliationError) {
155
+ attachSecondaryError(firstCompletionError, reconciliationError);
156
+ throw firstCompletionError;
157
+ }
158
+ if (reconciledClaim.status === 'completed') {
159
+ return {
160
+ status: 'delivered',
161
+ alreadyProcessed: true,
162
+ receipt: reconciledClaim.receipt,
163
+ };
164
+ }
165
+ if (reconciledClaim.status === 'in-flight') {
166
+ return {
167
+ status: 'in-flight',
168
+ alreadyProcessed: true,
169
+ };
170
+ }
171
+ claimToken = reconciledClaim.claimToken;
172
+ }
173
+ }
174
+ try {
175
+ await ledger.release({
176
+ idempotencyKey: request.idempotencyKey,
177
+ claimToken,
178
+ });
179
+ }
180
+ catch (releaseError) {
181
+ attachSecondaryError(firstCompletionError, releaseError);
182
+ }
183
+ throw firstCompletionError;
184
+ };
185
+ return {
186
+ async deliver(deliveryInput) {
187
+ const request = normalizeNotificationDeliveryRequest(deliveryInput, {
188
+ allowedOrigins: [...allowedDeepLinkOrigins],
189
+ });
190
+ const claim = await claimDelivery(request);
191
+ if (claim.status === 'completed') {
192
+ return {
193
+ status: 'delivered',
194
+ alreadyProcessed: true,
195
+ receipt: claim.receipt,
196
+ };
197
+ }
198
+ if (claim.status === 'in-flight') {
199
+ return {
200
+ status: 'in-flight',
201
+ alreadyProcessed: true,
202
+ };
203
+ }
204
+ const provider = providersByTarget.get(request.target);
205
+ if (provider === undefined) {
206
+ await ledger.release({
207
+ idempotencyKey: request.idempotencyKey,
208
+ claimToken: claim.claimToken,
209
+ });
210
+ return {
211
+ status: 'unavailable',
212
+ alreadyProcessed: false,
213
+ };
214
+ }
215
+ let receiptInput;
216
+ try {
217
+ receiptInput = await provider.deliverIdempotently(request);
218
+ }
219
+ catch (error) {
220
+ if (error instanceof NotificationDeliveryNotSentError) {
221
+ try {
222
+ await ledger.release({
223
+ idempotencyKey: request.idempotencyKey,
224
+ claimToken: claim.claimToken,
225
+ });
226
+ }
227
+ catch (releaseError) {
228
+ attachSecondaryError(error, releaseError);
229
+ }
230
+ }
231
+ throw error;
232
+ }
233
+ // Once a provider resolves, delivery may already have happened. Keep the
234
+ // claim on validation or completion failure so an immediate retry cannot
235
+ // send a duplicate; the lease provides explicit recovery semantics.
236
+ const receipt = normalizeDeliveryReceipt(receiptInput);
237
+ const reconciliation = await completeDelivery(request, claim.claimToken, receipt);
238
+ if (reconciliation !== undefined) {
239
+ return reconciliation;
240
+ }
241
+ return {
242
+ status: 'delivered',
243
+ alreadyProcessed: false,
244
+ receipt,
245
+ };
246
+ },
247
+ };
248
+ }
249
+ export function normalizeNotificationDeliveryRequest(input, policy = {}) {
250
+ assertRecord(input, 'NotificationDeliveryRequest');
251
+ const allowedDeepLinkOrigins = normalizeAllowedDeepLinkOrigins(policy.allowedOrigins);
252
+ return {
253
+ target: normalizePlatformTarget(input.target),
254
+ topic: normalizeNotificationTopic(input.topic),
255
+ recipient: normalizeIdentifier(input.recipient, 'recipient'),
256
+ idempotencyKey: normalizeIdentifier(input.idempotencyKey, 'idempotencyKey'),
257
+ deepLink: normalizeDeepLink(input.deepLink, allowedDeepLinkOrigins),
258
+ templateData: normalizeTemplateData(input.templateData),
259
+ };
260
+ }
261
+ function normalizePlatformTarget(input) {
262
+ const target = normalizeIdentifier(input, 'target');
263
+ if (!platformTargets.has(target)) {
264
+ throw new Error('target must be a supported PlatformTarget.');
265
+ }
266
+ return target;
267
+ }
268
+ function normalizeNotificationTopic(input) {
269
+ const topic = normalizeIdentifier(input, 'topic');
270
+ if (!notificationTopics.has(topic)) {
271
+ throw new Error('topic must be a supported NotificationTopic.');
272
+ }
273
+ return topic;
274
+ }
275
+ export function normalizeDeliveryReceipt(input) {
276
+ assertRecord(input, 'DeliveryReceipt');
277
+ return {
278
+ providerMessageId: normalizeIdentifier(input.providerMessageId, 'providerMessageId'),
279
+ acceptedAt: normalizeTimestamp(input.acceptedAt, 'acceptedAt'),
280
+ };
281
+ }
282
+ function normalizeNotificationDeliveryClaimResult(input, claimedAt) {
283
+ assertRecord(input, 'NotificationDeliveryClaimResult');
284
+ switch (input.status) {
285
+ case 'claimed': {
286
+ const leaseExpiresAt = normalizeTimestamp(input.leaseExpiresAt, 'leaseExpiresAt');
287
+ if (Date.parse(leaseExpiresAt) <= Date.parse(claimedAt)) {
288
+ throw new Error('Notification delivery claim lease must expire in the future.');
289
+ }
290
+ return {
291
+ status: 'claimed',
292
+ claimToken: normalizeIdentifier(input.claimToken, 'claimToken'),
293
+ leaseExpiresAt,
294
+ };
295
+ }
296
+ case 'in-flight':
297
+ return { status: 'in-flight' };
298
+ case 'completed':
299
+ return {
300
+ status: 'completed',
301
+ receipt: normalizeDeliveryReceipt(input.receipt),
302
+ };
303
+ default:
304
+ throw new Error('NotificationDeliveryClaimResult has an unsupported status.');
305
+ }
306
+ }
307
+ function normalizeTemplateData(input) {
308
+ assertRecord(input, 'templateData');
309
+ assertOwnEnumerablePropertyLimit(input, maxNotificationTemplateDataEntries, 'templateData');
310
+ const inputEntries = Object.entries(input);
311
+ const entries = inputEntries.map(([key, value]) => {
312
+ const normalizedKey = normalizeIdentifier(key, 'templateData key');
313
+ if (typeof value !== 'string'
314
+ && typeof value !== 'number'
315
+ && typeof value !== 'boolean') {
316
+ throw new Error(`templateData.${key} must be a string, number, or boolean.`);
317
+ }
318
+ if (typeof value === 'number' && !Number.isFinite(value)) {
319
+ throw new Error(`templateData.${key} must be a finite number.`);
320
+ }
321
+ if (typeof value === 'string' && value.length > 16384) {
322
+ throw new Error(`templateData.${key} must contain at most 16384 characters.`);
323
+ }
324
+ return [normalizedKey, value];
325
+ });
326
+ entries.sort(([left], [right]) => compareCodeUnits(left, right));
327
+ return Object.fromEntries(entries);
328
+ }
329
+ function normalizeDeepLink(input, allowedOrigins) {
330
+ const deepLink = normalizeIdentifier(input, 'deepLink');
331
+ if (/[\x00-\x1F\x7F]/u.test(deepLink)) {
332
+ throw new Error('deepLink must not contain control characters.');
333
+ }
334
+ if (deepLink.startsWith('/') && !deepLink.startsWith('//')) {
335
+ const baseUrl = new URL('https://mpgd.invalid');
336
+ const parsed = new URL(deepLink, baseUrl);
337
+ if (parsed.origin !== baseUrl.origin) {
338
+ throw new Error('deepLink must stay on the configured game origin.');
339
+ }
340
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
341
+ }
342
+ let parsed;
343
+ try {
344
+ parsed = new URL(deepLink);
345
+ }
346
+ catch {
347
+ throw new Error('deepLink must be an absolute URL or root-relative path.');
348
+ }
349
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
350
+ throw new Error('deepLink must use an HTTP(S) URL.');
351
+ }
352
+ if (parsed.username !== '' || parsed.password !== '') {
353
+ throw new Error('deepLink must not contain URL credentials.');
354
+ }
355
+ if (!allowedOrigins.has(parsed.origin)) {
356
+ throw new Error('deepLink origin is not allowed.');
357
+ }
358
+ return parsed.toString();
359
+ }
360
+ function normalizeAllowedDeepLinkOrigins(input) {
361
+ const origins = new Set();
362
+ for (const [index, value] of (input ?? []).entries()) {
363
+ const originInput = normalizeIdentifier(value, `allowedOrigins[${String(index)}]`);
364
+ let parsed;
365
+ try {
366
+ parsed = new URL(originInput);
367
+ }
368
+ catch {
369
+ throw new Error(`allowedOrigins[${String(index)}] must be an HTTP(S) origin.`);
370
+ }
371
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
372
+ throw new Error(`allowedOrigins[${String(index)}] must be an HTTP(S) origin.`);
373
+ }
374
+ if (parsed.username !== ''
375
+ || parsed.password !== ''
376
+ || parsed.pathname !== '/'
377
+ || parsed.search !== ''
378
+ || parsed.hash !== '') {
379
+ throw new Error(`allowedOrigins[${String(index)}] must not include credentials or a path.`);
380
+ }
381
+ origins.add(parsed.origin);
382
+ }
383
+ return origins;
384
+ }
385
+ function normalizeLeaseDuration(input) {
386
+ if (typeof input !== 'number'
387
+ || !Number.isSafeInteger(input)
388
+ || input <= 0
389
+ || input > 24 * 60 * 60 * 1000) {
390
+ throw new Error('claimLeaseMs must be a positive integer no greater than 24 hours.');
391
+ }
392
+ return input;
393
+ }
394
+ function createNotificationDeliveryFingerprint(request) {
395
+ return JSON.stringify([
396
+ request.target,
397
+ request.topic,
398
+ request.recipient,
399
+ request.deepLink,
400
+ request.templateData,
401
+ ]);
402
+ }
403
+ function deliveryReceiptsEqual(left, right) {
404
+ return (left.providerMessageId === right.providerMessageId
405
+ && left.acceptedAt === right.acceptedAt);
406
+ }
407
+ function compareCodeUnits(left, right) {
408
+ if (left < right) {
409
+ return -1;
410
+ }
411
+ return left > right ? 1 : 0;
412
+ }
413
+ function attachSecondaryError(primary, secondary) {
414
+ if (primary instanceof Error && primary.cause === undefined) {
415
+ primary.cause = secondary;
416
+ }
417
+ }
418
+ function assertRecord(input, label) {
419
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
420
+ throw new Error(`${label} must be an object.`);
421
+ }
422
+ }
423
+ function normalizeIdentifier(input, label) {
424
+ if (typeof input !== 'string'
425
+ || input.length === 0
426
+ || input.length > 2048
427
+ || input.trim() !== input) {
428
+ throw new Error(`${label} must be a non-empty, trimmed string.`);
429
+ }
430
+ if (/[\x00-\x1F\x7F]/u.test(input)) {
431
+ throw new Error(`${label} must not contain control characters.`);
432
+ }
433
+ return input;
434
+ }
435
+ function normalizeTimestamp(input, label) {
436
+ if (typeof input !== 'string') {
437
+ throw new Error(`${label} must be a valid timestamp.`);
438
+ }
439
+ const timestamp = Date.parse(input);
440
+ if (!Number.isFinite(timestamp)) {
441
+ throw new Error(`${label} must be a valid timestamp.`);
442
+ }
443
+ return new Date(timestamp).toISOString();
444
+ }
@@ -0,0 +1,96 @@
1
+ export type GameProgressValue = null | boolean | number | string | readonly GameProgressValue[] | {
2
+ readonly [key: string]: GameProgressValue;
3
+ };
4
+ export interface ActiveGameProgress {
5
+ readonly id: string;
6
+ readonly updatedAt: string;
7
+ readonly payload: {
8
+ readonly [key: string]: GameProgressValue;
9
+ };
10
+ }
11
+ export interface GameProgressSnapshot {
12
+ readonly completedIds: readonly string[];
13
+ readonly bestTimesMs: Readonly<Record<string, number>>;
14
+ readonly bestScores: Readonly<Record<string, number>>;
15
+ readonly activeProgress?: ActiveGameProgress;
16
+ }
17
+ export declare const gameProgressLimits: {
18
+ readonly maxIdentifierLength: 256;
19
+ readonly maxCompletedIds: 512;
20
+ readonly maxMetricEntries: 512;
21
+ readonly maxPayloadDepth: 16;
22
+ readonly maxPayloadNodes: 2048;
23
+ readonly maxPayloadStringUnits: 32768;
24
+ };
25
+ export interface ServerResolvedPlayerContext {
26
+ readonly authoritativePlayerId: string;
27
+ }
28
+ export interface GuestProgressHandoffRequest {
29
+ readonly guestId: string;
30
+ readonly handoffNonce: string;
31
+ readonly idempotencyKey: string;
32
+ readonly guestProgress: GameProgressSnapshot;
33
+ }
34
+ export interface ReconcileGuestProgressRequest extends GuestProgressHandoffRequest {
35
+ readonly authoritativePlayerId: string;
36
+ }
37
+ export interface ProgressHandoffVerificationRequest {
38
+ readonly handoffNonce: string;
39
+ }
40
+ export interface VerifiedProgressHandoff {
41
+ readonly handoffNonce: string;
42
+ readonly authoritativePlayerId: string;
43
+ readonly guestId: string;
44
+ readonly issuedAt: string;
45
+ readonly expiresAt: string;
46
+ }
47
+ export interface ProgressHandoffVerifier {
48
+ verify(request: ProgressHandoffVerificationRequest): Promise<VerifiedProgressHandoff | undefined>;
49
+ }
50
+ export interface VerifyGuestProgressRequest {
51
+ readonly authoritativePlayerId: string;
52
+ readonly guestId: string;
53
+ readonly handoffNonce: string;
54
+ readonly progress: GameProgressSnapshot;
55
+ }
56
+ export interface GuestProgressVerifier {
57
+ /** Validates client metrics and returns only server-accepted, non-authoritative progress. */
58
+ verify(request: VerifyGuestProgressRequest): Promise<GameProgressSnapshot>;
59
+ }
60
+ export type ProgressLinkDeduplication = 'none' | 'idempotency-key' | 'handoff-nonce';
61
+ export interface ReconcileGuestProgressResult {
62
+ readonly authoritativePlayerId: string;
63
+ readonly progress: GameProgressSnapshot;
64
+ readonly alreadyProcessed: boolean;
65
+ readonly deduplicatedBy: ProgressLinkDeduplication;
66
+ }
67
+ export interface ProgressLinkStore {
68
+ /** Atomically deduplicates by both idempotency key and handoff nonce before merging. */
69
+ reconcile(request: ReconcileGuestProgressRequest): Promise<ReconcileGuestProgressResult>;
70
+ }
71
+ export interface ProgressLinkService {
72
+ reconcileGuestProgress(context: ServerResolvedPlayerContext, request: GuestProgressHandoffRequest): Promise<ReconcileGuestProgressResult>;
73
+ }
74
+ export interface AuthoritativeProgressRecord {
75
+ readonly authoritativePlayerId: string;
76
+ readonly progress: GameProgressSnapshot;
77
+ }
78
+ /** Process-local test helper. Production services must provide a durable store. */
79
+ export declare class InMemoryProgressLinkStore implements ProgressLinkStore {
80
+ private readonly progressByPlayerId;
81
+ private readonly reconciliationsByIdempotencyKey;
82
+ private readonly reconciliationsByHandoffNonce;
83
+ constructor(initialProgress?: readonly AuthoritativeProgressRecord[]);
84
+ reconcile(input: ReconcileGuestProgressRequest): Promise<ReconcileGuestProgressResult>;
85
+ getProgress(authoritativePlayerId: string): Promise<GameProgressSnapshot | undefined>;
86
+ }
87
+ export declare function createInMemoryProgressLinkStore(initialProgress?: readonly AuthoritativeProgressRecord[]): InMemoryProgressLinkStore;
88
+ export declare function createProgressLinkService(input: {
89
+ readonly store: ProgressLinkStore;
90
+ readonly handoffVerifier: ProgressHandoffVerifier;
91
+ readonly progressVerifier: GuestProgressVerifier;
92
+ readonly now?: () => string;
93
+ }): ProgressLinkService;
94
+ export declare function createEmptyGameProgressSnapshot(): GameProgressSnapshot;
95
+ export declare function normalizeGameProgressSnapshot(input: unknown): GameProgressSnapshot;
96
+ export declare function mergeGameProgressSnapshots(serverInput: GameProgressSnapshot, guestInput: GameProgressSnapshot): GameProgressSnapshot;
@@ -0,0 +1,394 @@
1
+ import { assertOwnEnumerablePropertyLimit } from './validation';
2
+ export const gameProgressLimits = {
3
+ maxIdentifierLength: 256,
4
+ maxCompletedIds: 512,
5
+ maxMetricEntries: 512,
6
+ maxPayloadDepth: 16,
7
+ maxPayloadNodes: 2048,
8
+ maxPayloadStringUnits: 32768,
9
+ };
10
+ const snapshotFields = new Set(['completedIds', 'bestTimesMs', 'bestScores', 'activeProgress']);
11
+ const activeProgressFields = new Set(['id', 'updatedAt', 'payload']);
12
+ /** Process-local test helper. Production services must provide a durable store. */
13
+ export class InMemoryProgressLinkStore {
14
+ progressByPlayerId = new Map();
15
+ reconciliationsByIdempotencyKey = new Map();
16
+ reconciliationsByHandoffNonce = new Map();
17
+ constructor(initialProgress = []) {
18
+ for (const record of initialProgress) {
19
+ const authoritativePlayerId = normalizeIdentifier(record.authoritativePlayerId, 'authoritativePlayerId');
20
+ this.progressByPlayerId.set(authoritativePlayerId, normalizeGameProgressSnapshot(record.progress));
21
+ }
22
+ }
23
+ async reconcile(input) {
24
+ const request = normalizeReconcileGuestProgressRequest(input);
25
+ const byIdempotencyKey = this.reconciliationsByIdempotencyKey.get(request.idempotencyKey);
26
+ const byHandoffNonce = this.reconciliationsByHandoffNonce.get(request.handoffNonce);
27
+ if (byIdempotencyKey !== undefined
28
+ && byHandoffNonce !== undefined
29
+ && byIdempotencyKey !== byHandoffNonce) {
30
+ throw new Error('idempotencyKey and handoffNonce belong to different reconciliations.');
31
+ }
32
+ const existing = byIdempotencyKey ?? byHandoffNonce;
33
+ if (existing !== undefined) {
34
+ assertMatchingProgressLinkIdentity(existing, request);
35
+ this.reconciliationsByIdempotencyKey.set(request.idempotencyKey, existing);
36
+ this.reconciliationsByHandoffNonce.set(request.handoffNonce, existing);
37
+ return {
38
+ authoritativePlayerId: existing.authoritativePlayerId,
39
+ progress: normalizeGameProgressSnapshot(existing.progress),
40
+ alreadyProcessed: true,
41
+ deduplicatedBy: byIdempotencyKey === undefined ? 'handoff-nonce' : 'idempotency-key',
42
+ };
43
+ }
44
+ const serverProgress = this.progressByPlayerId.get(request.authoritativePlayerId)
45
+ ?? createEmptyGameProgressSnapshot();
46
+ const progress = mergeGameProgressSnapshots(serverProgress, request.guestProgress);
47
+ const reconciliation = {
48
+ authoritativePlayerId: request.authoritativePlayerId,
49
+ guestId: request.guestId,
50
+ progress,
51
+ };
52
+ this.progressByPlayerId.set(request.authoritativePlayerId, progress);
53
+ this.reconciliationsByIdempotencyKey.set(request.idempotencyKey, reconciliation);
54
+ this.reconciliationsByHandoffNonce.set(request.handoffNonce, reconciliation);
55
+ return {
56
+ authoritativePlayerId: request.authoritativePlayerId,
57
+ progress: normalizeGameProgressSnapshot(progress),
58
+ alreadyProcessed: false,
59
+ deduplicatedBy: 'none',
60
+ };
61
+ }
62
+ async getProgress(authoritativePlayerId) {
63
+ const progress = this.progressByPlayerId.get(normalizeIdentifier(authoritativePlayerId, 'authoritativePlayerId'));
64
+ return progress === undefined ? undefined : normalizeGameProgressSnapshot(progress);
65
+ }
66
+ }
67
+ export function createInMemoryProgressLinkStore(initialProgress = []) {
68
+ return new InMemoryProgressLinkStore(initialProgress);
69
+ }
70
+ export function createProgressLinkService(input) {
71
+ const now = input.now ?? (() => new Date().toISOString());
72
+ return {
73
+ async reconcileGuestProgress(contextInput, handoffInput) {
74
+ const context = normalizeServerResolvedPlayerContext(contextInput);
75
+ const metadata = normalizeGuestProgressHandoffMetadata(handoffInput);
76
+ await verifyProgressHandoff(input.handoffVerifier, context, metadata, now);
77
+ // These validations deliberately stay at separate trust boundaries: the
78
+ // client request, verifier output, store input, and store output may each
79
+ // come from independently implemented code. Payload limits keep every
80
+ // traversal bounded while preventing a typed-but-unvalidated value from
81
+ // skipping runtime validation.
82
+ const guestProgress = normalizeGameProgressSnapshot(handoffInput.guestProgress);
83
+ const verifiedProgress = normalizeGameProgressSnapshot(await input.progressVerifier.verify({
84
+ authoritativePlayerId: context.authoritativePlayerId,
85
+ guestId: metadata.guestId,
86
+ handoffNonce: metadata.handoffNonce,
87
+ progress: guestProgress,
88
+ }));
89
+ const result = await input.store.reconcile({
90
+ authoritativePlayerId: context.authoritativePlayerId,
91
+ ...metadata,
92
+ guestProgress: verifiedProgress,
93
+ });
94
+ return normalizeReconcileGuestProgressResult(result, context.authoritativePlayerId);
95
+ },
96
+ };
97
+ }
98
+ export function createEmptyGameProgressSnapshot() {
99
+ return {
100
+ completedIds: [],
101
+ bestTimesMs: {},
102
+ bestScores: {},
103
+ };
104
+ }
105
+ export function normalizeGameProgressSnapshot(input) {
106
+ assertRecord(input, 'GameProgressSnapshot');
107
+ assertOnlyFields(input, snapshotFields, 'GameProgressSnapshot');
108
+ if (!Array.isArray(input.completedIds)) {
109
+ throw new Error('completedIds must be an array.');
110
+ }
111
+ assertCollectionLimit(input.completedIds.length, gameProgressLimits.maxCompletedIds, 'completedIds');
112
+ const completedIds = [...new Set(Array.from(input.completedIds, (id, index) => (normalizeIdentifier(id, `completedIds[${String(index)}]`))))].sort();
113
+ const bestTimesMs = normalizeMetricMap(input.bestTimesMs, 'bestTimesMs', (value, label) => {
114
+ if (value < 0) {
115
+ throw new Error(`${label} must be greater than or equal to zero.`);
116
+ }
117
+ });
118
+ const bestScores = normalizeMetricMap(input.bestScores, 'bestScores');
119
+ if (input.activeProgress === undefined) {
120
+ return {
121
+ completedIds,
122
+ bestTimesMs,
123
+ bestScores,
124
+ };
125
+ }
126
+ return {
127
+ completedIds,
128
+ bestTimesMs,
129
+ bestScores,
130
+ activeProgress: normalizeActiveProgress(input.activeProgress),
131
+ };
132
+ }
133
+ export function mergeGameProgressSnapshots(serverInput, guestInput) {
134
+ // This exported helper is also a runtime boundary; TypeScript types alone do
135
+ // not prove that either snapshot came from a trusted or validated source.
136
+ const server = normalizeGameProgressSnapshot(serverInput);
137
+ const guest = normalizeGameProgressSnapshot(guestInput);
138
+ const activeProgress = selectActiveProgress(server.activeProgress, guest.activeProgress);
139
+ const completedIds = [...new Set([...server.completedIds, ...guest.completedIds])].sort();
140
+ assertCollectionLimit(completedIds.length, gameProgressLimits.maxCompletedIds, 'merged completedIds');
141
+ return {
142
+ completedIds,
143
+ bestTimesMs: mergeMetricMaps(server.bestTimesMs, guest.bestTimesMs, Math.min),
144
+ bestScores: mergeMetricMaps(server.bestScores, guest.bestScores, Math.max),
145
+ ...(activeProgress === undefined ? {} : { activeProgress }),
146
+ };
147
+ }
148
+ function normalizeReconcileGuestProgressRequest(input) {
149
+ assertRecord(input, 'ReconcileGuestProgressRequest');
150
+ const metadata = normalizeGuestProgressHandoffMetadata(input);
151
+ return {
152
+ authoritativePlayerId: normalizeIdentifier(input.authoritativePlayerId, 'authoritativePlayerId'),
153
+ ...metadata,
154
+ guestProgress: normalizeGameProgressSnapshot(input.guestProgress),
155
+ };
156
+ }
157
+ function normalizeServerResolvedPlayerContext(input) {
158
+ assertRecord(input, 'ServerResolvedPlayerContext');
159
+ return {
160
+ authoritativePlayerId: normalizeIdentifier(input.authoritativePlayerId, 'authoritativePlayerId'),
161
+ };
162
+ }
163
+ function normalizeGuestProgressHandoffMetadata(input) {
164
+ assertRecord(input, 'GuestProgressHandoffRequest');
165
+ return {
166
+ guestId: normalizeIdentifier(input.guestId, 'guestId'),
167
+ handoffNonce: normalizeIdentifier(input.handoffNonce, 'handoffNonce'),
168
+ idempotencyKey: normalizeIdentifier(input.idempotencyKey, 'idempotencyKey'),
169
+ };
170
+ }
171
+ async function verifyProgressHandoff(verifier, context, request, now) {
172
+ const handoff = await verifier.verify({ handoffNonce: request.handoffNonce });
173
+ if (handoff === undefined) {
174
+ throw new Error('Progress handoff is invalid or expired.');
175
+ }
176
+ const verified = normalizeVerifiedProgressHandoff(handoff);
177
+ const currentTime = Date.parse(normalizeTimestamp(now(), 'now()'));
178
+ const issuedAt = Date.parse(verified.issuedAt);
179
+ const expiresAt = Date.parse(verified.expiresAt);
180
+ if (verified.handoffNonce !== request.handoffNonce
181
+ || verified.authoritativePlayerId !== context.authoritativePlayerId
182
+ || verified.guestId !== request.guestId
183
+ || issuedAt > currentTime
184
+ || expiresAt <= issuedAt
185
+ || expiresAt <= currentTime) {
186
+ throw new Error('Progress handoff is invalid or expired.');
187
+ }
188
+ }
189
+ function normalizeReconcileGuestProgressResult(input, expectedPlayerId) {
190
+ assertRecord(input, 'ReconcileGuestProgressResult');
191
+ const authoritativePlayerId = normalizeIdentifier(input.authoritativePlayerId, 'result authoritativePlayerId');
192
+ if (authoritativePlayerId !== expectedPlayerId) {
193
+ throw new Error('Progress link store returned a result for another player.');
194
+ }
195
+ if (typeof input.alreadyProcessed !== 'boolean') {
196
+ throw new Error('Progress link store returned an invalid alreadyProcessed value.');
197
+ }
198
+ if (input.deduplicatedBy !== 'none'
199
+ && input.deduplicatedBy !== 'idempotency-key'
200
+ && input.deduplicatedBy !== 'handoff-nonce') {
201
+ throw new Error('Progress link store returned an invalid deduplicatedBy value.');
202
+ }
203
+ if (input.alreadyProcessed !== (input.deduplicatedBy !== 'none')) {
204
+ throw new Error('Progress link store returned inconsistent deduplication state.');
205
+ }
206
+ return {
207
+ authoritativePlayerId,
208
+ progress: normalizeGameProgressSnapshot(input.progress),
209
+ alreadyProcessed: input.alreadyProcessed,
210
+ deduplicatedBy: input.deduplicatedBy,
211
+ };
212
+ }
213
+ function normalizeVerifiedProgressHandoff(input) {
214
+ assertRecord(input, 'VerifiedProgressHandoff');
215
+ return {
216
+ handoffNonce: normalizeIdentifier(input.handoffNonce, 'verified handoffNonce'),
217
+ authoritativePlayerId: normalizeIdentifier(input.authoritativePlayerId, 'verified authoritativePlayerId'),
218
+ guestId: normalizeIdentifier(input.guestId, 'verified guestId'),
219
+ issuedAt: normalizeTimestamp(input.issuedAt, 'verified issuedAt'),
220
+ expiresAt: normalizeTimestamp(input.expiresAt, 'verified expiresAt'),
221
+ };
222
+ }
223
+ function normalizeActiveProgress(input) {
224
+ assertRecord(input, 'activeProgress');
225
+ assertOnlyFields(input, activeProgressFields, 'activeProgress');
226
+ return {
227
+ id: normalizeIdentifier(input.id, 'activeProgress.id'),
228
+ updatedAt: normalizeTimestamp(input.updatedAt, 'activeProgress.updatedAt'),
229
+ payload: normalizeProgressPayload(input.payload),
230
+ };
231
+ }
232
+ function normalizeMetricMap(input, label, validate) {
233
+ assertRecord(input, label);
234
+ assertOwnEnumerablePropertyLimit(input, gameProgressLimits.maxMetricEntries, label);
235
+ const inputEntries = Object.entries(input);
236
+ const entries = inputEntries.map(([key, value]) => {
237
+ const normalizedKey = normalizeIdentifier(key, `${label} key`);
238
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
239
+ throw new Error(`${label}.${key} must be a finite number.`);
240
+ }
241
+ validate?.(value, `${label}.${key}`);
242
+ return [normalizedKey, value];
243
+ });
244
+ entries.sort(([left], [right]) => compareCodeUnits(left, right));
245
+ return Object.fromEntries(entries);
246
+ }
247
+ function mergeMetricMaps(server, guest, select) {
248
+ const keys = [...new Set([...Object.keys(server), ...Object.keys(guest)])].sort();
249
+ assertCollectionLimit(keys.length, gameProgressLimits.maxMetricEntries, 'merged metric map');
250
+ const mergedEntries = [];
251
+ for (const key of keys) {
252
+ const serverValue = Object.hasOwn(server, key) ? server[key] : undefined;
253
+ const guestValue = Object.hasOwn(guest, key) ? guest[key] : undefined;
254
+ if (serverValue === undefined) {
255
+ if (guestValue !== undefined) {
256
+ mergedEntries.push([key, guestValue]);
257
+ }
258
+ continue;
259
+ }
260
+ if (guestValue === undefined) {
261
+ mergedEntries.push([key, serverValue]);
262
+ continue;
263
+ }
264
+ mergedEntries.push([key, select(serverValue, guestValue)]);
265
+ }
266
+ return Object.fromEntries(mergedEntries);
267
+ }
268
+ function selectActiveProgress(server, guest) {
269
+ if (server === undefined) {
270
+ return guest;
271
+ }
272
+ if (guest === undefined) {
273
+ return server;
274
+ }
275
+ return Date.parse(guest.updatedAt) > Date.parse(server.updatedAt) ? guest : server;
276
+ }
277
+ function normalizeProgressPayload(input) {
278
+ assertRecord(input, 'activeProgress.payload');
279
+ const normalized = normalizeGameProgressValue(input, 'activeProgress.payload', new Set(), { nodes: 0, stringUnits: 0 }, 0);
280
+ if (!isGameProgressRecord(normalized)) {
281
+ throw new Error('activeProgress.payload must be an object.');
282
+ }
283
+ return normalized;
284
+ }
285
+ function normalizeGameProgressValue(input, label, ancestors, budget, depth) {
286
+ if (depth > gameProgressLimits.maxPayloadDepth) {
287
+ throw new Error(`activeProgress.payload must not exceed depth ${String(gameProgressLimits.maxPayloadDepth)}.`);
288
+ }
289
+ budget.nodes += 1;
290
+ if (budget.nodes > gameProgressLimits.maxPayloadNodes) {
291
+ throw new Error(`activeProgress.payload must not exceed ${String(gameProgressLimits.maxPayloadNodes)} nodes.`);
292
+ }
293
+ if (input === null
294
+ || typeof input === 'boolean') {
295
+ return input;
296
+ }
297
+ if (typeof input === 'string') {
298
+ consumePayloadStringBudget(budget, input.length);
299
+ return input;
300
+ }
301
+ if (typeof input === 'number') {
302
+ if (!Number.isFinite(input)) {
303
+ throw new Error(`${label} must contain only finite numbers.`);
304
+ }
305
+ return input;
306
+ }
307
+ if (typeof input !== 'object') {
308
+ throw new Error(`${label} must be JSON-compatible.`);
309
+ }
310
+ if (ancestors.has(input)) {
311
+ throw new Error(`${label} must not contain circular references.`);
312
+ }
313
+ ancestors.add(input);
314
+ if (Array.isArray(input)) {
315
+ assertCollectionLimit(input.length, gameProgressLimits.maxPayloadNodes, 'activeProgress.payload array');
316
+ const output = Array.from(input, (value, index) => {
317
+ return normalizeGameProgressValue(value, `${label}[${String(index)}]`, ancestors, budget, depth + 1);
318
+ });
319
+ ancestors.delete(input);
320
+ return output;
321
+ }
322
+ assertRecord(input, label);
323
+ assertOwnEnumerablePropertyLimit(input, gameProgressLimits.maxPayloadNodes, label);
324
+ const entries = Object.entries(input)
325
+ .sort(([left], [right]) => compareCodeUnits(left, right))
326
+ .map(([key, value]) => {
327
+ const normalizedKey = normalizeIdentifier(key, `${label} key`);
328
+ consumePayloadStringBudget(budget, normalizedKey.length);
329
+ return [
330
+ normalizedKey,
331
+ normalizeGameProgressValue(value, `${label}.${key}`, ancestors, budget, depth + 1),
332
+ ];
333
+ });
334
+ ancestors.delete(input);
335
+ return Object.fromEntries(entries);
336
+ }
337
+ function consumePayloadStringBudget(budget, stringUnits) {
338
+ budget.stringUnits += stringUnits;
339
+ if (budget.stringUnits > gameProgressLimits.maxPayloadStringUnits) {
340
+ throw new Error(`activeProgress.payload strings must not exceed ${String(gameProgressLimits.maxPayloadStringUnits)} total characters.`);
341
+ }
342
+ }
343
+ function isGameProgressRecord(input) {
344
+ return input !== null && typeof input === 'object' && !Array.isArray(input);
345
+ }
346
+ function compareCodeUnits(left, right) {
347
+ if (left < right) {
348
+ return -1;
349
+ }
350
+ return left > right ? 1 : 0;
351
+ }
352
+ function assertMatchingProgressLinkIdentity(existing, request) {
353
+ if (existing.authoritativePlayerId !== request.authoritativePlayerId
354
+ || existing.guestId !== request.guestId) {
355
+ throw new Error('A reconciliation key cannot be reused for another player or guest.');
356
+ }
357
+ }
358
+ function assertOnlyFields(input, allowedFields, label) {
359
+ for (const key of Object.keys(input)) {
360
+ if (!allowedFields.has(key)) {
361
+ throw new Error(`${label} contains unsupported field ${key}.`);
362
+ }
363
+ }
364
+ }
365
+ function assertCollectionLimit(length, maximum, label) {
366
+ if (length > maximum) {
367
+ throw new Error(`${label} must not contain more than ${String(maximum)} entries.`);
368
+ }
369
+ }
370
+ function assertRecord(input, label) {
371
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
372
+ throw new Error(`${label} must be an object.`);
373
+ }
374
+ }
375
+ function normalizeIdentifier(input, label) {
376
+ if (typeof input !== 'string' || input.length === 0 || input.trim() !== input) {
377
+ throw new Error(`${label} must be a non-empty, trimmed string.`);
378
+ }
379
+ if (input.length > gameProgressLimits.maxIdentifierLength) {
380
+ throw new Error(`${label} must not exceed ${String(gameProgressLimits.maxIdentifierLength)} characters.`);
381
+ }
382
+ if (/[\x00-\x1F\x7F]/u.test(input)) {
383
+ throw new Error(`${label} must not contain control characters.`);
384
+ }
385
+ return input;
386
+ }
387
+ function normalizeTimestamp(input, label) {
388
+ const value = normalizeIdentifier(input, label);
389
+ const timestamp = Date.parse(value);
390
+ if (!Number.isFinite(timestamp)) {
391
+ throw new Error(`${label} must be a valid timestamp.`);
392
+ }
393
+ return new Date(timestamp).toISOString();
394
+ }
package/dist/server.d.ts CHANGED
@@ -75,4 +75,5 @@ export declare function createGameServicesRouter(backend: GameServicesBackendApi
75
75
  };
76
76
  export declare function createGameServicesRpcFetchHandler(router: ReturnType<typeof createGameServicesRouter>, options?: CreateGameServicesRpcFetchHandlerOptions): (request: Request) => Promise<Response>;
77
77
  export declare function createGameServicesHttpFetchHandler(handler: GameServicesBackendApiHandler, options?: CreateGameServicesFetchHandlerOptions): (request: Request) => Promise<Response>;
78
- export {};
78
+ export * from './notification-delivery';
79
+ export * from './progress-link';
package/dist/server.js CHANGED
@@ -472,3 +472,5 @@ function applyCorsHeaders(headers, corsHeaders) {
472
472
  headers.set(key, value);
473
473
  }
474
474
  }
475
+ export * from './notification-delivery';
476
+ export * from './progress-link';
@@ -0,0 +1 @@
1
+ export declare function assertOwnEnumerablePropertyLimit(input: Record<string, unknown>, maximum: number, label: string): void;
@@ -0,0 +1,11 @@
1
+ export function assertOwnEnumerablePropertyLimit(input, maximum, label) {
2
+ let propertyCount = 0;
3
+ for (const key in input) {
4
+ if (Object.hasOwn(input, key)) {
5
+ propertyCount += 1;
6
+ if (propertyCount > maximum) {
7
+ throw new Error(`${label} must not contain more than ${String(maximum)} entries.`);
8
+ }
9
+ }
10
+ }
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-services",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,6 +37,14 @@
37
37
  "types": "./dist/contract.d.ts",
38
38
  "default": "./dist/contract.js"
39
39
  },
40
+ "./notification-delivery": {
41
+ "types": "./dist/notification-delivery.d.ts",
42
+ "default": "./dist/notification-delivery.js"
43
+ },
44
+ "./progress-link": {
45
+ "types": "./dist/progress-link.d.ts",
46
+ "default": "./dist/progress-link.js"
47
+ },
40
48
  "./server": {
41
49
  "types": "./dist/server.d.ts",
42
50
  "default": "./dist/server.js"
@@ -53,9 +61,9 @@
53
61
  "@orpc/client": "2.0.0-beta.14",
54
62
  "@orpc/contract": "2.0.0-beta.14",
55
63
  "@orpc/server": "2.0.0-beta.14",
56
- "@mpgd/catalog": "0.3.2",
57
- "@mpgd/platform": "0.3.2",
58
- "@mpgd/analytics": "0.3.2"
64
+ "@mpgd/analytics": "0.3.3",
65
+ "@mpgd/catalog": "0.3.3",
66
+ "@mpgd/platform": "0.4.0"
59
67
  },
60
68
  "devDependencies": {
61
69
  "ttsc": "0.16.9",
@@ -71,6 +79,6 @@
71
79
  "lint": "ttsc --noEmit",
72
80
  "format": "ttsc format",
73
81
  "fix": "ttsc fix",
74
- "test": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts"
82
+ "test": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts"
75
83
  }
76
84
  }