scorezilla 0.1.0-next.3 → 0.3.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Identity preset helpers — opinionated, selectable ways for your game to
3
+ * generate or fetch a `playerId` for score submission.
4
+ *
5
+ * Background: every Scorezilla score carries an opaque `playerId`. The SDK
6
+ * doesn't care whether it's a UUID, a nickname, an email, or a server
7
+ * session token. But _how_ your game decides on that value is a UX +
8
+ * privacy decision the team should make explicitly. These presets are the
9
+ * blessed patterns; pick one per integration.
10
+ *
11
+ * See ADR 0003 (MCP identity axis) for the design rationale:
12
+ * https://github.com/isco-tec/scorezilla/blob/main/docs/adr/0003-mcp-identity-axis.md
13
+ *
14
+ * @module scorezilla/identity
15
+ * @since 0.3.0
16
+ */
17
+ interface AnonymousPlayerOptions {
18
+ /** localStorage key under which the generated UUID is persisted. */
19
+ readonly storageKey: string;
20
+ }
21
+ interface PromptedPlayerOptions {
22
+ /** localStorage key under which the user-entered name is persisted. */
23
+ readonly storageKey: string;
24
+ /** Message shown in `window.prompt()` on first run. */
25
+ readonly prompt: string;
26
+ }
27
+ /**
28
+ * Identity handle returned by the storage-backed presets.
29
+ *
30
+ * `forget()` clears the persisted value from browser storage. It does
31
+ * **not** delete server-side score history for this player — to fully
32
+ * erase a player's data, call the admin "delete player" endpoint.
33
+ */
34
+ interface PlayerHandle {
35
+ readonly id: string;
36
+ readonly forget: () => void;
37
+ }
38
+ /**
39
+ * Marker returned by `useServerAuthoritative()` to signal that the
40
+ * game's backend (not the browser) owns the `playerId` via the
41
+ * HMAC-signed secure path (`scorezilla/server`).
42
+ */
43
+ interface ServerAuthoritativeMarker {
44
+ readonly source: 'server-authoritative';
45
+ }
46
+ /**
47
+ * Anonymous player identity. Generates an opaque UUID on first run and
48
+ * persists it in `localStorage` so the same browser keeps the same ID
49
+ * across page reloads.
50
+ *
51
+ * **Privacy.** Stores a randomly-generated UUID in browser localStorage;
52
+ * the value is sent to the API on every score submission and persisted
53
+ * indefinitely in the player's score-history rows. No PII is collected.
54
+ * `forget()` removes the localStorage entry; for full server-side erasure
55
+ * call the admin "delete player" endpoint.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * import { Scorezilla } from 'scorezilla';
60
+ * import { useAnonymousPlayer } from 'scorezilla/identity';
61
+ *
62
+ * const player = useAnonymousPlayer({ storageKey: 'mygame:player' });
63
+ * const sz = new Scorezilla({ publicKey: 'pk_…' });
64
+ * await sz.submitScore({ boardId, playerId: player.id, score: 42 });
65
+ * ```
66
+ *
67
+ * @since 0.3.0
68
+ * @stability stable
69
+ */
70
+ declare function useAnonymousPlayer(options: AnonymousPlayerOptions): PlayerHandle;
71
+ /**
72
+ * Prompted player identity. On first run shows a `window.prompt()` asking
73
+ * the user for a name, then persists it in `localStorage` for subsequent
74
+ * visits. Returns `null` if there is no browser (SSR), no `window.prompt`,
75
+ * or if the user cancelled / entered an empty value.
76
+ *
77
+ * **Privacy.** The user-entered string is stored in browser localStorage,
78
+ * transmitted to the API on every score submission, and persisted
79
+ * indefinitely on the leaderboard. The persisted value is whatever the
80
+ * user typed — sanitize at the UI layer if you care. `forget()` clears
81
+ * local state but does NOT delete server-side history.
82
+ *
83
+ * **UX caveat.** `window.prompt()` blocks the main thread and looks
84
+ * dated in modern apps. For a polished flow, build your own inline form
85
+ * and pass the result to `submitScore` directly — the preset is here to
86
+ * cover quick prototypes and jam-style integrations.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * import { Scorezilla } from 'scorezilla';
91
+ * import { usePromptedPlayer } from 'scorezilla/identity';
92
+ *
93
+ * const player = usePromptedPlayer({
94
+ * storageKey: 'mygame:player',
95
+ * prompt: 'Enter a name for the leaderboard:',
96
+ * });
97
+ *
98
+ * if (player) {
99
+ * const sz = new Scorezilla({ publicKey: 'pk_…' });
100
+ * await sz.submitScore({ boardId, playerId: player.id, score: 42 });
101
+ * }
102
+ * ```
103
+ *
104
+ * @since 0.3.0
105
+ * @stability stable
106
+ */
107
+ declare function usePromptedPlayer(options: PromptedPlayerOptions): PlayerHandle | null;
108
+ /**
109
+ * Server-authoritative identity marker. Signals that the game's backend
110
+ * is responsible for the `playerId` via the HMAC-signed secure path
111
+ * (`scorezilla/server`). The browser SDK does no identity work — the
112
+ * server picks the value, signs the submission, and posts.
113
+ *
114
+ * The return value is a no-op marker; you don't pass it anywhere. It
115
+ * exists so MCP-returned snippets can emit a single line that
116
+ * unambiguously says "this game uses the secure path; identity is
117
+ * server-authoritative."
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * // Client (no identity helper needed):
122
+ * import { useServerAuthoritative } from 'scorezilla/identity';
123
+ * useServerAuthoritative();
124
+ *
125
+ * // Server (where the real work happens):
126
+ * import { Scorezilla } from 'scorezilla/server';
127
+ * const sz = new Scorezilla({ secretKey: process.env.SCOREZILLA_SECRET_KEY! });
128
+ * await sz.submitScore({ boardId, playerId: serverDerivedId, score });
129
+ * ```
130
+ *
131
+ * @since 0.3.0
132
+ * @stability stable
133
+ */
134
+ declare function useServerAuthoritative(): ServerAuthoritativeMarker;
135
+ /**
136
+ * OAuth-backed player identity. **Preview stub in 0.3.0-next.x** — throws
137
+ * on call. Full implementation (Google + GitHub for v1, Apple + Discord
138
+ * deferred) lands in a follow-up release on the `next` dist-tag, before
139
+ * the `latest` 0.3.0 ships.
140
+ *
141
+ * Until then: drive your own OAuth flow and pass the resulting user
142
+ * identifier to `submitScore` directly.
143
+ *
144
+ * @since 0.3.0
145
+ * @stability preview
146
+ */
147
+ declare function useAuthProvider(_options: {
148
+ readonly provider: 'google' | 'github';
149
+ }): never;
150
+
151
+ export { type AnonymousPlayerOptions, type PlayerHandle, type PromptedPlayerOptions, type ServerAuthoritativeMarker, useAnonymousPlayer, useAuthProvider, usePromptedPlayer, useServerAuthoritative };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Identity preset helpers — opinionated, selectable ways for your game to
3
+ * generate or fetch a `playerId` for score submission.
4
+ *
5
+ * Background: every Scorezilla score carries an opaque `playerId`. The SDK
6
+ * doesn't care whether it's a UUID, a nickname, an email, or a server
7
+ * session token. But _how_ your game decides on that value is a UX +
8
+ * privacy decision the team should make explicitly. These presets are the
9
+ * blessed patterns; pick one per integration.
10
+ *
11
+ * See ADR 0003 (MCP identity axis) for the design rationale:
12
+ * https://github.com/isco-tec/scorezilla/blob/main/docs/adr/0003-mcp-identity-axis.md
13
+ *
14
+ * @module scorezilla/identity
15
+ * @since 0.3.0
16
+ */
17
+ interface AnonymousPlayerOptions {
18
+ /** localStorage key under which the generated UUID is persisted. */
19
+ readonly storageKey: string;
20
+ }
21
+ interface PromptedPlayerOptions {
22
+ /** localStorage key under which the user-entered name is persisted. */
23
+ readonly storageKey: string;
24
+ /** Message shown in `window.prompt()` on first run. */
25
+ readonly prompt: string;
26
+ }
27
+ /**
28
+ * Identity handle returned by the storage-backed presets.
29
+ *
30
+ * `forget()` clears the persisted value from browser storage. It does
31
+ * **not** delete server-side score history for this player — to fully
32
+ * erase a player's data, call the admin "delete player" endpoint.
33
+ */
34
+ interface PlayerHandle {
35
+ readonly id: string;
36
+ readonly forget: () => void;
37
+ }
38
+ /**
39
+ * Marker returned by `useServerAuthoritative()` to signal that the
40
+ * game's backend (not the browser) owns the `playerId` via the
41
+ * HMAC-signed secure path (`scorezilla/server`).
42
+ */
43
+ interface ServerAuthoritativeMarker {
44
+ readonly source: 'server-authoritative';
45
+ }
46
+ /**
47
+ * Anonymous player identity. Generates an opaque UUID on first run and
48
+ * persists it in `localStorage` so the same browser keeps the same ID
49
+ * across page reloads.
50
+ *
51
+ * **Privacy.** Stores a randomly-generated UUID in browser localStorage;
52
+ * the value is sent to the API on every score submission and persisted
53
+ * indefinitely in the player's score-history rows. No PII is collected.
54
+ * `forget()` removes the localStorage entry; for full server-side erasure
55
+ * call the admin "delete player" endpoint.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * import { Scorezilla } from 'scorezilla';
60
+ * import { useAnonymousPlayer } from 'scorezilla/identity';
61
+ *
62
+ * const player = useAnonymousPlayer({ storageKey: 'mygame:player' });
63
+ * const sz = new Scorezilla({ publicKey: 'pk_…' });
64
+ * await sz.submitScore({ boardId, playerId: player.id, score: 42 });
65
+ * ```
66
+ *
67
+ * @since 0.3.0
68
+ * @stability stable
69
+ */
70
+ declare function useAnonymousPlayer(options: AnonymousPlayerOptions): PlayerHandle;
71
+ /**
72
+ * Prompted player identity. On first run shows a `window.prompt()` asking
73
+ * the user for a name, then persists it in `localStorage` for subsequent
74
+ * visits. Returns `null` if there is no browser (SSR), no `window.prompt`,
75
+ * or if the user cancelled / entered an empty value.
76
+ *
77
+ * **Privacy.** The user-entered string is stored in browser localStorage,
78
+ * transmitted to the API on every score submission, and persisted
79
+ * indefinitely on the leaderboard. The persisted value is whatever the
80
+ * user typed — sanitize at the UI layer if you care. `forget()` clears
81
+ * local state but does NOT delete server-side history.
82
+ *
83
+ * **UX caveat.** `window.prompt()` blocks the main thread and looks
84
+ * dated in modern apps. For a polished flow, build your own inline form
85
+ * and pass the result to `submitScore` directly — the preset is here to
86
+ * cover quick prototypes and jam-style integrations.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * import { Scorezilla } from 'scorezilla';
91
+ * import { usePromptedPlayer } from 'scorezilla/identity';
92
+ *
93
+ * const player = usePromptedPlayer({
94
+ * storageKey: 'mygame:player',
95
+ * prompt: 'Enter a name for the leaderboard:',
96
+ * });
97
+ *
98
+ * if (player) {
99
+ * const sz = new Scorezilla({ publicKey: 'pk_…' });
100
+ * await sz.submitScore({ boardId, playerId: player.id, score: 42 });
101
+ * }
102
+ * ```
103
+ *
104
+ * @since 0.3.0
105
+ * @stability stable
106
+ */
107
+ declare function usePromptedPlayer(options: PromptedPlayerOptions): PlayerHandle | null;
108
+ /**
109
+ * Server-authoritative identity marker. Signals that the game's backend
110
+ * is responsible for the `playerId` via the HMAC-signed secure path
111
+ * (`scorezilla/server`). The browser SDK does no identity work — the
112
+ * server picks the value, signs the submission, and posts.
113
+ *
114
+ * The return value is a no-op marker; you don't pass it anywhere. It
115
+ * exists so MCP-returned snippets can emit a single line that
116
+ * unambiguously says "this game uses the secure path; identity is
117
+ * server-authoritative."
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * // Client (no identity helper needed):
122
+ * import { useServerAuthoritative } from 'scorezilla/identity';
123
+ * useServerAuthoritative();
124
+ *
125
+ * // Server (where the real work happens):
126
+ * import { Scorezilla } from 'scorezilla/server';
127
+ * const sz = new Scorezilla({ secretKey: process.env.SCOREZILLA_SECRET_KEY! });
128
+ * await sz.submitScore({ boardId, playerId: serverDerivedId, score });
129
+ * ```
130
+ *
131
+ * @since 0.3.0
132
+ * @stability stable
133
+ */
134
+ declare function useServerAuthoritative(): ServerAuthoritativeMarker;
135
+ /**
136
+ * OAuth-backed player identity. **Preview stub in 0.3.0-next.x** — throws
137
+ * on call. Full implementation (Google + GitHub for v1, Apple + Discord
138
+ * deferred) lands in a follow-up release on the `next` dist-tag, before
139
+ * the `latest` 0.3.0 ships.
140
+ *
141
+ * Until then: drive your own OAuth flow and pass the resulting user
142
+ * identifier to `submitScore` directly.
143
+ *
144
+ * @since 0.3.0
145
+ * @stability preview
146
+ */
147
+ declare function useAuthProvider(_options: {
148
+ readonly provider: 'google' | 'github';
149
+ }): never;
150
+
151
+ export { type AnonymousPlayerOptions, type PlayerHandle, type PromptedPlayerOptions, type ServerAuthoritativeMarker, useAnonymousPlayer, useAuthProvider, usePromptedPlayer, useServerAuthoritative };
@@ -0,0 +1,82 @@
1
+ // src/identity.ts
2
+ var isBrowser = () => typeof window !== "undefined";
3
+ function readPersisted(key) {
4
+ if (!isBrowser()) return null;
5
+ try {
6
+ return window.localStorage.getItem(key);
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+ function writePersisted(key, value) {
12
+ if (!isBrowser()) return;
13
+ try {
14
+ window.localStorage.setItem(key, value);
15
+ } catch {
16
+ }
17
+ }
18
+ function removePersisted(key) {
19
+ if (!isBrowser()) return;
20
+ try {
21
+ window.localStorage.removeItem(key);
22
+ } catch {
23
+ }
24
+ }
25
+ function mintUuid() {
26
+ if (isBrowser() && typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
27
+ return crypto.randomUUID();
28
+ }
29
+ return `anon-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
30
+ }
31
+ function requireStorageKey(fnName, options) {
32
+ if (!options || typeof options.storageKey !== "string" || options.storageKey.length === 0) {
33
+ throw new TypeError(`${fnName}: options.storageKey is required (non-empty string)`);
34
+ }
35
+ return options.storageKey;
36
+ }
37
+ function useAnonymousPlayer(options) {
38
+ const storageKey = requireStorageKey("useAnonymousPlayer", options);
39
+ let id = readPersisted(storageKey);
40
+ if (id === null || id.length === 0) {
41
+ id = mintUuid();
42
+ writePersisted(storageKey, id);
43
+ }
44
+ return {
45
+ id,
46
+ forget: () => removePersisted(storageKey)
47
+ };
48
+ }
49
+ function usePromptedPlayer(options) {
50
+ const storageKey = requireStorageKey("usePromptedPlayer", options);
51
+ if (typeof options.prompt !== "string" || options.prompt.length === 0) {
52
+ throw new TypeError("usePromptedPlayer: options.prompt is required (non-empty string)");
53
+ }
54
+ let id = readPersisted(storageKey);
55
+ if (id === null || id.length === 0) {
56
+ if (!isBrowser() || typeof window.prompt !== "function") {
57
+ return null;
58
+ }
59
+ const entered = window.prompt(options.prompt);
60
+ if (entered === null || entered.length === 0) {
61
+ return null;
62
+ }
63
+ id = entered;
64
+ writePersisted(storageKey, id);
65
+ }
66
+ return {
67
+ id,
68
+ forget: () => removePersisted(storageKey)
69
+ };
70
+ }
71
+ function useServerAuthoritative() {
72
+ return { source: "server-authoritative" };
73
+ }
74
+ function useAuthProvider(_options) {
75
+ throw new Error(
76
+ "useAuthProvider is not yet implemented in this 0.3.0-next preview. OAuth provider helpers (Google + GitHub for v1) ship in a follow-up release on the `next` dist-tag. Until then, drive your own OAuth flow and pass the resulting user identifier to submitScore directly."
77
+ );
78
+ }
79
+
80
+ export { useAnonymousPlayer, useAuthProvider, usePromptedPlayer, useServerAuthoritative };
81
+ //# sourceMappingURL=identity.js.map
82
+ //# sourceMappingURL=identity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/identity.ts"],"names":[],"mappings":"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","file":"identity.js","sourcesContent":["/**\n * Identity preset helpers — opinionated, selectable ways for your game to\n * generate or fetch a `playerId` for score submission.\n *\n * Background: every Scorezilla score carries an opaque `playerId`. The SDK\n * doesn't care whether it's a UUID, a nickname, an email, or a server\n * session token. But _how_ your game decides on that value is a UX +\n * privacy decision the team should make explicitly. These presets are the\n * blessed patterns; pick one per integration.\n *\n * See ADR 0003 (MCP identity axis) for the design rationale:\n * https://github.com/isco-tec/scorezilla/blob/main/docs/adr/0003-mcp-identity-axis.md\n *\n * @module scorezilla/identity\n * @since 0.3.0\n */\n\nexport interface AnonymousPlayerOptions {\n /** localStorage key under which the generated UUID is persisted. */\n readonly storageKey: string;\n}\n\nexport interface PromptedPlayerOptions {\n /** localStorage key under which the user-entered name is persisted. */\n readonly storageKey: string;\n /** Message shown in `window.prompt()` on first run. */\n readonly prompt: string;\n}\n\n/**\n * Identity handle returned by the storage-backed presets.\n *\n * `forget()` clears the persisted value from browser storage. It does\n * **not** delete server-side score history for this player — to fully\n * erase a player's data, call the admin \"delete player\" endpoint.\n */\nexport interface PlayerHandle {\n readonly id: string;\n readonly forget: () => void;\n}\n\n/**\n * Marker returned by `useServerAuthoritative()` to signal that the\n * game's backend (not the browser) owns the `playerId` via the\n * HMAC-signed secure path (`scorezilla/server`).\n */\nexport interface ServerAuthoritativeMarker {\n readonly source: 'server-authoritative';\n}\n\nconst isBrowser = (): boolean => typeof window !== 'undefined';\n\nfunction readPersisted(key: string): string | null {\n if (!isBrowser()) return null;\n try {\n return window.localStorage.getItem(key);\n } catch {\n // Storage may throw in sandboxed iframes, privacy mode, or when the\n // user has disabled site data. Treat as \"missing\"; the caller will\n // mint or re-prompt.\n return null;\n }\n}\n\nfunction writePersisted(key: string, value: string): void {\n if (!isBrowser()) return;\n try {\n window.localStorage.setItem(key, value);\n } catch {\n // ignore; next call will re-mint or re-prompt\n }\n}\n\nfunction removePersisted(key: string): void {\n if (!isBrowser()) return;\n try {\n window.localStorage.removeItem(key);\n } catch {\n // ignore\n }\n}\n\nfunction mintUuid(): string {\n if (isBrowser() && typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n return crypto.randomUUID();\n }\n // Best-effort fallback: timestamp + random suffix. Not cryptographically\n // strong, but opaque enough for the identifier-only use case. The\n // browsers we target (Chrome 92+, Firefox 95+, Safari 15.4+) all have\n // crypto.randomUUID — this branch is reached only in non-browser\n // environments where useAnonymousPlayer shouldn't be called anyway.\n return `anon-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction requireStorageKey(fnName: string, options: { storageKey?: unknown } | undefined): string {\n if (!options || typeof options.storageKey !== 'string' || options.storageKey.length === 0) {\n throw new TypeError(`${fnName}: options.storageKey is required (non-empty string)`);\n }\n return options.storageKey;\n}\n\n/**\n * Anonymous player identity. Generates an opaque UUID on first run and\n * persists it in `localStorage` so the same browser keeps the same ID\n * across page reloads.\n *\n * **Privacy.** Stores a randomly-generated UUID in browser localStorage;\n * the value is sent to the API on every score submission and persisted\n * indefinitely in the player's score-history rows. No PII is collected.\n * `forget()` removes the localStorage entry; for full server-side erasure\n * call the admin \"delete player\" endpoint.\n *\n * @example\n * ```ts\n * import { Scorezilla } from 'scorezilla';\n * import { useAnonymousPlayer } from 'scorezilla/identity';\n *\n * const player = useAnonymousPlayer({ storageKey: 'mygame:player' });\n * const sz = new Scorezilla({ publicKey: 'pk_…' });\n * await sz.submitScore({ boardId, playerId: player.id, score: 42 });\n * ```\n *\n * @since 0.3.0\n * @stability stable\n */\nexport function useAnonymousPlayer(options: AnonymousPlayerOptions): PlayerHandle {\n const storageKey = requireStorageKey('useAnonymousPlayer', options);\n let id = readPersisted(storageKey);\n if (id === null || id.length === 0) {\n id = mintUuid();\n writePersisted(storageKey, id);\n }\n return {\n id,\n forget: () => removePersisted(storageKey),\n };\n}\n\n/**\n * Prompted player identity. On first run shows a `window.prompt()` asking\n * the user for a name, then persists it in `localStorage` for subsequent\n * visits. Returns `null` if there is no browser (SSR), no `window.prompt`,\n * or if the user cancelled / entered an empty value.\n *\n * **Privacy.** The user-entered string is stored in browser localStorage,\n * transmitted to the API on every score submission, and persisted\n * indefinitely on the leaderboard. The persisted value is whatever the\n * user typed — sanitize at the UI layer if you care. `forget()` clears\n * local state but does NOT delete server-side history.\n *\n * **UX caveat.** `window.prompt()` blocks the main thread and looks\n * dated in modern apps. For a polished flow, build your own inline form\n * and pass the result to `submitScore` directly — the preset is here to\n * cover quick prototypes and jam-style integrations.\n *\n * @example\n * ```ts\n * import { Scorezilla } from 'scorezilla';\n * import { usePromptedPlayer } from 'scorezilla/identity';\n *\n * const player = usePromptedPlayer({\n * storageKey: 'mygame:player',\n * prompt: 'Enter a name for the leaderboard:',\n * });\n *\n * if (player) {\n * const sz = new Scorezilla({ publicKey: 'pk_…' });\n * await sz.submitScore({ boardId, playerId: player.id, score: 42 });\n * }\n * ```\n *\n * @since 0.3.0\n * @stability stable\n */\nexport function usePromptedPlayer(options: PromptedPlayerOptions): PlayerHandle | null {\n const storageKey = requireStorageKey('usePromptedPlayer', options);\n if (typeof options.prompt !== 'string' || options.prompt.length === 0) {\n throw new TypeError('usePromptedPlayer: options.prompt is required (non-empty string)');\n }\n\n let id = readPersisted(storageKey);\n if (id === null || id.length === 0) {\n if (!isBrowser() || typeof window.prompt !== 'function') {\n return null;\n }\n const entered = window.prompt(options.prompt);\n if (entered === null || entered.length === 0) {\n return null;\n }\n id = entered;\n writePersisted(storageKey, id);\n }\n return {\n id,\n forget: () => removePersisted(storageKey),\n };\n}\n\n/**\n * Server-authoritative identity marker. Signals that the game's backend\n * is responsible for the `playerId` via the HMAC-signed secure path\n * (`scorezilla/server`). The browser SDK does no identity work — the\n * server picks the value, signs the submission, and posts.\n *\n * The return value is a no-op marker; you don't pass it anywhere. It\n * exists so MCP-returned snippets can emit a single line that\n * unambiguously says \"this game uses the secure path; identity is\n * server-authoritative.\"\n *\n * @example\n * ```ts\n * // Client (no identity helper needed):\n * import { useServerAuthoritative } from 'scorezilla/identity';\n * useServerAuthoritative();\n *\n * // Server (where the real work happens):\n * import { Scorezilla } from 'scorezilla/server';\n * const sz = new Scorezilla({ secretKey: process.env.SCOREZILLA_SECRET_KEY! });\n * await sz.submitScore({ boardId, playerId: serverDerivedId, score });\n * ```\n *\n * @since 0.3.0\n * @stability stable\n */\nexport function useServerAuthoritative(): ServerAuthoritativeMarker {\n return { source: 'server-authoritative' };\n}\n\n/**\n * OAuth-backed player identity. **Preview stub in 0.3.0-next.x** — throws\n * on call. Full implementation (Google + GitHub for v1, Apple + Discord\n * deferred) lands in a follow-up release on the `next` dist-tag, before\n * the `latest` 0.3.0 ships.\n *\n * Until then: drive your own OAuth flow and pass the resulting user\n * identifier to `submitScore` directly.\n *\n * @since 0.3.0\n * @stability preview\n */\nexport function useAuthProvider(_options: { readonly provider: 'google' | 'github' }): never {\n throw new Error(\n 'useAuthProvider is not yet implemented in this 0.3.0-next preview. ' +\n 'OAuth provider helpers (Google + GitHub for v1) ship in a follow-up ' +\n 'release on the `next` dist-tag. Until then, drive your own OAuth flow ' +\n 'and pass the resulting user identifier to submitScore directly.',\n );\n}\n"]}
package/dist/index.cjs CHANGED
@@ -33,6 +33,7 @@ function validateConfig(cfg) {
33
33
  timeoutMs: cfg.timeoutMs,
34
34
  maxRetries: cfg.maxRetries,
35
35
  sleepImpl: cfg.sleepImpl,
36
+ warn: cfg.warn,
36
37
  userAgent: cfg.userAgent,
37
38
  auth
38
39
  };
@@ -119,7 +120,9 @@ var ScorezillaError = class _ScorezillaError extends Error {
119
120
  * {@link ScorezillaErrorCode}. For network errors, this is `'network_error'`;
120
121
  * for aborts, `'aborted'`; for timeouts, `'timeout'`. */
121
122
  code;
122
- /** Sub-classifier — present on `out_of_bounds` (`'below_min' | 'above_max'`)
123
+ /** Sub-classifier — present on:
124
+ * - `out_of_bounds`: `'below_min' | 'above_max'`
125
+ * - `usage_cap_exceeded`: `'over_cap' | 'suspended'`
123
126
  * and possibly other codes in future minor releases. */
124
127
  reason;
125
128
  /** Seconds — present on `rate_limited`. Honored by the transport's retry
@@ -132,6 +135,20 @@ var ScorezillaError = class _ScorezillaError extends Error {
132
135
  bound;
133
136
  /** Which rate-limit layer fired on `rate_limited`. */
134
137
  layer;
138
+ /** Tenant's billing tier — present on `usage_cap_exceeded`. */
139
+ tier;
140
+ /** The cap value crossed on `usage_cap_exceeded`. `0` indicates a
141
+ * suspended tenant. `undefined` on all other error codes. */
142
+ cap;
143
+ /** The post-increment submit count on `usage_cap_exceeded`. Always
144
+ * `> cap` when `reason === 'over_cap'`. */
145
+ count;
146
+ /** The period the count belongs to on `usage_cap_exceeded`, in `YYYY-MM`
147
+ * UTC form. */
148
+ period;
149
+ /** ISO-8601 timestamp of midnight UTC on the 1st of the next month —
150
+ * the counter's natural reset point on `usage_cap_exceeded`. */
151
+ resetsAt;
135
152
  /** The underlying cause (e.g., a `TypeError: fetch failed`) for
136
153
  * network/abort/timeout paths. `undefined` when the error came from a
137
154
  * successfully-parsed API error body. */
@@ -146,6 +163,11 @@ var ScorezillaError = class _ScorezillaError extends Error {
146
163
  this.requestId = truncateField(init.requestId);
147
164
  this.bound = init.bound;
148
165
  this.layer = truncateField(init.layer);
166
+ this.tier = truncateField(init.tier);
167
+ this.cap = init.cap;
168
+ this.count = init.count;
169
+ this.period = truncateField(init.period);
170
+ this.resetsAt = truncateField(init.resetsAt);
149
171
  this.cause = init.cause;
150
172
  Object.setPrototypeOf(this, _ScorezillaError.prototype);
151
173
  if (typeof Error.captureStackTrace === "function") {
@@ -159,6 +181,22 @@ var ScorezillaError = class _ScorezillaError extends Error {
159
181
  isRateLimited() {
160
182
  return this.code === "rate_limited";
161
183
  }
184
+ /**
185
+ * `true` when this error is a 402 / `usage_cap_exceeded`. The tenant
186
+ * has either hit their tier's monthly submit cap (`reason ===
187
+ * 'over_cap'`) or is suspended (`reason === 'suspended'`).
188
+ *
189
+ * Consumers SHOULD NOT auto-retry on this error — the cap doesn't lift
190
+ * until `resetsAt`. Surface to the developer with an upgrade prompt
191
+ * (over_cap) or contact-support message (suspended).
192
+ */
193
+ isUsageCapExceeded() {
194
+ return this.code === "usage_cap_exceeded";
195
+ }
196
+ /** `true` when this error is a 402 + reason 'suspended' (vs over-cap). */
197
+ isSuspended() {
198
+ return this.code === "usage_cap_exceeded" && this.reason === "suspended";
199
+ }
162
200
  /** `true` when this error is a 401 / `unauthorized` (or 403 / `forbidden`). */
163
201
  isAuth() {
164
202
  return this.code === "unauthorized" || this.code === "forbidden";
@@ -171,13 +209,21 @@ var ScorezillaError = class _ScorezillaError extends Error {
171
209
  isOutOfBounds() {
172
210
  return this.code === "out_of_bounds";
173
211
  }
174
- /** `true` for transient / retryable conditions: network errors, timeouts,
175
- * 5xx, and 429. The transport layer relies on this for its retry policy. */
212
+ /** `true` for the SDK's retryable conditions: pure network errors, 5xx, and
213
+ * 429. Deliberately excludes `timeout` and `aborted` those are caller-
214
+ * observable terminal states, not transient. Aligned with `shouldRetryError`
215
+ * in `retry.ts` so a consumer mirroring the SDK's retry policy gets the
216
+ * same answer the transport does. */
176
217
  isTransient() {
177
- if (this.status === STATUS_NETWORK_ERROR) return true;
218
+ if (this.code === "network_error") return true;
178
219
  if (this.status >= 500 && this.status < 600) return true;
179
220
  return this.isRateLimited();
180
221
  }
222
+ /** `true` when this error is a 409 / `conflict` (idempotency-key conflict
223
+ * on retry). */
224
+ isConflict() {
225
+ return this.code === "conflict";
226
+ }
181
227
  // ─── Factory ─────────────────────────────────────────────────────────
182
228
  /**
183
229
  * Build a `ScorezillaError` from a fetch round-trip outcome.
@@ -198,6 +244,13 @@ var ScorezillaError = class _ScorezillaError extends Error {
198
244
  retryAfter: body.retryAfter,
199
245
  bound: body.bound,
200
246
  layer: body.layer,
247
+ // Usage-cap fields from `ApiError` (populated by the server on
248
+ // 402 responses; undefined on other errors).
249
+ tier: body.tier,
250
+ cap: body.cap,
251
+ count: body.count,
252
+ period: body.period,
253
+ resetsAt: body.resetsAt,
201
254
  requestId,
202
255
  cause
203
256
  });
@@ -239,8 +292,10 @@ var ScorezillaError = class _ScorezillaError extends Error {
239
292
  };
240
293
  function codeForStatus(status) {
241
294
  if (status === 401) return "unauthorized";
295
+ if (status === 402) return "usage_cap_exceeded";
242
296
  if (status === 403) return "forbidden";
243
297
  if (status === 404) return "not_found";
298
+ if (status === 409) return "conflict";
244
299
  if (status === 422) return "out_of_bounds";
245
300
  if (status === 429) return "rate_limited";
246
301
  if (status >= 500) return "internal_error";
@@ -351,6 +406,7 @@ async function request(opts) {
351
406
  }
352
407
  const response = await fetchImpl(url, init);
353
408
  if (response.ok) {
409
+ warnOnDeprecationOnce(response, opts.warnImpl);
354
410
  return await parseJson(response);
355
411
  }
356
412
  const body = await safelyParseErrorBody(response);
@@ -462,6 +518,29 @@ function readRetryAfter(response) {
462
518
  const n = Number(raw);
463
519
  return Number.isFinite(n) && n >= 0 ? n : void 0;
464
520
  }
521
+ var seenDeprecations = /* @__PURE__ */ new Set();
522
+ function warnOnDeprecationOnce(response, warnImpl) {
523
+ const deprecation = response.headers.get("Deprecation");
524
+ const sunset = response.headers.get("Sunset");
525
+ if (!deprecation && !sunset) return;
526
+ const link = response.headers.get("Link") ?? "";
527
+ const key = `${deprecation ?? ""}|${sunset ?? ""}|${link}`;
528
+ if (seenDeprecations.has(key)) return;
529
+ seenDeprecations.add(key);
530
+ const detail = [];
531
+ if (deprecation === "true" || deprecation) detail.push(`Deprecation: ${deprecation}`);
532
+ if (sunset) detail.push(`Sunset: ${sunset}`);
533
+ if (link) {
534
+ const m = link.match(/<([^>]+)>/);
535
+ if (m) detail.push(`Docs: ${m[1]}`);
536
+ }
537
+ const message = `[scorezilla-sdk] API responded with deprecation signal: ${detail.join(" \xB7 ")}. Upgrade your SDK before the sunset date.`;
538
+ if (warnImpl) {
539
+ warnImpl(message);
540
+ } else {
541
+ console.warn(message);
542
+ }
543
+ }
465
544
  function combineSignalsWithTimeout(caller, timeoutMs) {
466
545
  const ctrl = new AbortController();
467
546
  let didTimeOut = false;
@@ -550,10 +629,11 @@ function validateMetadata(metadata) {
550
629
  `scorezilla: metadata exceeds ${METADATA_MAX_BYTES} bytes (got ${byteLength} bytes when JSON-stringified)`
551
630
  );
552
631
  }
632
+ return serialized;
553
633
  }
554
634
  var Scorezilla = class _Scorezilla {
555
635
  /** The package version, injected at build time from `package.json`. */
556
- static version = "0.1.0-next.3";
636
+ static version = "0.3.0-next.0";
557
637
  #config;
558
638
  #userAgent;
559
639
  #authHeader;
@@ -611,7 +691,8 @@ var Scorezilla = class _Scorezilla {
611
691
  return this.#request({
612
692
  path: submitScorePath(input.boardId),
613
693
  method: "POST",
614
- body
694
+ body,
695
+ signal: input.signal
615
696
  });
616
697
  }
617
698
  /**
@@ -635,7 +716,8 @@ var Scorezilla = class _Scorezilla {
635
716
  if (input.offset !== void 0) q.offset = input.offset;
636
717
  return this.#request({
637
718
  path: getLeaderboardPath(input.boardId, q),
638
- method: "GET"
719
+ method: "GET",
720
+ signal: input.signal
639
721
  });
640
722
  }
641
723
  /**
@@ -664,7 +746,8 @@ var Scorezilla = class _Scorezilla {
664
746
  async getPlayerRank(input) {
665
747
  return this.#request({
666
748
  path: getPlayerRankPath(input.boardId, input.playerId),
667
- method: "GET"
749
+ method: "GET",
750
+ signal: input.signal
668
751
  });
669
752
  }
670
753
  /**
@@ -689,7 +772,8 @@ var Scorezilla = class _Scorezilla {
689
772
  if (input.after !== void 0) q.after = input.after;
690
773
  return this.#request({
691
774
  path: getWindowAroundPath(input.boardId, input.playerId, q),
692
- method: "GET"
775
+ method: "GET",
776
+ signal: input.signal
693
777
  });
694
778
  }
695
779
  // ─── Internal ────────────────────────────────────────────────────────
@@ -716,7 +800,9 @@ var Scorezilla = class _Scorezilla {
716
800
  headers
717
801
  };
718
802
  if (opts.body !== void 0) requestOpts.body = opts.body;
803
+ if (opts.signal !== void 0) requestOpts.signal = opts.signal;
719
804
  if (this.#config.fetch !== void 0) requestOpts.fetchImpl = this.#config.fetch;
805
+ if (this.#config.warn !== void 0) requestOpts.warnImpl = this.#config.warn;
720
806
  if (this.#config.timeoutMs !== void 0) requestOpts.timeoutMs = this.#config.timeoutMs;
721
807
  if (this.#config.maxRetries !== void 0 || this.#config.sleepImpl !== void 0) {
722
808
  requestOpts.retry = {
@@ -732,7 +818,7 @@ function createClient(config) {
732
818
  }
733
819
 
734
820
  // src/index.ts
735
- var SDK_VERSION = "0.1.0-next.3";
821
+ var SDK_VERSION = "0.3.0-next.0";
736
822
 
737
823
  exports.SDK_VERSION = SDK_VERSION;
738
824
  exports.Scorezilla = Scorezilla;