@openleaderboard/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+
17
+ Copyright 2026 OpenLeaderboard contributors
18
+
19
+ Full license text: https://www.apache.org/licenses/LICENSE-2.0.txt
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # OpenLeaderboard — TypeScript SDK
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@openleaderboard/sdk.svg?color=c6f135&label=npm)](https://www.npmjs.com/package/@openleaderboard/sdk)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@openleaderboard/sdk.svg)](https://www.npmjs.com/package/@openleaderboard/sdk)
5
+ [![license](https://img.shields.io/npm/l/@openleaderboard/sdk.svg)](../../LICENSE)
6
+
7
+ A dependency-free client for the [OpenLeaderboard](../../README.md) API. Works in
8
+ **browsers** and **Node 18+** (uses the global Fetch API and Web Crypto).
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @openleaderboard/sdk
14
+ ```
15
+
16
+ ## Quickstart
17
+
18
+ ```ts
19
+ import { LeaderboardClient, NotFoundError } from "@openleaderboard/sdk";
20
+
21
+ const lb = new LeaderboardClient("https://lb.example.com", "lb_your_api_key");
22
+
23
+ // Submit (write-behind: durably logged, ranked shortly after).
24
+ await lb.submitScore("high", playerId, 1500);
25
+
26
+ // Read back.
27
+ const me = await lb.getRank("high", playerId); // throws NotFoundError if absent
28
+ const top = await lb.getTop("high", 10);
29
+ const near = await lb.getNeighbors("high", playerId, 5); // me ± 5
30
+ const friends = await lb.getFriends("high", ["alice", "bob"]);
31
+
32
+ // Segmented / windowed reads (window: literal id or "daily"/"weekly"/"monthly").
33
+ await lb.getTop("high", 10, { segment: "region=eu", window: "daily" });
34
+ ```
35
+
36
+ Errors: `NotFoundError` (404) and `LeaderboardError` (other non-2xx, with `.status`).
37
+
38
+ ### One-time board setup
39
+
40
+ ```ts
41
+ await lb.createBoard("laptimes", { sortOrder: "asc", updatePolicy: "best" });
42
+ await lb.createBoard("weekly", { windows: [{ kind: "all" }, { kind: "weekly" }] });
43
+ ```
44
+
45
+ ## Node < 18
46
+
47
+ Pass a fetch implementation:
48
+
49
+ ```ts
50
+ import fetch from "node-fetch";
51
+ new LeaderboardClient(url, key, { fetch });
52
+ ```
53
+
54
+ ## Signed submissions (server-side only)
55
+
56
+ `signSubmission` and the client's `signingSecret` option produce HMAC signatures
57
+ matching the server's `SIGNING_SECRET`. **Never put the secret in browser/client
58
+ code** — anyone can read it. Sign from a trusted backend instead. Integer scores
59
+ sign identically to the Go server (cross-validated against the server and
60
+ `openssl`).
61
+
62
+ ```ts
63
+ const lb = new LeaderboardClient(url, key, { appId, signingSecret }); // backend only
64
+ ```
65
+
66
+ ## Develop
67
+
68
+ ```bash
69
+ npm run typecheck
70
+ npm run build
71
+ npm run test:hmac # offline HMAC cross-check
72
+ npm run test:e2e # against a running server (LB_API_KEY env)
73
+ ```
74
+
75
+ ## Releasing
76
+
77
+ `package.json` is authoritative. Bump `version` in your PR; when it merges to
78
+ `main`, CI (`.github/workflows/release-sdk.yml`) publishes exactly that version
79
+ to npm. Changes that don't bump the version are a no-op (already published →
80
+ skipped). npm versions are immutable, so each release needs a new version.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * OpenLeaderboard TypeScript SDK.
3
+ *
4
+ * Dependency-free client over the Fetch API — runs in browsers and Node 18+.
5
+ *
6
+ * ```ts
7
+ * import { LeaderboardClient } from "@openleaderboard/sdk";
8
+ * const lb = new LeaderboardClient("https://lb.example.com", "lb_your_api_key");
9
+ * await lb.submitScore("high", playerId, 1500);
10
+ * const me = await lb.getRank("high", playerId);
11
+ * const top = await lb.getTop("high", 10);
12
+ * ```
13
+ */
14
+ export interface RankEntry {
15
+ member: string;
16
+ score: number;
17
+ rank: number;
18
+ exact: boolean;
19
+ }
20
+ export interface SubmitResult {
21
+ accepted: boolean;
22
+ duplicate: boolean;
23
+ }
24
+ /** Selects the physical board (segment/window) to read. */
25
+ export interface QueryOpts {
26
+ segment?: string;
27
+ /** Literal window id ("d=2026-06-13") or cadence keyword ("daily"/"weekly"/"monthly"). */
28
+ window?: string;
29
+ }
30
+ export interface SubmitOpts {
31
+ segments?: string[];
32
+ idem?: string;
33
+ }
34
+ export interface WindowDef {
35
+ kind: string;
36
+ customId?: string;
37
+ }
38
+ export interface BoardDef {
39
+ sortOrder?: "desc" | "asc";
40
+ updatePolicy?: "best" | "last" | "increment";
41
+ tieBreak?: "lexical" | "firstToReach";
42
+ windows?: WindowDef[];
43
+ }
44
+ type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
45
+ export interface ClientOptions {
46
+ /** App id; required only when signingSecret is set. */
47
+ appId?: string;
48
+ /**
49
+ * Optional HMAC secret for signed submissions. Only use in a TRUSTED backend
50
+ * — never ship it in browser/client code.
51
+ */
52
+ signingSecret?: string;
53
+ /** Override the fetch implementation (e.g. for Node <18 or tests). */
54
+ fetch?: FetchLike;
55
+ }
56
+ /** Thrown for non-2xx responses. */
57
+ export declare class LeaderboardError extends Error {
58
+ readonly status: number;
59
+ constructor(status: number, message: string);
60
+ }
61
+ /** Thrown when a member or board does not exist (HTTP 404). */
62
+ export declare class NotFoundError extends LeaderboardError {
63
+ constructor(message: string);
64
+ }
65
+ export declare class LeaderboardClient {
66
+ private readonly apiKey;
67
+ private readonly opts;
68
+ private readonly baseUrl;
69
+ private readonly fetchFn;
70
+ constructor(baseUrl: string, apiKey: string, opts?: ClientOptions);
71
+ /** Define a board. Typically a one-time setup call. */
72
+ createBoard(board: string, def?: BoardDef): Promise<void>;
73
+ /** Submit a score (write-behind: durably logged, ranked shortly after). */
74
+ submitScore(board: string, member: string, score: number, opts?: SubmitOpts): Promise<SubmitResult>;
75
+ /** A member's rank. Throws {@link NotFoundError} if absent. */
76
+ getRank(board: string, member: string, q?: QueryOpts): Promise<RankEntry>;
77
+ /** Top N entries (rank 1..N). */
78
+ getTop(board: string, n: number, q?: QueryOpts): Promise<RankEntry[]>;
79
+ /** A page of the ranking starting at offset (0-based). */
80
+ getPage(board: string, offset: number, limit: number, q?: QueryOpts): Promise<RankEntry[]>;
81
+ /** The member plus up to k entries on each side of it. */
82
+ getNeighbors(board: string, member: string, k: number, q?: QueryOpts): Promise<RankEntry[]>;
83
+ /** Rank an explicit set of members against each other (a friend leaderboard). */
84
+ getFriends(board: string, members: string[], q?: QueryOpts): Promise<RankEntry[]>;
85
+ private send;
86
+ }
87
+ /**
88
+ * Produces an HMAC-SHA256 submission signature matching the server (pkg/trust).
89
+ * Exported for trusted server-side signing and testing. Matches Go's float
90
+ * formatting for the common integer-score case. (Very large/small fractional
91
+ * scores may format with an exponent in JS and would not match — sign integer
92
+ * scores, or sign server-side.)
93
+ */
94
+ export declare function signSubmission(secret: string, app: string, board: string, member: string, score: number, ts: number, nonce: string): Promise<string>;
95
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * OpenLeaderboard TypeScript SDK.
3
+ *
4
+ * Dependency-free client over the Fetch API — runs in browsers and Node 18+.
5
+ *
6
+ * ```ts
7
+ * import { LeaderboardClient } from "@openleaderboard/sdk";
8
+ * const lb = new LeaderboardClient("https://lb.example.com", "lb_your_api_key");
9
+ * await lb.submitScore("high", playerId, 1500);
10
+ * const me = await lb.getRank("high", playerId);
11
+ * const top = await lb.getTop("high", 10);
12
+ * ```
13
+ */
14
+ /** Thrown for non-2xx responses. */
15
+ export class LeaderboardError extends Error {
16
+ constructor(status, message) {
17
+ super(message);
18
+ this.status = status;
19
+ this.name = "LeaderboardError";
20
+ }
21
+ }
22
+ /** Thrown when a member or board does not exist (HTTP 404). */
23
+ export class NotFoundError extends LeaderboardError {
24
+ constructor(message) {
25
+ super(404, message);
26
+ this.name = "NotFoundError";
27
+ }
28
+ }
29
+ export class LeaderboardClient {
30
+ constructor(baseUrl, apiKey, opts = {}) {
31
+ this.apiKey = apiKey;
32
+ this.opts = opts;
33
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
34
+ const f = opts.fetch ?? globalThis.fetch;
35
+ if (!f) {
36
+ throw new Error("global fetch unavailable; pass opts.fetch (Node <18)");
37
+ }
38
+ this.fetchFn = f;
39
+ }
40
+ /** Define a board. Typically a one-time setup call. */
41
+ async createBoard(board, def = {}) {
42
+ await this.send("POST", "/v1/boards", {
43
+ board,
44
+ sort_order: def.sortOrder,
45
+ update_policy: def.updatePolicy,
46
+ tie_break: def.tieBreak,
47
+ windows: def.windows?.map((w) => ({ kind: w.kind, custom_id: w.customId })),
48
+ });
49
+ }
50
+ /** Submit a score (write-behind: durably logged, ranked shortly after). */
51
+ async submitScore(board, member, score, opts = {}) {
52
+ const body = {
53
+ member,
54
+ score,
55
+ segments: opts.segments,
56
+ idem: opts.idem,
57
+ };
58
+ if (this.opts.signingSecret) {
59
+ const ts = Math.floor(Date.now() / 1000);
60
+ const nonce = randomNonce();
61
+ body.ts = ts;
62
+ body.nonce = nonce;
63
+ body.sig = await signSubmission(this.opts.signingSecret, this.opts.appId ?? "", board, member, score, ts, nonce);
64
+ }
65
+ const r = await this.send("POST", `/v1/boards/${enc(board)}/scores`, body);
66
+ return { accepted: !!r.accepted, duplicate: !!r.duplicate };
67
+ }
68
+ /** A member's rank. Throws {@link NotFoundError} if absent. */
69
+ async getRank(board, member, q = {}) {
70
+ return this.send("GET", `/v1/boards/${enc(board)}/rank${qs({ member, ...q })}`);
71
+ }
72
+ /** Top N entries (rank 1..N). */
73
+ async getTop(board, n, q = {}) {
74
+ const r = await this.send("GET", `/v1/boards/${enc(board)}/top${qs({ n: String(n), ...q })}`);
75
+ return r.entries ?? [];
76
+ }
77
+ /** A page of the ranking starting at offset (0-based). */
78
+ async getPage(board, offset, limit, q = {}) {
79
+ const r = await this.send("GET", `/v1/boards/${enc(board)}/page${qs({ offset: String(offset), limit: String(limit), ...q })}`);
80
+ return r.entries ?? [];
81
+ }
82
+ /** The member plus up to k entries on each side of it. */
83
+ async getNeighbors(board, member, k, q = {}) {
84
+ const r = await this.send("GET", `/v1/boards/${enc(board)}/neighbors${qs({ member, k: String(k), ...q })}`);
85
+ return r.entries ?? [];
86
+ }
87
+ /** Rank an explicit set of members against each other (a friend leaderboard). */
88
+ async getFriends(board, members, q = {}) {
89
+ const r = await this.send("POST", `/v1/boards/${enc(board)}/friends${qs({ ...q })}`, { members });
90
+ return r.entries ?? [];
91
+ }
92
+ async send(method, path, body) {
93
+ const headers = { Authorization: `Bearer ${this.apiKey}` };
94
+ let bodyStr;
95
+ if (body !== undefined) {
96
+ headers["Content-Type"] = "application/json";
97
+ bodyStr = JSON.stringify(body);
98
+ }
99
+ const resp = await this.fetchFn(this.baseUrl + path, { method, headers, body: bodyStr });
100
+ const text = await resp.text();
101
+ if (resp.status === 404)
102
+ throw new NotFoundError(text);
103
+ if (!resp.ok)
104
+ throw new LeaderboardError(resp.status, `${method} ${path} -> ${resp.status}: ${text}`);
105
+ return text ? JSON.parse(text) : {};
106
+ }
107
+ }
108
+ function enc(s) {
109
+ return encodeURIComponent(s);
110
+ }
111
+ function qs(params) {
112
+ const p = new URLSearchParams();
113
+ for (const [k, v] of Object.entries(params)) {
114
+ if (v != null && v !== "")
115
+ p.set(k, v);
116
+ }
117
+ const s = p.toString();
118
+ return s ? `?${s}` : "";
119
+ }
120
+ function randomNonce() {
121
+ const a = new Uint8Array(16);
122
+ globalThis.crypto.getRandomValues(a);
123
+ return [...a].map((b) => b.toString(16).padStart(2, "0")).join("");
124
+ }
125
+ /**
126
+ * Produces an HMAC-SHA256 submission signature matching the server (pkg/trust).
127
+ * Exported for trusted server-side signing and testing. Matches Go's float
128
+ * formatting for the common integer-score case. (Very large/small fractional
129
+ * scores may format with an exponent in JS and would not match — sign integer
130
+ * scores, or sign server-side.)
131
+ */
132
+ export async function signSubmission(secret, app, board, member, score, ts, nonce) {
133
+ const canonical = [app, board, member, String(score), String(ts), nonce].join("\n");
134
+ const encb = new TextEncoder();
135
+ const key = await globalThis.crypto.subtle.importKey("raw", encb.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
136
+ const sig = await globalThis.crypto.subtle.sign("HMAC", key, encb.encode(canonical));
137
+ return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
138
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@openleaderboard/sdk",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript client for the OpenLeaderboard API (browser + Node 18+).",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": ["dist"],
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json",
18
+ "typecheck": "tsc -p tsconfig.json --noEmit",
19
+ "test:e2e": "node test/e2e.mjs",
20
+ "test:hmac": "node test/hmac.mjs",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "keywords": ["leaderboard", "gaming", "ranking", "api"],
24
+ "license": "Apache-2.0",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/kodeni-am/leaderboard.git",
28
+ "directory": "sdk/typescript"
29
+ },
30
+ "homepage": "https://github.com/kodeni-am/leaderboard/tree/main/sdk/typescript#readme",
31
+ "bugs": { "url": "https://github.com/kodeni-am/leaderboard/issues" },
32
+ "publishConfig": { "access": "public" },
33
+ "engines": { "node": ">=18" },
34
+ "devDependencies": { "typescript": "^5.5.0" }
35
+ }