@indiegems/gem-web-sdk 5.9.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/bin/gem-sdk-stamp.mjs +95 -0
- package/dist/game.d.ts +2601 -0
- package/dist/gem-web-sdk.bridge.js +1 -0
- package/dist/gem-web-sdk.capture.js +1 -0
- package/dist/gem-web-sdk.esm.js +3 -0
- package/dist/gem-web-sdk.game.js +3 -0
- package/dist/gem-web-sdk.min.js +2 -0
- package/dist/gem-web-sdk.sdk.js +3 -0
- package/dist/gem-web-sdk.server-api.js +2 -0
- package/dist/gem-web-sdk.server.js +3 -0
- package/dist/index.d.ts +1981 -0
- package/dist/sdk.d.ts +3841 -0
- package/dist/server-api.d.ts +1078 -0
- package/dist/server.d.ts +1438 -0
- package/package.json +43 -0
- package/schema/bridge.v1.json +2624 -0
|
@@ -0,0 +1,2624 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": [
|
|
3
|
+
"The gem-web <-> game postMessage bridge, version 1.",
|
|
4
|
+
"",
|
|
5
|
+
"This file is the SOURCE OF TRUTH. The SDK's generated protocol types are",
|
|
6
|
+
"produced from it and checked in, and a build check fails if the two have",
|
|
7
|
+
"drifted. Nothing hand-edits the generated files.",
|
|
8
|
+
"",
|
|
9
|
+
"The format is deliberately narrow rather than full JSON Schema: it describes",
|
|
10
|
+
"exactly the shapes this bridge sends, so the generator can emit exact",
|
|
11
|
+
"hand-rolled validators with no runtime dependency. The frame-side bundle ships",
|
|
12
|
+
"into every game page on a path we deliberately do not cache, so a validation",
|
|
13
|
+
"library there is weight we chose not to spend.",
|
|
14
|
+
"",
|
|
15
|
+
"Every field carries a bound. An unbounded string lets a frame post 100 MB and",
|
|
16
|
+
"OOM the parent tab, which is the whole reason validation exists here at all."
|
|
17
|
+
],
|
|
18
|
+
|
|
19
|
+
"frozen": false,
|
|
20
|
+
"$frozenComment": [
|
|
21
|
+
"Set to true by the export-template RELEASE JOB, not by a human",
|
|
22
|
+
"remembering. From the moment that template ships, a frame IIFE is baked into",
|
|
23
|
+
"every exported game package and is not updatable by the platform — so a",
|
|
24
|
+
"change to how an existing message is interpreted breaks those games forever.",
|
|
25
|
+
"",
|
|
26
|
+
"It is a TRIPWIRE forcing a deliberate `v` bump, not a wall. While true,",
|
|
27
|
+
"the freeze check permits a new capability-gated message type",
|
|
28
|
+
"and a new OPTIONAL field on an existing one, and refuses everything else.",
|
|
29
|
+
"An earlier draft of that gate refused ANY diff, which would have made every",
|
|
30
|
+
"additive change this schema permits permanently unshippable, and is stricter",
|
|
31
|
+
"than this schema's own rule about what constitutes a breaking change.",
|
|
32
|
+
"",
|
|
33
|
+
"False today: no exported game package references this SDK, so there is no",
|
|
34
|
+
"frame in the field and amending v1 in place is still correct."
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
"protocol": "gem-web-bridge",
|
|
38
|
+
"version": 1,
|
|
39
|
+
|
|
40
|
+
"versionNegotiation": {
|
|
41
|
+
"$comment": [
|
|
42
|
+
"A version stamp with no rule is decoration. The rule:",
|
|
43
|
+
"",
|
|
44
|
+
"1. The parent sends {min, max} in the handshake.",
|
|
45
|
+
"2. The frame selects the highest version it supports within that range and",
|
|
46
|
+
" echoes it in `gem.handshake.accept`. All later messages carry that `v`.",
|
|
47
|
+
"3. No overlap -> the frame replies `gem.error` with code `version_mismatch`",
|
|
48
|
+
" and the parent enters the `unsupported` state. It does not retry.",
|
|
49
|
+
"4. New fields are ADDITIVE and IGNORABLE. A receiver must not fail on an",
|
|
50
|
+
" unknown field, so adding one is not a breaking change.",
|
|
51
|
+
"5. `v` bumps ONLY on a breaking change: a removed field, a narrowed type, or",
|
|
52
|
+
" a changed meaning.",
|
|
53
|
+
"6. The parent supports N-1 for at least one full release window after N",
|
|
54
|
+
" ships, so a game built against the previous version keeps working."
|
|
55
|
+
],
|
|
56
|
+
"supportedByThisBuild": { "min": 1, "max": 1 },
|
|
57
|
+
"parentSupportsPreviousVersions": 1
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
"limits": {
|
|
61
|
+
"$comment": [
|
|
62
|
+
"Applied by the generated validators. `maxMessageBytes` is checked before",
|
|
63
|
+
"parsing anything else -- it is the backstop for a message whose shape is",
|
|
64
|
+
"fine but whose size is not.",
|
|
65
|
+
"",
|
|
66
|
+
"Rate limits are per message type, enforced on BOTH ends: the frame is",
|
|
67
|
+
"untrusted, and a parent bug should not be able to flood a game either.",
|
|
68
|
+
"`gem.requestToken` is the one that matters most -- each call downstream is an",
|
|
69
|
+
"RS256 signature plus a database round trip, so an unbounded loop is free",
|
|
70
|
+
"CPU-amplification DoS against the API.",
|
|
71
|
+
"",
|
|
72
|
+
"`maxMessageDepth`, `maxMessageNodes` and `maxArrayLength` bound the COST of",
|
|
73
|
+
"validating, which is a different problem from bounding the SIZE of what is",
|
|
74
|
+
"valid. Structured clone preserves shared object references, so a sender can",
|
|
75
|
+
"ship a tiny directed acyclic graph -- 7 objects, 100 properties each, all",
|
|
76
|
+
"pointing at the same child -- that a naive recursive walk expands to 10^14",
|
|
77
|
+
"nodes. Every path through it is shallow, so a depth cap alone does not see",
|
|
78
|
+
"it, and every leaf is legitimately plain data, so no shape check refuses it.",
|
|
79
|
+
"The receiving tab simply stops.",
|
|
80
|
+
"",
|
|
81
|
+
"`maxMessageNodes` is the real defence: it bounds total work regardless of",
|
|
82
|
+
"shape. `maxArrayLength` refuses a huge sparse array before iterating it,",
|
|
83
|
+
"since serializing one costs bytes and walking one costs billions of steps.",
|
|
84
|
+
"These live here rather than in the generator because they are protocol",
|
|
85
|
+
"limits, and because the constant that mattered most was previously the one",
|
|
86
|
+
"constant not written down as part of the contract.",
|
|
87
|
+
"",
|
|
88
|
+
"`handshakeTimeoutMs` IS A REPORTING WINDOW, NOT A DEADLINE. The parent",
|
|
89
|
+
"keeps offering for as long as it holds the frame (see `gem.handshake`'s",
|
|
90
|
+
"rate limit); this is only how long it waits before telling its own",
|
|
91
|
+
"operator that the first window produced nothing. It ends no session and",
|
|
92
|
+
"closes no port. It was a deadline until 2026-09-02, and a game whose boot",
|
|
93
|
+
"is a code-split module graph -- listener attached after `load`, which is",
|
|
94
|
+
"after the offer -- could not connect to a one-shot embedder at all."
|
|
95
|
+
],
|
|
96
|
+
"maxMessageBytes": 65536,
|
|
97
|
+
"maxMessageDepth": 8,
|
|
98
|
+
"maxMessageNodes": 5000,
|
|
99
|
+
"maxArrayLength": 1024,
|
|
100
|
+
"handshakeTimeoutMs": 10000
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
"malformedInboundLimit": {
|
|
104
|
+
"$comment": [
|
|
105
|
+
"Untyped and unknown-type inbound messages get their OWN bucket,",
|
|
106
|
+
"consumed only on the REFUSAL path — never on a well-formed message.",
|
|
107
|
+
"",
|
|
108
|
+
"Previously both halves consumed a `gem.error` token on every inbound",
|
|
109
|
+
"message before the per-type check. Three consequences, all bad: the",
|
|
110
|
+
"declared 600/min for gem.progress was unreachable because everything",
|
|
111
|
+
"shared one 60/min ceiling; every valid message was metered twice; and when",
|
|
112
|
+
"the bucket emptied, messages vanished on a bare `return` with no callback",
|
|
113
|
+
"and no telemetry. A game reporting load progress at 10/s drained it in six",
|
|
114
|
+
"seconds, after which gem.ready got through roughly 1-in-10 per second — so",
|
|
115
|
+
"the loading screen appeared to hang, non-deterministically, and nothing",
|
|
116
|
+
"said why. That failure mode is the reason this is its own key: token",
|
|
117
|
+
"refresh must never fail silently.",
|
|
118
|
+
"",
|
|
119
|
+
"The original fix was right in intent and wrong in mechanism. Metering",
|
|
120
|
+
"untyped spam is necessary — `{}` is the cheapest message to send and used",
|
|
121
|
+
"to be unlimited — but it must not be charged to a declared type's quota."
|
|
122
|
+
],
|
|
123
|
+
"key": "__malformed",
|
|
124
|
+
"perMinute": 60
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
"errorCodes": {
|
|
128
|
+
"version_mismatch": "No overlap between the parent's supported range and the frame's.",
|
|
129
|
+
"handshake_timeout": "The handshake had not completed within `handshakeTimeoutMs`. A REPORT, not an outcome: the parent goes on offering and the frame may still connect afterwards. Raised once per session.",
|
|
130
|
+
"handshake_duplicate": "A second handshake was attempted on a document that already has a port.",
|
|
131
|
+
"invalid_message": "A message failed shape validation.",
|
|
132
|
+
"rate_limited": "The sender exceeded the per-type rate limit.",
|
|
133
|
+
"token_unavailable": "The parent could not obtain a play token.",
|
|
134
|
+
"unsupported": "The receiver does not implement this message type.",
|
|
135
|
+
"unsupported_capability": "A known message type whose capability the receiver did not advertise. Distinct from `unsupported` on purpose: reusing that code makes the two indistinguishable to the sender, and this is the one place the protocol needs the discrimination. Reserved before the freeze because `gem.error.code` is a closed enum and cannot gain a value afterwards.",
|
|
136
|
+
"internal": "Unexpected failure on the sending side."
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
"messages": [
|
|
140
|
+
{
|
|
141
|
+
"type": "gem.handshake",
|
|
142
|
+
"direction": "parentToFrame",
|
|
143
|
+
"transport": "window",
|
|
144
|
+
"$comment": [
|
|
145
|
+
"The ONLY message sent over `window.postMessage`. It transfers a MessagePort;",
|
|
146
|
+
"everything after it travels over that port.",
|
|
147
|
+
"",
|
|
148
|
+
"This is what removes the origin-confusion class rather than checking for it:",
|
|
149
|
+
"a port cannot be forged or targeted by a third party, only held. The parent",
|
|
150
|
+
"SHOULD validate `event.origin` and `event.source` here -- today that check lives in",
|
|
151
|
+
"`GemHost.checkHandshakeOrigin`, which nothing in the SDK calls, so the embedder",
|
|
152
|
+
"must do it. The frame",
|
|
153
|
+
"validates the embedder's origin -- a game embedded by a hostile page must",
|
|
154
|
+
"not accept its tokens.",
|
|
155
|
+
"",
|
|
156
|
+
"THE RATE LIMIT SUSTAINS ONE OFFER EVERY TWO SECONDS, INDEFINITELY. The",
|
|
157
|
+
"parent cannot know when a frame's listener attaches -- a window message",
|
|
158
|
+
"dispatched into a document with no listener is discarded, not queued -- so",
|
|
159
|
+
"it offers on a steady cadence until the frame answers, bounded by the",
|
|
160
|
+
"page's own lifetime rather than by a deadline.",
|
|
161
|
+
"",
|
|
162
|
+
"DELIBERATELY ASYMMETRIC with gem.handshake.accept, which stays at 5. The",
|
|
163
|
+
"per-type limits exist because the frame is untrusted and because a parent",
|
|
164
|
+
"bug must not be able to flood a game. For THIS message the flood argument",
|
|
165
|
+
"is thin: a page that embeds a frame can already navigate, resize or",
|
|
166
|
+
"destroy it, so a faster handshake cadence adds no capability it lacks --",
|
|
167
|
+
"and a frame adopts the first offer it hears and refuses replacements, so",
|
|
168
|
+
"what a higher budget exposes is its own refusal path, which is metered",
|
|
169
|
+
"separately by malformedInboundLimit."
|
|
170
|
+
],
|
|
171
|
+
"fields": {
|
|
172
|
+
"versions": {
|
|
173
|
+
"kind": "object",
|
|
174
|
+
"fields": {
|
|
175
|
+
"min": { "kind": "int", "min": 1, "max": 1000 },
|
|
176
|
+
"max": { "kind": "int", "min": 1, "max": 1000 }
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
"capabilities": {
|
|
180
|
+
"kind": "array",
|
|
181
|
+
"maxItems": 64,
|
|
182
|
+
"items": { "kind": "string", "maxLength": 64 },
|
|
183
|
+
"optional": true,
|
|
184
|
+
"$comment": [
|
|
185
|
+
"What this peer implements, as distinct from which grammar it speaks.",
|
|
186
|
+
"",
|
|
187
|
+
"An OPEN vocabulary: entries are bounded opaque strings and a receiver",
|
|
188
|
+
"MUST ignore unknown ones. Deliberately not `kind: enum` — codegen emits",
|
|
189
|
+
"a membership check for a closed vocabulary, so an entry added after the",
|
|
190
|
+
"freeze would fail an older peer's handshake validation and the game",
|
|
191
|
+
"would not connect at all. Names are version-independent and never",
|
|
192
|
+
"redefined. The registry is `schema/capabilities.json`.",
|
|
193
|
+
"",
|
|
194
|
+
"Sent before the frame picks a version, so a parent advertises without",
|
|
195
|
+
"knowing which grammar wins. That is fine: capability and version are",
|
|
196
|
+
"orthogonal axes.",
|
|
197
|
+
"",
|
|
198
|
+
"NOT an authorization control. A frame-side refusal to send an",
|
|
199
|
+
"unadvertised message is a limit the sender enforces against itself;",
|
|
200
|
+
"the receiver-side refusal is the only enforcement."
|
|
201
|
+
]
|
|
202
|
+
},
|
|
203
|
+
"embedder": {
|
|
204
|
+
"kind": "string",
|
|
205
|
+
"maxLength": 2048,
|
|
206
|
+
"format": "origin",
|
|
207
|
+
"$comment": "The parent's own origin, so the frame can check who is embedding it."
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
"rateLimit": { "perMinute": 30 }
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
{
|
|
214
|
+
"type": "gem.handshake.accept",
|
|
215
|
+
"direction": "frameToParent",
|
|
216
|
+
"transport": "port",
|
|
217
|
+
"$comment": "The frame's selected protocol version. Completes the handshake.",
|
|
218
|
+
"fields": {
|
|
219
|
+
"v": { "kind": "int", "min": 1, "max": 1000 },
|
|
220
|
+
"capabilities": {
|
|
221
|
+
"kind": "array",
|
|
222
|
+
"maxItems": 64,
|
|
223
|
+
"items": { "kind": "string", "maxLength": 64 },
|
|
224
|
+
"optional": true,
|
|
225
|
+
"$comment": "What the FRAME understands. Same open-vocabulary rules as gem.handshake."
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
"rateLimit": { "perMinute": 5 }
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
{
|
|
232
|
+
"type": "gem.token",
|
|
233
|
+
"direction": "parentToFrame",
|
|
234
|
+
"transport": "port",
|
|
235
|
+
"$comment": [
|
|
236
|
+
"A game-scoped play token. Short-lived by construction (5 minutes).",
|
|
237
|
+
"",
|
|
238
|
+
"Treat everything here as public: CSP does not contain exfiltration. WebRTC",
|
|
239
|
+
"bypasses `connect-src` -- and games legitimately need it, since the platform",
|
|
240
|
+
"ships WebRtcP2p -- and a game's own <meta> CSP can add a `report-uri` that",
|
|
241
|
+
"`connect-src` does not govern. The token is origin-bound and the renewal",
|
|
242
|
+
"chain is capped for exactly this reason.",
|
|
243
|
+
"",
|
|
244
|
+
"A REMOVED FIELD, `garden`, AND WHY IT IS STILL WORTH READING ABOUT HERE.",
|
|
245
|
+
"It named a backend environment by tag — the frame resolved the tag through",
|
|
246
|
+
"a table it carried, and that table is gone: `apiBaseUrl` below is how a",
|
|
247
|
+
"backend is named now. It is UNDECLARED rather than deprecated-in-place",
|
|
248
|
+
"because a declared field costs the frame bundle its validator on a path",
|
|
249
|
+
"deliberately not cached, and this one exists only to be refused. Rule 4 of",
|
|
250
|
+
"the version negotiation above already covers what happens to it: a",
|
|
251
|
+
"receiver must not fail on an unknown field, so a sender that still emits",
|
|
252
|
+
"it is not sending an invalid message.",
|
|
253
|
+
"",
|
|
254
|
+
"THE FRAME STILL LOOKS FOR IT, and a grant carrying it with no `apiBaseUrl`",
|
|
255
|
+
"is REFUSED. That is the one case where silence would be wrong: such a",
|
|
256
|
+
"sender believes it is choosing a backend and is not being heard, and",
|
|
257
|
+
"falling through to the default would send a development session's token to",
|
|
258
|
+
"the live one. See the `acceptApiBase` guard. Senders should drop the",
|
|
259
|
+
"field; nothing reads its value."
|
|
260
|
+
],
|
|
261
|
+
"fields": {
|
|
262
|
+
"token": { "kind": "string", "maxLength": 4096 },
|
|
263
|
+
"requestId": {
|
|
264
|
+
"kind": "string",
|
|
265
|
+
"maxLength": 64,
|
|
266
|
+
"optional": true,
|
|
267
|
+
"$comment": "Echoes the gem.requestToken that asked. ABSENT means an unsolicited push, which updates the cache but must not settle an outstanding ask — `generation` decides which token wins, so a push cannot be silently overwritten by a stale reply."
|
|
268
|
+
},
|
|
269
|
+
"generation": {
|
|
270
|
+
"kind": "int",
|
|
271
|
+
"min": 0,
|
|
272
|
+
"max": 2147483647,
|
|
273
|
+
"$comment": [
|
|
274
|
+
"Monotonic per connection. Single-flight alone is subtly wrong: a",
|
|
275
|
+
"proactive refresh in flight may be answered by the SAME token a",
|
|
276
|
+
"concurrent 401 just rejected, so a caller that joins it replays an",
|
|
277
|
+
"identical token, 401s again, and burns its one replay on a refresh",
|
|
278
|
+
"that was never meant to answer it. A 401 replay asks for a generation",
|
|
279
|
+
"greater than the one it holds. A client that skips outright while a",
|
|
280
|
+
"refresh is in flight has this exact bug shape."
|
|
281
|
+
]
|
|
282
|
+
},
|
|
283
|
+
"apiBaseUrl": {
|
|
284
|
+
"kind": "string",
|
|
285
|
+
"format": "url",
|
|
286
|
+
"maxLength": 512,
|
|
287
|
+
"optional": true,
|
|
288
|
+
"$comment": [
|
|
289
|
+
"The API root this session's requests go to. The embedder already",
|
|
290
|
+
"holds this value — it is what its own build is configured with — so",
|
|
291
|
+
"it says it, and the frame stops having to resolve anything.",
|
|
292
|
+
"",
|
|
293
|
+
"ABSENT means the frame's own default, which is the production API. So",
|
|
294
|
+
"the field need not appear in production traffic at all, and a sender",
|
|
295
|
+
"that omits it lands on the most-restricted target.",
|
|
296
|
+
"",
|
|
297
|
+
"`format: url` is `isSafeHttpUrl`: no userinfo, and plaintext http only",
|
|
298
|
+
"on loopback. The frame checks the same rule again on arrival and",
|
|
299
|
+
"refuses the whole token if it fails.",
|
|
300
|
+
"",
|
|
301
|
+
"THIS RESTORES A FIELD THAT WAS ONCE REMOVED, so the argument against",
|
|
302
|
+
"it belongs here. It was removed as a token-exfiltration primitive,",
|
|
303
|
+
"because the URL validator of the day accepted `http://` anywhere and",
|
|
304
|
+
"accepted `https://api.gemarcade.com@evil.tld/`, which the WHATWG",
|
|
305
|
+
"parser resolves to host `evil.tld`. Neither is true of the validator",
|
|
306
|
+
"now: userinfo is refused outright and plaintext is loopback-only.",
|
|
307
|
+
"The deeper reason is that the tag bought nothing it claimed to — the",
|
|
308
|
+
"parent MINTS the play token and hands it over on this same message,",
|
|
309
|
+
"so a page compromised enough to choose the frame's API root can post",
|
|
310
|
+
"that token to a collector itself, without the frame's cooperation.",
|
|
311
|
+
"",
|
|
312
|
+
"A CHANGE OF VALUE MID-SESSION IS REFUSED by the frame. A correct",
|
|
313
|
+
"sender repeats its own configured root on every token; one that moves",
|
|
314
|
+
"is either confused or is no longer the page that booted the game.",
|
|
315
|
+
"",
|
|
316
|
+
"This field replaces `garden`, which is described in the message",
|
|
317
|
+
"comment above and is no longer declared anywhere."
|
|
318
|
+
]
|
|
319
|
+
},
|
|
320
|
+
"playerId": { "kind": "string", "maxLength": 64 },
|
|
321
|
+
"gameId": { "kind": "string", "maxLength": 64 },
|
|
322
|
+
"channel": { "kind": "enum", "values": ["dev", "test", "release"] },
|
|
323
|
+
"gameVersion": {
|
|
324
|
+
"kind": "string",
|
|
325
|
+
"maxLength": 64,
|
|
326
|
+
"optional": true,
|
|
327
|
+
"$comment": [
|
|
328
|
+
"The version of the BUILD the parent routed this player to.",
|
|
329
|
+
"",
|
|
330
|
+
"gem-api refuses every room and match call whose reported version",
|
|
331
|
+
"names no build assigned to the channel being played. The number was",
|
|
332
|
+
"the game's to declare until this field existed, kept in step by hand",
|
|
333
|
+
"with the number the build was uploaded under -- and a live game",
|
|
334
|
+
"shipped with the two disagreeing, declaring 2.0.1 inside a build",
|
|
335
|
+
"published as 3.3.0. Every room call was refused, on a best-effort",
|
|
336
|
+
"path that only warns, and for three days players earned nothing. Both",
|
|
337
|
+
"halves of the upload agreed with each other, so every tool that",
|
|
338
|
+
"checks an upload looked correct.",
|
|
339
|
+
"",
|
|
340
|
+
"The parent already knows this: it chose the build. So it says it, and",
|
|
341
|
+
"a game stops having to answer a question it cannot verify.",
|
|
342
|
+
"",
|
|
343
|
+
"ABSENT MEANS THE FRAME FALLS BACK to whatever the game declared,",
|
|
344
|
+
"which is what every embedder published before this field does. The",
|
|
345
|
+
"fallback is deprecated rather than removed for exactly that reason.",
|
|
346
|
+
"",
|
|
347
|
+
"PARENT-ASSERTED AND UNVERIFIABLE HERE, like `playerId`, `gameId` and",
|
|
348
|
+
"`channel` beside it -- and it is checked where they are: gem-api",
|
|
349
|
+
"looks the value up against the builds on the channel the token",
|
|
350
|
+
"scopes, so a frame that reported a version nobody published is",
|
|
351
|
+
"refused rather than believed."
|
|
352
|
+
]
|
|
353
|
+
},
|
|
354
|
+
"expiresAt": {
|
|
355
|
+
"kind": "int",
|
|
356
|
+
"min": 0,
|
|
357
|
+
"max": 4102444800,
|
|
358
|
+
"$comment": "Unix seconds. Upper bound is 2100-01-01, a sanity bound, not a policy."
|
|
359
|
+
},
|
|
360
|
+
"chainEndsAt": {
|
|
361
|
+
"kind": "int",
|
|
362
|
+
"min": 0,
|
|
363
|
+
"max": 4102444800,
|
|
364
|
+
"$comment": [
|
|
365
|
+
"The token-chain ceiling: the wall-clock time past which no further",
|
|
366
|
+
"renewal will be granted, however many times the frame asks.",
|
|
367
|
+
"",
|
|
368
|
+
"Distinct from the grant TTL. Without it, silent renewal turns a",
|
|
369
|
+
"5-minute credential into a permanent capability for as long as a tab",
|
|
370
|
+
"stays open. Carried in the message (and in the token) rather than in a",
|
|
371
|
+
"server-side session record -- the design admits no new tables."
|
|
372
|
+
]
|
|
373
|
+
},
|
|
374
|
+
"allowDevTools": {
|
|
375
|
+
"kind": "boolean",
|
|
376
|
+
"optional": true,
|
|
377
|
+
"$comment": [
|
|
378
|
+
"Whether this session may open the SDK devtools overlay.",
|
|
379
|
+
"",
|
|
380
|
+
"ABSENT MEANS FALSE, the same shape as `garden` above: the field does",
|
|
381
|
+
"not appear in an ordinary player's traffic at all, and stripping it in",
|
|
382
|
+
"transit cannot escalate anyone -- it lands on the closed answer.",
|
|
383
|
+
"",
|
|
384
|
+
"A PRODUCT GATE, NOT A CONFIDENTIALITY BOUNDARY, and the distinction is",
|
|
385
|
+
"the reason this field is allowed to exist. The overlay reaches nothing",
|
|
386
|
+
"the game's own JS context could not already reach -- it renders in the",
|
|
387
|
+
"game's realm, over the game's own `Gem` handle, and reports nothing",
|
|
388
|
+
"back on the wire. So this decides whether a debug UI appears, not what",
|
|
389
|
+
"an operator can do; gem-api remains the gate on every request the",
|
|
390
|
+
"panel makes.",
|
|
391
|
+
"",
|
|
392
|
+
"PARENT-ASSERTED AND UNVERIFIABLE HERE, like every other claim on this",
|
|
393
|
+
"message. `playerId`, `gameId` and `channel` are supplied explicitly",
|
|
394
|
+
"rather than read out of the JWT precisely because the frame cannot",
|
|
395
|
+
"verify claims, so this field sits at exactly the trust level the whole",
|
|
396
|
+
"SDK is already scoped by. What bounds its reach is `allowedEmbedders`:",
|
|
397
|
+
"only an origin the game itself named can be the parent that asserts",
|
|
398
|
+
"it.",
|
|
399
|
+
"",
|
|
400
|
+
"THE PARENT MUST RESOLVE IT THE SAME WAY ON EVERY PATH, including the",
|
|
401
|
+
"ones it can answer without a network call. A parent that short-circuits",
|
|
402
|
+
"one class of grantee and awaits a lookup for another lands this field",
|
|
403
|
+
"on different `generation` values for the two -- and `generation` is on",
|
|
404
|
+
"this very message, so the timing is readable by the game even when the",
|
|
405
|
+
"value is not."
|
|
406
|
+
]
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
"rateLimit": { "perMinute": 30 }
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
{
|
|
413
|
+
"type": "gem.token.denied",
|
|
414
|
+
"direction": "parentToFrame",
|
|
415
|
+
"transport": "port",
|
|
416
|
+
"$comment": [
|
|
417
|
+
"The parent refuses to mint. TWO BITS ON THE WIRE, deliberately.",
|
|
418
|
+
"",
|
|
419
|
+
"An earlier design carried a six-value enum — signed_out, session_expired,",
|
|
420
|
+
"revoked, banned, chain_exhausted, rate_limited. That is a moderation-state",
|
|
421
|
+
"ORACLE: it tells untrusted game code, at 12/min for free, whether this",
|
|
422
|
+
"player is banned, whether an operator revoked their session, and whether",
|
|
423
|
+
"they signed out deliberately or timed out. A game would use it — 'you got",
|
|
424
|
+
"banned' screens, ban-status resale, or simply polling and correlating with",
|
|
425
|
+
"playerId to build a sanction list for the whole catalog. None of it is a",
|
|
426
|
+
"fact the game is party to.",
|
|
427
|
+
"",
|
|
428
|
+
"And the SDK does not act on six values. It acts on: terminal, or retry",
|
|
429
|
+
"after N ms. The real taxonomy stays in the parent's server-side telemetry,",
|
|
430
|
+
"and the player-facing explanation is rendered by the parent's scrim —",
|
|
431
|
+
"which is the correct side of the trust boundary to explain an",
|
|
432
|
+
"account-state fact.",
|
|
433
|
+
"",
|
|
434
|
+
"`retryable` decides behaviour, NOT `reason`. `unavailable` appears in both",
|
|
435
|
+
"a terminal and a retryable case, so an exhaustive switch on `reason`",
|
|
436
|
+
"decides nothing. `reason` must never gain a `rate_limited` member: an",
|
|
437
|
+
"earlier revision put one on the public error type while the wire had two",
|
|
438
|
+
"values, and that is the shape that invites widening the schema back."
|
|
439
|
+
],
|
|
440
|
+
"fields": {
|
|
441
|
+
"requestId": { "kind": "string", "maxLength": 64 },
|
|
442
|
+
"retryable": { "kind": "boolean" },
|
|
443
|
+
"retryAfterMs": { "kind": "int", "min": 0, "max": 3600000, "optional": true },
|
|
444
|
+
"reason": {
|
|
445
|
+
"kind": "enum",
|
|
446
|
+
"values": ["chain_exhausted", "unavailable"],
|
|
447
|
+
"$comment": "`chain_exhausted` is a PROTOCOL fact (the token-chain ceiling), not an account fact. `unavailable` covers sign-out, expiry, revocation, ban and internal error alike."
|
|
448
|
+
},
|
|
449
|
+
"correlationId": {
|
|
450
|
+
"kind": "string",
|
|
451
|
+
"maxLength": 64,
|
|
452
|
+
"$comment": [
|
|
453
|
+
"ALWAYS present, including on a locally-decided denial. If the parent",
|
|
454
|
+
"omitted it when declining without asking the server, its presence",
|
|
455
|
+
"would itself be the timing oracle the uniform-delay rule exists to",
|
|
456
|
+
"close.",
|
|
457
|
+
"",
|
|
458
|
+
"Random per denial, no account-state entropy, not correlatable across",
|
|
459
|
+
"players, logged parent-side beside the full taxonomy. Without it a",
|
|
460
|
+
"player says 'it froze' and a third-party developer cannot separate 'I",
|
|
461
|
+
"broke my token handling' from 'this player was sanctioned'."
|
|
462
|
+
]
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
"rateLimit": { "perMinute": 30 }
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
{
|
|
469
|
+
"type": "gem.player",
|
|
470
|
+
"direction": "parentToFrame",
|
|
471
|
+
"transport": "port",
|
|
472
|
+
"$comment": [
|
|
473
|
+
"Who the player is, for display. Sent after the handshake.",
|
|
474
|
+
"",
|
|
475
|
+
"Deliberately NOT fetched from /v1/players/me: the account surface is",
|
|
476
|
+
"unreachable with a game-scoped token by construction, and adding it to a",
|
|
477
|
+
"sandbox CORS allowlist is what the token rules forbid. This is also why",
|
|
478
|
+
"it is a message rather than an API call.",
|
|
479
|
+
"",
|
|
480
|
+
"`untrusted` holds PLAYER-AUTHORED text. Length bounds are not content",
|
|
481
|
+
"bounds: a game that innerHTMLs another player's name hands its token to",
|
|
482
|
+
"whoever chose that name, and the SDK's skills teach agents to build",
|
|
483
|
+
"against this field, so it is a prompt-injection vector too. The parent",
|
|
484
|
+
"scrubs before sending — NFC-normalize, bound combining marks per base",
|
|
485
|
+
"character, strip Cf/Cn/default-ignorables, fold line breaks.",
|
|
486
|
+
"",
|
|
487
|
+
"`Mn` (Nonspacing_Mark) must NOT be stripped: it is every combining",
|
|
488
|
+
"diacritic in Latin, Vietnamese, Arabic, Hebrew, Devanagari and Thai, so",
|
|
489
|
+
"stripping it turns decomposed José into Jose and makes Thai unreadable —",
|
|
490
|
+
"and inconsistently, since NFC é survives while NFD e+U+0301 does not.",
|
|
491
|
+
"",
|
|
492
|
+
"`displayTag` and `avatarUrl` sit OUTSIDE `untrusted` because they are",
|
|
493
|
+
"platform-assigned, not player-authored. `displayHandle` is inside because",
|
|
494
|
+
"it embeds displayName — otherwise the precomposed 'safe' field is a",
|
|
495
|
+
"laundering path for the unsafe one."
|
|
496
|
+
],
|
|
497
|
+
"fields": {
|
|
498
|
+
"untrusted": {
|
|
499
|
+
"kind": "object",
|
|
500
|
+
"fields": {
|
|
501
|
+
"displayName": { "kind": "string", "maxLength": 64 },
|
|
502
|
+
"displayHandle": {
|
|
503
|
+
"kind": "string",
|
|
504
|
+
"maxLength": 80,
|
|
505
|
+
"$comment": "Precomposed `DisplayName#1234`, so no game has to know the convention. gem-web's AGENTS.md states the platform rule flatly: never display a username; always display_name + display_tag."
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
"displayTag": { "kind": "string", "maxLength": 8 },
|
|
510
|
+
"avatarUrl": {
|
|
511
|
+
"kind": "string",
|
|
512
|
+
"maxLength": 2048,
|
|
513
|
+
"format": "url",
|
|
514
|
+
"optional": true,
|
|
515
|
+
"$comment": "Structurally validated only — https (or loopback), no userinfo, parsed rather than regex-matched. It is NOT origin-pinned: the validator is a pure function with no configuration, so it cannot know the CDN origin. An earlier version of this comment claimed a pin that the generated code did not emit — a documented control that did not exist. THE CONSUMER MUST PIN THE ORIGIN before putting this in an <img src>, or a hostile parent supplies a request-time beacon carrying the Referer."
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
"rateLimit": { "perMinute": 12 }
|
|
519
|
+
},
|
|
520
|
+
|
|
521
|
+
{
|
|
522
|
+
"type": "gem.pause",
|
|
523
|
+
"direction": "parentToFrame",
|
|
524
|
+
"transport": "port",
|
|
525
|
+
"$comment": "The parent is backgrounded or a modal is open. The game should pause gameplay and release the cursor (document.exitPointerLock) -- pointer lock belongs to the frame's own document, so the parent cannot release it. Cooperative; not enforced.",
|
|
526
|
+
"fields": {
|
|
527
|
+
"paused": { "kind": "boolean" }
|
|
528
|
+
},
|
|
529
|
+
"rateLimit": { "perMinute": 120 }
|
|
530
|
+
},
|
|
531
|
+
|
|
532
|
+
{
|
|
533
|
+
"type": "gem.prefs",
|
|
534
|
+
"direction": "parentToFrame",
|
|
535
|
+
"transport": "port",
|
|
536
|
+
"$comment": [
|
|
537
|
+
"The only mechanism by which a cooperating game can honour the player's OS",
|
|
538
|
+
"and account settings. Sent once after the handshake and again on change."
|
|
539
|
+
],
|
|
540
|
+
"fields": {
|
|
541
|
+
"reducedMotion": { "kind": "boolean" },
|
|
542
|
+
"muted": { "kind": "boolean" },
|
|
543
|
+
"showFrameStats": {
|
|
544
|
+
"kind": "boolean",
|
|
545
|
+
"optional": true,
|
|
546
|
+
"$comment": [
|
|
547
|
+
"The player switched the Arcade's FPS overlay on (or off) — the parent's",
|
|
548
|
+
"own control, like `muted`. While true, and while not paused, the SDK",
|
|
549
|
+
"counts the frame document's own requestAnimationFrame ticks and sends",
|
|
550
|
+
"`gem.frameStats` about once a second; nothing runs and nothing is sent",
|
|
551
|
+
"otherwise, so the feature costs a game nothing until a player asks for",
|
|
552
|
+
"it.",
|
|
553
|
+
"",
|
|
554
|
+
"OPTIONAL because it is additive: a parent that predates it sends the",
|
|
555
|
+
"three fields above and the frame reads the absence as false. Carried on",
|
|
556
|
+
"this message rather than on a request/response pair because `gem.prefs`",
|
|
557
|
+
"already is the parent's push of its arcade-controls state, and the",
|
|
558
|
+
"frame has nothing to ask.",
|
|
559
|
+
"",
|
|
560
|
+
"A DISPLAY PREFERENCE OF THE DEVICE, not of the player: the parent keeps",
|
|
561
|
+
"it per device, like the theme, and includes it in the first push after",
|
|
562
|
+
"connecting so a reloaded game resumes reporting without another press.",
|
|
563
|
+
"A parent that did not advertise `frameStats` must not send it true —",
|
|
564
|
+
"the frame would be counting for nobody."
|
|
565
|
+
]
|
|
566
|
+
},
|
|
567
|
+
"locale": {
|
|
568
|
+
"kind": "string",
|
|
569
|
+
"maxLength": 35,
|
|
570
|
+
"format": "bcp47",
|
|
571
|
+
"$comment": [
|
|
572
|
+
"RFC 5646 tag. 35 is the practical maximum length for a well-formed tag.",
|
|
573
|
+
"",
|
|
574
|
+
"`format: bcp47` IS A STRUCTURAL BOUND, NOT A PROMISE THE TAG IS USABLE.",
|
|
575
|
+
"The generated check is `/^[a-z]{2,8}(-[a-z0-9]{1,8})*$/i`, and `en-a`,",
|
|
576
|
+
"`aa-1`, `en-x` and `root` all pass it and all throw `RangeError` from",
|
|
577
|
+
"`Intl`. A RECEIVER MUST NOT HAND THIS STRAIGHT TO AN `Intl` CONSTRUCTOR.",
|
|
578
|
+
"Two independent implementations read the old one-line comment as a",
|
|
579
|
+
"guarantee, which is why this says it at length.",
|
|
580
|
+
"",
|
|
581
|
+
"Tightening the regex is the wrong fix and was rejected. This validator",
|
|
582
|
+
"rejects the WHOLE message on a format failure, so a stricter rule would",
|
|
583
|
+
"cost `reducedMotion` and `muted` as well — a motion-sensitive player",
|
|
584
|
+
"getting animations because a language tag was malformed is a worse",
|
|
585
|
+
"outcome than a bad tag. It would also be a narrowed type, which",
|
|
586
|
+
"`versionNegotiation` rule 5 makes a `v` bump.",
|
|
587
|
+
"",
|
|
588
|
+
"NOTHING SENDS THIS. No Arcade surface populates a locale, and that is a",
|
|
589
|
+
"decision rather than a gap: games detect their own language, because the",
|
|
590
|
+
"parent's answer is `navigator` for almost every player anyway and is",
|
|
591
|
+
"clamped to the Arcade's own language set where it is not. The field stays",
|
|
592
|
+
"because removing it is a `v` bump and an embedder may legitimately send",
|
|
593
|
+
"one."
|
|
594
|
+
]
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
"rateLimit": { "perMinute": 60 }
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
{
|
|
601
|
+
"type": "gem.cohorts",
|
|
602
|
+
"direction": "parentToFrame",
|
|
603
|
+
"transport": "port",
|
|
604
|
+
"$comment": [
|
|
605
|
+
"Which store-page experiment cohorts this player is in, as opaque",
|
|
606
|
+
"identifiers. Sent once after the handshake and again whenever the",
|
|
607
|
+
"answer changes. Gated on `cohorts`, so a parent must check",
|
|
608
|
+
"`frameSupports('cohorts')` before sending; an older frame drops it.",
|
|
609
|
+
"",
|
|
610
|
+
"ALWAYS SENT BY A CONFORMING WEB PARENT, EVEN WHEN EMPTY. That is what",
|
|
611
|
+
"makes the two absences distinguishable on the frame side: an empty",
|
|
612
|
+
"`cohorts` array means 'this player is in no experiment', and never",
|
|
613
|
+
"having received the message means only 'the embedder never said'. A",
|
|
614
|
+
"parent that sends the message only when the array is non-empty",
|
|
615
|
+
"collapses those into one, and a game cannot tell 'not in an",
|
|
616
|
+
"experiment' from 'this embedder does not implement experiments'.",
|
|
617
|
+
"",
|
|
618
|
+
"The values are OPAQUE. `experimentId` is the experiment's id exactly as",
|
|
619
|
+
"the store-page experiment resource names it (a lowercase hyphenated",
|
|
620
|
+
"UUID, hence the bound); `variantKey` is the developer-chosen key of the",
|
|
621
|
+
"variant the player was bucketed into, bounded as the store-page API",
|
|
622
|
+
"bounds it. Neither carries a weight, a revision or a winner, and the",
|
|
623
|
+
"frame is not told how the bucket was chosen. When an experiment",
|
|
624
|
+
"concludes it leaves the array, so the answer converges to empty.",
|
|
625
|
+
"",
|
|
626
|
+
"Not on `gem.token`: the play token's experiment claim is for the",
|
|
627
|
+
"backend's own routes, and neither the parent nor this SDK decodes the",
|
|
628
|
+
"token. This message is the one channel by which a game learns its",
|
|
629
|
+
"cohort, which is why it is a message rather than a claim."
|
|
630
|
+
],
|
|
631
|
+
"fields": {
|
|
632
|
+
"cohorts": {
|
|
633
|
+
"kind": "array",
|
|
634
|
+
"maxItems": 8,
|
|
635
|
+
"items": {
|
|
636
|
+
"kind": "object",
|
|
637
|
+
"fields": {
|
|
638
|
+
"experimentId": { "kind": "string", "maxLength": 36 },
|
|
639
|
+
"variantKey": { "kind": "string", "maxLength": 32 }
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
"$comment": [
|
|
643
|
+
"One entry per RUNNING experiment the player is bucketed into.",
|
|
644
|
+
"At most one experiment runs per game and channel today, so a",
|
|
645
|
+
"single entry is the common case; the bound leaves room for the",
|
|
646
|
+
"platform to run more without a contract change."
|
|
647
|
+
]
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
"rateLimit": { "perMinute": 12 }
|
|
651
|
+
},
|
|
652
|
+
|
|
653
|
+
{
|
|
654
|
+
"type": "gem.viewport",
|
|
655
|
+
"direction": "parentToFrame",
|
|
656
|
+
"transport": "port",
|
|
657
|
+
"$comment": [
|
|
658
|
+
"Where the frame's OWN box is unsafe to draw essential UI: the display",
|
|
659
|
+
"cutout, the home indicator, a corner radius — measured in from each edge",
|
|
660
|
+
"of the frame, in CSS px of the frame's coordinate space. Sent once after",
|
|
661
|
+
"the handshake and again whenever it changes: a rotation, a fullscreen",
|
|
662
|
+
"transition, the parent's chrome hiding or showing beside the frame.",
|
|
663
|
+
"",
|
|
664
|
+
"WHY THE PARENT SAYS IT. `env(safe-area-inset-*)` inside an iframe is",
|
|
665
|
+
"unspecified (an open CSS Working Group question): Chrome resolves it to 0 in an",
|
|
666
|
+
"embedded document, WebKit hands the child the TOP document's values",
|
|
667
|
+
"unadjusted for where the frame sits. So a game that pads its HUD by",
|
|
668
|
+
"`env()` is silently zero on Android and DOUBLE-PADDED in an installed iOS",
|
|
669
|
+
"app whenever the parent's own chrome already sits between the notch and",
|
|
670
|
+
"the frame. Only the parent knows both the page's insets and the frame's",
|
|
671
|
+
"rect, so only the parent can say which part of the frame is actually",
|
|
672
|
+
"under the cutout. This message is that answer.",
|
|
673
|
+
"",
|
|
674
|
+
"Gated on `viewport`, so a parent checks `frameSupports('viewport')`",
|
|
675
|
+
"before sending; an older frame drops it. ALWAYS SENT BY A CONFORMING WEB",
|
|
676
|
+
"PARENT, EVEN WHEN EVERY INSET IS 0 — a zero is an answer ('nothing of",
|
|
677
|
+
"your box is under a cutout'), and never having received the message is",
|
|
678
|
+
"a different fact ('the embedder never said'), under which the SDK's CSS",
|
|
679
|
+
"variables stay unset and a game's `var(--gem-safe-top, env(...))` falls",
|
|
680
|
+
"back to the browser's own answer.",
|
|
681
|
+
"",
|
|
682
|
+
"INSETS ONLY — no chrome state, no form factor, no orientation. What the",
|
|
683
|
+
"parent's chrome is doing is the parent's business, and a form-factor",
|
|
684
|
+
"signal crossing the bridge is an owner decision this message does not",
|
|
685
|
+
"reopen (public-docs/what-a-game-must-do.md, the television section). A",
|
|
686
|
+
"game learns that part of its box is unusable, not why.",
|
|
687
|
+
"",
|
|
688
|
+
"NOT the TV overscan allowance. `env()` is 0 on every television and the",
|
|
689
|
+
"title-safe margin is documented as the game's own; folding the arcade's",
|
|
690
|
+
"allowance in here would double with that margin on every TV-ready game.",
|
|
691
|
+
"",
|
|
692
|
+
"Integers, not fractions: a fractional CSS pixel of inset is not a",
|
|
693
|
+
"placement anyone can act on, and rounding UP on the sender keeps the",
|
|
694
|
+
"promise conservative. 4096 is a bound, not a size — no inset on any",
|
|
695
|
+
"device approaches it, and a value near it is a parent that is wrong."
|
|
696
|
+
],
|
|
697
|
+
"fields": {
|
|
698
|
+
"insets": {
|
|
699
|
+
"kind": "object",
|
|
700
|
+
"fields": {
|
|
701
|
+
"top": { "kind": "int", "min": 0, "max": 4096 },
|
|
702
|
+
"right": { "kind": "int", "min": 0, "max": 4096 },
|
|
703
|
+
"bottom": { "kind": "int", "min": 0, "max": 4096 },
|
|
704
|
+
"left": { "kind": "int", "min": 0, "max": 4096 }
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
},
|
|
708
|
+
"rateLimit": { "perMinute": 60 }
|
|
709
|
+
},
|
|
710
|
+
|
|
711
|
+
{
|
|
712
|
+
"type": "gem.deviceSettings",
|
|
713
|
+
"direction": "parentToFrame",
|
|
714
|
+
"transport": "port",
|
|
715
|
+
"$comment": [
|
|
716
|
+
"This device's settings for THIS GAME, as the parent last stored them. Gated",
|
|
717
|
+
"by the `deviceSettings` capability. Sent ONCE per session, as soon as the",
|
|
718
|
+
"parent can name the game -- a web arcade right after the handshake accept,",
|
|
719
|
+
"a developer tool after the first token grant -- and never again: the game",
|
|
720
|
+
"already holds everything it writes afterwards.",
|
|
721
|
+
"",
|
|
722
|
+
"`settings` ABSENT means 'nothing stored for this game on this device'.",
|
|
723
|
+
"Present, it is the object the game last put here, after one JSON round",
|
|
724
|
+
"trip. The parent never reads inside it, never merges and never migrates.",
|
|
725
|
+
"",
|
|
726
|
+
"WHAT THIS IS FOR, AND WHAT IT IS NOT. Device-local configuration: a",
|
|
727
|
+
"volume, a graphics quality, a resolution scale, a dismissed tip. It is NOT",
|
|
728
|
+
"a save and NOT the roaming key/value store (`gem.me.settings`). It does not",
|
|
729
|
+
"follow the player to another device or browser, it is shared by every",
|
|
730
|
+
"account that plays this game on this device, and it can vanish at any",
|
|
731
|
+
"time -- cleared site data, a private window, another browser, the parent",
|
|
732
|
+
"evicting it. A game must boot correctly from nothing. Progression,",
|
|
733
|
+
"unlocks, currency, anything a player would mind losing: never here.",
|
|
734
|
+
"",
|
|
735
|
+
"FILED BY THE PARENT, keyed by what the PARENT knows about the session --",
|
|
736
|
+
"the game and the channel of the build being played. The game never names",
|
|
737
|
+
"either and the wire carries neither, so a dev build's settings never reach",
|
|
738
|
+
"a release build of the same game and a dev-only key cannot break a shipped",
|
|
739
|
+
"one.",
|
|
740
|
+
"",
|
|
741
|
+
"TWO ABSENCES, kept apart on the frame side exactly as `gem.cohorts` keeps",
|
|
742
|
+
"them: a message with no `settings` is the answer 'nothing stored'; never",
|
|
743
|
+
"receiving the message means only 'this embedder never said'. The SDK's",
|
|
744
|
+
"`wait` resolves at once on the first and only at its deadline on the",
|
|
745
|
+
"second; `current` is null for both.",
|
|
746
|
+
"",
|
|
747
|
+
"Public to the parent, like everything on the bridge, and the game's own",
|
|
748
|
+
"text -- nothing in it is another player's."
|
|
749
|
+
],
|
|
750
|
+
"fields": {
|
|
751
|
+
"settings": {
|
|
752
|
+
"kind": "json",
|
|
753
|
+
"optional": true,
|
|
754
|
+
"$comment": [
|
|
755
|
+
"A plain JSON object. The generated validator refuses a primitive, an",
|
|
756
|
+
"array and null, and the frame's `put` refuses them locally before",
|
|
757
|
+
"sending. Bounded by the message cap and, on both ends, by device",
|
|
758
|
+
"settings' OWN CAP: 16384 bytes of the UTF-8 JSON serialisation. Small",
|
|
759
|
+
"on purpose -- a blob that cannot hold a save is a blob nobody mistakes",
|
|
760
|
+
"for one. The number lives here and is mirrored as a constant on each",
|
|
761
|
+
"side, because a parent with no dependency on this package has to",
|
|
762
|
+
"enforce the same bound the frame pre-checks against."
|
|
763
|
+
]
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
"rateLimit": {
|
|
767
|
+
"perMinute": 12
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
|
|
771
|
+
{
|
|
772
|
+
"type": "gem.willTerminate",
|
|
773
|
+
"direction": "parentToFrame",
|
|
774
|
+
"transport": "port",
|
|
775
|
+
"$comment": [
|
|
776
|
+
"Sent ahead of a teardown so a cooperating game can persist state first.",
|
|
777
|
+
"Without it, 'rejoin' is a euphemism for 'restart and lose your progress'."
|
|
778
|
+
],
|
|
779
|
+
"fields": {
|
|
780
|
+
"reason": {
|
|
781
|
+
"kind": "enum",
|
|
782
|
+
"values": ["grant_expiring", "session_ended", "navigating", "kill_switch"]
|
|
783
|
+
},
|
|
784
|
+
"graceMs": { "kind": "int", "min": 0, "max": 60000 }
|
|
785
|
+
},
|
|
786
|
+
"rateLimit": { "perMinute": 10 }
|
|
787
|
+
},
|
|
788
|
+
|
|
789
|
+
{
|
|
790
|
+
"type": "gem.roomEvent",
|
|
791
|
+
"direction": "parentToFrame",
|
|
792
|
+
"transport": "port",
|
|
793
|
+
"$comment": [
|
|
794
|
+
"A room push, relayed from the notification WebSocket. A HINT, never state.",
|
|
795
|
+
"",
|
|
796
|
+
"Gated by the `rooms` capability. The mapping lives in",
|
|
797
|
+
"`schema/capabilities.json` and nowhere else — a second declaration here",
|
|
798
|
+
"would be a field nothing reads, which is the shape this repo keeps finding",
|
|
799
|
+
"as a defect.",
|
|
800
|
+
"",
|
|
801
|
+
"The parent holds that socket because it holds the player token and",
|
|
802
|
+
"`POST /v1/ws/ticket` accepts only player tokens. The frame holds a",
|
|
803
|
+
"game-scoped token by design, so it cannot open the socket and must not be",
|
|
804
|
+
"handed a credential for one. See public-docs/rooms-and-matches.md §3.",
|
|
805
|
+
"",
|
|
806
|
+
"CARRIES NO ROOM STATE. No member list, no display names, no join code, no",
|
|
807
|
+
"connection details. The frame re-reads the room over its OWN token and",
|
|
808
|
+
"diffs: a push triggers an authoritative refresh rather than being applied.",
|
|
809
|
+
"On web",
|
|
810
|
+
"that choice pays twice: the push crosses an origin boundary, so a message",
|
|
811
|
+
"that carried state would be a route by which a compromised parent writes",
|
|
812
|
+
"the game's model of who is in the room. The parent can make the frame",
|
|
813
|
+
"LOOK; it cannot make it BELIEVE.",
|
|
814
|
+
"",
|
|
815
|
+
"`version` is the debounce. A hint whose room version the frame already",
|
|
816
|
+
"holds costs no request — the engine's If-None-Match behaviour expressed in",
|
|
817
|
+
"the one field the push already carries.",
|
|
818
|
+
"",
|
|
819
|
+
"`version_stale` IS THE ONE THAT DOES NOT WORK LIKE THAT. It is",
|
|
820
|
+
"platform-scoped rather than",
|
|
821
|
+
"room-scoped: it says a build line the room requires is ahead of one a",
|
|
822
|
+
"member is running, and its whole distinguishing payload is `platform` —",
|
|
823
|
+
"no version, no match id. So there is nothing to re-read and nothing to",
|
|
824
|
+
"debounce against, and receiving one triggers no refresh of anything. It is",
|
|
825
|
+
"FORWARDED. What a game does about a stale build is the game's decision,",
|
|
826
|
+
"and the platform layer holds no opinion about it — which is the point of",
|
|
827
|
+
"carrying it here rather than letting the page act on it, because then a",
|
|
828
|
+
"browser game and a native game observe the same thing on the same push.",
|
|
829
|
+
"",
|
|
830
|
+
"The five values are the whole vocabulary. gem-api's socket also carries",
|
|
831
|
+
"friend requests, party invites, transfer receipts, purchase completions and",
|
|
832
|
+
"P2P signaling for the signed-in player; `GemHost.relayRoomEvent` drops",
|
|
833
|
+
"every type outside this enum rather than passing it along. Room INVITES are",
|
|
834
|
+
"excluded deliberately — an invite is a friend-graph EVENT, and DESIGN.md",
|
|
835
|
+
"§10's friends carve-out is a read of the accepted list (`gem.friends`),",
|
|
836
|
+
"nothing more: no invite events, no presence, no graph state crosses the",
|
|
837
|
+
"bridge.",
|
|
838
|
+
"",
|
|
839
|
+
"PARENT TO FRAME ONLY. A frame-authored room event would let a game write",
|
|
840
|
+
"the parent's model of a room it is not in."
|
|
841
|
+
],
|
|
842
|
+
"fields": {
|
|
843
|
+
"event": {
|
|
844
|
+
"kind": "enum",
|
|
845
|
+
"values": ["updated", "match_started", "match_ready", "match_failed", "version_stale"],
|
|
846
|
+
"$comment": "gem-api's `game_room_*` names, minus a prefix the message type already supplies."
|
|
847
|
+
},
|
|
848
|
+
"roomId": { "kind": "string", "maxLength": 64 },
|
|
849
|
+
"matchId": {
|
|
850
|
+
"kind": "string",
|
|
851
|
+
"maxLength": 64,
|
|
852
|
+
"optional": true,
|
|
853
|
+
"$comment": "Present on the three match events. Absent on `updated`, which is about membership, and on `version_stale`, which is about a build."
|
|
854
|
+
},
|
|
855
|
+
"version": {
|
|
856
|
+
"kind": "int",
|
|
857
|
+
"min": 0,
|
|
858
|
+
"max": 2147483647,
|
|
859
|
+
"optional": true,
|
|
860
|
+
"$comment": "The room's optimistic-concurrency version. Monotonic per room; the frame skips a refresh for a version it already holds. Bounded like `gem.token.generation` — gem-api stores it as an i64, but a room that reached 2^31 mutations would have been alive for years, and the bound exists to cap validation cost rather than to describe the domain."
|
|
861
|
+
},
|
|
862
|
+
"reason": {
|
|
863
|
+
"kind": "string",
|
|
864
|
+
"maxLength": 64,
|
|
865
|
+
"optional": true,
|
|
866
|
+
"$comment": "Why an allocation failed. Server-authored and coarse — `no_capacity`, not a stack trace."
|
|
867
|
+
},
|
|
868
|
+
"platform": {
|
|
869
|
+
"kind": "enum",
|
|
870
|
+
"values": ["universal", "mobile"],
|
|
871
|
+
"optional": true,
|
|
872
|
+
"$comment": "Which build line went stale. Read on `version_stale` and on nothing else, and the whole of what distinguishes that event — a slot that dropped it would forward a push no game could act on. The same two values gem-api's client platform enum has. OPTIONAL because the parent blanks a value outside them rather than passing it along: the string arrives from a server push, so this is defence in depth against a crafted payload, and it is also what keeps a platform gem-api adds later from becoming a message the frame refuses outright. A game that sees no platform knows a build line went stale without being told which."
|
|
873
|
+
}
|
|
874
|
+
},
|
|
875
|
+
"rateLimit": { "perMinute": 120 }
|
|
876
|
+
},
|
|
877
|
+
|
|
878
|
+
{
|
|
879
|
+
"type": "gem.socketState",
|
|
880
|
+
"direction": "parentToFrame",
|
|
881
|
+
"transport": "port",
|
|
882
|
+
"$comment": [
|
|
883
|
+
"Whether the PARENT's notification socket — the one every gem.roomEvent,",
|
|
884
|
+
"gem.signalEvent and gem.dedicatedEvent is relayed from — is up. Gated by",
|
|
885
|
+
"the `socketState` capability; the mapping lives in",
|
|
886
|
+
"schema/capabilities.json and nowhere else.",
|
|
887
|
+
"",
|
|
888
|
+
"THE ONE CONNECTIVITY FACT THE FRAME CANNOT SEE FOR ITSELF. The frame",
|
|
889
|
+
"holds a game-scoped token and no socket of its own; the page holds the",
|
|
890
|
+
"player's socket and reconnects it with backoff. A push that fired while",
|
|
891
|
+
"the socket was down is gone — the socket has no replay — so the frame's",
|
|
892
|
+
"room model is only as fresh as its last poll. This message is how the",
|
|
893
|
+
"frame learns the gap happened, and `connected: true` is its cue to",
|
|
894
|
+
"re-read the room and the match over its own token (`gem.rooms.reconcile`).",
|
|
895
|
+
"",
|
|
896
|
+
"CARRIES NO STATE AND NO CREDENTIAL: one boolean and a reason from a",
|
|
897
|
+
"closed set. `reason` is present only with `connected: false` and says",
|
|
898
|
+
"what the page observed — `connecting` (never yet up), `dropped` (the",
|
|
899
|
+
"socket closed or a ticket request failed; a reconnect is coming),",
|
|
900
|
+
"`watchdog` (no traffic within the heartbeat window; reconnecting),",
|
|
901
|
+
"`terminal` (this frame's socket is not coming back — the session cannot",
|
|
902
|
+
"mint a ticket, or the page changed accounts). An account switch is",
|
|
903
|
+
"reported as `terminal` and not by name: which account a page is signed",
|
|
904
|
+
"in as is an account-level fact, and a game-scoped sandbox has no business",
|
|
905
|
+
"with it.",
|
|
906
|
+
"",
|
|
907
|
+
"SENT ON BOTH EDGES AND ONCE AFTER THE HANDSHAKE, so a frame that loaded",
|
|
908
|
+
"during a backoff learns the socket is down, then up. LATEST STATE WINS on",
|
|
909
|
+
"the parent: the rate limit below is well under the socket's own fastest",
|
|
910
|
+
"possible flap (backoff runs one to thirty seconds), and a parent whose",
|
|
911
|
+
"send is refused resends the CURRENT state when the bucket allows, so the",
|
|
912
|
+
"last message a frame receives is never a stale `false`.",
|
|
913
|
+
"",
|
|
914
|
+
"PARENT TO FRAME ONLY. The frame's reaction is bounded on its side: it",
|
|
915
|
+
"re-reads on a false-to-true EDGE, once per cooldown, sharing one",
|
|
916
|
+
"in-flight read — a flapping or hostile parent cannot turn this into a",
|
|
917
|
+
"request loop against gem-api."
|
|
918
|
+
],
|
|
919
|
+
"fields": {
|
|
920
|
+
"connected": {
|
|
921
|
+
"kind": "boolean"
|
|
922
|
+
},
|
|
923
|
+
"reason": {
|
|
924
|
+
"kind": "enum",
|
|
925
|
+
"values": ["connecting", "dropped", "watchdog", "terminal"],
|
|
926
|
+
"optional": true,
|
|
927
|
+
"$comment": "Present only when `connected` is false. Closed: a parent must not send a value outside this set, and the frame's validator rejects one."
|
|
928
|
+
}
|
|
929
|
+
},
|
|
930
|
+
"rateLimit": {
|
|
931
|
+
"perMinute": 12
|
|
932
|
+
}
|
|
933
|
+
},
|
|
934
|
+
{
|
|
935
|
+
"type": "gem.seatedEvent",
|
|
936
|
+
"direction": "parentToFrame",
|
|
937
|
+
"transport": "port",
|
|
938
|
+
"$comment": [
|
|
939
|
+
"This player holds a seat in a room the frame did not join. Gated by the",
|
|
940
|
+
"`partySeating` capability; the mapping lives in schema/capabilities.json",
|
|
941
|
+
"and nowhere else.",
|
|
942
|
+
"",
|
|
943
|
+
"gem-api's `party_game_room_joined`, which it sends the moment a party is",
|
|
944
|
+
"seated in a room. A party reaches a game exactly one way — the LEADER's",
|
|
945
|
+
"single room call seats every member — so a member's frame becomes a room",
|
|
946
|
+
"member without making a room call, and this is the only thing that can",
|
|
947
|
+
"tell it so.",
|
|
948
|
+
"",
|
|
949
|
+
"SEPARATE FROM gem.roomEvent, and the separation is the whole reason this",
|
|
950
|
+
"type exists rather than a sixth value in that enum.",
|
|
951
|
+
"",
|
|
952
|
+
"First, meaning. Every gem.roomEvent is about the room the frame is IN —",
|
|
953
|
+
"which is why the frame drops one whose roomId is not the room it holds,",
|
|
954
|
+
"and why the type can promise to carry no state and be re-read on the",
|
|
955
|
+
"frame's own token. This is the opposite case: a room the frame is NOT in",
|
|
956
|
+
"and has never heard of. Riding the same type would mean writing an",
|
|
957
|
+
"exception into that type's one invariant.",
|
|
958
|
+
"",
|
|
959
|
+
"Second, and decisively, COMPATIBILITY. A new enum value is not additive",
|
|
960
|
+
"the way a new field is. `versionNegotiation` rule 4 makes fields",
|
|
961
|
+
"ignorable and rule 5 says a widened type is no `v` bump, but the",
|
|
962
|
+
"generated validator answers an unrecognised enum value with a REJECTION,",
|
|
963
|
+
"and the frame turns a rejection into `gem.error` with `invalid_message` —",
|
|
964
|
+
"surfaced to the game and to the embedder. So a sixth value would fire a",
|
|
965
|
+
"spurious protocol error in every already-published game, every time a",
|
|
966
|
+
"party was seated, on games that cannot use the event anyway. An unknown",
|
|
967
|
+
"TYPE is the case the protocol already handles correctly: the frame",
|
|
968
|
+
"ignores it, meters it against the malformed bucket, and says nothing.",
|
|
969
|
+
"Adding a type is silent where adding a value is loud.",
|
|
970
|
+
"",
|
|
971
|
+
"CARRIES NO ROOM STATE, for gem.roomEvent's reason and one more. The",
|
|
972
|
+
"frame re-reads the room over its OWN token and confirms this player is a",
|
|
973
|
+
"member before it believes any of it — so a parent can make the frame",
|
|
974
|
+
"look at a room, and cannot seat anybody in one.",
|
|
975
|
+
"",
|
|
976
|
+
"NO PARTY ID CROSSES. The payload gem-api sends carries one; it stops",
|
|
977
|
+
"here. The frame has no party surface (public-docs/p2p-and-parties.md",
|
|
978
|
+
"§7.1), and the v2 room routes deliberately replaced the platform party",
|
|
979
|
+
"id with a room-scoped marker — relaying it here would put back the",
|
|
980
|
+
"cross-game social-graph fact that removal was for.",
|
|
981
|
+
"",
|
|
982
|
+
"ONE FIELD, AND NO VERSION. gem.roomEvent carries the room's version so",
|
|
983
|
+
"the frame can skip a refresh it already holds; there is no equivalent",
|
|
984
|
+
"here, because the push this is projected from is a PARTY event and the",
|
|
985
|
+
"version on it is the party record's, not the room's. Relaying that under",
|
|
986
|
+
"a room-shaped name is how a frame ends up suppressing a real seating",
|
|
987
|
+
"against a number from another object. The frame reads the room over its",
|
|
988
|
+
"own token anyway, and the authoritative version arrives with it.",
|
|
989
|
+
"",
|
|
990
|
+
"PARENT TO FRAME ONLY. A frame-authored seating claim would let a game",
|
|
991
|
+
"point the SDK at a room it was never seated in."
|
|
992
|
+
],
|
|
993
|
+
"fields": {
|
|
994
|
+
"roomId": { "kind": "string", "maxLength": 64 }
|
|
995
|
+
},
|
|
996
|
+
"rateLimit": { "perMinute": 60 }
|
|
997
|
+
},
|
|
998
|
+
|
|
999
|
+
{
|
|
1000
|
+
"type": "gem.signalEvent",
|
|
1001
|
+
"direction": "parentToFrame",
|
|
1002
|
+
"transport": "port",
|
|
1003
|
+
"$comment": [
|
|
1004
|
+
"One P2P signaling message from the notification WebSocket, relayed in.",
|
|
1005
|
+
"Gated by the `p2p` capability; the mapping lives in",
|
|
1006
|
+
"schema/capabilities.json and nowhere else.",
|
|
1007
|
+
"",
|
|
1008
|
+
"UNLIKE gem.roomEvent, THIS CARRIES STATE, and the difference is forced",
|
|
1009
|
+
"rather than chosen. A room hint names a room the frame can re-read over",
|
|
1010
|
+
"its own token; there is no route by which a frame can re-read an SDP",
|
|
1011
|
+
"offer another player sent, so the offer itself has to cross. The",
|
|
1012
|
+
"compensating control is downstream and cryptographic: a peer's identity",
|
|
1013
|
+
"is bound to a DTLS certificate fingerprint that the platform attests to",
|
|
1014
|
+
"the frame over the frame's OWN TLS connection (POST",
|
|
1015
|
+
"/matches/{id}/join-credential), so a parent that rewrites an SDP cannot",
|
|
1016
|
+
"impersonate a peer — it can only break the connection. See",
|
|
1017
|
+
"public-docs/p2p-and-parties.md §3.",
|
|
1018
|
+
"",
|
|
1019
|
+
"`data` is gem-api's grammar, not this bridge's. It is emitted as a",
|
|
1020
|
+
"bounded plain-data object rather than a declared field list because the",
|
|
1021
|
+
"vocabulary belongs to the notification socket, which gains fields",
|
|
1022
|
+
"whenever gem-api does — pinning it into a frozen v1 message would make",
|
|
1023
|
+
"every gem-api addition a bridge schema change and a re-vendor of two",
|
|
1024
|
+
"artifacts. The parent projects socket fields ONTO A FRESH OBJECT BY NAME",
|
|
1025
|
+
"before sending, exactly as `relayRoomEvent` does, so what is not on the",
|
|
1026
|
+
"parent's allowlist never crosses. `jwks_url` is dropped there",
|
|
1027
|
+
"deliberately: a frame that took its JWKS endpoint from a relayed message",
|
|
1028
|
+
"would let the parent choose the keys it verifies peers against.",
|
|
1029
|
+
"",
|
|
1030
|
+
"The nine events are the whole vocabulary; an unrecognised p2p_* type is",
|
|
1031
|
+
"a drop at the parent, not a passthrough.",
|
|
1032
|
+
"",
|
|
1033
|
+
"PARENT TO FRAME ONLY. gem.signal is the other direction."
|
|
1034
|
+
],
|
|
1035
|
+
"fields": {
|
|
1036
|
+
"event": {
|
|
1037
|
+
"kind": "enum",
|
|
1038
|
+
"values": [
|
|
1039
|
+
"hello",
|
|
1040
|
+
"offer",
|
|
1041
|
+
"answer",
|
|
1042
|
+
"ice",
|
|
1043
|
+
"peerJoined",
|
|
1044
|
+
"peerLeft",
|
|
1045
|
+
"migration",
|
|
1046
|
+
"matchEnded",
|
|
1047
|
+
"error"
|
|
1048
|
+
],
|
|
1049
|
+
"$comment": "gem-api's `p2p_*` names, minus a prefix the message type already supplies."
|
|
1050
|
+
},
|
|
1051
|
+
"matchId": {
|
|
1052
|
+
"kind": "string",
|
|
1053
|
+
"maxLength": 64,
|
|
1054
|
+
"$comment": "Hoisted out of `data` so the frame can route without reading the free-form part."
|
|
1055
|
+
},
|
|
1056
|
+
"data": {
|
|
1057
|
+
"kind": "json",
|
|
1058
|
+
"$comment": "The projected payload. Bounded by the message-level walk, not by a field list."
|
|
1059
|
+
}
|
|
1060
|
+
},
|
|
1061
|
+
"rateLimit": { "perMinute": 1800 }
|
|
1062
|
+
},
|
|
1063
|
+
|
|
1064
|
+
{
|
|
1065
|
+
"type": "gem.dedicatedEvent",
|
|
1066
|
+
"direction": "parentToFrame",
|
|
1067
|
+
"transport": "port",
|
|
1068
|
+
"$comment": [
|
|
1069
|
+
"One dedicated-server signaling push from the notification WebSocket, or",
|
|
1070
|
+
"the embedding page's own refusal of an offer it did not put on that",
|
|
1071
|
+
"socket. Gated by the `dedicated` capability; the mapping lives in",
|
|
1072
|
+
"schema/capabilities.json and nowhere else.",
|
|
1073
|
+
"",
|
|
1074
|
+
"SEPARATE FROM gem.signalEvent, and the separation is the point. A parent",
|
|
1075
|
+
"may relay one transport and not the other — they are different socket",
|
|
1076
|
+
"message sets, different failure domains, and gem-api ships an independent",
|
|
1077
|
+
"kill switch for each. Folding them into one message type would make",
|
|
1078
|
+
"`p2p` mean 'relays both', so a game whose parent implements only P2P",
|
|
1079
|
+
"would send an offer nobody answers and wait out its timeout. That is the",
|
|
1080
|
+
"hang capability negotiation exists to prevent.",
|
|
1081
|
+
"",
|
|
1082
|
+
"LIKE gem.signalEvent, THIS CARRIES STATE: the answer's SDP. Same reason",
|
|
1083
|
+
"— there is no route by which the frame can re-read what the server sent",
|
|
1084
|
+
"— and the same compensating control, one step stronger here: the",
|
|
1085
|
+
"server's DTLS identity is pinned by gem-api for the signaling session,",
|
|
1086
|
+
"so a parent that rewrote an answer would break the handshake rather than",
|
|
1087
|
+
"impersonate the server.",
|
|
1088
|
+
"",
|
|
1089
|
+
"The three events are the whole vocabulary. `answer` is gem-api's",
|
|
1090
|
+
"`webrtc_sdp_answer`. `error` is a REFUSAL OF THE OFFER by whoever was",
|
|
1091
|
+
"carrying it — gem-api's `webrtc_error`, or the embedding page refusing",
|
|
1092
|
+
"an offer it did not put on the socket (see `code`). Without it, a",
|
|
1093
|
+
"refused offer's only signal is silence until the exchange times out,",
|
|
1094
|
+
"and every refusal reads as 'the server never came up'.",
|
|
1095
|
+
"",
|
|
1096
|
+
"`matchEnded` is gem-api's `match_ended`, and it is LIFECYCLE rather than",
|
|
1097
|
+
"signaling — it rides here because it is the same socket, the same",
|
|
1098
|
+
"transport and the same capability, and a parent that relays one relays",
|
|
1099
|
+
"all three. Note the name: the P2P table's `p2p_match_ended` is a",
|
|
1100
|
+
"DIFFERENT socket type for a different transport, and the absence of this",
|
|
1101
|
+
"one is what left a browser holding a finished dedicated match. Such a",
|
|
1102
|
+
"browser keeps answering ICE consent checks, which the server cannot tell",
|
|
1103
|
+
"apart from a player sitting on their turn, so it held its pod for the",
|
|
1104
|
+
"full two-hour ceiling. No transport-level detection can close that",
|
|
1105
|
+
"without evicting real players; the client has to let go itself.",
|
|
1106
|
+
"",
|
|
1107
|
+
"Fields are declared rather than free-form, unlike gem.signalEvent's",
|
|
1108
|
+
"`data`. There is one message per event and five fields between them, so",
|
|
1109
|
+
"the generated validator can bound each one — where the P2P payload is a",
|
|
1110
|
+
"vocabulary that gains fields whenever gem-api does. Per-event fields are",
|
|
1111
|
+
"optional for the same reason gem.roomEvent's are: one message type",
|
|
1112
|
+
"carries three events and no event's fields belong to another.",
|
|
1113
|
+
"",
|
|
1114
|
+
"PARENT TO FRAME ONLY. gem.dedicatedOffer is the other direction."
|
|
1115
|
+
],
|
|
1116
|
+
"fields": {
|
|
1117
|
+
"event": {
|
|
1118
|
+
"kind": "enum",
|
|
1119
|
+
"values": ["answer", "error", "matchEnded"],
|
|
1120
|
+
"$comment": "gem-api's `webrtc_sdp_answer`, `webrtc_error` and `match_ended`, named for what they are rather than for the prefix the message type already supplies. An `error` may instead be the embedding page's own refusal of an offer it did not put on the socket — see `code`."
|
|
1121
|
+
},
|
|
1122
|
+
"matchId": { "kind": "string", "maxLength": 64 },
|
|
1123
|
+
"sdp": {
|
|
1124
|
+
"kind": "string",
|
|
1125
|
+
"maxLength": 10240,
|
|
1126
|
+
"optional": true,
|
|
1127
|
+
"$comment": "The answer, on `answer`. gem-api's configured SDP ceiling is 10 KB and this matches it; the socket's own frame cap binds first in both directions."
|
|
1128
|
+
},
|
|
1129
|
+
"sessionId": {
|
|
1130
|
+
"kind": "string",
|
|
1131
|
+
"maxLength": 64,
|
|
1132
|
+
"optional": true,
|
|
1133
|
+
"$comment": "gem-api's signaling session id, minted when it stored the offer. The offering client never sees its own — the id reaches the server, not the offerer — so this is a value to LOG, never one to match an outstanding offer against."
|
|
1134
|
+
},
|
|
1135
|
+
"code": {
|
|
1136
|
+
"kind": "string",
|
|
1137
|
+
"maxLength": 64,
|
|
1138
|
+
"optional": true,
|
|
1139
|
+
"$comment": [
|
|
1140
|
+
"Why the offer was refused, on `error`. Coarse, and written by whichever",
|
|
1141
|
+
"of two authors refused it.",
|
|
1142
|
+
"",
|
|
1143
|
+
"gem-api writes one for a refusal it made — `webrtc_no_server`,",
|
|
1144
|
+
"`age_restricted`, `rate_limited`.",
|
|
1145
|
+
"",
|
|
1146
|
+
"The page embedding the game writes one for an offer it did not put on",
|
|
1147
|
+
"the socket. A code with the `embedder_` prefix is always the page's own,",
|
|
1148
|
+
"for an offer that never reached gem-api. Not every code the page writes",
|
|
1149
|
+
"carries the prefix: where its refusal means exactly what a gem-api code",
|
|
1150
|
+
"means, it reuses that spelling, so a code without the prefix does not",
|
|
1151
|
+
"prove the offer reached gem-api.",
|
|
1152
|
+
"",
|
|
1153
|
+
"`embedder_relay_unavailable`: the page accepted the offer past every",
|
|
1154
|
+
"check it makes before a send — capability, wiring, token, budget,",
|
|
1155
|
+
"shape, size — and could not put it on the player's notification",
|
|
1156
|
+
"WebSocket, because the socket was not open at that moment. Nothing",
|
|
1157
|
+
"about the offer, the page's wiring or the account is wrong. TRANSIENT,",
|
|
1158
|
+
"and worth a resend: the next attempt may succeed.",
|
|
1159
|
+
"",
|
|
1160
|
+
"`embedder_no_relay`: the page advertised `dedicated` but has nothing",
|
|
1161
|
+
"wired to relay signaling with, so no offer can leave it. That will not",
|
|
1162
|
+
"change for the life of the page. TERMINAL: no resend can succeed."
|
|
1163
|
+
]
|
|
1164
|
+
},
|
|
1165
|
+
"message": { "kind": "string", "maxLength": 256, "optional": true },
|
|
1166
|
+
"reason": {
|
|
1167
|
+
"kind": "string",
|
|
1168
|
+
"maxLength": 64,
|
|
1169
|
+
"optional": true,
|
|
1170
|
+
"$comment": "Why the match ended, on `matchEnded`. gem-api's own vocabulary — `explicit`, `stale_cleanup`, `game_room_closed`, `replaced` — and a value to LOG: nothing branches on it, because every one of them means the same thing to a client, which is that this match is over."
|
|
1171
|
+
}
|
|
1172
|
+
},
|
|
1173
|
+
"rateLimit": { "perMinute": 120 }
|
|
1174
|
+
},
|
|
1175
|
+
|
|
1176
|
+
{
|
|
1177
|
+
"type": "gem.partyResult",
|
|
1178
|
+
"direction": "parentToFrame",
|
|
1179
|
+
"transport": "port",
|
|
1180
|
+
"$comment": [
|
|
1181
|
+
"The answer to one gem.partyRequest. Gated by the `parties` capability.",
|
|
1182
|
+
"",
|
|
1183
|
+
"THE MINIMUM A GAME CAN ACT ON, AND NOTHING ELSE. `partyId` is present",
|
|
1184
|
+
"only when the local player is the party LEADER, because that is the only",
|
|
1185
|
+
"case in which the frame has anything to do with it: gem-api requires the",
|
|
1186
|
+
"caller to be leader to bring a party into a room, so a non-leader that",
|
|
1187
|
+
"attached the id would be refused a room it could otherwise have had.",
|
|
1188
|
+
"",
|
|
1189
|
+
"Absence therefore means 'no party, or you do not lead it' — one state,",
|
|
1190
|
+
"because the frame's behaviour is identical in both: proceed solo.",
|
|
1191
|
+
"Splitting them would hand a game the fact that this player is in someone",
|
|
1192
|
+
"else's party, which is a social-graph fact no game asked for.",
|
|
1193
|
+
"",
|
|
1194
|
+
"There is deliberately no roster, no join code, no member list and no",
|
|
1195
|
+
"party name. A party is ACCOUNT-LEVEL AND CROSS-GAME — its record carries",
|
|
1196
|
+
"which other games the members suggest playing and what platforms they are",
|
|
1197
|
+
"on — so handing it to a game frame would export the player's social graph",
|
|
1198
|
+
"and their activity across the whole catalog. See public-docs/p2p-and-parties.md",
|
|
1199
|
+
"§6 and DESIGN.md §10."
|
|
1200
|
+
],
|
|
1201
|
+
"fields": {
|
|
1202
|
+
"requestId": {
|
|
1203
|
+
"kind": "string",
|
|
1204
|
+
"maxLength": 64,
|
|
1205
|
+
"$comment": "Echoes the gem.partyRequest that asked. Echo-only, like gem.token's."
|
|
1206
|
+
},
|
|
1207
|
+
"partyId": {
|
|
1208
|
+
"kind": "string",
|
|
1209
|
+
"maxLength": 64,
|
|
1210
|
+
"optional": true,
|
|
1211
|
+
"$comment": "Present ONLY when the local player leads a party. See above."
|
|
1212
|
+
}
|
|
1213
|
+
},
|
|
1214
|
+
"rateLimit": { "perMinute": 30 }
|
|
1215
|
+
},
|
|
1216
|
+
|
|
1217
|
+
{
|
|
1218
|
+
"type": "gem.clusterTarget",
|
|
1219
|
+
"direction": "parentToFrame",
|
|
1220
|
+
"transport": "port",
|
|
1221
|
+
"$comment": [
|
|
1222
|
+
"Where this player should be allocated. Gated by the `clusterTarget`",
|
|
1223
|
+
"capability.",
|
|
1224
|
+
"",
|
|
1225
|
+
"ONE RESOLVED ID, NEVER THE MEASUREMENT THAT PRODUCED IT. The parent",
|
|
1226
|
+
"ranks regions by timing STUN Binding requests against the probe",
|
|
1227
|
+
"endpoints `GET /v1/regions` publishes, once per player session. What",
|
|
1228
|
+
"crosses is the winner. What must never cross is the vector: a",
|
|
1229
|
+
"round-trip time to every region is coarse geolocation plus a network",
|
|
1230
|
+
"fingerprint, and it would be readable by third-party game code that has",
|
|
1231
|
+
"no use for it. There is no ranking, no runner-up, no per-region timing",
|
|
1232
|
+
"and no `probe_targets_fresh` here, and adding any of them is not an",
|
|
1233
|
+
"additive change however optional the field is.",
|
|
1234
|
+
"",
|
|
1235
|
+
"PUSHED, NOT ASKED, because the fact exists before the frame does — the",
|
|
1236
|
+
"Arcade page measures at startup and the same measurement survives a game",
|
|
1237
|
+
"swap. Sent once after the handshake; sent again only if the parent's",
|
|
1238
|
+
"answer actually changes.",
|
|
1239
|
+
"",
|
|
1240
|
+
"ABSENCE IS EXPRESSED BY NOT SENDING. There is no empty or sentinel",
|
|
1241
|
+
"value, and the field is required for that reason: a parent with no",
|
|
1242
|
+
"answer stays quiet, the frame omits the cluster target from its",
|
|
1243
|
+
"allocation request, and the server is left to decide. That is what",
|
|
1244
|
+
"`/v1/regions` asks a client with no `default_cluster_target_id` to do.",
|
|
1245
|
+
"",
|
|
1246
|
+
"NOT the room's `region`. That one is a free-form matchmaking label",
|
|
1247
|
+
"gem-api stores on the room and filters quick-join by; it plays no part",
|
|
1248
|
+
"in allocation. This is the cluster the match is placed in. Two nearby",
|
|
1249
|
+
"words for two unrelated facts, so they are named apart — DESIGN.md §1."
|
|
1250
|
+
],
|
|
1251
|
+
"fields": {
|
|
1252
|
+
"clusterTargetId": {
|
|
1253
|
+
"kind": "string",
|
|
1254
|
+
"maxLength": 64,
|
|
1255
|
+
"$comment": "`cluster_target_id` as `/v1/regions` reports it, e.g. `ct_azureeastus01`."
|
|
1256
|
+
}
|
|
1257
|
+
},
|
|
1258
|
+
"rateLimit": { "perMinute": 12 }
|
|
1259
|
+
},
|
|
1260
|
+
|
|
1261
|
+
{
|
|
1262
|
+
"type": "gem.devtools",
|
|
1263
|
+
"direction": "parentToFrame",
|
|
1264
|
+
"transport": "port",
|
|
1265
|
+
"$comment": [
|
|
1266
|
+
"Open, close or toggle the SDK devtools overlay in the frame. Gated by the",
|
|
1267
|
+
"`devtools` capability.",
|
|
1268
|
+
"",
|
|
1269
|
+
"THE GAME IS NOT CONSULTED. The SDK handles this itself -- it checks",
|
|
1270
|
+
"`allowDevTools` from the last grant, loads the panel artifact and mounts",
|
|
1271
|
+
"it, with no callback, no option and no hook the game has to have wired.",
|
|
1272
|
+
"A developer debugging a game gets the panel whether or not that game's",
|
|
1273
|
+
"author ever thought about devtools, which is the entire point of a",
|
|
1274
|
+
"platform-side trigger.",
|
|
1275
|
+
"",
|
|
1276
|
+
"ONE FIELD, AND IT WILL STAY ONE FIELD. `action` is a closed enum and",
|
|
1277
|
+
"this message must NEVER gain a field that influences where the panel's",
|
|
1278
|
+
"code is fetched from -- no `src`, no `url`, no `version`, no `base`, no",
|
|
1279
|
+
"string of any kind that names a location.",
|
|
1280
|
+
"",
|
|
1281
|
+
"The reason is the same one that made `garden` an enum rather than a URL,",
|
|
1282
|
+
"with higher stakes. Game origins carry `frame-ancestors` and `report-uri`",
|
|
1283
|
+
"only -- no `script-src` -- so Subresource Integrity is the ONLY control",
|
|
1284
|
+
"on the panel's load path. A URL-influencing field here would turn a",
|
|
1285
|
+
"parent into a source of code executing in the game's own origin, in the",
|
|
1286
|
+
"same realm as the live play token. Today the parent can hand the frame a",
|
|
1287
|
+
"CREDENTIAL and cannot hand it CODE, and that asymmetry is load-bearing.",
|
|
1288
|
+
"",
|
|
1289
|
+
"A dedicated build check asserts this field set on every",
|
|
1290
|
+
"build, unconditionally -- deliberately NOT via the freeze rules, which",
|
|
1291
|
+
"return early while `frozen` is false and would therefore enforce nothing",
|
|
1292
|
+
"today.",
|
|
1293
|
+
"",
|
|
1294
|
+
"NOTHING IS SENT BACK. There is no frame-to-parent reply, no `opened`,",
|
|
1295
|
+
"no `closed`, no refusal. An earlier draft carried one so a button in",
|
|
1296
|
+
"gem-web could reset its own state, and that button does not exist -- the",
|
|
1297
|
+
"entry point is a URL parameter acted on once at load. A failure to load",
|
|
1298
|
+
"the panel is reported on the game's own console, which is where a",
|
|
1299
|
+
"developer debugging it is already looking. If a persistent trigger is",
|
|
1300
|
+
"ever built, it pays for that message then."
|
|
1301
|
+
],
|
|
1302
|
+
"fields": {
|
|
1303
|
+
"action": {
|
|
1304
|
+
"kind": "enum",
|
|
1305
|
+
"values": ["open", "close", "toggle"]
|
|
1306
|
+
}
|
|
1307
|
+
},
|
|
1308
|
+
"rateLimit": { "perMinute": 30 }
|
|
1309
|
+
},
|
|
1310
|
+
|
|
1311
|
+
{
|
|
1312
|
+
"type": "gem.screenshot",
|
|
1313
|
+
"direction": "parentToFrame",
|
|
1314
|
+
"transport": "port",
|
|
1315
|
+
"$comment": [
|
|
1316
|
+
"The parent asks the frame for a picture of the running game, so a player",
|
|
1317
|
+
"can attach one to a bug report. Gated by the `screenshot` capability.",
|
|
1318
|
+
"",
|
|
1319
|
+
"THE IMAGE DOES NOT TRAVEL IN THIS MESSAGE, AND MUST NEVER BE ADDED TO IT.",
|
|
1320
|
+
"This message carries two integers. The pixels come back on a MessagePort",
|
|
1321
|
+
"the PARENT mints and transfers alongside it, outside the validated",
|
|
1322
|
+
"grammar entirely. That is not an optimisation, and the reasons are worth",
|
|
1323
|
+
"restating here because the obvious 'just add a field' is wrong three",
|
|
1324
|
+
"times over:",
|
|
1325
|
+
"",
|
|
1326
|
+
"1. IT WOULD NOT FIT. `maxMessageBytes` is 65536 and the generated",
|
|
1327
|
+
" `walk()` charges a string `length * 3 + 2`, so a data URL has a budget",
|
|
1328
|
+
" of roughly 21,800 characters -- about 12 KB of image. A screenshot of",
|
|
1329
|
+
" a detailed scene is 150-300 KB re-encoded and megabytes lossless.",
|
|
1330
|
+
"",
|
|
1331
|
+
"2. THE PROTOTYPE REFUSAL IS THE ALLOCATION BOUND, NOT A STYLE RULE.",
|
|
1332
|
+
" `walk()` rejects any value whose prototype is not `Object.prototype`",
|
|
1333
|
+
" or null, which is what keeps an ArrayBuffer, Blob or ImageBitmap out.",
|
|
1334
|
+
" Those types size as approximately nothing in every accounting this",
|
|
1335
|
+
" protocol has -- no bytes, one node, one depth level -- so a 32768 x",
|
|
1336
|
+
" 32768 bitmap is ~4 GiB resident and passes a 65536-byte cap trivially.",
|
|
1337
|
+
" By the time a field check could run, structured clone has ALREADY",
|
|
1338
|
+
" allocated the payload. Admitting one transferable through the schema",
|
|
1339
|
+
" removes the cap for every message, not just this one.",
|
|
1340
|
+
"",
|
|
1341
|
+
"3. IT IS THREE IMPLEMENTATIONS, NOT ONE. A new field kind has to be",
|
|
1342
|
+
" taught to the generator, to the emitted validator, and to gem-web's",
|
|
1343
|
+
" independently hand-written parent-side validator.",
|
|
1344
|
+
"",
|
|
1345
|
+
"WHY THE PARENT MINTS THE PORT. Three properties fall out of it, and all",
|
|
1346
|
+
"three are load-bearing:",
|
|
1347
|
+
"",
|
|
1348
|
+
"- CORRELATION IS STRUCTURAL. The port IS the request, so there is no",
|
|
1349
|
+
" `requestId` to specify, echo, spoof or test. The parent owns the",
|
|
1350
|
+
" deadline and can `close()` unilaterally.",
|
|
1351
|
+
"- A FRAME WITH NO PORT CANNOT PUSH A SCREENSHOT. Unsolicited image data",
|
|
1352
|
+
" has nowhere to go. Were the FRAME to mint the channel, a hostile game",
|
|
1353
|
+
" could transfer ports carrying multi-hundred-megabyte bitmaps at frame",
|
|
1354
|
+
" rate, and the per-minute rate limiter would not be the thing that",
|
|
1355
|
+
" stopped it.",
|
|
1356
|
+
"- THE PORT CARRIES EXACTLY ONE VALUE AND NO FIELD NAMES: a single",
|
|
1357
|
+
" transferred `ImageBitmap`, after which both ends close. No names means",
|
|
1358
|
+
" no second, undeclared vocabulary growing beside this one. Everything",
|
|
1359
|
+
" NAMED -- the refusal reasons, the dimension caps -- stays here, in",
|
|
1360
|
+
" schema-declared plain data.",
|
|
1361
|
+
"",
|
|
1362
|
+
"THERE IS NO SUCCESS REPLY. The bytes arriving are the acknowledgement.",
|
|
1363
|
+
"A `gem.screenshot.ok` would be a second thing to keep in step with the",
|
|
1364
|
+
"first for no information gained.",
|
|
1365
|
+
"",
|
|
1366
|
+
"THE CAPS ARE A REQUEST, NOT A GUARANTEE. The frame should downscale to",
|
|
1367
|
+
"fit, and refuse `too_large` when it cannot. The parent must size its own",
|
|
1368
|
+
"canvas from its own constants and re-check what actually arrives: these",
|
|
1369
|
+
"numbers travel to an untrusted peer and come back as a suggestion.",
|
|
1370
|
+
"",
|
|
1371
|
+
"NOTHING HERE TELLS THE FRAME WHY. Not that a player opened a feedback",
|
|
1372
|
+
"sheet, not the category they picked, and above all not whether they went",
|
|
1373
|
+
"on to submit. A game that learns a bug report was just filed against it",
|
|
1374
|
+
"can retaliate against that session in ways nobody can observe."
|
|
1375
|
+
],
|
|
1376
|
+
"fields": {
|
|
1377
|
+
"maxWidth": {
|
|
1378
|
+
"kind": "int",
|
|
1379
|
+
"min": 1,
|
|
1380
|
+
"max": 7680,
|
|
1381
|
+
"$comment": "8K wide. Generous on purpose -- this bounds the ASK, and the parent's own re-check bounds what it will accept."
|
|
1382
|
+
},
|
|
1383
|
+
"maxHeight": {
|
|
1384
|
+
"kind": "int",
|
|
1385
|
+
"min": 1,
|
|
1386
|
+
"max": 4320
|
|
1387
|
+
}
|
|
1388
|
+
},
|
|
1389
|
+
"rateLimit": { "perMinute": 6 }
|
|
1390
|
+
},
|
|
1391
|
+
|
|
1392
|
+
{
|
|
1393
|
+
"type": "gem.screenshot.denied",
|
|
1394
|
+
"direction": "frameToParent",
|
|
1395
|
+
"transport": "port",
|
|
1396
|
+
"$comment": [
|
|
1397
|
+
"The frame will not supply a screenshot for the request it just received.",
|
|
1398
|
+
"",
|
|
1399
|
+
"WHY A REFUSAL MESSAGE EXISTS AT ALL. A `MessagePort` has no portable",
|
|
1400
|
+
"close event -- the parent cannot observe the frame dropping its end. So",
|
|
1401
|
+
"the parent's wait is settled by exactly three things: bytes on the port,",
|
|
1402
|
+
"this message, or its own deadline. Without this, EVERY refusal costs the",
|
|
1403
|
+
"player the full timeout staring at a spinner, and every refusal looks",
|
|
1404
|
+
"identical to a game that crashed.",
|
|
1405
|
+
"",
|
|
1406
|
+
"THIS IS gem-web-sdk's OWN RECORDED LESSON, applied one message earlier:",
|
|
1407
|
+
"a refused dedicated offer must be refused on the channel the caller is",
|
|
1408
|
+
"waiting on, because a `gem.error` reaches a different handler and leaves",
|
|
1409
|
+
"the waiter to time out anyway.",
|
|
1410
|
+
"",
|
|
1411
|
+
"THE PARENT NEVER RENDERS THESE STRINGS. It renders its own copy, chosen",
|
|
1412
|
+
"from its own vocabulary. A closed enum buys exactly that: the frame",
|
|
1413
|
+
"cannot put text of its choosing in front of the player, and cannot make",
|
|
1414
|
+
"the platform's own error message say something it wrote. `reason` is for",
|
|
1415
|
+
"the developer -- it belongs in the report's diagnostic context.",
|
|
1416
|
+
"",
|
|
1417
|
+
"TWO REASONS ARE DELIBERATELY ABSENT AND MUST STAY ABSENT:",
|
|
1418
|
+
"",
|
|
1419
|
+
"- `timeout`. The frame cannot observe the parent's deadline, so it could",
|
|
1420
|
+
" never honestly send this. A member no sender can produce reads as",
|
|
1421
|
+
" coverage of a case nothing covers.",
|
|
1422
|
+
"- `rate_limited`. That is the per-type limiter's job, and it already has",
|
|
1423
|
+
" a code. See `gem.token.denied`, whose `reason` carries the same",
|
|
1424
|
+
" prohibition for the same reason: a refusal enum that grows to explain",
|
|
1425
|
+
" every failure becomes an oracle."
|
|
1426
|
+
],
|
|
1427
|
+
"fields": {
|
|
1428
|
+
"reason": {
|
|
1429
|
+
"kind": "enum",
|
|
1430
|
+
"values": [
|
|
1431
|
+
"not_wired",
|
|
1432
|
+
"no_source",
|
|
1433
|
+
"unsupported",
|
|
1434
|
+
"unreadable",
|
|
1435
|
+
"tainted",
|
|
1436
|
+
"too_large",
|
|
1437
|
+
"busy",
|
|
1438
|
+
"declined",
|
|
1439
|
+
"failed"
|
|
1440
|
+
],
|
|
1441
|
+
"$comment": [
|
|
1442
|
+
"`not_wired` -- the capability was advertised but nothing implements it.",
|
|
1443
|
+
"`no_source` -- the default strategy found no visible canvas, or one of zero area.",
|
|
1444
|
+
"`unsupported` -- the WebGL context has `preserveDrawingBuffer: false`, or was lost. THE ONE THE FLAG FIXES, and it is kept narrow on purpose: it used to also cover the case below, so a developer whose canvas simply cannot be read was told to set a flag that would change nothing.",
|
|
1445
|
+
"`unreadable` -- this page cannot read that canvas AT ALL: control was transferred to an OffscreenCanvas, or the context is one the SDK has no way to snapshot (WebGPU). No flag fixes it; the game must supply `onScreenshot`.",
|
|
1446
|
+
"`tainted` -- the canvas origin-clean flag is false. THE ONE A DEVELOPER CAN ACT ON (add `crossorigin` to the offending image), so it must stay distinguishable from `failed`.",
|
|
1447
|
+
"`too_large` -- the source exceeds the requested caps and cannot be downscaled.",
|
|
1448
|
+
"`busy` -- a capture is already in flight. Single-flight is enforced by the frame.",
|
|
1449
|
+
"`declined` -- the game's handler returned null. A PRODUCT ANSWER, not an error: a cutscene, licensed art, another player's private board.",
|
|
1450
|
+
"`failed` -- the handler threw, or the encode or transfer failed."
|
|
1451
|
+
]
|
|
1452
|
+
},
|
|
1453
|
+
"retryable": {
|
|
1454
|
+
"kind": "boolean",
|
|
1455
|
+
"$comment": [
|
|
1456
|
+
"Behaviour is decided by THIS, not by `reason` -- the same split",
|
|
1457
|
+
"`gem.token.denied` draws. A parent that switches on the reason enum",
|
|
1458
|
+
"has written a second copy of this classification that drifts from",
|
|
1459
|
+
"the frame's.",
|
|
1460
|
+
"",
|
|
1461
|
+
"The parent must not offer the player a retry when this is false.",
|
|
1462
|
+
"'Try again' on an unwired game fails identically, forever, and",
|
|
1463
|
+
"reads to the player as their own fault."
|
|
1464
|
+
]
|
|
1465
|
+
}
|
|
1466
|
+
},
|
|
1467
|
+
"rateLimit": { "perMinute": 12 }
|
|
1468
|
+
},
|
|
1469
|
+
|
|
1470
|
+
{
|
|
1471
|
+
"type": "gem.requestContext",
|
|
1472
|
+
"direction": "parentToFrame",
|
|
1473
|
+
"transport": "port",
|
|
1474
|
+
"$comment": [
|
|
1475
|
+
"Ask the game for state to attach to a bug report the player is filing",
|
|
1476
|
+
"right now. Answered with `gem.gameContext`. Gated by the",
|
|
1477
|
+
"`feedbackContext` capability.",
|
|
1478
|
+
"",
|
|
1479
|
+
"NO FIELDS, AND IT SHOULD STAY THAT WAY. There is nothing to say: the",
|
|
1480
|
+
"frame knows its own state and the parent has no criteria to offer. A",
|
|
1481
|
+
"field here would be the parent telling a game what to report about",
|
|
1482
|
+
"itself, which it is in no position to do. `gem.ready` is the precedent",
|
|
1483
|
+
"for a message that carries only its own occurrence.",
|
|
1484
|
+
"",
|
|
1485
|
+
"IN PARTICULAR, NOT WHY. This message must never grow a field naming the",
|
|
1486
|
+
"feedback category, the player's typed message, or anything else about",
|
|
1487
|
+
"the report. A game that learns it is being reported — and what for —",
|
|
1488
|
+
"can retaliate against that session unobservably. The frame is told that",
|
|
1489
|
+
"state is wanted and nothing else; see `gem.screenshot`, which carries",
|
|
1490
|
+
"the same prohibition for the same reason.",
|
|
1491
|
+
"",
|
|
1492
|
+
"ASKED, NOT PUSHED, AND THIS REVERSES AN EARLIER DECISION.",
|
|
1493
|
+
"",
|
|
1494
|
+
"The first cut had the game PUSH context whenever its state changed, on",
|
|
1495
|
+
"the argument that the most valuable bug report comes from a game that",
|
|
1496
|
+
"is stuck, and a stuck game cannot answer a request. That argument is",
|
|
1497
|
+
"true and was not enough.",
|
|
1498
|
+
"",
|
|
1499
|
+
"What it ignored is the cost on the game. Useful context is debug state —",
|
|
1500
|
+
"an entity dump, a scene graph summary, a ring buffer of recent events —",
|
|
1501
|
+
"and gathering it can be genuinely expensive. A push model makes a game",
|
|
1502
|
+
"pay that cost continuously, on every level transition for the whole",
|
|
1503
|
+
"session, to produce a value that is read only if a player happens to",
|
|
1504
|
+
"file a report. That is a bad trade offered to every game, in exchange",
|
|
1505
|
+
"for a better answer in one case.",
|
|
1506
|
+
"",
|
|
1507
|
+
"So the game registers a provider and the platform calls it once, when a",
|
|
1508
|
+
"player actually asks. A game whose main loop is wedged contributes no",
|
|
1509
|
+
"context and the report goes without it — an accepted loss, and the",
|
|
1510
|
+
"screenshot is unavailable in that case too.",
|
|
1511
|
+
"",
|
|
1512
|
+
"THE PARENT OWNS THE DEADLINE, because the frame cannot be trusted to",
|
|
1513
|
+
"answer and a player must not wait on a game that will not. There is no",
|
|
1514
|
+
"`timeout` anywhere in this exchange for the same reason it is absent",
|
|
1515
|
+
"from `gem.screenshot.denied`: the frame cannot observe the parent's",
|
|
1516
|
+
"clock, so it could never honestly report one."
|
|
1517
|
+
],
|
|
1518
|
+
"fields": {},
|
|
1519
|
+
"rateLimit": { "perMinute": 12 }
|
|
1520
|
+
},
|
|
1521
|
+
|
|
1522
|
+
{
|
|
1523
|
+
"type": "gem.gameContext",
|
|
1524
|
+
"direction": "frameToParent",
|
|
1525
|
+
"transport": "port",
|
|
1526
|
+
"$comment": [
|
|
1527
|
+
"The game's answer to `gem.requestContext`: state worth attaching to the",
|
|
1528
|
+
"report the player is filing. The level, the seed, the build, the wave,",
|
|
1529
|
+
"whatever makes a report actionable instead of 'it broke'. Gated by the",
|
|
1530
|
+
"`feedbackContext` capability.",
|
|
1531
|
+
"",
|
|
1532
|
+
"AN ANSWER, NOT AN ANNOUNCEMENT. A parent accepts this only while it has",
|
|
1533
|
+
"a request outstanding, and drops it otherwise — so an unsolicited one",
|
|
1534
|
+
"cannot fill the parent's slot, and a game cannot spend this type's",
|
|
1535
|
+
"budget writing junk at a parent that is not asking. Correlation is",
|
|
1536
|
+
"positional: one request outstanding at a time, and the parent's own",
|
|
1537
|
+
"deadline retires it.",
|
|
1538
|
+
"",
|
|
1539
|
+
"THE PARENT MUST NEST THIS UNDER AN UNTRUSTED KEY, and gem-api's own",
|
|
1540
|
+
"house rule is the reason: text authored by another party is isolated",
|
|
1541
|
+
"structurally rather than by naming convention, so a reader can render",
|
|
1542
|
+
"everything beneath one key inertly without enumerating which fields are",
|
|
1543
|
+
"attacker-reachable. This blob is game-authored and lands on a row",
|
|
1544
|
+
"attributed to the PLAYER who submitted it, readable by that game's",
|
|
1545
|
+
"developers and by support for 180 days. Flattened in beside the",
|
|
1546
|
+
"platform's own diagnostics it would be indistinguishable from them —",
|
|
1547
|
+
"and a game setting `locale` would overwrite what the platform actually",
|
|
1548
|
+
"observed.",
|
|
1549
|
+
"",
|
|
1550
|
+
"NOT A TELEMETRY CHANNEL. It is gathered when a player chooses to send a",
|
|
1551
|
+
"report and at no other time — no report, no call, no blob. A game",
|
|
1552
|
+
"wanting analytics has gem-api for that, with its own consent story.",
|
|
1553
|
+
"",
|
|
1554
|
+
"WHAT NOT TO PUT IN IT. Anything about a person: another player's name,",
|
|
1555
|
+
"a chat log, an email, an account id. The player submitting the report",
|
|
1556
|
+
"does not author this and cannot see it, and support reads it. Game",
|
|
1557
|
+
"state only."
|
|
1558
|
+
],
|
|
1559
|
+
"fields": {
|
|
1560
|
+
"context": {
|
|
1561
|
+
"kind": "json",
|
|
1562
|
+
"$comment": [
|
|
1563
|
+
"Free-form and game-owned. Bounded by the message-level walk rather",
|
|
1564
|
+
"than by a field list — the same treatment `gem.signalEvent.data`",
|
|
1565
|
+
"gets, and for the same reason: the vocabulary belongs to another",
|
|
1566
|
+
"system, in this case the game's own.",
|
|
1567
|
+
"",
|
|
1568
|
+
"The walk's bound is TIGHTER than gem-api's on the column it ends up",
|
|
1569
|
+
"in (65536 charged with a 3x string multiplier, depth 8, against",
|
|
1570
|
+
"gem-api's 65536 serialized bytes and depth 16), so a blob that",
|
|
1571
|
+
"crosses this bridge is already inside what the API will accept —",
|
|
1572
|
+
"which is the correct direction for the two limits to differ. The",
|
|
1573
|
+
"parent still re-measures after nesting, because nesting adds levels",
|
|
1574
|
+
"and its own fields."
|
|
1575
|
+
]
|
|
1576
|
+
}
|
|
1577
|
+
},
|
|
1578
|
+
"rateLimit": { "perMinute": 12 }
|
|
1579
|
+
},
|
|
1580
|
+
|
|
1581
|
+
{
|
|
1582
|
+
"type": "gem.signal",
|
|
1583
|
+
"direction": "frameToParent",
|
|
1584
|
+
"transport": "port",
|
|
1585
|
+
"$comment": [
|
|
1586
|
+
"One P2P signaling message the frame is asking the parent to send on the",
|
|
1587
|
+
"player's notification WebSocket. Gated by the `p2p` capability.",
|
|
1588
|
+
"",
|
|
1589
|
+
"THIS IS THE HIGHEST-RISK MESSAGE IN THE BRIDGE and the reason is one",
|
|
1590
|
+
"sentence: the socket belongs to the PLAYER, not to the game, and it",
|
|
1591
|
+
"carries friend requests, party invites and purchase receipts. A frame",
|
|
1592
|
+
"that could ask the parent to send an arbitrary frame on it would have",
|
|
1593
|
+
"escaped the sandbox.",
|
|
1594
|
+
"",
|
|
1595
|
+
"So the parent treats `signal` and `data` as a REQUEST, never as a frame",
|
|
1596
|
+
"to forward. It selects a template by `signal`, copies only the fields",
|
|
1597
|
+
"that template names, derives `game_id` from the token IT issued, and",
|
|
1598
|
+
"refuses any `matchId` it has not itself seen on this player's socket.",
|
|
1599
|
+
"Nothing here is trusted beyond routing, which is DESIGN.md §9's",
|
|
1600
|
+
"invariant and the reason a frame->parent field can be added safely at",
|
|
1601
|
+
"all.",
|
|
1602
|
+
"",
|
|
1603
|
+
"It is metered and size-capped at the parent too. gem-api rate-limits the",
|
|
1604
|
+
"P2P routes, but a frame that exhausted the PLAYER's server-side budget",
|
|
1605
|
+
"would be denying service to the player rather than to itself, so this",
|
|
1606
|
+
"side cannot lean on that as its only defence. gem-api also caps a client",
|
|
1607
|
+
"WebSocket frame at 16384 bytes, which is below what this message may",
|
|
1608
|
+
"legally carry — `data` is arbitrary JSON — so the parent measures the",
|
|
1609
|
+
"built frame against that ceiling, because a message the socket will",
|
|
1610
|
+
"refuse is one there is no point sending.",
|
|
1611
|
+
"",
|
|
1612
|
+
"The per-minute limit is high on purpose: ICE candidate trickle is bursty",
|
|
1613
|
+
"and a star-topology host signals with every peer at once. It is still",
|
|
1614
|
+
"below the socket's own 30/second ceiling."
|
|
1615
|
+
],
|
|
1616
|
+
"fields": {
|
|
1617
|
+
"signal": {
|
|
1618
|
+
"kind": "enum",
|
|
1619
|
+
"values": ["join", "offer", "answer", "ice", "migrationAck", "hostUnreachable"],
|
|
1620
|
+
"$comment": "Which template the parent applies. An unrecognised value cannot reach here — the generated validator refuses it — and an unrecognised one at the parent is a drop."
|
|
1621
|
+
},
|
|
1622
|
+
"matchId": {
|
|
1623
|
+
"kind": "string",
|
|
1624
|
+
"maxLength": 64,
|
|
1625
|
+
"$comment": "Checked against the matches the PARENT has seen on this player's socket. A frame naming any other match is refused; the socket delivers only this player's events, so a match id learned there is one the player is genuinely party to."
|
|
1626
|
+
},
|
|
1627
|
+
"data": {
|
|
1628
|
+
"kind": "json",
|
|
1629
|
+
"$comment": "The request's arguments. Read field by field by the parent's template; anything the template does not name is dropped rather than forwarded."
|
|
1630
|
+
}
|
|
1631
|
+
},
|
|
1632
|
+
"rateLimit": { "perMinute": 1200 }
|
|
1633
|
+
},
|
|
1634
|
+
|
|
1635
|
+
{
|
|
1636
|
+
"type": "gem.dedicatedOffer",
|
|
1637
|
+
"direction": "frameToParent",
|
|
1638
|
+
"transport": "port",
|
|
1639
|
+
"$comment": [
|
|
1640
|
+
"The one complete SDP offer a frame asks the parent to put on the",
|
|
1641
|
+
"player's notification socket, for the dedicated server holding this",
|
|
1642
|
+
"match. Gated by the `dedicated` capability.",
|
|
1643
|
+
"",
|
|
1644
|
+
"ONE MESSAGE TYPE PER TEMPLATE, rather than gem.signal's template-name",
|
|
1645
|
+
"enum. The frame still names no socket message type and still supplies no",
|
|
1646
|
+
"`game_id` — the parent fills both in — but a second template here would",
|
|
1647
|
+
"be a new MESSAGE TYPE, which the freeze permits, where a second enum",
|
|
1648
|
+
"value would not be. gem.signal predates that consideration and carries",
|
|
1649
|
+
"its six templates in a closed enum it can no longer grow.",
|
|
1650
|
+
"",
|
|
1651
|
+
"The parent's obligations are gem.signal's, unchanged: meter it, cap the",
|
|
1652
|
+
"built frame at what the socket will accept, and derive `game_id` from",
|
|
1653
|
+
"the token it issued. It does NOT pin the match id, and that is the one",
|
|
1654
|
+
"difference — see below.",
|
|
1655
|
+
"",
|
|
1656
|
+
"WHY THE MATCH IS NOT PINNED. gem.signal refuses a `matchId` the parent",
|
|
1657
|
+
"has not seen on the player's socket, exempting only `join` because a",
|
|
1658
|
+
"player joining a room already in a match learns the id by reading the",
|
|
1659
|
+
"room rather than from a push. This message is that same case with no",
|
|
1660
|
+
"join ahead of it: the offer IS the first message on this path, so a pin",
|
|
1661
|
+
"would make 'join a match already running' unconnectable outright.",
|
|
1662
|
+
"",
|
|
1663
|
+
"What replaces the pin is gem-api's own gate, which is stronger here than",
|
|
1664
|
+
"on the P2P path: the offer's room is resolved from the ALLOCATION, never",
|
|
1665
|
+
"from the message, and membership of that room is checked before the",
|
|
1666
|
+
"offer is stored or published. A frame naming a match it is not in is",
|
|
1667
|
+
"refused with `not_match_member`, and the offer reaches nobody — unlike a",
|
|
1668
|
+
"P2P offer, which is relayed to another player. So the exemption costs a",
|
|
1669
|
+
"refused probe of a question the frame's own game-scoped token can",
|
|
1670
|
+
"already ask through match status, bounded by the parent's per-second",
|
|
1671
|
+
"socket budget.",
|
|
1672
|
+
"",
|
|
1673
|
+
"NON-TRICKLE, one offer, gathered to completion — the platform design's",
|
|
1674
|
+
"choice, and the reason there is no candidate message beside this one.",
|
|
1675
|
+
"It fits: gem-api raised its client frame ceiling to 16 KB specifically",
|
|
1676
|
+
"so a complete SDP plus its JSON envelope would pass."
|
|
1677
|
+
],
|
|
1678
|
+
"fields": {
|
|
1679
|
+
"matchId": {
|
|
1680
|
+
"kind": "string",
|
|
1681
|
+
"maxLength": 64,
|
|
1682
|
+
"$comment": "The match this offer is for. Sent by the parent as `match_id`; never read out of any other field."
|
|
1683
|
+
},
|
|
1684
|
+
"sdp": {
|
|
1685
|
+
"kind": "string",
|
|
1686
|
+
"maxLength": 10240,
|
|
1687
|
+
"$comment": "One complete offer. gem-api's configured ceiling, which it validates structurally and relays verbatim — it reads no candidate."
|
|
1688
|
+
},
|
|
1689
|
+
"intent": {
|
|
1690
|
+
"kind": "enum",
|
|
1691
|
+
"values": ["initial", "ice_restart", "full_reconnect"],
|
|
1692
|
+
"$comment": "gem-api's `signaling_intent`, carried unchanged from the P2P path. `full_reconnect` is what rotates the session's pinned DTLS fingerprint, so a reconnecting peer that reuses `initial` is refused with `fingerprint_mismatch`."
|
|
1693
|
+
}
|
|
1694
|
+
},
|
|
1695
|
+
"rateLimit": { "perMinute": 60 }
|
|
1696
|
+
},
|
|
1697
|
+
|
|
1698
|
+
{
|
|
1699
|
+
"type": "gem.partyRequest",
|
|
1700
|
+
"direction": "frameToParent",
|
|
1701
|
+
"transport": "port",
|
|
1702
|
+
"$comment": [
|
|
1703
|
+
"Ask the parent to resolve the local player's current party. Gated by the",
|
|
1704
|
+
"`parties` capability.",
|
|
1705
|
+
"",
|
|
1706
|
+
"The frame cannot call a single party route: every one of them takes an",
|
|
1707
|
+
"account-scoped player token and the frame holds a game-scoped one. That",
|
|
1708
|
+
"is the same boundary that keeps the frame off the notification socket,",
|
|
1709
|
+
"and it is not a policy this bridge can widen.",
|
|
1710
|
+
"",
|
|
1711
|
+
"Sent by the room surface when a create/join/quick-join is made without an",
|
|
1712
|
+
"explicit party id. Every",
|
|
1713
|
+
"failure — no party, a party this player does not lead, a parent that does",
|
|
1714
|
+
"not implement `parties`, a request that times out — means the same thing",
|
|
1715
|
+
"and is never an error: proceed solo."
|
|
1716
|
+
],
|
|
1717
|
+
"fields": {
|
|
1718
|
+
"requestId": {
|
|
1719
|
+
"kind": "string",
|
|
1720
|
+
"maxLength": 64,
|
|
1721
|
+
"$comment": "Opaque and frame-chosen. The parent must treat it as an echo-only correlator, exactly as it does gem.requestToken's."
|
|
1722
|
+
}
|
|
1723
|
+
},
|
|
1724
|
+
"rateLimit": { "perMinute": 12 }
|
|
1725
|
+
},
|
|
1726
|
+
|
|
1727
|
+
{
|
|
1728
|
+
"type": "gem.ready",
|
|
1729
|
+
"direction": "frameToParent",
|
|
1730
|
+
"transport": "port",
|
|
1731
|
+
"$comment": [
|
|
1732
|
+
"REQUIRED contract for the `web` platform. The parent shows its loading",
|
|
1733
|
+
"state until this arrives; a game that never sends it never becomes",
|
|
1734
|
+
"playable, and the conformance suite checks for it."
|
|
1735
|
+
],
|
|
1736
|
+
"fields": {},
|
|
1737
|
+
"rateLimit": { "perMinute": 5 }
|
|
1738
|
+
},
|
|
1739
|
+
|
|
1740
|
+
{
|
|
1741
|
+
"type": "gem.progress",
|
|
1742
|
+
"direction": "frameToParent",
|
|
1743
|
+
"transport": "port",
|
|
1744
|
+
"$comment": [
|
|
1745
|
+
"Load progress, for the parent's progress UI. `total` of 0 means",
|
|
1746
|
+
"indeterminate -- a game that cannot know its total up front says so",
|
|
1747
|
+
"rather than reporting a fake denominator."
|
|
1748
|
+
],
|
|
1749
|
+
"fields": {
|
|
1750
|
+
"loaded": { "kind": "int", "min": 0, "max": 1099511627776 },
|
|
1751
|
+
"total": { "kind": "int", "min": 0, "max": 1099511627776 }
|
|
1752
|
+
},
|
|
1753
|
+
"rateLimit": { "perMinute": 600 }
|
|
1754
|
+
},
|
|
1755
|
+
|
|
1756
|
+
{
|
|
1757
|
+
"type": "gem.requestToken",
|
|
1758
|
+
"direction": "frameToParent",
|
|
1759
|
+
"transport": "port",
|
|
1760
|
+
"$comment": [
|
|
1761
|
+
"Ask for a fresh play token. Rate-limited on BOTH the parent and the",
|
|
1762
|
+
"endpoint: each call is an RS256 signature plus a database round trip.",
|
|
1763
|
+
"",
|
|
1764
|
+
"`requestId` exists because there was previously no way to tell which",
|
|
1765
|
+
"gem.token answered which ask: with a 12/min limit and N concurrent 401s,",
|
|
1766
|
+
"an uncorrelated reply cannot resolve the right caller. It is opaque and",
|
|
1767
|
+
"frame-chosen, so the PARENT must treat it as an echo-only correlator and",
|
|
1768
|
+
"derive its own request identity — never trust it for anything but routing."
|
|
1769
|
+
],
|
|
1770
|
+
"fields": {
|
|
1771
|
+
"requestId": { "kind": "string", "maxLength": 64 },
|
|
1772
|
+
"reason": {
|
|
1773
|
+
"kind": "enum",
|
|
1774
|
+
"values": ["initial", "expiring", "rejected"],
|
|
1775
|
+
"$comment": "Why the frame is asking. `expiring` is the proactive renewal at 75% TTL; `rejected` follows a 401 and is the one that must not loop."
|
|
1776
|
+
}
|
|
1777
|
+
},
|
|
1778
|
+
"rateLimit": { "perMinute": 12 }
|
|
1779
|
+
},
|
|
1780
|
+
|
|
1781
|
+
{
|
|
1782
|
+
"type": "gem.requestLocalServer",
|
|
1783
|
+
"direction": "frameToParent",
|
|
1784
|
+
"transport": "port",
|
|
1785
|
+
"$comment": [
|
|
1786
|
+
"Ask the embedder to run this game's own dedicated server, on this",
|
|
1787
|
+
"machine, for a room the frame has already created. Gated by the",
|
|
1788
|
+
"`localHost` capability, which only a development tool advertises.",
|
|
1789
|
+
"",
|
|
1790
|
+
"WHY IT GOES THROUGH THE PARENT AT ALL. A dedicated match is served by a",
|
|
1791
|
+
"PROCESS, and a frame cannot start one. The embedder can: the Project",
|
|
1792
|
+
"Editor is a desktop application sitting on the developer's own machine,",
|
|
1793
|
+
"already holding their signed-in identity. So the frame asks and the",
|
|
1794
|
+
"parent hosts, exactly as it asks and the parent signals.",
|
|
1795
|
+
"",
|
|
1796
|
+
"WHAT THE FRAME SUPPLIES IS A ROOM ID AND NOTHING ELSE. Not an address,",
|
|
1797
|
+
"not a port, not a command, not a bundle path. Everything about WHAT to",
|
|
1798
|
+
"run and WHERE it listens is the parent's, resolved from the project it",
|
|
1799
|
+
"already has open — a frame that could name any of them would be asking a",
|
|
1800
|
+
"desktop application to run something of its choosing.",
|
|
1801
|
+
"",
|
|
1802
|
+
"THE ROOM ID IS NOT TRUSTED, only routed. The parent registers with",
|
|
1803
|
+
"gem-api using its own developer credential, and gem-api refuses a room",
|
|
1804
|
+
"the registering developer does not host. So a frame naming somebody",
|
|
1805
|
+
"else's room gets a refusal from the platform rather than a server, and",
|
|
1806
|
+
"the parent needs no room model of its own to be safe.",
|
|
1807
|
+
"",
|
|
1808
|
+
"ANSWERED BY `gem.localServer` OR `gem.localServer.denied`, both echoing",
|
|
1809
|
+
"`requestId`. A denial is mandatory rather than optional: starting a",
|
|
1810
|
+
"server involves spawning a process and a network round trip, either of",
|
|
1811
|
+
"which can fail, and without an answer the frame waits out a timeout and",
|
|
1812
|
+
"reports a hang instead of the reason."
|
|
1813
|
+
],
|
|
1814
|
+
"fields": {
|
|
1815
|
+
"requestId": { "kind": "string", "maxLength": 64 },
|
|
1816
|
+
"gameRoomId": { "kind": "string", "maxLength": 64 }
|
|
1817
|
+
},
|
|
1818
|
+
"rateLimit": { "perMinute": 6 }
|
|
1819
|
+
},
|
|
1820
|
+
|
|
1821
|
+
{
|
|
1822
|
+
"type": "gem.localServer",
|
|
1823
|
+
"direction": "parentToFrame",
|
|
1824
|
+
"transport": "port",
|
|
1825
|
+
"$comment": [
|
|
1826
|
+
"A server is running on this machine and the platform has been told it",
|
|
1827
|
+
"serves the room the frame named. Settles `gem.requestLocalServer`.",
|
|
1828
|
+
"",
|
|
1829
|
+
"IT CARRIES NO ADDRESS, and that is the same rule the platform side",
|
|
1830
|
+
"keeps: the server publishes where it can be reached by writing the",
|
|
1831
|
+
"room's own settings, which is where the SDK already reads a match's",
|
|
1832
|
+
"transport advert. There is exactly one place a developer's address can",
|
|
1833
|
+
"be wrong, and it is not this message.",
|
|
1834
|
+
"",
|
|
1835
|
+
"THE FRAME MUST WAIT FOR THIS BEFORE STARTING THE MATCH. Registering",
|
|
1836
|
+
"binds the room; starting the match is what consumes the binding. A match",
|
|
1837
|
+
"started first allocates a fleet server, and the room is then serving from",
|
|
1838
|
+
"a pod while a local runner waits for players who will never arrive —",
|
|
1839
|
+
"which looks like the local server being broken."
|
|
1840
|
+
],
|
|
1841
|
+
"fields": {
|
|
1842
|
+
"requestId": { "kind": "string", "maxLength": 64 },
|
|
1843
|
+
"serverId": {
|
|
1844
|
+
"kind": "string",
|
|
1845
|
+
"maxLength": 128,
|
|
1846
|
+
"optional": true,
|
|
1847
|
+
"$comment": "What the platform knows the runner as, for the frame's logs. Diagnostic only — nothing in the SDK branches on it."
|
|
1848
|
+
}
|
|
1849
|
+
},
|
|
1850
|
+
"rateLimit": { "perMinute": 30 }
|
|
1851
|
+
},
|
|
1852
|
+
|
|
1853
|
+
{
|
|
1854
|
+
"type": "gem.localServer.denied",
|
|
1855
|
+
"direction": "parentToFrame",
|
|
1856
|
+
"transport": "port",
|
|
1857
|
+
"$comment": [
|
|
1858
|
+
"No server was started. Settles `gem.requestLocalServer`.",
|
|
1859
|
+
"",
|
|
1860
|
+
"TWO REASONS, AND THE SPLIT IS THE ONE A DEVELOPER ACTS ON. `refused`",
|
|
1861
|
+
"means the PLATFORM said no — the wrong role, the wrong channel, a room",
|
|
1862
|
+
"this developer does not host — and asking again changes nothing.",
|
|
1863
|
+
"`unavailable` means this machine could not host right now: not signed",
|
|
1864
|
+
"in, the project has no server bundle, the process would not start. Only",
|
|
1865
|
+
"the second is worth retrying, which is what `retryable` says.",
|
|
1866
|
+
"",
|
|
1867
|
+
"Deliberately coarser than the parent's own diagnosis. The parent is the",
|
|
1868
|
+
"developer's own editor and can say far more in ITS log, where the",
|
|
1869
|
+
"developer is looking; a wider enum here would freeze that vocabulary",
|
|
1870
|
+
"into the protocol for every future host."
|
|
1871
|
+
],
|
|
1872
|
+
"fields": {
|
|
1873
|
+
"requestId": { "kind": "string", "maxLength": 64 },
|
|
1874
|
+
"retryable": { "kind": "boolean" },
|
|
1875
|
+
"reason": {
|
|
1876
|
+
"kind": "enum",
|
|
1877
|
+
"values": ["refused", "unavailable"],
|
|
1878
|
+
"$comment": "`refused` is the platform's answer and is terminal. `unavailable` is this machine's, and may not be."
|
|
1879
|
+
}
|
|
1880
|
+
},
|
|
1881
|
+
"rateLimit": { "perMinute": 30 }
|
|
1882
|
+
},
|
|
1883
|
+
|
|
1884
|
+
{
|
|
1885
|
+
"type": "gem.exit",
|
|
1886
|
+
"direction": "frameToParent",
|
|
1887
|
+
"transport": "port",
|
|
1888
|
+
"$comment": "The game is done. The parent closes the port and unmounts the frame.",
|
|
1889
|
+
"fields": {
|
|
1890
|
+
"reason": { "kind": "string", "maxLength": 64, "optional": true }
|
|
1891
|
+
},
|
|
1892
|
+
"rateLimit": { "perMinute": 5 }
|
|
1893
|
+
},
|
|
1894
|
+
|
|
1895
|
+
{
|
|
1896
|
+
"type": "gem.openExternal",
|
|
1897
|
+
"direction": "frameToParent",
|
|
1898
|
+
"transport": "port",
|
|
1899
|
+
"$comment": [
|
|
1900
|
+
"Ask the parent to open a URL. The parent decides -- a frame cannot",
|
|
1901
|
+
"navigate the top-level document itself, and must not be able to. The",
|
|
1902
|
+
"parent enforces its own scheme allowlist on top of this validation."
|
|
1903
|
+
],
|
|
1904
|
+
"fields": {
|
|
1905
|
+
"url": { "kind": "string", "maxLength": 2048, "format": "url" }
|
|
1906
|
+
},
|
|
1907
|
+
"rateLimit": { "perMinute": 6 }
|
|
1908
|
+
},
|
|
1909
|
+
|
|
1910
|
+
{
|
|
1911
|
+
"type": "gem.requestFullscreen",
|
|
1912
|
+
"direction": "frameToParent",
|
|
1913
|
+
"transport": "port",
|
|
1914
|
+
"$comment": [
|
|
1915
|
+
"Fullscreen is requested through the parent rather than taken by the frame,",
|
|
1916
|
+
"so the parent's chrome and the browser's user-activation rules stay in one",
|
|
1917
|
+
"place."
|
|
1918
|
+
],
|
|
1919
|
+
"fields": {
|
|
1920
|
+
"enabled": { "kind": "boolean" }
|
|
1921
|
+
},
|
|
1922
|
+
"rateLimit": { "perMinute": 30 }
|
|
1923
|
+
},
|
|
1924
|
+
|
|
1925
|
+
{
|
|
1926
|
+
"type": "gem.audioState",
|
|
1927
|
+
"direction": "frameToParent",
|
|
1928
|
+
"transport": "port",
|
|
1929
|
+
"$comment": [
|
|
1930
|
+
"The game's OWN mute changed — the player pressed the mute in the game's",
|
|
1931
|
+
"menu — so the parent's mute control can show the same thing.",
|
|
1932
|
+
"",
|
|
1933
|
+
"The return half of `gem.prefs.muted`, which carries the parent's control",
|
|
1934
|
+
"the other way. With both, a player has one mute rather than two that",
|
|
1935
|
+
"disagree: pressing either moves both. A parent that mirrors this sets its",
|
|
1936
|
+
"own control to the value and pushes `gem.prefs` as it would for a press of",
|
|
1937
|
+
"its own — an echo the frame must expect, since the value it hears back is",
|
|
1938
|
+
"the one it just sent.",
|
|
1939
|
+
"",
|
|
1940
|
+
"Gated by `audioState`: sent only to a parent that advertised it."
|
|
1941
|
+
],
|
|
1942
|
+
"fields": {
|
|
1943
|
+
"muted": { "kind": "boolean" }
|
|
1944
|
+
},
|
|
1945
|
+
"rateLimit": { "perMinute": 120 }
|
|
1946
|
+
},
|
|
1947
|
+
|
|
1948
|
+
{
|
|
1949
|
+
"type": "gem.rewardCue",
|
|
1950
|
+
"direction": "frameToParent",
|
|
1951
|
+
"transport": "port",
|
|
1952
|
+
"$comment": [
|
|
1953
|
+
"A platform-currency reward is on its way — the game's SERVER has just",
|
|
1954
|
+
"moved (or is about to move) a progression mapped onto a platform currency,",
|
|
1955
|
+
"and the game would like the arcade's wallet to show it landing: a pending",
|
|
1956
|
+
"state now and, when the platform confirms the grant, the currency flying",
|
|
1957
|
+
"from `origin` into the wallet.",
|
|
1958
|
+
"",
|
|
1959
|
+
"A PRESENTATION CUE, NOT A CLAIM. Nothing here is trusted as a number. The",
|
|
1960
|
+
"amount that lands and the player's new total reach the parent from gem-api",
|
|
1961
|
+
"itself (the `platform_grant_fired` socket event, sent only after the grant",
|
|
1962
|
+
"committed); `expected` is shown, if at all, as provisional and time-boxed,",
|
|
1963
|
+
"and a cue the platform never confirms simply dissolves. A frame cannot",
|
|
1964
|
+
"make the wallet say a number the platform did not — which is what lets a",
|
|
1965
|
+
"balance sit in chrome a frame can see at all.",
|
|
1966
|
+
"",
|
|
1967
|
+
"`origin` is where the flight starts, in CSS px of the frame's own",
|
|
1968
|
+
"coordinate space — the same space `gem.viewport` speaks — measured from",
|
|
1969
|
+
"the frame's top-left corner; the parent translates it into its own page.",
|
|
1970
|
+
"Absent, the arcade animates at the wallet alone. Integers, like",
|
|
1971
|
+
"`gem.viewport`'s insets: a fractional pixel is not a placement, and the",
|
|
1972
|
+
"SDK rounds and clamps on the way out. 16384 is a bound, not a size.",
|
|
1973
|
+
"",
|
|
1974
|
+
"Mappings never fire on the dev and test channels (gem-api's rule), so a",
|
|
1975
|
+
"cue there has nothing to confirm. The SDK still sends it; what to show",
|
|
1976
|
+
"off the release channel is the parent's decision.",
|
|
1977
|
+
"",
|
|
1978
|
+
"Gated by `rewardCue`: sent only to a parent that advertised it, which is",
|
|
1979
|
+
"how a game learns (`gem.cueReward` returns false) that it should play its",
|
|
1980
|
+
"own celebration instead."
|
|
1981
|
+
],
|
|
1982
|
+
"fields": {
|
|
1983
|
+
"currencySlug": { "kind": "string", "maxLength": 32 },
|
|
1984
|
+
"expected": { "kind": "int", "min": 1, "max": 1000000000, "optional": true },
|
|
1985
|
+
"origin": {
|
|
1986
|
+
"kind": "object",
|
|
1987
|
+
"optional": true,
|
|
1988
|
+
"fields": {
|
|
1989
|
+
"x": { "kind": "int", "min": 0, "max": 16384 },
|
|
1990
|
+
"y": { "kind": "int", "min": 0, "max": 16384 }
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
},
|
|
1994
|
+
"rateLimit": { "perMinute": 60 }
|
|
1995
|
+
},
|
|
1996
|
+
|
|
1997
|
+
{
|
|
1998
|
+
"type": "gem.frameStats",
|
|
1999
|
+
"direction": "frameToParent",
|
|
2000
|
+
"transport": "port",
|
|
2001
|
+
"$comment": [
|
|
2002
|
+
"How the game is running on this device, measured INSIDE the frame: the",
|
|
2003
|
+
"frames the browser delivered to the game document over the last sample",
|
|
2004
|
+
"window, as a rate and as a 95th-percentile frame time. The Arcade draws",
|
|
2005
|
+
"the readout in its own chrome — the player-facing FPS overlay — and the",
|
|
2006
|
+
"game draws nothing.",
|
|
2007
|
+
"",
|
|
2008
|
+
"WHY THE FRAME MEASURES AND THE PARENT MAY NOT. The game runs in a",
|
|
2009
|
+
"cross-origin iframe, which Chromium renders in its own process on its own",
|
|
2010
|
+
"compositor schedule. A parent counting its OWN requestAnimationFrame",
|
|
2011
|
+
"ticks is timing the arcade document, not the game: a game stuttering at",
|
|
2012
|
+
"25 fps sits under a parent-side counter reading a steady 60. That is",
|
|
2013
|
+
"worse than no counter, so a parent-measured figure is ruled out and the",
|
|
2014
|
+
"frame is the only place the number can come from.",
|
|
2015
|
+
"",
|
|
2016
|
+
"WHAT IT COUNTS. The frame document's own requestAnimationFrame delivery,",
|
|
2017
|
+
"which is exact for an engine that renders on that callback (three.js,",
|
|
2018
|
+
"Phaser, Babylon.js, any engine's web export) and is what the SDK measures",
|
|
2019
|
+
"with no code from the game. A game that renders in a worker through OffscreenCanvas is",
|
|
2020
|
+
"measured on its main thread here, not at its renderer; reporting its own",
|
|
2021
|
+
"figure is a hook this version does not carry.",
|
|
2022
|
+
"",
|
|
2023
|
+
"SENT ONLY WHILE ASKED. Nothing is counted and nothing is sent until",
|
|
2024
|
+
"`gem.prefs.showFrameStats` arrives true, and the counter stops on false",
|
|
2025
|
+
"and while `gem.pause` holds — so the feature is free for every game whose",
|
|
2026
|
+
"player never turns it on. About one message a second while on; the limit",
|
|
2027
|
+
"below is headroom, not the cadence.",
|
|
2028
|
+
"",
|
|
2029
|
+
"COSMETIC, AND NOT A TRUST INPUT. This is the game's own account of",
|
|
2030
|
+
"itself, and the parent renders formatted numbers from it and nothing",
|
|
2031
|
+
"else — no quality gating, no anti-cheat, no entitlement may key on it.",
|
|
2032
|
+
"The receiving side drops it while its toggle is off (a late message, or a",
|
|
2033
|
+
"game sending unasked) and caps the rate at a few Hz on top of this limit,",
|
|
2034
|
+
"so a hostile frame cannot use it to force the parent to re-render.",
|
|
2035
|
+
"Integers, so a figure is bounded by the validator and the parent formats",
|
|
2036
|
+
"rather than parses: a fraction of a frame per second is not a reading a",
|
|
2037
|
+
"player can act on. 1000 and 100000 are bounds, not sizes.",
|
|
2038
|
+
"",
|
|
2039
|
+
"Gated by `frameStats`: sent only to a parent that advertised it."
|
|
2040
|
+
],
|
|
2041
|
+
"fields": {
|
|
2042
|
+
"fps": { "kind": "int", "min": 0, "max": 1000 },
|
|
2043
|
+
"frameTimeP95Ms": { "kind": "int", "min": 0, "max": 100000 }
|
|
2044
|
+
},
|
|
2045
|
+
"rateLimit": { "perMinute": 120 }
|
|
2046
|
+
},
|
|
2047
|
+
|
|
2048
|
+
{
|
|
2049
|
+
"type": "gem.error",
|
|
2050
|
+
"direction": "both",
|
|
2051
|
+
"transport": "port",
|
|
2052
|
+
"$comment": "Either side reporting a protocol-level failure. Never carries payload data.",
|
|
2053
|
+
"fields": {
|
|
2054
|
+
"code": {
|
|
2055
|
+
"kind": "enum",
|
|
2056
|
+
"values": [
|
|
2057
|
+
"version_mismatch",
|
|
2058
|
+
"handshake_timeout",
|
|
2059
|
+
"handshake_duplicate",
|
|
2060
|
+
"invalid_message",
|
|
2061
|
+
"rate_limited",
|
|
2062
|
+
"token_unavailable",
|
|
2063
|
+
"unsupported",
|
|
2064
|
+
"unsupported_capability",
|
|
2065
|
+
"internal"
|
|
2066
|
+
]
|
|
2067
|
+
},
|
|
2068
|
+
"message": { "kind": "string", "maxLength": 256, "optional": true }
|
|
2069
|
+
},
|
|
2070
|
+
"rateLimit": { "perMinute": 60 }
|
|
2071
|
+
},
|
|
2072
|
+
{
|
|
2073
|
+
"type": "gem.requestPurchase",
|
|
2074
|
+
"direction": "frameToParent",
|
|
2075
|
+
"transport": "port",
|
|
2076
|
+
"$comment": [
|
|
2077
|
+
"Ask the parent to buy one of THIS GAME's items or recipes with platform",
|
|
2078
|
+
"currency, on the player's behalf. Gated by the `purchases` capability.",
|
|
2079
|
+
"",
|
|
2080
|
+
"WHY THIS CROSSES THE BRIDGE AT ALL. Calling `gem.purchases.request` chooses",
|
|
2081
|
+
"the platform-owned flow: the parent renders its purchase sheet and submits",
|
|
2082
|
+
"the confirmed order with the player's account session. A game that owns its",
|
|
2083
|
+
"own purchase UI uses `gem.me.mutations.purchase` instead; that direct SDK",
|
|
2084
|
+
"request does not create this message or invoke the parent overlay.",
|
|
2085
|
+
"",
|
|
2086
|
+
"ONLY THE SPEND CROSSES. The catalog does NOT: a game-scoped token MAY read",
|
|
2087
|
+
"`/v1/inventory/platform-funded-game-inventory-purchase-catalog`, deliberately.",
|
|
2088
|
+
"Your game needs its own prices to render a shop, and withholding them would",
|
|
2089
|
+
"not stop a convincing imitation anyway - a game knows its own items. So build",
|
|
2090
|
+
"the shop from your own catalog read; only the money moves through here.",
|
|
2091
|
+
"",
|
|
2092
|
+
"NO PRICE IS SENT, deliberately. The parent re-fetches the authoritative price",
|
|
2093
|
+
"when it renders the prompt. A price the frame supplied would be a price the",
|
|
2094
|
+
"frame chose.",
|
|
2095
|
+
"",
|
|
2096
|
+
"The parent renders the confirmation in ITS OWN chrome, from platform records,",
|
|
2097
|
+
"never from anything in this message. That is what makes the prompt something",
|
|
2098
|
+
"a game cannot forge.",
|
|
2099
|
+
"",
|
|
2100
|
+
"WHAT THE PARENT OWES, none of which this schema can enforce. An embedder",
|
|
2101
|
+
"that advertises `purchases` without",
|
|
2102
|
+
"the following is not offering a degraded store, it is offering a game a",
|
|
2103
|
+
"button that charges a player:",
|
|
2104
|
+
"",
|
|
2105
|
+
" EARNED CURRENCIES ONLY. `currencySlug` is frame-chosen and this schema does",
|
|
2106
|
+
" not constrain which currency it names. The argument that unblocked shipping",
|
|
2107
|
+
" this without a top-up flow binds a currency a player EARNS - short of",
|
|
2108
|
+
" Gems, the answer is to go and play. It does not bind one they BUY. Check",
|
|
2109
|
+
" the requested currency against a server-side allowlist of earnable",
|
|
2110
|
+
" currencies and refuse the rest; on the day an item carries a price in both",
|
|
2111
|
+
" Gems and Coins, an unchecked slug lets the frame choose which wallet to",
|
|
2112
|
+
" drain, and the safety argument lapses with no code change and no review.",
|
|
2113
|
+
"",
|
|
2114
|
+
" THE FRAME CHOOSES THE AMOUNT. Not the unit price - but `targetId` x",
|
|
2115
|
+
" `currencySlug` x `quantity` is nevertheless the total, up to ten times the",
|
|
2116
|
+
" most expensive item in a catalog the game itself publishes. Render unit",
|
|
2117
|
+
" price, quantity and total distinctly so a 10x multiplier is never hidden",
|
|
2118
|
+
" behind one figure.",
|
|
2119
|
+
"",
|
|
2120
|
+
" ONE PROMPT AT A TIME, and not instantly actionable. A gem.requestPurchase",
|
|
2121
|
+
" arriving while a prompt is open is answered `declined` without rendering",
|
|
2122
|
+
" anything. Twelve modals a minute is inside the rate limit below and is a",
|
|
2123
|
+
" successful consent-fatigue attack; and because the frame chooses the moment",
|
|
2124
|
+
" it asks, a confirm control that is actionable the instant it paints is a",
|
|
2125
|
+
" tap-jacking target - quiet input on it briefly after paint.",
|
|
2126
|
+
"",
|
|
2127
|
+
" ONE PURCHASE PER ASK. Each gem.requestPurchase is a distinct purchase and",
|
|
2128
|
+
" carries its own idempotency key. Do NOT collapse repeated asks: a player",
|
|
2129
|
+
" who buys a potion, drinks it and buys another has made two purchases, and",
|
|
2130
|
+
" answering the second from the first's record charges once and grants",
|
|
2131
|
+
" nothing - which the player watched themselves agree to. Repeat-buying a",
|
|
2132
|
+
" consumable is the most ordinary thing a shop does.",
|
|
2133
|
+
"",
|
|
2134
|
+
" THE COST OF THAT IS ACCEPTED AND NAMED: if a purchaseResult is lost - the",
|
|
2135
|
+
" tab closes mid-flight - a game that asks again is asking for a second",
|
|
2136
|
+
" purchase and is charged for one. The window is small (the answer is sent",
|
|
2137
|
+
" the moment the spend returns, and an ask from a frame that cannot receive",
|
|
2138
|
+
" the answer is refused before anything is prompted or spent), and the",
|
|
2139
|
+
" alternative silently breaks the common case to protect the rare one.",
|
|
2140
|
+
"",
|
|
2141
|
+
" METER IT IN MONEY, SERVER-SIDE. The `perMinute` below binds a BUGGY game,",
|
|
2142
|
+
" not a hostile one: a hostile game does not call the SDK, it posts on the",
|
|
2143
|
+
" port it holds. The limit that matters is spend velocity per player per game,",
|
|
2144
|
+
" enforced where the account session is, so a malicious game cannot walk a",
|
|
2145
|
+
" wallet to zero one confirmed prompt at a time.",
|
|
2146
|
+
"",
|
|
2147
|
+
" TREAT EVERY FIELD AS UNTRUSTED TEXT. The generated validator checks type",
|
|
2148
|
+
" and length and nothing else - these strings may carry `../`, `%2e%2e`, NUL,",
|
|
2149
|
+
" newlines, RTL overrides and astral Unicode. gem.player.untrusted states the",
|
|
2150
|
+
" same duty for text going the other way; it applies at least as strongly to",
|
|
2151
|
+
" text entering the side that holds the money. Match ids against records you",
|
|
2152
|
+
" fetched, never interpolate one into a path, a query or a log line, and never",
|
|
2153
|
+
" echo one back to a player.",
|
|
2154
|
+
"",
|
|
2155
|
+
" A DEV EMBEDDER IS NOT AUTHENTICATED. On loopback the frame accepts any",
|
|
2156
|
+
" loopback embedder, so during development anything local can play parent and",
|
|
2157
|
+
" answer ok without spending. That is inert in production by structure, and it",
|
|
2158
|
+
" is one more reason a game re-reads inventory on ok rather than granting",
|
|
2159
|
+
" locally."
|
|
2160
|
+
],
|
|
2161
|
+
"fields": {
|
|
2162
|
+
"requestId": {
|
|
2163
|
+
"kind": "string",
|
|
2164
|
+
"maxLength": 64,
|
|
2165
|
+
"$comment": "Opaque and frame-chosen. The parent treats it as an echo-only correlator and derives its own request identity, exactly as it does for gem.requestToken and gem.partyRequest - it is not unique across tabs or reloads and must never key anything on the money path. It may also contain newlines and control characters, so it is not safe to write into a log line unescaped."
|
|
2166
|
+
},
|
|
2167
|
+
"purchaseKind": {
|
|
2168
|
+
"kind": "enum",
|
|
2169
|
+
"values": ["item", "recipe"],
|
|
2170
|
+
"$comment": "A closed set, so the generated validator refuses anything else at the boundary rather than leaving the parent to interpret it."
|
|
2171
|
+
},
|
|
2172
|
+
"targetId": {
|
|
2173
|
+
"kind": "string",
|
|
2174
|
+
"maxLength": 64,
|
|
2175
|
+
"$comment": "The item-definition or recipe id, from this game's own published config. THE ID, not the slug: the parent matches this against the platform's own catalog, which carries ids and no slugs, so a slug sent here matches nothing and is declined. A game reading its config by slug still sends the `id` off the record it found."
|
|
2176
|
+
},
|
|
2177
|
+
"currencySlug": {
|
|
2178
|
+
"kind": "string",
|
|
2179
|
+
"maxLength": 32,
|
|
2180
|
+
"$comment": "Which platform currency to pay in, e.g. GEMS. The item must carry a price in it; the parent resolves that price itself and refuses when there is none. FRAME-CHOSEN AND UNCONSTRAINED HERE: nothing in this schema limits it to a currency the player earned rather than bought, so the parent checks it against a server-side earned-currency allowlist. See the EARNED CURRENCIES ONLY obligation above - this field is where that whole argument lands."
|
|
2181
|
+
},
|
|
2182
|
+
"quantity": {
|
|
2183
|
+
"kind": "int",
|
|
2184
|
+
"min": 1,
|
|
2185
|
+
"max": 10,
|
|
2186
|
+
"optional": true,
|
|
2187
|
+
"$comment": "Defaults to 1; recipes are always 1. The bound MATCHES THE SERVER's (`quantity_invalid` outside 1..=10) rather than being a number chosen here - a frame-side bound the server does not share would either refuse a purchase the platform would have allowed, or pass one it then rejects with a code the game cannot explain. The TOTAL is computed and displayed by the parent, never by the game."
|
|
2188
|
+
},
|
|
2189
|
+
"catalogId": {
|
|
2190
|
+
"kind": "string",
|
|
2191
|
+
"maxLength": 64,
|
|
2192
|
+
"optional": true,
|
|
2193
|
+
"$comment": "Required for recipes, which are addressed within a catalog. Comes from the game's published config."
|
|
2194
|
+
}
|
|
2195
|
+
},
|
|
2196
|
+
"rateLimit": {
|
|
2197
|
+
"perMinute": 12,
|
|
2198
|
+
"$comment": "Bounds a BUGGY game. NOT a security control: a hostile game posts on the port it holds rather than calling the SDK, so a frame-side limit is one the attacker enforces against itself. See METER IT IN MONEY, SERVER-SIDE above."
|
|
2199
|
+
}
|
|
2200
|
+
},
|
|
2201
|
+
{
|
|
2202
|
+
"type": "gem.purchaseResult",
|
|
2203
|
+
"direction": "parentToFrame",
|
|
2204
|
+
"transport": "port",
|
|
2205
|
+
"$comment": [
|
|
2206
|
+
"The answer to one gem.requestPurchase. Gated by the `purchases` capability.",
|
|
2207
|
+
"",
|
|
2208
|
+
"OUTCOME CARRIES NO REASON, and that is a control rather than an oversight.",
|
|
2209
|
+
"Distinguishing 'the player said no' from 'the player could not afford it'",
|
|
2210
|
+
"tells untrusted game code about the player's wallet, one purchase at a time -",
|
|
2211
|
+
"a catalog priced on a 1/2/4/8 ladder recovers a balance to within one item",
|
|
2212
|
+
"price, free. So a decline is a decline.",
|
|
2213
|
+
"",
|
|
2214
|
+
"EVERY OUTCOME RESOLVES. Only a protocol failure rejects. A player tapping",
|
|
2215
|
+
"Cancel is an ordinary thing that happens in every shop, and rejecting there",
|
|
2216
|
+
"means every game in the catalog logs an unhandled rejection each time somebody",
|
|
2217
|
+
"changes their mind.",
|
|
2218
|
+
"",
|
|
2219
|
+
"HOW A GAME SHOULD READ EACH ONE:",
|
|
2220
|
+
" ok - the purchase completed and the grant is on the player's",
|
|
2221
|
+
" game-scope inventory. Re-read inventory rather than assuming",
|
|
2222
|
+
" what landed; `purchaseId` names the server's record of it.",
|
|
2223
|
+
" declined - return to the shop SILENTLY. No dialog, no 'purchase failed'",
|
|
2224
|
+
" copy. The player already saw the prompt and answered it, and an",
|
|
2225
|
+
" error toast after a deliberate Cancel reads as a bug.",
|
|
2226
|
+
" unavailable - one neutral 'store unavailable'. Never auto-retried; only a",
|
|
2227
|
+
" fresh player action may ask again. AND RE-READ INVENTORY:",
|
|
2228
|
+
" 'unavailable' means no answer arrived, not that nothing",
|
|
2229
|
+
" happened - the parent may have prompted, charged and",
|
|
2230
|
+
" answered while the answer was lost at a gate or a timeout.",
|
|
2231
|
+
" 'declined' is the only outcome that says a purchase",
|
|
2232
|
+
" definitely did not happen."
|
|
2233
|
+
],
|
|
2234
|
+
"fields": {
|
|
2235
|
+
"requestId": {
|
|
2236
|
+
"kind": "string",
|
|
2237
|
+
"maxLength": 64,
|
|
2238
|
+
"$comment": "Echoes the gem.requestPurchase that asked. Echo-only."
|
|
2239
|
+
},
|
|
2240
|
+
"outcome": {
|
|
2241
|
+
"kind": "enum",
|
|
2242
|
+
"values": ["ok", "declined", "unavailable"],
|
|
2243
|
+
"$comment": "THREE VALUES, AND THAT IS THE COMPLETE VOCABULARY - there is no 'insufficient funds' and there will not be one. A reason would leak the wallet this message exists to keep private, and the top-up case is handled entirely in the parent's own chrome: a player short of currency is offered the route there and the game only ever sees 'declined'. So this set does not need to grow when Coins joins Gems, which matters because `check-schema-freeze` treats a new enum value as breaking (an N-1 receiver refuses it). THE VALUE IS NOT THE WHOLE CHANNEL, AND THE REST OF IT IS DELIBERATELY NOT DEFENDED. A refusal the parent reaches without rendering anything is separable from a human Cancel by latency, so a game can learn 'could not afford' from timing even though no reason is sent. That is accepted rather than mitigated: every probe costs the attacker a prompt a real person has to look at and dismiss, so the oracle runs at human speed, in the open, against an EARNED in-game balance that is not a secret, a credential or money - and a game that spams purchase prompts to read it is a game players close. An earlier revision of this comment promised a quantised emission schedule; nothing implemented it, and a contract that names a control nobody built is worse than one that admits the gap. Parents: do not infer from this that you may send a reason. The enum stays closed at three, because that much is free."
|
|
2244
|
+
},
|
|
2245
|
+
"purchaseId": {
|
|
2246
|
+
"kind": "string",
|
|
2247
|
+
"maxLength": 64,
|
|
2248
|
+
"optional": true,
|
|
2249
|
+
"$comment": "The platform's own id for this purchase, present on 'ok'. A HANDLE FOR THE PLATFORM'S RECORD - what an operator or a support ticket needs to find this exact purchase - and NOT something a game reads back: no SDK call takes it, and the SDK deliberately does not surface the field a platform-funded purchase lands under. An earlier version of this comment said a game could correlate an inventory re-read against it, which was never true of any call that exists. MINT IT FOR THIS DISCLOSURE: it crosses to game code, and anything handed across the bridge is public - so an opaque high-entropy token, not a database key, not a sequence, and not a ULID or UUIDv7, each of which hands every game in the catalog the arcade's purchase rate and a server-side timestamp."
|
|
2250
|
+
}
|
|
2251
|
+
},
|
|
2252
|
+
"rateLimit": { "perMinute": 30 }
|
|
2253
|
+
},
|
|
2254
|
+
{
|
|
2255
|
+
"type": "gem.deviceSettings.put",
|
|
2256
|
+
"direction": "frameToParent",
|
|
2257
|
+
"transport": "port",
|
|
2258
|
+
"$comment": [
|
|
2259
|
+
"Replace this device's settings for this game with `settings`, whole.",
|
|
2260
|
+
"Gated by the `deviceSettings` capability. Answered by",
|
|
2261
|
+
"`gem.deviceSettings.result`.",
|
|
2262
|
+
"",
|
|
2263
|
+
"WHOLE-BLOB REPLACE: no keys, no merge. A game that wants one field",
|
|
2264
|
+
"changed sends the object with that field changed, and the parent stores",
|
|
2265
|
+
"what it was sent and nothing else. Last writer wins -- across tabs too,",
|
|
2266
|
+
"where nothing relays a write from one game document to another.",
|
|
2267
|
+
"",
|
|
2268
|
+
"The parent STORES this and never ACTS on it, which is the invariant",
|
|
2269
|
+
"DESIGN.md section 9 holds for every frame-to-parent field: it is opaque",
|
|
2270
|
+
"game-authored JSON, and a parent that read inside it would be trusting",
|
|
2271
|
+
"untrusted game code about something.",
|
|
2272
|
+
"",
|
|
2273
|
+
"The parent does not push `gem.deviceSettings` back after a put. The game",
|
|
2274
|
+
"holds what it wrote."
|
|
2275
|
+
],
|
|
2276
|
+
"fields": {
|
|
2277
|
+
"requestId": {
|
|
2278
|
+
"kind": "string",
|
|
2279
|
+
"maxLength": 64,
|
|
2280
|
+
"$comment": "Opaque and frame-chosen. An echo-only correlator, exactly as for gem.requestPurchase: not unique across tabs or reloads, and it may carry newlines and control characters, so it is not safe to write into a log line unescaped."
|
|
2281
|
+
},
|
|
2282
|
+
"settings": {
|
|
2283
|
+
"kind": "json",
|
|
2284
|
+
"$comment": "A plain JSON object -- a primitive, an array or null is refused by the generated validator, and by the SDK locally first. Over the 16384-byte cap the parent answers `too_large` and stores nothing; the SDK measures the serialisation and answers that locally too, so an oversized put never leaves the frame."
|
|
2285
|
+
}
|
|
2286
|
+
},
|
|
2287
|
+
"rateLimit": {
|
|
2288
|
+
"perMinute": 30,
|
|
2289
|
+
"$comment": "Bounds a BUGGY game: a slider that writes on every pixel of a drag is one, and the right shape is to debounce and write on release. Not a security control -- a hostile game posts on the port it holds."
|
|
2290
|
+
}
|
|
2291
|
+
},
|
|
2292
|
+
|
|
2293
|
+
{
|
|
2294
|
+
"type": "gem.deviceSettings.result",
|
|
2295
|
+
"direction": "parentToFrame",
|
|
2296
|
+
"transport": "port",
|
|
2297
|
+
"$comment": [
|
|
2298
|
+
"The answer to one gem.deviceSettings.put. Gated by the `deviceSettings`",
|
|
2299
|
+
"capability.",
|
|
2300
|
+
"",
|
|
2301
|
+
"THREE VALUES, COMPLETE ON DAY ONE. `check-schema-freeze` treats a new",
|
|
2302
|
+
"enum value as breaking -- an N-1 receiver refuses the whole message -- so",
|
|
2303
|
+
"this set was closed before it shipped:",
|
|
2304
|
+
" stored - the parent holds it; the next session on this device",
|
|
2305
|
+
" starts from it.",
|
|
2306
|
+
" too_large - over the cap. Nothing was stored; the previous value",
|
|
2307
|
+
" stands.",
|
|
2308
|
+
" unavailable - the parent could not store it just now: storage refused",
|
|
2309
|
+
" (a private window, a quota), or a developer tool that has",
|
|
2310
|
+
" not yet learned which game it is hosting. Nothing was",
|
|
2311
|
+
" stored. Not a softer no and not a retry hint -- a game",
|
|
2312
|
+
" keeps running on the value it holds.",
|
|
2313
|
+
"",
|
|
2314
|
+
"EVERY OUTCOME RESOLVES on the frame side and none is an error to a game:",
|
|
2315
|
+
"a device that cannot remember a volume is a device where the player sets",
|
|
2316
|
+
"it again."
|
|
2317
|
+
],
|
|
2318
|
+
"fields": {
|
|
2319
|
+
"requestId": {
|
|
2320
|
+
"kind": "string",
|
|
2321
|
+
"maxLength": 64,
|
|
2322
|
+
"$comment": "Echoes the gem.deviceSettings.put that asked. Echo-only."
|
|
2323
|
+
},
|
|
2324
|
+
"outcome": {
|
|
2325
|
+
"kind": "enum",
|
|
2326
|
+
"values": ["stored", "too_large", "unavailable"],
|
|
2327
|
+
"$comment": "Closed at three. No reason field: there is nothing a game could do with one that it should not already do with `unavailable`."
|
|
2328
|
+
}
|
|
2329
|
+
},
|
|
2330
|
+
"rateLimit": {
|
|
2331
|
+
"perMinute": 30
|
|
2332
|
+
}
|
|
2333
|
+
},
|
|
2334
|
+
|
|
2335
|
+
{
|
|
2336
|
+
"type": "gem.requestAd",
|
|
2337
|
+
"direction": "frameToParent",
|
|
2338
|
+
"transport": "port",
|
|
2339
|
+
"$comment": [
|
|
2340
|
+
"Ask the parent whether it has anything to show at a break in play.",
|
|
2341
|
+
"Gated by the `ads` capability.",
|
|
2342
|
+
"",
|
|
2343
|
+
"THE ANSWER IS USUALLY NO, and that is the ordinary outcome rather than a",
|
|
2344
|
+
"failure. The shape of this pair is deliberately the shape an ad request",
|
|
2345
|
+
"has everywhere: a placement and a format go out, a fill or a no-fill comes",
|
|
2346
|
+
"back, and the side that RENDERS is the side that decides. That is what",
|
|
2347
|
+
"makes whatever is behind it replaceable without a single game changing.",
|
|
2348
|
+
"",
|
|
2349
|
+
"THE FRAME NEVER SEES THE CREATIVE. No title, no image, no id of whatever",
|
|
2350
|
+
"was shown crosses back - only gem.adResult's three-value outcome. A game",
|
|
2351
|
+
"learns that a panel was painted, never what was in it.",
|
|
2352
|
+
"",
|
|
2353
|
+
"WHAT THE PARENT OWES, none of which this schema can enforce:",
|
|
2354
|
+
"",
|
|
2355
|
+
" ADVERTISE `ads` AS A DEPLOYMENT-WIDE FACT, and never condition the",
|
|
2356
|
+
" advertisement on anything about the player - not a subscription, not an",
|
|
2357
|
+
" age, not a consent state. gem.adResult's enum is closed at three values",
|
|
2358
|
+
" precisely so that no answer discloses any of those; conditioning the",
|
|
2359
|
+
" HANDSHAKE on them moves the disclosure somewhere no enum guards it, and a",
|
|
2360
|
+
" frame reads the advertised capability set directly. A player who must not",
|
|
2361
|
+
" be shown anything is answered `no_ad`, like every other reason.",
|
|
2362
|
+
"",
|
|
2363
|
+
" RENDER IT IN YOUR OWN CHROME. Labelled as a promotion, dismissible, and",
|
|
2364
|
+
" focus-trapped over the paused game. Nothing in this message is rendered.",
|
|
2365
|
+
"",
|
|
2366
|
+
" PAUSE WITH `gem.pause`. That message already means 'stop running and stop",
|
|
2367
|
+
" making noise', which is exactly what is wanted while a panel is up. Do",
|
|
2368
|
+
" not invent a second pause for this.",
|
|
2369
|
+
"",
|
|
2370
|
+
" ANSWER EVERY ASK, exactly once. An unanswered one costs the caller its",
|
|
2371
|
+
" full timeout with the game paused behind it, which a player experiences",
|
|
2372
|
+
" as the game having hung.",
|
|
2373
|
+
"",
|
|
2374
|
+
" ANSWER EVERY `no_ad` AND `unavailable` ON A SCHEDULE THAT DOES NOT",
|
|
2375
|
+
" DEPEND ON WHICH BRANCH PRODUCED IT. A refusal you reach from a local",
|
|
2376
|
+
" boolean in under a millisecond is separable by latency from one that",
|
|
2377
|
+
" took a round trip, and on this surface the difference between those",
|
|
2378
|
+
" branches is an entitlement, an age or a consent state. Hold a floor",
|
|
2379
|
+
" comfortably above your slowest no-branch. `shown` is not delayed; its",
|
|
2380
|
+
" latency is a player reading a panel. See gem.adResult's comment.",
|
|
2381
|
+
"",
|
|
2382
|
+
" TREAT EVERY FIELD AS UNTRUSTED TEXT. `placement` is game-authored and the",
|
|
2383
|
+
" generated validator checks type and length only - it may carry `../`,",
|
|
2384
|
+
" NUL, newlines, RTL overrides and astral Unicode. The SDK will only SEND",
|
|
2385
|
+
" `^[a-z0-9_-]{1,32}$`, which is the grammar the platform's own ad",
|
|
2386
|
+
" endpoint accepts - but that is the sender keeping a promise, not a",
|
|
2387
|
+
" guarantee you hold: a hostile frame posts on the port directly. It is",
|
|
2388
|
+
" matched against nothing, never interpolated into a path or a log line,",
|
|
2389
|
+
" and never shown to a player.",
|
|
2390
|
+
"",
|
|
2391
|
+
" THE `perMinute` BELOW BOUNDS A BUGGY GAME, NOT A HOSTILE ONE. A hostile",
|
|
2392
|
+
" game posts on the port it holds rather than calling the SDK, so a",
|
|
2393
|
+
" frame-side limit is one the attacker enforces against itself. What bounds",
|
|
2394
|
+
" a hostile game here is that the parent decides whether to render at all."
|
|
2395
|
+
],
|
|
2396
|
+
"fields": {
|
|
2397
|
+
"requestId": {
|
|
2398
|
+
"kind": "string",
|
|
2399
|
+
"maxLength": 64,
|
|
2400
|
+
"$comment": "Opaque and frame-chosen. The parent treats it as an echo-only correlator and derives its own request identity, exactly as it does for gem.requestToken, gem.partyRequest and gem.requestPurchase - it is not unique across tabs or reloads and must never key anything on it. It may also contain newlines and control characters, so it is not safe to write into a log line unescaped."
|
|
2401
|
+
},
|
|
2402
|
+
"placement": {
|
|
2403
|
+
"kind": "string",
|
|
2404
|
+
"maxLength": 32,
|
|
2405
|
+
"$comment": "The game's own name for the break it is at - 'level-complete', 'between-rounds'. UNTRUSTED TEXT, MATCHED AGAINST NOTHING BY TODAY'S PARENT: it does not look it up, does not vary its decision on it and does not render it. THE BOUND IS THE PLATFORM'S OWN, not a number chosen here: the ad endpoint behind the arcade accepts `^[a-z0-9_-]{1,32}$` and answers 400 otherwise, so 32 is what this carries and the SDK refuses the charset before sending. A real ad network keys its ad units BY placement - that is what the field is for - so the day one is swapped in the parent starts forwarding this string unchanged, and a game that shipped 'Boss Fight' or a 40-character name would have to be rebuilt. The grammar is narrow NOW, while nothing has shipped against it, so a game learns at development time instead. Codegen emits type and length only; the charset is the sender's to keep."
|
|
2406
|
+
},
|
|
2407
|
+
"format": {
|
|
2408
|
+
"kind": "enum",
|
|
2409
|
+
"values": ["interstitial", "rewarded"],
|
|
2410
|
+
"$comment": "A closed set, so a typo is refused at the boundary rather than left for the parent to interpret. `rewarded` IS IN THE GRAMMAR AND IS NOT SERVED: it is answered `no_ad` today, and a `shown` would grant nothing even when it is not - see gem.adResult's outcome. A game that grants a reward on an outcome has a reward any page that can post on this port can forge."
|
|
2411
|
+
}
|
|
2412
|
+
},
|
|
2413
|
+
"rateLimit": {
|
|
2414
|
+
"perMinute": 6,
|
|
2415
|
+
"$comment": "Bounds a BUGGY game. NOT a security control: a hostile game posts on the port it holds rather than calling the SDK, so a frame-side limit is one the attacker enforces against itself. Six is generous for breaks in play and mean for a loop."
|
|
2416
|
+
}
|
|
2417
|
+
},
|
|
2418
|
+
{
|
|
2419
|
+
"type": "gem.adResult",
|
|
2420
|
+
"direction": "parentToFrame",
|
|
2421
|
+
"transport": "port",
|
|
2422
|
+
"$comment": [
|
|
2423
|
+
"The answer to one gem.requestAd. Gated by the `ads` capability.",
|
|
2424
|
+
"",
|
|
2425
|
+
"EVERY OUTCOME RESOLVES. Nothing here is an error. A no-fill is the common",
|
|
2426
|
+
"case rather than an exception, and a game that had to catch one would be a",
|
|
2427
|
+
"game logging an unhandled rejection at every break in play.",
|
|
2428
|
+
"",
|
|
2429
|
+
"HOW A GAME SHOULD READ EACH ONE:",
|
|
2430
|
+
" shown - a panel was painted and has closed. Resume. It GRANTS",
|
|
2431
|
+
" NOTHING: not a receipt, not an entitlement, and not",
|
|
2432
|
+
" permission to hand out a reward.",
|
|
2433
|
+
" no_ad - nothing was painted. Carry on as though the call had not",
|
|
2434
|
+
" been made. Render no placeholder and no 'nothing right",
|
|
2435
|
+
" now' copy; empty is the ordinary case.",
|
|
2436
|
+
" unavailable - the shell could not render at all just now. NOT a softer",
|
|
2437
|
+
" no; a game reads it identically to `no_ad`.",
|
|
2438
|
+
"",
|
|
2439
|
+
"THREE VALUES, AND THAT IS THE COMPLETE VOCABULARY. `no_ad` absorbs the",
|
|
2440
|
+
"feature being switched off, an empty catalogue once the running game is",
|
|
2441
|
+
"excluded, a frequency cap that has not expired, and a player whose",
|
|
2442
|
+
"subscription promises no interruptions - and it MUST KEEP ABSORBING THEM.",
|
|
2443
|
+
"A fourth value naming any of those hands untrusted game code an age, a",
|
|
2444
|
+
"consent state or an entitlement, which is the exact property this",
|
|
2445
|
+
"arrangement exists to withhold. The real reason belongs in the platform's",
|
|
2446
|
+
"own telemetry, on the side of the boundary that already knows it.",
|
|
2447
|
+
"",
|
|
2448
|
+
"IT IS ALSO BREAKING. `check-schema-freeze` treats a new enum value as",
|
|
2449
|
+
"breaking because an N-1 receiver's generated validator refuses the WHOLE",
|
|
2450
|
+
"message rather than the field, so the ask times out - and the SDK reads an",
|
|
2451
|
+
"outcome it does not recognise as `unavailable`. A shipped game bundle",
|
|
2452
|
+
"cannot be patched to learn a new value. Three is permanent, which is",
|
|
2453
|
+
"precisely what makes it the right place to put this guarantee.",
|
|
2454
|
+
"",
|
|
2455
|
+
"THE VALUE IS NOT THE WHOLE CHANNEL. A `no_ad` reached without rendering",
|
|
2456
|
+
"anything is separable by LATENCY from one that followed a panel a player",
|
|
2457
|
+
"read and dismissed - and the no-branches are separable from EACH OTHER",
|
|
2458
|
+
"the same way: a platform switch read off a local boolean settles in under",
|
|
2459
|
+
"a millisecond, while an empty catalogue and a subscription that promises",
|
|
2460
|
+
"no interruptions are both decided a round trip away. So on this surface",
|
|
2461
|
+
"the quantity behind that latency is an age, a consent state or an",
|
|
2462
|
+
"entitlement, and a parent MUST settle every `no_ad` and `unavailable` on",
|
|
2463
|
+
"a schedule that does not depend on which branch produced it. `shown` is",
|
|
2464
|
+
"NOT delayed: its latency is a player reading a panel, which is that",
|
|
2465
|
+
"player's own doing. Nothing in this schema can enforce any of this; it is",
|
|
2466
|
+
"stated because the enum alone does not finish the job.",
|
|
2467
|
+
"",
|
|
2468
|
+
"DO NOT REACH FOR gem.purchaseResult's ARGUMENT HERE. That pair accepts",
|
|
2469
|
+
"its timing channel on two premises and NEITHER transfers. Its probes are",
|
|
2470
|
+
"not free - each one costs a prompt a human has to look at and dismiss, so",
|
|
2471
|
+
"the oracle runs at human speed and in the open - whereas the branch timed",
|
|
2472
|
+
"here renders NOTHING, so a game can walk it at its own rate limit with",
|
|
2473
|
+
"the player none the wiser. And what leaks there is an EARNED in-game",
|
|
2474
|
+
"balance, which is not a secret; what leaks here is a protected attribute",
|
|
2475
|
+
"of a person. Same shape, opposite answer."
|
|
2476
|
+
],
|
|
2477
|
+
"fields": {
|
|
2478
|
+
"requestId": {
|
|
2479
|
+
"kind": "string",
|
|
2480
|
+
"maxLength": 64,
|
|
2481
|
+
"$comment": "Echoes the gem.requestAd that asked. Echo-only."
|
|
2482
|
+
},
|
|
2483
|
+
"outcome": {
|
|
2484
|
+
"kind": "enum",
|
|
2485
|
+
"values": ["shown", "no_ad", "unavailable"],
|
|
2486
|
+
"$comment": "THREE VALUES, AND NO REASON. See the message comment above: `no_ad` is one answer covering the flag, the empty catalogue, the cap and the subscription, and a fourth value naming any of them is the disclosure this pair exists to prevent. This exact list is pinned by a test on the SDK side, because prose is not a control."
|
|
2487
|
+
}
|
|
2488
|
+
},
|
|
2489
|
+
"rateLimit": { "perMinute": 12 }
|
|
2490
|
+
},
|
|
2491
|
+
{
|
|
2492
|
+
"type": "gem.requestSignIn",
|
|
2493
|
+
"direction": "frameToParent",
|
|
2494
|
+
"transport": "port",
|
|
2495
|
+
"$comment": [
|
|
2496
|
+
"Ask the parent to sign the player in - or, for somebody playing without",
|
|
2497
|
+
"an account, to create one. Gated by the `signIn` capability.",
|
|
2498
|
+
"",
|
|
2499
|
+
"WHY THIS CROSSES THE BRIDGE AT ALL. A game upsells what an account buys -",
|
|
2500
|
+
"progress that follows the player, a name on a leaderboard, friends - and",
|
|
2501
|
+
"the frame has no move of its own: it cannot navigate the top document,",
|
|
2502
|
+
"cannot render the sign-in form, and holds a game-scoped token that the",
|
|
2503
|
+
"account surface refuses by construction. So the game ASKS, and the parent,",
|
|
2504
|
+
"which holds the account session and owns every account-state surface,",
|
|
2505
|
+
"DECIDES and RENDERS.",
|
|
2506
|
+
"",
|
|
2507
|
+
"NOTHING BUT A CORRELATOR IS SENT, deliberately. No URL: where the player",
|
|
2508
|
+
"lands afterwards is the parent's own route, and a return address the frame",
|
|
2509
|
+
"supplied would be an open redirect the frame chose. No text: copy a game",
|
|
2510
|
+
"authored, rendered inside the platform's own sign-in chrome, is a phishing",
|
|
2511
|
+
"kit with the platform's name on it. The parent's prompt is drawn from",
|
|
2512
|
+
"platform records and nothing in this message.",
|
|
2513
|
+
"",
|
|
2514
|
+
"WHAT THE PARENT OWES, none of which this schema can enforce:",
|
|
2515
|
+
"",
|
|
2516
|
+
" THE PLAYER CHOOSES, NOT THE GAME. The frame picks the moment it asks, so",
|
|
2517
|
+
" a parent that navigates the top document on receipt has handed untrusted",
|
|
2518
|
+
" code a redirect. Confirm in your own chrome, or honour the ask only under",
|
|
2519
|
+
" transient user activation (a click in the frame activates its ancestors).",
|
|
2520
|
+
" One prompt at a time: an ask arriving while one is open is answered",
|
|
2521
|
+
" `declined` with nothing rendered.",
|
|
2522
|
+
"",
|
|
2523
|
+
" WARN BEFORE YOU NAVIGATE. If signing in takes the top document away, send",
|
|
2524
|
+
" gem.willTerminate { reason: 'navigating' } and honour its grace first, so",
|
|
2525
|
+
" a cooperating game saves. No gem.signInResult follows - there is no frame",
|
|
2526
|
+
" left to hear it.",
|
|
2527
|
+
"",
|
|
2528
|
+
" IN PLACE, THE TOKEN GOES FIRST. If the player signs in and the frame",
|
|
2529
|
+
" survives, mint a play token for the account and push it the ordinary way",
|
|
2530
|
+
" - an unsolicited gem.token, then gem.player - and only THEN answer",
|
|
2531
|
+
" `signed_in`. The port preserves order, and that order is the contract: by",
|
|
2532
|
+
" the time the game hears the answer, every call it makes is already the",
|
|
2533
|
+
" account's.",
|
|
2534
|
+
"",
|
|
2535
|
+
" A TERMINAL DENIAL IS NOT UNDONE BY A PUSH. A frame whose token chain",
|
|
2536
|
+
" ended in a non-retryable gem.token.denied ignores every later gem.token,",
|
|
2537
|
+
" by design - a source that has given up is not restarted by a message",
|
|
2538
|
+
" arriving late. That frame cannot be signed in in place. Reload it under",
|
|
2539
|
+
" the account instead of answering `signed_in`, which would be false. A",
|
|
2540
|
+
" parent that frames a game for a player with no account, meaning to offer",
|
|
2541
|
+
" this, must therefore not have answered that player's first",
|
|
2542
|
+
" gem.requestToken terminally.",
|
|
2543
|
+
"",
|
|
2544
|
+
" ALREADY SIGNED IN IS `signed_in`, with nothing rendered and nothing",
|
|
2545
|
+
" minted. It is the truthful answer, and it needs no fourth value."
|
|
2546
|
+
],
|
|
2547
|
+
"fields": {
|
|
2548
|
+
"requestId": {
|
|
2549
|
+
"kind": "string",
|
|
2550
|
+
"maxLength": 64,
|
|
2551
|
+
"$comment": "Opaque and frame-chosen. Echo-only, exactly as on gem.requestPurchase: not unique across tabs or reloads, never a key for anything, and not safe to write into a log line unescaped."
|
|
2552
|
+
}
|
|
2553
|
+
},
|
|
2554
|
+
"rateLimit": {
|
|
2555
|
+
"perMinute": 6,
|
|
2556
|
+
"$comment": "Bounds a BUGGY game. NOT a security control: a hostile game posts on the port it holds rather than calling the SDK. What stops a game nagging is the parent's one-prompt-at-a-time rule and its freedom to answer `declined` without rendering anything."
|
|
2557
|
+
}
|
|
2558
|
+
},
|
|
2559
|
+
{
|
|
2560
|
+
"type": "gem.signInResult",
|
|
2561
|
+
"direction": "parentToFrame",
|
|
2562
|
+
"transport": "port",
|
|
2563
|
+
"$comment": [
|
|
2564
|
+
"The answer to one gem.requestSignIn. Gated by the `signIn` capability.",
|
|
2565
|
+
"",
|
|
2566
|
+
"EVERY OUTCOME RESOLVES. A player closing a sign-in prompt is an ordinary",
|
|
2567
|
+
"thing that happens at every upsell, not an error.",
|
|
2568
|
+
"",
|
|
2569
|
+
"HOW A GAME SHOULD READ EACH ONE:",
|
|
2570
|
+
" signed_in - the player is signed in, and the token this frame holds",
|
|
2571
|
+
" already names their account: the parent pushed gem.token",
|
|
2572
|
+
" and gem.player BEFORE sending this. Re-read identity, the",
|
|
2573
|
+
" display player and anything loaded under the old identity.",
|
|
2574
|
+
" Also the answer for a player who was signed in all along.",
|
|
2575
|
+
" declined - return to the game SILENTLY. The player saw the prompt and",
|
|
2576
|
+
" closed it, or the parent chose not to show one. No 'sign-in",
|
|
2577
|
+
" failed' copy; an error after a deliberate Cancel reads as a",
|
|
2578
|
+
" bug.",
|
|
2579
|
+
" unavailable - the parent could not offer sign-in just now, or never",
|
|
2580
|
+
" answered. Never auto-retried; only a fresh player action",
|
|
2581
|
+
" may ask again.",
|
|
2582
|
+
"",
|
|
2583
|
+
"A PARENT THAT SIGNS IN BY NAVIGATING NEVER SENDS THIS. The top document",
|
|
2584
|
+
"goes away and the frame with it; the game boots again under the account's",
|
|
2585
|
+
"token and nothing is waiting for an answer. So a game must have saved",
|
|
2586
|
+
"BEFORE it asks - gem.willTerminate is the warning, not this.",
|
|
2587
|
+
"",
|
|
2588
|
+
"THE IDENTITY MAY HAVE CHANGED, AND NOTHING HERE SAYS WHETHER IT DID. A",
|
|
2589
|
+
"platform may attach credentials to the record the player already had",
|
|
2590
|
+
"(same playerId, progress intact) or sign them into a different one (new",
|
|
2591
|
+
"playerId, the account's own progress). The token says which. This message",
|
|
2592
|
+
"deliberately carries no playerId of its own - a second copy of an",
|
|
2593
|
+
"identity is a second thing to disagree with the first.",
|
|
2594
|
+
"",
|
|
2595
|
+
"THREE VALUES, PERMANENTLY. `check-schema-freeze` treats a new enum value as",
|
|
2596
|
+
"breaking: an N-1 frame's generated validator refuses the whole message and",
|
|
2597
|
+
"the ask times out. No reason rides on `declined` - 'closed the prompt',",
|
|
2598
|
+
"'failed a captcha' and 'is not permitted an account' are account-state",
|
|
2599
|
+
"facts, and the parent owns every account-state explanation."
|
|
2600
|
+
],
|
|
2601
|
+
"fields": {
|
|
2602
|
+
"requestId": {
|
|
2603
|
+
"kind": "string",
|
|
2604
|
+
"maxLength": 64,
|
|
2605
|
+
"$comment": "Echoes the gem.requestSignIn that asked. Echo-only."
|
|
2606
|
+
},
|
|
2607
|
+
"outcome": {
|
|
2608
|
+
"kind": "enum",
|
|
2609
|
+
"values": ["signed_in", "declined", "unavailable"],
|
|
2610
|
+
"$comment": "THREE VALUES, AND NO REASON. See the message comment above. This exact list is pinned by a test on the SDK side, because prose is not a control."
|
|
2611
|
+
}
|
|
2612
|
+
},
|
|
2613
|
+
"rateLimit": { "perMinute": 12 }
|
|
2614
|
+
}
|
|
2615
|
+
],
|
|
2616
|
+
|
|
2617
|
+
"notInThisBridge": {
|
|
2618
|
+
"gem.presence": [
|
|
2619
|
+
"Presence, CCU and analytics are derived server-side from token-authenticated",
|
|
2620
|
+
"heartbeats. A frame-authored presence message would let a game inflate its",
|
|
2621
|
+
"own CCU and its store ranking."
|
|
2622
|
+
]
|
|
2623
|
+
}
|
|
2624
|
+
}
|