@mpgd/game-services 0.3.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/notification-delivery.d.ts +106 -0
- package/dist/notification-delivery.js +444 -0
- package/dist/progress-link.d.ts +96 -0
- package/dist/progress-link.js +394 -0
- package/dist/runtime.d.ts +31 -0
- package/dist/runtime.js +198 -0
- package/dist/server.d.ts +2 -1
- package/dist/server.js +2 -0
- package/dist/validation.d.ts +1 -0
- package/dist/validation.js +11 -0
- package/package.json +17 -5
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { assertOwnEnumerablePropertyLimit } from './validation';
|
|
2
|
+
export const gameProgressLimits = {
|
|
3
|
+
maxIdentifierLength: 256,
|
|
4
|
+
maxCompletedIds: 512,
|
|
5
|
+
maxMetricEntries: 512,
|
|
6
|
+
maxPayloadDepth: 16,
|
|
7
|
+
maxPayloadNodes: 2048,
|
|
8
|
+
maxPayloadStringUnits: 32768,
|
|
9
|
+
};
|
|
10
|
+
const snapshotFields = new Set(['completedIds', 'bestTimesMs', 'bestScores', 'activeProgress']);
|
|
11
|
+
const activeProgressFields = new Set(['id', 'updatedAt', 'payload']);
|
|
12
|
+
/** Process-local test helper. Production services must provide a durable store. */
|
|
13
|
+
export class InMemoryProgressLinkStore {
|
|
14
|
+
progressByPlayerId = new Map();
|
|
15
|
+
reconciliationsByIdempotencyKey = new Map();
|
|
16
|
+
reconciliationsByHandoffNonce = new Map();
|
|
17
|
+
constructor(initialProgress = []) {
|
|
18
|
+
for (const record of initialProgress) {
|
|
19
|
+
const authoritativePlayerId = normalizeIdentifier(record.authoritativePlayerId, 'authoritativePlayerId');
|
|
20
|
+
this.progressByPlayerId.set(authoritativePlayerId, normalizeGameProgressSnapshot(record.progress));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async reconcile(input) {
|
|
24
|
+
const request = normalizeReconcileGuestProgressRequest(input);
|
|
25
|
+
const byIdempotencyKey = this.reconciliationsByIdempotencyKey.get(request.idempotencyKey);
|
|
26
|
+
const byHandoffNonce = this.reconciliationsByHandoffNonce.get(request.handoffNonce);
|
|
27
|
+
if (byIdempotencyKey !== undefined
|
|
28
|
+
&& byHandoffNonce !== undefined
|
|
29
|
+
&& byIdempotencyKey !== byHandoffNonce) {
|
|
30
|
+
throw new Error('idempotencyKey and handoffNonce belong to different reconciliations.');
|
|
31
|
+
}
|
|
32
|
+
const existing = byIdempotencyKey ?? byHandoffNonce;
|
|
33
|
+
if (existing !== undefined) {
|
|
34
|
+
assertMatchingProgressLinkIdentity(existing, request);
|
|
35
|
+
this.reconciliationsByIdempotencyKey.set(request.idempotencyKey, existing);
|
|
36
|
+
this.reconciliationsByHandoffNonce.set(request.handoffNonce, existing);
|
|
37
|
+
return {
|
|
38
|
+
authoritativePlayerId: existing.authoritativePlayerId,
|
|
39
|
+
progress: normalizeGameProgressSnapshot(existing.progress),
|
|
40
|
+
alreadyProcessed: true,
|
|
41
|
+
deduplicatedBy: byIdempotencyKey === undefined ? 'handoff-nonce' : 'idempotency-key',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const serverProgress = this.progressByPlayerId.get(request.authoritativePlayerId)
|
|
45
|
+
?? createEmptyGameProgressSnapshot();
|
|
46
|
+
const progress = mergeGameProgressSnapshots(serverProgress, request.guestProgress);
|
|
47
|
+
const reconciliation = {
|
|
48
|
+
authoritativePlayerId: request.authoritativePlayerId,
|
|
49
|
+
guestId: request.guestId,
|
|
50
|
+
progress,
|
|
51
|
+
};
|
|
52
|
+
this.progressByPlayerId.set(request.authoritativePlayerId, progress);
|
|
53
|
+
this.reconciliationsByIdempotencyKey.set(request.idempotencyKey, reconciliation);
|
|
54
|
+
this.reconciliationsByHandoffNonce.set(request.handoffNonce, reconciliation);
|
|
55
|
+
return {
|
|
56
|
+
authoritativePlayerId: request.authoritativePlayerId,
|
|
57
|
+
progress: normalizeGameProgressSnapshot(progress),
|
|
58
|
+
alreadyProcessed: false,
|
|
59
|
+
deduplicatedBy: 'none',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async getProgress(authoritativePlayerId) {
|
|
63
|
+
const progress = this.progressByPlayerId.get(normalizeIdentifier(authoritativePlayerId, 'authoritativePlayerId'));
|
|
64
|
+
return progress === undefined ? undefined : normalizeGameProgressSnapshot(progress);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function createInMemoryProgressLinkStore(initialProgress = []) {
|
|
68
|
+
return new InMemoryProgressLinkStore(initialProgress);
|
|
69
|
+
}
|
|
70
|
+
export function createProgressLinkService(input) {
|
|
71
|
+
const now = input.now ?? (() => new Date().toISOString());
|
|
72
|
+
return {
|
|
73
|
+
async reconcileGuestProgress(contextInput, handoffInput) {
|
|
74
|
+
const context = normalizeServerResolvedPlayerContext(contextInput);
|
|
75
|
+
const metadata = normalizeGuestProgressHandoffMetadata(handoffInput);
|
|
76
|
+
await verifyProgressHandoff(input.handoffVerifier, context, metadata, now);
|
|
77
|
+
// These validations deliberately stay at separate trust boundaries: the
|
|
78
|
+
// client request, verifier output, store input, and store output may each
|
|
79
|
+
// come from independently implemented code. Payload limits keep every
|
|
80
|
+
// traversal bounded while preventing a typed-but-unvalidated value from
|
|
81
|
+
// skipping runtime validation.
|
|
82
|
+
const guestProgress = normalizeGameProgressSnapshot(handoffInput.guestProgress);
|
|
83
|
+
const verifiedProgress = normalizeGameProgressSnapshot(await input.progressVerifier.verify({
|
|
84
|
+
authoritativePlayerId: context.authoritativePlayerId,
|
|
85
|
+
guestId: metadata.guestId,
|
|
86
|
+
handoffNonce: metadata.handoffNonce,
|
|
87
|
+
progress: guestProgress,
|
|
88
|
+
}));
|
|
89
|
+
const result = await input.store.reconcile({
|
|
90
|
+
authoritativePlayerId: context.authoritativePlayerId,
|
|
91
|
+
...metadata,
|
|
92
|
+
guestProgress: verifiedProgress,
|
|
93
|
+
});
|
|
94
|
+
return normalizeReconcileGuestProgressResult(result, context.authoritativePlayerId);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function createEmptyGameProgressSnapshot() {
|
|
99
|
+
return {
|
|
100
|
+
completedIds: [],
|
|
101
|
+
bestTimesMs: {},
|
|
102
|
+
bestScores: {},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export function normalizeGameProgressSnapshot(input) {
|
|
106
|
+
assertRecord(input, 'GameProgressSnapshot');
|
|
107
|
+
assertOnlyFields(input, snapshotFields, 'GameProgressSnapshot');
|
|
108
|
+
if (!Array.isArray(input.completedIds)) {
|
|
109
|
+
throw new Error('completedIds must be an array.');
|
|
110
|
+
}
|
|
111
|
+
assertCollectionLimit(input.completedIds.length, gameProgressLimits.maxCompletedIds, 'completedIds');
|
|
112
|
+
const completedIds = [...new Set(Array.from(input.completedIds, (id, index) => (normalizeIdentifier(id, `completedIds[${String(index)}]`))))].sort();
|
|
113
|
+
const bestTimesMs = normalizeMetricMap(input.bestTimesMs, 'bestTimesMs', (value, label) => {
|
|
114
|
+
if (value < 0) {
|
|
115
|
+
throw new Error(`${label} must be greater than or equal to zero.`);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
const bestScores = normalizeMetricMap(input.bestScores, 'bestScores');
|
|
119
|
+
if (input.activeProgress === undefined) {
|
|
120
|
+
return {
|
|
121
|
+
completedIds,
|
|
122
|
+
bestTimesMs,
|
|
123
|
+
bestScores,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
completedIds,
|
|
128
|
+
bestTimesMs,
|
|
129
|
+
bestScores,
|
|
130
|
+
activeProgress: normalizeActiveProgress(input.activeProgress),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
export function mergeGameProgressSnapshots(serverInput, guestInput) {
|
|
134
|
+
// This exported helper is also a runtime boundary; TypeScript types alone do
|
|
135
|
+
// not prove that either snapshot came from a trusted or validated source.
|
|
136
|
+
const server = normalizeGameProgressSnapshot(serverInput);
|
|
137
|
+
const guest = normalizeGameProgressSnapshot(guestInput);
|
|
138
|
+
const activeProgress = selectActiveProgress(server.activeProgress, guest.activeProgress);
|
|
139
|
+
const completedIds = [...new Set([...server.completedIds, ...guest.completedIds])].sort();
|
|
140
|
+
assertCollectionLimit(completedIds.length, gameProgressLimits.maxCompletedIds, 'merged completedIds');
|
|
141
|
+
return {
|
|
142
|
+
completedIds,
|
|
143
|
+
bestTimesMs: mergeMetricMaps(server.bestTimesMs, guest.bestTimesMs, Math.min),
|
|
144
|
+
bestScores: mergeMetricMaps(server.bestScores, guest.bestScores, Math.max),
|
|
145
|
+
...(activeProgress === undefined ? {} : { activeProgress }),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function normalizeReconcileGuestProgressRequest(input) {
|
|
149
|
+
assertRecord(input, 'ReconcileGuestProgressRequest');
|
|
150
|
+
const metadata = normalizeGuestProgressHandoffMetadata(input);
|
|
151
|
+
return {
|
|
152
|
+
authoritativePlayerId: normalizeIdentifier(input.authoritativePlayerId, 'authoritativePlayerId'),
|
|
153
|
+
...metadata,
|
|
154
|
+
guestProgress: normalizeGameProgressSnapshot(input.guestProgress),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function normalizeServerResolvedPlayerContext(input) {
|
|
158
|
+
assertRecord(input, 'ServerResolvedPlayerContext');
|
|
159
|
+
return {
|
|
160
|
+
authoritativePlayerId: normalizeIdentifier(input.authoritativePlayerId, 'authoritativePlayerId'),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function normalizeGuestProgressHandoffMetadata(input) {
|
|
164
|
+
assertRecord(input, 'GuestProgressHandoffRequest');
|
|
165
|
+
return {
|
|
166
|
+
guestId: normalizeIdentifier(input.guestId, 'guestId'),
|
|
167
|
+
handoffNonce: normalizeIdentifier(input.handoffNonce, 'handoffNonce'),
|
|
168
|
+
idempotencyKey: normalizeIdentifier(input.idempotencyKey, 'idempotencyKey'),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
async function verifyProgressHandoff(verifier, context, request, now) {
|
|
172
|
+
const handoff = await verifier.verify({ handoffNonce: request.handoffNonce });
|
|
173
|
+
if (handoff === undefined) {
|
|
174
|
+
throw new Error('Progress handoff is invalid or expired.');
|
|
175
|
+
}
|
|
176
|
+
const verified = normalizeVerifiedProgressHandoff(handoff);
|
|
177
|
+
const currentTime = Date.parse(normalizeTimestamp(now(), 'now()'));
|
|
178
|
+
const issuedAt = Date.parse(verified.issuedAt);
|
|
179
|
+
const expiresAt = Date.parse(verified.expiresAt);
|
|
180
|
+
if (verified.handoffNonce !== request.handoffNonce
|
|
181
|
+
|| verified.authoritativePlayerId !== context.authoritativePlayerId
|
|
182
|
+
|| verified.guestId !== request.guestId
|
|
183
|
+
|| issuedAt > currentTime
|
|
184
|
+
|| expiresAt <= issuedAt
|
|
185
|
+
|| expiresAt <= currentTime) {
|
|
186
|
+
throw new Error('Progress handoff is invalid or expired.');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function normalizeReconcileGuestProgressResult(input, expectedPlayerId) {
|
|
190
|
+
assertRecord(input, 'ReconcileGuestProgressResult');
|
|
191
|
+
const authoritativePlayerId = normalizeIdentifier(input.authoritativePlayerId, 'result authoritativePlayerId');
|
|
192
|
+
if (authoritativePlayerId !== expectedPlayerId) {
|
|
193
|
+
throw new Error('Progress link store returned a result for another player.');
|
|
194
|
+
}
|
|
195
|
+
if (typeof input.alreadyProcessed !== 'boolean') {
|
|
196
|
+
throw new Error('Progress link store returned an invalid alreadyProcessed value.');
|
|
197
|
+
}
|
|
198
|
+
if (input.deduplicatedBy !== 'none'
|
|
199
|
+
&& input.deduplicatedBy !== 'idempotency-key'
|
|
200
|
+
&& input.deduplicatedBy !== 'handoff-nonce') {
|
|
201
|
+
throw new Error('Progress link store returned an invalid deduplicatedBy value.');
|
|
202
|
+
}
|
|
203
|
+
if (input.alreadyProcessed !== (input.deduplicatedBy !== 'none')) {
|
|
204
|
+
throw new Error('Progress link store returned inconsistent deduplication state.');
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
authoritativePlayerId,
|
|
208
|
+
progress: normalizeGameProgressSnapshot(input.progress),
|
|
209
|
+
alreadyProcessed: input.alreadyProcessed,
|
|
210
|
+
deduplicatedBy: input.deduplicatedBy,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function normalizeVerifiedProgressHandoff(input) {
|
|
214
|
+
assertRecord(input, 'VerifiedProgressHandoff');
|
|
215
|
+
return {
|
|
216
|
+
handoffNonce: normalizeIdentifier(input.handoffNonce, 'verified handoffNonce'),
|
|
217
|
+
authoritativePlayerId: normalizeIdentifier(input.authoritativePlayerId, 'verified authoritativePlayerId'),
|
|
218
|
+
guestId: normalizeIdentifier(input.guestId, 'verified guestId'),
|
|
219
|
+
issuedAt: normalizeTimestamp(input.issuedAt, 'verified issuedAt'),
|
|
220
|
+
expiresAt: normalizeTimestamp(input.expiresAt, 'verified expiresAt'),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function normalizeActiveProgress(input) {
|
|
224
|
+
assertRecord(input, 'activeProgress');
|
|
225
|
+
assertOnlyFields(input, activeProgressFields, 'activeProgress');
|
|
226
|
+
return {
|
|
227
|
+
id: normalizeIdentifier(input.id, 'activeProgress.id'),
|
|
228
|
+
updatedAt: normalizeTimestamp(input.updatedAt, 'activeProgress.updatedAt'),
|
|
229
|
+
payload: normalizeProgressPayload(input.payload),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function normalizeMetricMap(input, label, validate) {
|
|
233
|
+
assertRecord(input, label);
|
|
234
|
+
assertOwnEnumerablePropertyLimit(input, gameProgressLimits.maxMetricEntries, label);
|
|
235
|
+
const inputEntries = Object.entries(input);
|
|
236
|
+
const entries = inputEntries.map(([key, value]) => {
|
|
237
|
+
const normalizedKey = normalizeIdentifier(key, `${label} key`);
|
|
238
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
239
|
+
throw new Error(`${label}.${key} must be a finite number.`);
|
|
240
|
+
}
|
|
241
|
+
validate?.(value, `${label}.${key}`);
|
|
242
|
+
return [normalizedKey, value];
|
|
243
|
+
});
|
|
244
|
+
entries.sort(([left], [right]) => compareCodeUnits(left, right));
|
|
245
|
+
return Object.fromEntries(entries);
|
|
246
|
+
}
|
|
247
|
+
function mergeMetricMaps(server, guest, select) {
|
|
248
|
+
const keys = [...new Set([...Object.keys(server), ...Object.keys(guest)])].sort();
|
|
249
|
+
assertCollectionLimit(keys.length, gameProgressLimits.maxMetricEntries, 'merged metric map');
|
|
250
|
+
const mergedEntries = [];
|
|
251
|
+
for (const key of keys) {
|
|
252
|
+
const serverValue = Object.hasOwn(server, key) ? server[key] : undefined;
|
|
253
|
+
const guestValue = Object.hasOwn(guest, key) ? guest[key] : undefined;
|
|
254
|
+
if (serverValue === undefined) {
|
|
255
|
+
if (guestValue !== undefined) {
|
|
256
|
+
mergedEntries.push([key, guestValue]);
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (guestValue === undefined) {
|
|
261
|
+
mergedEntries.push([key, serverValue]);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
mergedEntries.push([key, select(serverValue, guestValue)]);
|
|
265
|
+
}
|
|
266
|
+
return Object.fromEntries(mergedEntries);
|
|
267
|
+
}
|
|
268
|
+
function selectActiveProgress(server, guest) {
|
|
269
|
+
if (server === undefined) {
|
|
270
|
+
return guest;
|
|
271
|
+
}
|
|
272
|
+
if (guest === undefined) {
|
|
273
|
+
return server;
|
|
274
|
+
}
|
|
275
|
+
return Date.parse(guest.updatedAt) > Date.parse(server.updatedAt) ? guest : server;
|
|
276
|
+
}
|
|
277
|
+
function normalizeProgressPayload(input) {
|
|
278
|
+
assertRecord(input, 'activeProgress.payload');
|
|
279
|
+
const normalized = normalizeGameProgressValue(input, 'activeProgress.payload', new Set(), { nodes: 0, stringUnits: 0 }, 0);
|
|
280
|
+
if (!isGameProgressRecord(normalized)) {
|
|
281
|
+
throw new Error('activeProgress.payload must be an object.');
|
|
282
|
+
}
|
|
283
|
+
return normalized;
|
|
284
|
+
}
|
|
285
|
+
function normalizeGameProgressValue(input, label, ancestors, budget, depth) {
|
|
286
|
+
if (depth > gameProgressLimits.maxPayloadDepth) {
|
|
287
|
+
throw new Error(`activeProgress.payload must not exceed depth ${String(gameProgressLimits.maxPayloadDepth)}.`);
|
|
288
|
+
}
|
|
289
|
+
budget.nodes += 1;
|
|
290
|
+
if (budget.nodes > gameProgressLimits.maxPayloadNodes) {
|
|
291
|
+
throw new Error(`activeProgress.payload must not exceed ${String(gameProgressLimits.maxPayloadNodes)} nodes.`);
|
|
292
|
+
}
|
|
293
|
+
if (input === null
|
|
294
|
+
|| typeof input === 'boolean') {
|
|
295
|
+
return input;
|
|
296
|
+
}
|
|
297
|
+
if (typeof input === 'string') {
|
|
298
|
+
consumePayloadStringBudget(budget, input.length);
|
|
299
|
+
return input;
|
|
300
|
+
}
|
|
301
|
+
if (typeof input === 'number') {
|
|
302
|
+
if (!Number.isFinite(input)) {
|
|
303
|
+
throw new Error(`${label} must contain only finite numbers.`);
|
|
304
|
+
}
|
|
305
|
+
return input;
|
|
306
|
+
}
|
|
307
|
+
if (typeof input !== 'object') {
|
|
308
|
+
throw new Error(`${label} must be JSON-compatible.`);
|
|
309
|
+
}
|
|
310
|
+
if (ancestors.has(input)) {
|
|
311
|
+
throw new Error(`${label} must not contain circular references.`);
|
|
312
|
+
}
|
|
313
|
+
ancestors.add(input);
|
|
314
|
+
if (Array.isArray(input)) {
|
|
315
|
+
assertCollectionLimit(input.length, gameProgressLimits.maxPayloadNodes, 'activeProgress.payload array');
|
|
316
|
+
const output = Array.from(input, (value, index) => {
|
|
317
|
+
return normalizeGameProgressValue(value, `${label}[${String(index)}]`, ancestors, budget, depth + 1);
|
|
318
|
+
});
|
|
319
|
+
ancestors.delete(input);
|
|
320
|
+
return output;
|
|
321
|
+
}
|
|
322
|
+
assertRecord(input, label);
|
|
323
|
+
assertOwnEnumerablePropertyLimit(input, gameProgressLimits.maxPayloadNodes, label);
|
|
324
|
+
const entries = Object.entries(input)
|
|
325
|
+
.sort(([left], [right]) => compareCodeUnits(left, right))
|
|
326
|
+
.map(([key, value]) => {
|
|
327
|
+
const normalizedKey = normalizeIdentifier(key, `${label} key`);
|
|
328
|
+
consumePayloadStringBudget(budget, normalizedKey.length);
|
|
329
|
+
return [
|
|
330
|
+
normalizedKey,
|
|
331
|
+
normalizeGameProgressValue(value, `${label}.${key}`, ancestors, budget, depth + 1),
|
|
332
|
+
];
|
|
333
|
+
});
|
|
334
|
+
ancestors.delete(input);
|
|
335
|
+
return Object.fromEntries(entries);
|
|
336
|
+
}
|
|
337
|
+
function consumePayloadStringBudget(budget, stringUnits) {
|
|
338
|
+
budget.stringUnits += stringUnits;
|
|
339
|
+
if (budget.stringUnits > gameProgressLimits.maxPayloadStringUnits) {
|
|
340
|
+
throw new Error(`activeProgress.payload strings must not exceed ${String(gameProgressLimits.maxPayloadStringUnits)} total characters.`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function isGameProgressRecord(input) {
|
|
344
|
+
return input !== null && typeof input === 'object' && !Array.isArray(input);
|
|
345
|
+
}
|
|
346
|
+
function compareCodeUnits(left, right) {
|
|
347
|
+
if (left < right) {
|
|
348
|
+
return -1;
|
|
349
|
+
}
|
|
350
|
+
return left > right ? 1 : 0;
|
|
351
|
+
}
|
|
352
|
+
function assertMatchingProgressLinkIdentity(existing, request) {
|
|
353
|
+
if (existing.authoritativePlayerId !== request.authoritativePlayerId
|
|
354
|
+
|| existing.guestId !== request.guestId) {
|
|
355
|
+
throw new Error('A reconciliation key cannot be reused for another player or guest.');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function assertOnlyFields(input, allowedFields, label) {
|
|
359
|
+
for (const key of Object.keys(input)) {
|
|
360
|
+
if (!allowedFields.has(key)) {
|
|
361
|
+
throw new Error(`${label} contains unsupported field ${key}.`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function assertCollectionLimit(length, maximum, label) {
|
|
366
|
+
if (length > maximum) {
|
|
367
|
+
throw new Error(`${label} must not contain more than ${String(maximum)} entries.`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function assertRecord(input, label) {
|
|
371
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
372
|
+
throw new Error(`${label} must be an object.`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function normalizeIdentifier(input, label) {
|
|
376
|
+
if (typeof input !== 'string' || input.length === 0 || input.trim() !== input) {
|
|
377
|
+
throw new Error(`${label} must be a non-empty, trimmed string.`);
|
|
378
|
+
}
|
|
379
|
+
if (input.length > gameProgressLimits.maxIdentifierLength) {
|
|
380
|
+
throw new Error(`${label} must not exceed ${String(gameProgressLimits.maxIdentifierLength)} characters.`);
|
|
381
|
+
}
|
|
382
|
+
if (/[\x00-\x1F\x7F]/u.test(input)) {
|
|
383
|
+
throw new Error(`${label} must not contain control characters.`);
|
|
384
|
+
}
|
|
385
|
+
return input;
|
|
386
|
+
}
|
|
387
|
+
function normalizeTimestamp(input, label) {
|
|
388
|
+
const value = normalizeIdentifier(input, label);
|
|
389
|
+
const timestamp = Date.parse(value);
|
|
390
|
+
if (!Number.isFinite(timestamp)) {
|
|
391
|
+
throw new Error(`${label} must be a valid timestamp.`);
|
|
392
|
+
}
|
|
393
|
+
return new Date(timestamp).toISOString();
|
|
394
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AnalyticsSink } from '@mpgd/analytics';
|
|
2
|
+
import type { PlatformGateway, PlatformTarget } from '@mpgd/platform';
|
|
3
|
+
import { type GameServicesBackendApi, type GameServicesClient } from './client.js';
|
|
4
|
+
import type { GameServicesLedgerTarget } from './types.js';
|
|
5
|
+
export type GameServicesAuthorityMode = 'production' | 'non-production';
|
|
6
|
+
export type GameServicesRuntimeMode = 'disabled' | 'local' | 'http' | 'orpc';
|
|
7
|
+
export type GameServicesDisabledReason = 'unsupported_target' | 'missing_authoritative_backend' | 'invalid_authoritative_backend' | 'local_backend_not_allowed' | 'local_backend_unavailable';
|
|
8
|
+
export interface GameServicesRuntime {
|
|
9
|
+
readonly mode: GameServicesRuntimeMode;
|
|
10
|
+
readonly reason?: GameServicesDisabledReason;
|
|
11
|
+
readonly baseUrl?: string;
|
|
12
|
+
readonly target?: GameServicesLedgerTarget;
|
|
13
|
+
readonly client?: GameServicesClient;
|
|
14
|
+
}
|
|
15
|
+
export interface CreateGameServicesRuntimeInput {
|
|
16
|
+
readonly gateway: PlatformGateway;
|
|
17
|
+
readonly playerId: string;
|
|
18
|
+
readonly authorityMode: GameServicesAuthorityMode;
|
|
19
|
+
readonly target?: PlatformTarget | string;
|
|
20
|
+
readonly baseUrl?: string;
|
|
21
|
+
readonly transport?: 'http' | 'orpc';
|
|
22
|
+
readonly allowLocalBackend?: boolean;
|
|
23
|
+
readonly localBackend?: GameServicesBackendApi;
|
|
24
|
+
readonly analytics?: AnalyticsSink;
|
|
25
|
+
readonly analyticsSessionId?: string;
|
|
26
|
+
readonly now?: () => string;
|
|
27
|
+
}
|
|
28
|
+
export declare function createGameServicesRuntime(input: CreateGameServicesRuntimeInput): GameServicesRuntime;
|
|
29
|
+
export declare function resolveGameServicesLedgerTarget(target: PlatformTarget | string): GameServicesLedgerTarget | null;
|
|
30
|
+
export declare function resolveGameServicesAuthorityMode(profile: string): GameServicesAuthorityMode;
|
|
31
|
+
export declare function resolveGameServicesTransport(transport: string | undefined): 'http' | 'orpc';
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createGameServicesClient, createGameServicesFetchBackendTransport, createGameServicesHttpBackendApi, createGameServicesOrpcBackendApi, createGameServicesOrpcClient, } from './client.js';
|
|
2
|
+
export function createGameServicesRuntime(input) {
|
|
3
|
+
assertAuthorityMode(input.authorityMode);
|
|
4
|
+
assertTransport(input.transport);
|
|
5
|
+
const target = resolveGameServicesLedgerTarget(input.target ?? input.gateway.target);
|
|
6
|
+
if (target === null) {
|
|
7
|
+
return disabledRuntime('unsupported_target');
|
|
8
|
+
}
|
|
9
|
+
const baseUrl = normalizeBaseUrl(input.baseUrl);
|
|
10
|
+
let backend;
|
|
11
|
+
let mode;
|
|
12
|
+
if (baseUrl !== undefined) {
|
|
13
|
+
const url = parseAbsoluteUrl(baseUrl);
|
|
14
|
+
if (input.authorityMode === 'production' && !isPublicHttpsUrl(url)) {
|
|
15
|
+
return disabledRuntime('invalid_authoritative_backend', target);
|
|
16
|
+
}
|
|
17
|
+
if (url === undefined) {
|
|
18
|
+
throw new Error('Game Services baseUrl must be a valid absolute URL.');
|
|
19
|
+
}
|
|
20
|
+
mode = input.transport === 'orpc' ? 'orpc' : 'http';
|
|
21
|
+
backend = mode === 'orpc'
|
|
22
|
+
? createGameServicesOrpcBackendApi(createGameServicesOrpcClient({ url: baseUrl }))
|
|
23
|
+
: createGameServicesHttpBackendApi({
|
|
24
|
+
transport: createGameServicesFetchBackendTransport({ baseUrl }),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
if (input.authorityMode === 'production') {
|
|
29
|
+
return disabledRuntime('missing_authoritative_backend', target);
|
|
30
|
+
}
|
|
31
|
+
if (input.allowLocalBackend !== true) {
|
|
32
|
+
return disabledRuntime('local_backend_not_allowed', target);
|
|
33
|
+
}
|
|
34
|
+
if (input.localBackend === undefined) {
|
|
35
|
+
return disabledRuntime('local_backend_unavailable', target);
|
|
36
|
+
}
|
|
37
|
+
mode = 'local';
|
|
38
|
+
backend = input.localBackend;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
mode,
|
|
42
|
+
...(baseUrl === undefined ? {} : { baseUrl }),
|
|
43
|
+
target,
|
|
44
|
+
client: createGameServicesClient({
|
|
45
|
+
gateway: input.gateway,
|
|
46
|
+
backend,
|
|
47
|
+
playerId: input.playerId,
|
|
48
|
+
target,
|
|
49
|
+
...(input.analytics === undefined ? {} : { analytics: input.analytics }),
|
|
50
|
+
...(input.analyticsSessionId === undefined
|
|
51
|
+
? {}
|
|
52
|
+
: { analyticsSessionId: input.analyticsSessionId }),
|
|
53
|
+
...(input.now === undefined ? {} : { now: input.now }),
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function resolveGameServicesLedgerTarget(target) {
|
|
58
|
+
if (target === 'browser'
|
|
59
|
+
|| target === 'android'
|
|
60
|
+
|| target === 'ios'
|
|
61
|
+
|| target === 'ait'
|
|
62
|
+
|| target === 'reddit') {
|
|
63
|
+
return target;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
export function resolveGameServicesAuthorityMode(profile) {
|
|
68
|
+
if (profile.length === 0 || profile.trim() !== profile) {
|
|
69
|
+
throw new Error('Game Services profile must be non-empty without surrounding whitespace.');
|
|
70
|
+
}
|
|
71
|
+
return profile === 'production' ? 'production' : 'non-production';
|
|
72
|
+
}
|
|
73
|
+
export function resolveGameServicesTransport(transport) {
|
|
74
|
+
if (transport === undefined || transport.length === 0 || transport === 'http') {
|
|
75
|
+
return 'http';
|
|
76
|
+
}
|
|
77
|
+
if (transport === 'orpc') {
|
|
78
|
+
return 'orpc';
|
|
79
|
+
}
|
|
80
|
+
throw new Error('Game Services transport must be http or orpc.');
|
|
81
|
+
}
|
|
82
|
+
function disabledRuntime(reason, target) {
|
|
83
|
+
return {
|
|
84
|
+
mode: 'disabled',
|
|
85
|
+
reason,
|
|
86
|
+
...(target === undefined ? {} : { target }),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function normalizeBaseUrl(baseUrl) {
|
|
90
|
+
const normalized = baseUrl?.trim();
|
|
91
|
+
return normalized === undefined || normalized.length === 0 ? undefined : normalized;
|
|
92
|
+
}
|
|
93
|
+
function isPublicHttpsUrl(url) {
|
|
94
|
+
return url !== undefined
|
|
95
|
+
&& url.protocol === 'https:'
|
|
96
|
+
&& url.username.length === 0
|
|
97
|
+
&& url.password.length === 0
|
|
98
|
+
&& !isNonPublicHostname(url.hostname);
|
|
99
|
+
}
|
|
100
|
+
function parseAbsoluteUrl(value) {
|
|
101
|
+
try {
|
|
102
|
+
return new URL(value);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function isNonPublicHostname(hostname) {
|
|
109
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/u, '');
|
|
110
|
+
if (normalized === 'localhost'
|
|
111
|
+
|| normalized.endsWith('.localhost')
|
|
112
|
+
|| normalized.endsWith('.local')) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
if (/^(?:\d{1,3}\.){3}\d{1,3}$/u.test(normalized)) {
|
|
116
|
+
return isNonPublicIpv4(normalized);
|
|
117
|
+
}
|
|
118
|
+
return normalized.includes(':') && isNonPublicIpv6(normalized);
|
|
119
|
+
}
|
|
120
|
+
function isNonPublicIpv4(address) {
|
|
121
|
+
const [first = 0, second = 0, third = 0] = address.split('.').map(Number);
|
|
122
|
+
return first === 0
|
|
123
|
+
|| first === 10
|
|
124
|
+
|| first === 127
|
|
125
|
+
|| (first === 100 && second >= 64 && second <= 127)
|
|
126
|
+
|| (first === 169 && second === 254)
|
|
127
|
+
|| (first === 172 && second >= 16 && second <= 31)
|
|
128
|
+
|| (first === 192 && second === 0 && third === 0)
|
|
129
|
+
|| (first === 192 && second === 0 && third === 2)
|
|
130
|
+
|| (first === 192 && second === 168)
|
|
131
|
+
|| (first === 198 && (second === 18 || second === 19))
|
|
132
|
+
|| (first === 198 && second === 51 && third === 100)
|
|
133
|
+
|| (first === 203 && second === 0 && third === 113)
|
|
134
|
+
|| first >= 224;
|
|
135
|
+
}
|
|
136
|
+
function isNonPublicIpv6(address) {
|
|
137
|
+
const words = expandIpv6(address);
|
|
138
|
+
if (words === undefined) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
if (words.slice(0, 7).every((word) => word === 0)) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff) {
|
|
145
|
+
return isNonPublicIpv4(wordsToIpv4(words));
|
|
146
|
+
}
|
|
147
|
+
if (words.slice(0, 6).every((word) => word === 0)) {
|
|
148
|
+
return isNonPublicIpv4(wordsToIpv4(words));
|
|
149
|
+
}
|
|
150
|
+
const first = words[0] ?? 0;
|
|
151
|
+
return (first & 0xfe00) === 0xfc00
|
|
152
|
+
|| (first & 0xffc0) === 0xfe80
|
|
153
|
+
|| (first & 0xff00) === 0xff00
|
|
154
|
+
|| (first === 0x2001 && words[1] === 0x0db8);
|
|
155
|
+
}
|
|
156
|
+
function wordsToIpv4(words) {
|
|
157
|
+
return `${(words[6] ?? 0) >> 8}.${(words[6] ?? 0) & 0xff}.`
|
|
158
|
+
+ `${(words[7] ?? 0) >> 8}.${(words[7] ?? 0) & 0xff}`;
|
|
159
|
+
}
|
|
160
|
+
function expandIpv6(address) {
|
|
161
|
+
const sections = address.split('::');
|
|
162
|
+
if (sections.length > 2) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
const head = ipv6Words(sections[0] ?? '');
|
|
166
|
+
const tail = ipv6Words(sections[1] ?? '');
|
|
167
|
+
if (head === undefined || tail === undefined) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const omitted = 8 - head.length - tail.length;
|
|
171
|
+
if ((sections.length === 1 && omitted !== 0) || (sections.length === 2 && omitted < 1)) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
return [...head, ...Array.from({ length: omitted }, () => 0), ...tail];
|
|
175
|
+
}
|
|
176
|
+
function ipv6Words(section) {
|
|
177
|
+
if (section.length === 0) {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
const words = [];
|
|
181
|
+
for (const segment of section.split(':')) {
|
|
182
|
+
if (!/^[0-9a-f]{1,4}$/u.test(segment)) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
words.push(Number.parseInt(segment, 16));
|
|
186
|
+
}
|
|
187
|
+
return words;
|
|
188
|
+
}
|
|
189
|
+
function assertAuthorityMode(authorityMode) {
|
|
190
|
+
if (authorityMode !== 'production' && authorityMode !== 'non-production') {
|
|
191
|
+
throw new Error('authorityMode must be production or non-production.');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function assertTransport(transport) {
|
|
195
|
+
if (transport !== undefined && transport !== 'http' && transport !== 'orpc') {
|
|
196
|
+
throw new Error('Game Services transport must be http or orpc.');
|
|
197
|
+
}
|
|
198
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -75,4 +75,5 @@ export declare function createGameServicesRouter(backend: GameServicesBackendApi
|
|
|
75
75
|
};
|
|
76
76
|
export declare function createGameServicesRpcFetchHandler(router: ReturnType<typeof createGameServicesRouter>, options?: CreateGameServicesRpcFetchHandlerOptions): (request: Request) => Promise<Response>;
|
|
77
77
|
export declare function createGameServicesHttpFetchHandler(handler: GameServicesBackendApiHandler, options?: CreateGameServicesFetchHandlerOptions): (request: Request) => Promise<Response>;
|
|
78
|
-
export
|
|
78
|
+
export * from './notification-delivery';
|
|
79
|
+
export * from './progress-link';
|
package/dist/server.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function assertOwnEnumerablePropertyLimit(input: Record<string, unknown>, maximum: number, label: string): void;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function assertOwnEnumerablePropertyLimit(input, maximum, label) {
|
|
2
|
+
let propertyCount = 0;
|
|
3
|
+
for (const key in input) {
|
|
4
|
+
if (Object.hasOwn(input, key)) {
|
|
5
|
+
propertyCount += 1;
|
|
6
|
+
if (propertyCount > maximum) {
|
|
7
|
+
throw new Error(`${label} must not contain more than ${String(maximum)} entries.`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|