@mpgd/game-services 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.
@@ -26,7 +26,15 @@ export declare class NotificationDeliveryNotSentError extends Error {
26
26
  readonly name = "NotificationDeliveryNotSentError";
27
27
  }
28
28
  export interface NotificationDeepLinkPolicy {
29
- /** Exact HTTP(S) origins permitted for absolute links. Root-relative links are always allowed. */
29
+ /**
30
+ * Exact HTTP(S) origins permitted for absolute links. Root-relative links are
31
+ * allowed when they stay a path on the game origin, so one that names a host
32
+ * after a backslash (`/\host`) is rejected. Either kind is rejected when its
33
+ * path would begin with an empty segment after dot segments collapse
34
+ * (`/.//host`), or after it is percent-decoded once and parsed again
35
+ * (`/%2F/host`, `/%5Chost`, `/%09/host`, `/.%2F/host`), or when it is longer
36
+ * than 2,048 characters after normalization.
37
+ */
30
38
  readonly allowedOrigins?: readonly string[];
31
39
  }
32
40
  export interface NotificationDeliveryClaimOptions extends NotificationDeepLinkPolicy {
@@ -20,6 +20,8 @@ const notificationTopics = new Set([
20
20
  const defaultClaimLeaseMs = 5 * 60 * 1_000;
21
21
  const maxCompletionAttempts = 3;
22
22
  const maxNotificationTemplateDataEntries = 128;
23
+ /** Caps identifiers, including a deep link after normalization so it normalizes to itself. */
24
+ const maxIdentifierLength = 2_048;
23
25
  /** Throw only when the provider can prove that no external send was attempted. */
24
26
  export class NotificationDeliveryNotSentError extends Error {
25
27
  name = 'NotificationDeliveryNotSentError';
@@ -337,11 +339,23 @@ function normalizeDeepLink(input, allowedOrigins) {
337
339
  }
338
340
  if (deepLink.startsWith('/') && !deepLink.startsWith('//')) {
339
341
  const baseUrl = new URL('https://mpgd.invalid');
340
- const parsed = new URL(deepLink, baseUrl);
341
- if (parsed.origin !== baseUrl.origin) {
342
+ let parsed;
343
+ // A backslash after the leading slash parses as an authority (`/\host`),
344
+ // so the link names a host, not a path on the game origin.
345
+ if (!deepLink.startsWith('/\\')) {
346
+ try {
347
+ parsed = new URL(deepLink, baseUrl);
348
+ }
349
+ catch {
350
+ // Rejected below.
351
+ }
352
+ }
353
+ if (parsed === undefined
354
+ || parsed.origin !== baseUrl.origin
355
+ || hasNetworkPathPrefix(parsed.pathname)) {
342
356
  throw new Error('deepLink must stay on the configured game origin.');
343
357
  }
344
- return `${parsed.pathname}${parsed.search}${parsed.hash}`;
358
+ return requireNormalizedDeepLinkLength(`${parsed.pathname}${parsed.search}${parsed.hash}`);
345
359
  }
346
360
  let parsed;
347
361
  try {
@@ -359,7 +373,33 @@ function normalizeDeepLink(input, allowedOrigins) {
359
373
  if (!allowedOrigins.has(parsed.origin)) {
360
374
  throw new Error('deepLink origin is not allowed.');
361
375
  }
362
- return parsed.toString();
376
+ if (hasNetworkPathPrefix(parsed.pathname)) {
377
+ throw new Error('deepLink path must not begin with an empty segment, even after percent-decoding.');
378
+ }
379
+ return requireNormalizedDeepLinkLength(parsed.toString());
380
+ }
381
+ /**
382
+ * Whether a normalized path could be read as a network-path reference to
383
+ * another host. Dot segments (also percent-encoded) can collapse `/.//host`
384
+ * into `//host`. A consumer that percent-decodes the path once and parses it
385
+ * again can reach the same shape from `/%2F/host`, `/%5Chost`, `/%09/host`,
386
+ * or `/.%2F/host`: URL parsing drops tabs and newlines, reads a backslash as
387
+ * a slash, and removes dot segments, so the decoded path is checked after
388
+ * the same steps.
389
+ */
390
+ function hasNetworkPathPrefix(pathname) {
391
+ const decoded = pathname
392
+ .replace(/%([0-7][0-9a-f])/giu, (_escape, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
393
+ .replace(/[\t\n\r]/gu, '');
394
+ return /^[/\\]{2}/u.test(decoded)
395
+ || new URL(decoded, 'https://mpgd.invalid').pathname.startsWith('//');
396
+ }
397
+ /** Normalization can grow a link; keep it within the input cap so it normalizes to itself. */
398
+ function requireNormalizedDeepLinkLength(deepLink) {
399
+ if (deepLink.length > maxIdentifierLength) {
400
+ throw new Error(`deepLink must contain at most ${String(maxIdentifierLength)} characters after normalization.`);
401
+ }
402
+ return deepLink;
363
403
  }
364
404
  function normalizeAllowedDeepLinkOrigins(input) {
365
405
  const origins = new Set();
@@ -427,7 +467,7 @@ function assertRecord(input, label) {
427
467
  function normalizeIdentifier(input, label) {
428
468
  if (typeof input !== 'string'
429
469
  || input.length === 0
430
- || input.length > 2_048
470
+ || input.length > maxIdentifierLength
431
471
  || input.trim() !== input) {
432
472
  throw new Error(`${label} must be a non-empty, trimmed string.`);
433
473
  }
package/dist/runtime.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { PlatformGateway, PlatformTarget } from '@mpgd/platform';
3
3
  import { type GameServicesBackendApi, type GameServicesBackendTransport, type GameServicesClient, type GameServicesHeaderResolver } from './client.js';
4
4
  import type { GameServicesLedgerTarget } from './types.js';
5
5
  import type { ClientRewardEvidenceRegistry } from './client-reward-evidence.js';
6
+ import type { CommerceReconcilerProduct } from './commerce-reconciler.js';
6
7
  import { type MonetizationOperationStore, type RecoverableMonetizationClient } from './monetization-recovery.js';
7
8
  export type GameServicesAuthorityMode = 'production' | 'non-production';
8
9
  export type GameServicesRuntimeMode = 'disabled' | 'local' | 'http' | 'orpc';
@@ -34,6 +35,12 @@ interface CreateGameServicesRuntimeCommonInput {
34
35
  /** Durable, compare-and-swap journal for purchase and rewarded-ad recovery. */
35
36
  readonly operationStore?: MonetizationOperationStore;
36
37
  readonly rewardEvidenceRegistry?: ClientRewardEvidenceRegistry;
38
+ /**
39
+ * Catalog kinds and store SKUs the commerce v2 reconciler resolves without a
40
+ * store read (`CommerceReconcilerProduct`), for example derived from the
41
+ * product catalog's `platformProductIds` for the gateway target.
42
+ */
43
+ readonly commerceProducts?: readonly CommerceReconcilerProduct[];
37
44
  }
38
45
  export type CreateGameServicesRuntimeInput = CreateGameServicesRuntimeCommonInput & ({
39
46
  readonly transport?: 'http';
package/dist/runtime.js CHANGED
@@ -62,6 +62,7 @@ export function createGameServicesRuntime(input) {
62
62
  : { analyticsSessionId: input.analyticsSessionId }),
63
63
  ...(input.now === undefined ? {} : { now: input.now }),
64
64
  ...(input.rewardEvidenceRegistry === undefined ? {} : { rewardEvidenceRegistry: input.rewardEvidenceRegistry }),
65
+ ...(input.commerceProducts === undefined ? {} : { commerceProducts: input.commerceProducts }),
65
66
  };
66
67
  const monetizationRecovery = input.operationStore === undefined
67
68
  ? undefined
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-services",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -202,9 +202,9 @@
202
202
  "@orpc/client": "2.0.0-beta.14",
203
203
  "@orpc/contract": "2.0.0-beta.14",
204
204
  "@orpc/server": "2.0.0-beta.14",
205
+ "@mpgd/platform": "0.22.0",
205
206
  "@mpgd/analytics": "0.4.8",
206
- "@mpgd/catalog": "0.9.3",
207
- "@mpgd/platform": "0.22.0"
207
+ "@mpgd/catalog": "0.9.3"
208
208
  },
209
209
  "peerDependencies": {
210
210
  "@apple/app-store-server-library": "^3.1.0"