@mpgd/adapter-ait 0.12.5 → 0.12.7
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/authority-fetch.d.ts +13 -0
- package/dist/authority-fetch.js +81 -1
- package/dist/host.js +64 -14
- package/package.json +3 -3
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
* response validation in its authority client. This helper only owns the
|
|
9
9
|
* WebView transport invocation, so all game-owned authority endpoints can use
|
|
10
10
|
* the same safe call shape.
|
|
11
|
+
*
|
|
12
|
+
* Authority calls carry grant evidence and idempotency headers, so the
|
|
13
|
+
* resource must resolve to an `https:` origin. Plain `http:` is accepted only
|
|
14
|
+
* for a loopback development authority (`localhost` or `127.0.0.1`).
|
|
15
|
+
*
|
|
16
|
+
* The transport receives exactly the representation that passed the policy
|
|
17
|
+
* check. A string, or a URL-like object (a string `href` and no `url`, which
|
|
18
|
+
* also covers a `URL` created in another realm such as an iframe), is
|
|
19
|
+
* forwarded as its validated absolute `href`. A same-realm `Request` is
|
|
20
|
+
* forwarded unchanged after its immutable `url` validated. Anything else is
|
|
21
|
+
* rejected: a Request-like object that is not a same-realm `Request` would be
|
|
22
|
+
* stringified by native fetch through `toString`, not `url`, so validating its
|
|
23
|
+
* `url` would not cover what is actually fetched.
|
|
11
24
|
*/
|
|
12
25
|
export declare function fetchAitAuthority(input: {
|
|
13
26
|
readonly resource: RequestInfo | URL;
|
package/dist/authority-fetch.js
CHANGED
|
@@ -8,11 +8,91 @@
|
|
|
8
8
|
* response validation in its authority client. This helper only owns the
|
|
9
9
|
* WebView transport invocation, so all game-owned authority endpoints can use
|
|
10
10
|
* the same safe call shape.
|
|
11
|
+
*
|
|
12
|
+
* Authority calls carry grant evidence and idempotency headers, so the
|
|
13
|
+
* resource must resolve to an `https:` origin. Plain `http:` is accepted only
|
|
14
|
+
* for a loopback development authority (`localhost` or `127.0.0.1`).
|
|
15
|
+
*
|
|
16
|
+
* The transport receives exactly the representation that passed the policy
|
|
17
|
+
* check. A string, or a URL-like object (a string `href` and no `url`, which
|
|
18
|
+
* also covers a `URL` created in another realm such as an iframe), is
|
|
19
|
+
* forwarded as its validated absolute `href`. A same-realm `Request` is
|
|
20
|
+
* forwarded unchanged after its immutable `url` validated. Anything else is
|
|
21
|
+
* rejected: a Request-like object that is not a same-realm `Request` would be
|
|
22
|
+
* stringified by native fetch through `toString`, not `url`, so validating its
|
|
23
|
+
* `url` would not cover what is actually fetched.
|
|
11
24
|
*/
|
|
12
25
|
export function fetchAitAuthority(input) {
|
|
13
26
|
const fetchImplementation = input.fetch ?? globalThis.fetch;
|
|
14
27
|
if (typeof fetchImplementation !== 'function') {
|
|
15
28
|
return Promise.reject(new TypeError('AIT authority fetch is unavailable.'));
|
|
16
29
|
}
|
|
17
|
-
|
|
30
|
+
const resolution = resolveAitAuthorityResource(input.resource);
|
|
31
|
+
if (!resolution.ok) {
|
|
32
|
+
return Promise.reject(resolution.error);
|
|
33
|
+
}
|
|
34
|
+
return fetchImplementation(resolution.resource, input.init);
|
|
35
|
+
}
|
|
36
|
+
const loopbackHostnames = new Set(['localhost', '127.0.0.1']);
|
|
37
|
+
function resolveAitAuthorityResource(resource) {
|
|
38
|
+
const candidate = readAitAuthorityResourceCandidate(resource);
|
|
39
|
+
if (candidate === undefined) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
error: new TypeError('AIT authority resource must be a string, URL, or same-realm Request.'),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
let url;
|
|
46
|
+
try {
|
|
47
|
+
url = new URL(candidate.href, globalThis.location?.href);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
error: new TypeError('AIT authority resource must be an absolute https: URL.'),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const policyError = checkAitAuthorityUrl(url);
|
|
56
|
+
if (policyError !== undefined) {
|
|
57
|
+
return { ok: false, error: policyError };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
ok: true,
|
|
61
|
+
resource: candidate.kind === 'request' ? candidate.request : url.href,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function checkAitAuthorityUrl(url) {
|
|
65
|
+
if (url.protocol === 'https:') {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
if (url.protocol === 'http:' && loopbackHostnames.has(url.hostname)) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return new TypeError(`AIT authority resource must use https: (http: is allowed only for localhost); received ${url.protocol}//${url.host}.`);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Reads the URL text once so the same value is both validated and forwarded.
|
|
75
|
+
*
|
|
76
|
+
* URL-like objects are recognized by shape (`instanceof URL` is false for a
|
|
77
|
+
* URL from another realm) and forwarded as their href string, so the object
|
|
78
|
+
* itself never reaches the transport. Requests are only accepted via a
|
|
79
|
+
* same-realm `instanceof Request`: the instance must be forwarded as-is, and
|
|
80
|
+
* native fetch stringifies any non-Request object through `toString` rather
|
|
81
|
+
* than reading `url`, so a duck-typed `{ url }` cannot be trusted.
|
|
82
|
+
*/
|
|
83
|
+
function readAitAuthorityResourceCandidate(resource) {
|
|
84
|
+
if (typeof resource === 'string') {
|
|
85
|
+
return { kind: 'href', href: resource };
|
|
86
|
+
}
|
|
87
|
+
if (typeof resource !== 'object' || resource === null) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (typeof Request === 'function' && resource instanceof Request) {
|
|
91
|
+
return { kind: 'request', href: resource.url, request: resource };
|
|
92
|
+
}
|
|
93
|
+
const { href, url } = resource;
|
|
94
|
+
if (typeof href === 'string' && url === undefined) {
|
|
95
|
+
return { kind: 'href', href };
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
18
98
|
}
|
package/dist/host.js
CHANGED
|
@@ -16,6 +16,13 @@ const invalidBridgeRequestId = 'ait-invalid-request';
|
|
|
16
16
|
const rewardedAdEvidenceSchema = 'apps-in-toss.rewarded-ad.callback.v1';
|
|
17
17
|
const iapEvidenceSchema = 'apps-in-toss.iap.callback.v1';
|
|
18
18
|
const minimumPromotionTossAppVersion = '5.232.0';
|
|
19
|
+
/**
|
|
20
|
+
* Every adapter-owned durable marker (purchase attempts, promotion grants,
|
|
21
|
+
* recovery cursors) lives under this prefix. Game code shares the same native
|
|
22
|
+
* storage, so `storage.save`/`storage.load` must never touch these keys: a
|
|
23
|
+
* forged `completed` marker would otherwise short-circuit purchase verification.
|
|
24
|
+
*/
|
|
25
|
+
const reservedStorageKeyPrefix = 'mpgd:ait:';
|
|
19
26
|
const promotionGrantStoragePrefix = 'mpgd:ait:promotion-grant:v1:';
|
|
20
27
|
/**
|
|
21
28
|
* A client idempotency key must survive a bridge reload. The authoritative
|
|
@@ -372,6 +379,7 @@ export function createAitHostBridge(options = {}) {
|
|
|
372
379
|
}
|
|
373
380
|
case 'ads.showRewarded': {
|
|
374
381
|
const placementId = readPlacementId(request.payload);
|
|
382
|
+
const correlationId = readRewardedAdCorrelationId(request.payload, request.id);
|
|
375
383
|
const adGroupId = adGroupIds.get(placementId);
|
|
376
384
|
if (adGroupId === undefined
|
|
377
385
|
|| adPlacementTypes.get(placementId) !== 'rewarded'
|
|
@@ -379,13 +387,15 @@ export function createAitHostBridge(options = {}) {
|
|
|
379
387
|
return ok(request, { status: 'unavailable', rewardGranted: false });
|
|
380
388
|
}
|
|
381
389
|
const shown = await withLoadedAdSlot(dependencies, adGroupId, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, activeAdGroupIds, adTimeoutMs, 'rewarded', async () => {
|
|
382
|
-
const correlationId = readIdempotencyKey(request.payload, request.id);
|
|
383
390
|
const result = await showRewardedAd(dependencies, adGroupId, adTimeoutMs, adDisplayStartTimeoutMs, adMaximumDisplayMs);
|
|
384
391
|
return result.rewardGranted
|
|
385
392
|
? {
|
|
386
393
|
...result,
|
|
387
|
-
//
|
|
388
|
-
//
|
|
394
|
+
// The correlationId is client-supplied evidence, not a native receipt:
|
|
395
|
+
// the Apps in Toss show call only carries the adGroupId, so nothing
|
|
396
|
+
// native echoes it back. game-services forwards it as
|
|
397
|
+
// platformImpressionId; the backend rewardAuthority must verify it
|
|
398
|
+
// (and the rest of this evidence) before granting any reward.
|
|
389
399
|
ledgerEntryId: correlationId,
|
|
390
400
|
evidence: {
|
|
391
401
|
schema: rewardedAdEvidenceSchema,
|
|
@@ -1030,11 +1040,19 @@ function showAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maxim
|
|
|
1030
1040
|
}
|
|
1031
1041
|
});
|
|
1032
1042
|
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Launch and inbound-share parameters arrive from untrusted deep links. Bound
|
|
1045
|
+
* each forwarded value and the nested `queryParams` JSON so a hostile link
|
|
1046
|
+
* cannot push oversized or unbounded data into the game.
|
|
1047
|
+
*/
|
|
1048
|
+
const maximumLaunchParamLength = 256;
|
|
1049
|
+
const maximumNestedQueryParamsJsonLength = 4_096;
|
|
1050
|
+
const maximumNestedQueryParamCount = 32;
|
|
1033
1051
|
function getLaunchIntent() {
|
|
1034
1052
|
const params = inboundSearchParams();
|
|
1035
|
-
const challengeToken =
|
|
1036
|
-
const puzzleId =
|
|
1037
|
-
const requestedEntry =
|
|
1053
|
+
const challengeToken = boundedParam(params.get('challengeToken'));
|
|
1054
|
+
const puzzleId = boundedParam(params.get('puzzleId'));
|
|
1055
|
+
const requestedEntry = boundedParam(params.get('entry'));
|
|
1038
1056
|
let entry;
|
|
1039
1057
|
if (requestedEntry !== undefined && launchEntries.has(requestedEntry)) {
|
|
1040
1058
|
entry = requestedEntry;
|
|
@@ -1053,8 +1071,8 @@ function getLaunchIntent() {
|
|
|
1053
1071
|
}
|
|
1054
1072
|
function readInboundShare() {
|
|
1055
1073
|
const params = inboundSearchParams();
|
|
1056
|
-
const puzzleId =
|
|
1057
|
-
const challengeToken =
|
|
1074
|
+
const puzzleId = boundedParam(params.get('puzzleId'));
|
|
1075
|
+
const challengeToken = boundedParam(params.get('challengeToken'));
|
|
1058
1076
|
return puzzleId === undefined && challengeToken === undefined
|
|
1059
1077
|
? null
|
|
1060
1078
|
: {
|
|
@@ -1065,12 +1083,24 @@ function readInboundShare() {
|
|
|
1065
1083
|
function inboundSearchParams() {
|
|
1066
1084
|
const params = new URLSearchParams(globalThis.location?.search ?? '');
|
|
1067
1085
|
const nested = params.get('queryParams');
|
|
1068
|
-
if (nested !== null) {
|
|
1086
|
+
if (nested !== null && nested.length <= maximumNestedQueryParamsJsonLength) {
|
|
1069
1087
|
try {
|
|
1070
1088
|
const parsed = JSON.parse(nested);
|
|
1071
1089
|
if (isRecord(parsed)) {
|
|
1090
|
+
let merged = 0;
|
|
1072
1091
|
for (const [key, value] of Object.entries(parsed)) {
|
|
1073
|
-
if (
|
|
1092
|
+
if (merged >= maximumNestedQueryParamCount) {
|
|
1093
|
+
break;
|
|
1094
|
+
}
|
|
1095
|
+
// Direct search params win; nested values only fill missing keys and
|
|
1096
|
+
// must be bounded strings. Keys are counted whether or not they merge
|
|
1097
|
+
// so an attacker cannot pad past the cap with ignored entries.
|
|
1098
|
+
merged += 1;
|
|
1099
|
+
if (typeof value === 'string'
|
|
1100
|
+
&& key.length > 0
|
|
1101
|
+
&& key.length <= maximumLaunchParamLength
|
|
1102
|
+
&& value.length <= maximumLaunchParamLength
|
|
1103
|
+
&& !params.has(key)) {
|
|
1074
1104
|
params.set(key, value);
|
|
1075
1105
|
}
|
|
1076
1106
|
}
|
|
@@ -2170,6 +2200,9 @@ function readStorageKey(payload) {
|
|
|
2170
2200
|
if (typeof key !== 'string' || key.length === 0) {
|
|
2171
2201
|
throw new TypeError('AIT storage key must be a non-empty string.');
|
|
2172
2202
|
}
|
|
2203
|
+
if (key.startsWith(reservedStorageKeyPrefix)) {
|
|
2204
|
+
throw new TypeError(`AIT storage keys starting with "${reservedStorageKeyPrefix}" are reserved for adapter-owned markers.`);
|
|
2205
|
+
}
|
|
2173
2206
|
return key;
|
|
2174
2207
|
}
|
|
2175
2208
|
function readPlacementId(payload) {
|
|
@@ -2186,9 +2219,24 @@ function readBannerSurfaceId(payload) {
|
|
|
2186
2219
|
}
|
|
2187
2220
|
return surfaceId;
|
|
2188
2221
|
}
|
|
2189
|
-
|
|
2222
|
+
/**
|
|
2223
|
+
* A rewarded-ad idempotencyKey becomes the client-supplied evidence
|
|
2224
|
+
* correlationId. It is optional, but when present it must obey the same
|
|
2225
|
+
* bounded visible-character rule as purchase keys so a game cannot push
|
|
2226
|
+
* arbitrary blobs into reward evidence.
|
|
2227
|
+
*/
|
|
2228
|
+
function readRewardedAdCorrelationId(payload, fallback) {
|
|
2190
2229
|
const value = readPayloadRecord(payload).idempotencyKey;
|
|
2191
|
-
|
|
2230
|
+
if (value === undefined) {
|
|
2231
|
+
return fallback;
|
|
2232
|
+
}
|
|
2233
|
+
if (typeof value !== 'string'
|
|
2234
|
+
|| value.length === 0
|
|
2235
|
+
|| value.length > 256
|
|
2236
|
+
|| /[\p{Cc}\p{Cf}]/u.test(value)) {
|
|
2237
|
+
throw new TypeError('AIT rewarded ad idempotencyKey must contain 1 to 256 visible characters.');
|
|
2238
|
+
}
|
|
2239
|
+
return value;
|
|
2192
2240
|
}
|
|
2193
2241
|
function readCommercePurchase(payload) {
|
|
2194
2242
|
const value = readPayloadRecord(payload);
|
|
@@ -2256,8 +2304,10 @@ function ok(request, data) {
|
|
|
2256
2304
|
function createBridgeError(id, code, message, retryable = false) {
|
|
2257
2305
|
return { id, ok: false, error: { code, message, retryable } };
|
|
2258
2306
|
}
|
|
2259
|
-
function
|
|
2260
|
-
return value === null || value.length === 0
|
|
2307
|
+
function boundedParam(value) {
|
|
2308
|
+
return value === null || value.length === 0 || value.length > maximumLaunchParamLength
|
|
2309
|
+
? undefined
|
|
2310
|
+
: value;
|
|
2261
2311
|
}
|
|
2262
2312
|
function isAbortError(error) {
|
|
2263
2313
|
return isRecord(error) && error.name === 'AbortError';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/adapter-ait",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.7",
|
|
4
4
|
"description": "Apps in Toss WebView platform adapter for mpgd games.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"dist"
|
|
60
60
|
],
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@mpgd/
|
|
63
|
-
"@mpgd/
|
|
62
|
+
"@mpgd/bridge": "0.10.0",
|
|
63
|
+
"@mpgd/platform": "0.14.0"
|
|
64
64
|
},
|
|
65
65
|
"peerDependencies": {
|
|
66
66
|
"@apps-in-toss/web-framework": ">=3.0.0 <4"
|