@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.
Files changed (56) hide show
  1. package/AGENTS.md +48 -0
  2. package/CHANGELOG.md +22 -0
  3. package/LICENSE +201 -0
  4. package/NOTICE +6 -0
  5. package/README.md +24 -2
  6. package/dist/api.d.ts +34 -0
  7. package/dist/api.js +61 -0
  8. package/dist/avatarReports.d.ts +15 -0
  9. package/dist/avatarReports.js +18 -0
  10. package/dist/bugs.d.ts +114 -0
  11. package/dist/bugs.js +276 -0
  12. package/dist/client.d.ts +390 -0
  13. package/dist/client.js +666 -0
  14. package/dist/clock.d.ts +19 -0
  15. package/dist/clock.js +32 -0
  16. package/dist/config.d.ts +85 -0
  17. package/dist/config.js +68 -0
  18. package/dist/deviceKey.d.ts +32 -0
  19. package/dist/deviceKey.js +26 -0
  20. package/dist/entitlements.d.ts +48 -0
  21. package/dist/entitlements.js +113 -0
  22. package/dist/errors.d.ts +21 -0
  23. package/dist/errors.js +32 -0
  24. package/dist/events.d.ts +93 -0
  25. package/dist/events.js +304 -0
  26. package/dist/families.d.ts +94 -0
  27. package/dist/families.js +1 -0
  28. package/dist/files.d.ts +108 -0
  29. package/dist/files.js +215 -0
  30. package/dist/identity.d.ts +129 -0
  31. package/dist/identity.js +437 -0
  32. package/dist/ids.d.ts +13 -0
  33. package/dist/ids.js +61 -0
  34. package/dist/index.d.ts +16 -0
  35. package/dist/index.js +7 -0
  36. package/dist/jwt.d.ts +2 -0
  37. package/dist/jwt.js +8 -0
  38. package/dist/locks.d.ts +6 -0
  39. package/dist/locks.js +20 -0
  40. package/dist/presence.d.ts +45 -0
  41. package/dist/presence.js +97 -0
  42. package/dist/progress.d.ts +108 -0
  43. package/dist/progress.js +347 -0
  44. package/dist/progression.d.ts +39 -0
  45. package/dist/progression.js +81 -0
  46. package/dist/remoteConfig.d.ts +48 -0
  47. package/dist/remoteConfig.js +70 -0
  48. package/dist/sessions.d.ts +45 -0
  49. package/dist/sessions.js +157 -0
  50. package/dist/store.d.ts +18 -0
  51. package/dist/store.js +57 -0
  52. package/dist/transport.d.ts +30 -0
  53. package/dist/transport.js +73 -0
  54. package/dist/validator.d.ts +43 -0
  55. package/dist/validator.js +252 -0
  56. package/package.json +40 -4
