scorezilla 0.1.0-next.1 → 0.2.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/dist/server.cjs CHANGED
@@ -3,34 +3,46 @@
3
3
  // src/config.ts
4
4
  var DEFAULT_BASE_URL = "https://api.scorezilla.dev";
5
5
  var SECRET_KEY_PREFIX = "sk_live_";
6
+ var SECRET_KEY_PATTERN = /^sk_live_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})_[A-Za-z0-9]+$/;
6
7
  function validateSecretKey(cfg) {
7
8
  if (!cfg || typeof cfg !== "object") {
8
9
  throw new Error("scorezilla/server: config must be an object with a secretKey field");
9
10
  }
10
11
  const sk = cfg.secretKey;
11
- if (!sk || typeof sk !== "object" || typeof sk.id !== "string" || typeof sk.secret !== "string") {
12
- throw new Error("scorezilla/server: secretKey must be an object with string `id` and `secret`");
13
- }
14
- if (sk.id.length === 0) {
15
- throw new Error("scorezilla/server: secretKey.id must be a non-empty string");
12
+ if (typeof sk !== "string") {
13
+ throw new Error(
14
+ `scorezilla/server: secretKey must be a single string of the shape ${SECRET_KEY_PREFIX}<keyId>_<random> (got: ${typeof sk})`
15
+ );
16
16
  }
17
- if (!sk.secret.startsWith(SECRET_KEY_PREFIX)) {
18
- const shape = `string of length ${sk.secret.length}`;
17
+ const match = SECRET_KEY_PATTERN.exec(sk);
18
+ if (!match) {
19
+ const shape = `string of length ${sk.length}`;
19
20
  throw new Error(
20
- `scorezilla/server: secretKey.secret must start with "${SECRET_KEY_PREFIX}" (live keys only) (got: ${shape})`
21
+ `scorezilla/server: secretKey must match ${SECRET_KEY_PATTERN.toString()} (got: ${shape}). v0.1.0-next.2 switched to a single-token format \u2014 if you have a pre-next.2 pair, issue a fresh key in the dashboard to upgrade.`
21
22
  );
22
23
  }
23
- return { keyId: sk.id, secret: sk.secret };
24
+ return { keyId: match[1], secret: sk };
24
25
  }
25
26
 
26
27
  // src/hmac.ts
27
28
  var enc = new TextEncoder();
28
29
  var HMAC_AUTH_SCHEME = "Scorezilla-HMAC-SHA256";
29
- async function buildSigningString(method, pathAndQuery, ts, nonce, body) {
30
+ var MIN_NONCE_LENGTH = 16;
31
+ var HMAC_SIGNING_VERSION_LATEST = 2;
32
+ async function buildSigningString(method, pathAndQuery, ts, nonce, body, host, version = HMAC_SIGNING_VERSION_LATEST) {
30
33
  const bodyHash = await sha256Hex(body);
34
+ const upperMethod = method.toUpperCase();
35
+ if (version === 1) {
36
+ return `${ts}
37
+ ${nonce}
38
+ ${upperMethod}
39
+ ${pathAndQuery}
40
+ ${bodyHash}`;
41
+ }
31
42
  return `${ts}
32
43
  ${nonce}
33
- ${method.toUpperCase()}
44
+ ${upperMethod}
45
+ ${host.toLowerCase()}
34
46
  ${pathAndQuery}
35
47
  ${bodyHash}`;
36
48
  }
@@ -51,16 +63,27 @@ async function sha256Hex(message) {
51
63
  }
52
64
  async function buildHmacAuthHeader(args) {
53
65
  const ts = args.nowSeconds ?? Math.floor(Date.now() / 1e3);
66
+ if (args.nonce !== void 0) {
67
+ if (typeof args.nonce !== "string" || args.nonce.length < MIN_NONCE_LENGTH) {
68
+ throw new Error(
69
+ `scorezilla: nonce must be a string of at least ${MIN_NONCE_LENGTH} characters; use the default (UUIDv4 via crypto.randomUUID) unless you have a specific reason.`
70
+ );
71
+ }
72
+ }
54
73
  const nonce = args.nonce ?? generateNonce();
74
+ const version = args.version ?? HMAC_SIGNING_VERSION_LATEST;
55
75
  const signingString = await buildSigningString(
56
76
  args.method,
57
77
  args.pathAndQuery,
58
78
  ts,
59
79
  nonce,
60
- args.body
80
+ args.body,
81
+ args.host,
82
+ version
61
83
  );
62
84
  const signature = await hmacSha256B64u(args.secret, signingString);
63
- return `${HMAC_AUTH_SCHEME} keyId=${args.keyId}, ts=${ts}, nonce=${nonce}, signature=${signature}`;
85
+ const vParam = version === 1 ? "" : `, v=${version}`;
86
+ return `${HMAC_AUTH_SCHEME} keyId=${args.keyId}, ts=${ts}, nonce=${nonce}, signature=${signature}${vParam}`;
64
87
  }
65
88
  function generateNonce() {
66
89
  const c = globalThis.crypto;
@@ -129,7 +152,9 @@ var ScorezillaError = class _ScorezillaError extends Error {
129
152
  * {@link ScorezillaErrorCode}. For network errors, this is `'network_error'`;
130
153
  * for aborts, `'aborted'`; for timeouts, `'timeout'`. */
131
154
  code;
132
- /** Sub-classifier — present on `out_of_bounds` (`'below_min' | 'above_max'`)
155
+ /** Sub-classifier — present on:
156
+ * - `out_of_bounds`: `'below_min' | 'above_max'`
157
+ * - `usage_cap_exceeded`: `'over_cap' | 'suspended'`
133
158
  * and possibly other codes in future minor releases. */
134
159
  reason;
135
160
  /** Seconds — present on `rate_limited`. Honored by the transport's retry
@@ -142,6 +167,20 @@ var ScorezillaError = class _ScorezillaError extends Error {
142
167
  bound;
143
168
  /** Which rate-limit layer fired on `rate_limited`. */
144
169
  layer;
170
+ /** Tenant's billing tier — present on `usage_cap_exceeded`. */
171
+ tier;
172
+ /** The cap value crossed on `usage_cap_exceeded`. `0` indicates a
173
+ * suspended tenant. `undefined` on all other error codes. */
174
+ cap;
175
+ /** The post-increment submit count on `usage_cap_exceeded`. Always
176
+ * `> cap` when `reason === 'over_cap'`. */
177
+ count;
178
+ /** The period the count belongs to on `usage_cap_exceeded`, in `YYYY-MM`
179
+ * UTC form. */
180
+ period;
181
+ /** ISO-8601 timestamp of midnight UTC on the 1st of the next month —
182
+ * the counter's natural reset point on `usage_cap_exceeded`. */
183
+ resetsAt;
145
184
  /** The underlying cause (e.g., a `TypeError: fetch failed`) for
146
185
  * network/abort/timeout paths. `undefined` when the error came from a
147
186
  * successfully-parsed API error body. */
@@ -156,6 +195,11 @@ var ScorezillaError = class _ScorezillaError extends Error {
156
195
  this.requestId = truncateField(init.requestId);
157
196
  this.bound = init.bound;
158
197
  this.layer = truncateField(init.layer);
198
+ this.tier = truncateField(init.tier);
199
+ this.cap = init.cap;
200
+ this.count = init.count;
201
+ this.period = truncateField(init.period);
202
+ this.resetsAt = truncateField(init.resetsAt);
159
203
  this.cause = init.cause;
160
204
  Object.setPrototypeOf(this, _ScorezillaError.prototype);
161
205
  if (typeof Error.captureStackTrace === "function") {
@@ -169,6 +213,22 @@ var ScorezillaError = class _ScorezillaError extends Error {
169
213
  isRateLimited() {
170
214
  return this.code === "rate_limited";
171
215
  }
216
+ /**
217
+ * `true` when this error is a 402 / `usage_cap_exceeded`. The tenant
218
+ * has either hit their tier's monthly submit cap (`reason ===
219
+ * 'over_cap'`) or is suspended (`reason === 'suspended'`).
220
+ *
221
+ * Consumers SHOULD NOT auto-retry on this error — the cap doesn't lift
222
+ * until `resetsAt`. Surface to the developer with an upgrade prompt
223
+ * (over_cap) or contact-support message (suspended).
224
+ */
225
+ isUsageCapExceeded() {
226
+ return this.code === "usage_cap_exceeded";
227
+ }
228
+ /** `true` when this error is a 402 + reason 'suspended' (vs over-cap). */
229
+ isSuspended() {
230
+ return this.code === "usage_cap_exceeded" && this.reason === "suspended";
231
+ }
172
232
  /** `true` when this error is a 401 / `unauthorized` (or 403 / `forbidden`). */
173
233
  isAuth() {
174
234
  return this.code === "unauthorized" || this.code === "forbidden";
@@ -181,13 +241,21 @@ var ScorezillaError = class _ScorezillaError extends Error {
181
241
  isOutOfBounds() {
182
242
  return this.code === "out_of_bounds";
183
243
  }
184
- /** `true` for transient / retryable conditions: network errors, timeouts,
185
- * 5xx, and 429. The transport layer relies on this for its retry policy. */
244
+ /** `true` for the SDK's retryable conditions: pure network errors, 5xx, and
245
+ * 429. Deliberately excludes `timeout` and `aborted` those are caller-
246
+ * observable terminal states, not transient. Aligned with `shouldRetryError`
247
+ * in `retry.ts` so a consumer mirroring the SDK's retry policy gets the
248
+ * same answer the transport does. */
186
249
  isTransient() {
187
- if (this.status === STATUS_NETWORK_ERROR) return true;
250
+ if (this.code === "network_error") return true;
188
251
  if (this.status >= 500 && this.status < 600) return true;
189
252
  return this.isRateLimited();
190
253
  }
254
+ /** `true` when this error is a 409 / `conflict` (idempotency-key conflict
255
+ * on retry). */
256
+ isConflict() {
257
+ return this.code === "conflict";
258
+ }
191
259
  // ─── Factory ─────────────────────────────────────────────────────────
192
260
  /**
193
261
  * Build a `ScorezillaError` from a fetch round-trip outcome.
@@ -208,6 +276,13 @@ var ScorezillaError = class _ScorezillaError extends Error {
208
276
  retryAfter: body.retryAfter,
209
277
  bound: body.bound,
210
278
  layer: body.layer,
279
+ // Usage-cap fields from `ApiError` (populated by the server on
280
+ // 402 responses; undefined on other errors).
281
+ tier: body.tier,
282
+ cap: body.cap,
283
+ count: body.count,
284
+ period: body.period,
285
+ resetsAt: body.resetsAt,
211
286
  requestId,
212
287
  cause
213
288
  });
@@ -249,8 +324,10 @@ var ScorezillaError = class _ScorezillaError extends Error {
249
324
  };
250
325
  function codeForStatus(status) {
251
326
  if (status === 401) return "unauthorized";
327
+ if (status === 402) return "usage_cap_exceeded";
252
328
  if (status === 403) return "forbidden";
253
329
  if (status === 404) return "not_found";
330
+ if (status === 409) return "conflict";
254
331
  if (status === 422) return "out_of_bounds";
255
332
  if (status === 429) return "rate_limited";
256
333
  if (status >= 500) return "internal_error";
@@ -361,6 +438,7 @@ async function request(opts) {
361
438
  }
362
439
  const response = await fetchImpl(url, init);
363
440
  if (response.ok) {
441
+ warnOnDeprecationOnce(response, opts.warnImpl);
364
442
  return await parseJson(response);
365
443
  }
366
444
  const body = await safelyParseErrorBody(response);
@@ -472,6 +550,29 @@ function readRetryAfter(response) {
472
550
  const n = Number(raw);
473
551
  return Number.isFinite(n) && n >= 0 ? n : void 0;
474
552
  }
553
+ var seenDeprecations = /* @__PURE__ */ new Set();
554
+ function warnOnDeprecationOnce(response, warnImpl) {
555
+ const deprecation = response.headers.get("Deprecation");
556
+ const sunset = response.headers.get("Sunset");
557
+ if (!deprecation && !sunset) return;
558
+ const link = response.headers.get("Link") ?? "";
559
+ const key = `${deprecation ?? ""}|${sunset ?? ""}|${link}`;
560
+ if (seenDeprecations.has(key)) return;
561
+ seenDeprecations.add(key);
562
+ const detail = [];
563
+ if (deprecation === "true" || deprecation) detail.push(`Deprecation: ${deprecation}`);
564
+ if (sunset) detail.push(`Sunset: ${sunset}`);
565
+ if (link) {
566
+ const m = link.match(/<([^>]+)>/);
567
+ if (m) detail.push(`Docs: ${m[1]}`);
568
+ }
569
+ const message = `[scorezilla-sdk] API responded with deprecation signal: ${detail.join(" \xB7 ")}. Upgrade your SDK before the sunset date.`;
570
+ if (warnImpl) {
571
+ warnImpl(message);
572
+ } else {
573
+ console.warn(message);
574
+ }
575
+ }
475
576
  function combineSignalsWithTimeout(caller, timeoutMs) {
476
577
  const ctrl = new AbortController();
477
578
  let didTimeOut = false;
@@ -529,27 +630,56 @@ var Scorezilla = class _Scorezilla {
529
630
  * Mirrors the static on the public-key client so consumers can log
530
631
  * the running SDK build the same way regardless of which surface
531
632
  * they imported. */
532
- static version = "0.1.0-next.1";
633
+ static version = "0.2.0";
533
634
  #keyId;
534
635
  #secret;
535
636
  #baseUrl;
637
+ /** Host portion of `#baseUrl` (e.g. "api.scorezilla.dev"). Captured at
638
+ * construction so every signed request binds to this exact origin —
639
+ * see `buildSigningString` v=2 in `hmac.ts`. */
640
+ #host;
536
641
  #fetchImpl;
537
642
  #timeoutMs;
538
643
  #maxRetries;
644
+ #sleepImpl;
645
+ #warnImpl;
539
646
  #userAgent;
540
647
  constructor(config) {
648
+ if (isRealBrowserEnvironment()) {
649
+ throw new Error(
650
+ "scorezilla/server: this adapter is server-only and must not run in browsers. Your bundler is shipping `scorezilla/server` into a browser bundle \u2014 check that it honors the `browser` export condition in package.json. Use the public-key client (`import { Scorezilla } from 'scorezilla'`) from browser code."
651
+ );
652
+ }
541
653
  const resolved = validateSecretKey(config);
542
654
  this.#keyId = resolved.keyId;
543
655
  this.#secret = resolved.secret;
544
656
  this.#baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
657
+ try {
658
+ this.#host = new URL(this.#baseUrl).host;
659
+ } catch {
660
+ throw new Error(
661
+ `scorezilla/server: baseUrl must be a valid absolute URL (got: ${this.#baseUrl})`
662
+ );
663
+ }
545
664
  this.#fetchImpl = config.fetch;
546
665
  this.#timeoutMs = config.timeoutMs;
547
666
  this.#maxRetries = config.maxRetries;
667
+ this.#sleepImpl = config.sleepImpl;
668
+ this.#warnImpl = config.warn;
548
669
  this.#userAgent = config.userAgent ?? defaultUserAgent(_Scorezilla.version);
549
670
  }
550
671
  /**
551
672
  * Submit a score to a board. Signed end-to-end — the API verifies
552
673
  * before any state change.
674
+ *
675
+ * **Behavioral note vs. the public-key client**: the server adapter
676
+ * does NOT perform local `metadata` validation. The public-key
677
+ * client (`scorezilla`) validates size + structure client-side and
678
+ * fails fast; the server adapter relies on the API to reject
679
+ * malformed metadata with `invalid_input`. Trade-off: smaller bundle
680
+ * + simpler server-side logic vs. one extra network round-trip on
681
+ * caller mistakes. If you want fast-fail behavior, validate metadata
682
+ * yourself before calling this method.
553
683
  */
554
684
  async submitScore(input) {
555
685
  return this.#request({
@@ -560,7 +690,8 @@ var Scorezilla = class _Scorezilla {
560
690
  playerId: input.playerId,
561
691
  score: input.score,
562
692
  ...input.metadata !== void 0 ? { metadata: input.metadata } : {}
563
- }
693
+ },
694
+ signal: input.signal
564
695
  });
565
696
  }
566
697
  /** Fetch the top-N entries on a board. */
@@ -570,14 +701,16 @@ var Scorezilla = class _Scorezilla {
570
701
  ...input.top !== void 0 ? { top: input.top } : {},
571
702
  ...input.offset !== void 0 ? { offset: input.offset } : {}
572
703
  }),
573
- method: "GET"
704
+ method: "GET",
705
+ signal: input.signal
574
706
  });
575
707
  }
576
708
  /** Look up a single player's rank on a board. */
577
709
  async getPlayerRank(input) {
578
710
  return this.#request({
579
711
  path: getPlayerRankPath(input.boardId, input.playerId),
580
- method: "GET"
712
+ method: "GET",
713
+ signal: input.signal
581
714
  });
582
715
  }
583
716
  /** Fetch the slice of entries surrounding a player. */
@@ -587,7 +720,8 @@ var Scorezilla = class _Scorezilla {
587
720
  ...input.before !== void 0 ? { before: input.before } : {},
588
721
  ...input.after !== void 0 ? { after: input.after } : {}
589
722
  }),
590
- method: "GET"
723
+ method: "GET",
724
+ signal: input.signal
591
725
  });
