@pithy-sh/leaderboard 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 +21 -0
- package/README.md +17 -0
- package/docs/costs.md +102 -0
- package/package.json +52 -0
- package/pithy.manifest.json +36 -0
- package/src/board/registry.ts +94 -0
- package/src/capability.ts +93 -0
- package/src/cloudflare-test.d.ts +14 -0
- package/src/config/boardKey.ts +16 -0
- package/src/config/config.ts +261 -0
- package/src/data/boardRecord.ts +39 -0
- package/src/data/entry.ts +59 -0
- package/src/data/lock.ts +26 -0
- package/src/data/tables.ts +43 -0
- package/src/entry/store.ts +184 -0
- package/src/error/errors.ts +125 -0
- package/src/http/guard.ts +61 -0
- package/src/http/handlers.ts +221 -0
- package/src/http/routes.ts +202 -0
- package/src/http/schemas.ts +126 -0
- package/src/index.ts +37 -0
- package/src/migrations/0001_entries.ts +86 -0
- package/src/rank/lock.ts +75 -0
- package/src/rank/materialize.ts +206 -0
- package/src/rank/query.ts +208 -0
- package/src/rank/retryPolicy.ts +39 -0
- package/src/rank/segment.ts +37 -0
- package/src/rank/tiers.ts +29 -0
- package/src/rank/worker.entry.ts +133 -0
- package/src/rank/worker.ts +94 -0
- package/src/rank/wrangler.jsonc +53 -0
- package/src/retention/prune.ts +104 -0
- package/src/seeds/example.ts +72 -0
- package/src/session/bookmark.ts +68 -0
- package/src/version.generated.ts +16 -0
- package/src/window/schedule.ts +120 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `@pithy-sh/leaderboard` throw sugar. The `leaderboard/*` codes live in core's closed `KitErrorPayload`
|
|
9
|
+
* union (CLAUDE.md §Errors: capabilities add their codes to the one union); these subclasses are the
|
|
10
|
+
* package-local vehicles that set one of those members — the same pattern as `@pithy-sh/media` and
|
|
11
|
+
* `@pithy-sh/turnstile`. Runtime code in this package throws one of these, never a plain `new Error`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Variable parts each subclass accepts; `code`/`status` are fixed by the subclass. */
|
|
15
|
+
interface LeaderboardErrorArgs {
|
|
16
|
+
/** Override the public, safe-to-expose message. */
|
|
17
|
+
message?: string;
|
|
18
|
+
/** A remediation hint (CLI action line). */
|
|
19
|
+
action?: string;
|
|
20
|
+
/** Internal context for logs + audit. Never serialized to clients. */
|
|
21
|
+
detail?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Values a translating client interpolates into its own wording for this code. Client-facing, so —
|
|
24
|
+
* unlike `action` and `detail` — these cross the boundary with `message`.
|
|
25
|
+
*/
|
|
26
|
+
params?: MessageParams;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class LeaderboardBoardNotFoundError extends PithyError {
|
|
30
|
+
constructor(args: LeaderboardErrorArgs = {}, options?: { cause?: unknown }) {
|
|
31
|
+
super(
|
|
32
|
+
{
|
|
33
|
+
code: "leaderboard/board_not_found",
|
|
34
|
+
status: 404,
|
|
35
|
+
message: args.message ?? "That board does not exist.",
|
|
36
|
+
action: args.action ?? "Check the board key against the `boards` list in pithy.config.ts.",
|
|
37
|
+
detail: args.detail,
|
|
38
|
+
params: args.params,
|
|
39
|
+
},
|
|
40
|
+
options,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class LeaderboardEntryNotFoundError extends PithyError {
|
|
46
|
+
constructor(args: LeaderboardErrorArgs = {}, options?: { cause?: unknown }) {
|
|
47
|
+
super(
|
|
48
|
+
{
|
|
49
|
+
code: "leaderboard/entry_not_found",
|
|
50
|
+
status: 404,
|
|
51
|
+
message: args.message ?? "You have no score on that board yet.",
|
|
52
|
+
action: args.action ?? "Submit a score first.",
|
|
53
|
+
detail: args.detail,
|
|
54
|
+
params: args.params,
|
|
55
|
+
},
|
|
56
|
+
options,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class LeaderboardScoreRejectedError extends PithyError {
|
|
62
|
+
constructor(args: LeaderboardErrorArgs = {}, options?: { cause?: unknown }) {
|
|
63
|
+
super(
|
|
64
|
+
{
|
|
65
|
+
code: "leaderboard/score_rejected",
|
|
66
|
+
status: 400,
|
|
67
|
+
message: args.message ?? "That score is outside the board's allowed range.",
|
|
68
|
+
action: args.action ?? "Submit a score within the board's min and max.",
|
|
69
|
+
detail: args.detail,
|
|
70
|
+
params: args.params,
|
|
71
|
+
},
|
|
72
|
+
options,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class LeaderboardSubmitForbiddenError extends PithyError {
|
|
78
|
+
constructor(args: LeaderboardErrorArgs = {}, options?: { cause?: unknown }) {
|
|
79
|
+
super(
|
|
80
|
+
{
|
|
81
|
+
code: "leaderboard/submit_forbidden",
|
|
82
|
+
status: 403,
|
|
83
|
+
message: args.message ?? "This session may not submit scores.",
|
|
84
|
+
action:
|
|
85
|
+
args.action ??
|
|
86
|
+
"Submit from your trusted server with the board's submit scope, or set `serverAuthoritative: false` to let clients post directly.",
|
|
87
|
+
detail: args.detail,
|
|
88
|
+
params: args.params,
|
|
89
|
+
},
|
|
90
|
+
options,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class LeaderboardBoardImmutableError extends PithyError {
|
|
96
|
+
constructor(args: LeaderboardErrorArgs = {}, options?: { cause?: unknown }) {
|
|
97
|
+
super(
|
|
98
|
+
{
|
|
99
|
+
code: "leaderboard/board_immutable",
|
|
100
|
+
status: 409,
|
|
101
|
+
message: args.message ?? "That board's definition cannot change.",
|
|
102
|
+
action: args.action ?? "Give the changed board a new key. The old board keeps its recorded scores.",
|
|
103
|
+
detail: args.detail,
|
|
104
|
+
params: args.params,
|
|
105
|
+
},
|
|
106
|
+
options,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class LeaderboardInvalidScheduleError extends PithyError {
|
|
112
|
+
constructor(args: LeaderboardErrorArgs = {}, options?: { cause?: unknown }) {
|
|
113
|
+
super(
|
|
114
|
+
{
|
|
115
|
+
code: "leaderboard/invalid_schedule",
|
|
116
|
+
status: 500,
|
|
117
|
+
message: args.message ?? "That board's window schedule is invalid.",
|
|
118
|
+
action: args.action ?? "Fix the board's `window` CRON expression in pithy.config.ts.",
|
|
119
|
+
detail: args.detail,
|
|
120
|
+
params: args.params,
|
|
121
|
+
},
|
|
122
|
+
options,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import { ForbiddenError, UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { MiddlewareHandler } from "hono";
|
|
7
|
+
import { LeaderboardSubmitForbiddenError } from "../error/errors";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Route guards.
|
|
11
|
+
*
|
|
12
|
+
* These depend on the core `AuthContext` seam and nothing else — `@pithy-sh/auth` populates `c.var.auth`,
|
|
13
|
+
* and leaderboard never reaches into its internals (CLAUDE.md §HTTP). Without auth installed `c.var.auth`
|
|
14
|
+
* is null and every route below is denied, which is the correct failure: an entry with no stable player
|
|
15
|
+
* id cannot be keyed, upserted, or rate-limited, so an unauthenticated board is not a degraded board but
|
|
16
|
+
* an append log of unattributable scores.
|
|
17
|
+
*/
|
|
18
|
+
export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
|
|
19
|
+
return async (c, next) => {
|
|
20
|
+
if (!c.var.auth) {
|
|
21
|
+
throw new UnauthorizedError({
|
|
22
|
+
message: "Authentication required.",
|
|
23
|
+
action: "Sign in and retry with a valid session or bearer token.",
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
await next();
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Require the board's submit scope while `serverAuthoritative` is on.
|
|
32
|
+
*
|
|
33
|
+
* Every vendor that offers server-authoritative writes ships it **off**; Pithy ships it **on**. The scope
|
|
34
|
+
* is the seam: mint it for your own trusted server's token and never for a player's, and a device cannot
|
|
35
|
+
* post a score it invented. Turning `serverAuthoritative` off removes this gate entirely — which is the
|
|
36
|
+
* vendor default, and is why it is not ours.
|
|
37
|
+
*/
|
|
38
|
+
export function requireSubmitScope(serverAuthoritative: boolean, scope: string): MiddlewareHandler<PithyHonoEnv> {
|
|
39
|
+
return async (c, next) => {
|
|
40
|
+
if (serverAuthoritative && !c.var.auth?.scopes.includes(scope)) {
|
|
41
|
+
throw new LeaderboardSubmitForbiddenError({
|
|
42
|
+
detail: `Submit requires the ${scope} scope; this session carries [${c.var.auth?.scopes.join(", ") ?? ""}].`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
await next();
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Require the admin scope for moderation — hiding or removing another player's entry. */
|
|
50
|
+
export function requireAdminScope(scope: string): MiddlewareHandler<PithyHonoEnv> {
|
|
51
|
+
return async (c, next) => {
|
|
52
|
+
if (!c.var.auth?.scopes.includes(scope)) {
|
|
53
|
+
throw new ForbiddenError({
|
|
54
|
+
message: "This session may not moderate leaderboard entries.",
|
|
55
|
+
action: `Retry with a token carrying the ${scope} scope.`,
|
|
56
|
+
detail: `Moderation requires the ${scope} scope; this session carries [${c.var.auth?.scopes.join(", ") ?? ""}].`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
await next();
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { assertBoardDefinition } from "../board/registry";
|
|
5
|
+
import type { LeaderboardBoard, LeaderboardConfig } from "../config/config";
|
|
6
|
+
import type { LeaderboardDatabase } from "../data/tables";
|
|
7
|
+
import { entryStore } from "../entry/store";
|
|
8
|
+
import {
|
|
9
|
+
LeaderboardBoardNotFoundError,
|
|
10
|
+
LeaderboardEntryNotFoundError,
|
|
11
|
+
LeaderboardScoreRejectedError,
|
|
12
|
+
} from "../error/errors";
|
|
13
|
+
import { entriesAround, type RankedEntry, rankOf, topEntries } from "../rank/query";
|
|
14
|
+
import { windowKeyAt } from "../window/schedule";
|
|
15
|
+
import type { HideBody, SegmentBody, SubmitScoreBody, VisibilityBody } from "./schemas";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The leaderboard handlers — pure functions over injected deps. Nothing here touches a Hono `Context`;
|
|
19
|
+
* the routes unwrap the request and hand the pieces down, which is what makes every case below testable
|
|
20
|
+
* without a request at all. Nothing here parses, either: the route line declares each shape with
|
|
21
|
+
* `zValidator`, so a handler receives values that are already the type it names.
|
|
22
|
+
*/
|
|
23
|
+
export interface HandlerDeps {
|
|
24
|
+
config: LeaderboardConfig;
|
|
25
|
+
db: LeaderboardDatabase;
|
|
26
|
+
/** The authenticated player, from the core AuthContext seam. Never from the request body. */
|
|
27
|
+
userId: string;
|
|
28
|
+
now: () => Date;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The configured board, or a 404. Board keys come from config, so an unknown key is not a lookup miss. */
|
|
32
|
+
function board(deps: HandlerDeps, key: string): LeaderboardBoard {
|
|
33
|
+
const found = deps.config.boards.find((b) => b.key === key);
|
|
34
|
+
if (!found) throw new LeaderboardBoardNotFoundError({ detail: `No board configured with key "${key}".` });
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Which window a read applies to: the one the client named, or the one open now.
|
|
40
|
+
*
|
|
41
|
+
* A named window is not validated against the schedule. A key that never was a window simply selects no
|
|
42
|
+
* entries, and letting a client read a closed window by key is the feature — that history is in the
|
|
43
|
+
* adopter's own D1, retained on their terms.
|
|
44
|
+
*/
|
|
45
|
+
function readWindow(deps: HandlerDeps, b: LeaderboardBoard, requested: string | undefined): string {
|
|
46
|
+
return requested ?? windowKeyAt(b.window, deps.now());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface BoardPage {
|
|
50
|
+
board: string;
|
|
51
|
+
window: string;
|
|
52
|
+
entries: RankedEntry[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function submitScore(
|
|
56
|
+
deps: HandlerDeps,
|
|
57
|
+
boardKey: string,
|
|
58
|
+
body: SubmitScoreBody,
|
|
59
|
+
): Promise<{ window: string }> {
|
|
60
|
+
const b = board(deps, boardKey);
|
|
61
|
+
const { score } = body;
|
|
62
|
+
// Server-side bounds are the whole demonstrated anti-cheat baseline. Checked before anything is
|
|
63
|
+
// written, so a rejected score leaves no trace on the board.
|
|
64
|
+
if (b.min !== undefined && score < b.min) {
|
|
65
|
+
throw new LeaderboardScoreRejectedError({ detail: `Score ${score} is below board "${b.key}" min ${b.min}.` });
|
|
66
|
+
}
|
|
67
|
+
if (b.max !== undefined && score > b.max) {
|
|
68
|
+
throw new LeaderboardScoreRejectedError({ detail: `Score ${score} is above board "${b.key}" max ${b.max}.` });
|
|
69
|
+
}
|
|
70
|
+
const now = deps.now();
|
|
71
|
+
// Guard the board's immutable fields before the first write of each board, not after.
|
|
72
|
+
await assertBoardDefinition(deps.db, b, now);
|
|
73
|
+
const window = windowKeyAt(b.window, now);
|
|
74
|
+
await entryStore(deps.db).submit(b, window, deps.userId, score, now, deps.config.visibleByDefault);
|
|
75
|
+
return { window };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function readTop(
|
|
79
|
+
deps: HandlerDeps,
|
|
80
|
+
boardKey: string,
|
|
81
|
+
query: { window?: string; limit: number; offset: number },
|
|
82
|
+
): Promise<BoardPage> {
|
|
83
|
+
const b = board(deps, boardKey);
|
|
84
|
+
const window = readWindow(deps, b, query.window);
|
|
85
|
+
return { board: b.key, window, entries: await topEntries(deps.db, b, window, query.limit, query.offset) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function readSegment(deps: HandlerDeps, boardKey: string, body: SegmentBody): Promise<BoardPage> {
|
|
89
|
+
const b = board(deps, boardKey);
|
|
90
|
+
const { userIds, limit, offset, window: requested } = body;
|
|
91
|
+
const window = readWindow(deps, b, requested);
|
|
92
|
+
return {
|
|
93
|
+
board: b.key,
|
|
94
|
+
window,
|
|
95
|
+
entries: await topEntries(deps.db, b, window, limit, offset, { segment: userIds }),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface OwnRank {
|
|
100
|
+
board: string;
|
|
101
|
+
window: string;
|
|
102
|
+
userId: string;
|
|
103
|
+
score: number;
|
|
104
|
+
rank: number | null;
|
|
105
|
+
tier: string | null;
|
|
106
|
+
visible: boolean;
|
|
107
|
+
/** True when `rank` came from the stored column and may lag the score beside it. */
|
|
108
|
+
rankStale: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function readOwnRank(deps: HandlerDeps, boardKey: string, query: { window?: string }): Promise<OwnRank> {
|
|
112
|
+
const b = board(deps, boardKey);
|
|
113
|
+
const window = readWindow(deps, b, query.window);
|
|
114
|
+
const materialized = typeof deps.config.rank === "object";
|
|
115
|
+
const own = await rankOf(deps.db, b, window, deps.userId, materialized);
|
|
116
|
+
if (!own) {
|
|
117
|
+
throw new LeaderboardEntryNotFoundError({
|
|
118
|
+
detail: `User ${deps.userId} has no entry on board "${b.key}" in window ${window}.`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
board: b.key,
|
|
123
|
+
window,
|
|
124
|
+
userId: deps.userId,
|
|
125
|
+
score: own.entry.score,
|
|
126
|
+
rank: own.rank,
|
|
127
|
+
tier: own.tier,
|
|
128
|
+
visible: own.entry.visible,
|
|
129
|
+
rankStale: materialized,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function readAround(
|
|
134
|
+
deps: HandlerDeps,
|
|
135
|
+
boardKey: string,
|
|
136
|
+
query: { window?: string; radius: number },
|
|
137
|
+
): Promise<BoardPage> {
|
|
138
|
+
const b = board(deps, boardKey);
|
|
139
|
+
const window = readWindow(deps, b, query.window);
|
|
140
|
+
return { board: b.key, window, entries: await entriesAround(deps.db, b, window, deps.userId, query.radius) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function setOwnVisibility(
|
|
144
|
+
deps: HandlerDeps,
|
|
145
|
+
boardKey: string,
|
|
146
|
+
query: { window?: string },
|
|
147
|
+
body: VisibilityBody,
|
|
148
|
+
): Promise<{ visible: boolean }> {
|
|
149
|
+
const b = board(deps, boardKey);
|
|
150
|
+
const { visible } = body;
|
|
151
|
+
const window = readWindow(deps, b, query.window);
|
|
152
|
+
const updated = await entryStore(deps.db).setVisibility(b.key, window, deps.userId, visible);
|
|
153
|
+
if (!updated) {
|
|
154
|
+
throw new LeaderboardEntryNotFoundError({
|
|
155
|
+
detail: `User ${deps.userId} has no entry on board "${b.key}" in window ${window} to make ${visible ? "visible" : "hidden"}.`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return { visible };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function hideEntry(
|
|
162
|
+
deps: HandlerDeps,
|
|
163
|
+
boardKey: string,
|
|
164
|
+
targetUserId: string,
|
|
165
|
+
query: { window?: string },
|
|
166
|
+
body: HideBody,
|
|
167
|
+
): Promise<{ hidden: boolean }> {
|
|
168
|
+
const b = board(deps, boardKey);
|
|
169
|
+
const { hidden } = body;
|
|
170
|
+
const window = readWindow(deps, b, query.window);
|
|
171
|
+
const updated = await entryStore(deps.db).hide(b.key, window, targetUserId, hidden);
|
|
172
|
+
if (!updated) {
|
|
173
|
+
throw new LeaderboardEntryNotFoundError({
|
|
174
|
+
message: "That entry does not exist.",
|
|
175
|
+
detail: `No entry for user ${targetUserId} on board "${b.key}" in window ${window}.`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return { hidden };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function removeEntry(
|
|
182
|
+
deps: HandlerDeps,
|
|
183
|
+
boardKey: string,
|
|
184
|
+
targetUserId: string,
|
|
185
|
+
query: { window?: string },
|
|
186
|
+
): Promise<{ removed: boolean }> {
|
|
187
|
+
const b = board(deps, boardKey);
|
|
188
|
+
const window = readWindow(deps, b, query.window);
|
|
189
|
+
const removed = await entryStore(deps.db).remove(b.key, window, targetUserId);
|
|
190
|
+
if (!removed) {
|
|
191
|
+
throw new LeaderboardEntryNotFoundError({
|
|
192
|
+
message: "That entry does not exist.",
|
|
193
|
+
detail: `No entry for user ${targetUserId} on board "${b.key}" in window ${window}.`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return { removed };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The board set, as configured — what a client needs to render a board picker. */
|
|
200
|
+
export function listBoards(deps: HandlerDeps): {
|
|
201
|
+
boards: Array<{
|
|
202
|
+
key: string;
|
|
203
|
+
store: string;
|
|
204
|
+
direction: string;
|
|
205
|
+
aggregation: string;
|
|
206
|
+
window: string | null;
|
|
207
|
+
tiers: Array<{ key: string; from: number }>;
|
|
208
|
+
}>;
|
|
209
|
+
} {
|
|
210
|
+
return {
|
|
211
|
+
boards: deps.config.boards.map((b) => ({
|
|
212
|
+
key: b.key,
|
|
213
|
+
// Always `d1` today; surfaced so a client can tell an exact board from a future approximate one.
|
|
214
|
+
store: b.store,
|
|
215
|
+
direction: b.direction,
|
|
216
|
+
aggregation: b.aggregation,
|
|
217
|
+
window: b.window ?? null,
|
|
218
|
+
tiers: b.tiers?.map((t) => ({ key: t.key, from: t.from })) ?? [],
|
|
219
|
+
})),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { zValidator } from "@hono/zod-validator";
|
|
6
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
7
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
8
|
+
import { validationHook } from "@pithy-sh/core/src/http/validation";
|
|
9
|
+
import type { Context, Hono } from "hono";
|
|
10
|
+
import type { LeaderboardConfig } from "../config/config";
|
|
11
|
+
import { BOOKMARK_HEADER, leaderboardSession, readBookmark } from "../session/bookmark";
|
|
12
|
+
import { requireAdminScope, requireAuth, requireSubmitScope } from "./guard";
|
|
13
|
+
import {
|
|
14
|
+
type HandlerDeps,
|
|
15
|
+
hideEntry,
|
|
16
|
+
listBoards,
|
|
17
|
+
readAround,
|
|
18
|
+
readOwnRank,
|
|
19
|
+
readSegment,
|
|
20
|
+
readTop,
|
|
21
|
+
removeEntry,
|
|
22
|
+
setOwnVisibility,
|
|
23
|
+
submitScore,
|
|
24
|
+
} from "./handlers";
|
|
25
|
+
import {
|
|
26
|
+
AroundQuery,
|
|
27
|
+
BoardParam,
|
|
28
|
+
EntryParam,
|
|
29
|
+
HideBody,
|
|
30
|
+
SegmentBody,
|
|
31
|
+
SubmitScoreBody,
|
|
32
|
+
TopQuery,
|
|
33
|
+
VisibilityBody,
|
|
34
|
+
WindowQuery,
|
|
35
|
+
} from "./schemas";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The leaderboard routes, each declaring both how a caller is verified and what it may send:
|
|
39
|
+
*
|
|
40
|
+
* GET /leaderboard → list boards (bearer | session) — takes nothing
|
|
41
|
+
* POST /leaderboard/:board → submit a score (bearer | session + submit scope)
|
|
42
|
+
* param BoardParam, json SubmitScoreBody
|
|
43
|
+
* GET /leaderboard/:board/top → top-N page (bearer | session)
|
|
44
|
+
* param BoardParam, query TopQuery
|
|
45
|
+
* POST /leaderboard/:board/segment → friends/cohort (bearer | session)
|
|
46
|
+
* param BoardParam, json SegmentBody
|
|
47
|
+
* GET /leaderboard/:board/me → my rank (bearer | session)
|
|
48
|
+
* param BoardParam, query WindowQuery
|
|
49
|
+
* GET /leaderboard/:board/around → around me (bearer | session)
|
|
50
|
+
* param BoardParam, query AroundQuery
|
|
51
|
+
* PUT /leaderboard/:board/me/visibility → my consent (bearer | session)
|
|
52
|
+
* param BoardParam, query WindowQuery, json VisibilityBody
|
|
53
|
+
* PUT /leaderboard/:board/entries/:userId/hidden → hide entry (bearer | session + admin scope)
|
|
54
|
+
* param EntryParam, query WindowQuery, json HideBody
|
|
55
|
+
* DELETE /leaderboard/:board/entries/:userId → remove entry (bearer | session + admin scope)
|
|
56
|
+
* param EntryParam, query WindowQuery
|
|
57
|
+
*
|
|
58
|
+
* Every route is gated by {@link requireAuth} — there is no public leaderboard surface, because an entry
|
|
59
|
+
* with no authenticated player has nothing to key on. Submit additionally requires the board's submit
|
|
60
|
+
* scope while `serverAuthoritative` is on (the default), and the two moderation routes require the admin
|
|
61
|
+
* scope. Turnstile, if the adopter runs it, stacks on top as middleware — it is a humanity check, not an
|
|
62
|
+
* identity, so it never replaces any of the above.
|
|
63
|
+
*
|
|
64
|
+
* Validators sit **after** the guards, never before: who you are is decided before what you sent, so an
|
|
65
|
+
* unauthorised request with a malformed body is still a 401. They sit **before** the handler, which is
|
|
66
|
+
* what moves a malformed request ahead of the board lookup — an unknown board sent a bad body now answers
|
|
67
|
+
* 400 rather than 404. The request was never well-formed enough to have a board.
|
|
68
|
+
*/
|
|
69
|
+
export interface LeaderboardRoutesOptions {
|
|
70
|
+
config: LeaderboardConfig;
|
|
71
|
+
basePath?: string;
|
|
72
|
+
/** Test seam: resolve handler deps from the request context. Defaults to the env-based resolver. */
|
|
73
|
+
resolveDeps?: (c: Context<PithyHonoEnv>, write: boolean) => HandlerDeps & { bookmark(): string | null };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function defaultResolveDeps(config: LeaderboardConfig) {
|
|
77
|
+
return (c: Context<PithyHonoEnv>, write: boolean): HandlerDeps & { bookmark(): string | null } => {
|
|
78
|
+
const d1 = (c.env as Record<string, unknown>).DB as D1Database | undefined;
|
|
79
|
+
if (!d1) {
|
|
80
|
+
throw new InternalError({
|
|
81
|
+
message: "The leaderboard is not configured.",
|
|
82
|
+
action: "Bind a D1 database named DB in wrangler.jsonc.",
|
|
83
|
+
detail: "The leaderboard capability requires a `DB` D1 binding; none was present on env.",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// A write anchors at the primary and hands back a bookmark; a read follows the client's bookmark
|
|
87
|
+
// when it sent one. That round trip is what makes a player see their own submission.
|
|
88
|
+
const session = leaderboardSession(
|
|
89
|
+
d1,
|
|
90
|
+
readBookmark(c.req.raw.headers),
|
|
91
|
+
write ? "first-primary" : "first-unconstrained",
|
|
92
|
+
);
|
|
93
|
+
const auth = c.var.auth;
|
|
94
|
+
if (!auth) {
|
|
95
|
+
throw new InternalError({ detail: "requireAuth() must run before a leaderboard handler resolves deps." });
|
|
96
|
+
}
|
|
97
|
+
return { config, db: session.db, userId: auth.userId, now: () => new Date(), bookmark: session.bookmark };
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function registerLeaderboardRoutes(options: LeaderboardRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
|
|
102
|
+
const base = options.basePath ?? "/leaderboard";
|
|
103
|
+
const resolve = options.resolveDeps ?? defaultResolveDeps(options.config);
|
|
104
|
+
const { serverAuthoritative, submitScope, adminScope } = options.config;
|
|
105
|
+
|
|
106
|
+
/** Run a handler and return its JSON, attaching the session's bookmark for the client to echo back. */
|
|
107
|
+
const respond = async <T>(
|
|
108
|
+
c: Context<PithyHonoEnv>,
|
|
109
|
+
write: boolean,
|
|
110
|
+
run: (deps: HandlerDeps) => Promise<T> | T,
|
|
111
|
+
status: 200 | 201 = 200,
|
|
112
|
+
) => {
|
|
113
|
+
const deps = resolve(c, write);
|
|
114
|
+
const body = await run(deps);
|
|
115
|
+
const bookmark = deps.bookmark();
|
|
116
|
+
if (bookmark) c.header(BOOKMARK_HEADER, bookmark);
|
|
117
|
+
return c.json(body as object, status);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return (app) => {
|
|
121
|
+
app.get(base, requireAuth(), (c) => respond(c, false, (deps) => listBoards(deps)));
|
|
122
|
+
|
|
123
|
+
// Static segments are registered before `:board`-rooted reads so they cannot be shadowed.
|
|
124
|
+
app.post(
|
|
125
|
+
`${base}/:board/segment`,
|
|
126
|
+
requireAuth(),
|
|
127
|
+
zValidator("param", BoardParam, validationHook),
|
|
128
|
+
zValidator("json", SegmentBody, validationHook),
|
|
129
|
+
(c) => respond(c, false, (deps) => readSegment(deps, c.req.valid("param").board, c.req.valid("json"))),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
app.get(
|
|
133
|
+
`${base}/:board/top`,
|
|
134
|
+
requireAuth(),
|
|
135
|
+
zValidator("param", BoardParam, validationHook),
|
|
136
|
+
zValidator("query", TopQuery, validationHook),
|
|
137
|
+
(c) => respond(c, false, (deps) => readTop(deps, c.req.valid("param").board, c.req.valid("query"))),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
app.get(
|
|
141
|
+
`${base}/:board/me`,
|
|
142
|
+
requireAuth(),
|
|
143
|
+
zValidator("param", BoardParam, validationHook),
|
|
144
|
+
zValidator("query", WindowQuery, validationHook),
|
|
145
|
+
(c) => respond(c, false, (deps) => readOwnRank(deps, c.req.valid("param").board, c.req.valid("query"))),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
app.get(
|
|
149
|
+
`${base}/:board/around`,
|
|
150
|
+
requireAuth(),
|
|
151
|
+
zValidator("param", BoardParam, validationHook),
|
|
152
|
+
zValidator("query", AroundQuery, validationHook),
|
|
153
|
+
(c) => respond(c, false, (deps) => readAround(deps, c.req.valid("param").board, c.req.valid("query"))),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
app.put(
|
|
157
|
+
`${base}/:board/me/visibility`,
|
|
158
|
+
requireAuth(),
|
|
159
|
+
zValidator("param", BoardParam, validationHook),
|
|
160
|
+
zValidator("query", WindowQuery, validationHook),
|
|
161
|
+
zValidator("json", VisibilityBody, validationHook),
|
|
162
|
+
(c) =>
|
|
163
|
+
respond(c, true, (deps) =>
|
|
164
|
+
setOwnVisibility(deps, c.req.valid("param").board, c.req.valid("query"), c.req.valid("json")),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
app.put(
|
|
169
|
+
`${base}/:board/entries/:userId/hidden`,
|
|
170
|
+
requireAuth(),
|
|
171
|
+
requireAdminScope(adminScope),
|
|
172
|
+
zValidator("param", EntryParam, validationHook),
|
|
173
|
+
zValidator("query", WindowQuery, validationHook),
|
|
174
|
+
zValidator("json", HideBody, validationHook),
|
|
175
|
+
(c) => {
|
|
176
|
+
const { board, userId } = c.req.valid("param");
|
|
177
|
+
return respond(c, true, (deps) => hideEntry(deps, board, userId, c.req.valid("query"), c.req.valid("json")));
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
app.delete(
|
|
182
|
+
`${base}/:board/entries/:userId`,
|
|
183
|
+
requireAuth(),
|
|
184
|
+
requireAdminScope(adminScope),
|
|
185
|
+
zValidator("param", EntryParam, validationHook),
|
|
186
|
+
zValidator("query", WindowQuery, validationHook),
|
|
187
|
+
(c) => {
|
|
188
|
+
const { board, userId } = c.req.valid("param");
|
|
189
|
+
return respond(c, true, (deps) => removeEntry(deps, board, userId, c.req.valid("query")));
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
app.post(
|
|
194
|
+
`${base}/:board`,
|
|
195
|
+
requireAuth(),
|
|
196
|
+
requireSubmitScope(serverAuthoritative, submitScope),
|
|
197
|
+
zValidator("param", BoardParam, validationHook),
|
|
198
|
+
zValidator("json", SubmitScoreBody, validationHook),
|
|
199
|
+
(c) => respond(c, true, (deps) => submitScore(deps, c.req.valid("param").board, c.req.valid("json")), 201),
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
}
|