scorezilla 0.1.0-next.0 → 0.1.0-next.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/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-next.1
4
+
5
+ ### Minor Changes
6
+
7
+ - [#18](https://github.com/isco-tec/scorezilla-js/pull/18) [`a02b07a`](https://github.com/isco-tec/scorezilla-js/commit/a02b07a116a9d15c2928ad4f98a9cfa3a8dceffd) Thanks [@isco-tec](https://github.com/isco-tec)! - **v0.2.0 — HMAC server adapter (`scorezilla/server`).** Closes [#17](https://github.com/isco-tec/scorezilla-js/issues/17).
8
+
9
+ The new `scorezilla/server` subpath ships an HMAC-SHA256 signing client for
10
+ game backends that need cheat-resistant submissions. The browser-side
11
+ public-key client (`scorezilla`) is unchanged.
12
+
13
+ ```ts
14
+ import { Scorezilla } from 'scorezilla/server';
15
+
16
+ const sz = new Scorezilla({
17
+ secretKey: {
18
+ id: process.env.SCOREZILLA_KEY_ID,
19
+ secret: process.env.SCOREZILLA_KEY_SECRET, // never ship to a browser
20
+ },
21
+ });
22
+
23
+ await sz.submitScore({ boardId, playerId, score, metadata });
24
+ await sz.getLeaderboard({ boardId, top });
25
+ await sz.getPlayerRank({ boardId, playerId });
26
+ await sz.getWindowAround({ boardId, playerId, before, after });
27
+ ```
28
+
29
+ Behavior:
30
+ - Each request is signed with HMAC-SHA256 over a canonical
31
+ `{ts}\n{nonce}\n{METHOD}\n{path?query}\n{sha256_hex(body)}` string and
32
+ delivered via `Authorization: Scorezilla-HMAC-SHA256 keyId=…, ts=…,
33
+ nonce=…, signature=…`. Matches the API's verifier byte-for-byte.
34
+ - `submitScore` posts to `/v1/secure/scores` (the boardId moves into the
35
+ body — the public-key endpoint at `/v1/boards/:id/scores` is unchanged).
36
+ - Read methods (`getLeaderboard` / `getPlayerRank` / `getWindowAround`)
37
+ hit the same paths as the public-key client.
38
+ - Every retry attempt regenerates `(ts, nonce)` so server-side replay
39
+ protection (10-minute nonce window) doesn't trip.
40
+ - Importing `scorezilla/server` from a browser bundle throws at module
41
+ evaluation — the `sk_live_*` secret never reaches client-side code.
42
+
43
+ Same `ScorezillaError` surface (`isRateLimited()`, `isAuth()`,
44
+ `isTransient()`, `code`, `requestId`, etc.) — typed catch patterns
45
+ written for v0.1 work unchanged.
46
+
47
+ Bundle: ~3.84 KB ESM gzipped, ~4.04 KB CJS gzipped — well under the
48
+ 6 KB per-entry cap.
49
+
3
50
  ## 0.1.0-next.0
4
51
 
5
52
  ### Minor Changes
package/README.md CHANGED
@@ -18,10 +18,13 @@ AI-vibe-coded games.
18
18
  - **Private.** No cookies, no `localStorage`, no fingerprinting beyond runtime
19
19
  detection — see [COMPATIBILITY.md](./COMPATIBILITY.md).
20
20
 
21
- > **Status:** v0.1.0 ships the **public-key client** (browser-safe). The HMAC
22
- > server adapter (`scorezilla/server`) lands in v0.2.0; React
23
- > (`scorezilla/react`) in v0.3.0; Phaser (`scorezilla/phaser`) in v0.4.0. See
24
- > [CHANGELOG.md](./CHANGELOG.md) and [VERSIONING.md](./VERSIONING.md).
21
+ > **Status:** v0.1.0 ships the **public-key client** (browser-safe).
22
+ > v0.2.0 ships the **HMAC server adapter** (`scorezilla/server`) for
23
+ > game backends that need cheat-resistant submissions. React
24
+ > (`scorezilla/react`) lands in v0.3.0; Phaser (`scorezilla/phaser`) in
25
+ > v0.4.0. The first preview is on the `next` dist-tag — install with
26
+ > `npm install scorezilla@next`. See [CHANGELOG.md](./CHANGELOG.md) and
27
+ > [VERSIONING.md](./VERSIONING.md).
25
28
 
26
29
  > **Commercial context.** Scorezilla is a hosted leaderboard service with free
27
30
  > and paid tiers — see [scorezilla.dev/pricing](https://scorezilla.dev/pricing).
@@ -76,6 +79,36 @@ await sz.getWindowAround({ boardId, playerId, before?, after? });
76
79
  See [**API.md**](./API.md) for the full reference, including every response
77
80
  field, every error code, and advanced patterns.
78
81
 
82
+ ## Server-side HMAC (`scorezilla/server`)
83
+
84
+ Public-key submissions are client-authoritative — anyone with your `pk_` can
85
+ submit any score from devtools. For games where ranking matters, sign each
86
+ submission server-side with a `sk_live_*` secret:
87
+
88
+ ```ts
89
+ import { Scorezilla, ScorezillaError } from 'scorezilla/server';
90
+
91
+ const sz = new Scorezilla({
92
+ secretKey: {
93
+ id: process.env.SCOREZILLA_KEY_ID!,
94
+ secret: process.env.SCOREZILLA_KEY_SECRET!, // never ship to a browser
95
+ },
96
+ });
97
+
98
+ await sz.submitScore({ boardId, playerId, score, metadata });
99
+ ```
100
+
101
+ Same method shape as the public-key client — `submitScore`, `getLeaderboard`,
102
+ `getPlayerRank`, `getWindowAround`. The adapter signs every request with
103
+ **HMAC-SHA256** over a canonical string (method + path + ts + nonce +
104
+ sha256(body)), and the API verifies before any state change. Replay
105
+ protection is enforced server-side via a 10-minute nonce window.
106
+
107
+ The `scorezilla/server` subpath is server-only — importing it from the
108
+ browser throws at module evaluation. Use environment variables (or your
109
+ secret manager) to load the `sk_live_*` value; never embed it in a build
110
+ that ships to clients.
111
+
79
112
  ## Error handling
80
113
 
81
114
  Every failure path — HTTP non-2xx, network error, timeout, abort, JSON parse
@@ -0,0 +1,284 @@
1
+ /** Minimal fetch shape — broader than `typeof fetch` so polyfills and
2
+ * test stubs (`vi.fn()`, `node-fetch`, etc.) typecheck cleanly. */
3
+ type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
4
+
5
+ /**
6
+ * SDK configuration.
7
+ *
8
+ * The `ScorezillaConfig` is a TypeScript-level discriminated union of
9
+ * `PublicKeyConfig` and `SecretKeyConfig`. The mutual-exclusivity (you may
10
+ * pass `publicKey` OR `secretKey`, never both) is enforced at compile time:
11
+ * passing both fields fails type-checking before the runtime check fires.
12
+ *
13
+ * The runtime check in {@link validateConfig} is the second line of defense
14
+ * for consumers using plain JS or `as any` casts.
15
+ */
16
+
17
+ /** Shared options across both auth modes. */
18
+ interface BaseConfig {
19
+ /** API base URL (no trailing slash required). Defaults to {@link DEFAULT_BASE_URL}. */
20
+ baseUrl?: string;
21
+ /** Custom fetch implementation — defaults to `globalThis.fetch`. Pass
22
+ * `node-fetch`, `undici`, or a mock here. The explicit signature
23
+ * (`(RequestInfo | URL, init?) => Promise<Response>`) is broader than
24
+ * `typeof fetch` so common polyfills typecheck cleanly. */
25
+ fetch?: FetchImpl;
26
+ /** Per-request timeout in milliseconds. Defaults to 30 s. */
27
+ timeoutMs?: number;
28
+ /** Maximum retry attempts on transient failures. Defaults to 2 (so the
29
+ * worst-case total request count is 3). */
30
+ maxRetries?: number;
31
+ /** Override the default `User-Agent` header (Node/Workers/Bun/Deno —
32
+ * browsers silently ignore the value). */
33
+ userAgent?: string;
34
+ }
35
+ /** Public-key auth: browser-safe path. The key is fingerprinted to a game
36
+ * on the server side via `pk_<gameSlug>_<base62>`. */
37
+ type PublicKeyConfig = BaseConfig & {
38
+ publicKey: string;
39
+ secretKey?: never;
40
+ };
41
+ /** Secret-key auth: server-side HMAC. The pair `{ id, secret }` is what
42
+ * the operator dashboard issues; the SDK signs requests with `secret` and
43
+ * identifies them via `id`. */
44
+ type SecretKeyConfig = BaseConfig & {
45
+ secretKey: {
46
+ id: string;
47
+ secret: string;
48
+ };
49
+ publicKey?: never;
50
+ };
51
+ /** The top-level config type. The union is open for additional auth modes
52
+ * in future major releases. */
53
+ type ScorezillaConfig = PublicKeyConfig | SecretKeyConfig;
54
+
55
+ /**
56
+ * Wire types for the Scorezilla API at /v1.
57
+ *
58
+ * Mirrors the documented response shapes. TypeScript's structural typing
59
+ * means additional fields the server adds in a minor release won't break
60
+ * consumers — see VERSIONING.md for the full SemVer contract.
61
+ *
62
+ * No Zod or other runtime validators in v0.1.0 — keeps the bundle small.
63
+ * Narrow untrusted input with the {@link isApiSuccess} / {@link isApiError}
64
+ * type guards exported below.
65
+ */
66
+ /**
67
+ * Machine-stable error codes returned by the API.
68
+ *
69
+ * Consumers MUST branch on this `code` rather than the human-readable
70
+ * `message` — message text is English-only and explicitly NOT part of the
71
+ * SemVer contract.
72
+ *
73
+ * The union is intentionally open (`| (string & {})`) so unknown future
74
+ * codes from a server-side minor release don't compile-error against the
75
+ * SDK. The trick preserves autocomplete on the known set while permitting
76
+ * arbitrary strings at runtime — see
77
+ * https://github.com/microsoft/TypeScript/issues/29729 for the pattern.
78
+ *
79
+ * @stable v0.1.0
80
+ */
81
+ type ScorezillaErrorCode = 'unauthorized' | 'forbidden' | 'not_found' | 'invalid_input' | 'invalid_json' | 'out_of_bounds' | 'rate_limited' | 'conflict' | 'internal_error' | (string & {});
82
+ /** Reason sub-classifier on `out_of_bounds` errors. Open union — see {@link ScorezillaErrorCode}. */
83
+ type OutOfBoundsReason = 'below_min' | 'above_max' | (string & {});
84
+ /** Successful API response envelope. The `T` is the per-route payload. */
85
+ type ApiSuccess<T> = {
86
+ ok: true;
87
+ } & T;
88
+ /** Failure response envelope. The server returns this on every non-2xx response. */
89
+ interface ApiError {
90
+ ok: false;
91
+ error: ScorezillaErrorCode;
92
+ /** Human-readable, English only. Not machine-stable — branch on `error` and `reason`. */
93
+ message?: string;
94
+ /** Sub-classifier — used by `out_of_bounds` (`'below_min' | 'above_max'`). */
95
+ reason?: string;
96
+ /** Seconds — present on `rate_limited`. Also mirrored in the HTTP `Retry-After` header. */
97
+ retryAfter?: number;
98
+ /** Which rate-limit layer fired — present on `rate_limited`. */
99
+ layer?: string;
100
+ /** The limit value that was crossed — present on `out_of_bounds`. */
101
+ bound?: number;
102
+ }
103
+ /** Discriminated envelope: every API response is one of these two shapes. */
104
+ type ApiResponse<T> = ApiSuccess<T> | ApiError;
105
+ /**
106
+ * A single ranked entry on a leaderboard.
107
+ *
108
+ * Returned as an array on `leaderboard` and `window-around` responses, and
109
+ * inline on `playerRank` (without the `rank` wrapper — see
110
+ * {@link PlayerRankResponse}).
111
+ */
112
+ interface RankedEntry {
113
+ /** 1-based rank. */
114
+ rank: number;
115
+ playerId: string;
116
+ score: number;
117
+ /** Milliseconds since epoch. */
118
+ submittedAt: number;
119
+ metadata?: Record<string, unknown>;
120
+ }
121
+ /** Payload from `POST /v1/boards/:boardId/scores`. */
122
+ interface SubmitScoreResponse {
123
+ boardId: string;
124
+ /** The key ID that authorized the submission. Useful for consumer-side audit. */
125
+ keyId: string;
126
+ /** 1-based rank after the submit settled. */
127
+ rank: number;
128
+ totalEntries: number;
129
+ isPersonalBest: boolean;
130
+ }
131
+ /** Payload from `GET /v1/boards/:boardId/leaderboard`. */
132
+ interface LeaderboardResponse {
133
+ boardId: string;
134
+ offset: number;
135
+ limit: number;
136
+ entries: RankedEntry[];
137
+ }
138
+ /** Payload from `GET /v1/boards/:boardId/players/:playerId/rank`. */
139
+ interface PlayerRankResponse {
140
+ boardId: string;
141
+ playerId: string;
142
+ rank: number;
143
+ score: number;
144
+ submittedAt: number;
145
+ totalEntries: number;
146
+ }
147
+ /** Payload from `GET /v1/boards/:boardId/players/:playerId/window`. */
148
+ interface WindowAroundResponse {
149
+ boardId: string;
150
+ playerId: string;
151
+ before: number;
152
+ after: number;
153
+ entries: RankedEntry[];
154
+ }
155
+
156
+ /**
157
+ * SDK error type.
158
+ *
159
+ * Every non-2xx API response is normalized into a `ScorezillaError` instance
160
+ * by the transport layer. Network failures and timeouts surface as the same
161
+ * class (with `status: 0`) so callers have a single error type to catch.
162
+ *
163
+ * **Invariant — consumers MUST branch on `code` (and optionally `reason`),
164
+ * never on `message`.** The English-language `message` is for operator
165
+ * logging only and is explicitly **not** part of the SemVer contract; a
166
+ * minor release MAY reword any message. Machine logic that depends on
167
+ * message text will break silently across upgrades.
168
+ */
169
+
170
+ /**
171
+ * Options for {@link ScorezillaError.from}.
172
+ *
173
+ * The fields mirror what's available after a fetch round-trip: the HTTP
174
+ * status, the parsed JSON body (if any), the request ID from
175
+ * `X-Request-Id`, and an optional `cause` for the underlying
176
+ * network/abort error.
177
+ */
178
+ interface ScorezillaErrorFromInit {
179
+ status: number;
180
+ body?: ApiError | undefined;
181
+ requestId?: string | undefined;
182
+ cause?: unknown;
183
+ }
184
+ /**
185
+ * Thrown by the SDK for every failure path — non-2xx responses, network
186
+ * errors, aborts, and timeouts.
187
+ *
188
+ * Cross-realm `instanceof` is guaranteed: the class sets `Error.prototype`
189
+ * explicitly so checks survive iframe / worker boundaries.
190
+ *
191
+ * @example
192
+ * ```ts
193
+ * try {
194
+ * await sz.submitScore({ boardId, playerId, score });
195
+ * } catch (e) {
196
+ * if (!(e instanceof ScorezillaError)) throw e;
197
+ *
198
+ * if (e.isRateLimited()) {
199
+ * await sleep((e.retryAfter ?? 30) * 1000);
200
+ * return retry();
201
+ * }
202
+ * if (e.code === 'out_of_bounds') {
203
+ * console.warn(`Score crosses ${e.reason} bound (limit ${e.bound})`);
204
+ * return;
205
+ * }
206
+ * if (e.isAuth()) throw new Error('SDK misconfigured — bad publicKey');
207
+ *
208
+ * // Anything else: surface to your reporter with requestId for support.
209
+ * console.error(`Scorezilla ${e.code} (${e.status}) — request ${e.requestId}`);
210
+ * throw e;
211
+ * }
212
+ * ```
213
+ *
214
+ * @since 0.1.0
215
+ * @stability stable
216
+ */
217
+ declare class ScorezillaError extends Error {
218
+ /** HTTP status of the response, or {@link STATUS_NETWORK_ERROR} (0) for
219
+ * network / abort / timeout. */
220
+ readonly status: number;
221
+ /** Machine-stable error code from the API. Open union — see
222
+ * {@link ScorezillaErrorCode}. For network errors, this is `'network_error'`;
223
+ * for aborts, `'aborted'`; for timeouts, `'timeout'`. */
224
+ readonly code: ScorezillaErrorCode;
225
+ /** Sub-classifier — present on `out_of_bounds` (`'below_min' | 'above_max'`)
226
+ * and possibly other codes in future minor releases. */
227
+ readonly reason: OutOfBoundsReason | string | undefined;
228
+ /** Seconds — present on `rate_limited`. Honored by the transport's retry
229
+ * policy (Step 2.4). */
230
+ readonly retryAfter: number | undefined;
231
+ /** Server-issued request ID, lifted from the `X-Request-Id` response
232
+ * header. Pass this to support when filing bugs. */
233
+ readonly requestId: string | undefined;
234
+ /** The bound value crossed on `out_of_bounds`. */
235
+ readonly bound: number | undefined;
236
+ /** Which rate-limit layer fired on `rate_limited`. */
237
+ readonly layer: string | undefined;
238
+ /** The underlying cause (e.g., a `TypeError: fetch failed`) for
239
+ * network/abort/timeout paths. `undefined` when the error came from a
240
+ * successfully-parsed API error body. */
241
+ readonly cause: unknown;
242
+ constructor(message: string, init: {
243
+ status: number;
244
+ code: ScorezillaErrorCode;
245
+ reason?: string | undefined;
246
+ retryAfter?: number | undefined;
247
+ requestId?: string | undefined;
248
+ bound?: number | undefined;
249
+ layer?: string | undefined;
250
+ cause?: unknown;
251
+ });
252
+ /** `true` when this error is a 429 / `rate_limited`. */
253
+ isRateLimited(): boolean;
254
+ /** `true` when this error is a 401 / `unauthorized` (or 403 / `forbidden`). */
255
+ isAuth(): boolean;
256
+ /** `true` when this error is a 404 / `not_found`. */
257
+ isNotFound(): boolean;
258
+ /** `true` when this error is a 422 / `out_of_bounds` (score below/above board limit). */
259
+ isOutOfBounds(): boolean;
260
+ /** `true` for transient / retryable conditions: network errors, timeouts,
261
+ * 5xx, and 429. The transport layer relies on this for its retry policy. */
262
+ isTransient(): boolean;
263
+ /**
264
+ * Build a `ScorezillaError` from a fetch round-trip outcome.
265
+ *
266
+ * Prefer this over `new ScorezillaError(...)` from the transport layer —
267
+ * it does the mapping from API response shape to error fields in one
268
+ * place, so future fields like `correlationId` get added once here.
269
+ *
270
+ * @param init - status, optional parsed body, optional requestId, optional cause
271
+ */
272
+ static from(init: ScorezillaErrorFromInit): ScorezillaError;
273
+ /**
274
+ * Build a `ScorezillaError` for a transport-level failure (no HTTP
275
+ * response received): network error, abort, or timeout.
276
+ */
277
+ static network(message: string, cause: unknown): ScorezillaError;
278
+ /** Build a `ScorezillaError` for an `AbortSignal`-triggered cancellation. */
279
+ static aborted(cause: unknown): ScorezillaError;
280
+ /** Build a `ScorezillaError` for a request that exceeded its timeout budget. */
281
+ static timeout(timeoutMs: number): ScorezillaError;
282
+ }
283
+
284
+ export { type ApiSuccess as A, type BaseConfig as B, type LeaderboardResponse as L, type OutOfBoundsReason as O, type PlayerRankResponse as P, type RankedEntry as R, type SecretKeyConfig as S, type WindowAroundResponse as W, type SubmitScoreResponse as a, ScorezillaError as b, type ScorezillaErrorCode as c, type ScorezillaConfig as d, type ApiError as e, type ApiResponse as f, type PublicKeyConfig as g };
@@ -0,0 +1,284 @@
1
+ /** Minimal fetch shape — broader than `typeof fetch` so polyfills and
2
+ * test stubs (`vi.fn()`, `node-fetch`, etc.) typecheck cleanly. */
3
+ type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
4
+
5
+ /**
6
+ * SDK configuration.
7
+ *
8
+ * The `ScorezillaConfig` is a TypeScript-level discriminated union of
9
+ * `PublicKeyConfig` and `SecretKeyConfig`. The mutual-exclusivity (you may
10
+ * pass `publicKey` OR `secretKey`, never both) is enforced at compile time:
11
+ * passing both fields fails type-checking before the runtime check fires.
12
+ *
13
+ * The runtime check in {@link validateConfig} is the second line of defense
14
+ * for consumers using plain JS or `as any` casts.
15
+ */
16
+
17
+ /** Shared options across both auth modes. */
18
+ interface BaseConfig {
19
+ /** API base URL (no trailing slash required). Defaults to {@link DEFAULT_BASE_URL}. */
20
+ baseUrl?: string;
21
+ /** Custom fetch implementation — defaults to `globalThis.fetch`. Pass
22
+ * `node-fetch`, `undici`, or a mock here. The explicit signature
23
+ * (`(RequestInfo | URL, init?) => Promise<Response>`) is broader than
24
+ * `typeof fetch` so common polyfills typecheck cleanly. */
25
+ fetch?: FetchImpl;
26
+ /** Per-request timeout in milliseconds. Defaults to 30 s. */
27
+ timeoutMs?: number;
28
+ /** Maximum retry attempts on transient failures. Defaults to 2 (so the
29
+ * worst-case total request count is 3). */
30
+ maxRetries?: number;
31
+ /** Override the default `User-Agent` header (Node/Workers/Bun/Deno —
32
+ * browsers silently ignore the value). */
33
+ userAgent?: string;
34
+ }
35
+ /** Public-key auth: browser-safe path. The key is fingerprinted to a game
36
+ * on the server side via `pk_<gameSlug>_<base62>`. */
37
+ type PublicKeyConfig = BaseConfig & {
38
+ publicKey: string;
39
+ secretKey?: never;
40
+ };
41
+ /** Secret-key auth: server-side HMAC. The pair `{ id, secret }` is what
42
+ * the operator dashboard issues; the SDK signs requests with `secret` and
43
+ * identifies them via `id`. */
44
+ type SecretKeyConfig = BaseConfig & {
45
+ secretKey: {
46
+ id: string;
47
+ secret: string;
48
+ };
49
+ publicKey?: never;
50
+ };
51
+ /** The top-level config type. The union is open for additional auth modes
52
+ * in future major releases. */
53
+ type ScorezillaConfig = PublicKeyConfig | SecretKeyConfig;
54
+
55
+ /**
56
+ * Wire types for the Scorezilla API at /v1.
57
+ *
58
+ * Mirrors the documented response shapes. TypeScript's structural typing
59
+ * means additional fields the server adds in a minor release won't break
60
+ * consumers — see VERSIONING.md for the full SemVer contract.
61
+ *
62
+ * No Zod or other runtime validators in v0.1.0 — keeps the bundle small.
63
+ * Narrow untrusted input with the {@link isApiSuccess} / {@link isApiError}
64
+ * type guards exported below.
65
+ */
66
+ /**
67
+ * Machine-stable error codes returned by the API.
68
+ *
69
+ * Consumers MUST branch on this `code` rather than the human-readable
70
+ * `message` — message text is English-only and explicitly NOT part of the
71
+ * SemVer contract.
72
+ *
73
+ * The union is intentionally open (`| (string & {})`) so unknown future
74
+ * codes from a server-side minor release don't compile-error against the
75
+ * SDK. The trick preserves autocomplete on the known set while permitting
76
+ * arbitrary strings at runtime — see
77
+ * https://github.com/microsoft/TypeScript/issues/29729 for the pattern.
78
+ *
79
+ * @stable v0.1.0
80
+ */
81
+ type ScorezillaErrorCode = 'unauthorized' | 'forbidden' | 'not_found' | 'invalid_input' | 'invalid_json' | 'out_of_bounds' | 'rate_limited' | 'conflict' | 'internal_error' | (string & {});
82
+ /** Reason sub-classifier on `out_of_bounds` errors. Open union — see {@link ScorezillaErrorCode}. */
83
+ type OutOfBoundsReason = 'below_min' | 'above_max' | (string & {});
84
+ /** Successful API response envelope. The `T` is the per-route payload. */
85
+ type ApiSuccess<T> = {
86
+ ok: true;
87
+ } & T;
88
+ /** Failure response envelope. The server returns this on every non-2xx response. */
89
+ interface ApiError {
90
+ ok: false;
91
+ error: ScorezillaErrorCode;
92
+ /** Human-readable, English only. Not machine-stable — branch on `error` and `reason`. */
93
+ message?: string;
94
+ /** Sub-classifier — used by `out_of_bounds` (`'below_min' | 'above_max'`). */
95
+ reason?: string;
96
+ /** Seconds — present on `rate_limited`. Also mirrored in the HTTP `Retry-After` header. */
97
+ retryAfter?: number;
98
+ /** Which rate-limit layer fired — present on `rate_limited`. */
99
+ layer?: string;
100
+ /** The limit value that was crossed — present on `out_of_bounds`. */
101
+ bound?: number;
102
+ }
103
+ /** Discriminated envelope: every API response is one of these two shapes. */
104
+ type ApiResponse<T> = ApiSuccess<T> | ApiError;
105
+ /**
106
+ * A single ranked entry on a leaderboard.
107
+ *
108
+ * Returned as an array on `leaderboard` and `window-around` responses, and
109
+ * inline on `playerRank` (without the `rank` wrapper — see
110
+ * {@link PlayerRankResponse}).
111
+ */
112
+ interface RankedEntry {
113
+ /** 1-based rank. */
114
+ rank: number;
115
+ playerId: string;
116
+ score: number;
117
+ /** Milliseconds since epoch. */
118
+ submittedAt: number;
119
+ metadata?: Record<string, unknown>;
120
+ }
121
+ /** Payload from `POST /v1/boards/:boardId/scores`. */
122
+ interface SubmitScoreResponse {
123
+ boardId: string;
124
+ /** The key ID that authorized the submission. Useful for consumer-side audit. */
125
+ keyId: string;
126
+ /** 1-based rank after the submit settled. */
127
+ rank: number;
128
+ totalEntries: number;
129
+ isPersonalBest: boolean;
130
+ }
131
+ /** Payload from `GET /v1/boards/:boardId/leaderboard`. */
132
+ interface LeaderboardResponse {
133
+ boardId: string;
134
+ offset: number;
135
+ limit: number;
136
+ entries: RankedEntry[];
137
+ }
138
+ /** Payload from `GET /v1/boards/:boardId/players/:playerId/rank`. */
139
+ interface PlayerRankResponse {
140
+ boardId: string;
141
+ playerId: string;
142
+ rank: number;
143
+ score: number;
144
+ submittedAt: number;
145
+ totalEntries: number;
146
+ }
147
+ /** Payload from `GET /v1/boards/:boardId/players/:playerId/window`. */
148
+ interface WindowAroundResponse {
149
+ boardId: string;
150
+ playerId: string;
151
+ before: number;
152
+ after: number;
153
+ entries: RankedEntry[];
154
+ }
155
+
156
+ /**
157
+ * SDK error type.
158
+ *
159
+ * Every non-2xx API response is normalized into a `ScorezillaError` instance
160
+ * by the transport layer. Network failures and timeouts surface as the same
161
+ * class (with `status: 0`) so callers have a single error type to catch.
162
+ *
163
+ * **Invariant — consumers MUST branch on `code` (and optionally `reason`),
164
+ * never on `message`.** The English-language `message` is for operator
165
+ * logging only and is explicitly **not** part of the SemVer contract; a
166
+ * minor release MAY reword any message. Machine logic that depends on
167
+ * message text will break silently across upgrades.
168
+ */
169
+
170
+ /**
171
+ * Options for {@link ScorezillaError.from}.
172
+ *
173
+ * The fields mirror what's available after a fetch round-trip: the HTTP
174
+ * status, the parsed JSON body (if any), the request ID from
175
+ * `X-Request-Id`, and an optional `cause` for the underlying
176
+ * network/abort error.
177
+ */
178
+ interface ScorezillaErrorFromInit {
179
+ status: number;
180
+ body?: ApiError | undefined;
181
+ requestId?: string | undefined;
182
+ cause?: unknown;
183
+ }
184
+ /**
185
+ * Thrown by the SDK for every failure path — non-2xx responses, network
186
+ * errors, aborts, and timeouts.
187
+ *
188
+ * Cross-realm `instanceof` is guaranteed: the class sets `Error.prototype`
189
+ * explicitly so checks survive iframe / worker boundaries.
190
+ *
191
+ * @example
192
+ * ```ts
193
+ * try {
194
+ * await sz.submitScore({ boardId, playerId, score });
195
+ * } catch (e) {
196
+ * if (!(e instanceof ScorezillaError)) throw e;
197
+ *
198
+ * if (e.isRateLimited()) {
199
+ * await sleep((e.retryAfter ?? 30) * 1000);
200
+ * return retry();
201
+ * }
202
+ * if (e.code === 'out_of_bounds') {
203
+ * console.warn(`Score crosses ${e.reason} bound (limit ${e.bound})`);
204
+ * return;
205
+ * }
206
+ * if (e.isAuth()) throw new Error('SDK misconfigured — bad publicKey');
207
+ *
208
+ * // Anything else: surface to your reporter with requestId for support.
209
+ * console.error(`Scorezilla ${e.code} (${e.status}) — request ${e.requestId}`);
210
+ * throw e;
211
+ * }
212
+ * ```
213
+ *
214
+ * @since 0.1.0
215
+ * @stability stable
216
+ */
217
+ declare class ScorezillaError extends Error {
218
+ /** HTTP status of the response, or {@link STATUS_NETWORK_ERROR} (0) for
219
+ * network / abort / timeout. */
220
+ readonly status: number;
221
+ /** Machine-stable error code from the API. Open union — see
222
+ * {@link ScorezillaErrorCode}. For network errors, this is `'network_error'`;
223
+ * for aborts, `'aborted'`; for timeouts, `'timeout'`. */
224
+ readonly code: ScorezillaErrorCode;
225
+ /** Sub-classifier — present on `out_of_bounds` (`'below_min' | 'above_max'`)
226
+ * and possibly other codes in future minor releases. */
227
+ readonly reason: OutOfBoundsReason | string | undefined;
228
+ /** Seconds — present on `rate_limited`. Honored by the transport's retry
229
+ * policy (Step 2.4). */
230
+ readonly retryAfter: number | undefined;
231
+ /** Server-issued request ID, lifted from the `X-Request-Id` response
232
+ * header. Pass this to support when filing bugs. */
233
+ readonly requestId: string | undefined;
234
+ /** The bound value crossed on `out_of_bounds`. */
235
+ readonly bound: number | undefined;
236
+ /** Which rate-limit layer fired on `rate_limited`. */
237
+ readonly layer: string | undefined;
238
+ /** The underlying cause (e.g., a `TypeError: fetch failed`) for
239
+ * network/abort/timeout paths. `undefined` when the error came from a
240
+ * successfully-parsed API error body. */
241
+ readonly cause: unknown;
242
+ constructor(message: string, init: {
243
+ status: number;
244
+ code: ScorezillaErrorCode;
245
+ reason?: string | undefined;
246
+ retryAfter?: number | undefined;
247
+ requestId?: string | undefined;
248
+ bound?: number | undefined;
249
+ layer?: string | undefined;
250
+ cause?: unknown;
251
+ });
252
+ /** `true` when this error is a 429 / `rate_limited`. */
253
+ isRateLimited(): boolean;
254
+ /** `true` when this error is a 401 / `unauthorized` (or 403 / `forbidden`). */
255
+ isAuth(): boolean;
256
+ /** `true` when this error is a 404 / `not_found`. */
257
+ isNotFound(): boolean;
258
+ /** `true` when this error is a 422 / `out_of_bounds` (score below/above board limit). */
259
+ isOutOfBounds(): boolean;
260
+ /** `true` for transient / retryable conditions: network errors, timeouts,
261
+ * 5xx, and 429. The transport layer relies on this for its retry policy. */
262
+ isTransient(): boolean;
263
+ /**
264
+ * Build a `ScorezillaError` from a fetch round-trip outcome.
265
+ *
266
+ * Prefer this over `new ScorezillaError(...)` from the transport layer —
267
+ * it does the mapping from API response shape to error fields in one
268
+ * place, so future fields like `correlationId` get added once here.
269
+ *
270
+ * @param init - status, optional parsed body, optional requestId, optional cause
271
+ */
272
+ static from(init: ScorezillaErrorFromInit): ScorezillaError;
273
+ /**
274
+ * Build a `ScorezillaError` for a transport-level failure (no HTTP
275
+ * response received): network error, abort, or timeout.
276
+ */
277
+ static network(message: string, cause: unknown): ScorezillaError;
278
+ /** Build a `ScorezillaError` for an `AbortSignal`-triggered cancellation. */
279
+ static aborted(cause: unknown): ScorezillaError;
280
+ /** Build a `ScorezillaError` for a request that exceeded its timeout budget. */
281
+ static timeout(timeoutMs: number): ScorezillaError;
282
+ }
283
+
284
+ export { type ApiSuccess as A, type BaseConfig as B, type LeaderboardResponse as L, type OutOfBoundsReason as O, type PlayerRankResponse as P, type RankedEntry as R, type SecretKeyConfig as S, type WindowAroundResponse as W, type SubmitScoreResponse as a, ScorezillaError as b, type ScorezillaErrorCode as c, type ScorezillaConfig as d, type ApiError as e, type ApiResponse as f, type PublicKeyConfig as g };