scorezilla 0.2.0 → 0.3.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/dist/identity.cjs +288 -0
- package/dist/identity.cjs.map +1 -0
- package/dist/identity.d.cts +262 -0
- package/dist/identity.d.ts +262 -0
- package/dist/identity.js +283 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +1 -1
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/package.json +15 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,88 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0-next.1
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#39](https://github.com/isco-tec/scorezilla-js/pull/39) [`608137f`](https://github.com/isco-tec/scorezilla-js/commit/608137f2a880fd3b9031cde8de765a5262d6c334) Thanks [@isco-tec](https://github.com/isco-tec)! - feat(identity): ship the Google provider for `useAuthProvider`
|
|
8
|
+
|
|
9
|
+
`useAuthProvider({ provider: 'google', clientId, storageKey })` is now
|
|
10
|
+
implemented and **stable**. It wraps Google Identity Services ("One Tap"),
|
|
11
|
+
derives a stable, opaque player id from the account's `sub` claim
|
|
12
|
+
(`google:<sub>`), and persists it in `localStorage` so returning visitors are
|
|
13
|
+
recognized without signing in again.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { Scorezilla } from 'scorezilla';
|
|
17
|
+
import { useAuthProvider } from 'scorezilla/identity';
|
|
18
|
+
|
|
19
|
+
const player = await useAuthProvider({
|
|
20
|
+
provider: 'google',
|
|
21
|
+
clientId: 'YOUR_CLIENT_ID.apps.googleusercontent.com',
|
|
22
|
+
storageKey: 'mygame:player',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (player) {
|
|
26
|
+
const sz = new Scorezilla({ publicKey: 'pk_…' });
|
|
27
|
+
await sz.submitScore({ boardId, playerId: player.id, score: 42 });
|
|
28
|
+
// player.signOut() clears the persisted id and disables Google auto-select.
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- **Resolves `null` when the player declines** or One Tap can't be shown — a
|
|
33
|
+
dismissed sign-in is not an error. It **rejects** only on genuine failures
|
|
34
|
+
(invalid args, script load failure, malformed credential).
|
|
35
|
+
- **`handle.source`** is `'signed-in'` for a fresh sign-in or `'restored'` when
|
|
36
|
+
the id was rehydrated from `localStorage` (a restored id is not a re-verified
|
|
37
|
+
live session).
|
|
38
|
+
- **Bring your own client ID.** The SDK never bundles Scorezilla-owned OAuth
|
|
39
|
+
credentials, so revocation and consent stay under your control.
|
|
40
|
+
- **Privacy.** Only the derived `sub`-based id is stored and transmitted on
|
|
41
|
+
score submission — never the Google credential, email, or profile.
|
|
42
|
+
- **Bundle.** The Google provider tree-shakes out for consumers who don't call
|
|
43
|
+
`useAuthProvider`; the Google Identity Services library is loaded at runtime
|
|
44
|
+
from `accounts.google.com`, never bundled.
|
|
45
|
+
- `useAuthProvider` is now async (replacing the `0.3.0-next.0` preview stub that
|
|
46
|
+
threw synchronously). Despite the `use*` name it is **not** a React hook.
|
|
47
|
+
Identity errors are plain `Error`/`TypeError` (not `ScorezillaError`), keeping
|
|
48
|
+
the `scorezilla/identity` subpath dependency-free. The host page's CSP must
|
|
49
|
+
allow `https://accounts.google.com`.
|
|
50
|
+
- The **GitHub** provider is not available yet — it ships in a follow-up and
|
|
51
|
+
will require a server-side token exchange (your backend or a Scorezilla
|
|
52
|
+
Workers proxy). Calling `useAuthProvider({ provider: 'github' })` rejects
|
|
53
|
+
with guidance until then.
|
|
54
|
+
|
|
55
|
+
## 0.3.0-next.0
|
|
56
|
+
|
|
57
|
+
### Minor Changes
|
|
58
|
+
|
|
59
|
+
- [#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)
|
|
60
|
+
|
|
61
|
+
New subpath export: `scorezilla/identity`. Three identity-strategy
|
|
62
|
+
presets ship as `stable`; one OAuth helper ships as a preview stub.
|
|
63
|
+
|
|
64
|
+
**Stable in this release:**
|
|
65
|
+
- `useAnonymousPlayer({ storageKey })` — generates a UUID, persists in
|
|
66
|
+
localStorage, same browser keeps the same id across reloads. Returns
|
|
67
|
+
`{ id, forget() }`. Privacy-safe by default (no PII).
|
|
68
|
+
- `usePromptedPlayer({ storageKey, prompt })` — `window.prompt()` on
|
|
69
|
+
first run, persists to localStorage. Returns `{ id, forget() } | null`
|
|
70
|
+
(null when SSR, no `prompt`, or user cancels).
|
|
71
|
+
- `useServerAuthoritative()` — no-op marker for snippets using the
|
|
72
|
+
HMAC-signed secure path (`scorezilla/server`). The browser SDK does
|
|
73
|
+
no identity work; the server picks the value.
|
|
74
|
+
|
|
75
|
+
**Preview stub in this release (throws on call):**
|
|
76
|
+
- `useAuthProvider({ provider: 'google' | 'github' })` — OAuth-backed
|
|
77
|
+
identity. Full implementation (Google + GitHub for v1) ships in a
|
|
78
|
+
follow-up `next` release before the 0.3.0 latest promote.
|
|
79
|
+
|
|
80
|
+
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
|
|
81
|
+
what `forget()` / `signOut()` does NOT do (server-side history is
|
|
82
|
+
retained — call admin delete-player for full erasure).
|
|
83
|
+
|
|
84
|
+
Closes upstream tracking issue isco-tec/scorezilla#125 (Phase 1).
|
|
85
|
+
|
|
3
86
|
## 0.2.0 — `scorezilla/server` HMAC adapter GA
|
|
4
87
|
|
|
5
88
|
### Minor Changes
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/identity/google.ts
|
|
4
|
+
var GIS_SRC = "https://accounts.google.com/gsi/client";
|
|
5
|
+
var GIS_LOAD_TIMEOUT_MS = 1e4;
|
|
6
|
+
var GOOGLE_CLIENT_ID_SUFFIX = ".apps.googleusercontent.com";
|
|
7
|
+
function isGoogleClientId(clientId) {
|
|
8
|
+
return clientId.endsWith(GOOGLE_CLIENT_ID_SUFFIX);
|
|
9
|
+
}
|
|
10
|
+
function getGoogleIdApi() {
|
|
11
|
+
return globalThis.google?.accounts?.id;
|
|
12
|
+
}
|
|
13
|
+
var gisLoadInFlight = null;
|
|
14
|
+
function loadGoogleIdentityServices() {
|
|
15
|
+
const existing = getGoogleIdApi();
|
|
16
|
+
if (existing) return Promise.resolve(existing);
|
|
17
|
+
if (typeof document === "undefined") {
|
|
18
|
+
return Promise.reject(
|
|
19
|
+
new Error("scorezilla/identity: Google sign-in requires a browser environment.")
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
if (gisLoadInFlight) return gisLoadInFlight;
|
|
23
|
+
gisLoadInFlight = injectGisScript().finally(() => {
|
|
24
|
+
gisLoadInFlight = null;
|
|
25
|
+
});
|
|
26
|
+
return gisLoadInFlight;
|
|
27
|
+
}
|
|
28
|
+
function injectGisScript() {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const existingTag = document.querySelector(`script[src="${GIS_SRC}"]`);
|
|
31
|
+
const script = existingTag ?? document.createElement("script");
|
|
32
|
+
const isOurs = existingTag === null;
|
|
33
|
+
const failWith = (message) => {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
if (isOurs) script.remove();
|
|
36
|
+
reject(new Error(message));
|
|
37
|
+
};
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
failWith("scorezilla/identity: timed out loading Google Identity Services.");
|
|
40
|
+
}, GIS_LOAD_TIMEOUT_MS);
|
|
41
|
+
script.addEventListener(
|
|
42
|
+
"load",
|
|
43
|
+
() => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
const api = getGoogleIdApi();
|
|
46
|
+
if (api) {
|
|
47
|
+
resolve(api);
|
|
48
|
+
} else {
|
|
49
|
+
failWith(
|
|
50
|
+
"scorezilla/identity: Google Identity Services loaded but window.google.accounts.id is unavailable."
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{ once: true }
|
|
55
|
+
);
|
|
56
|
+
script.addEventListener(
|
|
57
|
+
"error",
|
|
58
|
+
() => failWith("scorezilla/identity: failed to load the Google Identity Services script."),
|
|
59
|
+
{ once: true }
|
|
60
|
+
);
|
|
61
|
+
if (isOurs) {
|
|
62
|
+
script.src = GIS_SRC;
|
|
63
|
+
script.async = true;
|
|
64
|
+
script.defer = true;
|
|
65
|
+
document.head.appendChild(script);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function isBlockedMoment(notification) {
|
|
70
|
+
try {
|
|
71
|
+
if (notification.isNotDisplayed?.() === true) return true;
|
|
72
|
+
if (notification.isSkippedMoment?.() === true) return true;
|
|
73
|
+
if (notification.isDismissedMoment?.() === true) {
|
|
74
|
+
return notification.getDismissedReason?.() !== "credential_returned";
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
function decodeSubFromIdToken(idToken) {
|
|
82
|
+
const segments = idToken.split(".");
|
|
83
|
+
const [, payloadSegment] = segments;
|
|
84
|
+
if (segments.length !== 3 || !payloadSegment) {
|
|
85
|
+
throw new Error("scorezilla/identity: malformed Google credential (expected a JWT).");
|
|
86
|
+
}
|
|
87
|
+
let payload;
|
|
88
|
+
try {
|
|
89
|
+
payload = base64UrlToJson(payloadSegment);
|
|
90
|
+
} catch {
|
|
91
|
+
throw new Error("scorezilla/identity: could not decode the Google credential payload.");
|
|
92
|
+
}
|
|
93
|
+
const sub = payload.sub;
|
|
94
|
+
if (typeof sub !== "string" || sub.length === 0) {
|
|
95
|
+
throw new Error('scorezilla/identity: Google credential is missing the "sub" claim.');
|
|
96
|
+
}
|
|
97
|
+
return sub;
|
|
98
|
+
}
|
|
99
|
+
function base64UrlToJson(segment) {
|
|
100
|
+
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
|
|
101
|
+
const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
|
|
102
|
+
const binary = atob(padded);
|
|
103
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
104
|
+
const json = new TextDecoder().decode(bytes);
|
|
105
|
+
return JSON.parse(json);
|
|
106
|
+
}
|
|
107
|
+
function disableGoogleAutoSelect() {
|
|
108
|
+
try {
|
|
109
|
+
getGoogleIdApi()?.disableAutoSelect();
|
|
110
|
+
} catch {
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function signInWithGoogle(params) {
|
|
114
|
+
const api = await loadGoogleIdentityServices();
|
|
115
|
+
const credential = await new Promise((resolve, reject) => {
|
|
116
|
+
let settled = false;
|
|
117
|
+
const finish = (value) => {
|
|
118
|
+
if (settled) return;
|
|
119
|
+
settled = true;
|
|
120
|
+
resolve(value);
|
|
121
|
+
};
|
|
122
|
+
const fail = (message) => {
|
|
123
|
+
if (settled) return;
|
|
124
|
+
settled = true;
|
|
125
|
+
reject(new Error(message));
|
|
126
|
+
};
|
|
127
|
+
api.initialize({
|
|
128
|
+
client_id: params.clientId,
|
|
129
|
+
auto_select: params.autoSelect,
|
|
130
|
+
cancel_on_tap_outside: false,
|
|
131
|
+
callback: (response) => {
|
|
132
|
+
if (response && typeof response.credential === "string" && response.credential.length > 0) {
|
|
133
|
+
finish(response.credential);
|
|
134
|
+
} else {
|
|
135
|
+
fail("scorezilla/identity: Google returned an empty credential.");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
api.prompt((notification) => {
|
|
140
|
+
if (isBlockedMoment(notification)) finish(null);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
return credential === null ? null : decodeSubFromIdToken(credential);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/identity.ts
|
|
147
|
+
var isBrowser = () => typeof window !== "undefined";
|
|
148
|
+
function readPersisted(key) {
|
|
149
|
+
if (!isBrowser()) return null;
|
|
150
|
+
try {
|
|
151
|
+
return window.localStorage.getItem(key);
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function writePersisted(key, value) {
|
|
157
|
+
if (!isBrowser()) return;
|
|
158
|
+
try {
|
|
159
|
+
window.localStorage.setItem(key, value);
|
|
160
|
+
} catch {
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function removePersisted(key) {
|
|
164
|
+
if (!isBrowser()) return;
|
|
165
|
+
try {
|
|
166
|
+
window.localStorage.removeItem(key);
|
|
167
|
+
} catch {
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function mintUuid() {
|
|
171
|
+
if (isBrowser() && typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
172
|
+
return crypto.randomUUID();
|
|
173
|
+
}
|
|
174
|
+
return `anon-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
175
|
+
}
|
|
176
|
+
function requireNonEmptyString(fnName, field, value) {
|
|
177
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
178
|
+
throw new TypeError(`${fnName}: options.${field} is required (non-empty string)`);
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
function requireStorageKey(fnName, options) {
|
|
183
|
+
return requireNonEmptyString(fnName, "storageKey", options?.storageKey);
|
|
184
|
+
}
|
|
185
|
+
function useAnonymousPlayer(options) {
|
|
186
|
+
const storageKey = requireStorageKey("useAnonymousPlayer", options);
|
|
187
|
+
let id = readPersisted(storageKey);
|
|
188
|
+
if (id === null || id.length === 0) {
|
|
189
|
+
id = mintUuid();
|
|
190
|
+
writePersisted(storageKey, id);
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
id,
|
|
194
|
+
forget: () => removePersisted(storageKey)
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function usePromptedPlayer(options) {
|
|
198
|
+
const storageKey = requireStorageKey("usePromptedPlayer", options);
|
|
199
|
+
if (typeof options.prompt !== "string" || options.prompt.length === 0) {
|
|
200
|
+
throw new TypeError("usePromptedPlayer: options.prompt is required (non-empty string)");
|
|
201
|
+
}
|
|
202
|
+
let id = readPersisted(storageKey);
|
|
203
|
+
if (id === null || id.length === 0) {
|
|
204
|
+
if (!isBrowser() || typeof window.prompt !== "function") {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const entered = window.prompt(options.prompt);
|
|
208
|
+
if (entered === null || entered.length === 0) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
id = entered;
|
|
212
|
+
writePersisted(storageKey, id);
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
id,
|
|
216
|
+
forget: () => removePersisted(storageKey)
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function useServerAuthoritative() {
|
|
220
|
+
return { source: "server-authoritative" };
|
|
221
|
+
}
|
|
222
|
+
async function useAuthProvider(options) {
|
|
223
|
+
if (!options || typeof options !== "object") {
|
|
224
|
+
throw new TypeError("useAuthProvider: options is required ({ provider, \u2026 }).");
|
|
225
|
+
}
|
|
226
|
+
switch (options.provider) {
|
|
227
|
+
case "google":
|
|
228
|
+
return signInWithGoogleProvider(options);
|
|
229
|
+
case "github":
|
|
230
|
+
throw new Error(
|
|
231
|
+
'useAuthProvider: the GitHub provider is not available yet. It ships in scorezilla@0.3.0-next.2 and will require a server-side token exchange (your backend or a Scorezilla Workers proxy), because GitHub OAuth cannot be completed securely in the browser alone. Until then use provider: "google", or drive your own GitHub OAuth flow and pass the resulting id to submitScore.'
|
|
232
|
+
);
|
|
233
|
+
default:
|
|
234
|
+
throw new TypeError(
|
|
235
|
+
`useAuthProvider: unknown provider ${JSON.stringify(
|
|
236
|
+
options.provider
|
|
237
|
+
)} (expected "google" or "github").`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
var googleSignInInFlight = /* @__PURE__ */ new Map();
|
|
242
|
+
async function signInWithGoogleProvider(options) {
|
|
243
|
+
const clientId = requireNonEmptyString("useAuthProvider", "clientId", options.clientId);
|
|
244
|
+
if (!isGoogleClientId(clientId)) {
|
|
245
|
+
throw new TypeError(
|
|
246
|
+
'useAuthProvider: options.clientId must be a Google OAuth client ID (ending in ".apps.googleusercontent.com"). Create one at https://console.cloud.google.com/apis/credentials.'
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const storageKey = requireNonEmptyString("useAuthProvider", "storageKey", options.storageKey);
|
|
250
|
+
const persisted = readPersisted(storageKey);
|
|
251
|
+
if (persisted !== null && persisted.length > 0) {
|
|
252
|
+
return makeAuthHandle(persisted, "google", storageKey, "restored");
|
|
253
|
+
}
|
|
254
|
+
if (!isBrowser()) {
|
|
255
|
+
throw new Error("useAuthProvider: Google sign-in requires a browser environment.");
|
|
256
|
+
}
|
|
257
|
+
const existing = googleSignInInFlight.get(storageKey);
|
|
258
|
+
if (existing) return existing;
|
|
259
|
+
const run = (async () => {
|
|
260
|
+
const sub = await signInWithGoogle({ clientId, autoSelect: options.autoSelect ?? false });
|
|
261
|
+
if (sub === null) return null;
|
|
262
|
+
const id = `google:${sub}`;
|
|
263
|
+
writePersisted(storageKey, id);
|
|
264
|
+
return makeAuthHandle(id, "google", storageKey, "signed-in");
|
|
265
|
+
})().finally(() => {
|
|
266
|
+
googleSignInInFlight.delete(storageKey);
|
|
267
|
+
});
|
|
268
|
+
googleSignInInFlight.set(storageKey, run);
|
|
269
|
+
return run;
|
|
270
|
+
}
|
|
271
|
+
function makeAuthHandle(id, provider, storageKey, source) {
|
|
272
|
+
return {
|
|
273
|
+
id,
|
|
274
|
+
provider,
|
|
275
|
+
source,
|
|
276
|
+
signOut: () => {
|
|
277
|
+
removePersisted(storageKey);
|
|
278
|
+
disableGoogleAutoSelect();
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
exports.useAnonymousPlayer = useAnonymousPlayer;
|
|
284
|
+
exports.useAuthProvider = useAuthProvider;
|
|
285
|
+
exports.usePromptedPlayer = usePromptedPlayer;
|
|
286
|
+
exports.useServerAuthoritative = useServerAuthoritative;
|
|
287
|
+
//# sourceMappingURL=identity.cjs.map
|
|
288
|
+
//# sourceMappingURL=identity.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/identity/google.ts","../src/identity.ts"],"names":[],"mappings":";;;AAmCA,IAAM,OAAA,GAAU,wCAAA;AAChB,IAAM,mBAAA,GAAsB,GAAA;AAkC5B,IAAM,uBAAA,GAA0B,6BAAA;AAOzB,SAAS,iBAAiB,QAAA,EAA2B;AAC1D,EAAA,OAAO,QAAA,CAAS,SAAS,uBAAuB,CAAA;AAClD;AAEA,SAAS,cAAA,GAA0C;AACjD,EAAA,OAAQ,UAAA,CAA4B,QAAQ,QAAA,EAAU,EAAA;AACxD;AAOA,IAAI,eAAA,GAA+C,IAAA;AAYnD,SAAS,0BAAA,GAAmD;AAC1D,EAAA,MAAM,WAAW,cAAA,EAAe;AAChC,EAAA,IAAI,QAAA,EAAU,OAAO,OAAA,CAAQ,OAAA,CAAQ,QAAQ,CAAA;AAE7C,EAAA,IAAI,OAAO,aAAa,WAAA,EAAa;AACnC,IAAA,OAAO,OAAA,CAAQ,MAAA;AAAA,MACb,IAAI,MAAM,qEAAqE;AAAA,KACjF;AAAA,EACF;AAEA,EAAA,IAAI,iBAAiB,OAAO,eAAA;AAC5B,EAAA,eAAA,GAAkB,eAAA,EAAgB,CAAE,OAAA,CAAQ,MAAM;AAChD,IAAA,eAAA,GAAkB,IAAA;AAAA,EACpB,CAAC,CAAA;AACD,EAAA,OAAO,eAAA;AACT;AAEA,SAAS,eAAA,GAAwC;AAC/C,EAAA,OAAO,IAAI,OAAA,CAAqB,CAAC,OAAA,EAAS,MAAA,KAAW;AAGnD,IAAA,MAAM,WAAA,GAAc,QAAA,CAAS,aAAA,CAAiC,CAAA,YAAA,EAAe,OAAO,CAAA,EAAA,CAAI,CAAA;AACxF,IAAA,MAAM,MAAA,GAAS,WAAA,IAAe,QAAA,CAAS,aAAA,CAAc,QAAQ,CAAA;AAC7D,IAAA,MAAM,SAAS,WAAA,KAAgB,IAAA;AAE/B,IAAA,MAAM,QAAA,GAAW,CAAC,OAAA,KAA0B;AAC1C,MAAA,YAAA,CAAa,KAAK,CAAA;AAClB,MAAA,IAAI,MAAA,SAAe,MAAA,EAAO;AAC1B,MAAA,MAAA,CAAO,IAAI,KAAA,CAAM,OAAO,CAAC,CAAA;AAAA,IAC3B,CAAA;AAIA,IAAA,MAAM,KAAA,GAAQ,WAAW,MAAM;AAC7B,MAAA,QAAA,CAAS,kEAAkE,CAAA;AAAA,IAC7E,GAAG,mBAAmB,CAAA;AAEtB,IAAA,MAAA,CAAO,gBAAA;AAAA,MACL,MAAA;AAAA,MACA,MAAM;AACJ,QAAA,YAAA,CAAa,KAAK,CAAA;AAClB,QAAA,MAAM,MAAM,cAAA,EAAe;AAC3B,QAAA,IAAI,GAAA,EAAK;AACP,UAAA,OAAA,CAAQ,GAAG,CAAA;AAAA,QACb,CAAA,MAAO;AACL,UAAA,QAAA;AAAA,YACE;AAAA,WAEF;AAAA,QACF;AAAA,MACF,CAAA;AAAA,MACA,EAAE,MAAM,IAAA;AAAK,KACf;AAEA,IAAA,MAAA,CAAO,gBAAA;AAAA,MACL,OAAA;AAAA,MACA,MAAM,SAAS,0EAA0E,CAAA;AAAA,MACzF,EAAE,MAAM,IAAA;AAAK,KACf;AAEA,IAAA,IAAI,MAAA,EAAQ;AACV,MAAA,MAAA,CAAO,GAAA,GAAM,OAAA;AACb,MAAA,MAAA,CAAO,KAAA,GAAQ,IAAA;AACf,MAAA,MAAA,CAAO,KAAA,GAAQ,IAAA;AACf,MAAA,QAAA,CAAS,IAAA,CAAK,YAAY,MAAM,CAAA;AAAA,IAClC;AAAA,EACF,CAAC,CAAA;AACH;AAGA,SAAS,gBAAgB,YAAA,EAAiD;AACxE,EAAA,IAAI;AACF,IAAA,IAAI,YAAA,CAAa,cAAA,IAAiB,KAAM,IAAA,EAAM,OAAO,IAAA;AACrD,IAAA,IAAI,YAAA,CAAa,eAAA,IAAkB,KAAM,IAAA,EAAM,OAAO,IAAA;AACtD,IAAA,IAAI,YAAA,CAAa,iBAAA,IAAoB,KAAM,IAAA,EAAM;AAI/C,MAAA,OAAO,YAAA,CAAa,sBAAqB,KAAM,qBAAA;AAAA,IACjD;AAAA,EACF,CAAA,CAAA,MAAQ;AAKN,IAAA,OAAO,IAAA;AAAA,EACT;AACA,EAAA,OAAO,KAAA;AACT;AAMA,SAAS,qBAAqB,OAAA,EAAyB;AACrD,EAAA,MAAM,QAAA,GAAW,OAAA,CAAQ,KAAA,CAAM,GAAG,CAAA;AAClC,EAAA,MAAM,GAAG,cAAc,CAAA,GAAI,QAAA;AAC3B,EAAA,IAAI,QAAA,CAAS,MAAA,KAAW,CAAA,IAAK,CAAC,cAAA,EAAgB;AAC5C,IAAA,MAAM,IAAI,MAAM,oEAAoE,CAAA;AAAA,EACtF;AAEA,EAAA,IAAI,OAAA;AACJ,EAAA,IAAI;AACF,IAAA,OAAA,GAAU,gBAAgB,cAAc,CAAA;AAAA,EAC1C,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,MAAM,sEAAsE,CAAA;AAAA,EACxF;AASA,EAAA,MAAM,MAAO,OAAA,CAA8B,GAAA;AAC3C,EAAA,IAAI,OAAO,GAAA,KAAQ,QAAA,IAAY,GAAA,CAAI,WAAW,CAAA,EAAG;AAC/C,IAAA,MAAM,IAAI,MAAM,oEAAoE,CAAA;AAAA,EACtF;AACA,EAAA,OAAO,GAAA;AACT;AAEA,SAAS,gBAAgB,OAAA,EAA0B;AACjD,EAAA,MAAM,MAAA,GAAS,QAAQ,OAAA,CAAQ,IAAA,EAAM,GAAG,CAAA,CAAE,OAAA,CAAQ,MAAM,GAAG,CAAA;AAC3D,EAAA,MAAM,MAAA,GAAS,SAAS,GAAA,CAAI,MAAA,CAAA,CAAQ,IAAK,MAAA,CAAO,MAAA,GAAS,KAAM,CAAC,CAAA;AAChE,EAAA,MAAM,MAAA,GAAS,KAAK,MAAM,CAAA;AAC1B,EAAA,MAAM,KAAA,GAAQ,WAAW,IAAA,CAAK,MAAA,EAAQ,CAAC,IAAA,KAAS,IAAA,CAAK,UAAA,CAAW,CAAC,CAAC,CAAA;AAClE,EAAA,MAAM,IAAA,GAAO,IAAI,WAAA,EAAY,CAAE,OAAO,KAAK,CAAA;AAC3C,EAAA,OAAO,IAAA,CAAK,MAAM,IAAI,CAAA;AACxB;AAOO,SAAS,uBAAA,GAAgC;AAC9C,EAAA,IAAI;AACF,IAAA,cAAA,IAAkB,iBAAA,EAAkB;AAAA,EACtC,CAAA,CAAA,MAAQ;AAAA,EAER;AACF;AAYA,eAAsB,iBAAiB,MAAA,EAAoD;AACzF,EAAA,MAAM,GAAA,GAAM,MAAM,0BAAA,EAA2B;AAE7C,EAAA,MAAM,aAAa,MAAM,IAAI,OAAA,CAAuB,CAAC,SAAS,MAAA,KAAW;AAKvE,IAAA,IAAI,OAAA,GAAU,KAAA;AACd,IAAA,MAAM,MAAA,GAAS,CAAC,KAAA,KAA+B;AAC7C,MAAA,IAAI,OAAA,EAAS;AACb,MAAA,OAAA,GAAU,IAAA;AACV,MAAA,OAAA,CAAQ,KAAK,CAAA;AAAA,IACf,CAAA;AACA,IAAA,MAAM,IAAA,GAAO,CAAC,OAAA,KAA0B;AACtC,MAAA,IAAI,OAAA,EAAS;AACb,MAAA,OAAA,GAAU,IAAA;AACV,MAAA,MAAA,CAAO,IAAI,KAAA,CAAM,OAAO,CAAC,CAAA;AAAA,IAC3B,CAAA;AAEA,IAAA,GAAA,CAAI,UAAA,CAAW;AAAA,MACb,WAAW,MAAA,CAAO,QAAA;AAAA,MAClB,aAAa,MAAA,CAAO,UAAA;AAAA,MACpB,qBAAA,EAAuB,KAAA;AAAA,MACvB,QAAA,EAAU,CAAC,QAAA,KAAa;AACtB,QAAA,IAAI,QAAA,IAAY,OAAO,QAAA,CAAS,UAAA,KAAe,YAAY,QAAA,CAAS,UAAA,CAAW,SAAS,CAAA,EAAG;AACzF,UAAA,MAAA,CAAO,SAAS,UAAU,CAAA;AAAA,QAC5B,CAAA,MAAO;AACL,UAAA,IAAA,CAAK,2DAA2D,CAAA;AAAA,QAClE;AAAA,MACF;AAAA,KACD,CAAA;AAED,IAAA,GAAA,CAAI,MAAA,CAAO,CAAC,YAAA,KAAiB;AAE3B,MAAA,IAAI,eAAA,CAAgB,YAAY,CAAA,EAAG,MAAA,CAAO,IAAI,CAAA;AAAA,IAChD,CAAC,CAAA;AAAA,EACH,CAAC,CAAA;AAED,EAAA,OAAO,UAAA,KAAe,IAAA,GAAO,IAAA,GAAO,oBAAA,CAAqB,UAAU,CAAA;AACrE;;;ACjLA,IAAM,SAAA,GAAY,MAAe,OAAO,MAAA,KAAW,WAAA;AAEnD,SAAS,cAAc,GAAA,EAA4B;AACjD,EAAA,IAAI,CAAC,SAAA,EAAU,EAAG,OAAO,IAAA;AACzB,EAAA,IAAI;AACF,IAAA,OAAO,MAAA,CAAO,YAAA,CAAa,OAAA,CAAQ,GAAG,CAAA;AAAA,EACxC,CAAA,CAAA,MAAQ;AAIN,IAAA,OAAO,IAAA;AAAA,EACT;AACF;AAEA,SAAS,cAAA,CAAe,KAAa,KAAA,EAAqB;AACxD,EAAA,IAAI,CAAC,WAAU,EAAG;AAClB,EAAA,IAAI;AACF,IAAA,MAAA,CAAO,YAAA,CAAa,OAAA,CAAQ,GAAA,EAAK,KAAK,CAAA;AAAA,EACxC,CAAA,CAAA,MAAQ;AAAA,EAER;AACF;AAEA,SAAS,gBAAgB,GAAA,EAAmB;AAC1C,EAAA,IAAI,CAAC,WAAU,EAAG;AAClB,EAAA,IAAI;AACF,IAAA,MAAA,CAAO,YAAA,CAAa,WAAW,GAAG,CAAA;AAAA,EACpC,CAAA,CAAA,MAAQ;AAAA,EAER;AACF;AAEA,SAAS,QAAA,GAAmB;AAC1B,EAAA,IAAI,SAAA,MAAe,OAAO,MAAA,KAAW,eAAe,OAAO,MAAA,CAAO,eAAe,UAAA,EAAY;AAC3F,IAAA,OAAO,OAAO,UAAA,EAAW;AAAA,EAC3B;AAMA,EAAA,OAAO,CAAA,KAAA,EAAQ,IAAA,CAAK,GAAA,EAAK,IAAI,IAAA,CAAK,MAAA,EAAO,CAAE,QAAA,CAAS,EAAE,CAAA,CAAE,KAAA,CAAM,CAAA,EAAG,EAAE,CAAC,CAAA,CAAA;AACtE;AAEA,SAAS,qBAAA,CAAsB,MAAA,EAAgB,KAAA,EAAe,KAAA,EAAwB;AACpF,EAAA,IAAI,OAAO,KAAA,KAAU,QAAA,IAAY,KAAA,CAAM,WAAW,CAAA,EAAG;AACnD,IAAA,MAAM,IAAI,SAAA,CAAU,CAAA,EAAG,MAAM,CAAA,UAAA,EAAa,KAAK,CAAA,+BAAA,CAAiC,CAAA;AAAA,EAClF;AACA,EAAA,OAAO,KAAA;AACT;AAEA,SAAS,iBAAA,CAAkB,QAAgB,OAAA,EAAuD;AAChG,EAAA,OAAO,qBAAA,CAAsB,MAAA,EAAQ,YAAA,EAAc,OAAA,EAAS,UAAU,CAAA;AACxE;AA0BO,SAAS,mBAAmB,OAAA,EAA+C;AAChF,EAAA,MAAM,UAAA,GAAa,iBAAA,CAAkB,oBAAA,EAAsB,OAAO,CAAA;AAClE,EAAA,IAAI,EAAA,GAAK,cAAc,UAAU,CAAA;AACjC,EAAA,IAAI,EAAA,KAAO,IAAA,IAAQ,EAAA,CAAG,MAAA,KAAW,CAAA,EAAG;AAClC,IAAA,EAAA,GAAK,QAAA,EAAS;AACd,IAAA,cAAA,CAAe,YAAY,EAAE,CAAA;AAAA,EAC/B;AACA,EAAA,OAAO;AAAA,IACL,EAAA;AAAA,IACA,MAAA,EAAQ,MAAM,eAAA,CAAgB,UAAU;AAAA,GAC1C;AACF;AAsCO,SAAS,kBAAkB,OAAA,EAAqD;AACrF,EAAA,MAAM,UAAA,GAAa,iBAAA,CAAkB,mBAAA,EAAqB,OAAO,CAAA;AACjE,EAAA,IAAI,OAAO,OAAA,CAAQ,MAAA,KAAW,YAAY,OAAA,CAAQ,MAAA,CAAO,WAAW,CAAA,EAAG;AACrE,IAAA,MAAM,IAAI,UAAU,kEAAkE,CAAA;AAAA,EACxF;AAEA,EAAA,IAAI,EAAA,GAAK,cAAc,UAAU,CAAA;AACjC,EAAA,IAAI,EAAA,KAAO,IAAA,IAAQ,EAAA,CAAG,MAAA,KAAW,CAAA,EAAG;AAClC,IAAA,IAAI,CAAC,SAAA,EAAU,IAAK,OAAO,MAAA,CAAO,WAAW,UAAA,EAAY;AACvD,MAAA,OAAO,IAAA;AAAA,IACT;AACA,IAAA,MAAM,OAAA,GAAU,MAAA,CAAO,MAAA,CAAO,OAAA,CAAQ,MAAM,CAAA;AAC5C,IAAA,IAAI,OAAA,KAAY,IAAA,IAAQ,OAAA,CAAQ,MAAA,KAAW,CAAA,EAAG;AAC5C,MAAA,OAAO,IAAA;AAAA,IACT;AACA,IAAA,EAAA,GAAK,OAAA;AACL,IAAA,cAAA,CAAe,YAAY,EAAE,CAAA;AAAA,EAC/B;AACA,EAAA,OAAO;AAAA,IACL,EAAA;AAAA,IACA,MAAA,EAAQ,MAAM,eAAA,CAAgB,UAAU;AAAA,GAC1C;AACF;AA4BO,SAAS,sBAAA,GAAoD;AAClE,EAAA,OAAO,EAAE,QAAQ,sBAAA,EAAuB;AAC1C;AAkEA,eAAsB,gBACpB,OAAA,EACkC;AAClC,EAAA,IAAI,CAAC,OAAA,IAAW,OAAO,OAAA,KAAY,QAAA,EAAU;AAC3C,IAAA,MAAM,IAAI,UAAU,8DAAyD,CAAA;AAAA,EAC/E;AAEA,EAAA,QAAQ,QAAQ,QAAA;AAAU,IACxB,KAAK,QAAA;AACH,MAAA,OAAO,yBAAyB,OAAO,CAAA;AAAA,IACzC,KAAK,QAAA;AACH,MAAA,MAAM,IAAI,KAAA;AAAA,QACR;AAAA,OAKF;AAAA,IACF;AACE,MAAA,MAAM,IAAI,SAAA;AAAA,QACR,qCAAqC,IAAA,CAAK,SAAA;AAAA,UACvC,OAAA,CAAmC;AAAA,SACrC,CAAA,iCAAA;AAAA,OACH;AAAA;AAEN;AAOA,IAAM,oBAAA,uBAA2B,GAAA,EAA8C;AAE/E,eAAe,yBACb,OAAA,EACkC;AAClC,EAAA,MAAM,QAAA,GAAW,qBAAA,CAAsB,iBAAA,EAAmB,UAAA,EAAY,QAAQ,QAAQ,CAAA;AACtF,EAAA,IAAI,CAAC,gBAAA,CAAiB,QAAQ,CAAA,EAAG;AAC/B,IAAA,MAAM,IAAI,SAAA;AAAA,MACR;AAAA,KAGF;AAAA,EACF;AACA,EAAA,MAAM,UAAA,GAAa,qBAAA,CAAsB,iBAAA,EAAmB,YAAA,EAAc,QAAQ,UAAU,CAAA;AAQ5F,EAAA,MAAM,SAAA,GAAY,cAAc,UAAU,CAAA;AAC1C,EAAA,IAAI,SAAA,KAAc,IAAA,IAAQ,SAAA,CAAU,MAAA,GAAS,CAAA,EAAG;AAC9C,IAAA,OAAO,cAAA,CAAe,SAAA,EAAW,QAAA,EAAU,UAAA,EAAY,UAAU,CAAA;AAAA,EACnE;AAEA,EAAA,IAAI,CAAC,WAAU,EAAG;AAChB,IAAA,MAAM,IAAI,MAAM,iEAAiE,CAAA;AAAA,EACnF;AAEA,EAAA,MAAM,QAAA,GAAW,oBAAA,CAAqB,GAAA,CAAI,UAAU,CAAA;AACpD,EAAA,IAAI,UAAU,OAAO,QAAA;AAErB,EAAA,MAAM,OAAO,YAA8C;AACzD,IAAA,MAAM,GAAA,GAAM,MAAM,gBAAA,CAAiB,EAAE,UAAU,UAAA,EAAY,OAAA,CAAQ,UAAA,IAAc,KAAA,EAAO,CAAA;AACxF,IAAA,IAAI,GAAA,KAAQ,MAAM,OAAO,IAAA;AACzB,IAAA,MAAM,EAAA,GAAK,UAAU,GAAG,CAAA,CAAA;AACxB,IAAA,cAAA,CAAe,YAAY,EAAE,CAAA;AAC7B,IAAA,OAAO,cAAA,CAAe,EAAA,EAAI,QAAA,EAAU,UAAA,EAAY,WAAW,CAAA;AAAA,EAC7D,CAAA,GAAG,CAAE,OAAA,CAAQ,MAAM;AACjB,IAAA,oBAAA,CAAqB,OAAO,UAAU,CAAA;AAAA,EACxC,CAAC,CAAA;AAED,EAAA,oBAAA,CAAqB,GAAA,CAAI,YAAY,GAAG,CAAA;AACxC,EAAA,OAAO,GAAA;AACT;AAEA,SAAS,cAAA,CACP,EAAA,EACA,QAAA,EACA,UAAA,EACA,MAAA,EACkB;AAClB,EAAA,OAAO;AAAA,IACL,EAAA;AAAA,IACA,QAAA;AAAA,IACA,MAAA;AAAA,IACA,SAAS,MAAM;AACb,MAAA,eAAA,CAAgB,UAAU,CAAA;AAC1B,MAA2B,uBAAA,EAAwB;AAAA,IACrD;AAAA,GACF;AACF","file":"identity.cjs","sourcesContent":["/**\n * Google provider for `useAuthProvider` — wraps Google Identity Services\n * (GIS) \"One Tap\" to produce a stable, opaque player id from the signed-in\n * Google account's `sub` claim.\n *\n * This module lives behind `useAuthProvider` in `../identity`. Consumers who\n * only use the non-OAuth presets (`useAnonymousPlayer`, etc.) tree-shake it\n * out entirely — the package sets `sideEffects: false` and this module has no\n * top-level side effects, so a bundler drops it when `useAuthProvider` is\n * unused. A size-limit gate (`.size-limit.cjs`) keeps that boundary honest.\n *\n * **What's bundled vs. fetched.** The heavyweight GIS library itself is NOT\n * bundled; it's loaded at runtime from `accounts.google.com` via an injected\n * `<script>` the first time sign-in runs. This module is just the thin\n * loader + One Tap orchestration + a tiny JWT-payload decoder.\n *\n * **Identity, not authorization.** The derived id is used purely as the\n * opaque `playerId` for leaderboard attribution. Score submission is still\n * authorized by the public key or the HMAC secure path — this module never\n * sends the Google credential to the Scorezilla API. We therefore decode the\n * ID token's payload client-side (no signature verification) solely to read\n * `sub`; the token arrives directly from Google's library over TLS.\n *\n * **One Tap is an implementation detail.** v1 uses GIS \"One Tap\". Under\n * browser FedCM / third-party-cookie changes, One Tap availability and its\n * prompt-moment semantics can vary, so `signInWithGoogle` resolves `null`\n * (rather than throwing) whenever no credential is obtained — callers fall\n * back gracefully. The public contract (`sub` string or `null`) is\n * independent of the flow, leaving room to add a rendered-button fallback\n * later without an API change.\n *\n * @module scorezilla/identity/google\n * @since 0.3.0\n */\n\nconst GIS_SRC = 'https://accounts.google.com/gsi/client';\nconst GIS_LOAD_TIMEOUT_MS = 10_000;\n\ninterface GooglePromptNotification {\n readonly isNotDisplayed?: () => boolean;\n readonly isSkippedMoment?: () => boolean;\n readonly isDismissedMoment?: () => boolean;\n readonly getDismissedReason?: () => string;\n}\n\ninterface GoogleCredentialResponse {\n readonly credential: string;\n}\n\ninterface GoogleIdApi {\n initialize(config: {\n client_id: string;\n callback: (response: GoogleCredentialResponse) => void;\n auto_select?: boolean;\n cancel_on_tap_outside?: boolean;\n }): void;\n prompt(listener?: (notification: GooglePromptNotification) => void): void;\n disableAutoSelect(): void;\n}\n\ninterface GoogleGlobal {\n readonly google?: { readonly accounts?: { readonly id?: GoogleIdApi } };\n}\n\nexport interface GoogleSignInParams {\n readonly clientId: string;\n readonly autoSelect: boolean;\n}\n\n/** Google OAuth Web client IDs always end with this suffix. */\nconst GOOGLE_CLIENT_ID_SUFFIX = '.apps.googleusercontent.com';\n\n/**\n * True if `clientId` has the shape of a Google OAuth Web client ID. Used to\n * turn a typo'd id into an actionable error up front, rather than a silent\n * \"One Tap couldn't be shown\" → `null` further down.\n */\nexport function isGoogleClientId(clientId: string): boolean {\n return clientId.endsWith(GOOGLE_CLIENT_ID_SUFFIX);\n}\n\nfunction getGoogleIdApi(): GoogleIdApi | undefined {\n return (globalThis as GoogleGlobal).google?.accounts?.id;\n}\n\n// Dedupes concurrent / retry-after-failure calls so the GIS <script> is never\n// injected twice. Cleared once the load settles (success or failure), so a\n// later sign-in starts clean. This relies on GIS being a page-level global:\n// once loaded, `getGoogleIdApi()` sees it from any subsequent call, which is\n// what makes clearing-on-settle safe (the fast-path covers \"already loaded\").\nlet gisLoadInFlight: Promise<GoogleIdApi> | null = null;\n\n/**\n * Resolve the GIS `id` API, injecting the loader `<script>` on first use.\n * Resolves immediately if GIS is already present (returning visit within the\n * same page, or a host that preloads the script).\n *\n * **Host CSP.** The page must allow the GIS origin in its Content-Security-\n * Policy — at minimum `script-src https://accounts.google.com` (One Tap also\n * needs `frame-src`/`connect-src` for `https://accounts.google.com`). Without\n * it the browser blocks the script and sign-in rejects with the load error.\n */\nfunction loadGoogleIdentityServices(): Promise<GoogleIdApi> {\n const existing = getGoogleIdApi();\n if (existing) return Promise.resolve(existing);\n\n if (typeof document === 'undefined') {\n return Promise.reject(\n new Error('scorezilla/identity: Google sign-in requires a browser environment.'),\n );\n }\n\n if (gisLoadInFlight) return gisLoadInFlight;\n gisLoadInFlight = injectGisScript().finally(() => {\n gisLoadInFlight = null;\n });\n return gisLoadInFlight;\n}\n\nfunction injectGisScript(): Promise<GoogleIdApi> {\n return new Promise<GoogleIdApi>((resolve, reject) => {\n // Reuse a tag the host page may already have added; only create (and later\n // clean up) one of our own when none exists.\n const existingTag = document.querySelector<HTMLScriptElement>(`script[src=\"${GIS_SRC}\"]`);\n const script = existingTag ?? document.createElement('script');\n const isOurs = existingTag === null;\n\n const failWith = (message: string): void => {\n clearTimeout(timer);\n if (isOurs) script.remove();\n reject(new Error(message));\n };\n\n // `failWith` closes over `timer` but only runs in async callbacks, after\n // this `const` is initialized — so the forward reference is safe.\n const timer = setTimeout(() => {\n failWith('scorezilla/identity: timed out loading Google Identity Services.');\n }, GIS_LOAD_TIMEOUT_MS);\n\n script.addEventListener(\n 'load',\n () => {\n clearTimeout(timer);\n const api = getGoogleIdApi();\n if (api) {\n resolve(api);\n } else {\n failWith(\n 'scorezilla/identity: Google Identity Services loaded but ' +\n 'window.google.accounts.id is unavailable.',\n );\n }\n },\n { once: true },\n );\n\n script.addEventListener(\n 'error',\n () => failWith('scorezilla/identity: failed to load the Google Identity Services script.'),\n { once: true },\n );\n\n if (isOurs) {\n script.src = GIS_SRC;\n script.async = true;\n script.defer = true;\n document.head.appendChild(script);\n }\n });\n}\n\n/** True when a One Tap moment means \"no credential is coming\". */\nfunction isBlockedMoment(notification: GooglePromptNotification): boolean {\n try {\n if (notification.isNotDisplayed?.() === true) return true;\n if (notification.isSkippedMoment?.() === true) return true;\n if (notification.isDismissedMoment?.() === true) {\n // A dismissal with reason `credential_returned` is the SUCCESS path — the\n // credential callback already fired (or is about to). Don't treat it as a\n // \"no credential\" moment.\n return notification.getDismissedReason?.() !== 'credential_returned';\n }\n } catch {\n // Under FedCM these legacy moment-status methods are deprecated and can\n // throw. Treat that as \"no credential from this moment\" — the credential\n // callback still fires on success, so this only affects the no-sign-in\n // path, which resolves to a clean `null`.\n return true;\n }\n return false;\n}\n\n/**\n * Decode a Google ID token's payload (no signature verification) and return\n * its `sub` claim — the stable, unique-per-account Google subject identifier.\n */\nfunction decodeSubFromIdToken(idToken: string): string {\n const segments = idToken.split('.');\n const [, payloadSegment] = segments;\n if (segments.length !== 3 || !payloadSegment) {\n throw new Error('scorezilla/identity: malformed Google credential (expected a JWT).');\n }\n\n let payload: unknown;\n try {\n payload = base64UrlToJson(payloadSegment);\n } catch {\n throw new Error('scorezilla/identity: could not decode the Google credential payload.');\n }\n\n // Read ONLY `sub`, behind a typeof guard. NEVER read any other claim here\n // (email, email_verified, aud, …) and NEVER use this value for an\n // authorization decision: the payload is unverified (no signature check), so\n // any other claim would be attacker-forgeable. `sub` is safe as an opaque\n // attribution id only. Reading just `sub` also sidesteps prototype-pollution\n // — `JSON.parse` doesn't mutate prototypes and nothing here propagates other\n // keys.\n const sub = (payload as { sub?: unknown }).sub;\n if (typeof sub !== 'string' || sub.length === 0) {\n throw new Error('scorezilla/identity: Google credential is missing the \"sub\" claim.');\n }\n return sub;\n}\n\nfunction base64UrlToJson(segment: string): unknown {\n const base64 = segment.replace(/-/g, '+').replace(/_/g, '/');\n const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);\n const binary = atob(padded);\n const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));\n const json = new TextDecoder().decode(bytes);\n return JSON.parse(json);\n}\n\n/**\n * Best-effort: stop GIS auto-selecting this account on the next visit (the\n * counterpart to `auto_select`). No-op if GIS was never loaded — e.g. a return\n * visit that short-circuited on the persisted id and never injected the script.\n */\nexport function disableGoogleAutoSelect(): void {\n try {\n getGoogleIdApi()?.disableAutoSelect();\n } catch {\n // GIS not present / not loaded — nothing to disable.\n }\n}\n\n/**\n * Run the Google One Tap flow.\n *\n * - Resolves the account's `sub` claim on a successful sign-in.\n * - Resolves `null` when no credential is obtained — the user dismissed or\n * declined One Tap, or it couldn't be displayed (no Google session,\n * cookies/FedCM blocked, cooldown). \"Didn't sign in\" is not an error.\n * - **Throws** only on hard failures: the GIS script failing to load/timing\n * out, or a malformed/empty credential.\n */\nexport async function signInWithGoogle(params: GoogleSignInParams): Promise<string | null> {\n const api = await loadGoogleIdentityServices();\n\n const credential = await new Promise<string | null>((resolve, reject) => {\n // The credential callback and the prompt moment-listener settle the same\n // promise independently, and GIS does not guarantee their ordering. Guard\n // against either firing after the other so a late \"no credential\" moment\n // can't override an already-successful sign-in (and vice versa).\n let settled = false;\n const finish = (value: string | null): void => {\n if (settled) return;\n settled = true;\n resolve(value);\n };\n const fail = (message: string): void => {\n if (settled) return;\n settled = true;\n reject(new Error(message));\n };\n\n api.initialize({\n client_id: params.clientId,\n auto_select: params.autoSelect,\n cancel_on_tap_outside: false,\n callback: (response) => {\n if (response && typeof response.credential === 'string' && response.credential.length > 0) {\n finish(response.credential);\n } else {\n fail('scorezilla/identity: Google returned an empty credential.');\n }\n },\n });\n\n api.prompt((notification) => {\n // A blocking moment means no credential is coming for this attempt.\n if (isBlockedMoment(notification)) finish(null);\n });\n });\n\n return credential === null ? null : decodeSubFromIdToken(credential);\n}\n","/**\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\nimport { disableGoogleAutoSelect, isGoogleClientId, signInWithGoogle } from './identity/google';\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\n/** OAuth providers selectable via {@link useAuthProvider}. */\nexport type AuthProvider = 'google' | 'github';\n\n/** Options for the Google provider (`provider: 'google'`). */\nexport interface GoogleAuthProviderOptions {\n readonly provider: 'google';\n /**\n * Your Google OAuth **client ID** (from the Google Cloud Console). The\n * helper never bundles Scorezilla-owned credentials — you bring your own so\n * revocation and consent stay under your control.\n */\n readonly clientId: string;\n /** localStorage key under which the derived player id is persisted. */\n readonly storageKey: string;\n /**\n * Let Google auto-select a returning account without an explicit tap\n * (GIS `auto_select`). Defaults to `false`.\n */\n readonly autoSelect?: boolean;\n}\n\n/**\n * Options for the GitHub provider (`provider: 'github'`).\n *\n * **Not available yet** — ships in `scorezilla@0.3.0-next.2`. GitHub OAuth\n * cannot be completed securely in the browser alone (the token exchange needs\n * a client secret and GitHub's token endpoint sends no CORS headers), so the\n * GitHub provider will require a server-side token exchange (your backend or\n * a Scorezilla Workers proxy). Calling it today rejects.\n *\n * @experimental The option fields below are provisional and will be finalized\n * (e.g. `clientId` becoming required, plus a token-exchange-endpoint field)\n * when the provider lands in `0.3.0-next.2` — before the `0.3.0` stable cut.\n */\nexport interface GitHubAuthProviderOptions {\n readonly provider: 'github';\n /** Reserved (provisional) — your GitHub OAuth app client ID. */\n readonly clientId?: string;\n /** Reserved (provisional) — localStorage key for the derived player id. */\n readonly storageKey?: string;\n}\n\n/** Discriminated union of {@link useAuthProvider} options, keyed on `provider`. */\nexport type AuthProviderOptions = GoogleAuthProviderOptions | GitHubAuthProviderOptions;\n\n/** How an {@link AuthPlayerHandle}'s `id` was obtained on this call. */\nexport type AuthIdSource = 'signed-in' | 'restored';\n\n/**\n * Handle returned by {@link useAuthProvider}. `id` is the opaque, stable\n * player id derived from the provider account (e.g. `google:<sub>`).\n * `signOut()` clears the persisted id and, where supported, disables the\n * provider's auto sign-in. It does **not** delete server-side score history.\n */\nexport interface AuthPlayerHandle {\n readonly id: string;\n readonly provider: AuthProvider;\n /**\n * `'signed-in'` when the id came from a fresh provider sign-in during this\n * call; `'restored'` when it was rehydrated from a prior session in\n * `localStorage` with no provider interaction. A `'restored'` id is **not**\n * a re-verified live session — see {@link useAuthProvider}.\n */\n readonly source: AuthIdSource;\n readonly signOut: () => void;\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 requireNonEmptyString(fnName: string, field: string, value: unknown): string {\n if (typeof value !== 'string' || value.length === 0) {\n throw new TypeError(`${fnName}: options.${field} is required (non-empty string)`);\n }\n return value;\n}\n\nfunction requireStorageKey(fnName: string, options: { storageKey?: unknown } | undefined): string {\n return requireNonEmptyString(fnName, 'storageKey', 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. Signs the player in with the chosen provider\n * and resolves a stable, opaque `playerId` derived from their account.\n *\n * Resolves to:\n * - an {@link AuthPlayerHandle} on success — `handle.source` distinguishes a\n * fresh sign-in (`'signed-in'`) from a `localStorage`-restored prior session\n * (`'restored'`); or\n * - `null` when the player **declines / dismisses** sign-in, or it can't be\n * shown (no provider session, blocked cookies). \"Didn't sign in\" is not an\n * error — fall back to another identity strategy.\n *\n * **Rejects** only on genuine failures: invalid arguments (`TypeError`), an\n * unavailable provider, or the provider flow breaking (script load failure,\n * malformed credential). Identity helpers throw plain `Error`/`TypeError` by\n * design — NOT `ScorezillaError` — so the `scorezilla/identity` subpath stays\n * dependency-free; don't `instanceof ScorezillaError` these.\n *\n * > Despite the `use*` name (shared with the other presets), this is a plain\n * > async function, **not a React hook** — rules-of-hooks don't apply. The\n * > `scorezilla/react` adapter exposes the React-bound surface separately.\n *\n * **Google** (stable since `0.3.0`) wraps Google Identity Services \"One Tap\".\n * The derived id is `google:<sub>`. It's persisted in `localStorage` under\n * `storageKey`, so returning visitors are recognized without signing in again\n * (`handle.source === 'restored'`); `signOut()` clears it. The host page's CSP\n * must allow `https://accounts.google.com` (`script-src`, plus `frame-src` /\n * `connect-src` for One Tap).\n *\n * **GitHub** ships in `0.3.0-next.2` and currently rejects — see\n * {@link GitHubAuthProviderOptions}.\n *\n * **Privacy.** Only the derived `sub`-based id is stored and transmitted (on\n * score submission) — never the Google credential, email, or profile. The id\n * is persisted in browser localStorage and stored indefinitely in the\n * player's score-history rows. `signOut()` clears local state; for full\n * server-side erasure call the admin \"delete player\" endpoint.\n *\n * **Bundle.** The Google provider is a separate module that tree-shakes out\n * entirely for consumers who don't call `useAuthProvider` (the package is\n * `sideEffects: false`; a size-limit gate verifies it). The Google Identity\n * Services library itself is never bundled — it's loaded at runtime from\n * `accounts.google.com` the first time sign-in runs.\n *\n * @example\n * ```ts\n * import { Scorezilla } from 'scorezilla';\n * import { useAuthProvider } from 'scorezilla/identity';\n *\n * const player = await useAuthProvider({\n * provider: 'google',\n * clientId: 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com',\n * storageKey: 'mygame:player',\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 (google) · preview (github)\n */\nexport async function useAuthProvider(\n options: AuthProviderOptions,\n): Promise<AuthPlayerHandle | null> {\n if (!options || typeof options !== 'object') {\n throw new TypeError('useAuthProvider: options is required ({ provider, … }).');\n }\n\n switch (options.provider) {\n case 'google':\n return signInWithGoogleProvider(options);\n case 'github':\n throw new Error(\n 'useAuthProvider: the GitHub provider is not available yet. It ships in ' +\n 'scorezilla@0.3.0-next.2 and will require a server-side token exchange ' +\n '(your backend or a Scorezilla Workers proxy), because GitHub OAuth cannot ' +\n 'be completed securely in the browser alone. Until then use provider: \"google\", ' +\n 'or drive your own GitHub OAuth flow and pass the resulting id to submitScore.',\n );\n default:\n throw new TypeError(\n `useAuthProvider: unknown provider ${JSON.stringify(\n (options as { provider?: unknown }).provider,\n )} (expected \"google\" or \"github\").`,\n );\n }\n}\n\n// Coalesces concurrent sign-ins for the same storageKey (e.g. React StrictMode\n// double-invoke, or two leaderboards on one page) so they share a single One\n// Tap rather than racing GIS's single global callback — which would otherwise\n// leave the losing call's promise unresolved. Entries clear when the sign-in\n// settles, so this never accumulates state.\nconst googleSignInInFlight = new Map<string, Promise<AuthPlayerHandle | null>>();\n\nasync function signInWithGoogleProvider(\n options: GoogleAuthProviderOptions,\n): Promise<AuthPlayerHandle | null> {\n const clientId = requireNonEmptyString('useAuthProvider', 'clientId', options.clientId);\n if (!isGoogleClientId(clientId)) {\n throw new TypeError(\n 'useAuthProvider: options.clientId must be a Google OAuth client ID ' +\n '(ending in \".apps.googleusercontent.com\"). Create one at ' +\n 'https://console.cloud.google.com/apis/credentials.',\n );\n }\n const storageKey = requireNonEmptyString('useAuthProvider', 'storageKey', options.storageKey);\n\n // Return visit: trust the persisted id without re-running sign-in. Like the\n // other presets, this value is whatever is in localStorage under storageKey\n // — it is NOT a freshly re-verified Google identity (hence source:\n // 'restored'). Consistent with ADR 0003 (playerId is opaque attribution,\n // never an auth credential; the secure path signs submissions server-side).\n // Call signOut() to force a fresh sign-in next time.\n const persisted = readPersisted(storageKey);\n if (persisted !== null && persisted.length > 0) {\n return makeAuthHandle(persisted, 'google', storageKey, 'restored');\n }\n\n if (!isBrowser()) {\n throw new Error('useAuthProvider: Google sign-in requires a browser environment.');\n }\n\n const existing = googleSignInInFlight.get(storageKey);\n if (existing) return existing;\n\n const run = (async (): Promise<AuthPlayerHandle | null> => {\n const sub = await signInWithGoogle({ clientId, autoSelect: options.autoSelect ?? false });\n if (sub === null) return null;\n const id = `google:${sub}`;\n writePersisted(storageKey, id);\n return makeAuthHandle(id, 'google', storageKey, 'signed-in');\n })().finally(() => {\n googleSignInInFlight.delete(storageKey);\n });\n\n googleSignInInFlight.set(storageKey, run);\n return run;\n}\n\nfunction makeAuthHandle(\n id: string,\n provider: AuthProvider,\n storageKey: string,\n source: AuthIdSource,\n): AuthPlayerHandle {\n return {\n id,\n provider,\n source,\n signOut: () => {\n removePersisted(storageKey);\n if (provider === 'google') disableGoogleAutoSelect();\n },\n };\n}\n"]}
|