scorezilla 0.3.0-next.1 → 0.3.0-next.3
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/API.md +125 -7
- package/CHANGELOG.md +88 -0
- package/README.md +131 -0
- package/RECIPES.md +227 -0
- package/dist/{errors-B7hyC-C5.d.cts → errors-CWTmormh.d.cts} +1 -1
- package/dist/{errors-B7hyC-C5.d.ts → errors-CWTmormh.d.ts} +1 -1
- package/dist/identity.cjs +116 -4
- package/dist/identity.cjs.map +1 -1
- package/dist/identity.d.cts +53 -20
- package/dist/identity.d.ts +53 -20
- package/dist/identity.js +116 -4
- package/dist/identity.js.map +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/server.cjs +344 -1
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +301 -3
- package/dist/server.d.ts +301 -3
- package/dist/server.js +338 -2
- package/dist/server.js.map +1 -1
- package/package.json +14 -3
package/dist/server.cjs
CHANGED
|
@@ -624,13 +624,225 @@ function defaultUserAgent(version, runtime = detectRuntime()) {
|
|
|
624
624
|
return `scorezilla-js/${version} (${runtime})`;
|
|
625
625
|
}
|
|
626
626
|
|
|
627
|
+
// src/verifiers.ts
|
|
628
|
+
async function loadJose() {
|
|
629
|
+
try {
|
|
630
|
+
return await import('jose');
|
|
631
|
+
} catch (cause) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
"scorezilla/server: the optional peer dependency 'jose' is required for verifyJwt() / verifySupabaseJwt(). Install it with `npm i jose` (or your package manager).",
|
|
634
|
+
{ cause }
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function extractBearerToken(req) {
|
|
639
|
+
const header = req.headers.get("authorization");
|
|
640
|
+
if (!header) return null;
|
|
641
|
+
const trimmed = header.trim();
|
|
642
|
+
if (!/^bearer\s+/i.test(trimmed)) return null;
|
|
643
|
+
return trimmed.replace(/^bearer\s+/i, "").trim() || null;
|
|
644
|
+
}
|
|
645
|
+
function verifyJwt(options) {
|
|
646
|
+
const claim = options.claim ?? "sub";
|
|
647
|
+
let keySet = null;
|
|
648
|
+
return async (req) => {
|
|
649
|
+
const token = extractBearerToken(req);
|
|
650
|
+
if (token === null) return null;
|
|
651
|
+
const jose = await loadJose();
|
|
652
|
+
if (keySet === null) {
|
|
653
|
+
keySet = jose.createRemoteJWKSet(
|
|
654
|
+
new URL(options.jwksUrl),
|
|
655
|
+
options.fetch ? { [jose.customFetch]: options.fetch } : void 0
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
try {
|
|
659
|
+
const { payload } = await jose.jwtVerify(token, keySet, {
|
|
660
|
+
...options.issuer !== void 0 ? { issuer: options.issuer } : {},
|
|
661
|
+
...options.audience !== void 0 ? { audience: options.audience } : {}
|
|
662
|
+
});
|
|
663
|
+
const id = payload[claim];
|
|
664
|
+
return typeof id === "string" && id.length > 0 ? { playerId: id } : null;
|
|
665
|
+
} catch {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
function verifySupabaseJwt(options) {
|
|
671
|
+
const base = options.supabaseUrl.replace(/\/+$/, "");
|
|
672
|
+
return verifyJwt({
|
|
673
|
+
jwksUrl: `${base}/auth/v1/.well-known/jwks.json`,
|
|
674
|
+
issuer: `${base}/auth/v1`,
|
|
675
|
+
audience: "authenticated",
|
|
676
|
+
...options.claim !== void 0 ? { claim: options.claim } : {},
|
|
677
|
+
...options.fetch !== void 0 ? { fetch: options.fetch } : {}
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
function verifyClerkJwt(options) {
|
|
681
|
+
const issuer = options.issuer.replace(/\/+$/, "");
|
|
682
|
+
return verifyJwt({
|
|
683
|
+
jwksUrl: `${issuer}/.well-known/jwks.json`,
|
|
684
|
+
issuer,
|
|
685
|
+
...options.audience !== void 0 ? { audience: options.audience } : {},
|
|
686
|
+
...options.claim !== void 0 ? { claim: options.claim } : {},
|
|
687
|
+
...options.fetch !== void 0 ? { fetch: options.fetch } : {}
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
function verifyAuth0Jwt(options) {
|
|
691
|
+
const host = options.domain.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
692
|
+
return verifyJwt({
|
|
693
|
+
jwksUrl: `https://${host}/.well-known/jwks.json`,
|
|
694
|
+
issuer: `https://${host}/`,
|
|
695
|
+
audience: options.audience,
|
|
696
|
+
...options.claim !== void 0 ? { claim: options.claim } : {},
|
|
697
|
+
...options.fetch !== void 0 ? { fetch: options.fetch } : {}
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
var FIREBASE_JWKS_URL = "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com";
|
|
701
|
+
function verifyFirebaseIdToken(options) {
|
|
702
|
+
return verifyJwt({
|
|
703
|
+
jwksUrl: FIREBASE_JWKS_URL,
|
|
704
|
+
issuer: `https://securetoken.google.com/${options.projectId}`,
|
|
705
|
+
audience: options.projectId,
|
|
706
|
+
...options.claim !== void 0 ? { claim: options.claim } : {},
|
|
707
|
+
...options.fetch !== void 0 ? { fetch: options.fetch } : {}
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// src/github-oauth.ts
|
|
712
|
+
var GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
713
|
+
var GITHUB_USER_URL = "https://api.github.com/user";
|
|
714
|
+
var MESSAGE_SOURCE = "scorezilla:github-oauth";
|
|
715
|
+
var USER_AGENT = "scorezilla-sdk-github-oauth";
|
|
716
|
+
var STATE_RE = /^[A-Za-z0-9_-]{8,128}$/;
|
|
717
|
+
var CODE_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
718
|
+
function createGitHubOAuthHandler(config) {
|
|
719
|
+
if (typeof config.clientId !== "string" || config.clientId.length === 0) {
|
|
720
|
+
throw new TypeError("createGitHubOAuthHandler: clientId is required.");
|
|
721
|
+
}
|
|
722
|
+
if (typeof config.clientSecret !== "string" || config.clientSecret.length === 0) {
|
|
723
|
+
throw new TypeError("createGitHubOAuthHandler: clientSecret is required.");
|
|
724
|
+
}
|
|
725
|
+
let allowedOrigin;
|
|
726
|
+
try {
|
|
727
|
+
const parsed = new URL(config.allowedOrigin);
|
|
728
|
+
allowedOrigin = parsed.origin;
|
|
729
|
+
if (allowedOrigin === "null") throw new Error("opaque origin");
|
|
730
|
+
} catch {
|
|
731
|
+
throw new TypeError(
|
|
732
|
+
"createGitHubOAuthHandler: allowedOrigin must be an absolute origin, e.g. 'https://mygame.example' (got " + JSON.stringify(config.allowedOrigin) + ")."
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
const fetchImpl = config.fetch ?? fetch;
|
|
736
|
+
return async (req) => {
|
|
737
|
+
if (req.method !== "GET") {
|
|
738
|
+
return new Response("method not allowed", { status: 405, headers: { allow: "GET" } });
|
|
739
|
+
}
|
|
740
|
+
const url = new URL(req.url);
|
|
741
|
+
const state = url.searchParams.get("state") ?? "";
|
|
742
|
+
const code = url.searchParams.get("code");
|
|
743
|
+
const ghError = url.searchParams.get("error");
|
|
744
|
+
if (!STATE_RE.test(state)) {
|
|
745
|
+
return new Response("invalid or missing state parameter", { status: 400 });
|
|
746
|
+
}
|
|
747
|
+
if (ghError !== null) {
|
|
748
|
+
return callbackPage(
|
|
749
|
+
{ state, error: ghError === "access_denied" ? "access_denied" : "exchange_failed" },
|
|
750
|
+
allowedOrigin
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
if (code === null || !CODE_RE.test(code)) {
|
|
754
|
+
return new Response("invalid or missing code parameter", { status: 400 });
|
|
755
|
+
}
|
|
756
|
+
let accessToken;
|
|
757
|
+
try {
|
|
758
|
+
const tokenRes = await fetchImpl(GITHUB_TOKEN_URL, {
|
|
759
|
+
method: "POST",
|
|
760
|
+
headers: {
|
|
761
|
+
accept: "application/json",
|
|
762
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
763
|
+
"user-agent": USER_AGENT
|
|
764
|
+
},
|
|
765
|
+
body: new URLSearchParams({
|
|
766
|
+
client_id: config.clientId,
|
|
767
|
+
client_secret: config.clientSecret,
|
|
768
|
+
code
|
|
769
|
+
}).toString()
|
|
770
|
+
});
|
|
771
|
+
const tokenBody = await tokenRes.json();
|
|
772
|
+
if (!tokenRes.ok || typeof tokenBody.access_token !== "string") {
|
|
773
|
+
return callbackPage({ state, error: "exchange_failed" }, allowedOrigin);
|
|
774
|
+
}
|
|
775
|
+
accessToken = tokenBody.access_token;
|
|
776
|
+
} catch {
|
|
777
|
+
return callbackPage({ state, error: "exchange_failed" }, allowedOrigin);
|
|
778
|
+
}
|
|
779
|
+
try {
|
|
780
|
+
const userRes = await fetchImpl(GITHUB_USER_URL, {
|
|
781
|
+
method: "GET",
|
|
782
|
+
headers: {
|
|
783
|
+
authorization: `Bearer ${accessToken}`,
|
|
784
|
+
accept: "application/vnd.github+json",
|
|
785
|
+
"user-agent": USER_AGENT
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
if (!userRes.ok) {
|
|
789
|
+
return callbackPage({ state, error: "exchange_failed" }, allowedOrigin);
|
|
790
|
+
}
|
|
791
|
+
const user = await userRes.json();
|
|
792
|
+
if (typeof user.id !== "number" || !Number.isSafeInteger(user.id)) {
|
|
793
|
+
return callbackPage({ state, error: "exchange_failed" }, allowedOrigin);
|
|
794
|
+
}
|
|
795
|
+
return callbackPage({ state, id: String(user.id) }, allowedOrigin);
|
|
796
|
+
} catch {
|
|
797
|
+
return callbackPage({ state, error: "exchange_failed" }, allowedOrigin);
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
function callbackPage(payload, allowedOrigin) {
|
|
802
|
+
const message = JSON.stringify({ source: MESSAGE_SOURCE, ...payload });
|
|
803
|
+
const target = JSON.stringify(allowedOrigin);
|
|
804
|
+
const html = `<!doctype html>
|
|
805
|
+
<html>
|
|
806
|
+
<head><meta charset="utf-8"><title>Signing in\u2026</title></head>
|
|
807
|
+
<body>
|
|
808
|
+
<script>
|
|
809
|
+
(function () {
|
|
810
|
+
if (window.opener) {
|
|
811
|
+
window.opener.postMessage(${message}, ${target});
|
|
812
|
+
window.close();
|
|
813
|
+
} else {
|
|
814
|
+
document.body.textContent =
|
|
815
|
+
'Sign-in handled \u2014 you can close this window. (If this keeps appearing, ' +
|
|
816
|
+
'the game page may be setting Cross-Origin-Opener-Policy: same-origin, ' +
|
|
817
|
+
'which severs the popup link; use same-origin-allow-popups.)';
|
|
818
|
+
}
|
|
819
|
+
})();
|
|
820
|
+
</script>
|
|
821
|
+
</body>
|
|
822
|
+
</html>`;
|
|
823
|
+
return new Response(html, {
|
|
824
|
+
status: 200,
|
|
825
|
+
headers: {
|
|
826
|
+
"content-type": "text/html; charset=utf-8",
|
|
827
|
+
// One-shot page embedding a one-shot state — never cache it.
|
|
828
|
+
"cache-control": "no-store",
|
|
829
|
+
// The URL carried the OAuth `code`; nothing on this page may leak it.
|
|
830
|
+
"referrer-policy": "no-referrer",
|
|
831
|
+
// Defense in depth for the inline script: nothing else may load or
|
|
832
|
+
// run on this page even if a future regression reflected input here.
|
|
833
|
+
"content-security-policy": "default-src 'none'; script-src 'unsafe-inline'",
|
|
834
|
+
"x-content-type-options": "nosniff"
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
|
|
627
839
|
// src/server.ts
|
|
628
840
|
var Scorezilla = class _Scorezilla {
|
|
629
841
|
/** The package version, injected at build time from `package.json`.
|
|
630
842
|
* Mirrors the static on the public-key client so consumers can log
|
|
631
843
|
* the running SDK build the same way regardless of which surface
|
|
632
844
|
* they imported. */
|
|
633
|
-
static version = "0.3.0-next.
|
|
845
|
+
static version = "0.3.0-next.3";
|
|
634
846
|
#keyId;
|
|
635
847
|
#secret;
|
|
636
848
|
#baseUrl;
|
|
@@ -772,8 +984,139 @@ function isRealBrowserEnvironment() {
|
|
|
772
984
|
const hasNodeLikeHost = Boolean(g.process?.versions?.node) || typeof g.Deno !== "undefined" || typeof g.Bun !== "undefined";
|
|
773
985
|
return !hasNodeLikeHost;
|
|
774
986
|
}
|
|
987
|
+
function createScoreSubmitHandler(config) {
|
|
988
|
+
const sz = new Scorezilla({
|
|
989
|
+
secretKey: config.secretKey,
|
|
990
|
+
...config.baseUrl !== void 0 ? { baseUrl: config.baseUrl } : {},
|
|
991
|
+
...config.fetch !== void 0 ? { fetch: config.fetch } : {},
|
|
992
|
+
...config.maxRetries !== void 0 ? { maxRetries: config.maxRetries } : {},
|
|
993
|
+
...config.timeoutMs !== void 0 ? { timeoutMs: config.timeoutMs } : {}
|
|
994
|
+
});
|
|
995
|
+
const parse = config.parseSubmission ?? defaultParseSubmission;
|
|
996
|
+
return async (req) => {
|
|
997
|
+
const cors = config.cors ? buildCorsHeaders(config.cors, req.headers.get("Origin")) : {};
|
|
998
|
+
if (req.method === "OPTIONS" && config.cors) {
|
|
999
|
+
return new Response(null, { status: 204, headers: cors });
|
|
1000
|
+
}
|
|
1001
|
+
if (req.method !== "POST") {
|
|
1002
|
+
return jsonResponse({ ok: false, error: "method_not_allowed" }, 405, cors);
|
|
1003
|
+
}
|
|
1004
|
+
if (config.rateLimit) {
|
|
1005
|
+
const decision = await config.rateLimit(req);
|
|
1006
|
+
if (!decision.ok) {
|
|
1007
|
+
const headers = { ...cors };
|
|
1008
|
+
if (decision.retryAfterSeconds !== void 0) {
|
|
1009
|
+
headers["Retry-After"] = String(decision.retryAfterSeconds);
|
|
1010
|
+
}
|
|
1011
|
+
return jsonResponse({ ok: false, error: "rate_limited" }, 429, headers);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
let identity;
|
|
1015
|
+
try {
|
|
1016
|
+
identity = await config.verify(req);
|
|
1017
|
+
} catch {
|
|
1018
|
+
identity = null;
|
|
1019
|
+
}
|
|
1020
|
+
if (!identity || typeof identity.playerId !== "string" || identity.playerId.length === 0) {
|
|
1021
|
+
return jsonResponse({ ok: false, error: "unauthorized" }, 401, cors);
|
|
1022
|
+
}
|
|
1023
|
+
let submission;
|
|
1024
|
+
try {
|
|
1025
|
+
submission = await parse(req);
|
|
1026
|
+
} catch {
|
|
1027
|
+
submission = null;
|
|
1028
|
+
}
|
|
1029
|
+
if (!submission || typeof submission.score !== "number" || !Number.isFinite(submission.score)) {
|
|
1030
|
+
return jsonResponse(
|
|
1031
|
+
{ ok: false, error: "bad_request", message: "invalid score submission" },
|
|
1032
|
+
400,
|
|
1033
|
+
cors
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
const metadata = mergeMetadata(submission.metadata, identity.metadata);
|
|
1037
|
+
try {
|
|
1038
|
+
const result = await sz.submitScore({
|
|
1039
|
+
boardId: config.boardId,
|
|
1040
|
+
playerId: identity.playerId,
|
|
1041
|
+
score: submission.score,
|
|
1042
|
+
...metadata !== void 0 ? { metadata } : {}
|
|
1043
|
+
});
|
|
1044
|
+
return jsonResponse(
|
|
1045
|
+
{
|
|
1046
|
+
ok: true,
|
|
1047
|
+
rank: result.rank,
|
|
1048
|
+
totalEntries: result.totalEntries,
|
|
1049
|
+
isPersonalBest: result.isPersonalBest
|
|
1050
|
+
},
|
|
1051
|
+
200,
|
|
1052
|
+
cors
|
|
1053
|
+
);
|
|
1054
|
+
} catch (err) {
|
|
1055
|
+
return mapSubmitError(err, cors);
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
async function defaultParseSubmission(req) {
|
|
1060
|
+
const raw = await req.json().catch(() => null);
|
|
1061
|
+
if (!isPlainObject(raw)) return null;
|
|
1062
|
+
const score = raw.score;
|
|
1063
|
+
if (typeof score !== "number" || !Number.isFinite(score)) return null;
|
|
1064
|
+
return { score, metadata: isPlainObject(raw.metadata) ? raw.metadata : void 0 };
|
|
1065
|
+
}
|
|
1066
|
+
function isPlainObject(value) {
|
|
1067
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1068
|
+
}
|
|
1069
|
+
function mergeMetadata(client, verified) {
|
|
1070
|
+
if (!client && !verified) return void 0;
|
|
1071
|
+
return { ...client ?? {}, ...verified ?? {} };
|
|
1072
|
+
}
|
|
1073
|
+
function jsonResponse(body, status, extra) {
|
|
1074
|
+
return new Response(JSON.stringify(body), {
|
|
1075
|
+
status,
|
|
1076
|
+
headers: { "Content-Type": "application/json; charset=utf-8", ...extra }
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
function mapSubmitError(err, cors) {
|
|
1080
|
+
if (err instanceof ScorezillaError) {
|
|
1081
|
+
if (err.isRateLimited()) {
|
|
1082
|
+
const headers = { ...cors };
|
|
1083
|
+
if (err.retryAfter !== void 0) headers["Retry-After"] = String(err.retryAfter);
|
|
1084
|
+
return jsonResponse({ ok: false, error: "rate_limited" }, 429, headers);
|
|
1085
|
+
}
|
|
1086
|
+
if (err.status >= 400 && err.status < 500) {
|
|
1087
|
+
return jsonResponse({ ok: false, error: err.code, message: err.message }, err.status, cors);
|
|
1088
|
+
}
|
|
1089
|
+
return jsonResponse({ ok: false, error: "upstream_error" }, 502, cors);
|
|
1090
|
+
}
|
|
1091
|
+
return jsonResponse({ ok: false, error: "server_error" }, 500, cors);
|
|
1092
|
+
}
|
|
1093
|
+
function buildCorsHeaders(cors, origin) {
|
|
1094
|
+
const headers = {
|
|
1095
|
+
"Access-Control-Allow-Methods": (cors.methods ?? ["POST", "OPTIONS"]).join(", "),
|
|
1096
|
+
"Access-Control-Allow-Headers": (cors.headers ?? ["content-type", "authorization"]).join(", "),
|
|
1097
|
+
"Access-Control-Max-Age": String(cors.maxAgeSeconds ?? 600),
|
|
1098
|
+
Vary: "Origin"
|
|
1099
|
+
};
|
|
1100
|
+
if (origin !== null && isCorsOriginAllowed(cors.origin, origin)) {
|
|
1101
|
+
headers["Access-Control-Allow-Origin"] = origin;
|
|
1102
|
+
}
|
|
1103
|
+
return headers;
|
|
1104
|
+
}
|
|
1105
|
+
function isCorsOriginAllowed(rule, origin) {
|
|
1106
|
+
if (typeof rule === "boolean") return rule;
|
|
1107
|
+
if (typeof rule === "string") return rule === origin;
|
|
1108
|
+
if (typeof rule === "function") return rule(origin);
|
|
1109
|
+
return rule.includes(origin);
|
|
1110
|
+
}
|
|
775
1111
|
|
|
776
1112
|
exports.Scorezilla = Scorezilla;
|
|
777
1113
|
exports.ScorezillaError = ScorezillaError;
|
|
1114
|
+
exports.createGitHubOAuthHandler = createGitHubOAuthHandler;
|
|
1115
|
+
exports.createScoreSubmitHandler = createScoreSubmitHandler;
|
|
1116
|
+
exports.verifyAuth0Jwt = verifyAuth0Jwt;
|
|
1117
|
+
exports.verifyClerkJwt = verifyClerkJwt;
|
|
1118
|
+
exports.verifyFirebaseIdToken = verifyFirebaseIdToken;
|
|
1119
|
+
exports.verifyJwt = verifyJwt;
|
|
1120
|
+
exports.verifySupabaseJwt = verifySupabaseJwt;
|
|
778
1121
|
//# sourceMappingURL=server.cjs.map
|
|
779
1122
|
//# sourceMappingURL=server.cjs.map
|