package/dist/files.js ADDED
@@ -0,0 +1,215 @@
1
+ import { KrtuworksError, isKrtuworksError } from './errors.js';
2
+ import { sha256Hex, uuidV4 } from './ids.js';
3
+ const POLL_LIMIT_MS = 60_000;
4
+ /**
5
+ * The player's files in the app's slots: a save is
6
+ * written to the store first, sent with its upload id as the Idempotency-Key and asked about until it
7
+ * is final; a conflict comes back with the slot's current version for the game's choice screen.
8
+ */
9
+ export class Files {
10
+ opts;
11
+ api;
12
+ store;
13
+ subject;
14
+ generation;
15
+ onSaved;
16
+ /** Per slot (and device), bumped by every save started here: a read compares it before and after. */
17
+ revisions = new Map();
18
+ constructor(opts, api, store, subject, generation,
19
+ /** Told after every save the server activated (the client's link reminder counts the first one). */
20
+ onSaved = () => undefined) {
21
+ this.opts = opts;
22
+ this.api = api;
23
+ this.store = store;
24
+ this.subject = subject;
25
+ this.generation = generation;
26
+ this.onSaved = onSaved;
27
+ }
28
+ revisionKey(slot, device) {
29
+ return `${slot}\u0000${device ?? ''}`;
30
+ }
31
+ path(slot) {
32
+ const base = `/v1/apps/${encodeURIComponent(this.opts.slug)}/files`;
33
+ return slot === undefined ? base : `${base}/${encodeURIComponent(slot)}`;
34
+ }
35
+ uploadKey(uploadId) {
36
+ return `krtu:${this.opts.clientId}:upload:${uploadId}`;
37
+ }
38
+ async list() {
39
+ const answer = await this.api.player(this.path());
40
+ return answer.json;
41
+ }
42
+ /** A slot's current version; null when `ifNoneMatch` (a version id held) is still current. */
43
+ async read(slot, options = {}) {
44
+ const revisionKey = this.revisionKey(slot, options.device);
45
+ const revision = this.revisions.get(revisionKey) ?? 0;
46
+ const answer = await this.api.player(this.path(slot), {
47
+ query: { device: options.device, platform: this.opts.platform, ...options.deviceInfo },
48
+ headers: options.ifNoneMatch ? { 'if-none-match': options.ifNoneMatch } : {},
49
+ bytes: true,
50
+ accept: [304],
51
+ });
52
+ if (answer.status === 304)
53
+ return null;
54
+ const etag = answer.headers.get('etag');
55
+ const similar = answer.headers.get('x-krtu-similar-device') === 'true';
56
+ return {
57
+ bytes: answer.bytes ?? new Uint8Array(),
58
+ newerLocalSave: (this.revisions.get(revisionKey) ?? 0) !== revision,
59
+ similarDevice: similar,
60
+ version: etag
61
+ ? {
62
+ id: etag.replace(/^W\//, '').replace(/"/g, ''),
63
+ generation: Number(answer.headers.get('x-krtu-generation') ?? 0),
64
+ sha256: answer.headers.get('x-krtu-sha256') ?? '',
65
+ savedAt: answer.headers.get('x-krtu-saved-at') ?? '',
66
+ }
67
+ : null,
68
+ };
69
+ }
70
+ /**
71
+ * Saves a new version of `slot`. Resolves with the new version once the server activated it; rejects
72
+ * with `file_conflict` (`details.current`: the version now in the slot) when another one came first.
73
+ */
74
+ async save(slot, data, options = {}) {
75
+ const owner = this.subject();
76
+ if (!owner)
77
+ throw new KrtuworksError('signed_out', 'No player is signed in.');
78
+ const revisionKey = this.revisionKey(slot, options.device);
79
+ this.revisions.set(revisionKey, (this.revisions.get(revisionKey) ?? 0) + 1);
80
+ const upload = {
81
+ uploadId: uuidV4(),
82
+ owner,
83
+ slot,
84
+ device: options.device ?? null,
85
+ base: options.base ?? null,
86
+ conditional: options.base !== undefined,
87
+ sha256: await sha256Hex(data),
88
+ bytes: data,
89
+ deviceInfo: options.deviceInfo ?? null,
90
+ savedAt: Date.now(),
91
+ };
92
+ await this.store.set(this.uploadKey(upload.uploadId), upload);
93
+ return this.send(upload);
94
+ }
95
+ async send(upload) {
96
+ const headers = { 'idempotency-key': upload.uploadId, 'content-type': 'application/octet-stream' };
97
+ if (upload.conditional) {
98
+ if (upload.base)
99
+ headers['if-match'] = upload.base;
100
+ else
101
+ headers['if-none-match'] = '*';
102
+ }
103
+ let answer;
104
+ try {
105
+ answer = await this.api.player(this.path(upload.slot), {
106
+ method: 'PUT',
107
+ query: { platform: this.opts.platform, build: this.opts.build, device: upload.device ?? undefined, ...(upload.deviceInfo ?? {}) },
108
+ headers,
109
+ body: upload.bytes,
110
+ accept: [202],
111
+ });
112
+ }
113
+ catch (err) {
114
+ if (isKrtuworksError(err) && !err.retryable)
115
+ await this.store.delete(this.uploadKey(upload.uploadId));
116
+ throw err;
117
+ }
118
+ if (answer.status === 201)
119
+ return this.done(upload, answer.json?.file ?? null);
120
+ return this.settle(upload, answer.json);
121
+ }
122
+ /** Reads an upload's state (200 again or 202) until it is final. */
123
+ async settle(upload, state) {
124
+ const started = Date.now();
125
+ const generation = this.generation();
126
+ let wait = 1000;
127
+ let current = state;
128
+ for (;;) {
129
+ const status = current?.status;
130
+ if (status === 'ACTIVATED')
131
+ return this.done(upload, null, current?.file);
132
+ if (status === 'REJECTED') {
133
+ await this.store.delete(this.uploadKey(upload.uploadId));
134
+ throw new KrtuworksError(String(current?.code ?? 'write_failed'), 'The save was refused.', 409, null, { upload: current });
135
+ }
136
+ if (Date.now() - started > POLL_LIMIT_MS) {
137
+ throw new KrtuworksError('upload_pending', 'The save is still being written; it is finished on a later launch.', 202, null, { uploadId: upload.uploadId });
138
+ }
139
+ await new Promise((r) => setTimeout(r, wait));
140
+ wait = Math.min(8000, wait * 2);
141
+ // Another player now: this upload is asked about with its own player's token on a later sign-in.
142
+ if (this.generation() !== generation)
143
+ throw new KrtuworksError('context_changed', 'The player changed while the save was being written; it is finished when that player signs in again.');
144
+ const answer = await this.api.player(`${this.path(upload.slot)}/uploads/${upload.uploadId}`);
145
+ current = answer.json;
146
+ }
147
+ }
148
+ async done(upload, file, ref) {
149
+ await this.store.delete(this.uploadKey(upload.uploadId));
150
+ this.onSaved();
151
+ if (file)
152
+ return file;
153
+ // An answer replayed from the upload's record names the version; read the slot for the whole of it.
154
+ const list = await this.list();
155
+ const slot = list.slots.find((s) => s.key === upload.slot);
156
+ const versions = [slot?.file, ...(slot?.devices ?? [])].filter((v) => !!v);
157
+ const found = versions.find((v) => v.id === ref?.id) ?? versions[0];
158
+ if (!found)
159
+ throw new KrtuworksError('file_not_found', 'The saved version is no longer the slot\'s.', 404);
160
+ return found;
161
+ }
162
+ /** Saves left unfinished by an earlier launch (an answer lost, a 202): finishes the signed-in player's. */
163
+ async resume() {
164
+ const owner = this.subject();
165
+ const out = [];
166
+ if (!owner)
167
+ return out;
168
+ for (const key of await this.store.keys(`krtu:${this.opts.clientId}:upload:`)) {
169
+ const upload = await this.store.get(key);
170
+ if (!upload || upload.owner !== owner)
171
+ continue;
172
+ try {
173
+ // Sent again with the same id and bytes: the server answers what the first one did.
174
+ await this.send(upload);
175
+ out.push({ uploadId: upload.uploadId, slot: upload.slot, outcome: 'saved' });
176
+ }
177
+ catch (err) {
178
+ const code = isKrtuworksError(err) ? err.code : 'error';
179
+ out.push({ uploadId: upload.uploadId, slot: upload.slot, outcome: code === 'upload_pending' || (isKrtuworksError(err) && err.retryable) ? 'waiting' : 'refused', code });
180
+ }
181
+ }
182
+ return out;
183
+ }
184
+ /** Saves of another player waiting on this device (a merged account's): the game asks the player what to keep. */
185
+ async waitingFor(owner) {
186
+ const out = [];
187
+ for (const key of await this.store.keys(`krtu:${this.opts.clientId}:upload:`)) {
188
+ const upload = await this.store.get(key);
189
+ if (upload?.owner === owner)
190
+ out.push({ uploadId: upload.uploadId, slot: upload.slot, savedAt: upload.savedAt });
191
+ }
192
+ return out;
193
+ }
194
+ /**
195
+ * A save another player left waiting (a merged account's) that the player chose to keep:
196
+ * saved now as this player's, over `base` (the slot's version the player saw), with a new upload id.
197
+ */
198
+ async adoptWaiting(uploadId, base) {
199
+ const waiting = await this.store.get(this.uploadKey(uploadId));
200
+ if (!waiting)
201
+ throw new KrtuworksError('upload_not_found', 'No such waiting save on this device.', 404);
202
+ const saved = await this.save(waiting.slot, waiting.bytes, { base, device: waiting.device ?? undefined, deviceInfo: waiting.deviceInfo ?? undefined });
203
+ await this.store.delete(this.uploadKey(uploadId));
204
+ return saved;
205
+ }
206
+ /** A waiting save the player chose not to keep. */
207
+ async discardWaiting(uploadId) {
208
+ await this.store.delete(this.uploadKey(uploadId));
209
+ }
210
+ /** After a merge: keep `keep` (the slot's version or the merged one) over `current` (the one the player saw). */
211
+ async resolve(slot, keep, current) {
212
+ const answer = await this.api.player(`${this.path(slot)}/resolve`, { method: 'POST', json: { keep, current } });
213
+ return answer.json?.file;
214
+ }
215
+ }
@@ -0,0 +1,129 @@
1
+ import type { Clock } from './clock.js';
2
+ import { type ResolvedOptions } from './config.js';
3
+ import { type ProofPurpose } from './deviceKey.js';
4
+ import type { KrtuStore } from './store.js';
5
+ export type PlayerKind = 'guest' | 'account';
6
+ export interface Player {
7
+ kind: PlayerKind;
8
+ /** The id this app knows the player by: the same on every device, never the Krtuworks ID itself. */
9
+ subject: string;
10
+ username: string | null;
11
+ picture: string | null;
12
+ }
13
+ interface IdentityMeta {
14
+ /** Bumped by every sign-in the player starts and every sign-out. */
15
+ epoch: number;
16
+ /** Players whose waiting queues move to the signed-in one once the server confirms a merge. */
17
+ waitingOwners: string[];
18
+ /** The age screen's answers for a guest not made yet: tried again on every launch. */
19
+ guestRequest: {
20
+ birthDate: string;
21
+ acceptedTerms: string;
22
+ } | null;
23
+ }
24
+ export type IdentityChange = {
25
+ type: 'signed_in';
26
+ player: Player;
27
+ promoted: boolean;
28
+ } | {
29
+ type: 'guest';
30
+ player: Player;
31
+ created: boolean;
32
+ } | {
33
+ type: 'signed_out';
34
+ reason: string;
35
+ } | {
36
+ type: 'merged';
37
+ previousSubject: string;
38
+ mergedInto: string | null;
39
+ } | {
40
+ type: 'deletion_pending';
41
+ } | {
42
+ type: 'sanctioned';
43
+ reason: string | null;
44
+ };
45
+ export interface IdentityContext {
46
+ opts: ResolvedOptions;
47
+ store: KrtuStore;
48
+ fetch: typeof fetch;
49
+ clock: Clock;
50
+ }
51
+ export declare const DEVICE_KEY_GRANT = "urn:krtuworks:params:oauth:grant-type:device-key";
52
+ /** `terms_required` names the documents in its text: `Accept the current terms: TERMS:v2 PRIVACY:v1.` */
53
+ export declare function termsIn(text: string): string | null;
54
+ /**
55
+ * Who plays: the tokens, the device's guest key, and every way a player arrives or leaves. One token
56
+ * chain per sign-in client; every write of it happens under one lock (Web Locks across tabs), and a
57
+ * refresh writes its answer only over the refresh token it spent.
58
+ */
59
+ export declare class Identity {
60
+ private readonly ctx;
61
+ private tokens;
62
+ private refreshing;
63
+ private readonly listeners;
64
+ /** Bumped whenever the signed-in player changes; an answer made for another one is dropped. */
65
+ generation: number;
66
+ constructor(ctx: IdentityContext);
67
+ private key;
68
+ private get lockName();
69
+ private get issuer();
70
+ load(): Promise<Player | null>;
71
+ get player(): Player | null;
72
+ onChange(listener: (change: IdentityChange) => void): () => void;
73
+ private emit;
74
+ meta(): Promise<IdentityMeta>;
75
+ private saveMeta;
76
+ /** The owners whose queues wait for a merge check, with the one to drop once it is answered. */
77
+ waitingOwners(): Promise<string[]>;
78
+ /** The merge of `subject` is settled: it no longer waits, and its guest key (retired by the server) goes. */
79
+ forgetWaitingOwner(subject: string): Promise<void>;
80
+ /** Writes who is signed in (or nobody); a different player bumps the generation. */
81
+ private adopt;
82
+ /**
83
+ * A valid access token, refreshed first when it is about to expire (or `force`: the server said it was).
84
+ * One refresh at a time in this tab (single flight) and across tabs (the lock).
85
+ */
86
+ accessToken(force?: boolean): Promise<string>;
87
+ private token;
88
+ private tokensFrom;
89
+ private refresh;
90
+ /** A refresh the server refused, read by what it says; under the identity lock. */
91
+ private refused;
92
+ private markMergedLocked;
93
+ /** The server said the player's account went into another one (401 account_merged). */
94
+ markMerged(previousSubject: string, mergedInto: string | null): Promise<never>;
95
+ private challenge;
96
+ private proofWith;
97
+ /** A proof by this device's guest key for one of the guest's own calls (deletion, data copy). */
98
+ guestProof(purpose: ProofPurpose): Promise<string>;
99
+ /** The device-key grant; the caller holds the identity lock. */
100
+ private deviceKeyGrant;
101
+ /** A guest whose refresh token is gone signs in again with its key; under the identity lock. */
102
+ private guestAgain;
103
+ /**
104
+ * Plays as a guest: the device's key, made now if there is none. A new guest needs the age screen's
105
+ * answers (13 or older, the current terms); they are kept and tried again on later launches when the
106
+ * server could not make the guest now. A sign-in the player starts meanwhile wins: this
107
+ * guest is then not used.
108
+ */
109
+ signInAsGuest(input?: {
110
+ birthDate?: string;
111
+ acceptedTerms?: string;
112
+ }): Promise<Player>;
113
+ /**
114
+ * The address to send the player to. A guest playing on this device is carried along (`krtu_guest`):
115
+ * a new Krtuworks ID made there IS this guest, and signing in to an existing one folds the guest into
116
+ * it. Starting a sign-in bumps the epoch, so a guest still being made in the background is not used.
117
+ */
118
+ beginSignIn(options?: {
119
+ redirectUri?: string;
120
+ carryGuest?: boolean;
121
+ }): Promise<string>;
122
+ /** Finishes a sign-in from the address the player came back to. */
123
+ completeSignIn(callbackUrl: string | URL): Promise<Player>;
124
+ /** OIDC Core 3.1.3.7 for a token straight from the token endpoint over TLS: issuer, audience, nonce, time. */
125
+ private checkIdToken;
126
+ /** Forgets the player on this device (a guest's key stays: signing in as the guest again reaches it). */
127
+ signOut(): Promise<void>;
128
+ }
129
+ export {};