scorezilla 0.1.0-next.3 → 0.3.0-next.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/CHANGELOG.md CHANGED
@@ -1,5 +1,189 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0-next.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#36](https://github.com/isco-tec/scorezilla-js/pull/36) [`19c2dcc`](https://github.com/isco-tec/scorezilla-js/commit/19c2dcc14d2000551d80498813b075172c8f4d66) Thanks [@isco-tec](https://github.com/isco-tec)! - feat(identity): preset helpers for `scorezilla/identity` (Phase 1)
8
+
9
+ New subpath export: `scorezilla/identity`. Three identity-strategy
10
+ presets ship as `stable`; one OAuth helper ships as a preview stub.
11
+
12
+ **Stable in this release:**
13
+ - `useAnonymousPlayer({ storageKey })` — generates a UUID, persists in
14
+ localStorage, same browser keeps the same id across reloads. Returns
15
+ `{ id, forget() }`. Privacy-safe by default (no PII).
16
+ - `usePromptedPlayer({ storageKey, prompt })` — `window.prompt()` on
17
+ first run, persists to localStorage. Returns `{ id, forget() } | null`
18
+ (null when SSR, no `prompt`, or user cancels).
19
+ - `useServerAuthoritative()` — no-op marker for snippets using the
20
+ HMAC-signed secure path (`scorezilla/server`). The browser SDK does
21
+ no identity work; the server picks the value.
22
+
23
+ **Preview stub in this release (throws on call):**
24
+ - `useAuthProvider({ provider: 'google' | 'github' })` — OAuth-backed
25
+ identity. Full implementation (Google + GitHub for v1) ships in a
26
+ follow-up `next` release before the 0.3.0 latest promote.
27
+
28
+ Per [ADR 0003](https://github.com/isco-tec/scorezilla/blob/main/docs/adr/0003-mcp-identity-axis.md). All helpers document where data is stored and
29
+ what `forget()` / `signOut()` does NOT do (server-side history is
30
+ retained — call admin delete-player for full erasure).
31
+
32
+ Closes upstream tracking issue isco-tec/scorezilla#125 (Phase 1).
33
+
34
+ ## 0.2.0 — `scorezilla/server` HMAC adapter GA
35
+
36
+ ### Minor Changes
37
+
38
+ - [#25](https://github.com/isco-tec/scorezilla-js/pull/25) [`5f7025b`](https://github.com/isco-tec/scorezilla-js/commit/5f7025b7b06dded92b3e710316454eb8c891d053) Thanks [@isco-tec](https://github.com/isco-tec)! - **`scorezilla/server`: HMAC requests now bind to the target host (v=2).**
39
+
40
+ The canonical signing string includes the host of the request URL, so a signature minted against staging cannot be replayed against prod (or any other origin that happens to share key material). SigV4 has had this since day one; closing the gap before MCP locks the format.
41
+
42
+ Wire format change:
43
+
44
+ ```
45
+ Authorization: Scorezilla-HMAC-SHA256 keyId=…, ts=…, nonce=…, signature=…, v=2
46
+ ```
47
+
48
+ The `v=2` parameter is new. The canonical signing string now has six lines instead of five — `host` is inserted between `METHOD` and `pathAndQuery`, lowercased per RFC 9110 §4.2.4.
49
+
50
+ **Backward compatibility.** The API verifier still accepts v=1 (no `v=` field, no host binding) during the rollout window — your existing pre-next.3 SDK builds will keep working until v=1 is deprecated. The SDK itself, however, emits v=2 unconditionally from next.3 onwards. If you've forked or wrapped `buildHmacAuthHeader` / `buildSigningString`, you'll need to thread a `host` parameter through.
51
+
52
+ **For consumers of `Scorezilla` from `scorezilla/server`:** no API changes. The constructor derives `host` from `baseUrl` automatically. As an added safety net, an invalid `baseUrl` now throws at construction time rather than producing 401 mismatches at every request.
53
+
54
+ **For low-level consumers of `buildSigningString` / `buildHmacAuthHeader`:** a new `host` parameter is now required. The latter also accepts an optional `version: 1 | 2` for explicit backward-compat scenarios.
55
+
56
+ - [`239642a`](https://github.com/isco-tec/scorezilla-js/commit/239642aaf73f643c2f51d806170d3ced31a3fe68) Thanks [@isco-tec](https://github.com/isco-tec)! - **Initial public release candidate.** First publish of the `scorezilla` SDK to npm.
57
+
58
+ Includes the v0.1.0 public-key client surface:
59
+ - `Scorezilla` class with `submitScore`, `getLeaderboard`, `getPlayerRank`, `getWindowAround`
60
+ - `ScorezillaError` for every failure path (network, timeout, abort, HTTP non-2xx)
61
+ - Universal runtime support (browsers, Node ≥ 20, Cloudflare Workers, Bun, Deno)
62
+ - Automatic retries with idempotency keys
63
+ - Dual ESM/CJS build with [arethetypeswrong](https://arethetypeswrong.github.io/)-clean exports map
64
+ - ~3.8 KB ESM gzipped
65
+
66
+ This RC publishes under the `next` npm dist-tag — install with `npm install scorezilla@next` to try it out. The HMAC server adapter, React hooks, and Phaser plugin land in subsequent v0.2.0, v0.3.0, and v0.4.0 releases.
67
+
68
+ - [#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).
69
+
70
+ The new `scorezilla/server` subpath ships an HMAC-SHA256 signing client for
71
+ game backends that need cheat-resistant submissions. The browser-side
72
+ public-key client (`scorezilla`) is unchanged.
73
+
74
+ ```ts
75
+ import { Scorezilla } from 'scorezilla/server';
76
+
77
+ const sz = new Scorezilla({
78
+ secretKey: {
79
+ id: process.env.SCOREZILLA_KEY_ID,
80
+ secret: process.env.SCOREZILLA_KEY_SECRET, // never ship to a browser
81
+ },
82
+ });
83
+
84
+ await sz.submitScore({ boardId, playerId, score, metadata });
85
+ await sz.getLeaderboard({ boardId, top });
86
+ await sz.getPlayerRank({ boardId, playerId });
87
+ await sz.getWindowAround({ boardId, playerId, before, after });
88
+ ```
89
+
90
+ Behavior:
91
+ - Each request is signed with HMAC-SHA256 over a canonical
92
+ `{ts}\n{nonce}\n{METHOD}\n{path?query}\n{sha256_hex(body)}` string and
93
+ delivered via `Authorization: Scorezilla-HMAC-SHA256 keyId=…, ts=…,
94
+ nonce=…, signature=…`. Matches the API's verifier byte-for-byte.
95
+ - `submitScore` posts to `/v1/secure/scores` (the boardId moves into the
96
+ body — the public-key endpoint at `/v1/boards/:id/scores` is unchanged).
97
+ - Read methods (`getLeaderboard` / `getPlayerRank` / `getWindowAround`)
98
+ hit the same paths as the public-key client.
99
+ - Every retry attempt regenerates `(ts, nonce)` so server-side replay
100
+ protection (10-minute nonce window) doesn't trip.
101
+ - Importing `scorezilla/server` from a browser bundle throws at module
102
+ evaluation — the `sk_live_*` secret never reaches client-side code.
103
+
104
+ Same `ScorezillaError` surface (`isRateLimited()`, `isAuth()`,
105
+ `isTransient()`, `code`, `requestId`, etc.) — typed catch patterns
106
+ written for v0.1 work unchanged.
107
+
108
+ Bundle: ~3.84 KB ESM gzipped, ~4.04 KB CJS gzipped — well under the
109
+ 6 KB per-entry cap.
110
+
111
+ - [#23](https://github.com/isco-tec/scorezilla-js/pull/23) [`5163e31`](https://github.com/isco-tec/scorezilla-js/commit/5163e3130ac208d591b026d66870ce5a194f90b6) Thanks [@isco-tec](https://github.com/isco-tec)! - **`scorezilla/server` adopts a single-token secret-key format.** Breaking
112
+ change vs. v0.1.0-next.1; we're still in pre-release so this is permitted.
113
+
114
+ Before (v0.1.0-next.1):
115
+
116
+ ```ts
117
+ new Scorezilla({
118
+ secretKey: { id: 'sk-id-uuid', secret: 'sk_live_xxxxx' },
119
+ });
120
+ ```
121
+
122
+ After (v0.1.0-next.2):
123
+
124
+ ```ts
125
+ new Scorezilla({
126
+ secretKey: 'sk_live_<keyId>_xxxxx', // one self-contained token
127
+ });
128
+ ```
129
+
130
+ The keyId is embedded in the secret string itself; the SDK parses it
131
+ out via `/^sk_live_([0-9a-f]{8}-...)_/` before signing. The HMAC key
132
+ remains the WHOLE plaintext. Wire format on the Authorization header
133
+ is unchanged. API verification is unchanged.
134
+
135
+ Rationale: matches Stripe's design and the public-key client's
136
+ single-string shape. One value to copy from the dashboard, one to
137
+ manage in env config, one to pass into the constructor. Previously
138
+ users had to manage two distinct values (`id` AND `secret`) which
139
+ created confusion — they're functionally one credential.
140
+
141
+ **To upgrade**: issue (or rotate) a fresh secret key in the dashboard.
142
+ Old-format keys (no embedded keyId) are rejected by the SDK with a
143
+ clear error message pointing at the migration path.
144
+
145
+ - [#30](https://github.com/isco-tec/scorezilla-js/pull/30) [`39fc70f`](https://github.com/isco-tec/scorezilla-js/commit/39fc70fa3a38bce619e87aa9f6179e79f1ae45ff) Thanks [@isco-tec](https://github.com/isco-tec)! - Handle HTTP 402 `usage_cap_exceeded` + observe API deprecation headers.
146
+
147
+ **402 / `usage_cap_exceeded`**: when the server returns 402 (tenant hit
148
+ their monthly submission cap or is suspended), the SDK now surfaces a
149
+ typed `ScorezillaError` with the full cap context:
150
+ - `err.code === 'usage_cap_exceeded'`
151
+ - `err.reason === 'over_cap' | 'suspended'`
152
+ - `err.tier`, `err.cap`, `err.count`, `err.period`, `err.resetsAt`
153
+ - `err.isUsageCapExceeded()` predicate
154
+ - `err.isSuspended()` predicate (distinguishes the suspended sub-case)
155
+ - `err.isTransient() === false` — no auto-retry (the cap doesn't lift
156
+ until `resetsAt`)
157
+
158
+ **Deprecation signals**: when an API response carries an RFC 8594
159
+ `Sunset` header or IETF `Deprecation` header, the SDK now logs a
160
+ once-per-process `console.warn` so developers see deprecation
161
+ notices during dev without prod-loop spam. The warning includes the
162
+ sunset date and any `Link: rel="deprecation"` documentation URL.
163
+
164
+ No breaking changes. New fields are additive; existing error handling
165
+ keeps working unchanged.
166
+
167
+ ### Patch Changes
168
+
169
+ - [#31](https://github.com/isco-tec/scorezilla-js/pull/31) [`9376a2d`](https://github.com/isco-tec/scorezilla-js/commit/9376a2d93d4edb42decca1bcf495b83fc013c309) Thanks [@isco-tec](https://github.com/isco-tec)! - **Error mapping, retry-policy alignment, `AbortSignal` plumbing, injectable warn, validator-return tightening.**
170
+
171
+ Bundled correctness + ergonomics fixes from the in-house improvement-phase review:
172
+ - `ScorezillaError.code` now resolves to `'conflict'` on HTTP 409 responses (previously fell through to `'invalid_input'`). The error type already listed `'conflict'` as a valid code; the gap was in the status→code mapper. A new `e.isConflict()` predicate joins the existing `isAuth() / isNotFound() / isRateLimited() / isOutOfBounds() / isUsageCapExceeded() / isTransient()` family.
173
+ - `e.isTransient()` no longer flags `timeout` or `aborted` as transient — those are caller-observable terminal states, not retryable. The predicate is now aligned with the transport's actual retry policy (`shouldRetryError` in `retry.ts`), so consumer code mirroring "if (e.isTransient()) retry()" no longer loops on timeouts.
174
+ - All four public methods on **both** the public-key client (`Scorezilla`) and the secret-key server adapter (`Scorezilla` from `scorezilla/server`) now accept an optional `signal?: AbortSignal` via a shared `CancellableInput` shape, and forward it through the transport. Unblocks request-cancellation propagation under frameworks that thread cancellation through the request lifecycle (Next.js route handlers, Hono, Express middleware, React effect cleanup).
175
+ - New `warn?: (...args: unknown[]) => void` field on `ScorezillaConfig`. Defaults to `console.warn`. Pass your logger to route SDK deprecation notices into your observability stack, or pass `() => {}` to suppress them. Used today only for the `Deprecation` / `Sunset` once-per-process warning emitted when the API signals an upcoming sunset — at million-integration scale, embedders shouldn't have to console-filter our messages.
176
+ - `validateMetadata` now returns the serialized JSON string it computed (previously declared `void` despite a comment that claimed otherwise). Public callers that re-stringify metadata can reuse the value and skip a duplicate `JSON.stringify` pass on the hot submit path.
177
+
178
+ No breaking changes for callers that didn't rely on the two buggy classifications, didn't need cancellation, and didn't read `validateMetadata`'s return value.
179
+
180
+ - [#28](https://github.com/isco-tec/scorezilla-js/pull/28) [`94b39d2`](https://github.com/isco-tec/scorezilla-js/commit/94b39d2e67ec7bba2681145560529f058acfc633) Thanks [@isco-tec](https://github.com/isco-tec)! - **Pre-release security hardening pass.** Three issues surfaced by a focused security review before the first public release of `scorezilla/server`:
181
+ - **HIGH — nonce injection.** `buildHmacAuthHeader` now requires injected nonces to be at least 16 characters. The default path (`crypto.randomUUID()`) is unaffected. Previously a misconfigured caller could pass `nonce: ''` and silently sign a header with no replay protection — the server has no minimum-length check, so the API would accept it. Now caught at SDK build-time with a clear error.
182
+ - **HIGH — server policy disclosure.** `HMAC_TIMESTAMP_WINDOW_SECONDS = 300` was previously exported as documentation of the API's clock-skew tolerance. Removed from the public API — publishing the server's replay-protection window in the SDK's types was unnecessary information disclosure and narrowed the pre-computation window for any future timestamp-forgery work. The SDK has no behavioral dependency on the value (it always emits a fresh `ts = floor(Date.now() / 1000)`).
183
+ - **MEDIUM — `EdgeRuntime` polyfill bypass.** The server adapter's runtime browser-guard previously trusted `globalThis.EdgeRuntime` as a "this is a server" signal. A browser extension or bundler misconfig setting that global would have bypassed the guard. Removed from the trusted set — the package's `exports.browser` condition remains the primary gate, and the runtime check now refuses to trust globals that can be polyfilled from a browser context. Real Vercel Edge runtimes still pass the guard because they don't have `window`/`document`.
184
+
185
+ No external-attack surface was exploitable; all three are self-inflicted/defense-in-depth issues caught before the first stable release. Tests added: 4 for nonce validation (rejects empty / too-short, accepts at-floor / default UUID), 1 for the EdgeRuntime polyfill scenario.
186
+
3
187
  ## 0.1.0-next.3
4
188
 
5
189
  ### Minor Changes
package/README.md CHANGED
@@ -43,6 +43,19 @@ yarn add scorezilla
43
43
  bun add scorezilla
44
44
  ```
45
45
 
46
+ ## Get your keys
47
+
48
+ The Quickstart below needs two values: a `publicKey` and a `boardId`. Both come from the dashboard:
49
+
50
+ 1. **Sign in** at [dashboard.scorezilla.dev](https://dashboard.scorezilla.dev) (magic-link email — no password).
51
+ 2. **Open the Tutorial Game** that's created for you on first sign-in (or create a new game).
52
+ 3. **Open Keys → Issue public key**. Copy the `pk_*` string (shown once — also visible in the keys list afterwards).
53
+ 4. **Open Boards → New board** (or use the auto-created "High Scores"). Copy the `boardId` UUID.
54
+
55
+ You're now ready for the Quickstart below. **Public keys are safe to ship in client code** — they only authorize submits, not key rotation or board admin.
56
+
57
+ > Using AI to scaffold? Skip the manual steps: install the [Scorezilla MCP server](https://github.com/isco-tec/scorezilla-mcp) in Claude Code / Cursor and run `bootstrap_leaderboard` — the AI gets your keys + board id + a ready-to-paste integration snippet in one tool call.
58
+
46
59
  ## Quickstart
47
60
 
48
61
  ```ts
@@ -37,6 +37,13 @@ interface BaseConfig {
37
37
  * this unset to use the default exponential backoff with jitter.
38
38
  * @internal */
39
39
  sleepImpl?: (ms: number, signal?: AbortSignal) => Promise<void>;
40
+ /** Inject a sink for SDK deprecation warnings. Defaults to
41
+ * `console.warn`. Pass your logger's `warn` to route SDK signals into
42
+ * the rest of your observability stack, or pass `() => {}` to
43
+ * suppress them. The SDK uses this ONLY for developer-visible
44
+ * deprecation notices triggered by `Deprecation` / `Sunset` response
45
+ * headers — never for runtime errors. */
46
+ warn?: (...args: unknown[]) => void;
40
47
  }
41
48
  /** Public-key auth: browser-safe path. The key is fingerprinted to a game
42
49
  * on the server side via `pk_<gameSlug>_<base62>`. */
@@ -86,9 +93,22 @@ type ScorezillaConfig = PublicKeyConfig | SecretKeyConfig;
86
93
  *
87
94
  * @stable v0.1.0
88
95
  */
89
- type ScorezillaErrorCode = 'unauthorized' | 'forbidden' | 'not_found' | 'invalid_input' | 'invalid_json' | 'out_of_bounds' | 'rate_limited' | 'conflict' | 'internal_error' | (string & {});
96
+ type ScorezillaErrorCode = 'unauthorized' | 'forbidden' | 'not_found' | 'invalid_input' | 'invalid_json' | 'out_of_bounds' | 'rate_limited' | 'conflict' | 'internal_error'
97
+ /** 402 Payment Required — tenant exceeded their monthly submission cap, OR
98
+ * the tenant is `'suspended'` (see {@link UsageCapReason}). The error body
99
+ * carries `tier`, `cap`, `count`, `period`, `resetsAt`. */
100
+ | 'usage_cap_exceeded' | (string & {});
90
101
  /** Reason sub-classifier on `out_of_bounds` errors. Open union — see {@link ScorezillaErrorCode}. */
91
102
  type OutOfBoundsReason = 'below_min' | 'above_max' | (string & {});
103
+ /** Reason sub-classifier on `usage_cap_exceeded` errors.
104
+ * - `'over_cap'` — tenant hit their tier's monthly submit limit
105
+ * - `'suspended'` — tenant's `billing_status` is `'suspended'`; every
106
+ * submit is rejected (cap is structurally 0)
107
+ * Open union for forward compatibility.
108
+ */
109
+ type UsageCapReason = 'over_cap' | 'suspended' | (string & {});
110
+ /** Tier identifier mirrored from the server's `PlanConfig.key`. */
111
+ type BillingTier = 'free' | 'indie' | 'pro' | 'studio' | 'enterprise' | 'suspended' | (string & {});
92
112
  /** Successful API response envelope. The `T` is the per-route payload. */
93
113
  type ApiSuccess<T> = {
94
114
  ok: true;
@@ -107,6 +127,21 @@ interface ApiError {
107
127
  layer?: string;
108
128
  /** The limit value that was crossed — present on `out_of_bounds`. */
109
129
  bound?: number;
130
+ /** Tenant's tier at the time of rejection. */
131
+ tier?: BillingTier;
132
+ /** The cap value that was crossed (monthly submit limit). `0` for
133
+ * suspended tenants; `null` is never sent (enterprise has no cap and
134
+ * can never be over). */
135
+ cap?: number;
136
+ /** The post-increment submit count when the cap was crossed. Always
137
+ * > `cap` when `reason === 'over_cap'`. */
138
+ count?: number;
139
+ /** The period the count belongs to, in `YYYY-MM` UTC form. */
140
+ period?: string;
141
+ /** ISO-8601 timestamp of midnight UTC on the 1st of the next month —
142
+ * when the counter resets. Lets clients compute `Retry-After` without
143
+ * parsing dates server-side. */
144
+ resetsAt?: string;
110
145
  }
111
146
  /** Discriminated envelope: every API response is one of these two shapes. */
112
147
  type ApiResponse<T> = ApiSuccess<T> | ApiError;
@@ -230,9 +265,11 @@ declare class ScorezillaError extends Error {
230
265
  * {@link ScorezillaErrorCode}. For network errors, this is `'network_error'`;
231
266
  * for aborts, `'aborted'`; for timeouts, `'timeout'`. */
232
267
  readonly code: ScorezillaErrorCode;
233
- /** Sub-classifier — present on `out_of_bounds` (`'below_min' | 'above_max'`)
268
+ /** Sub-classifier — present on:
269
+ * - `out_of_bounds`: `'below_min' | 'above_max'`
270
+ * - `usage_cap_exceeded`: `'over_cap' | 'suspended'`
234
271
  * and possibly other codes in future minor releases. */
235
- readonly reason: OutOfBoundsReason | string | undefined;
272
+ readonly reason: OutOfBoundsReason | UsageCapReason | string | undefined;
236
273
  /** Seconds — present on `rate_limited`. Honored by the transport's retry
237
274
  * policy (Step 2.4). */
238
275
  readonly retryAfter: number | undefined;
@@ -243,6 +280,20 @@ declare class ScorezillaError extends Error {
243
280
  readonly bound: number | undefined;
244
281
  /** Which rate-limit layer fired on `rate_limited`. */
245
282
  readonly layer: string | undefined;
283
+ /** Tenant's billing tier — present on `usage_cap_exceeded`. */
284
+ readonly tier: BillingTier | undefined;
285
+ /** The cap value crossed on `usage_cap_exceeded`. `0` indicates a
286
+ * suspended tenant. `undefined` on all other error codes. */
287
+ readonly cap: number | undefined;
288
+ /** The post-increment submit count on `usage_cap_exceeded`. Always
289
+ * `> cap` when `reason === 'over_cap'`. */
290
+ readonly count: number | undefined;
291
+ /** The period the count belongs to on `usage_cap_exceeded`, in `YYYY-MM`
292
+ * UTC form. */
293
+ readonly period: string | undefined;
294
+ /** ISO-8601 timestamp of midnight UTC on the 1st of the next month —
295
+ * the counter's natural reset point on `usage_cap_exceeded`. */
296
+ readonly resetsAt: string | undefined;
246
297
  /** The underlying cause (e.g., a `TypeError: fetch failed`) for
247
298
  * network/abort/timeout paths. `undefined` when the error came from a
248
299
  * successfully-parsed API error body. */
@@ -255,19 +306,42 @@ declare class ScorezillaError extends Error {
255
306
  requestId?: string | undefined;
256
307
  bound?: number | undefined;
257
308
  layer?: string | undefined;
309
+ tier?: BillingTier | undefined;
310
+ cap?: number | undefined;
311
+ count?: number | undefined;
312
+ period?: string | undefined;
313
+ resetsAt?: string | undefined;
258
314
  cause?: unknown;
259
315
  });
260
316
  /** `true` when this error is a 429 / `rate_limited`. */
261
317
  isRateLimited(): boolean;
318
+ /**
319
+ * `true` when this error is a 402 / `usage_cap_exceeded`. The tenant
320
+ * has either hit their tier's monthly submit cap (`reason ===
321
+ * 'over_cap'`) or is suspended (`reason === 'suspended'`).
322
+ *
323
+ * Consumers SHOULD NOT auto-retry on this error — the cap doesn't lift
324
+ * until `resetsAt`. Surface to the developer with an upgrade prompt
325
+ * (over_cap) or contact-support message (suspended).
326
+ */
327
+ isUsageCapExceeded(): boolean;
328
+ /** `true` when this error is a 402 + reason 'suspended' (vs over-cap). */
329
+ isSuspended(): boolean;
262
330
  /** `true` when this error is a 401 / `unauthorized` (or 403 / `forbidden`). */
263
331
  isAuth(): boolean;
264
332
  /** `true` when this error is a 404 / `not_found`. */
265
333
  isNotFound(): boolean;
266
334
  /** `true` when this error is a 422 / `out_of_bounds` (score below/above board limit). */
267
335
  isOutOfBounds(): boolean;
268
- /** `true` for transient / retryable conditions: network errors, timeouts,
269
- * 5xx, and 429. The transport layer relies on this for its retry policy. */
336
+ /** `true` for the SDK's retryable conditions: pure network errors, 5xx, and
337
+ * 429. Deliberately excludes `timeout` and `aborted` those are caller-
338
+ * observable terminal states, not transient. Aligned with `shouldRetryError`
339
+ * in `retry.ts` so a consumer mirroring the SDK's retry policy gets the
340
+ * same answer the transport does. */
270
341
  isTransient(): boolean;
342
+ /** `true` when this error is a 409 / `conflict` (idempotency-key conflict
343
+ * on retry). */
344
+ isConflict(): boolean;
271
345
  /**
272
346
  * Build a `ScorezillaError` from a fetch round-trip outcome.
273
347
  *
@@ -37,6 +37,13 @@ interface BaseConfig {
37
37
  * this unset to use the default exponential backoff with jitter.
38
38
  * @internal */
39
39
  sleepImpl?: (ms: number, signal?: AbortSignal) => Promise<void>;
40
+ /** Inject a sink for SDK deprecation warnings. Defaults to
41
+ * `console.warn`. Pass your logger's `warn` to route SDK signals into
42
+ * the rest of your observability stack, or pass `() => {}` to
43
+ * suppress them. The SDK uses this ONLY for developer-visible
44
+ * deprecation notices triggered by `Deprecation` / `Sunset` response
45
+ * headers — never for runtime errors. */
46
+ warn?: (...args: unknown[]) => void;
40
47
  }
41
48
  /** Public-key auth: browser-safe path. The key is fingerprinted to a game
42
49
  * on the server side via `pk_<gameSlug>_<base62>`. */
@@ -86,9 +93,22 @@ type ScorezillaConfig = PublicKeyConfig | SecretKeyConfig;
86
93
  *
87
94
  * @stable v0.1.0
88
95
  */
89
- type ScorezillaErrorCode = 'unauthorized' | 'forbidden' | 'not_found' | 'invalid_input' | 'invalid_json' | 'out_of_bounds' | 'rate_limited' | 'conflict' | 'internal_error' | (string & {});
96
+ type ScorezillaErrorCode = 'unauthorized' | 'forbidden' | 'not_found' | 'invalid_input' | 'invalid_json' | 'out_of_bounds' | 'rate_limited' | 'conflict' | 'internal_error'
97
+ /** 402 Payment Required — tenant exceeded their monthly submission cap, OR
98
+ * the tenant is `'suspended'` (see {@link UsageCapReason}). The error body
99
+ * carries `tier`, `cap`, `count`, `period`, `resetsAt`. */
100
+ | 'usage_cap_exceeded' | (string & {});
90
101
  /** Reason sub-classifier on `out_of_bounds` errors. Open union — see {@link ScorezillaErrorCode}. */
91
102
  type OutOfBoundsReason = 'below_min' | 'above_max' | (string & {});
103
+ /** Reason sub-classifier on `usage_cap_exceeded` errors.
104
+ * - `'over_cap'` — tenant hit their tier's monthly submit limit
105
+ * - `'suspended'` — tenant's `billing_status` is `'suspended'`; every
106
+ * submit is rejected (cap is structurally 0)
107
+ * Open union for forward compatibility.
108
+ */
109
+ type UsageCapReason = 'over_cap' | 'suspended' | (string & {});
110
+ /** Tier identifier mirrored from the server's `PlanConfig.key`. */
111
+ type BillingTier = 'free' | 'indie' | 'pro' | 'studio' | 'enterprise' | 'suspended' | (string & {});
92
112
  /** Successful API response envelope. The `T` is the per-route payload. */
93
113
  type ApiSuccess<T> = {
94
114
  ok: true;
@@ -107,6 +127,21 @@ interface ApiError {
107
127
  layer?: string;
108
128
  /** The limit value that was crossed — present on `out_of_bounds`. */
109
129
  bound?: number;
130
+ /** Tenant's tier at the time of rejection. */
131
+ tier?: BillingTier;
132
+ /** The cap value that was crossed (monthly submit limit). `0` for
133
+ * suspended tenants; `null` is never sent (enterprise has no cap and
134
+ * can never be over). */
135
+ cap?: number;
136
+ /** The post-increment submit count when the cap was crossed. Always
137
+ * > `cap` when `reason === 'over_cap'`. */
138
+ count?: number;
139
+ /** The period the count belongs to, in `YYYY-MM` UTC form. */
140
+ period?: string;
141
+ /** ISO-8601 timestamp of midnight UTC on the 1st of the next month —
142
+ * when the counter resets. Lets clients compute `Retry-After` without
143
+ * parsing dates server-side. */
144
+ resetsAt?: string;
110
145
  }
111
146
  /** Discriminated envelope: every API response is one of these two shapes. */
112
147
  type ApiResponse<T> = ApiSuccess<T> | ApiError;
@@ -230,9 +265,11 @@ declare class ScorezillaError extends Error {
230
265
  * {@link ScorezillaErrorCode}. For network errors, this is `'network_error'`;
231
266
  * for aborts, `'aborted'`; for timeouts, `'timeout'`. */
232
267
  readonly code: ScorezillaErrorCode;
233
- /** Sub-classifier — present on `out_of_bounds` (`'below_min' | 'above_max'`)
268
+ /** Sub-classifier — present on:
269
+ * - `out_of_bounds`: `'below_min' | 'above_max'`
270
+ * - `usage_cap_exceeded`: `'over_cap' | 'suspended'`
234
271
  * and possibly other codes in future minor releases. */
235
- readonly reason: OutOfBoundsReason | string | undefined;
272
+ readonly reason: OutOfBoundsReason | UsageCapReason | string | undefined;
236
273
  /** Seconds — present on `rate_limited`. Honored by the transport's retry
237
274
  * policy (Step 2.4). */
238
275
  readonly retryAfter: number | undefined;
@@ -243,6 +280,20 @@ declare class ScorezillaError extends Error {
243
280
  readonly bound: number | undefined;
244
281
  /** Which rate-limit layer fired on `rate_limited`. */
245
282
  readonly layer: string | undefined;
283
+ /** Tenant's billing tier — present on `usage_cap_exceeded`. */
284
+ readonly tier: BillingTier | undefined;
285
+ /** The cap value crossed on `usage_cap_exceeded`. `0` indicates a
286
+ * suspended tenant. `undefined` on all other error codes. */
287
+ readonly cap: number | undefined;
288
+ /** The post-increment submit count on `usage_cap_exceeded`. Always
289
+ * `> cap` when `reason === 'over_cap'`. */
290
+ readonly count: number | undefined;
291
+ /** The period the count belongs to on `usage_cap_exceeded`, in `YYYY-MM`
292
+ * UTC form. */
293
+ readonly period: string | undefined;
294
+ /** ISO-8601 timestamp of midnight UTC on the 1st of the next month —
295
+ * the counter's natural reset point on `usage_cap_exceeded`. */
296
+ readonly resetsAt: string | undefined;
246
297
  /** The underlying cause (e.g., a `TypeError: fetch failed`) for
247
298
  * network/abort/timeout paths. `undefined` when the error came from a
248
299
  * successfully-parsed API error body. */
@@ -255,19 +306,42 @@ declare class ScorezillaError extends Error {
255
306
  requestId?: string | undefined;
256
307
  bound?: number | undefined;
257
308
  layer?: string | undefined;
309
+ tier?: BillingTier | undefined;
310
+ cap?: number | undefined;
311
+ count?: number | undefined;
312
+ period?: string | undefined;
313
+ resetsAt?: string | undefined;
258
314
  cause?: unknown;
259
315
  });
260
316
  /** `true` when this error is a 429 / `rate_limited`. */
261
317
  isRateLimited(): boolean;
318
+ /**
319
+ * `true` when this error is a 402 / `usage_cap_exceeded`. The tenant
320
+ * has either hit their tier's monthly submit cap (`reason ===
321
+ * 'over_cap'`) or is suspended (`reason === 'suspended'`).
322
+ *
323
+ * Consumers SHOULD NOT auto-retry on this error — the cap doesn't lift
324
+ * until `resetsAt`. Surface to the developer with an upgrade prompt
325
+ * (over_cap) or contact-support message (suspended).
326
+ */
327
+ isUsageCapExceeded(): boolean;
328
+ /** `true` when this error is a 402 + reason 'suspended' (vs over-cap). */
329
+ isSuspended(): boolean;
262
330
  /** `true` when this error is a 401 / `unauthorized` (or 403 / `forbidden`). */
263
331
  isAuth(): boolean;
264
332
  /** `true` when this error is a 404 / `not_found`. */
265
333
  isNotFound(): boolean;
266
334
  /** `true` when this error is a 422 / `out_of_bounds` (score below/above board limit). */
267
335
  isOutOfBounds(): boolean;
268
- /** `true` for transient / retryable conditions: network errors, timeouts,
269
- * 5xx, and 429. The transport layer relies on this for its retry policy. */
336
+ /** `true` for the SDK's retryable conditions: pure network errors, 5xx, and
337
+ * 429. Deliberately excludes `timeout` and `aborted` those are caller-
338
+ * observable terminal states, not transient. Aligned with `shouldRetryError`
339
+ * in `retry.ts` so a consumer mirroring the SDK's retry policy gets the
340
+ * same answer the transport does. */
270
341
  isTransient(): boolean;
342
+ /** `true` when this error is a 409 / `conflict` (idempotency-key conflict
343
+ * on retry). */
344
+ isConflict(): boolean;
271
345
  /**
272
346
  * Build a `ScorezillaError` from a fetch round-trip outcome.
273
347
  *
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ // src/identity.ts
4
+ var isBrowser = () => typeof window !== "undefined";
5
+ function readPersisted(key) {
6
+ if (!isBrowser()) return null;
7
+ try {
8
+ return window.localStorage.getItem(key);
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+ function writePersisted(key, value) {
14
+ if (!isBrowser()) return;
15
+ try {
16
+ window.localStorage.setItem(key, value);
17
+ } catch {
18
+ }
19
+ }
20
+ function removePersisted(key) {
21
+ if (!isBrowser()) return;
22
+ try {
23
+ window.localStorage.removeItem(key);
24
+ } catch {
25
+ }
26
+ }
27
+ function mintUuid() {
28
+ if (isBrowser() && typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
29
+ return crypto.randomUUID();
30
+ }
31
+ return `anon-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
32
+ }
33
+ function requireStorageKey(fnName, options) {
34
+ if (!options || typeof options.storageKey !== "string" || options.storageKey.length === 0) {
35
+ throw new TypeError(`${fnName}: options.storageKey is required (non-empty string)`);
36
+ }
37
+ return options.storageKey;
38
+ }
39
+ function useAnonymousPlayer(options) {
40
+ const storageKey = requireStorageKey("useAnonymousPlayer", options);
41
+ let id = readPersisted(storageKey);
42
+ if (id === null || id.length === 0) {
43
+ id = mintUuid();
44
+ writePersisted(storageKey, id);
45
+ }
46
+ return {
47
+ id,
48
+ forget: () => removePersisted(storageKey)
49
+ };
50
+ }
51
+ function usePromptedPlayer(options) {
52
+ const storageKey = requireStorageKey("usePromptedPlayer", options);
53
+ if (typeof options.prompt !== "string" || options.prompt.length === 0) {
54
+ throw new TypeError("usePromptedPlayer: options.prompt is required (non-empty string)");
55
+ }
56
+ let id = readPersisted(storageKey);
57
+ if (id === null || id.length === 0) {
58
+ if (!isBrowser() || typeof window.prompt !== "function") {
59
+ return null;
60
+ }
61
+ const entered = window.prompt(options.prompt);
62
+ if (entered === null || entered.length === 0) {
63
+ return null;
64
+ }
65
+ id = entered;
66
+ writePersisted(storageKey, id);
67
+ }
68
+ return {
69
+ id,
70
+ forget: () => removePersisted(storageKey)
71
+ };
72
+ }
73
+ function useServerAuthoritative() {
74
+ return { source: "server-authoritative" };
75
+ }
76
+ function useAuthProvider(_options) {
77
+ throw new Error(
78
+ "useAuthProvider is not yet implemented in this 0.3.0-next preview. OAuth provider helpers (Google + GitHub for v1) ship in a follow-up release on the `next` dist-tag. Until then, drive your own OAuth flow and pass the resulting user identifier to submitScore directly."
79
+ );
80
+ }
81
+
82
+ exports.useAnonymousPlayer = useAnonymousPlayer;
83
+ exports.useAuthProvider = useAuthProvider;
84
+ exports.usePromptedPlayer = usePromptedPlayer;
85
+ exports.useServerAuthoritative = useServerAuthoritative;
86
+ //# sourceMappingURL=identity.cjs.map
87
+ //# sourceMappingURL=identity.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/identity.ts"],"names":[],"mappings":"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","file":"identity.cjs","sourcesContent":["/**\n * Identity preset helpers — opinionated, selectable ways for your game to\n * generate or fetch a `playerId` for score submission.\n *\n * Background: every Scorezilla score carries an opaque `playerId`. The SDK\n * doesn't care whether it's a UUID, a nickname, an email, or a server\n * session token. But _how_ your game decides on that value is a UX +\n * privacy decision the team should make explicitly. These presets are the\n * blessed patterns; pick one per integration.\n *\n * See ADR 0003 (MCP identity axis) for the design rationale:\n * https://github.com/isco-tec/scorezilla/blob/main/docs/adr/0003-mcp-identity-axis.md\n *\n * @module scorezilla/identity\n * @since 0.3.0\n */\n\nexport interface AnonymousPlayerOptions {\n /** localStorage key under which the generated UUID is persisted. */\n readonly storageKey: string;\n}\n\nexport interface PromptedPlayerOptions {\n /** localStorage key under which the user-entered name is persisted. */\n readonly storageKey: string;\n /** Message shown in `window.prompt()` on first run. */\n readonly prompt: string;\n}\n\n/**\n * Identity handle returned by the storage-backed presets.\n *\n * `forget()` clears the persisted value from browser storage. It does\n * **not** delete server-side score history for this player — to fully\n * erase a player's data, call the admin \"delete player\" endpoint.\n */\nexport interface PlayerHandle {\n readonly id: string;\n readonly forget: () => void;\n}\n\n/**\n * Marker returned by `useServerAuthoritative()` to signal that the\n * game's backend (not the browser) owns the `playerId` via the\n * HMAC-signed secure path (`scorezilla/server`).\n */\nexport interface ServerAuthoritativeMarker {\n readonly source: 'server-authoritative';\n}\n\nconst isBrowser = (): boolean => typeof window !== 'undefined';\n\nfunction readPersisted(key: string): string | null {\n if (!isBrowser()) return null;\n try {\n return window.localStorage.getItem(key);\n } catch {\n // Storage may throw in sandboxed iframes, privacy mode, or when the\n // user has disabled site data. Treat as \"missing\"; the caller will\n // mint or re-prompt.\n return null;\n }\n}\n\nfunction writePersisted(key: string, value: string): void {\n if (!isBrowser()) return;\n try {\n window.localStorage.setItem(key, value);\n } catch {\n // ignore; next call will re-mint or re-prompt\n }\n}\n\nfunction removePersisted(key: string): void {\n if (!isBrowser()) return;\n try {\n window.localStorage.removeItem(key);\n } catch {\n // ignore\n }\n}\n\nfunction mintUuid(): string {\n if (isBrowser() && typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n return crypto.randomUUID();\n }\n // Best-effort fallback: timestamp + random suffix. Not cryptographically\n // strong, but opaque enough for the identifier-only use case. The\n // browsers we target (Chrome 92+, Firefox 95+, Safari 15.4+) all have\n // crypto.randomUUID — this branch is reached only in non-browser\n // environments where useAnonymousPlayer shouldn't be called anyway.\n return `anon-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction requireStorageKey(fnName: string, options: { storageKey?: unknown } | undefined): string {\n if (!options || typeof options.storageKey !== 'string' || options.storageKey.length === 0) {\n throw new TypeError(`${fnName}: options.storageKey is required (non-empty string)`);\n }\n return options.storageKey;\n}\n\n/**\n * Anonymous player identity. Generates an opaque UUID on first run and\n * persists it in `localStorage` so the same browser keeps the same ID\n * across page reloads.\n *\n * **Privacy.** Stores a randomly-generated UUID in browser localStorage;\n * the value is sent to the API on every score submission and persisted\n * indefinitely in the player's score-history rows. No PII is collected.\n * `forget()` removes the localStorage entry; for full server-side erasure\n * call the admin \"delete player\" endpoint.\n *\n * @example\n * ```ts\n * import { Scorezilla } from 'scorezilla';\n * import { useAnonymousPlayer } from 'scorezilla/identity';\n *\n * const player = useAnonymousPlayer({ storageKey: 'mygame:player' });\n * const sz = new Scorezilla({ publicKey: 'pk_…' });\n * await sz.submitScore({ boardId, playerId: player.id, score: 42 });\n * ```\n *\n * @since 0.3.0\n * @stability stable\n */\nexport function useAnonymousPlayer(options: AnonymousPlayerOptions): PlayerHandle {\n const storageKey = requireStorageKey('useAnonymousPlayer', options);\n let id = readPersisted(storageKey);\n if (id === null || id.length === 0) {\n id = mintUuid();\n writePersisted(storageKey, id);\n }\n return {\n id,\n forget: () => removePersisted(storageKey),\n };\n}\n\n/**\n * Prompted player identity. On first run shows a `window.prompt()` asking\n * the user for a name, then persists it in `localStorage` for subsequent\n * visits. Returns `null` if there is no browser (SSR), no `window.prompt`,\n * or if the user cancelled / entered an empty value.\n *\n * **Privacy.** The user-entered string is stored in browser localStorage,\n * transmitted to the API on every score submission, and persisted\n * indefinitely on the leaderboard. The persisted value is whatever the\n * user typed — sanitize at the UI layer if you care. `forget()` clears\n * local state but does NOT delete server-side history.\n *\n * **UX caveat.** `window.prompt()` blocks the main thread and looks\n * dated in modern apps. For a polished flow, build your own inline form\n * and pass the result to `submitScore` directly — the preset is here to\n * cover quick prototypes and jam-style integrations.\n *\n * @example\n * ```ts\n * import { Scorezilla } from 'scorezilla';\n * import { usePromptedPlayer } from 'scorezilla/identity';\n *\n * const player = usePromptedPlayer({\n * storageKey: 'mygame:player',\n * prompt: 'Enter a name for the leaderboard:',\n * });\n *\n * if (player) {\n * const sz = new Scorezilla({ publicKey: 'pk_…' });\n * await sz.submitScore({ boardId, playerId: player.id, score: 42 });\n * }\n * ```\n *\n * @since 0.3.0\n * @stability stable\n */\nexport function usePromptedPlayer(options: PromptedPlayerOptions): PlayerHandle | null {\n const storageKey = requireStorageKey('usePromptedPlayer', options);\n if (typeof options.prompt !== 'string' || options.prompt.length === 0) {\n throw new TypeError('usePromptedPlayer: options.prompt is required (non-empty string)');\n }\n\n let id = readPersisted(storageKey);\n if (id === null || id.length === 0) {\n if (!isBrowser() || typeof window.prompt !== 'function') {\n return null;\n }\n const entered = window.prompt(options.prompt);\n if (entered === null || entered.length === 0) {\n return null;\n }\n id = entered;\n writePersisted(storageKey, id);\n }\n return {\n id,\n forget: () => removePersisted(storageKey),\n };\n}\n\n/**\n * Server-authoritative identity marker. Signals that the game's backend\n * is responsible for the `playerId` via the HMAC-signed secure path\n * (`scorezilla/server`). The browser SDK does no identity work — the\n * server picks the value, signs the submission, and posts.\n *\n * The return value is a no-op marker; you don't pass it anywhere. It\n * exists so MCP-returned snippets can emit a single line that\n * unambiguously says \"this game uses the secure path; identity is\n * server-authoritative.\"\n *\n * @example\n * ```ts\n * // Client (no identity helper needed):\n * import { useServerAuthoritative } from 'scorezilla/identity';\n * useServerAuthoritative();\n *\n * // Server (where the real work happens):\n * import { Scorezilla } from 'scorezilla/server';\n * const sz = new Scorezilla({ secretKey: process.env.SCOREZILLA_SECRET_KEY! });\n * await sz.submitScore({ boardId, playerId: serverDerivedId, score });\n * ```\n *\n * @since 0.3.0\n * @stability stable\n */\nexport function useServerAuthoritative(): ServerAuthoritativeMarker {\n return { source: 'server-authoritative' };\n}\n\n/**\n * OAuth-backed player identity. **Preview stub in 0.3.0-next.x** — throws\n * on call. Full implementation (Google + GitHub for v1, Apple + Discord\n * deferred) lands in a follow-up release on the `next` dist-tag, before\n * the `latest` 0.3.0 ships.\n *\n * Until then: drive your own OAuth flow and pass the resulting user\n * identifier to `submitScore` directly.\n *\n * @since 0.3.0\n * @stability preview\n */\nexport function useAuthProvider(_options: { readonly provider: 'google' | 'github' }): never {\n throw new Error(\n 'useAuthProvider is not yet implemented in this 0.3.0-next preview. ' +\n 'OAuth provider helpers (Google + GitHub for v1) ship in a follow-up ' +\n 'release on the `next` dist-tag. Until then, drive your own OAuth flow ' +\n 'and pass the resulting user identifier to submitScore directly.',\n );\n}\n"]}