@krtuworks/sdk 0.0.0-stage → 1.0.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/AGENTS.md +48 -0
- package/CHANGELOG.md +22 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +24 -2
- package/dist/api.d.ts +34 -0
- package/dist/api.js +61 -0
- package/dist/avatarReports.d.ts +15 -0
- package/dist/avatarReports.js +18 -0
- package/dist/bugs.d.ts +114 -0
- package/dist/bugs.js +276 -0
- package/dist/client.d.ts +390 -0
- package/dist/client.js +666 -0
- package/dist/clock.d.ts +19 -0
- package/dist/clock.js +32 -0
- package/dist/config.d.ts +85 -0
- package/dist/config.js +68 -0
- package/dist/deviceKey.d.ts +32 -0
- package/dist/deviceKey.js +26 -0
- package/dist/entitlements.d.ts +48 -0
- package/dist/entitlements.js +113 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.js +32 -0
- package/dist/events.d.ts +93 -0
- package/dist/events.js +304 -0
- package/dist/families.d.ts +94 -0
- package/dist/families.js +1 -0
- package/dist/files.d.ts +108 -0
- package/dist/files.js +215 -0
- package/dist/identity.d.ts +129 -0
- package/dist/identity.js +437 -0
- package/dist/ids.d.ts +13 -0
- package/dist/ids.js +61 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +7 -0
- package/dist/jwt.d.ts +2 -0
- package/dist/jwt.js +8 -0
- package/dist/locks.d.ts +6 -0
- package/dist/locks.js +20 -0
- package/dist/presence.d.ts +45 -0
- package/dist/presence.js +97 -0
- package/dist/progress.d.ts +108 -0
- package/dist/progress.js +347 -0
- package/dist/progression.d.ts +39 -0
- package/dist/progression.js +81 -0
- package/dist/remoteConfig.d.ts +48 -0
- package/dist/remoteConfig.js +70 -0
- package/dist/sessions.d.ts +45 -0
- package/dist/sessions.js +157 -0
- package/dist/store.d.ts +18 -0
- package/dist/store.js +57 -0
- package/dist/transport.d.ts +30 -0
- package/dist/transport.js +73 -0
- package/dist/validator.d.ts +43 -0
- package/dist/validator.js +252 -0
- package/package.json +40 -4
package/dist/identity.js
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { CURRENT_TERMS, DEV_RESOURCE, GUEST_SCOPE, PLAYER_SCOPE } from './config.js';
|
|
2
|
+
import { createDeviceKey, signProof } from './deviceKey.js';
|
|
3
|
+
import { KrtuworksError } from './errors.js';
|
|
4
|
+
import { base64Url, randomToken, sha256 } from './ids.js';
|
|
5
|
+
import { jwtClaims } from './jwt.js';
|
|
6
|
+
import { withLock } from './locks.js';
|
|
7
|
+
import { errorOf, form, send } from './transport.js';
|
|
8
|
+
export const DEVICE_KEY_GRANT = 'urn:krtuworks:params:oauth:grant-type:device-key';
|
|
9
|
+
const PENDING_TTL_MS = 10 * 60_000;
|
|
10
|
+
const REFRESH_EARLY_MS = 60_000;
|
|
11
|
+
function signedOut() {
|
|
12
|
+
return new KrtuworksError('signed_out', 'No player is signed in.');
|
|
13
|
+
}
|
|
14
|
+
/** `terms_required` names the documents in its text: `Accept the current terms: TERMS:v2 PRIVACY:v1.` */
|
|
15
|
+
export function termsIn(text) {
|
|
16
|
+
const named = text.match(/\b[A-Z][A-Z_]*:v\d+\b/g);
|
|
17
|
+
return named ? [...new Set(named)].join(' ') : null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Who plays: the tokens, the device's guest key, and every way a player arrives or leaves. One token
|
|
21
|
+
* chain per sign-in client; every write of it happens under one lock (Web Locks across tabs), and a
|
|
22
|
+
* refresh writes its answer only over the refresh token it spent.
|
|
23
|
+
*/
|
|
24
|
+
export class Identity {
|
|
25
|
+
ctx;
|
|
26
|
+
tokens = null;
|
|
27
|
+
refreshing = null;
|
|
28
|
+
listeners = new Set();
|
|
29
|
+
/** Bumped whenever the signed-in player changes; an answer made for another one is dropped. */
|
|
30
|
+
generation = 0;
|
|
31
|
+
constructor(ctx) {
|
|
32
|
+
this.ctx = ctx;
|
|
33
|
+
}
|
|
34
|
+
key(name) {
|
|
35
|
+
return `krtu:${this.ctx.opts.clientId}:${name}`;
|
|
36
|
+
}
|
|
37
|
+
get lockName() {
|
|
38
|
+
return `krtu:${this.ctx.opts.clientId}:identity`;
|
|
39
|
+
}
|
|
40
|
+
get issuer() {
|
|
41
|
+
return `${this.ctx.opts.idOrigin}/oidc`;
|
|
42
|
+
}
|
|
43
|
+
async load() {
|
|
44
|
+
this.tokens = (await this.ctx.store.get(this.key('tokens'))) ?? null;
|
|
45
|
+
return this.player;
|
|
46
|
+
}
|
|
47
|
+
get player() {
|
|
48
|
+
const t = this.tokens;
|
|
49
|
+
return t ? { kind: t.kind, subject: t.subject, username: t.username, picture: t.picture } : null;
|
|
50
|
+
}
|
|
51
|
+
onChange(listener) {
|
|
52
|
+
this.listeners.add(listener);
|
|
53
|
+
return () => this.listeners.delete(listener);
|
|
54
|
+
}
|
|
55
|
+
emit(change) {
|
|
56
|
+
for (const listener of this.listeners) {
|
|
57
|
+
try {
|
|
58
|
+
listener(change);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// a listener's failure is the app's; the SDK goes on
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async meta() {
|
|
66
|
+
const meta = await this.ctx.store.get(this.key('meta'));
|
|
67
|
+
return { epoch: meta?.epoch ?? 0, waitingOwners: meta?.waitingOwners ?? [], guestRequest: meta?.guestRequest ?? null };
|
|
68
|
+
}
|
|
69
|
+
async saveMeta(meta) {
|
|
70
|
+
await this.ctx.store.set(this.key('meta'), meta);
|
|
71
|
+
}
|
|
72
|
+
/** The owners whose queues wait for a merge check, with the one to drop once it is answered. */
|
|
73
|
+
async waitingOwners() {
|
|
74
|
+
return (await this.meta()).waitingOwners;
|
|
75
|
+
}
|
|
76
|
+
/** The merge of `subject` is settled: it no longer waits, and its guest key (retired by the server) goes. */
|
|
77
|
+
async forgetWaitingOwner(subject) {
|
|
78
|
+
await withLock(this.lockName, async () => {
|
|
79
|
+
const meta = await this.meta();
|
|
80
|
+
meta.waitingOwners = meta.waitingOwners.filter((s) => s !== subject);
|
|
81
|
+
await this.saveMeta(meta);
|
|
82
|
+
const key = await this.ctx.store.get(this.key('deviceKey'));
|
|
83
|
+
if (key?.subject === subject)
|
|
84
|
+
await this.ctx.store.delete(this.key('deviceKey'));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** Writes who is signed in (or nobody); a different player bumps the generation. */
|
|
88
|
+
async adopt(next) {
|
|
89
|
+
if (next)
|
|
90
|
+
await this.ctx.store.set(this.key('tokens'), next);
|
|
91
|
+
else
|
|
92
|
+
await this.ctx.store.delete(this.key('tokens'));
|
|
93
|
+
if ((this.tokens?.subject ?? null) !== (next?.subject ?? null))
|
|
94
|
+
this.generation += 1;
|
|
95
|
+
this.tokens = next;
|
|
96
|
+
}
|
|
97
|
+
// ── Tokens ──────────────────────────────────────────────────────────────
|
|
98
|
+
/**
|
|
99
|
+
* A valid access token, refreshed first when it is about to expire (or `force`: the server said it was).
|
|
100
|
+
* One refresh at a time in this tab (single flight) and across tabs (the lock).
|
|
101
|
+
*/
|
|
102
|
+
async accessToken(force = false) {
|
|
103
|
+
const t = this.tokens;
|
|
104
|
+
if (!t)
|
|
105
|
+
throw signedOut();
|
|
106
|
+
if (!force && t.accessExpiresAt - REFRESH_EARLY_MS > this.ctx.clock.serverNow())
|
|
107
|
+
return t.accessToken;
|
|
108
|
+
this.refreshing ??= this.refresh(t.accessToken).finally(() => {
|
|
109
|
+
this.refreshing = null;
|
|
110
|
+
});
|
|
111
|
+
return (await this.refreshing).accessToken;
|
|
112
|
+
}
|
|
113
|
+
async token(fields) {
|
|
114
|
+
const { body, headers } = form({ client_id: this.ctx.opts.clientId, ...fields });
|
|
115
|
+
return send(this.ctx.fetch, this.ctx.clock, `${this.ctx.opts.idOrigin}/oidc/token`, { method: 'POST', headers, body });
|
|
116
|
+
}
|
|
117
|
+
tokensFrom(json, kind, previous) {
|
|
118
|
+
const access = json?.access_token;
|
|
119
|
+
if (typeof access !== 'string')
|
|
120
|
+
throw new KrtuworksError('invalid_response', 'The token answer carries no access token.');
|
|
121
|
+
const claims = jwtClaims(access);
|
|
122
|
+
const id = typeof json?.id_token === 'string' ? jwtClaims(json.id_token) : null;
|
|
123
|
+
const subject = String(claims.sub ?? id?.sub ?? '');
|
|
124
|
+
if (!subject)
|
|
125
|
+
throw new KrtuworksError('invalid_response', 'The access token names no player.');
|
|
126
|
+
const exp = typeof claims.exp === 'number' ? claims.exp * 1000 : this.ctx.clock.serverNow() + 600_000;
|
|
127
|
+
const sameSubject = previous?.subject === subject;
|
|
128
|
+
return {
|
|
129
|
+
kind,
|
|
130
|
+
subject,
|
|
131
|
+
username: typeof id?.preferred_username === 'string' ? id.preferred_username : sameSubject ? previous.username : null,
|
|
132
|
+
picture: typeof id?.picture === 'string' ? id.picture : sameSubject ? previous.picture : null,
|
|
133
|
+
accessToken: access,
|
|
134
|
+
accessExpiresAt: exp,
|
|
135
|
+
refreshToken: typeof json?.refresh_token === 'string' ? json.refresh_token : sameSubject ? previous.refreshToken : null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
async refresh(usedAccess) {
|
|
139
|
+
return withLock(this.lockName, async () => {
|
|
140
|
+
const stored = (await this.ctx.store.get(this.key('tokens'))) ?? null;
|
|
141
|
+
if (!stored) {
|
|
142
|
+
await this.adopt(null);
|
|
143
|
+
throw signedOut();
|
|
144
|
+
}
|
|
145
|
+
if (stored.accessToken !== usedAccess && stored.accessExpiresAt - REFRESH_EARLY_MS > this.ctx.clock.serverNow()) {
|
|
146
|
+
await this.adopt(stored);
|
|
147
|
+
return stored;
|
|
148
|
+
}
|
|
149
|
+
if (!stored.refreshToken) {
|
|
150
|
+
if (stored.kind === 'guest')
|
|
151
|
+
return this.guestAgain(stored);
|
|
152
|
+
await this.adopt(null);
|
|
153
|
+
this.emit({ type: 'signed_out', reason: 'expired' });
|
|
154
|
+
throw signedOut();
|
|
155
|
+
}
|
|
156
|
+
const answer = await this.token({ grant_type: 'refresh_token', refresh_token: stored.refreshToken, resource: DEV_RESOURCE });
|
|
157
|
+
if (answer.status === 200) {
|
|
158
|
+
const next = this.tokensFrom(answer.json, stored.kind, stored);
|
|
159
|
+
await this.adopt(next);
|
|
160
|
+
return next;
|
|
161
|
+
}
|
|
162
|
+
return this.refused(stored, errorOf(answer));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/** A refresh the server refused, read by what it says; under the identity lock. */
|
|
166
|
+
async refused(stored, err) {
|
|
167
|
+
if (err.code !== 'invalid_grant')
|
|
168
|
+
throw err;
|
|
169
|
+
const why = String(err.details.description ?? err.message);
|
|
170
|
+
if (why.includes('account_merged'))
|
|
171
|
+
return this.markMergedLocked(stored.subject, null);
|
|
172
|
+
// A frozen app keeps its grants: the same refresh token works again once it is resumed.
|
|
173
|
+
if (why.includes('app_suspended'))
|
|
174
|
+
throw err;
|
|
175
|
+
await this.adopt(null);
|
|
176
|
+
if (why.includes('sanctioned')) {
|
|
177
|
+
this.emit({ type: 'sanctioned', reason: null });
|
|
178
|
+
throw new KrtuworksError('sanctioned', 'The player is banned from this app.', 403);
|
|
179
|
+
}
|
|
180
|
+
if (why.includes('account_deletion_pending')) {
|
|
181
|
+
this.emit({ type: 'deletion_pending' });
|
|
182
|
+
throw new KrtuworksError('account_deletion_pending', 'This account is waiting to be deleted.', 403);
|
|
183
|
+
}
|
|
184
|
+
// Revoked or expired: a guest signs in again with its key, anyone else needs the player.
|
|
185
|
+
if (stored.kind === 'guest')
|
|
186
|
+
return this.guestAgain(stored);
|
|
187
|
+
this.emit({ type: 'signed_out', reason: why });
|
|
188
|
+
throw signedOut();
|
|
189
|
+
}
|
|
190
|
+
async markMergedLocked(previousSubject, mergedInto) {
|
|
191
|
+
const meta = await this.meta();
|
|
192
|
+
if (!meta.waitingOwners.includes(previousSubject))
|
|
193
|
+
meta.waitingOwners.push(previousSubject);
|
|
194
|
+
await this.saveMeta(meta);
|
|
195
|
+
await this.adopt(null);
|
|
196
|
+
this.emit({ type: 'merged', previousSubject, mergedInto });
|
|
197
|
+
throw new KrtuworksError('account_merged', 'This account was merged into another one; sign in with it.', 401, null, { mergedInto });
|
|
198
|
+
}
|
|
199
|
+
/** The server said the player's account went into another one (401 account_merged). */
|
|
200
|
+
async markMerged(previousSubject, mergedInto) {
|
|
201
|
+
return withLock(this.lockName, () => this.markMergedLocked(previousSubject, mergedInto));
|
|
202
|
+
}
|
|
203
|
+
// ── Guests (device key) ──────────────────────────────────────────────────
|
|
204
|
+
async challenge(purpose) {
|
|
205
|
+
const { body, headers } = form({ client_id: this.ctx.opts.clientId, purpose });
|
|
206
|
+
const answer = await send(this.ctx.fetch, this.ctx.clock, `${this.ctx.opts.idOrigin}/oidc/device-key/challenge`, { method: 'POST', headers, body });
|
|
207
|
+
if (answer.status !== 200 || typeof answer.json?.nonce !== 'string')
|
|
208
|
+
throw errorOf(answer);
|
|
209
|
+
return answer.json.nonce;
|
|
210
|
+
}
|
|
211
|
+
async proofWith(key, purpose) {
|
|
212
|
+
const nonce = await this.challenge(purpose);
|
|
213
|
+
return signProof(key, { nonce, clientId: this.ctx.opts.clientId, purpose, iat: Math.floor(this.ctx.clock.serverNow() / 1000) });
|
|
214
|
+
}
|
|
215
|
+
/** A proof by this device's guest key for one of the guest's own calls (deletion, data copy). */
|
|
216
|
+
async guestProof(purpose) {
|
|
217
|
+
const key = await this.ctx.store.get(this.key('deviceKey'));
|
|
218
|
+
if (!key?.known)
|
|
219
|
+
throw new KrtuworksError('no_guest_key', 'This device holds no guest key.');
|
|
220
|
+
return this.proofWith(key, purpose);
|
|
221
|
+
}
|
|
222
|
+
/** The device-key grant; the caller holds the identity lock. */
|
|
223
|
+
async deviceKeyGrant(request) {
|
|
224
|
+
let key = await this.ctx.store.get(this.key('deviceKey'));
|
|
225
|
+
if (!key) {
|
|
226
|
+
// Stored before the server hears of it: a lost answer must not leave a guest no key can reach.
|
|
227
|
+
key = { ...(await createDeviceKey()), known: false, subject: null };
|
|
228
|
+
await this.ctx.store.set(this.key('deviceKey'), key);
|
|
229
|
+
}
|
|
230
|
+
if (!key.known && !request)
|
|
231
|
+
throw new KrtuworksError('birth_date_required', 'A new guest needs the age screen: pass birthDate and acceptedTerms.', 400);
|
|
232
|
+
const assertion = await this.proofWith(key, 'login');
|
|
233
|
+
const answer = await this.token({
|
|
234
|
+
grant_type: DEVICE_KEY_GRANT,
|
|
235
|
+
assertion,
|
|
236
|
+
scope: GUEST_SCOPE,
|
|
237
|
+
resource: DEV_RESOURCE,
|
|
238
|
+
birth_date: key.known ? undefined : request.birthDate,
|
|
239
|
+
accepted_terms: key.known ? undefined : request.acceptedTerms,
|
|
240
|
+
});
|
|
241
|
+
if (answer.status !== 200) {
|
|
242
|
+
const err = errorOf(answer);
|
|
243
|
+
if (err.code === 'terms_required')
|
|
244
|
+
err.details.terms = termsIn(err.message);
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
const created = !key.known;
|
|
248
|
+
const tokens = this.tokensFrom(answer.json, 'guest', null);
|
|
249
|
+
if (created || key.subject !== tokens.subject)
|
|
250
|
+
await this.ctx.store.set(this.key('deviceKey'), { ...key, known: true, subject: tokens.subject });
|
|
251
|
+
return { tokens, created };
|
|
252
|
+
}
|
|
253
|
+
/** A guest whose refresh token is gone signs in again with its key; under the identity lock. */
|
|
254
|
+
async guestAgain(stored) {
|
|
255
|
+
try {
|
|
256
|
+
const { tokens } = await this.deviceKeyGrant(null);
|
|
257
|
+
await this.adopt(tokens);
|
|
258
|
+
return tokens;
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
const e = err;
|
|
262
|
+
if (e.code !== 'invalid_grant' && e.code !== 'birth_date_required')
|
|
263
|
+
throw err;
|
|
264
|
+
const why = String(e.details?.description ?? e.message);
|
|
265
|
+
if (why.includes('account_merged'))
|
|
266
|
+
return this.markMergedLocked(stored.subject, null);
|
|
267
|
+
await this.adopt(null);
|
|
268
|
+
if (why.includes('account_deletion_pending')) {
|
|
269
|
+
this.emit({ type: 'deletion_pending' });
|
|
270
|
+
throw new KrtuworksError('account_deletion_pending', 'This account is waiting to be deleted.', 403);
|
|
271
|
+
}
|
|
272
|
+
if (why.includes('sanctioned')) {
|
|
273
|
+
this.emit({ type: 'sanctioned', reason: null });
|
|
274
|
+
throw new KrtuworksError('sanctioned', 'The player is banned from this app.', 403);
|
|
275
|
+
}
|
|
276
|
+
this.emit({ type: 'signed_out', reason: why });
|
|
277
|
+
throw signedOut();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Plays as a guest: the device's key, made now if there is none. A new guest needs the age screen's
|
|
282
|
+
* answers (13 or older, the current terms); they are kept and tried again on later launches when the
|
|
283
|
+
* server could not make the guest now. A sign-in the player starts meanwhile wins: this
|
|
284
|
+
* guest is then not used.
|
|
285
|
+
*/
|
|
286
|
+
async signInAsGuest(input = {}) {
|
|
287
|
+
const meta = await this.meta();
|
|
288
|
+
const epoch = meta.epoch;
|
|
289
|
+
if (input.birthDate) {
|
|
290
|
+
meta.guestRequest = { birthDate: input.birthDate, acceptedTerms: input.acceptedTerms ?? CURRENT_TERMS };
|
|
291
|
+
await this.saveMeta(meta);
|
|
292
|
+
}
|
|
293
|
+
const result = await withLock(this.lockName, async () => {
|
|
294
|
+
const current = (await this.ctx.store.get(this.key('tokens'))) ?? null;
|
|
295
|
+
if (current) {
|
|
296
|
+
await this.adopt(current);
|
|
297
|
+
return { player: this.player, created: false, fresh: false };
|
|
298
|
+
}
|
|
299
|
+
const { tokens, created } = await this.deviceKeyGrant((await this.meta()).guestRequest);
|
|
300
|
+
const now = await this.meta();
|
|
301
|
+
if (now.epoch !== epoch)
|
|
302
|
+
throw new KrtuworksError('context_changed', 'A sign-in started while the guest was being made; the guest was not used.');
|
|
303
|
+
now.guestRequest = null;
|
|
304
|
+
await this.saveMeta(now);
|
|
305
|
+
await this.adopt(tokens);
|
|
306
|
+
return { player: this.player, created, fresh: true };
|
|
307
|
+
});
|
|
308
|
+
if (result.fresh)
|
|
309
|
+
this.emit({ type: 'guest', player: result.player, created: result.created });
|
|
310
|
+
return result.player;
|
|
311
|
+
}
|
|
312
|
+
// ── Sign-in with Krtuworks ID (authorization code + PKCE) ─────────────────
|
|
313
|
+
/**
|
|
314
|
+
* The address to send the player to. A guest playing on this device is carried along (`krtu_guest`):
|
|
315
|
+
* a new Krtuworks ID made there IS this guest, and signing in to an existing one folds the guest into
|
|
316
|
+
* it. Starting a sign-in bumps the epoch, so a guest still being made in the background is not used.
|
|
317
|
+
*/
|
|
318
|
+
async beginSignIn(options = {}) {
|
|
319
|
+
const redirectUri = options.redirectUri ?? this.ctx.opts.redirectUri;
|
|
320
|
+
if (!redirectUri)
|
|
321
|
+
throw new TypeError('redirectUri is needed: one of the client\'s registered redirect addresses');
|
|
322
|
+
const epoch = await withLock(this.lockName, async () => {
|
|
323
|
+
const meta = await this.meta();
|
|
324
|
+
meta.epoch += 1;
|
|
325
|
+
await this.saveMeta(meta);
|
|
326
|
+
return meta.epoch;
|
|
327
|
+
});
|
|
328
|
+
const verifier = randomToken(32);
|
|
329
|
+
const state = randomToken(16);
|
|
330
|
+
const nonce = randomToken(16);
|
|
331
|
+
const params = new URLSearchParams({
|
|
332
|
+
client_id: this.ctx.opts.clientId,
|
|
333
|
+
redirect_uri: redirectUri,
|
|
334
|
+
response_type: 'code',
|
|
335
|
+
scope: PLAYER_SCOPE,
|
|
336
|
+
// offline_access only counts with prompt=consent (OIDC Core 11); the page shows no consent screen
|
|
337
|
+
// for the app's own players.
|
|
338
|
+
prompt: 'consent',
|
|
339
|
+
resource: DEV_RESOURCE,
|
|
340
|
+
code_challenge: base64Url(await sha256(verifier)),
|
|
341
|
+
code_challenge_method: 'S256',
|
|
342
|
+
state,
|
|
343
|
+
nonce,
|
|
344
|
+
});
|
|
345
|
+
if (this.tokens?.kind === 'guest' && options.carryGuest !== false) {
|
|
346
|
+
const key = await this.ctx.store.get(this.key('deviceKey'));
|
|
347
|
+
if (key?.known) {
|
|
348
|
+
try {
|
|
349
|
+
params.set('krtu_guest', await this.proofWith(key, 'promote'));
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// offline or refused: the sign-in goes on without the guest
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const pending = { verifier, nonce, redirectUri, epoch, startedAt: Date.now() };
|
|
357
|
+
await this.ctx.store.set(this.key(`signin:${state}`), pending);
|
|
358
|
+
return `${this.ctx.opts.idOrigin}/oidc/auth?${params}`;
|
|
359
|
+
}
|
|
360
|
+
/** Finishes a sign-in from the address the player came back to. */
|
|
361
|
+
async completeSignIn(callbackUrl) {
|
|
362
|
+
const url = new URL(String(callbackUrl));
|
|
363
|
+
const state = url.searchParams.get('state');
|
|
364
|
+
if (!state)
|
|
365
|
+
throw new KrtuworksError('invalid_callback', 'The address carries no sign-in.');
|
|
366
|
+
const pending = await this.ctx.store.get(this.key(`signin:${state}`));
|
|
367
|
+
await this.ctx.store.delete(this.key(`signin:${state}`));
|
|
368
|
+
if (!pending || Date.now() - pending.startedAt > PENDING_TTL_MS)
|
|
369
|
+
throw new KrtuworksError('invalid_callback', 'This sign-in is unknown or too old: start it again.');
|
|
370
|
+
const error = url.searchParams.get('error');
|
|
371
|
+
if (error)
|
|
372
|
+
throw new KrtuworksError(error, url.searchParams.get('error_description') ?? 'The sign-in did not finish.', 400);
|
|
373
|
+
const iss = url.searchParams.get('iss');
|
|
374
|
+
if (iss !== null && iss !== this.issuer)
|
|
375
|
+
throw new KrtuworksError('invalid_callback', 'The answer came from another issuer.');
|
|
376
|
+
const code = url.searchParams.get('code');
|
|
377
|
+
if (!code)
|
|
378
|
+
throw new KrtuworksError('invalid_callback', 'The address carries no code.');
|
|
379
|
+
const answer = await this.token({ grant_type: 'authorization_code', code, redirect_uri: pending.redirectUri, code_verifier: pending.verifier, resource: DEV_RESOURCE });
|
|
380
|
+
if (answer.status !== 200)
|
|
381
|
+
throw errorOf(answer);
|
|
382
|
+
this.checkIdToken(answer.json?.id_token, pending.nonce);
|
|
383
|
+
const outcome = await withLock(this.lockName, async () => {
|
|
384
|
+
const meta = await this.meta();
|
|
385
|
+
if (meta.epoch !== pending.epoch)
|
|
386
|
+
throw new KrtuworksError('context_changed', 'Another sign-in started after this one; this one was not used.');
|
|
387
|
+
const before = (await this.ctx.store.get(this.key('tokens'))) ?? null;
|
|
388
|
+
const next = this.tokensFrom(answer.json, 'account', before);
|
|
389
|
+
let promoted = false;
|
|
390
|
+
if (before?.kind === 'guest') {
|
|
391
|
+
if (before.subject === next.subject) {
|
|
392
|
+
// The guest became this Krtuworks ID: same player, its key retired by the server.
|
|
393
|
+
promoted = true;
|
|
394
|
+
await this.ctx.store.delete(this.key('deviceKey'));
|
|
395
|
+
}
|
|
396
|
+
else if (!meta.waitingOwners.includes(before.subject)) {
|
|
397
|
+
// Signed in to an existing account: the guest folds into it once the server confirms.
|
|
398
|
+
meta.waitingOwners.push(before.subject);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
meta.guestRequest = null;
|
|
402
|
+
await this.saveMeta(meta);
|
|
403
|
+
await this.adopt(next);
|
|
404
|
+
return { player: this.player, promoted };
|
|
405
|
+
});
|
|
406
|
+
this.emit({ type: 'signed_in', player: outcome.player, promoted: outcome.promoted });
|
|
407
|
+
return outcome.player;
|
|
408
|
+
}
|
|
409
|
+
/** OIDC Core 3.1.3.7 for a token straight from the token endpoint over TLS: issuer, audience, nonce, time. */
|
|
410
|
+
checkIdToken(idToken, nonce) {
|
|
411
|
+
if (typeof idToken !== 'string')
|
|
412
|
+
throw new KrtuworksError('invalid_response', 'The sign-in answer carries no ID token.');
|
|
413
|
+
const claims = jwtClaims(idToken);
|
|
414
|
+
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
415
|
+
const fresh = typeof claims.exp === 'number' && claims.exp * 1000 > this.ctx.clock.serverNow() - 60_000;
|
|
416
|
+
if (claims.iss !== this.issuer || !aud.includes(this.ctx.opts.clientId) || claims.nonce !== nonce || !fresh) {
|
|
417
|
+
throw new KrtuworksError('invalid_response', 'The ID token is not for this sign-in.');
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/** Forgets the player on this device (a guest's key stays: signing in as the guest again reaches it). */
|
|
421
|
+
async signOut() {
|
|
422
|
+
const refreshToken = await withLock(this.lockName, async () => {
|
|
423
|
+
const meta = await this.meta();
|
|
424
|
+
meta.epoch += 1;
|
|
425
|
+
meta.guestRequest = null;
|
|
426
|
+
await this.saveMeta(meta);
|
|
427
|
+
const stored = (await this.ctx.store.get(this.key('tokens'))) ?? null;
|
|
428
|
+
await this.adopt(null);
|
|
429
|
+
return stored?.refreshToken ?? null;
|
|
430
|
+
});
|
|
431
|
+
if (refreshToken) {
|
|
432
|
+
const { body, headers } = form({ client_id: this.ctx.opts.clientId, token: refreshToken, token_type_hint: 'refresh_token' });
|
|
433
|
+
await send(this.ctx.fetch, this.ctx.clock, `${this.ctx.opts.idOrigin}/oidc/token/revocation`, { method: 'POST', headers, body }).catch(() => undefined);
|
|
434
|
+
}
|
|
435
|
+
this.emit({ type: 'signed_out', reason: 'signed_out' });
|
|
436
|
+
}
|
|
437
|
+
}
|
package/dist/ids.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 9562 UUIDv7: 48 bits of Unix milliseconds, then random. Event, op and batch ids are made once and
|
|
3
|
+
* never again for the same thing, so an answer lost on the way is safe to ask for twice.
|
|
4
|
+
*/
|
|
5
|
+
export declare function uuidV7(nowMs?: number): string;
|
|
6
|
+
export declare function uuidV4(): string;
|
|
7
|
+
export declare function base64(input: Uint8Array): string;
|
|
8
|
+
export declare function base64Url(input: Uint8Array | ArrayBuffer | string): string;
|
|
9
|
+
export declare function fromBase64Url(text: string): Uint8Array;
|
|
10
|
+
export declare function sha256(input: Uint8Array | string): Promise<Uint8Array>;
|
|
11
|
+
export declare function sha256Hex(input: Uint8Array | string): Promise<string>;
|
|
12
|
+
/** A random URL-safe string of `bytes` random bytes (PKCE verifier, state, nonce). */
|
|
13
|
+
export declare function randomToken(bytes?: number): string;
|
package/dist/ids.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
function hex(bytes) {
|
|
3
|
+
let out = '';
|
|
4
|
+
for (const b of bytes)
|
|
5
|
+
out += b.toString(16).padStart(2, '0');
|
|
6
|
+
return out;
|
|
7
|
+
}
|
|
8
|
+
function uuidFrom(b) {
|
|
9
|
+
const h = hex(b);
|
|
10
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* RFC 9562 UUIDv7: 48 bits of Unix milliseconds, then random. Event, op and batch ids are made once and
|
|
14
|
+
* never again for the same thing, so an answer lost on the way is safe to ask for twice.
|
|
15
|
+
*/
|
|
16
|
+
export function uuidV7(nowMs = Date.now()) {
|
|
17
|
+
const b = crypto.getRandomValues(new Uint8Array(16));
|
|
18
|
+
let ts = Math.max(0, Math.floor(nowMs));
|
|
19
|
+
for (let i = 5; i >= 0; i -= 1) {
|
|
20
|
+
b[i] = ts % 256;
|
|
21
|
+
ts = Math.floor(ts / 256);
|
|
22
|
+
}
|
|
23
|
+
b[6] = (b[6] & 0x0f) | 0x70;
|
|
24
|
+
b[8] = (b[8] & 0x3f) | 0x80;
|
|
25
|
+
return uuidFrom(b);
|
|
26
|
+
}
|
|
27
|
+
export function uuidV4() {
|
|
28
|
+
return crypto.randomUUID();
|
|
29
|
+
}
|
|
30
|
+
export function base64(input) {
|
|
31
|
+
let binary = '';
|
|
32
|
+
for (let i = 0; i < input.length; i += 0x8000)
|
|
33
|
+
binary += String.fromCharCode(...input.subarray(i, i + 0x8000));
|
|
34
|
+
return btoa(binary);
|
|
35
|
+
}
|
|
36
|
+
export function base64Url(input) {
|
|
37
|
+
const bytes = typeof input === 'string' ? encoder.encode(input) : input instanceof Uint8Array ? input : new Uint8Array(input);
|
|
38
|
+
let binary = '';
|
|
39
|
+
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
40
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
41
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
42
|
+
}
|
|
43
|
+
export function fromBase64Url(text) {
|
|
44
|
+
const b64 = text.replace(/-/g, '+').replace(/_/g, '/');
|
|
45
|
+
const binary = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));
|
|
46
|
+
const out = new Uint8Array(binary.length);
|
|
47
|
+
for (let i = 0; i < binary.length; i += 1)
|
|
48
|
+
out[i] = binary.charCodeAt(i);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
export async function sha256(input) {
|
|
52
|
+
const bytes = typeof input === 'string' ? encoder.encode(input) : input;
|
|
53
|
+
return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
|
54
|
+
}
|
|
55
|
+
export async function sha256Hex(input) {
|
|
56
|
+
return hex(await sha256(input));
|
|
57
|
+
}
|
|
58
|
+
/** A random URL-safe string of `bytes` random bytes (PKCE verifier, state, nonce). */
|
|
59
|
+
export function randomToken(bytes = 32) {
|
|
60
|
+
return base64Url(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
61
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { AVATAR_REPORT_REASONS, type AvatarReportReason } from './avatarReports.js';
|
|
2
|
+
export { BUG_CATEGORIES, type BugCategory, type BugFlushResult, type BugReportInput, type BugReportView } from './bugs.js';
|
|
3
|
+
export { Krtuworks, type Gate, type LeaderboardDisplay, type LeaderboardEntriesOptions, type LeaderboardEntriesView, type LeaderboardEntry, type LeaderboardsView, type LeaderboardView, type MergeSettled, type ProgressView, type SendResult, type TrackOptions, } from './client.js';
|
|
4
|
+
export { CURRENT_TERMS, MODULES, type AppType, type DeviceInfo, type Environment, type KrtuworksAppConfig, type KrtuworksOptions, type Module, type Platform, } from './config.js';
|
|
5
|
+
export type { Entitlement } from './entitlements.js';
|
|
6
|
+
export { KrtuworksError, isKrtuworksError } from './errors.js';
|
|
7
|
+
export type { EventPosition, WireEvent } from './events.js';
|
|
8
|
+
export type { Family, FamilyData, Place } from './families.js';
|
|
9
|
+
export type { FileRead, FileVersion, SaveOptions, SlotView } from './files.js';
|
|
10
|
+
export type { IdentityChange, Player, PlayerKind } from './identity.js';
|
|
11
|
+
export type { PresenceReason, PresenceResult, PresenceState } from './presence.js';
|
|
12
|
+
export type { LevelRef } from './progression.js';
|
|
13
|
+
export type { SortOrder, StatKind, UploadMethod } from './progress.js';
|
|
14
|
+
export type { RemoteConfigValue, Switch } from './remoteConfig.js';
|
|
15
|
+
export { defaultStore, indexedDbStore, memoryStore, type KrtuStore } from './store.js';
|
|
16
|
+
export { configProblems, platformProblems, validate, type ValidateOptions, type ValidationCheck, type ValidationReport, type ValidationStatus } from './validator.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { AVATAR_REPORT_REASONS } from './avatarReports.js';
|
|
2
|
+
export { BUG_CATEGORIES } from './bugs.js';
|
|
3
|
+
export { Krtuworks, } from './client.js';
|
|
4
|
+
export { CURRENT_TERMS, MODULES, } from './config.js';
|
|
5
|
+
export { KrtuworksError, isKrtuworksError } from './errors.js';
|
|
6
|
+
export { defaultStore, indexedDbStore, memoryStore } from './store.js';
|
|
7
|
+
export { configProblems, platformProblems, validate } from './validator.js';
|
package/dist/jwt.d.ts
ADDED
package/dist/jwt.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { fromBase64Url } from './ids.js';
|
|
2
|
+
/** A JWT's claims, unverified: the SDK reads its own tokens, it does not judge them (the servers do). */
|
|
3
|
+
export function jwtClaims(token) {
|
|
4
|
+
const parts = token.split('.');
|
|
5
|
+
if (parts.length !== 3)
|
|
6
|
+
throw new Error('not a JWT');
|
|
7
|
+
return JSON.parse(new TextDecoder().decode(fromBase64Url(parts[1])));
|
|
8
|
+
}
|
package/dist/locks.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs `fn` while holding `name`: across the origin's tabs with the Web Locks API, otherwise inside this
|
|
3
|
+
* process. Token refreshes and queue writes go through here, so two tabs never spend one refresh token
|
|
4
|
+
* or freeze the same events twice.
|
|
5
|
+
*/
|
|
6
|
+
export declare function withLock<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
package/dist/locks.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const tails = new Map();
|
|
2
|
+
/**
|
|
3
|
+
* Runs `fn` while holding `name`: across the origin's tabs with the Web Locks API, otherwise inside this
|
|
4
|
+
* process. Token refreshes and queue writes go through here, so two tabs never spend one refresh token
|
|
5
|
+
* or freeze the same events twice.
|
|
6
|
+
*/
|
|
7
|
+
export function withLock(name, fn) {
|
|
8
|
+
const locks = globalThis.navigator?.locks;
|
|
9
|
+
if (locks)
|
|
10
|
+
return locks.request(name, fn);
|
|
11
|
+
const previous = tails.get(name) ?? Promise.resolve();
|
|
12
|
+
const run = previous.then(fn);
|
|
13
|
+
const tail = run.then(() => undefined, () => undefined);
|
|
14
|
+
tails.set(name, tail);
|
|
15
|
+
void tail.then(() => {
|
|
16
|
+
if (tails.get(name) === tail)
|
|
17
|
+
tails.delete(name);
|
|
18
|
+
});
|
|
19
|
+
return run;
|
|
20
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Api } from './api.js';
|
|
2
|
+
import type { KrtuStore } from './store.js';
|
|
3
|
+
export type PresenceReason = 'no_platform' | 'not_game' | 'module_disabled' | 'hidden' | 'unknown_state' | 'param_missing' | 'states_unknown' | 'platform_rejected';
|
|
4
|
+
export interface PresenceResult {
|
|
5
|
+
applied: boolean;
|
|
6
|
+
reason?: PresenceReason;
|
|
7
|
+
}
|
|
8
|
+
export interface PresenceState {
|
|
9
|
+
key: string;
|
|
10
|
+
token: string;
|
|
11
|
+
/** The parameter names the state's text needs. */
|
|
12
|
+
params: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The rich presence states the owner declared and whether the player shows their presence (the server
|
|
16
|
+
* `/v1/me/presence-states`, `/v1/me/presence-visibility`), kept per player in the store and revalidated
|
|
17
|
+
* with the ETag. A browser has no platform presence to write, so a call that passes every rule ends in
|
|
18
|
+
* `no_platform`.
|
|
19
|
+
*/
|
|
20
|
+
export declare class Presence {
|
|
21
|
+
private readonly clientId;
|
|
22
|
+
private readonly store;
|
|
23
|
+
private readonly api;
|
|
24
|
+
private readonly subject;
|
|
25
|
+
private cached;
|
|
26
|
+
constructor(clientId: string, store: KrtuStore, api: Api, subject: () => string | null);
|
|
27
|
+
private key;
|
|
28
|
+
/** Reads the current player's kept copy. */
|
|
29
|
+
load(): Promise<void>;
|
|
30
|
+
/** The player changed or signed out: nothing of the old player stays in memory. */
|
|
31
|
+
forget(): void;
|
|
32
|
+
private current;
|
|
33
|
+
states(): PresenceState[] | null;
|
|
34
|
+
visible(): boolean | null;
|
|
35
|
+
private keep;
|
|
36
|
+
/** Asks the server for the states and the choice (If-None-Match: a 304 keeps the kept copy). */
|
|
37
|
+
refresh(): Promise<void>;
|
|
38
|
+
setVisible(visible: boolean): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* The first rule a call breaks, or null when it passes them all: the state is declared and every
|
|
41
|
+
* parameter it declares has a non-empty value (the caller's, else the built-in one). Undeclared extras
|
|
42
|
+
* are dropped with a log line.
|
|
43
|
+
*/
|
|
44
|
+
check(key: string, given: Record<string, string | number>, builtins: Record<string, () => string | null>, log: (line: string) => void): PresenceReason | null;
|
|
45
|
+
}
|