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/CHANGELOG.md +222 -0
- package/README.md +17 -4
- package/dist/{errors-Bp9ZDYqm.d.cts → errors-B7hyC-C5.d.cts} +94 -12
- package/dist/{errors-Bp9ZDYqm.d.ts → errors-B7hyC-C5.d.ts} +94 -12
- package/dist/index.cjs +112 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -6
- package/dist/index.d.ts +18 -6
- package/dist/index.js +112 -21
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +171 -24
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +28 -13
- package/dist/server.d.ts +28 -13
- package/dist/server.js +171 -24
- package/dist/server.js.map +1 -1
- package/package.json +5 -2
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 (
|
|
12
|
-
throw new Error(
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
|
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
|
|
185
|
-
*
|
|
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.
|
|
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.
|
|
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 = {
|
|
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;
|