592
726
  }
593
727
  /**
@@ -613,18 +747,31 @@ var Scorezilla = class _Scorezilla {
613
747
  secret: this.#secret,
614
748
  method,
615
749
  pathAndQuery,
750
+ host: this.#host,
616
751
  body
617
752
  })
618
753
  };
619
754
  if (opts.body !== void 0) requestOpts.body = opts.body;
755
+ if (opts.signal !== void 0) requestOpts.signal = opts.signal;
620
756
  if (this.#fetchImpl !== void 0) requestOpts.fetchImpl = this.#fetchImpl;
757
+ if (this.#warnImpl !== void 0) requestOpts.warnImpl = this.#warnImpl;
621
758
  if (this.#timeoutMs !== void 0) requestOpts.timeoutMs = this.#timeoutMs;
622
- if (this.#maxRetries !== void 0) {
623
- requestOpts.retry = { maxRetries: this.#maxRetries };
759
+ if (this.#maxRetries !== void 0 || this.#sleepImpl !== void 0) {
760
+ requestOpts.retry = {
761
+ ...this.#maxRetries !== void 0 ? { maxRetries: this.#maxRetries } : {},
762
+ ...this.#sleepImpl !== void 0 ? { sleepImpl: this.#sleepImpl } : {}
763
+ };
624
764
  }
625
765
  return request(requestOpts);
626
766
  }
627
767
  };
768
+ function isRealBrowserEnvironment() {
769
+ const g = globalThis;
770
+ const hasBrowserGlobals = typeof g.window !== "undefined" && typeof g.document !== "undefined";
771
+ if (!hasBrowserGlobals) return false;
772
+ const hasNodeLikeHost = Boolean(g.process?.versions?.node) || typeof g.Deno !== "undefined" || typeof g.Bun !== "undefined";
773
+ return !hasNodeLikeHost;
774
+ }
628
775
 
629
776
  exports.Scorezilla = Scorezilla;
630
777
  exports.ScorezillaError = ScorezillaError;