@mpgd/platform 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capability-conformance.d.ts +6 -5
- package/dist/capability-conformance.js +178 -42
- package/dist/commerce-capabilities.d.ts +0 -6
- package/dist/commerce-capabilities.js +2 -8
- package/dist/commerce-contract.d.ts +12 -2
- package/dist/commerce.d.ts +8 -17
- package/dist/commerce.js +10 -94
- package/dist/index.d.ts +80 -43
- package/dist/index.js +3 -36
- package/dist/notifications-conformance.d.ts +529 -0
- package/dist/notifications-conformance.js +3537 -0
- package/dist/notifications-contract.d.ts +204 -0
- package/dist/notifications-contract.js +1 -0
- package/dist/notifications-policy.d.ts +885 -0
- package/dist/notifications-policy.js +1658 -0
- package/dist/notifications-vectors.d.ts +427 -0
- package/dist/notifications-vectors.js +4949 -0
- package/dist/notifications.d.ts +265 -0
- package/dist/notifications.js +4958 -0
- package/dist/route-vectors.d.ts +23 -0
- package/dist/route-vectors.js +96 -0
- package/dist/routes.d.ts +60 -0
- package/dist/routes.js +209 -0
- package/dist/share-link-conformance.d.ts +47 -0
- package/dist/share-link-conformance.js +100 -0
- package/package.json +26 -2
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared vectors for `@mpgd/platform/routes`. Every package that turns a
|
|
3
|
+
* game-supplied root-relative link into a path on the game or mini-app origin
|
|
4
|
+
* runs these, so a new escape is added here once and caught everywhere.
|
|
5
|
+
*/
|
|
6
|
+
export interface RootRelativeRouteVectors {
|
|
7
|
+
/** Root-relative inputs `normalizeRootRelativeRoute` accepts, with the route it returns. */
|
|
8
|
+
readonly normalized: readonly (readonly [input: string, route: string])[];
|
|
9
|
+
/** Root-relative inputs a client could read as another host: `normalizeRootRelativeRoute` answers `undefined`. */
|
|
10
|
+
readonly rejected: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
export declare const rootRelativeRouteVectors: RootRelativeRouteVectors;
|
|
13
|
+
/**
|
|
14
|
+
* Shared vectors for `normalizeShareLink`, next to the root-relative routes
|
|
15
|
+
* and escapes above, which share links follow too.
|
|
16
|
+
*/
|
|
17
|
+
export interface ShareLinkVectors {
|
|
18
|
+
/** Absolute links `normalizeShareLink` accepts, with the link it returns. */
|
|
19
|
+
readonly normalized: readonly (readonly [input: string, link: string])[];
|
|
20
|
+
/** Links outside the `ShareIntent.deepLink` contract: `normalizeShareLink` answers `undefined`. */
|
|
21
|
+
readonly rejected: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
export declare const shareLinkVectors: ShareLinkVectors;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export const rootRelativeRouteVectors = {
|
|
2
|
+
normalized: [
|
|
3
|
+
['/', '/'],
|
|
4
|
+
['/daily', '/daily'],
|
|
5
|
+
['/levels//3?from=push#start', '/levels//3?from=push#start'],
|
|
6
|
+
['/levels/../daily', '/daily'],
|
|
7
|
+
['/%2e%2e/levels', '/levels'],
|
|
8
|
+
['/?//x', '/?//x'],
|
|
9
|
+
['/#//x', '/#//x'],
|
|
10
|
+
['/a/%2Fb', '/a/%2Fb'],
|
|
11
|
+
['/a/%09b', '/a/%09b'],
|
|
12
|
+
['/a/b\\c', '/a/b/c'],
|
|
13
|
+
['/%252F/x', '/%252F/x'],
|
|
14
|
+
['/search%3Fq', '/search%3Fq'],
|
|
15
|
+
['/é', '/%C3%A9'],
|
|
16
|
+
['/?q=a b', '/?q=a%20b'],
|
|
17
|
+
],
|
|
18
|
+
rejected: [
|
|
19
|
+
// A second slash or a backslash after the leading slash parses as an authority.
|
|
20
|
+
'//evil.example/daily',
|
|
21
|
+
'/\\evil.example/daily',
|
|
22
|
+
'/\\ x',
|
|
23
|
+
'/\\ evil.example/daily',
|
|
24
|
+
'/\\mpgd.invalid/daily',
|
|
25
|
+
'/\\mpgd.invalid//evil.example/daily',
|
|
26
|
+
'/\\user:pw@mpgd.invalid:443/daily',
|
|
27
|
+
// Control characters; URL parsing would drop the tab or newline.
|
|
28
|
+
'/\t/ x',
|
|
29
|
+
'/\t/evil.example/daily',
|
|
30
|
+
'/\n/mpgd.invalid/daily',
|
|
31
|
+
'/daily\u0000',
|
|
32
|
+
'/daily\u007f',
|
|
33
|
+
// Dot segments collapse into a leading `//`.
|
|
34
|
+
'/.//evil.example/daily',
|
|
35
|
+
'/levels/..//evil.example/daily',
|
|
36
|
+
'/%2e//evil.example/daily',
|
|
37
|
+
'/%2E%2E//evil.example/daily',
|
|
38
|
+
'/./\\evil.example/daily',
|
|
39
|
+
// One percent-decode turns these into the shapes above.
|
|
40
|
+
'/%2F%2Fevil.example/daily',
|
|
41
|
+
'/%5cevil.example/daily',
|
|
42
|
+
'/%09/evil.example/daily',
|
|
43
|
+
'/%0A%0d/evil.example/daily',
|
|
44
|
+
'/%09%5Cevil.example/daily',
|
|
45
|
+
'/%09.//evil.example/daily',
|
|
46
|
+
'/.%2F/evil.example/daily',
|
|
47
|
+
'/levels/..%2F%2Fevil.example/daily',
|
|
48
|
+
// A double-encoded dot segment resolves after one decode.
|
|
49
|
+
'/%252e%252e//evil.example/daily',
|
|
50
|
+
// A client may keep a decoded `?` or `#` in the path, or end the path at any of them.
|
|
51
|
+
'/%3F%2F..%2F%2Fevil.example/daily',
|
|
52
|
+
'/%23%2F..%2F%2Fevil.example/daily',
|
|
53
|
+
'/a%3Fb%23%2F..%2F%2Fevil.example/daily',
|
|
54
|
+
'/.%2F%2Fevil.example%3F%2F..%2F..',
|
|
55
|
+
'/.%2F%2Fevil.example%23%2F..%2F..',
|
|
56
|
+
'/.%2F%2Fevil.example%3F%2F..%2F..%3F%2F..%2F..',
|
|
57
|
+
'/a%2F..%2F%2Fevil.example%3F%2F..%2F..%2F..',
|
|
58
|
+
'/a%23%2F..%2F%2Fevil.example%3F%2F..%2F..',
|
|
59
|
+
'/a%3F%2F..%2F%2Fevil.example%23%2F..%2F..',
|
|
60
|
+
'/a%3F%2F..%2F%2Fevil.example%3F%2F..%2F..',
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
export const shareLinkVectors = {
|
|
64
|
+
normalized: [
|
|
65
|
+
[
|
|
66
|
+
'https://game.example/daily?challengeToken=x#result',
|
|
67
|
+
'https://game.example/daily?challengeToken=x#result',
|
|
68
|
+
],
|
|
69
|
+
['HTTPS://Game.Example/levels/../daily', 'https://game.example/daily'],
|
|
70
|
+
['http://game.example', 'http://game.example/'],
|
|
71
|
+
['https://game.example/levels//3', 'https://game.example/levels//3'],
|
|
72
|
+
],
|
|
73
|
+
rejected: [
|
|
74
|
+
// Page-relative or empty links, which hosts would resolve differently.
|
|
75
|
+
'',
|
|
76
|
+
' /daily',
|
|
77
|
+
'./daily',
|
|
78
|
+
'daily?x=1',
|
|
79
|
+
'?challengeToken=x',
|
|
80
|
+
'#top',
|
|
81
|
+
// Other schemes, including an Apps in Toss link for another app.
|
|
82
|
+
'javascript:alert(1)',
|
|
83
|
+
'data:text/html,hi',
|
|
84
|
+
'intoss://other-app/daily',
|
|
85
|
+
'\\\\evil.example/daily',
|
|
86
|
+
// Absolute links that are not spelled out as HTTP(S), carry credentials,
|
|
87
|
+
// or keep a path a client could read as another host.
|
|
88
|
+
'https:/daily',
|
|
89
|
+
'https:evil.example/x',
|
|
90
|
+
'https://user:pw@game.example/daily',
|
|
91
|
+
'https://game.example//evil.example/x',
|
|
92
|
+
'https://game.example/.//evil.example/x',
|
|
93
|
+
'https://game.example/%2F%2Fevil.example/x',
|
|
94
|
+
'https://game.example/daily\n',
|
|
95
|
+
],
|
|
96
|
+
};
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ShareAdapter, ShareIntent } from './index.js';
|
|
2
|
+
/** Whether `value` contains an ASCII control character (C0 or DEL). */
|
|
3
|
+
export declare function hasRouteControlCharacter(value: string): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Whether a normalized URL path could be read as a network-path reference to
|
|
6
|
+
* another host. Dot segments (also percent-encoded) can collapse `/.//host`
|
|
7
|
+
* into `//host`. A consumer that percent-decodes the path once and parses it
|
|
8
|
+
* again can reach the same shape from `/%2F/host`, `/%5Chost`, `/%09/host`,
|
|
9
|
+
* or `/.%2F/host`: URL parsing drops tabs and newlines, reads a backslash as a
|
|
10
|
+
* slash, and removes dot segments, so the decoded path is checked after the
|
|
11
|
+
* same steps. A consumer may also end the decoded path at any `?` or `#`, or
|
|
12
|
+
* keep them all in it, so each of those readings is checked:
|
|
13
|
+
* `/.%2F%2Fhost%3F%2F..%2F..` reaches `//host` when the path ends at the `?`,
|
|
14
|
+
* and `/%3F%2F..%2F%2Fhost` when it keeps it. A path with more than 16
|
|
15
|
+
* decoded `?` and `#` is answered `true`. `pathname` is a URL's path (it
|
|
16
|
+
* starts with `/`); anything else is answered `true`, so a caller fails
|
|
17
|
+
* closed.
|
|
18
|
+
*/
|
|
19
|
+
export declare function hasNetworkPathPrefix(pathname: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Normalizes a root-relative link to the `pathname`, `search`, and `hash` it
|
|
22
|
+
* names on the game's own origin, or answers `undefined` when a client could
|
|
23
|
+
* read it as another host: a second slash or a backslash right after the
|
|
24
|
+
* leading slash (`//host`, `/\host`), a control character, or a path whose
|
|
25
|
+
* normalized form begins with an empty segment, also once percent-decoded
|
|
26
|
+
* (see `hasNetworkPathPrefix`, which also refuses a path with more than 16
|
|
27
|
+
* percent-encoded `?` and `#`, `%3F` and `%23`).
|
|
28
|
+
* Dot segments that stay on the origin resolve (`/levels/../daily` is
|
|
29
|
+
* `/daily`), and a normalized route normalizes to itself. The result is not
|
|
30
|
+
* length-capped, because percent-encoding can grow it; callers apply their
|
|
31
|
+
* own limits.
|
|
32
|
+
*/
|
|
33
|
+
export declare function normalizeRootRelativeRoute(input: string): string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Normalizes a `ShareIntent.deepLink`, or answers `undefined` for a link
|
|
36
|
+
* outside its contract. A root-relative link becomes the route
|
|
37
|
+
* `normalizeRootRelativeRoute` returns. An absolute link must spell out
|
|
38
|
+
* `https://` or `http://`, carry no credentials, and keep a path no client
|
|
39
|
+
* can read as another host; it comes back serialized. Anything else is
|
|
40
|
+
* refused: a page-relative link (`./daily`, `?x=1`), another scheme
|
|
41
|
+
* (`javascript:`, `intoss:`), and any link with a control character.
|
|
42
|
+
*/
|
|
43
|
+
export declare function normalizeShareLink(deepLink: string): string | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Reads a `share` intent under the `ShareIntent` contract: the intent with its
|
|
46
|
+
* `deepLink` normalized by `normalizeShareLink`, or `undefined` when it is not
|
|
47
|
+
* an object, its kind is unsupported, its title or text is not a string, or
|
|
48
|
+
* its `deepLink` is not a link that
|
|
49
|
+
* rule accepts. The intent may come from untyped game code, so none of this
|
|
50
|
+
* is assumed.
|
|
51
|
+
*/
|
|
52
|
+
export declare function readShareIntent(intent: unknown): ShareIntent | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Puts `readShareIntent` in front of an adapter's `share`: the adapter and its
|
|
55
|
+
* host see only intents the contract accepts, with the link normalized, and
|
|
56
|
+
* any other intent answers `unavailable` without reaching them. Every other
|
|
57
|
+
* member is kept as is. Adapters wrap their `sharing` with it, and their tests
|
|
58
|
+
* run `@mpgd/platform/share-link-conformance`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function withShareIntentCheck(sharing: ShareAdapter): ShareAdapter;
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route safety for links a game hands to the kit, such as notification deep
|
|
3
|
+
* links and share links. A URL parser treats `\` as `/`, drops tabs and
|
|
4
|
+
* newlines, and resolves dot segments, and some clients percent-decode a path
|
|
5
|
+
* once before routing it. Any of these can turn a root-relative link into a
|
|
6
|
+
* protocol-relative one (`//evil.example/x`) that a client opens on another
|
|
7
|
+
* host.
|
|
8
|
+
*/
|
|
9
|
+
/** Collapse HTTP(S) path segments without requiring the DOM URL global. */
|
|
10
|
+
function normalizePath(path) {
|
|
11
|
+
const segments = path.replace(/\\/gu, '/').slice(1).split('/');
|
|
12
|
+
const result = [];
|
|
13
|
+
for (const [index, segment] of segments.entries()) {
|
|
14
|
+
const dot = segment.replace(/%2e/giu, '.');
|
|
15
|
+
if (dot === '.' || dot === '..') {
|
|
16
|
+
if (dot === '..') {
|
|
17
|
+
result.pop();
|
|
18
|
+
}
|
|
19
|
+
if (index === segments.length - 1) {
|
|
20
|
+
result.push('');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
result.push(segment);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return `/${result.join('/')}`;
|
|
28
|
+
}
|
|
29
|
+
/** WHATWG HTTP(S) component encoding; existing percent escapes stay intact. */
|
|
30
|
+
function encodeRouteComponent(value, component) {
|
|
31
|
+
const extra = component === 'path' ? ' "#<>?^`{}' : component === 'query' ? ' "#<>\'' : ' "<>`';
|
|
32
|
+
let result = '';
|
|
33
|
+
for (const character of value) {
|
|
34
|
+
const code = character.codePointAt(0) ?? 0;
|
|
35
|
+
if (code > 0x7e || code <= 0x20 || extra.includes(character)) {
|
|
36
|
+
const scalar = code >= 0xd800 && code <= 0xdfff ? '\uFFFD' : character;
|
|
37
|
+
// encodeURIComponent leaves an apostrophe unescaped, unlike a special query.
|
|
38
|
+
result += character === "'" ? '%27' : encodeURIComponent(scalar);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
result += character;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
/** Whether `value` contains an ASCII control character (C0 or DEL). */
|
|
47
|
+
export function hasRouteControlCharacter(value) {
|
|
48
|
+
return /[\x00-\x1F\x7F]/u.test(value);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The most decoded `?` and `#` a path may carry. Each one is a reading
|
|
52
|
+
* `hasNetworkPathPrefix` parses, so a path with more is refused instead.
|
|
53
|
+
*/
|
|
54
|
+
const maxPathDelimiters = 16;
|
|
55
|
+
/**
|
|
56
|
+
* Whether a normalized URL path could be read as a network-path reference to
|
|
57
|
+
* another host. Dot segments (also percent-encoded) can collapse `/.//host`
|
|
58
|
+
* into `//host`. A consumer that percent-decodes the path once and parses it
|
|
59
|
+
* again can reach the same shape from `/%2F/host`, `/%5Chost`, `/%09/host`,
|
|
60
|
+
* or `/.%2F/host`: URL parsing drops tabs and newlines, reads a backslash as a
|
|
61
|
+
* slash, and removes dot segments, so the decoded path is checked after the
|
|
62
|
+
* same steps. A consumer may also end the decoded path at any `?` or `#`, or
|
|
63
|
+
* keep them all in it, so each of those readings is checked:
|
|
64
|
+
* `/.%2F%2Fhost%3F%2F..%2F..` reaches `//host` when the path ends at the `?`,
|
|
65
|
+
* and `/%3F%2F..%2F%2Fhost` when it keeps it. A path with more than 16
|
|
66
|
+
* decoded `?` and `#` is answered `true`. `pathname` is a URL's path (it
|
|
67
|
+
* starts with `/`); anything else is answered `true`, so a caller fails
|
|
68
|
+
* closed.
|
|
69
|
+
*/
|
|
70
|
+
export function hasNetworkPathPrefix(pathname) {
|
|
71
|
+
if (!pathname.startsWith('/')) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
const decoded = pathname
|
|
75
|
+
.replace(/%([0-7][0-9a-f])/giu, (_escape, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
|
76
|
+
.replace(/[\t\n\r]/gu, '');
|
|
77
|
+
if (/^[/\\]{2}/u.test(decoded)) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
const delimiters = [];
|
|
81
|
+
for (const match of decoded.matchAll(/[?#]/gu)) {
|
|
82
|
+
if (delimiters.length === maxPathDelimiters) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
delimiters.push(match.index);
|
|
86
|
+
}
|
|
87
|
+
// Each reading is a prefix of the decoded path, so it starts with one slash
|
|
88
|
+
// and no second slash or backslash: parsing it cannot fail or reach another
|
|
89
|
+
// origin. A `?` or `#` left in a reading is escaped so it stays in the path,
|
|
90
|
+
// and the path-only normalizer keeps trailing spaces and controls, as when
|
|
91
|
+
// the rest of the link follows, instead of stripping them as URL input.
|
|
92
|
+
return [...delimiters, decoded.length].some((end) => {
|
|
93
|
+
const reading = decoded
|
|
94
|
+
.slice(0, end)
|
|
95
|
+
.replace(/[?#]/gu, (delimiter) => (delimiter === '?' ? '%3F' : '%23'));
|
|
96
|
+
return normalizePath(reading).startsWith('//');
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Normalizes a root-relative link to the `pathname`, `search`, and `hash` it
|
|
101
|
+
* names on the game's own origin, or answers `undefined` when a client could
|
|
102
|
+
* read it as another host: a second slash or a backslash right after the
|
|
103
|
+
* leading slash (`//host`, `/\host`), a control character, or a path whose
|
|
104
|
+
* normalized form begins with an empty segment, also once percent-decoded
|
|
105
|
+
* (see `hasNetworkPathPrefix`, which also refuses a path with more than 16
|
|
106
|
+
* percent-encoded `?` and `#`, `%3F` and `%23`).
|
|
107
|
+
* Dot segments that stay on the origin resolve (`/levels/../daily` is
|
|
108
|
+
* `/daily`), and a normalized route normalizes to itself. The result is not
|
|
109
|
+
* length-capped, because percent-encoding can grow it; callers apply their
|
|
110
|
+
* own limits.
|
|
111
|
+
*/
|
|
112
|
+
export function normalizeRootRelativeRoute(input) {
|
|
113
|
+
if (!input.startsWith('/')
|
|
114
|
+
|| input.startsWith('//')
|
|
115
|
+
|| input.startsWith('/\\')
|
|
116
|
+
|| hasRouteControlCharacter(input)) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
// Match the HTTP(S) URL path algorithm with string operations so pure
|
|
120
|
+
// notification/catalog validation also works in runtimes with no URL.
|
|
121
|
+
const trimmed = input.replace(/ +$/u, '');
|
|
122
|
+
const hashIndex = trimmed.indexOf('#');
|
|
123
|
+
const beforeHash = hashIndex < 0 ? trimmed : trimmed.slice(0, hashIndex);
|
|
124
|
+
const queryIndex = beforeHash.indexOf('?');
|
|
125
|
+
const path = queryIndex < 0 ? beforeHash : beforeHash.slice(0, queryIndex);
|
|
126
|
+
const query = queryIndex < 0 ? '' : beforeHash.slice(queryIndex + 1);
|
|
127
|
+
const fragment = hashIndex < 0 ? '' : trimmed.slice(hashIndex + 1);
|
|
128
|
+
const pathname = encodeRouteComponent(normalizePath(path), 'path');
|
|
129
|
+
const search = query === '' ? '' : `?${encodeRouteComponent(query, 'query')}`;
|
|
130
|
+
const hash = fragment === '' ? '' : `#${encodeRouteComponent(fragment, 'hash')}`;
|
|
131
|
+
return hasNetworkPathPrefix(pathname) ? undefined : `${pathname}${search}${hash}`;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Normalizes a `ShareIntent.deepLink`, or answers `undefined` for a link
|
|
135
|
+
* outside its contract. A root-relative link becomes the route
|
|
136
|
+
* `normalizeRootRelativeRoute` returns. An absolute link must spell out
|
|
137
|
+
* `https://` or `http://`, carry no credentials, and keep a path no client
|
|
138
|
+
* can read as another host; it comes back serialized. Anything else is
|
|
139
|
+
* refused: a page-relative link (`./daily`, `?x=1`), another scheme
|
|
140
|
+
* (`javascript:`, `intoss:`), and any link with a control character.
|
|
141
|
+
*/
|
|
142
|
+
export function normalizeShareLink(deepLink) {
|
|
143
|
+
if (hasRouteControlCharacter(deepLink)) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
if (deepLink.startsWith('/')) {
|
|
147
|
+
return normalizeRootRelativeRoute(deepLink);
|
|
148
|
+
}
|
|
149
|
+
// `https:/daily` and `https:host/x` parse as absolute links to another host
|
|
150
|
+
// without a base but resolve on the page with one, so only the full form counts.
|
|
151
|
+
if (!/^https?:\/\//iu.test(deepLink)) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
let parsed;
|
|
155
|
+
try {
|
|
156
|
+
parsed = new URL(deepLink);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
return parsed.username === ''
|
|
162
|
+
&& parsed.password === ''
|
|
163
|
+
&& !hasNetworkPathPrefix(parsed.pathname)
|
|
164
|
+
? parsed.href
|
|
165
|
+
: undefined;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Reads a `share` intent under the `ShareIntent` contract: the intent with its
|
|
169
|
+
* `deepLink` normalized by `normalizeShareLink`, or `undefined` when it is not
|
|
170
|
+
* an object, its kind is unsupported, its title or text is not a string, or
|
|
171
|
+
* its `deepLink` is not a link that
|
|
172
|
+
* rule accepts. The intent may come from untyped game code, so none of this
|
|
173
|
+
* is assumed.
|
|
174
|
+
*/
|
|
175
|
+
export function readShareIntent(intent) {
|
|
176
|
+
if (typeof intent !== 'object' || intent === null || Array.isArray(intent)) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
const { kind, title, text, deepLink } = intent;
|
|
180
|
+
const link = typeof deepLink === 'string' ? normalizeShareLink(deepLink) : undefined;
|
|
181
|
+
return (kind === 'daily-result' || kind === 'friend-challenge' || kind === 'invite')
|
|
182
|
+
&& typeof title === 'string' && typeof text === 'string' && link !== undefined
|
|
183
|
+
? { ...intent, kind, title, text, deepLink: link }
|
|
184
|
+
: undefined;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Puts `readShareIntent` in front of an adapter's `share`: the adapter and its
|
|
188
|
+
* host see only intents the contract accepts, with the link normalized, and
|
|
189
|
+
* any other intent answers `unavailable` without reaching them. Every other
|
|
190
|
+
* member is kept as is. Adapters wrap their `sharing` with it, and their tests
|
|
191
|
+
* run `@mpgd/platform/share-link-conformance`.
|
|
192
|
+
*/
|
|
193
|
+
export function withShareIntentCheck(sharing) {
|
|
194
|
+
const share = sharing.share?.bind(sharing);
|
|
195
|
+
const readInboundShare = sharing.readInboundShare?.bind(sharing);
|
|
196
|
+
const checkedShare = share === undefined
|
|
197
|
+
? undefined
|
|
198
|
+
: async (intent) => {
|
|
199
|
+
const checked = readShareIntent(intent);
|
|
200
|
+
return checked === undefined ? { status: 'unavailable' } : share(checked);
|
|
201
|
+
};
|
|
202
|
+
// The bound methods also cover an adapter whose methods live on a prototype,
|
|
203
|
+
// which the spread does not copy.
|
|
204
|
+
return {
|
|
205
|
+
...sharing,
|
|
206
|
+
...(checkedShare === undefined ? {} : { share: checkedShare }),
|
|
207
|
+
...(readInboundShare === undefined ? {} : { readInboundShare }),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ShareIntent, ShareResult } from './index.js';
|
|
2
|
+
/** How a fixture observes what its host was handed. */
|
|
3
|
+
export interface ShareLinkConformanceHost {
|
|
4
|
+
/**
|
|
5
|
+
* What the host was handed for each share that reached it, oldest first:
|
|
6
|
+
* the link, or `null` for a host that takes no link (a WeChat card).
|
|
7
|
+
*/
|
|
8
|
+
readonly handedLinks: () => readonly (string | null)[];
|
|
9
|
+
/**
|
|
10
|
+
* What the host is handed for a link `normalizeShareLink` accepted: the
|
|
11
|
+
* normalized route or absolute URL as the adapter maps it, or `null` for a
|
|
12
|
+
* host that takes no link.
|
|
13
|
+
*/
|
|
14
|
+
readonly expectedLink: (link: string) => string | null;
|
|
15
|
+
}
|
|
16
|
+
export interface ShareLinkConformanceFixture {
|
|
17
|
+
/** Names the fixture in failures. */
|
|
18
|
+
readonly name: string;
|
|
19
|
+
/** The adapter's `share`, called as a game calls it. */
|
|
20
|
+
readonly share: (intent: ShareIntent) => Promise<ShareResult>;
|
|
21
|
+
/** The answer for a share the host presented. */
|
|
22
|
+
readonly sharedResult: ShareResult;
|
|
23
|
+
/**
|
|
24
|
+
* Omit only for a host that cannot be observed, such as a sandbox bridge
|
|
25
|
+
* that answers by itself; the runner then checks the answers alone.
|
|
26
|
+
*/
|
|
27
|
+
readonly host?: ShareLinkConformanceHost;
|
|
28
|
+
}
|
|
29
|
+
export interface ShareLinkConformanceReport {
|
|
30
|
+
readonly name: string;
|
|
31
|
+
/** Intents answered `unavailable` without reaching the host. */
|
|
32
|
+
readonly refused: number;
|
|
33
|
+
/** Intents shared with the expected link. */
|
|
34
|
+
readonly shared: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Runs the `ShareIntent.deepLink` contract against one adapter's `share`:
|
|
38
|
+
* every escape in `rootRelativeRouteVectors.rejected`, every link outside the
|
|
39
|
+
* contract in `shareLinkVectors.rejected`, and malformed intents answer
|
|
40
|
+
* `unavailable` and never reach the host; every route in
|
|
41
|
+
* `rootRelativeRouteVectors.normalized` and absolute link in
|
|
42
|
+
* `shareLinkVectors.normalized` is shared, and the host is handed the
|
|
43
|
+
* normalized link as the adapter maps it. Every adapter that hands share
|
|
44
|
+
* links to a host runs it, so a new escape added to the vectors is checked
|
|
45
|
+
* on every target. It throws one error listing every failure.
|
|
46
|
+
*/
|
|
47
|
+
export declare function runShareLinkConformance(fixture: ShareLinkConformanceFixture): Promise<ShareLinkConformanceReport>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { rootRelativeRouteVectors, shareLinkVectors } from './route-vectors.js';
|
|
2
|
+
const baseIntent = { kind: 'invite', title: 'Join', text: 'Join me' };
|
|
3
|
+
/**
|
|
4
|
+
* Intents from untyped game code that every adapter refuses whatever their
|
|
5
|
+
* link, because the contract needs an object with a string `text` and
|
|
6
|
+
* `deepLink`.
|
|
7
|
+
*/
|
|
8
|
+
const malformedIntents = [
|
|
9
|
+
['a null intent', null],
|
|
10
|
+
['a string intent', '/daily'],
|
|
11
|
+
['an array intent', []],
|
|
12
|
+
['an intent without text', { kind: 'invite', title: 'Join', deepLink: '/daily' }],
|
|
13
|
+
['an intent with a number for text', { ...baseIntent, text: 1, deepLink: '/daily' }],
|
|
14
|
+
['an intent without a deep link', { ...baseIntent }],
|
|
15
|
+
['an intent with a null deep link', { ...baseIntent, deepLink: null }],
|
|
16
|
+
[
|
|
17
|
+
'an intent with a URL object for its deep link',
|
|
18
|
+
{ ...baseIntent, deepLink: new URL('https://game.example/daily') },
|
|
19
|
+
],
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* Runs the `ShareIntent.deepLink` contract against one adapter's `share`:
|
|
23
|
+
* every escape in `rootRelativeRouteVectors.rejected`, every link outside the
|
|
24
|
+
* contract in `shareLinkVectors.rejected`, and malformed intents answer
|
|
25
|
+
* `unavailable` and never reach the host; every route in
|
|
26
|
+
* `rootRelativeRouteVectors.normalized` and absolute link in
|
|
27
|
+
* `shareLinkVectors.normalized` is shared, and the host is handed the
|
|
28
|
+
* normalized link as the adapter maps it. Every adapter that hands share
|
|
29
|
+
* links to a host runs it, so a new escape added to the vectors is checked
|
|
30
|
+
* on every target. It throws one error listing every failure.
|
|
31
|
+
*/
|
|
32
|
+
export async function runShareLinkConformance(fixture) {
|
|
33
|
+
const failures = [];
|
|
34
|
+
let refused = 0;
|
|
35
|
+
let shared = 0;
|
|
36
|
+
const handedCount = () => fixture.host?.handedLinks().length ?? 0;
|
|
37
|
+
async function attempt(label, intent) {
|
|
38
|
+
try {
|
|
39
|
+
return await fixture.share(intent);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
failures.push(`${label}: share rejected instead of answering (${errorMessage(error)})`);
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function expectRefused(label, intent) {
|
|
47
|
+
const before = handedCount();
|
|
48
|
+
const result = await attempt(label, intent);
|
|
49
|
+
if (result !== undefined && !isSameResult(result, { status: 'unavailable' })) {
|
|
50
|
+
failures.push(`${label}: answered ${JSON.stringify(result)} instead of unavailable`);
|
|
51
|
+
}
|
|
52
|
+
if (handedCount() !== before) {
|
|
53
|
+
failures.push(`${label}: reached the host`);
|
|
54
|
+
}
|
|
55
|
+
refused += 1;
|
|
56
|
+
}
|
|
57
|
+
async function expectShared(label, deepLink, link) {
|
|
58
|
+
const before = handedCount();
|
|
59
|
+
const result = await attempt(label, { ...baseIntent, deepLink });
|
|
60
|
+
if (result !== undefined && !isSameResult(result, fixture.sharedResult)) {
|
|
61
|
+
failures.push(`${label}: answered ${JSON.stringify(result)} instead of ${JSON.stringify(fixture.sharedResult)}`);
|
|
62
|
+
}
|
|
63
|
+
if (fixture.host !== undefined) {
|
|
64
|
+
const handed = fixture.host.handedLinks().slice(before);
|
|
65
|
+
const expected = fixture.host.expectedLink(link);
|
|
66
|
+
if (handed.length !== 1 || handed[0] !== expected) {
|
|
67
|
+
failures.push(`${label}: the host was handed ${JSON.stringify(handed)} instead of ${JSON.stringify([expected])}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
shared += 1;
|
|
71
|
+
}
|
|
72
|
+
for (const [label, intent] of malformedIntents) {
|
|
73
|
+
await expectRefused(label, intent);
|
|
74
|
+
}
|
|
75
|
+
for (const deepLink of [...rootRelativeRouteVectors.rejected, ...shareLinkVectors.rejected]) {
|
|
76
|
+
await expectRefused(JSON.stringify(deepLink), { ...baseIntent, deepLink });
|
|
77
|
+
}
|
|
78
|
+
for (const [deepLink, link] of [
|
|
79
|
+
...rootRelativeRouteVectors.normalized,
|
|
80
|
+
...shareLinkVectors.normalized,
|
|
81
|
+
]) {
|
|
82
|
+
await expectShared(JSON.stringify(deepLink), deepLink, link);
|
|
83
|
+
}
|
|
84
|
+
if (failures.length > 0) {
|
|
85
|
+
throw new Error(`${fixture.name}: share link conformance failed:\n- ${failures.join('\n- ')}`);
|
|
86
|
+
}
|
|
87
|
+
return { name: fixture.name, refused, shared };
|
|
88
|
+
}
|
|
89
|
+
function isSameResult(actual, expected) {
|
|
90
|
+
if (typeof actual !== 'object' || actual === null) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const record = actual;
|
|
94
|
+
return record['status'] === expected.status
|
|
95
|
+
&& record['completion'] === expected.completion
|
|
96
|
+
&& Object.keys(record).every((key) => key === 'status' || key === 'completion');
|
|
97
|
+
}
|
|
98
|
+
function errorMessage(error) {
|
|
99
|
+
return error instanceof Error ? error.message : String(error);
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Shared platform gateway, monetization, ads, leaderboard, identity, lifecycle, and storage contracts for mpgd games.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -60,6 +60,30 @@
|
|
|
60
60
|
"./commerce-wallet": {
|
|
61
61
|
"types": "./dist/commerce-wallet.d.ts",
|
|
62
62
|
"default": "./dist/commerce-wallet.js"
|
|
63
|
+
},
|
|
64
|
+
"./notifications": {
|
|
65
|
+
"types": "./dist/notifications.d.ts",
|
|
66
|
+
"default": "./dist/notifications.js"
|
|
67
|
+
},
|
|
68
|
+
"./notifications-vectors": {
|
|
69
|
+
"types": "./dist/notifications-vectors.d.ts",
|
|
70
|
+
"default": "./dist/notifications-vectors.js"
|
|
71
|
+
},
|
|
72
|
+
"./notifications-conformance": {
|
|
73
|
+
"types": "./dist/notifications-conformance.d.ts",
|
|
74
|
+
"default": "./dist/notifications-conformance.js"
|
|
75
|
+
},
|
|
76
|
+
"./routes": {
|
|
77
|
+
"types": "./dist/routes.d.ts",
|
|
78
|
+
"default": "./dist/routes.js"
|
|
79
|
+
},
|
|
80
|
+
"./route-vectors": {
|
|
81
|
+
"types": "./dist/route-vectors.d.ts",
|
|
82
|
+
"default": "./dist/route-vectors.js"
|
|
83
|
+
},
|
|
84
|
+
"./share-link-conformance": {
|
|
85
|
+
"types": "./dist/share-link-conformance.d.ts",
|
|
86
|
+
"default": "./dist/share-link-conformance.js"
|
|
63
87
|
}
|
|
64
88
|
},
|
|
65
89
|
"files": [
|
|
@@ -84,6 +108,6 @@
|
|
|
84
108
|
"lint": "ttsc --noEmit",
|
|
85
109
|
"format": "ttsc format",
|
|
86
110
|
"fix": "ttsc fix",
|
|
87
|
-
"test": "vitest run && node test/capability-conformance-dist-import.mjs && node test/storage-conformance-dist-import.mjs && node test/ads-dist-import.mjs && node test/commerce-dist-import.mjs && node test/commerce-conformance-dist-import.mjs && node test/wallet-commerce-dist-import.mjs && cd ../.. && node tools/run-ttsx.mjs packages/platform/test/commerce-derived-key-parity.ts"
|
|
111
|
+
"test": "vitest run && node test/capability-conformance-dist-import.mjs && node test/storage-conformance-dist-import.mjs && node test/ads-dist-import.mjs && node test/commerce-dist-import.mjs && node test/commerce-conformance-dist-import.mjs && node test/wallet-commerce-dist-import.mjs && node test/notifications-dist-import.mjs && node test/notifications-conformance-dist-import.mjs && node test/routes-dist-import.mjs && node test/share-link-conformance-dist-import.mjs && cd ../.. && node tools/run-ttsx.mjs packages/platform/test/commerce-derived-key-parity.ts"
|
|
88
112
|
}
|
|
89
113
|
}
|