@mpgd/game-services 0.15.0 → 0.15.2
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/README.md +6 -0
- package/dist/admob-ssv-conformance.js +13 -13
- package/dist/admob-ssv.js +4 -4
- package/dist/apps-in-toss-evidence-verification-conformance.js +1 -1
- package/dist/apps-in-toss-evidence-verification.js +3 -3
- package/dist/apps-in-toss-partner-api.js +14 -14
- package/dist/microsoft-store-identity.js +5 -5
- package/dist/notification-delivery.js +4 -4
- package/dist/operation-results.d.ts +21 -0
- package/dist/operation-results.js +1 -0
- package/dist/operations.d.ts +1 -1
- package/dist/progress-link.js +2 -2
- package/dist/server.js +2 -2
- package/dist/types.d.ts +2 -20
- package/dist/verified-leaderboard-conformance.js +11 -11
- package/dist/verified-leaderboard.js +2 -2
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -11,6 +11,12 @@ verification and ledger boundaries.
|
|
|
11
11
|
compatible. Types are available from `@mpgd/game-services/client` and the package
|
|
12
12
|
root. No UI runtime or Phaser dependency is added.
|
|
13
13
|
|
|
14
|
+
Headless consumers can import `GameServicesOperationClient` and operation types
|
|
15
|
+
from `@mpgd/game-services/operations`. This entrypoint's declarations support
|
|
16
|
+
`lib: ["ES2022"]`, `types: []`, and `skipLibCheck: false`; they do not require
|
|
17
|
+
catalog validators, transport declarations, or DOM globals. Existing response
|
|
18
|
+
type exports from `@mpgd/game-services/types` remain compatible.
|
|
19
|
+
|
|
14
20
|
```ts
|
|
15
21
|
await client.purchase(input, {
|
|
16
22
|
correlationId: 'view-operation-42',
|
|
@@ -11,7 +11,7 @@ export const adMobSsvConformanceChecks = [
|
|
|
11
11
|
'timestamp age and future-skew boundaries fail closed',
|
|
12
12
|
'callbacks sharing one signed transaction preserve one authority identity',
|
|
13
13
|
];
|
|
14
|
-
const fixtureTimestampMs =
|
|
14
|
+
const fixtureTimestampMs = 1_784_160_000_000;
|
|
15
15
|
const fixtureKeyId = '1234567890';
|
|
16
16
|
const fixturePublicKey = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvYnh4p45wAsdENipvmqwXzvdNaCgRqx2IvxoYI8kiummEYytzHIhL738YLr3vs5vG/A8xY8QiCLDqVV2c5sUZw==';
|
|
17
17
|
const firstFixtureSignature = 'MEYCIQDR1_VmQ4xw4WHff8KGDgv4insP7p5VAw8MhP5mZTFTgAIhAJ2Ejy5MU75UxeAxFqdLzvJwzL43qXl3PwuHMGYbjaiR';
|
|
@@ -67,7 +67,7 @@ async function createAdMobSsvConformanceContext(input, options) {
|
|
|
67
67
|
callbackSource,
|
|
68
68
|
publicKeySource,
|
|
69
69
|
subtle,
|
|
70
|
-
now: () => fixtureTimestampMs +
|
|
70
|
+
now: () => fixtureTimestampMs + 1_000,
|
|
71
71
|
}),
|
|
72
72
|
};
|
|
73
73
|
}
|
|
@@ -96,7 +96,7 @@ export async function runAdMobSsvConformance(input = {}) {
|
|
|
96
96
|
callbackSource: mapCallbackSource(new Map()),
|
|
97
97
|
publicKeySource,
|
|
98
98
|
subtle,
|
|
99
|
-
now: () => fixtureTimestampMs +
|
|
99
|
+
now: () => fixtureTimestampMs + 1_000,
|
|
100
100
|
}).verifyAdReward(verificationInput(firstRequest));
|
|
101
101
|
assertDecision(pending, 'pending', 'ADMOB_SSV_EVIDENCE_PENDING');
|
|
102
102
|
const tamperedCallback = fixtureCallback('reward-1', `${firstFixtureSignature.slice(0, -1)}S`);
|
|
@@ -104,14 +104,14 @@ export async function runAdMobSsvConformance(input = {}) {
|
|
|
104
104
|
callbackSource: fixedCallbackSource(tamperedCallback),
|
|
105
105
|
publicKeySource,
|
|
106
106
|
subtle,
|
|
107
|
-
now: () => fixtureTimestampMs +
|
|
107
|
+
now: () => fixtureTimestampMs + 1_000,
|
|
108
108
|
}).verifyAdReward(verificationInput(firstRequest));
|
|
109
109
|
assertDecision(tampered, 'rejected', 'ADMOB_SSV_SIGNATURE_INVALID');
|
|
110
110
|
const equivalentPercentEncoding = await createAdMobSsvEvidenceVerifier({
|
|
111
111
|
callbackSource: fixedCallbackSource(fixtureCallback('reward-1', firstFixtureSignature).replace('%7B', '%7b')),
|
|
112
112
|
publicKeySource,
|
|
113
113
|
subtle,
|
|
114
|
-
now: () => fixtureTimestampMs +
|
|
114
|
+
now: () => fixtureTimestampMs + 1_000,
|
|
115
115
|
}).verifyAdReward(verificationInput(firstRequest));
|
|
116
116
|
assert(equivalentPercentEncoding.status === 'verified', 'equivalent percent-encoding must verify against AdMob-decoded query bytes');
|
|
117
117
|
const validCallback = fixtureCallback('reward-1', firstFixtureSignature);
|
|
@@ -119,21 +119,21 @@ export async function runAdMobSsvConformance(input = {}) {
|
|
|
119
119
|
callbackSource: fixedCallbackSource(validCallback.replace('&signature=', '&user_id=player-1&signature=')),
|
|
120
120
|
publicKeySource,
|
|
121
121
|
subtle,
|
|
122
|
-
now: () => fixtureTimestampMs +
|
|
122
|
+
now: () => fixtureTimestampMs + 1_000,
|
|
123
123
|
}).verifyAdReward(verificationInput(firstRequest));
|
|
124
124
|
assertDecision(duplicateSignedField, 'rejected', 'ADMOB_SSV_CALLBACK_INVALID');
|
|
125
125
|
const missingSignedField = await createAdMobSsvEvidenceVerifier({
|
|
126
126
|
callbackSource: fixedCallbackSource(validCallback.replace('&user_id=player-1', '')),
|
|
127
127
|
publicKeySource,
|
|
128
128
|
subtle,
|
|
129
|
-
now: () => fixtureTimestampMs +
|
|
129
|
+
now: () => fixtureTimestampMs + 1_000,
|
|
130
130
|
}).verifyAdReward(verificationInput(firstRequest));
|
|
131
131
|
assertDecision(missingSignedField, 'rejected', 'ADMOB_SSV_CALLBACK_INVALID');
|
|
132
132
|
const unknownKey = await createAdMobSsvEvidenceVerifier({
|
|
133
133
|
callbackSource: fixedCallbackSource(fixtureCallback('reward-1', firstFixtureSignature).replace(`key_id=${fixtureKeyId}`, 'key_id=9999999999')),
|
|
134
134
|
publicKeySource,
|
|
135
135
|
subtle,
|
|
136
|
-
now: () => fixtureTimestampMs +
|
|
136
|
+
now: () => fixtureTimestampMs + 1_000,
|
|
137
137
|
}).verifyAdReward(verificationInput(firstRequest));
|
|
138
138
|
assertDecision(unknownKey, 'rejected', 'ADMOB_SSV_KEY_UNAVAILABLE');
|
|
139
139
|
const secp256k1Decision = await verifier.verifyAdReward(verificationInput(rewardRequest('reward-secp')));
|
|
@@ -149,28 +149,28 @@ export async function runAdMobSsvConformance(input = {}) {
|
|
|
149
149
|
callbackSource,
|
|
150
150
|
publicKeySource,
|
|
151
151
|
subtle,
|
|
152
|
-
now: () => fixtureTimestampMs +
|
|
152
|
+
now: () => fixtureTimestampMs + 86_400_001,
|
|
153
153
|
}).verifyAdReward(verificationInput(firstRequest));
|
|
154
154
|
assertDecision(expired, 'rejected', 'ADMOB_SSV_CALLBACK_EXPIRED');
|
|
155
155
|
const future = await createAdMobSsvEvidenceVerifier({
|
|
156
156
|
callbackSource,
|
|
157
157
|
publicKeySource,
|
|
158
158
|
subtle,
|
|
159
|
-
now: () => fixtureTimestampMs -
|
|
159
|
+
now: () => fixtureTimestampMs - 300_001,
|
|
160
160
|
}).verifyAdReward(verificationInput(firstRequest));
|
|
161
161
|
assertDecision(future, 'rejected', 'ADMOB_SSV_TIMESTAMP_IN_FUTURE');
|
|
162
162
|
const ageBoundary = await createAdMobSsvEvidenceVerifier({
|
|
163
163
|
callbackSource,
|
|
164
164
|
publicKeySource,
|
|
165
165
|
subtle,
|
|
166
|
-
now: () => fixtureTimestampMs +
|
|
166
|
+
now: () => fixtureTimestampMs + 86_400_000,
|
|
167
167
|
}).verifyAdReward(verificationInput(firstRequest));
|
|
168
168
|
assert(ageBoundary.status === 'verified', 'the maximum callback age boundary must remain valid');
|
|
169
169
|
const futureSkewBoundary = await createAdMobSsvEvidenceVerifier({
|
|
170
170
|
callbackSource,
|
|
171
171
|
publicKeySource,
|
|
172
172
|
subtle,
|
|
173
|
-
now: () => fixtureTimestampMs -
|
|
173
|
+
now: () => fixtureTimestampMs - 300_000,
|
|
174
174
|
}).verifyAdReward(verificationInput(firstRequest));
|
|
175
175
|
assert(futureSkewBoundary.status === 'verified', 'the maximum future skew boundary must remain valid');
|
|
176
176
|
const sharedTransactionDecision = await verifier.verifyAdReward(verificationInput(rewardRequest('reward-2')));
|
|
@@ -188,7 +188,7 @@ function verificationInput(request) {
|
|
|
188
188
|
placement,
|
|
189
189
|
platformPlacementId: placement.platformPlacementIds.android,
|
|
190
190
|
signal: new AbortController().signal,
|
|
191
|
-
timeoutMs:
|
|
191
|
+
timeoutMs: 10_000,
|
|
192
192
|
};
|
|
193
193
|
}
|
|
194
194
|
function rewardRequest(idempotencyKey) {
|
package/dist/admob-ssv.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Point, verify as verifySecp256k1 } from '@noble/secp256k1';
|
|
2
2
|
export const admobSsvCustomDataSchema = 'mpgd.admob.ssv.binding.v1';
|
|
3
|
-
export const defaultAdMobSsvMaximumCallbackAgeMs =
|
|
4
|
-
export const defaultAdMobSsvMaximumFutureSkewMs =
|
|
3
|
+
export const defaultAdMobSsvMaximumCallbackAgeMs = 86_400_000;
|
|
4
|
+
export const defaultAdMobSsvMaximumFutureSkewMs = 300_000;
|
|
5
5
|
const signatureMarker = '&signature=';
|
|
6
|
-
const maximumCallbackUrlLength =
|
|
7
|
-
const maximumDateTimestampMs =
|
|
6
|
+
const maximumCallbackUrlLength = 65_536;
|
|
7
|
+
const maximumDateTimestampMs = 8_640_000_000_000_000;
|
|
8
8
|
const ecPublicKeyObjectIdentifier = Uint8Array.of(0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01);
|
|
9
9
|
const p256ObjectIdentifier = Uint8Array.of(0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07);
|
|
10
10
|
const p384ObjectIdentifier = Uint8Array.of(0x2b, 0x81, 0x04, 0x00, 0x22);
|
|
@@ -391,7 +391,7 @@ async function runRewardTimestampValidationScenario(createVerifier, now) {
|
|
|
391
391
|
async function runAuthorityErrorsAndRewardMatchingScenario(createVerifier, now) {
|
|
392
392
|
const fixture = createAppsInTossProductionEvidenceAuthorityFixture();
|
|
393
393
|
const diagnosticReason = `Authority temporarily unavailable: ${'x'.repeat(512)} `;
|
|
394
|
-
const oversizedDiagnosticReason = 'x'.repeat(
|
|
394
|
+
const oversizedDiagnosticReason = 'x'.repeat(4_097);
|
|
395
395
|
fixture.enqueuePurchaseResult({ decision: 'rejected', reason: diagnosticReason });
|
|
396
396
|
fixture.enqueuePurchaseResult({ decision: 'pending', reason: diagnosticReason });
|
|
397
397
|
fixture.enqueuePurchaseResult({ decision: 'pending', reason: oversizedDiagnosticReason });
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export const appsInTossPurchaseCallbackEvidenceSchema = 'apps-in-toss.iap.callback.v1';
|
|
2
2
|
export const appsInTossRewardCallbackEvidenceSchema = 'apps-in-toss.rewarded-ad.callback.v1';
|
|
3
3
|
/** Leaves five seconds of headroom inside the SDK's documented 30-second window. */
|
|
4
|
-
export const appsInTossProductGrantCallbackTimeoutMs =
|
|
4
|
+
export const appsInTossProductGrantCallbackTimeoutMs = 25_000;
|
|
5
5
|
// Offset-free order-status timestamps are documented as KST (UTC+09:00).
|
|
6
6
|
const appsInTossKstOffsetMinutes = 9 * 60;
|
|
7
|
-
const appsInTossAuthorityReasonMaxLength =
|
|
7
|
+
const appsInTossAuthorityReasonMaxLength = 4_096;
|
|
8
8
|
const appsInTossTimestampPattern = new RegExp(String.raw `^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})`
|
|
9
9
|
+ String.raw `(?:\.(\d{1,3}))?(Z|([+-])(\d{2}):(\d{2}))?$`, 'u');
|
|
10
10
|
const appsInTossProductGrantVerificationPortBrand = Symbol('AppsInTossProductGrantVerificationPort');
|
|
@@ -393,7 +393,7 @@ function normalizeTimestamp(value, offsetFreeOffsetMinutes) {
|
|
|
393
393
|
if (offsetMinutes === undefined) {
|
|
394
394
|
return undefined;
|
|
395
395
|
}
|
|
396
|
-
return new Date(wallClock - offsetMinutes *
|
|
396
|
+
return new Date(wallClock - offsetMinutes * 60_000).toISOString();
|
|
397
397
|
}
|
|
398
398
|
function pending(reason) {
|
|
399
399
|
return { status: 'pending', reason };
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
export const appsInTossPartnerApiBaseUrl = 'https://apps-in-toss-api.toss.im';
|
|
2
|
-
export const defaultAppsInTossPartnerApiTimeoutMs =
|
|
2
|
+
export const defaultAppsInTossPartnerApiTimeoutMs = 10_000;
|
|
3
3
|
const verifyAnonymousKeyPath = 'api-partner/v1/apps-in-toss/users/anon-key/verify';
|
|
4
4
|
const sendFunctionalMessagePath = 'api-partner/v1/apps-in-toss/messenger/send-message';
|
|
5
5
|
const exchangeLoginAuthorizationCodePath = 'api-partner/v1/apps-in-toss/user/oauth2/generate-token';
|
|
6
6
|
const getLoginUserPath = 'api-partner/v1/apps-in-toss/user/oauth2/login-me';
|
|
7
7
|
const getIapOrderStatusPath = 'api-partner/v1/apps-in-toss/order/get-order-status';
|
|
8
|
-
const maximumResponseBodyBytes = 256 *
|
|
8
|
+
const maximumResponseBodyBytes = 256 * 1_024;
|
|
9
9
|
export class AppsInTossPartnerApiError extends Error {
|
|
10
10
|
status;
|
|
11
11
|
code;
|
|
@@ -82,7 +82,7 @@ export function createAppsInTossPartnerApiClient(input) {
|
|
|
82
82
|
return parseLoginToken(envelope.success, response.status);
|
|
83
83
|
},
|
|
84
84
|
async getLoginUser(request) {
|
|
85
|
-
const accessToken = normalizeOpaqueValue(request.accessToken, 'accessToken',
|
|
85
|
+
const accessToken = normalizeOpaqueValue(request.accessToken, 'accessToken', 16_384);
|
|
86
86
|
const response = await requestJson({
|
|
87
87
|
mtls: input.mtls,
|
|
88
88
|
method: 'GET',
|
|
@@ -233,7 +233,7 @@ function requireSuccessEnvelope(response) {
|
|
|
233
233
|
const error = isRecord(response.body.error) ? response.body.error : undefined;
|
|
234
234
|
const code = typeof error?.errorCode === 'string' ? error.errorCode : undefined;
|
|
235
235
|
const reason = typeof error?.reason === 'string'
|
|
236
|
-
? error.reason.slice(0,
|
|
236
|
+
? error.reason.slice(0, 1_024)
|
|
237
237
|
: 'Apps in Toss partner API request failed.';
|
|
238
238
|
throw new AppsInTossPartnerApiError(reason, response.status, code);
|
|
239
239
|
}
|
|
@@ -261,10 +261,10 @@ function parseLoginToken(input, status) {
|
|
|
261
261
|
}
|
|
262
262
|
return {
|
|
263
263
|
tokenType,
|
|
264
|
-
accessToken: requireOpaqueValue(input.accessToken, 'accessToken', status,
|
|
265
|
-
refreshToken: requireOpaqueValue(input.refreshToken, 'refreshToken', status,
|
|
264
|
+
accessToken: requireOpaqueValue(input.accessToken, 'accessToken', status, 16_384),
|
|
265
|
+
refreshToken: requireOpaqueValue(input.refreshToken, 'refreshToken', status, 16_384),
|
|
266
266
|
expiresInSeconds: requirePositiveInteger(input.expiresIn, 'expiresIn', status),
|
|
267
|
-
scope: requireOpaqueValue(input.scope, 'scope', status,
|
|
267
|
+
scope: requireOpaqueValue(input.scope, 'scope', status, 4_096),
|
|
268
268
|
};
|
|
269
269
|
}
|
|
270
270
|
function parseLoginUser(input, status) {
|
|
@@ -273,7 +273,7 @@ function parseLoginUser(input, status) {
|
|
|
273
273
|
}
|
|
274
274
|
return {
|
|
275
275
|
userKey: requireTossUserKey(input.userKey, 'userKey', status),
|
|
276
|
-
scope: requireOpaqueValue(input.scope, 'scope', status,
|
|
276
|
+
scope: requireOpaqueValue(input.scope, 'scope', status, 4_096),
|
|
277
277
|
};
|
|
278
278
|
}
|
|
279
279
|
function parseIapOrderStatus(input, status) {
|
|
@@ -286,13 +286,13 @@ function parseIapOrderStatus(input, status) {
|
|
|
286
286
|
}
|
|
287
287
|
const reason = input.reason;
|
|
288
288
|
return {
|
|
289
|
-
orderId: requireOpaqueValue(input.orderId, 'orderId', status,
|
|
290
|
-
sku: requireOpaqueValue(input.sku, 'sku', status,
|
|
291
|
-
statusDeterminedAt: requireOpaqueValue(input.statusDeterminedAt, 'statusDeterminedAt', status,
|
|
289
|
+
orderId: requireOpaqueValue(input.orderId, 'orderId', status, 2_048),
|
|
290
|
+
sku: requireOpaqueValue(input.sku, 'sku', status, 2_048),
|
|
291
|
+
statusDeterminedAt: requireOpaqueValue(input.statusDeterminedAt, 'statusDeterminedAt', status, 2_048),
|
|
292
292
|
status: orderStatus,
|
|
293
293
|
...(reason === undefined
|
|
294
294
|
? {}
|
|
295
|
-
: { reason: requireOpaqueValue(reason, 'reason', status,
|
|
295
|
+
: { reason: requireOpaqueValue(reason, 'reason', status, 4_096) }),
|
|
296
296
|
};
|
|
297
297
|
}
|
|
298
298
|
const appsInTossIapOrderStatuses = new Set([
|
|
@@ -358,7 +358,7 @@ function normalizeContext(input) {
|
|
|
358
358
|
function normalizeIdentifier(value, field) {
|
|
359
359
|
const normalized = value.trim();
|
|
360
360
|
if (normalized.length === 0
|
|
361
|
-
|| normalized.length >
|
|
361
|
+
|| normalized.length > 2_048
|
|
362
362
|
|| /[\p{Cc}\p{Cf}]/u.test(normalized)) {
|
|
363
363
|
throw new TypeError(`${field} must contain 1 to 2048 characters without control or format characters.`);
|
|
364
364
|
}
|
|
@@ -444,7 +444,7 @@ function normalizeBaseUrl(input) {
|
|
|
444
444
|
}
|
|
445
445
|
function normalizeTimeout(value) {
|
|
446
446
|
const timeoutMs = value ?? defaultAppsInTossPartnerApiTimeoutMs;
|
|
447
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs >
|
|
447
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) {
|
|
448
448
|
throw new TypeError('AIT partner API timeoutMs must be between 1 and 60000.');
|
|
449
449
|
}
|
|
450
450
|
return timeoutMs;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const microsoftStoreIdentityCredentialsSchema = 'mpgd.microsoft-store.identity-credentials.v1';
|
|
2
2
|
export const microsoftStoreIdentityCredentialsRequestUrl = 'https://microsoft-store-authority.internal/v1/credentials';
|
|
3
|
-
const defaultTimeoutMs =
|
|
4
|
-
const maximumResponseBytes = 16 *
|
|
3
|
+
const defaultTimeoutMs = 8_000;
|
|
4
|
+
const maximumResponseBytes = 16 * 1_024;
|
|
5
5
|
const gameIdPattern = /^[a-z0-9][a-z0-9-]{0,63}$/u;
|
|
6
6
|
export class MicrosoftStoreIdentityAuthorityError extends Error {
|
|
7
7
|
code;
|
|
@@ -45,8 +45,8 @@ export function parseMicrosoftStoreIdentityCredentialsResponse(value, expectedRe
|
|
|
45
45
|
schema: microsoftStoreIdentityCredentialsSchema,
|
|
46
46
|
gameId: requireGameId(record.gameId),
|
|
47
47
|
playerId: requireText(record.playerId, 'response playerId', 256),
|
|
48
|
-
accessToken: requireToken(record.accessToken, 'Microsoft Store access token',
|
|
49
|
-
userStoreId: requireToken(record.userStoreId, 'Microsoft Store User Store ID',
|
|
48
|
+
accessToken: requireToken(record.accessToken, 'Microsoft Store access token', 4_096),
|
|
49
|
+
userStoreId: requireToken(record.userStoreId, 'Microsoft Store User Store ID', 4_096),
|
|
50
50
|
accountBindingId: requireText(record.accountBindingId, 'Microsoft Store account binding ID', 512),
|
|
51
51
|
...(record.sandbox === undefined
|
|
52
52
|
? {}
|
|
@@ -71,7 +71,7 @@ export async function resolveMicrosoftStoreIdentityCredentials(input) {
|
|
|
71
71
|
playerId: input.playerId,
|
|
72
72
|
});
|
|
73
73
|
const timeoutMs = input.timeoutMs ?? defaultTimeoutMs;
|
|
74
|
-
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs >
|
|
74
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 25_000) {
|
|
75
75
|
throw new TypeError('Microsoft Store identity timeout is invalid.');
|
|
76
76
|
}
|
|
77
77
|
const abort = createAbortScope(input.signal, timeoutMs);
|
|
@@ -17,7 +17,7 @@ const notificationTopics = new Set([
|
|
|
17
17
|
'streak-at-risk',
|
|
18
18
|
'friend-challenge',
|
|
19
19
|
]);
|
|
20
|
-
const defaultClaimLeaseMs = 5 * 60 *
|
|
20
|
+
const defaultClaimLeaseMs = 5 * 60 * 1_000;
|
|
21
21
|
const maxCompletionAttempts = 3;
|
|
22
22
|
const maxNotificationTemplateDataEntries = 128;
|
|
23
23
|
/** Throw only when the provider can prove that no external send was attempted. */
|
|
@@ -322,7 +322,7 @@ function normalizeTemplateData(input) {
|
|
|
322
322
|
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
323
323
|
throw new Error(`templateData.${key} must be a finite number.`);
|
|
324
324
|
}
|
|
325
|
-
if (typeof value === 'string' && value.length >
|
|
325
|
+
if (typeof value === 'string' && value.length > 16_384) {
|
|
326
326
|
throw new Error(`templateData.${key} must contain at most 16384 characters.`);
|
|
327
327
|
}
|
|
328
328
|
return [normalizedKey, value];
|
|
@@ -390,7 +390,7 @@ function normalizeLeaseDuration(input) {
|
|
|
390
390
|
if (typeof input !== 'number'
|
|
391
391
|
|| !Number.isSafeInteger(input)
|
|
392
392
|
|| input <= 0
|
|
393
|
-
|| input > 24 * 60 * 60 *
|
|
393
|
+
|| input > 24 * 60 * 60 * 1_000) {
|
|
394
394
|
throw new Error('claimLeaseMs must be a positive integer no greater than 24 hours.');
|
|
395
395
|
}
|
|
396
396
|
return input;
|
|
@@ -427,7 +427,7 @@ function assertRecord(input, label) {
|
|
|
427
427
|
function normalizeIdentifier(input, label) {
|
|
428
428
|
if (typeof input !== 'string'
|
|
429
429
|
|| input.length === 0
|
|
430
|
-
|| input.length >
|
|
430
|
+
|| input.length > 2_048
|
|
431
431
|
|| input.trim() !== input) {
|
|
432
432
|
throw new Error(`${label} must be a non-empty, trimmed string.`);
|
|
433
433
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Shared response shapes with no schema, DOM or transport dependencies. */
|
|
2
|
+
export interface VerifyPurchaseResponse {
|
|
3
|
+
readonly verified: boolean;
|
|
4
|
+
readonly ledgerEntryId?: string;
|
|
5
|
+
readonly alreadyProcessed: boolean;
|
|
6
|
+
readonly reason?: string;
|
|
7
|
+
readonly finalization?: PurchaseGrantFinalization;
|
|
8
|
+
}
|
|
9
|
+
export type PurchaseGrantFinalizationAction = 'acknowledge' | 'consume' | 'finish' | 'complete';
|
|
10
|
+
export interface PurchaseGrantFinalization {
|
|
11
|
+
readonly status: 'completed' | 'pending';
|
|
12
|
+
readonly action?: PurchaseGrantFinalizationAction;
|
|
13
|
+
readonly alreadyCompleted: boolean;
|
|
14
|
+
readonly reason?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ClaimAdRewardResponse {
|
|
17
|
+
readonly granted: boolean;
|
|
18
|
+
readonly ledgerEntryId?: string;
|
|
19
|
+
readonly alreadyProcessed: boolean;
|
|
20
|
+
readonly reason?: string;
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/operations.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { LogicalAdPlacementId, LogicalProductId, PurchaseResult, RewardedAdResult } from '@mpgd/platform';
|
|
2
2
|
import type { GameServicesOperationOptions, GameServicesPurchaseProgress, GameServicesRewardedAdProgress } from './operation-progress.js';
|
|
3
|
-
import type { ClaimAdRewardResponse, VerifyPurchaseResponse } from './
|
|
3
|
+
import type { ClaimAdRewardResponse, VerifyPurchaseResponse } from './operation-results.js';
|
|
4
4
|
export type { GameServicesOperationKind, GameServicesOperationLocation, GameServicesOperationOptions, GameServicesOperationProgress, GameServicesPurchaseProgress, GameServicesRewardedAdProgress, } from './operation-progress.js';
|
|
5
5
|
/** DOM-free public operation port, implemented by GameServicesClient. */
|
|
6
6
|
export interface GameServicesOperationClient {
|
package/dist/progress-link.js
CHANGED
|
@@ -4,8 +4,8 @@ export const gameProgressLimits = {
|
|
|
4
4
|
maxCompletedIds: 512,
|
|
5
5
|
maxMetricEntries: 512,
|
|
6
6
|
maxPayloadDepth: 16,
|
|
7
|
-
maxPayloadNodes:
|
|
8
|
-
maxPayloadStringUnits:
|
|
7
|
+
maxPayloadNodes: 2_048,
|
|
8
|
+
maxPayloadStringUnits: 32_768,
|
|
9
9
|
};
|
|
10
10
|
const snapshotFields = new Set(['completedIds', 'bestTimesMs', 'bestScores', 'activeProgress']);
|
|
11
11
|
const activeProgressFields = new Set(['id', 'updatedAt', 'payload']);
|
package/dist/server.js
CHANGED
|
@@ -994,14 +994,14 @@ function matchesEntitlementTransactionIdentity(transaction, identity) {
|
|
|
994
994
|
&& transaction.payload.deploymentTarget === identity.deploymentTarget;
|
|
995
995
|
}
|
|
996
996
|
function resolveEvidenceVerificationTimeout(timeoutMs) {
|
|
997
|
-
const resolved = timeoutMs ??
|
|
997
|
+
const resolved = timeoutMs ?? 10_000;
|
|
998
998
|
if (!Number.isFinite(resolved) || resolved <= 0) {
|
|
999
999
|
throw new Error('evidenceVerificationTimeoutMs must be a positive finite number.');
|
|
1000
1000
|
}
|
|
1001
1001
|
return resolved;
|
|
1002
1002
|
}
|
|
1003
1003
|
function resolvePurchaseGrantFinalizationTimeout(timeoutMs) {
|
|
1004
|
-
const resolved = timeoutMs ??
|
|
1004
|
+
const resolved = timeoutMs ?? 15_000;
|
|
1005
1005
|
if (!Number.isFinite(resolved) || resolved <= 0) {
|
|
1006
1006
|
throw new Error('purchaseGrantFinalizationTimeoutMs must be a positive finite number.');
|
|
1007
1007
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ProductGrant } from '@mpgd/catalog';
|
|
2
2
|
import type { LeaderboardScoreInput, LogicalAdPlacementId, LogicalProductId, PlatformEvidenceEnvelope, PlatformTarget } from '@mpgd/platform';
|
|
3
|
+
import type { ClaimAdRewardResponse, PurchaseGrantFinalization, VerifyPurchaseResponse } from './operation-results.js';
|
|
4
|
+
export type { ClaimAdRewardResponse, PurchaseGrantFinalization, PurchaseGrantFinalizationAction, VerifyPurchaseResponse, } from './operation-results.js';
|
|
3
5
|
export type { PlatformEvidenceEnvelope } from '@mpgd/platform';
|
|
4
6
|
export type GameServicesStoreTarget = Extract<PlatformTarget, 'microsoft-store' | 'android' | 'ios' | 'ait'>;
|
|
5
7
|
export type GameServicesAdRewardTarget = Extract<PlatformTarget, 'android' | 'ios' | 'ait' | 'verse8'>;
|
|
@@ -21,20 +23,6 @@ export interface VerifyPurchaseRequest {
|
|
|
21
23
|
readonly purchasedAt: string;
|
|
22
24
|
readonly evidence?: PlatformEvidenceEnvelope;
|
|
23
25
|
}
|
|
24
|
-
export interface VerifyPurchaseResponse {
|
|
25
|
-
readonly verified: boolean;
|
|
26
|
-
readonly ledgerEntryId?: string;
|
|
27
|
-
readonly alreadyProcessed: boolean;
|
|
28
|
-
readonly reason?: string;
|
|
29
|
-
readonly finalization?: PurchaseGrantFinalization;
|
|
30
|
-
}
|
|
31
|
-
export type PurchaseGrantFinalizationAction = 'acknowledge' | 'consume' | 'finish' | 'complete';
|
|
32
|
-
export interface PurchaseGrantFinalization {
|
|
33
|
-
readonly status: 'completed' | 'pending';
|
|
34
|
-
readonly action?: PurchaseGrantFinalizationAction;
|
|
35
|
-
readonly alreadyCompleted: boolean;
|
|
36
|
-
readonly reason?: string;
|
|
37
|
-
}
|
|
38
26
|
export interface ClaimAdRewardRequest {
|
|
39
27
|
readonly target: GameServicesAdRewardTarget;
|
|
40
28
|
/** Deployment config key used to resolve game-owned platform placement identifiers. */
|
|
@@ -47,12 +35,6 @@ export interface ClaimAdRewardRequest {
|
|
|
47
35
|
readonly completedAt: string;
|
|
48
36
|
readonly evidence?: PlatformEvidenceEnvelope;
|
|
49
37
|
}
|
|
50
|
-
export interface ClaimAdRewardResponse {
|
|
51
|
-
readonly granted: boolean;
|
|
52
|
-
readonly ledgerEntryId?: string;
|
|
53
|
-
readonly alreadyProcessed: boolean;
|
|
54
|
-
readonly reason?: string;
|
|
55
|
-
}
|
|
56
38
|
export interface RecordLeaderboardScoreRequest extends LeaderboardScoreInput {
|
|
57
39
|
readonly target: GameServicesLeaderboardTarget;
|
|
58
40
|
readonly playerId: string;
|
|
@@ -71,7 +71,7 @@ async function runFirstSelectionAndSnapshotScenario(context) {
|
|
|
71
71
|
participantLabel: 'First Player',
|
|
72
72
|
attemptId: 'attempt:later',
|
|
73
73
|
score: 20,
|
|
74
|
-
metrics: { elapsedMs:
|
|
74
|
+
metrics: { elapsedMs: 20_000, hints: 1, mistakes: 2 },
|
|
75
75
|
completedAt: '2030-01-02T02:02:00.000Z',
|
|
76
76
|
});
|
|
77
77
|
const laterRecord = await context.service.recordVerifiedAttempt(laterRequest);
|
|
@@ -81,7 +81,7 @@ async function runFirstSelectionAndSnapshotScenario(context) {
|
|
|
81
81
|
participantLabel: 'First Player',
|
|
82
82
|
attemptId: 'attempt:earlier',
|
|
83
83
|
score: 30,
|
|
84
|
-
metrics: { elapsedMs:
|
|
84
|
+
metrics: { elapsedMs: 30_000, hints: 0, mistakes: 1 },
|
|
85
85
|
completedAt: '2030-01-02T02:01:00.000Z',
|
|
86
86
|
}));
|
|
87
87
|
await context.service.recordVerifiedAttempt(createAttempt({
|
|
@@ -99,12 +99,12 @@ async function runFirstSelectionAndSnapshotScenario(context) {
|
|
|
99
99
|
participantLabel: 'First Player',
|
|
100
100
|
attemptId: 'attempt:latest',
|
|
101
101
|
score: 5,
|
|
102
|
-
metrics: { elapsedMs:
|
|
102
|
+
metrics: { elapsedMs: 5_000, hints: 2, mistakes: 0 },
|
|
103
103
|
completedAt: '2030-01-02T02:04:00.000Z',
|
|
104
104
|
}));
|
|
105
105
|
assertEqual(latestRecord.retained, false, 'later completions must not replace the earliest retained attempt');
|
|
106
106
|
assertEqual(latestRecord.entry.attemptId, 'attempt:earlier', 'rejected later attempts must report the retained earliest attempt');
|
|
107
|
-
assertEqual(latestRecord.entry.metrics?.elapsedMs,
|
|
107
|
+
assertEqual(latestRecord.entry.metrics?.elapsedMs, 30_000, 'rejected attempts must report the retained entry metrics');
|
|
108
108
|
const snapshot = await context.service.getSnapshot({
|
|
109
109
|
leaderboardId: 'first:board',
|
|
110
110
|
participantId: 'participant:first',
|
|
@@ -314,7 +314,7 @@ async function runIdentityAndDefinitionConflictsScenario(context) {
|
|
|
314
314
|
participantLabel: 'Original Label',
|
|
315
315
|
attemptId: 'attempt:identity',
|
|
316
316
|
score: 42,
|
|
317
|
-
metrics: { elapsedMs:
|
|
317
|
+
metrics: { elapsedMs: 42_000, hints: 1, mistakes: 2 },
|
|
318
318
|
completedAt: '2030-01-02T02:30:00.000Z',
|
|
319
319
|
});
|
|
320
320
|
await context.service.recordVerifiedAttempt(request);
|
|
@@ -323,7 +323,7 @@ async function runIdentityAndDefinitionConflictsScenario(context) {
|
|
|
323
323
|
attempt: {
|
|
324
324
|
...request.attempt,
|
|
325
325
|
participantLabel: 'Changed Label',
|
|
326
|
-
metrics: { mistakes: 2, elapsedMs:
|
|
326
|
+
metrics: { mistakes: 2, elapsedMs: 42_000, hints: 1 },
|
|
327
327
|
completedAt: '2030-01-02T11:30:00.000+09:00',
|
|
328
328
|
verification: {
|
|
329
329
|
...request.attempt.verification,
|
|
@@ -334,7 +334,7 @@ async function runIdentityAndDefinitionConflictsScenario(context) {
|
|
|
334
334
|
assertEqual(equivalentRetry.alreadyProcessed, true, 'equivalent retries must be idempotent');
|
|
335
335
|
assertEqual(equivalentRetry.entry.participantLabel, 'Original Label', 'retry labels must not rewrite stored presentation metadata');
|
|
336
336
|
assertEqual(equivalentRetry.entry.completedAt, request.attempt.completedAt, 'equivalent retries must preserve the original timestamp representation');
|
|
337
|
-
assertEqual(equivalentRetry.entry.metrics?.elapsedMs,
|
|
337
|
+
assertEqual(equivalentRetry.entry.metrics?.elapsedMs, 42_000, 'equivalent retries must preserve the original metrics');
|
|
338
338
|
await assertRejects(() => context.service.recordVerifiedAttempt({
|
|
339
339
|
...request,
|
|
340
340
|
attempt: { ...request.attempt, score: 43 },
|
|
@@ -343,7 +343,7 @@ async function runIdentityAndDefinitionConflictsScenario(context) {
|
|
|
343
343
|
...request,
|
|
344
344
|
attempt: {
|
|
345
345
|
...request.attempt,
|
|
346
|
-
metrics: { elapsedMs:
|
|
346
|
+
metrics: { elapsedMs: 42_000, hints: 0, mistakes: 2 },
|
|
347
347
|
},
|
|
348
348
|
}), 'attempt IDs reused with different metrics must fail closed');
|
|
349
349
|
await assertRejects(() => context.service.recordVerifiedAttempt({
|
|
@@ -432,7 +432,7 @@ async function runMutationIsolationScenario(context) {
|
|
|
432
432
|
participantLabel: 'Stable Label',
|
|
433
433
|
attemptId: 'attempt:mutation',
|
|
434
434
|
score: 50,
|
|
435
|
-
metrics: { elapsedMs:
|
|
435
|
+
metrics: { elapsedMs: 50_000, hints: 1, mistakes: 2 },
|
|
436
436
|
completedAt: '2030-01-02T02:50:00.000Z',
|
|
437
437
|
});
|
|
438
438
|
const response = await context.service.recordVerifiedAttempt(request);
|
|
@@ -465,7 +465,7 @@ async function runMutationIsolationScenario(context) {
|
|
|
465
465
|
assert(participantEntry !== undefined, 'participant reads must return the retained entry');
|
|
466
466
|
assertEqual(snapshot.definition.scoreOrder, 'ascending', 'stored definitions must be cloned');
|
|
467
467
|
assertEqual(snapshotEntry.score, 50, 'stored attempts must resist caller mutation');
|
|
468
|
-
assertEqual(snapshotEntry.metrics?.elapsedMs,
|
|
468
|
+
assertEqual(snapshotEntry.metrics?.elapsedMs, 50_000, 'stored metrics must be cloned');
|
|
469
469
|
assertEqual(snapshotEntry.participantLabel, 'Stable Label', 'response mutation must not rewrite retained entries');
|
|
470
470
|
try {
|
|
471
471
|
Object.assign(snapshot.definition, { scoreOrder: 'descending' });
|
|
@@ -510,7 +510,7 @@ async function runMutationIsolationScenario(context) {
|
|
|
510
510
|
assertEqual(rereadSnapshot?.definition.scoreOrder, 'ascending', 'snapshot definition mutation must not rewrite stored policy');
|
|
511
511
|
assertEqual(rereadSnapshot?.entries[0]?.score, 50, 'snapshot entry mutation must not rewrite retained attempts');
|
|
512
512
|
assertEqual(rereadSnapshot?.entries[0]?.participantLabel, 'Stable Label', 'snapshot label mutation must not rewrite presentation metadata');
|
|
513
|
-
assertEqual(rereadSnapshot?.entries[0]?.metrics?.elapsedMs,
|
|
513
|
+
assertEqual(rereadSnapshot?.entries[0]?.metrics?.elapsedMs, 50_000, 'snapshot metric mutation must not rewrite retained metrics');
|
|
514
514
|
assertEqual(rereadSnapshot?.entries.length, 1, 'snapshot array mutation must not rewrite retained entries');
|
|
515
515
|
assertEqual(rereadSnapshot?.participantEntry?.score, 50, 'participant entry mutation must not rewrite retained attempts');
|
|
516
516
|
assertEqual(rereadSnapshot?.participantEntry?.participantLabel, 'Stable Label', 'participant entry mutation must not rewrite presentation metadata');
|
|
@@ -572,7 +572,7 @@ function parseTimestamp(input) {
|
|
|
572
572
|
const offsetDirection = match[9] === '-' ? -1 : 1;
|
|
573
573
|
const offsetMilliseconds = offsetDirection
|
|
574
574
|
* (offsetHour * 60 + offsetMinute)
|
|
575
|
-
*
|
|
575
|
+
* 60_000;
|
|
576
576
|
return localTime.getTime() - offsetMilliseconds;
|
|
577
577
|
}
|
|
578
578
|
function compareOrdinal(left, right) {
|
|
@@ -601,7 +601,7 @@ function assertOptionalCursor(input) {
|
|
|
601
601
|
}
|
|
602
602
|
if (typeof input !== 'string'
|
|
603
603
|
|| input.length === 0
|
|
604
|
-
|| input.length >
|
|
604
|
+
|| input.length > 65_536
|
|
605
605
|
|| !/^[A-Za-z0-9_-]+$/u.test(input)) {
|
|
606
606
|
throw new VerifiedLeaderboardCursorError('cursor must be a base64url string no longer than 65536 characters.');
|
|
607
607
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/game-services",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.2",
|
|
4
4
|
"description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -138,12 +138,12 @@
|
|
|
138
138
|
"@orpc/client": "2.0.0-beta.14",
|
|
139
139
|
"@orpc/contract": "2.0.0-beta.14",
|
|
140
140
|
"@orpc/server": "2.0.0-beta.14",
|
|
141
|
-
"@mpgd/
|
|
142
|
-
"@mpgd/
|
|
143
|
-
"@mpgd/
|
|
141
|
+
"@mpgd/catalog": "0.7.3",
|
|
142
|
+
"@mpgd/platform": "0.12.1",
|
|
143
|
+
"@mpgd/analytics": "0.3.13"
|
|
144
144
|
},
|
|
145
145
|
"devDependencies": {
|
|
146
|
-
"ttsc": "0.
|
|
146
|
+
"ttsc": "0.30.4",
|
|
147
147
|
"typescript": "7.0.2"
|
|
148
148
|
},
|
|
149
149
|
"main": "./dist/index.js",
|