@mpgd/adapter-verse8 0.1.0 → 0.2.1
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/ads-contract.d.ts +1 -0
- package/dist/ads-contract.js +1 -0
- package/dist/agent8-services.d.ts +83 -0
- package/dist/agent8-services.js +928 -0
- package/dist/agent8.d.ts +37 -0
- package/dist/agent8.js +276 -0
- package/dist/index.d.ts +68 -1
- package/dist/index.js +273 -32
- package/dist/server.d.ts +40 -0
- package/dist/server.js +258 -0
- package/package.json +14 -3
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
import { assertGetVerifiedLeaderboardSnapshotRequest, assertLeaderboardRankedEntry, assertRecordVerifiedLeaderboardAttemptRequest, assertRecordVerifiedLeaderboardAttemptResponse, assertVerifiedLeaderboardAttempt, assertVerifiedLeaderboardDefinition, assertVerifiedLeaderboardSnapshot, createVerifiedLeaderboardCursor, parseVerifiedLeaderboardCursor, } from '@mpgd/game-services/verified-leaderboard';
|
|
2
|
+
const defaultStorageStateNamespace = 'mpgdVerse8Storage';
|
|
3
|
+
const defaultLeaderboardCollectionNamespace = 'mpgdVerse8Leaderboard';
|
|
4
|
+
const defaultMaximumStorageEntries = 128;
|
|
5
|
+
const defaultMaximumStorageValueBytes = 64 * 1024;
|
|
6
|
+
const defaultMaximumStorageStateBytes = 512 * 1024;
|
|
7
|
+
const defaultMaximumLeaderboardParticipants = 1000;
|
|
8
|
+
const maximumLeaderboardParticipantsLimit = 10000;
|
|
9
|
+
const minimumPersistenceSecretBytes = 32;
|
|
10
|
+
const maximumPersistenceSecretBytes = 1024;
|
|
11
|
+
const sha256DigestPattern = /^[0-9a-f]{64}$/;
|
|
12
|
+
const sha256RoundConstants = Uint32Array.from(('428a2f98 71374491 b5c0fbcf e9b5dba5 3956c25b 59f111f1 923f82a4 ab1c5ed5 '
|
|
13
|
+
+ 'd807aa98 12835b01 243185be 550c7dc3 72be5d74 80deb1fe 9bdc06a7 c19bf174 '
|
|
14
|
+
+ 'e49b69c1 efbe4786 0fc19dc6 240ca1cc 2de92c6f 4a7484aa 5cb0a9dc 76f988da '
|
|
15
|
+
+ '983e5152 a831c66d b00327c8 bf597fc7 c6e00bf3 d5a79147 06ca6351 14292967 '
|
|
16
|
+
+ '27b70a85 2e1b2138 4d2c6dfc 53380d13 650a7354 766a0abb 81c2c92e 92722c85 '
|
|
17
|
+
+ 'a2bfe8a1 a81a664b c24b8b70 c76c51a3 d192e819 d6990624 f40e3585 106aa070 '
|
|
18
|
+
+ '19a4c116 1e376c08 2748774c 34b0bcb5 391c0cb3 4ed8aa4a 5b9cca4f 682e6ff3 '
|
|
19
|
+
+ '748f82ee 78a5636f 84c87814 8cc70208 90befffa a4506ceb bef9a3f7 c67178f2').split(' '), (value) => Number.parseInt(value, 16));
|
|
20
|
+
export function createVerse8Agent8StorageService(options) {
|
|
21
|
+
if (!isRecord(options)) {
|
|
22
|
+
throw new Error('Verse8 Agent8 storage options are required.');
|
|
23
|
+
}
|
|
24
|
+
assertPrivateStorageCodec(options.codec);
|
|
25
|
+
const persistenceKey = readPersistenceSecret(options.persistenceSecret, 'persistenceSecret');
|
|
26
|
+
const namespace = normalizeNamespace(options.stateNamespace ?? defaultStorageStateNamespace, 'stateNamespace');
|
|
27
|
+
const maximumEntries = positiveSafeInteger(options.maximumEntries ?? defaultMaximumStorageEntries, 'maximumEntries');
|
|
28
|
+
const maximumValueBytes = positiveSafeInteger(options.maximumValueBytes ?? defaultMaximumStorageValueBytes, 'maximumValueBytes');
|
|
29
|
+
const maximumStateBytes = positiveSafeInteger(options.maximumStateBytes ?? defaultMaximumStorageStateBytes, 'maximumStateBytes');
|
|
30
|
+
if (maximumValueBytes > maximumStateBytes) {
|
|
31
|
+
throw new Error('maximumValueBytes must not exceed maximumStateBytes.');
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
async load(account, input, context) {
|
|
35
|
+
assertAccount(account);
|
|
36
|
+
assertStorageKey(input.key);
|
|
37
|
+
const userState = await context.getUserState(account);
|
|
38
|
+
const state = readStorageState(userState[namespace]);
|
|
39
|
+
assertStorageStateSize(state, maximumStateBytes);
|
|
40
|
+
assertStorageEntryCount(state, maximumEntries);
|
|
41
|
+
const storageKey = createPrivateStorageKey(persistenceKey, account, input.key);
|
|
42
|
+
if (!Object.hasOwn(state.values, storageKey)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const envelope = state.values[storageKey];
|
|
46
|
+
if (envelope === undefined) {
|
|
47
|
+
throw new Error('Stored Verse8 cloud save state is invalid.');
|
|
48
|
+
}
|
|
49
|
+
const value = cloneJsonValue(await options.codec.open({
|
|
50
|
+
account,
|
|
51
|
+
key: input.key,
|
|
52
|
+
envelope: cloneStorageEnvelope(envelope),
|
|
53
|
+
}));
|
|
54
|
+
if (utf8Bytes(JSON.stringify(value)).length > maximumValueBytes) {
|
|
55
|
+
throw new Error('Decrypted Verse8 cloud save value exceeds maximumValueBytes.');
|
|
56
|
+
}
|
|
57
|
+
return { value };
|
|
58
|
+
},
|
|
59
|
+
async save(account, input, context) {
|
|
60
|
+
assertAccount(account);
|
|
61
|
+
assertStorageKey(input.key);
|
|
62
|
+
const value = cloneJsonValue(input.value);
|
|
63
|
+
if (utf8Bytes(JSON.stringify(value)).length > maximumValueBytes) {
|
|
64
|
+
throw new Error('Verse8 cloud save value exceeds maximumValueBytes.');
|
|
65
|
+
}
|
|
66
|
+
const storageKey = createPrivateStorageKey(persistenceKey, account, input.key);
|
|
67
|
+
const envelope = readStorageEnvelope(await options.codec.seal({
|
|
68
|
+
account,
|
|
69
|
+
key: input.key,
|
|
70
|
+
value: cloneJsonValue(value),
|
|
71
|
+
}));
|
|
72
|
+
if (utf8Bytes(JSON.stringify(envelope)).length > maximumStateBytes) {
|
|
73
|
+
throw new Error('Encrypted Verse8 cloud save value exceeds maximumStateBytes.');
|
|
74
|
+
}
|
|
75
|
+
await context.lock(storageLockKey(account), async () => {
|
|
76
|
+
const userState = await context.getUserState(account);
|
|
77
|
+
const state = readStorageState(userState[namespace]);
|
|
78
|
+
assertStorageStateSize(state, maximumStateBytes);
|
|
79
|
+
assertStorageEntryCount(state, maximumEntries);
|
|
80
|
+
const isNewKey = !Object.hasOwn(state.values, storageKey);
|
|
81
|
+
if (isNewKey && Object.keys(state.values).length >= maximumEntries) {
|
|
82
|
+
throw new Error('Verse8 cloud save exceeds maximumEntries.');
|
|
83
|
+
}
|
|
84
|
+
const next = {
|
|
85
|
+
version: 2,
|
|
86
|
+
values: {
|
|
87
|
+
...state.values,
|
|
88
|
+
[storageKey]: envelope,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
if (utf8Bytes(JSON.stringify(next)).length > maximumStateBytes) {
|
|
92
|
+
throw new Error('Verse8 cloud save state exceeds maximumStateBytes.');
|
|
93
|
+
}
|
|
94
|
+
await context.updateUserState(account, { [namespace]: next });
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export function createVerse8Agent8LeaderboardBoundary(options) {
|
|
100
|
+
const provider = createVerse8Agent8VerifiedLeaderboardProvider(options.context, options);
|
|
101
|
+
return {
|
|
102
|
+
async submit(account, submission) {
|
|
103
|
+
assertAccount(account);
|
|
104
|
+
const verified = await options.verifySubmission({ account, submission });
|
|
105
|
+
if (verified === null) {
|
|
106
|
+
return {
|
|
107
|
+
accepted: false,
|
|
108
|
+
reason: 'VERIFICATION_REJECTED',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
assertRecordVerifiedLeaderboardAttemptRequest(verified);
|
|
112
|
+
if (verified.attempt.participantId !== account) {
|
|
113
|
+
throw new Error('Verified leaderboard participant must match the authenticated account.');
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
accepted: true,
|
|
117
|
+
record: await provider.recordVerifiedAttempt(verified),
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
getSnapshot(account, input) {
|
|
121
|
+
assertAccount(account);
|
|
122
|
+
return provider.getSnapshot({
|
|
123
|
+
...input,
|
|
124
|
+
participantId: account,
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export function createVerse8Agent8VerifiedLeaderboardProvider(context, options) {
|
|
130
|
+
if (!isRecord(options)) {
|
|
131
|
+
throw new Error('Verse8 Agent8 leaderboard options are required.');
|
|
132
|
+
}
|
|
133
|
+
const persistenceKey = readPersistenceSecret(options.persistenceSecret, 'persistenceSecret');
|
|
134
|
+
const namespace = normalizeNamespace(options.collectionNamespace ?? defaultLeaderboardCollectionNamespace, 'collectionNamespace');
|
|
135
|
+
const maximumParticipants = positiveSafeInteger(options.maximumParticipants ?? defaultMaximumLeaderboardParticipants, 'maximumParticipants');
|
|
136
|
+
if (maximumParticipants > maximumLeaderboardParticipantsLimit) {
|
|
137
|
+
throw new Error(`maximumParticipants must not exceed ${String(maximumLeaderboardParticipantsLimit)}.`);
|
|
138
|
+
}
|
|
139
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
140
|
+
return {
|
|
141
|
+
async recordVerifiedAttempt(input) {
|
|
142
|
+
assertRecordVerifiedLeaderboardAttemptRequest(input);
|
|
143
|
+
const definition = cloneDefinition(input.definition);
|
|
144
|
+
const attempt = cloneAttempt(input.attempt);
|
|
145
|
+
const attemptDigest = createAttemptDigest(persistenceKey, attempt);
|
|
146
|
+
const collections = createLeaderboardCollections(namespace, definition.leaderboardId);
|
|
147
|
+
const decisionCollection = createAttemptDecisionCollection(namespace, definition.leaderboardId, attempt.attemptId, persistenceKey);
|
|
148
|
+
return context.lock(leaderboardLockKey(definition.leaderboardId), async () => {
|
|
149
|
+
await ensureDefinition(context, collections, definition);
|
|
150
|
+
const loadedEntries = await loadEntries(context, collections, definition, maximumParticipants);
|
|
151
|
+
const entries = await flushPendingEntryDecisions(context, namespace, collections, definition, loadedEntries, persistenceKey);
|
|
152
|
+
const existingDecision = await loadAttemptDecision(context, decisionCollection, attempt, attemptDigest);
|
|
153
|
+
if (existingDecision !== undefined) {
|
|
154
|
+
return cloneRecordResponse(existingDecision.response, true);
|
|
155
|
+
}
|
|
156
|
+
const orphanedAttemptEntry = entries.find((entry) => entry.attempt.attemptId === attempt.attemptId);
|
|
157
|
+
if (orphanedAttemptEntry !== undefined
|
|
158
|
+
&& orphanedAttemptEntry.attemptDigest !== attemptDigest) {
|
|
159
|
+
throw attemptConflict(attempt.attemptId);
|
|
160
|
+
}
|
|
161
|
+
const retained = entries.find((entry) => entry.attempt.participantId === attempt.participantId);
|
|
162
|
+
let nextEntries;
|
|
163
|
+
if (retained === undefined) {
|
|
164
|
+
if (entries.length >= maximumParticipants) {
|
|
165
|
+
throw new Error('Verse8 leaderboard exceeds maximumParticipants.');
|
|
166
|
+
}
|
|
167
|
+
nextEntries = [...entries, createStoredEntry('', attempt, attemptDigest)];
|
|
168
|
+
}
|
|
169
|
+
else if (shouldReplaceRetainedAttempt(definition, retained.attempt, attempt)) {
|
|
170
|
+
const nextEntry = createStoredEntry(retained.itemId, attempt, attemptDigest);
|
|
171
|
+
nextEntries = entries.map((entry) => entry.itemId === retained.itemId ? nextEntry : entry);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
nextEntries = entries;
|
|
175
|
+
}
|
|
176
|
+
const rankedEntries = rankEntries(definition, nextEntries);
|
|
177
|
+
const current = rankedEntries.find((entry) => entry.participantId === attempt.participantId);
|
|
178
|
+
if (current === undefined) {
|
|
179
|
+
throw new Error('Retained Verse8 leaderboard entry was not found after recording.');
|
|
180
|
+
}
|
|
181
|
+
const attemptRetained = current.attemptId === attempt.attemptId;
|
|
182
|
+
const response = {
|
|
183
|
+
recorded: true,
|
|
184
|
+
alreadyProcessed: false,
|
|
185
|
+
retained: attemptRetained,
|
|
186
|
+
entry: current,
|
|
187
|
+
...(attemptRetained ? {} : { reason: 'ATTEMPT_NOT_RETAINED' }),
|
|
188
|
+
};
|
|
189
|
+
assertRecordVerifiedLeaderboardAttemptResponse(response);
|
|
190
|
+
const pendingDecision = {
|
|
191
|
+
attemptDigest,
|
|
192
|
+
response: cloneRecordResponse(response, false),
|
|
193
|
+
};
|
|
194
|
+
if (attemptRetained) {
|
|
195
|
+
let retainedItemId;
|
|
196
|
+
if (retained === undefined) {
|
|
197
|
+
const added = await context.addCollectionItem(collections.entries, createEntryItem(attempt, attemptDigest, pendingDecision));
|
|
198
|
+
retainedItemId = readItemId(added, 'Verse8 retained leaderboard entry');
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
retainedItemId = retained.itemId;
|
|
202
|
+
await context.updateCollectionItem(collections.entries, {
|
|
203
|
+
__id: retainedItemId,
|
|
204
|
+
...createEntryItem(attempt, attemptDigest, pendingDecision),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
await ensureAttemptDecision(context, decisionCollection, attempt, pendingDecision);
|
|
208
|
+
await context.updateCollectionItem(collections.entries, {
|
|
209
|
+
__id: retainedItemId,
|
|
210
|
+
...createEntryItem(attempt, attemptDigest),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
await ensureAttemptDecision(context, decisionCollection, attempt, pendingDecision);
|
|
215
|
+
}
|
|
216
|
+
return cloneRecordResponse(response, false);
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
async getSnapshot(input) {
|
|
220
|
+
assertGetVerifiedLeaderboardSnapshotRequest(input);
|
|
221
|
+
const collections = createLeaderboardCollections(namespace, input.leaderboardId);
|
|
222
|
+
return context.lock(leaderboardLockKey(input.leaderboardId), async () => {
|
|
223
|
+
const definition = await loadDefinition(context, collections);
|
|
224
|
+
if (definition === undefined) {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
const loadedEntries = await loadEntries(context, collections, definition, maximumParticipants);
|
|
228
|
+
const entries = await flushPendingEntryDecisions(context, namespace, collections, definition, loadedEntries, persistenceKey);
|
|
229
|
+
const rankedEntries = rankEntries(definition, entries);
|
|
230
|
+
const cursorPosition = input.cursor === undefined
|
|
231
|
+
? undefined
|
|
232
|
+
: parseVerifiedLeaderboardCursor(input.cursor, definition);
|
|
233
|
+
const firstPageIndex = cursorPosition === undefined
|
|
234
|
+
? 0
|
|
235
|
+
: rankedEntries.findIndex((entry) => compareRankedEntryToCursor(definition, entry, cursorPosition) > 0);
|
|
236
|
+
const pageStart = firstPageIndex < 0 ? rankedEntries.length : firstPageIndex;
|
|
237
|
+
const pageEnd = pageStart + (input.limit ?? 10);
|
|
238
|
+
const pageEntries = rankedEntries.slice(pageStart, pageEnd);
|
|
239
|
+
const participantEntry = input.participantId === undefined
|
|
240
|
+
? undefined
|
|
241
|
+
: rankedEntries.find((entry) => entry.participantId === input.participantId);
|
|
242
|
+
const lastEntry = pageEntries.at(-1);
|
|
243
|
+
const nextCursor = lastEntry !== undefined && pageEnd < rankedEntries.length
|
|
244
|
+
? createVerifiedLeaderboardCursor(definition, lastEntry)
|
|
245
|
+
: undefined;
|
|
246
|
+
const snapshot = {
|
|
247
|
+
definition: cloneDefinition(definition),
|
|
248
|
+
entries: pageEntries,
|
|
249
|
+
...(participantEntry === undefined ? {} : { participantEntry }),
|
|
250
|
+
totalParticipants: rankedEntries.length,
|
|
251
|
+
generatedAt: now(),
|
|
252
|
+
...(nextCursor === undefined ? {} : { nextCursor }),
|
|
253
|
+
};
|
|
254
|
+
assertVerifiedLeaderboardSnapshot(snapshot);
|
|
255
|
+
return snapshot;
|
|
256
|
+
});
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function createLeaderboardCollections(namespace, leaderboardId) {
|
|
261
|
+
const boardDigest = sha256(JSON.stringify(['board', leaderboardId]));
|
|
262
|
+
return {
|
|
263
|
+
leaderboardId,
|
|
264
|
+
definitions: `${namespace}D${boardDigest}`,
|
|
265
|
+
entries: `${namespace}E${boardDigest}`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
function createAttemptDecisionCollection(namespace, leaderboardId, attemptId, persistenceKey) {
|
|
269
|
+
const decisionDigest = hmacSha256(persistenceKey, JSON.stringify(['attempt-decision', leaderboardId, attemptId]));
|
|
270
|
+
return `${namespace}A${decisionDigest}`;
|
|
271
|
+
}
|
|
272
|
+
async function ensureDefinition(context, collections, definition) {
|
|
273
|
+
const existing = await loadDefinition(context, collections);
|
|
274
|
+
if (existing === undefined) {
|
|
275
|
+
await context.addCollectionItem(collections.definitions, { ...definition });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (existing.scoreOrder !== definition.scoreOrder
|
|
279
|
+
|| existing.attemptSelection !== definition.attemptSelection) {
|
|
280
|
+
throw new Error(`Leaderboard definition conflict for ${JSON.stringify(definition.leaderboardId)}.`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function loadDefinition(context, collections) {
|
|
284
|
+
const item = await loadSingleCollectionItem(context, collections.definitions, 'Verse8 leaderboard definition');
|
|
285
|
+
if (item === undefined) {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
const definition = {
|
|
289
|
+
leaderboardId: item.leaderboardId,
|
|
290
|
+
scoreOrder: item.scoreOrder,
|
|
291
|
+
attemptSelection: item.attemptSelection,
|
|
292
|
+
};
|
|
293
|
+
assertVerifiedLeaderboardDefinition(definition);
|
|
294
|
+
if (definition.leaderboardId !== collections.leaderboardId) {
|
|
295
|
+
throw new Error('Stored Verse8 leaderboard definition is invalid.');
|
|
296
|
+
}
|
|
297
|
+
return cloneDefinition(definition);
|
|
298
|
+
}
|
|
299
|
+
async function loadAttemptDecision(context, collectionId, attempt, expectedDigest) {
|
|
300
|
+
const item = await loadSingleCollectionItem(context, collectionId, 'Verse8 leaderboard attempt decision');
|
|
301
|
+
if (item === undefined) {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
if (item.attemptDigest !== expectedDigest) {
|
|
305
|
+
throw attemptConflict(attempt.attemptId);
|
|
306
|
+
}
|
|
307
|
+
assertRecordVerifiedLeaderboardAttemptResponse(item.response);
|
|
308
|
+
assertStoredAttemptDecision(attempt, item.response);
|
|
309
|
+
return {
|
|
310
|
+
attemptDigest: expectedDigest,
|
|
311
|
+
response: cloneRecordResponse(item.response, false),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
async function ensureAttemptDecision(context, collectionId, attempt, decision) {
|
|
315
|
+
const existing = await loadAttemptDecision(context, collectionId, attempt, decision.attemptDigest);
|
|
316
|
+
if (existing === undefined) {
|
|
317
|
+
await context.addCollectionItem(collectionId, {
|
|
318
|
+
attemptDigest: decision.attemptDigest,
|
|
319
|
+
response: cloneRecordResponse(decision.response, false),
|
|
320
|
+
});
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (!areRecordResponsesEqual(existing.response, decision.response)) {
|
|
324
|
+
throw new Error('Stored Verse8 leaderboard attempt decision is invalid.');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
async function flushPendingEntryDecisions(context, namespace, collections, definition, entries, persistenceKey) {
|
|
328
|
+
const nextEntries = [...entries];
|
|
329
|
+
for (const [index, entry] of nextEntries.entries()) {
|
|
330
|
+
if (entry.pendingDecision === undefined) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const decisionCollection = createAttemptDecisionCollection(namespace, definition.leaderboardId, entry.attempt.attemptId, persistenceKey);
|
|
334
|
+
await ensureAttemptDecision(context, decisionCollection, entry.attempt, entry.pendingDecision);
|
|
335
|
+
await context.updateCollectionItem(collections.entries, {
|
|
336
|
+
__id: entry.itemId,
|
|
337
|
+
...createEntryItem(entry.attempt, entry.attemptDigest),
|
|
338
|
+
});
|
|
339
|
+
nextEntries[index] = createStoredEntry(entry.itemId, entry.attempt, entry.attemptDigest);
|
|
340
|
+
}
|
|
341
|
+
return nextEntries;
|
|
342
|
+
}
|
|
343
|
+
async function loadEntries(context, collections, definition, maximumParticipants) {
|
|
344
|
+
const items = await context.getCollectionItems(collections.entries, {
|
|
345
|
+
limit: maximumParticipants + 1,
|
|
346
|
+
});
|
|
347
|
+
if (items.length > maximumParticipants) {
|
|
348
|
+
throw new Error('Verse8 leaderboard exceeds maximumParticipants.');
|
|
349
|
+
}
|
|
350
|
+
const entries = items.map((item) => readEntry(item));
|
|
351
|
+
const participantIds = new Set();
|
|
352
|
+
const attemptIds = new Set();
|
|
353
|
+
for (const entry of entries) {
|
|
354
|
+
if (participantIds.has(entry.attempt.participantId)
|
|
355
|
+
|| attemptIds.has(entry.attempt.attemptId)) {
|
|
356
|
+
throw new Error('Stored Verse8 leaderboard entries are duplicated.');
|
|
357
|
+
}
|
|
358
|
+
participantIds.add(entry.attempt.participantId);
|
|
359
|
+
attemptIds.add(entry.attempt.attemptId);
|
|
360
|
+
}
|
|
361
|
+
return [...entries].sort((left, right) => compareLeaderboardOrder(definition.scoreOrder, left.attempt, right.attempt));
|
|
362
|
+
}
|
|
363
|
+
async function loadSingleCollectionItem(context, collectionId, label) {
|
|
364
|
+
const items = await context.getCollectionItems(collectionId, { limit: 2 });
|
|
365
|
+
if (items.length > 1) {
|
|
366
|
+
throw new Error(`${label} is duplicated.`);
|
|
367
|
+
}
|
|
368
|
+
if (items[0] !== undefined) {
|
|
369
|
+
readItemId(items[0], label);
|
|
370
|
+
}
|
|
371
|
+
return items[0];
|
|
372
|
+
}
|
|
373
|
+
function readEntry(item) {
|
|
374
|
+
const itemId = readItemId(item, 'Verse8 retained leaderboard entry');
|
|
375
|
+
if (typeof item.attemptDigest !== 'string' || !sha256DigestPattern.test(item.attemptDigest)) {
|
|
376
|
+
throw new Error('Stored Verse8 leaderboard entry is invalid.');
|
|
377
|
+
}
|
|
378
|
+
const attempt = readStoredAttempt(item.attempt);
|
|
379
|
+
const pendingDecision = item.pendingDecision === undefined
|
|
380
|
+
? undefined
|
|
381
|
+
: readPendingAttemptDecision(item.pendingDecision, attempt, item.attemptDigest);
|
|
382
|
+
return createStoredEntry(itemId, attempt, item.attemptDigest, pendingDecision);
|
|
383
|
+
}
|
|
384
|
+
function readPendingAttemptDecision(input, attempt, expectedDigest) {
|
|
385
|
+
if (!isRecord(input)
|
|
386
|
+
|| Object.keys(input).length !== 2
|
|
387
|
+
|| !Object.hasOwn(input, 'attemptDigest')
|
|
388
|
+
|| !Object.hasOwn(input, 'response')
|
|
389
|
+
|| input.attemptDigest !== expectedDigest) {
|
|
390
|
+
throw new Error('Stored Verse8 leaderboard pending decision is invalid.');
|
|
391
|
+
}
|
|
392
|
+
assertRecordVerifiedLeaderboardAttemptResponse(input.response);
|
|
393
|
+
assertStoredAttemptDecision(attempt, input.response);
|
|
394
|
+
if (!input.response.retained) {
|
|
395
|
+
throw new Error('Stored Verse8 leaderboard pending decision is invalid.');
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
attemptDigest: expectedDigest,
|
|
399
|
+
response: cloneRecordResponse(input.response, false),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function readStoredAttempt(input) {
|
|
403
|
+
if (!isRecord(input)) {
|
|
404
|
+
throw new Error('Stored Verse8 leaderboard entry is invalid.');
|
|
405
|
+
}
|
|
406
|
+
const rankedEntry = {
|
|
407
|
+
rank: 1,
|
|
408
|
+
participantId: input.participantId,
|
|
409
|
+
...(input.participantLabel === undefined
|
|
410
|
+
? {}
|
|
411
|
+
: { participantLabel: input.participantLabel }),
|
|
412
|
+
attemptId: input.attemptId,
|
|
413
|
+
score: input.score,
|
|
414
|
+
...(input.metrics === undefined ? {} : { metrics: input.metrics }),
|
|
415
|
+
completedAt: input.completedAt,
|
|
416
|
+
};
|
|
417
|
+
assertLeaderboardRankedEntry(rankedEntry);
|
|
418
|
+
return {
|
|
419
|
+
participantId: rankedEntry.participantId,
|
|
420
|
+
...(rankedEntry.participantLabel === undefined
|
|
421
|
+
? {}
|
|
422
|
+
: { participantLabel: rankedEntry.participantLabel }),
|
|
423
|
+
attemptId: rankedEntry.attemptId,
|
|
424
|
+
score: rankedEntry.score,
|
|
425
|
+
...(rankedEntry.metrics === undefined ? {} : { metrics: { ...rankedEntry.metrics } }),
|
|
426
|
+
completedAt: rankedEntry.completedAt,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function createStoredEntry(itemId, attempt, attemptDigest, pendingDecision) {
|
|
430
|
+
return {
|
|
431
|
+
itemId,
|
|
432
|
+
attemptDigest,
|
|
433
|
+
attempt: cloneStoredAttempt(attempt),
|
|
434
|
+
...(pendingDecision === undefined
|
|
435
|
+
? {}
|
|
436
|
+
: {
|
|
437
|
+
pendingDecision: {
|
|
438
|
+
attemptDigest: pendingDecision.attemptDigest,
|
|
439
|
+
response: cloneRecordResponse(pendingDecision.response, false),
|
|
440
|
+
},
|
|
441
|
+
}),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
function createEntryItem(attempt, attemptDigest, pendingDecision) {
|
|
445
|
+
return {
|
|
446
|
+
attemptDigest,
|
|
447
|
+
attempt: cloneStoredAttempt(attempt),
|
|
448
|
+
...(pendingDecision === undefined
|
|
449
|
+
? {}
|
|
450
|
+
: {
|
|
451
|
+
pendingDecision: {
|
|
452
|
+
attemptDigest: pendingDecision.attemptDigest,
|
|
453
|
+
response: cloneRecordResponse(pendingDecision.response, false),
|
|
454
|
+
},
|
|
455
|
+
}),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
function rankEntries(definition, entries) {
|
|
459
|
+
return [...entries]
|
|
460
|
+
.sort((left, right) => compareLeaderboardOrder(definition.scoreOrder, left.attempt, right.attempt))
|
|
461
|
+
.map((entry, index) => toRankedEntry(entry.attempt, index + 1));
|
|
462
|
+
}
|
|
463
|
+
function shouldReplaceRetainedAttempt(definition, retained, candidate) {
|
|
464
|
+
if (definition.attemptSelection === 'first') {
|
|
465
|
+
return compareAttemptChronology(candidate, retained) < 0;
|
|
466
|
+
}
|
|
467
|
+
return compareLeaderboardOrder(definition.scoreOrder, candidate, retained) < 0;
|
|
468
|
+
}
|
|
469
|
+
function compareRankedEntryToCursor(definition, entry, cursor) {
|
|
470
|
+
if (entry.score !== cursor.score) {
|
|
471
|
+
return definition.scoreOrder === 'ascending'
|
|
472
|
+
? entry.score - cursor.score
|
|
473
|
+
: cursor.score - entry.score;
|
|
474
|
+
}
|
|
475
|
+
const completedAtDifference = Date.parse(entry.completedAt) - cursor.completedAtMs;
|
|
476
|
+
return completedAtDifference === 0
|
|
477
|
+
? compareOrdinal(entry.attemptId, cursor.attemptId)
|
|
478
|
+
: completedAtDifference;
|
|
479
|
+
}
|
|
480
|
+
function compareAttemptChronology(left, right) {
|
|
481
|
+
const timestampDifference = Date.parse(left.completedAt) - Date.parse(right.completedAt);
|
|
482
|
+
return timestampDifference === 0
|
|
483
|
+
? compareOrdinal(left.attemptId, right.attemptId)
|
|
484
|
+
: timestampDifference;
|
|
485
|
+
}
|
|
486
|
+
function compareLeaderboardOrder(scoreOrder, left, right) {
|
|
487
|
+
if (left.score !== right.score) {
|
|
488
|
+
return scoreOrder === 'ascending' ? left.score - right.score : right.score - left.score;
|
|
489
|
+
}
|
|
490
|
+
return compareAttemptChronology(left, right);
|
|
491
|
+
}
|
|
492
|
+
function compareOrdinal(left, right) {
|
|
493
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
494
|
+
}
|
|
495
|
+
function toRankedEntry(attempt, rank) {
|
|
496
|
+
return {
|
|
497
|
+
rank,
|
|
498
|
+
participantId: attempt.participantId,
|
|
499
|
+
...(attempt.participantLabel === undefined
|
|
500
|
+
? {}
|
|
501
|
+
: { participantLabel: attempt.participantLabel }),
|
|
502
|
+
attemptId: attempt.attemptId,
|
|
503
|
+
score: attempt.score,
|
|
504
|
+
...(attempt.metrics === undefined ? {} : { metrics: { ...attempt.metrics } }),
|
|
505
|
+
completedAt: attempt.completedAt,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function cloneDefinition(input) {
|
|
509
|
+
return {
|
|
510
|
+
leaderboardId: input.leaderboardId,
|
|
511
|
+
scoreOrder: input.scoreOrder,
|
|
512
|
+
attemptSelection: input.attemptSelection,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
function cloneAttempt(input) {
|
|
516
|
+
assertVerifiedLeaderboardAttempt(input);
|
|
517
|
+
return {
|
|
518
|
+
...cloneStoredAttempt(input),
|
|
519
|
+
verification: { ...input.verification },
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function cloneStoredAttempt(input) {
|
|
523
|
+
return {
|
|
524
|
+
participantId: input.participantId,
|
|
525
|
+
...(input.participantLabel === undefined
|
|
526
|
+
? {}
|
|
527
|
+
: { participantLabel: input.participantLabel }),
|
|
528
|
+
attemptId: input.attemptId,
|
|
529
|
+
score: input.score,
|
|
530
|
+
...(input.metrics === undefined ? {} : { metrics: { ...input.metrics } }),
|
|
531
|
+
completedAt: input.completedAt,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
function cloneRecordResponse(input, alreadyProcessed) {
|
|
535
|
+
const response = {
|
|
536
|
+
recorded: true,
|
|
537
|
+
alreadyProcessed,
|
|
538
|
+
retained: input.retained,
|
|
539
|
+
entry: {
|
|
540
|
+
...input.entry,
|
|
541
|
+
...(input.entry.metrics === undefined ? {} : { metrics: { ...input.entry.metrics } }),
|
|
542
|
+
},
|
|
543
|
+
...(input.reason === undefined ? {} : { reason: input.reason }),
|
|
544
|
+
};
|
|
545
|
+
assertRecordVerifiedLeaderboardAttemptResponse(response);
|
|
546
|
+
return response;
|
|
547
|
+
}
|
|
548
|
+
function createAttemptDigest(persistenceKey, attempt) {
|
|
549
|
+
const metrics = attempt.metrics === undefined
|
|
550
|
+
? null
|
|
551
|
+
: Object.keys(attempt.metrics)
|
|
552
|
+
.sort(compareOrdinal)
|
|
553
|
+
.map((key) => [key, attempt.metrics?.[key]]);
|
|
554
|
+
return hmacSha256(persistenceKey, JSON.stringify([
|
|
555
|
+
'attempt-integrity-v1',
|
|
556
|
+
attempt.participantId,
|
|
557
|
+
attempt.attemptId,
|
|
558
|
+
Object.is(attempt.score, -0) ? 0 : attempt.score,
|
|
559
|
+
metrics,
|
|
560
|
+
Date.parse(attempt.completedAt),
|
|
561
|
+
attempt.verification.authorityId,
|
|
562
|
+
attempt.verification.evidenceId,
|
|
563
|
+
Date.parse(attempt.verification.verifiedAt),
|
|
564
|
+
]));
|
|
565
|
+
}
|
|
566
|
+
function assertStoredAttemptDecision(attempt, response) {
|
|
567
|
+
if (response.alreadyProcessed
|
|
568
|
+
|| response.entry.participantId !== attempt.participantId
|
|
569
|
+
|| (response.retained && (response.entry.attemptId !== attempt.attemptId
|
|
570
|
+
|| response.entry.score !== attempt.score
|
|
571
|
+
|| !areMetricMapsEqual(response.entry.metrics, attempt.metrics)
|
|
572
|
+
|| Date.parse(response.entry.completedAt) !== Date.parse(attempt.completedAt)))
|
|
573
|
+
|| (!response.retained && response.entry.attemptId === attempt.attemptId)) {
|
|
574
|
+
throw new Error('Stored Verse8 leaderboard attempt decision is invalid.');
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function areRecordResponsesEqual(left, right) {
|
|
578
|
+
return left.recorded === right.recorded
|
|
579
|
+
&& left.alreadyProcessed === right.alreadyProcessed
|
|
580
|
+
&& left.retained === right.retained
|
|
581
|
+
&& left.reason === right.reason
|
|
582
|
+
&& left.entry.rank === right.entry.rank
|
|
583
|
+
&& left.entry.participantId === right.entry.participantId
|
|
584
|
+
&& left.entry.participantLabel === right.entry.participantLabel
|
|
585
|
+
&& left.entry.attemptId === right.entry.attemptId
|
|
586
|
+
&& left.entry.score === right.entry.score
|
|
587
|
+
&& areMetricMapsEqual(left.entry.metrics, right.entry.metrics)
|
|
588
|
+
&& Date.parse(left.entry.completedAt) === Date.parse(right.entry.completedAt);
|
|
589
|
+
}
|
|
590
|
+
function areMetricMapsEqual(left, right) {
|
|
591
|
+
if (left === undefined || right === undefined) {
|
|
592
|
+
return left === right;
|
|
593
|
+
}
|
|
594
|
+
const leftKeys = Object.keys(left);
|
|
595
|
+
const rightKeys = Object.keys(right);
|
|
596
|
+
return leftKeys.length === rightKeys.length
|
|
597
|
+
&& leftKeys.every((key) => left[key] === right[key]);
|
|
598
|
+
}
|
|
599
|
+
function attemptConflict(attemptId) {
|
|
600
|
+
return new Error(`Attempt id conflict for ${JSON.stringify(attemptId)}.`);
|
|
601
|
+
}
|
|
602
|
+
function readItemId(item, label) {
|
|
603
|
+
if (typeof item.__id !== 'string' || item.__id.length === 0) {
|
|
604
|
+
throw new Error(`${label} has an invalid item ID.`);
|
|
605
|
+
}
|
|
606
|
+
return item.__id;
|
|
607
|
+
}
|
|
608
|
+
function readStorageState(value) {
|
|
609
|
+
if (value === undefined) {
|
|
610
|
+
return { version: 2, values: {} };
|
|
611
|
+
}
|
|
612
|
+
if (!isRecord(value)
|
|
613
|
+
|| Object.keys(value).length !== 2
|
|
614
|
+
|| !Object.hasOwn(value, 'version')
|
|
615
|
+
|| !Object.hasOwn(value, 'values')
|
|
616
|
+
|| value.version !== 2
|
|
617
|
+
|| !isRecord(value.values)) {
|
|
618
|
+
throw new Error('Stored Verse8 cloud save state is invalid.');
|
|
619
|
+
}
|
|
620
|
+
const values = {};
|
|
621
|
+
for (const [key, storedValue] of Object.entries(value.values)) {
|
|
622
|
+
if (!sha256DigestPattern.test(key)) {
|
|
623
|
+
throw new Error('Stored Verse8 cloud save state is invalid.');
|
|
624
|
+
}
|
|
625
|
+
values[key] = readStorageEnvelope(storedValue);
|
|
626
|
+
}
|
|
627
|
+
return { version: 2, values };
|
|
628
|
+
}
|
|
629
|
+
function assertStorageStateSize(state, maximumStateBytes) {
|
|
630
|
+
if (utf8Bytes(JSON.stringify(state)).length > maximumStateBytes) {
|
|
631
|
+
throw new Error('Stored Verse8 cloud save state exceeds maximumStateBytes.');
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function assertStorageEntryCount(state, maximumEntries) {
|
|
635
|
+
if (Object.keys(state.values).length > maximumEntries) {
|
|
636
|
+
throw new Error('Stored Verse8 cloud save state exceeds maximumEntries.');
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function assertPrivateStorageCodec(codec) {
|
|
640
|
+
if (typeof codec !== 'object'
|
|
641
|
+
|| codec === null
|
|
642
|
+
|| codec.security !== 'authenticated-encryption'
|
|
643
|
+
|| typeof codec.seal !== 'function'
|
|
644
|
+
|| typeof codec.open !== 'function') {
|
|
645
|
+
throw new Error('A Verse8 private storage codec is required.');
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function readStorageEnvelope(input) {
|
|
649
|
+
if (!isRecord(input)
|
|
650
|
+
|| Object.keys(input).length !== 2
|
|
651
|
+
|| !Object.hasOwn(input, 'keyId')
|
|
652
|
+
|| !Object.hasOwn(input, 'ciphertext')
|
|
653
|
+
|| typeof input.keyId !== 'string'
|
|
654
|
+
|| !/^[A-Za-z0-9._:-]{1,128}$/.test(input.keyId)
|
|
655
|
+
|| typeof input.ciphertext !== 'string'
|
|
656
|
+
|| input.ciphertext.length === 0
|
|
657
|
+
|| !isWellFormedUnicode(input.ciphertext)) {
|
|
658
|
+
throw new Error('Verse8 private storage envelope is invalid.');
|
|
659
|
+
}
|
|
660
|
+
return {
|
|
661
|
+
keyId: input.keyId,
|
|
662
|
+
ciphertext: input.ciphertext,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
function cloneStorageEnvelope(envelope) {
|
|
666
|
+
return {
|
|
667
|
+
keyId: envelope.keyId,
|
|
668
|
+
ciphertext: envelope.ciphertext,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
function cloneJsonValue(input) {
|
|
672
|
+
assertJsonValue(input, new Set(), 0);
|
|
673
|
+
const serialized = JSON.stringify(input);
|
|
674
|
+
if (serialized === undefined) {
|
|
675
|
+
throw new Error('Verse8 cloud save values must be JSON-compatible.');
|
|
676
|
+
}
|
|
677
|
+
return JSON.parse(serialized);
|
|
678
|
+
}
|
|
679
|
+
function assertJsonValue(input, seen, depth) {
|
|
680
|
+
if (depth > 64) {
|
|
681
|
+
throw new Error('Verse8 cloud save value exceeds the maximum nesting depth.');
|
|
682
|
+
}
|
|
683
|
+
if (input === null
|
|
684
|
+
|| typeof input === 'boolean'
|
|
685
|
+
|| (typeof input === 'number' && Number.isFinite(input))) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
if (typeof input === 'string') {
|
|
689
|
+
if (!isWellFormedUnicode(input)) {
|
|
690
|
+
throw new Error('Verse8 cloud save strings must contain well-formed Unicode.');
|
|
691
|
+
}
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (typeof input !== 'object' || seen.has(input)) {
|
|
695
|
+
throw new Error('Verse8 cloud save values must be acyclic JSON data.');
|
|
696
|
+
}
|
|
697
|
+
seen.add(input);
|
|
698
|
+
if (Array.isArray(input)) {
|
|
699
|
+
for (const value of input) {
|
|
700
|
+
assertJsonValue(value, seen, depth + 1);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
const prototype = Object.getPrototypeOf(input);
|
|
705
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
706
|
+
throw new Error('Verse8 cloud save values must use plain JSON objects.');
|
|
707
|
+
}
|
|
708
|
+
for (const [key, value] of Object.entries(input)) {
|
|
709
|
+
if (!isWellFormedUnicode(key)) {
|
|
710
|
+
throw new Error('Verse8 cloud save object keys must contain well-formed Unicode.');
|
|
711
|
+
}
|
|
712
|
+
assertJsonValue(value, seen, depth + 1);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
seen.delete(input);
|
|
716
|
+
}
|
|
717
|
+
function normalizeNamespace(value, label) {
|
|
718
|
+
const normalized = value.trim();
|
|
719
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]{0,47}$/.test(normalized)) {
|
|
720
|
+
throw new Error(`${label} must be a safe non-empty Agent8 identifier.`);
|
|
721
|
+
}
|
|
722
|
+
return normalized;
|
|
723
|
+
}
|
|
724
|
+
function positiveSafeInteger(value, label) {
|
|
725
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
726
|
+
throw new Error(`${label} must be a positive safe integer.`);
|
|
727
|
+
}
|
|
728
|
+
return value;
|
|
729
|
+
}
|
|
730
|
+
function assertAccount(value) {
|
|
731
|
+
if (typeof value !== 'string'
|
|
732
|
+
|| value.trim() !== value
|
|
733
|
+
|| value.length === 0
|
|
734
|
+
|| value.length > 256
|
|
735
|
+
|| !isWellFormedUnicode(value)) {
|
|
736
|
+
throw new Error('account must be a canonical bounded string.');
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
function assertStorageKey(value) {
|
|
740
|
+
if (typeof value !== 'string'
|
|
741
|
+
|| value.length === 0
|
|
742
|
+
|| value.length > 256
|
|
743
|
+
|| !isWellFormedUnicode(value)
|
|
744
|
+
|| value === '__proto__'
|
|
745
|
+
|| value === 'constructor'
|
|
746
|
+
|| value === 'prototype') {
|
|
747
|
+
throw new Error('Verse8 cloud save key must be bounded well-formed Unicode.');
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
function isWellFormedUnicode(input) {
|
|
751
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
752
|
+
const codeUnit = input.charCodeAt(index);
|
|
753
|
+
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
|
754
|
+
if (index + 1 >= input.length) {
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
const next = input.charCodeAt(index + 1);
|
|
758
|
+
if (next < 0xdc00 || next > 0xdfff) {
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
index += 1;
|
|
762
|
+
}
|
|
763
|
+
else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return true;
|
|
768
|
+
}
|
|
769
|
+
function storageLockKey(account) {
|
|
770
|
+
return `mpgd:verse8:storage:${sha256(account)}`;
|
|
771
|
+
}
|
|
772
|
+
function leaderboardLockKey(leaderboardId) {
|
|
773
|
+
return `mpgd:verse8:leaderboard:${sha256(leaderboardId)}`;
|
|
774
|
+
}
|
|
775
|
+
function createPrivateStorageKey(persistenceKey, account, storageKey) {
|
|
776
|
+
return hmacSha256(persistenceKey, JSON.stringify(['storage-slot', account, storageKey]));
|
|
777
|
+
}
|
|
778
|
+
function readPersistenceSecret(value, label) {
|
|
779
|
+
if (typeof value !== 'string'
|
|
780
|
+
|| value.trim() !== value
|
|
781
|
+
|| !isWellFormedUnicode(value)) {
|
|
782
|
+
throw new Error(`${label} must be a canonical server-only secret.`);
|
|
783
|
+
}
|
|
784
|
+
const bytes = utf8Bytes(value);
|
|
785
|
+
if (bytes.length < minimumPersistenceSecretBytes
|
|
786
|
+
|| bytes.length > maximumPersistenceSecretBytes) {
|
|
787
|
+
throw new Error(`${label} must contain between ${String(minimumPersistenceSecretBytes)}`
|
|
788
|
+
+ ` and ${String(maximumPersistenceSecretBytes)} UTF-8 bytes.`);
|
|
789
|
+
}
|
|
790
|
+
return bytes;
|
|
791
|
+
}
|
|
792
|
+
function sha256(input) {
|
|
793
|
+
return bytesToHex(sha256Bytes(utf8Bytes(input)));
|
|
794
|
+
}
|
|
795
|
+
function hmacSha256(key, input) {
|
|
796
|
+
const normalizedKey = key.length > 64 ? sha256Bytes(key) : key.slice();
|
|
797
|
+
const innerPad = new Uint8Array(64);
|
|
798
|
+
const outerPad = new Uint8Array(64);
|
|
799
|
+
for (let index = 0; index < 64; index += 1) {
|
|
800
|
+
const keyByte = normalizedKey[index] ?? 0;
|
|
801
|
+
innerPad[index] = keyByte ^ 0x36;
|
|
802
|
+
outerPad[index] = keyByte ^ 0x5c;
|
|
803
|
+
}
|
|
804
|
+
const innerDigest = sha256Bytes(concatenateBytes(innerPad, utf8Bytes(input)));
|
|
805
|
+
return bytesToHex(sha256Bytes(concatenateBytes(outerPad, innerDigest)));
|
|
806
|
+
}
|
|
807
|
+
function sha256Bytes(bytes) {
|
|
808
|
+
const bitLength = bytes.length * 8;
|
|
809
|
+
const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64;
|
|
810
|
+
const message = new Uint8Array(paddedLength);
|
|
811
|
+
message.set(bytes);
|
|
812
|
+
message[bytes.length] = 0x80;
|
|
813
|
+
const view = new DataView(message.buffer);
|
|
814
|
+
view.setUint32(paddedLength - 8, Math.floor(bitLength / 4294967296), false);
|
|
815
|
+
view.setUint32(paddedLength - 4, bitLength >>> 0, false);
|
|
816
|
+
const hash = new Uint32Array(8);
|
|
817
|
+
hash[0] = 0x6a09e667;
|
|
818
|
+
hash[1] = 0xbb67ae85;
|
|
819
|
+
hash[2] = 0x3c6ef372;
|
|
820
|
+
hash[3] = 0xa54ff53a;
|
|
821
|
+
hash[4] = 0x510e527f;
|
|
822
|
+
hash[5] = 0x9b05688c;
|
|
823
|
+
hash[6] = 0x1f83d9ab;
|
|
824
|
+
hash[7] = 0x5be0cd19;
|
|
825
|
+
const schedule = new Uint32Array(64);
|
|
826
|
+
for (let offset = 0; offset < message.length; offset += 64) {
|
|
827
|
+
for (let index = 0; index < 16; index += 1) {
|
|
828
|
+
schedule[index] = view.getUint32(offset + index * 4, false);
|
|
829
|
+
}
|
|
830
|
+
for (let index = 16; index < 64; index += 1) {
|
|
831
|
+
const previous15 = schedule[index - 15];
|
|
832
|
+
const previous2 = schedule[index - 2];
|
|
833
|
+
const sigma0 = rotateRight(previous15, 7)
|
|
834
|
+
^ rotateRight(previous15, 18)
|
|
835
|
+
^ (previous15 >>> 3);
|
|
836
|
+
const sigma1 = rotateRight(previous2, 17)
|
|
837
|
+
^ rotateRight(previous2, 19)
|
|
838
|
+
^ (previous2 >>> 10);
|
|
839
|
+
schedule[index] = (schedule[index - 16]
|
|
840
|
+
+ sigma0
|
|
841
|
+
+ schedule[index - 7]
|
|
842
|
+
+ sigma1) >>> 0;
|
|
843
|
+
}
|
|
844
|
+
let a = hash[0];
|
|
845
|
+
let b = hash[1];
|
|
846
|
+
let c = hash[2];
|
|
847
|
+
let d = hash[3];
|
|
848
|
+
let e = hash[4];
|
|
849
|
+
let f = hash[5];
|
|
850
|
+
let g = hash[6];
|
|
851
|
+
let h = hash[7];
|
|
852
|
+
for (let index = 0; index < 64; index += 1) {
|
|
853
|
+
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
|
|
854
|
+
const choice = (e & f) ^ (~e & g);
|
|
855
|
+
const temporary1 = (h
|
|
856
|
+
+ sum1
|
|
857
|
+
+ choice
|
|
858
|
+
+ sha256RoundConstants[index]
|
|
859
|
+
+ schedule[index]) >>> 0;
|
|
860
|
+
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
|
|
861
|
+
const majority = (a & b) ^ (a & c) ^ (b & c);
|
|
862
|
+
const temporary2 = (sum0 + majority) >>> 0;
|
|
863
|
+
h = g;
|
|
864
|
+
g = f;
|
|
865
|
+
f = e;
|
|
866
|
+
e = (d + temporary1) >>> 0;
|
|
867
|
+
d = c;
|
|
868
|
+
c = b;
|
|
869
|
+
b = a;
|
|
870
|
+
a = (temporary1 + temporary2) >>> 0;
|
|
871
|
+
}
|
|
872
|
+
hash[0] = (hash[0] + a) >>> 0;
|
|
873
|
+
hash[1] = (hash[1] + b) >>> 0;
|
|
874
|
+
hash[2] = (hash[2] + c) >>> 0;
|
|
875
|
+
hash[3] = (hash[3] + d) >>> 0;
|
|
876
|
+
hash[4] = (hash[4] + e) >>> 0;
|
|
877
|
+
hash[5] = (hash[5] + f) >>> 0;
|
|
878
|
+
hash[6] = (hash[6] + g) >>> 0;
|
|
879
|
+
hash[7] = (hash[7] + h) >>> 0;
|
|
880
|
+
}
|
|
881
|
+
const digest = new Uint8Array(32);
|
|
882
|
+
const digestView = new DataView(digest.buffer);
|
|
883
|
+
for (let index = 0; index < hash.length; index += 1) {
|
|
884
|
+
digestView.setUint32(index * 4, hash[index], false);
|
|
885
|
+
}
|
|
886
|
+
return digest;
|
|
887
|
+
}
|
|
888
|
+
function concatenateBytes(left, right) {
|
|
889
|
+
const output = new Uint8Array(left.length + right.length);
|
|
890
|
+
output.set(left);
|
|
891
|
+
output.set(right, left.length);
|
|
892
|
+
return output;
|
|
893
|
+
}
|
|
894
|
+
function bytesToHex(bytes) {
|
|
895
|
+
return [...bytes].map((value) => value.toString(16).padStart(2, '0')).join('');
|
|
896
|
+
}
|
|
897
|
+
function rotateRight(value, bits) {
|
|
898
|
+
return (value >>> bits) | (value << (32 - bits));
|
|
899
|
+
}
|
|
900
|
+
function utf8Bytes(input) {
|
|
901
|
+
const bytes = [];
|
|
902
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
903
|
+
let codePoint = input.charCodeAt(index);
|
|
904
|
+
if (codePoint >= 0xd800 && codePoint <= 0xdbff) {
|
|
905
|
+
const next = input.charCodeAt(index + 1);
|
|
906
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
907
|
+
codePoint = 0x10000 + ((codePoint - 0xd800) << 10) + (next - 0xdc00);
|
|
908
|
+
index += 1;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (codePoint < 0x80) {
|
|
912
|
+
bytes.push(codePoint);
|
|
913
|
+
}
|
|
914
|
+
else if (codePoint < 0x800) {
|
|
915
|
+
bytes.push(0xc0 | (codePoint >>> 6), 0x80 | (codePoint & 0x3f));
|
|
916
|
+
}
|
|
917
|
+
else if (codePoint < 0x10000) {
|
|
918
|
+
bytes.push(0xe0 | (codePoint >>> 12), 0x80 | ((codePoint >>> 6) & 0x3f), 0x80 | (codePoint & 0x3f));
|
|
919
|
+
}
|
|
920
|
+
else {
|
|
921
|
+
bytes.push(0xf0 | (codePoint >>> 18), 0x80 | ((codePoint >>> 12) & 0x3f), 0x80 | ((codePoint >>> 6) & 0x3f), 0x80 | (codePoint & 0x3f));
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return Uint8Array.from(bytes);
|
|
925
|
+
}
|
|
926
|
+
function isRecord(value) {
|
|
927
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
928
|
+
}
|