@orakl/shared 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.
Files changed (56) hide show
  1. package/LICENSE +662 -0
  2. package/dist/analytics.d.ts +21 -0
  3. package/dist/analytics.js +21 -0
  4. package/dist/avatar-helpers.d.ts +46 -0
  5. package/dist/avatar-helpers.js +69 -0
  6. package/dist/avatars.d.ts +6 -0
  7. package/dist/avatars.js +138 -0
  8. package/dist/badges.d.ts +126 -0
  9. package/dist/badges.js +667 -0
  10. package/dist/constants/auth.constants.d.ts +26 -0
  11. package/dist/constants/auth.constants.js +94 -0
  12. package/dist/constants/feature-flags.constants.d.ts +138 -0
  13. package/dist/constants/feature-flags.constants.js +119 -0
  14. package/dist/constants/font.constants.d.ts +9 -0
  15. package/dist/constants/font.constants.js +14 -0
  16. package/dist/constants/lobby.constants.d.ts +2 -0
  17. package/dist/constants/lobby.constants.js +438 -0
  18. package/dist/constants/patterns.constants.d.ts +4 -0
  19. package/dist/constants/patterns.constants.js +4 -0
  20. package/dist/constants/theme.constants.d.ts +24 -0
  21. package/dist/constants/theme.constants.js +69 -0
  22. package/dist/env.d.ts +3 -0
  23. package/dist/env.js +5 -0
  24. package/dist/font.d.ts +4 -0
  25. package/dist/font.js +8 -0
  26. package/dist/history-range.d.ts +13 -0
  27. package/dist/history-range.js +29 -0
  28. package/dist/index.d.ts +23 -0
  29. package/dist/index.js +26 -0
  30. package/dist/lobby.d.ts +16 -0
  31. package/dist/lobby.js +50 -0
  32. package/dist/manage-questions.d.ts +52 -0
  33. package/dist/manage-questions.js +26 -0
  34. package/dist/math.d.ts +27 -0
  35. package/dist/math.js +30 -0
  36. package/dist/murmurhash.d.ts +9 -0
  37. package/dist/murmurhash.js +52 -0
  38. package/dist/pricing.d.ts +32 -0
  39. package/dist/pricing.js +32 -0
  40. package/dist/profile-tabs.d.ts +8 -0
  41. package/dist/profile-tabs.js +18 -0
  42. package/dist/quiz-routes.d.ts +28 -0
  43. package/dist/quiz-routes.js +65 -0
  44. package/dist/sanitize.d.ts +38 -0
  45. package/dist/sanitize.js +109 -0
  46. package/dist/schemas/question.schema.d.ts +1 -0
  47. package/dist/schemas/question.schema.js +1 -0
  48. package/dist/solo-entitlements.d.ts +28 -0
  49. package/dist/solo-entitlements.js +56 -0
  50. package/dist/theme.d.ts +39 -0
  51. package/dist/theme.js +139 -0
  52. package/dist/types/auth.d.ts +143 -0
  53. package/dist/types/auth.js +28 -0
  54. package/dist/utils.d.ts +50 -0
  55. package/dist/utils.js +133 -0
  56. package/package.json +45 -0
package/dist/lobby.js ADDED
@@ -0,0 +1,50 @@
1
+ import { ADJECTIVES, NOUNS } from "./constants/lobby.constants.js";
2
+ import { murmurhash } from "./murmurhash.js";
3
+ function slugify(text) {
4
+ return text
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9\s-]/g, "")
7
+ .trim()
8
+ .replace(/\s+/g, "-");
9
+ }
10
+ /**
11
+ * Deterministic lobby code from quiz name + category IDs.
12
+ * Same name + same categories always produces the same adj-noun code.
13
+ */
14
+ export function generateLobbyCode(name, categoryIds) {
15
+ const key = `${slugify(name)},${[...categoryIds].sort().join(",")}`;
16
+ const h1 = murmurhash(key);
17
+ const h2 = murmurhash(`${key}\x00`);
18
+ const adj = ADJECTIVES[h1 % ADJECTIVES.length];
19
+ const noun = NOUNS[h2 % NOUNS.length];
20
+ return `${adj}-${noun}`;
21
+ }
22
+ /**
23
+ * Cryptographically-random integer in [0, max).
24
+ */
25
+ function cryptoRandInt(max) {
26
+ const buf = new Uint32Array(1);
27
+ crypto.getRandomValues(buf);
28
+ return buf[0] % max;
29
+ }
30
+ /**
31
+ * Finds the player with the given id in a player list.
32
+ * Returns the first match, or undefined when no player has that id.
33
+ */
34
+ export function findPlayer(players, id) {
35
+ return players.find((p) => p.id === id);
36
+ }
37
+ /**
38
+ * Random lobby code for tests / fallback when no quiz meta is available.
39
+ */
40
+ export function generateRandomLobbyCode(existingCodes) {
41
+ const maxAttempts = 100;
42
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
43
+ const adj = ADJECTIVES[cryptoRandInt(ADJECTIVES.length)];
44
+ const noun = NOUNS[cryptoRandInt(NOUNS.length)];
45
+ const code = `${adj}-${noun}`;
46
+ if (!existingCodes || !existingCodes.has(code))
47
+ return code;
48
+ }
49
+ throw new Error("Unable to generate unique lobby code");
50
+ }
@@ -0,0 +1,52 @@
1
+ import type { AuthoredQuestionFilters, OperatorQuestionRevision, Question, QuestionSnapshot } from "@orakl/protocol";
2
+ /**
3
+ * Wire shape of `GET /api/manage/questions/[id]` — what the dashboard's
4
+ * details view renders. `snapshot` is the same frozen shape the flag review
5
+ * uses; `matchItems` carries the pairs of an image-matching question in
6
+ * stored order, since the snapshot flattens them to `left|right` strings.
7
+ */
8
+ export interface AuthoredQuestionDto {
9
+ id: string;
10
+ category: string;
11
+ created_at: string;
12
+ updated_at: string | null;
13
+ author: {
14
+ id: string | null;
15
+ nickname: string | null;
16
+ email: string | null;
17
+ };
18
+ published_at: string | null;
19
+ published_by: string | null;
20
+ unpublished_at: string | null;
21
+ unpublished_by: string | null;
22
+ unpublish_reason: string | null;
23
+ unpublisher: {
24
+ nickname: string | null;
25
+ email: string | null;
26
+ } | null;
27
+ changed_since_publish: number;
28
+ snapshot: QuestionSnapshot;
29
+ matchItems?: {
30
+ left: string[];
31
+ right: string[];
32
+ correctPairIndex: number;
33
+ };
34
+ }
35
+ /** Wire shape of `GET /api/manage/questions/[id]/revisions`: the question
36
+ * as it is now, and every prior version newest first. */
37
+ export interface RevisionsDto {
38
+ current: Question;
39
+ revisions: OperatorQuestionRevision[];
40
+ }
41
+ /** How the dashboard names a question's author: nickname, else email, else
42
+ * "Deleted account" once the account is gone (`id` null), else "Unknown". */
43
+ export declare function authorLabel(author: {
44
+ id: string | null;
45
+ nickname: string | null;
46
+ email: string | null;
47
+ }): string;
48
+ /** Rows per page on `/manage/questions` and its list endpoint. */
49
+ export declare const AUTHORED_QUESTIONS_PAGE_SIZE = 20;
50
+ /** `?author=&category=&published=&q=&changed=` from an untrusted query
51
+ * string; the page load and the list endpoint both parse through here. */
52
+ export declare function parseAuthoredFilters(params: URLSearchParams): AuthoredQuestionFilters;
@@ -0,0 +1,26 @@
1
+ /** How the dashboard names a question's author: nickname, else email, else
2
+ * "Deleted account" once the account is gone (`id` null), else "Unknown". */
3
+ export function authorLabel(author) {
4
+ if (author.nickname)
5
+ return author.nickname;
6
+ if (author.email)
7
+ return author.email;
8
+ return author.id === null ? "Deleted account" : "Unknown";
9
+ }
10
+ /** Rows per page on `/manage/questions` and its list endpoint. */
11
+ export const AUTHORED_QUESTIONS_PAGE_SIZE = 20;
12
+ const Q_MAX_LEN = 100;
13
+ /** `?author=&category=&published=&q=&changed=` from an untrusted query
14
+ * string; the page load and the list endpoint both parse through here. */
15
+ export function parseAuthoredFilters(params) {
16
+ const authorId = params.get("author") || undefined;
17
+ const category = params.get("category") || undefined;
18
+ const publishedRaw = params.get("published");
19
+ const published = publishedRaw === "shared" || publishedRaw === "private"
20
+ ? publishedRaw
21
+ : "all";
22
+ const q = (params.get("q") ?? "").trim().slice(0, Q_MAX_LEN) || undefined;
23
+ const changedRaw = params.get("changed");
24
+ const changed = changedRaw === "1" || changedRaw === "true" ? true : undefined;
25
+ return { authorId, category, published, q, changed };
26
+ }
package/dist/math.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ export interface PercentageOffsetOptions {
2
+ defaultOffset?: number;
3
+ min?: number;
4
+ max?: number;
5
+ }
6
+ /**
7
+ * Helper function to calculate clamped values within a specified range.
8
+ * @param value - The value to clamp.
9
+ * @param min - The minimum allowed value.
10
+ * @param max - The maximum allowed value.
11
+ */
12
+ export declare const calculateClamped: (value: number, min: number, max: number) => number;
13
+ /**
14
+ * Rounds a ratio to a whole percentage.
15
+ * @param numerator - The part, or an already-computed ratio when `denominator` is omitted.
16
+ * @param denominator - The whole; defaults to 1.
17
+ * @returns `Math.round((numerator / denominator) * 100)`.
18
+ */
19
+ export declare const percent: (numerator: number, denominator?: number) => number;
20
+ /**
21
+ * Calculates a percentage offset based on a dimension and clamps it within a range.
22
+ * @param offset - The offset distance.
23
+ * @param dimension - The dimension (width or height) to calculate percentage against.
24
+ * @param options - Configuration options for default, min, and max values.
25
+ * @returns The calculated and clamped percentage.
26
+ */
27
+ export declare const calculatePercentageOffset: (offset: number, dimension: number, options?: PercentageOffsetOptions) => number;
package/dist/math.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Helper function to calculate clamped values within a specified range.
3
+ * @param value - The value to clamp.
4
+ * @param min - The minimum allowed value.
5
+ * @param max - The maximum allowed value.
6
+ */
7
+ export const calculateClamped = (value, min, max) => {
8
+ return Math.max(min, Math.min(max, value));
9
+ };
10
+ /**
11
+ * Rounds a ratio to a whole percentage.
12
+ * @param numerator - The part, or an already-computed ratio when `denominator` is omitted.
13
+ * @param denominator - The whole; defaults to 1.
14
+ * @returns `Math.round((numerator / denominator) * 100)`.
15
+ */
16
+ export const percent = (numerator, denominator = 1) => {
17
+ return Math.round((numerator / denominator) * 100);
18
+ };
19
+ /**
20
+ * Calculates a percentage offset based on a dimension and clamps it within a range.
21
+ * @param offset - The offset distance.
22
+ * @param dimension - The dimension (width or height) to calculate percentage against.
23
+ * @param options - Configuration options for default, min, and max values.
24
+ * @returns The calculated and clamped percentage.
25
+ */
26
+ export const calculatePercentageOffset = (offset, dimension, options = {}) => {
27
+ const { defaultOffset = 50, min = 0, max = 100 } = options;
28
+ const percentage = defaultOffset + (offset / dimension) * 100;
29
+ return calculateClamped(percentage, min, max);
30
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * JS Implementation of MurmurHash3 (r136) (as of May 20, 2011)
3
+ *
4
+ * @author <a href="mailto:gary.court@gmail.com">Gary Court</a>
5
+ * @see http://github.com/garycourt/murmurhash-js
6
+ * @author <a href="mailto:aappleby@gmail.com">Austin Appleby</a>
7
+ * @see http://sites.google.com/site/murmurhash/
8
+ */
9
+ export declare function murmurhash(key: string): number;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * JS Implementation of MurmurHash3 (r136) (as of May 20, 2011)
3
+ *
4
+ * @author <a href="mailto:gary.court@gmail.com">Gary Court</a>
5
+ * @see http://github.com/garycourt/murmurhash-js
6
+ * @author <a href="mailto:aappleby@gmail.com">Austin Appleby</a>
7
+ * @see http://sites.google.com/site/murmurhash/
8
+ */
9
+ export function murmurhash(key) {
10
+ const remainder = key.length & 3;
11
+ const bytes = key.length - remainder;
12
+ const c1 = 0xcc9e2d51;
13
+ const c2 = 0x1b873593;
14
+ let h1 = 0;
15
+ let i = 0;
16
+ while (i < bytes) {
17
+ let k1 = (key.charCodeAt(i) & 0xff) |
18
+ ((key.charCodeAt(++i) & 0xff) << 8) |
19
+ ((key.charCodeAt(++i) & 0xff) << 16) |
20
+ ((key.charCodeAt(++i) & 0xff) << 24);
21
+ ++i;
22
+ // Optimized with Math.imul for 32-bit integer multiplication
23
+ k1 = Math.imul(k1, c1);
24
+ k1 = (k1 << 15) | (k1 >>> 17);
25
+ k1 = Math.imul(k1, c2);
26
+ h1 ^= k1;
27
+ h1 = (h1 << 13) | (h1 >>> 19);
28
+ h1 = (Math.imul(h1, 5) + 0xe6546b64) | 0;
29
+ }
30
+ let k2 = 0;
31
+ switch (remainder) {
32
+ // biome-ignore lint/suspicious/noFallthroughSwitchClause: intentional cascade in MurmurHash
33
+ case 3:
34
+ k2 ^= (key.charCodeAt(i + 2) & 0xff) << 16;
35
+ // biome-ignore lint/suspicious/noFallthroughSwitchClause: intentional cascade in MurmurHash
36
+ case 2:
37
+ k2 ^= (key.charCodeAt(i + 1) & 0xff) << 8;
38
+ case 1:
39
+ k2 ^= key.charCodeAt(i) & 0xff;
40
+ k2 = Math.imul(k2, c1);
41
+ k2 = (k2 << 15) | (k2 >>> 17);
42
+ k2 = Math.imul(k2, c2);
43
+ h1 ^= k2;
44
+ }
45
+ h1 ^= key.length;
46
+ h1 ^= h1 >>> 16;
47
+ h1 = Math.imul(h1, 0x85ebca6b);
48
+ h1 ^= h1 >>> 13;
49
+ h1 = Math.imul(h1, 0xc2b2ae35);
50
+ h1 ^= h1 >>> 16;
51
+ return h1 >>> 0;
52
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Subscription prices, in cents, for the two Polar products (decision 7,
3
+ * issue #1190).
4
+ */
5
+ export declare const PRICES: {
6
+ readonly monthly: 500;
7
+ readonly lifetime: 10000;
8
+ };
9
+ /** Romanian VAT, the rate the estimate assumes; Polar charges the buyer's
10
+ * own rate at checkout. */
11
+ export declare const VAT_RATE = 0.21;
12
+ /** Polar Pro's percentage fee on the gross price (Polar Pro pricing, 2026-09). */
13
+ export declare const POLAR_FEE_RATE = 0.038;
14
+ /** Polar Pro's fixed fee per order, in US cents (Polar Pro pricing, 2026-09). */
15
+ export declare const POLAR_FEE_FIXED_USD = 40;
16
+ /** Fixed USD→EUR rate for the fixed fee; an estimate, not a live rate. */
17
+ export declare const USD_EUR = 0.92;
18
+ /**
19
+ * Splits a gross EUR price, in cents, into VAT, Polar's fee, and what
20
+ * reaches Orakl. VAT is extracted from the VAT-inclusive gross
21
+ * (`gross × 21/121`); the fee is Polar Pro's 3.8% + $0.40 fixed fee,
22
+ * converted to EUR at a fixed rate; the net is the remainder. Each figure
23
+ * is rounded to the cent before the net is taken, so the three sum to the
24
+ * gross exactly.
25
+ */
26
+ export declare function estimateBreakdown(grossCents: number): {
27
+ vat: number;
28
+ fee: number;
29
+ net: number;
30
+ };
31
+ /** Formats a cent amount as a euro figure, e.g. `357` → `"€3.57"`. */
32
+ export declare function formatEur(cents: number): string;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Subscription prices, in cents, for the two Polar products (decision 7,
3
+ * issue #1190).
4
+ */
5
+ export const PRICES = { monthly: 500, lifetime: 10000 };
6
+ /** Romanian VAT, the rate the estimate assumes; Polar charges the buyer's
7
+ * own rate at checkout. */
8
+ export const VAT_RATE = 0.21;
9
+ /** Polar Pro's percentage fee on the gross price (Polar Pro pricing, 2026-09). */
10
+ export const POLAR_FEE_RATE = 0.038;
11
+ /** Polar Pro's fixed fee per order, in US cents (Polar Pro pricing, 2026-09). */
12
+ export const POLAR_FEE_FIXED_USD = 40;
13
+ /** Fixed USD→EUR rate for the fixed fee; an estimate, not a live rate. */
14
+ export const USD_EUR = 0.92;
15
+ /**
16
+ * Splits a gross EUR price, in cents, into VAT, Polar's fee, and what
17
+ * reaches Orakl. VAT is extracted from the VAT-inclusive gross
18
+ * (`gross × 21/121`); the fee is Polar Pro's 3.8% + $0.40 fixed fee,
19
+ * converted to EUR at a fixed rate; the net is the remainder. Each figure
20
+ * is rounded to the cent before the net is taken, so the three sum to the
21
+ * gross exactly.
22
+ */
23
+ export function estimateBreakdown(grossCents) {
24
+ const vat = Math.round((grossCents * VAT_RATE) / (1 + VAT_RATE));
25
+ const fee = Math.round(grossCents * POLAR_FEE_RATE + POLAR_FEE_FIXED_USD * USD_EUR);
26
+ const net = grossCents - vat - fee;
27
+ return { vat, fee, net };
28
+ }
29
+ /** Formats a cent amount as a euro figure, e.g. `357` → `"€3.57"`. */
30
+ export function formatEur(cents) {
31
+ return `€${(cents / 100).toFixed(2)}`;
32
+ }
@@ -0,0 +1,8 @@
1
+ import type { UserPower, UserRole } from "./types/auth.ts";
2
+ export declare const TABS: readonly ["account", "appearance", "privacy", "tools"];
3
+ export type ProfileTab = (typeof TABS)[number];
4
+ /** Admins, any Management/Mimic power holder, or a non-production build sees the Tools tab. */
5
+ export declare function showsToolsTab(user: {
6
+ role: UserRole;
7
+ powers: UserPower[];
8
+ }): boolean;
@@ -0,0 +1,18 @@
1
+ import { isProduction } from "./env.js";
2
+ import { hasPower, hasRole } from "./types/auth.js";
3
+ export const TABS = ["account", "appearance", "privacy", "tools"];
4
+ /** The Management and Mimic powers that unlock the Tools tab. */
5
+ const TOOLS_POWERS = [
6
+ "can-manage-categories",
7
+ "can-manage-avatars",
8
+ "can-moderate-questions",
9
+ "can-publish-questions",
10
+ "can-manage-features",
11
+ "can-access-mimic",
12
+ ];
13
+ /** Admins, any Management/Mimic power holder, or a non-production build sees the Tools tab. */
14
+ export function showsToolsTab(user) {
15
+ return (!isProduction() ||
16
+ hasRole(user.role, "admin") ||
17
+ TOOLS_POWERS.some((power) => hasPower(user, power)));
18
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Phase-derived route resolver — the single source of truth for "which URL
3
+ * does a given actor belong at, for a given Phase + membership" (ADR 0004).
4
+ *
5
+ * Pure and dependency-free: imported by both the server (`load` redirects) and
6
+ * the client (SSE-driven `goto`). Phase→route logic lives here and nowhere else.
7
+ */
8
+ import type { ActiveGamePhase } from "@orakl/protocol";
9
+ /** Who is watching the Game lifecycle. Each maps the same Phase differently. */
10
+ export type Actor = "player" | "curator" | "display";
11
+ /** A token-holder's relationship to the current Lobby. */
12
+ export type Membership = "none" | "pending" | "active";
13
+ /** Phase as reported by `GameStore.getPhase()` (pre-start lobby + active phases). */
14
+ export type ServerPhase = "lobby" | ActiveGamePhase;
15
+ /**
16
+ * The gated journey state a route corresponds to: the server-authoritative
17
+ * Phase, plus the pre-membership / approval markers that are not Phases.
18
+ */
19
+ export type JourneyState = "code-entry" | "setup" | "pending" | "lobby" | "playing" | "role_selection" | "final_scores";
20
+ /**
21
+ * Derive the journey state from membership + Phase. Pure; shared by the server
22
+ * guard and the client navigation effect so they can never disagree.
23
+ */
24
+ export declare function journeyState(membership: Membership, phase: ServerPhase | null): JourneyState;
25
+ /** The canonical route for an actor in a given journey state. */
26
+ export declare function routeForState(actor: Actor, state: JourneyState): string;
27
+ /** Convenience: membership + Phase → route, in one hop. */
28
+ export declare function routeForPhase(actor: Actor, membership: Membership, phase: ServerPhase | null): string;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Derive the journey state from membership + Phase. Pure; shared by the server
3
+ * guard and the client navigation effect so they can never disagree.
4
+ */
5
+ export function journeyState(membership, phase) {
6
+ if (membership === "none")
7
+ return phase ? "setup" : "code-entry";
8
+ if (membership === "pending")
9
+ return "pending";
10
+ // active member — route follows the live Phase
11
+ switch (phase) {
12
+ case "playing":
13
+ case "round_results":
14
+ return "playing";
15
+ case "role_selection":
16
+ return "role_selection";
17
+ case "final_scores":
18
+ return "final_scores";
19
+ default:
20
+ return "lobby";
21
+ }
22
+ }
23
+ /**
24
+ * Per-actor route table. `player` and `curator` share the "has a live lobby"
25
+ * states (lobby/playing/role_selection/final_scores) — a curator playing (or
26
+ * observing) their own quiz walks the identical journey through the identical
27
+ * `/quiz/*` routes (ADR 0019 route unification). The "no lobby yet" states
28
+ * differ: a stray player belongs at `/join` (enter a code); a curator with no
29
+ * lobby of their own belongs at `/curator/create` (host one), never `/join`.
30
+ * `display` is stubbed until that journey migrates (ADR 0004).
31
+ */
32
+ const PLAYER_ROUTES = {
33
+ "code-entry": "/join",
34
+ setup: "/quiz/setup",
35
+ pending: "/quiz/setup",
36
+ lobby: "/quiz/lobby",
37
+ playing: "/quiz/play",
38
+ role_selection: "/quiz/roles",
39
+ final_scores: "/quiz/results",
40
+ };
41
+ const CURATOR_ROUTES = {
42
+ ...PLAYER_ROUTES,
43
+ // Unreachable in practice (a curator's own row is never pending, and only
44
+ // exists once their lobby does) — routed home defensively regardless.
45
+ "code-entry": "/curator/create",
46
+ setup: "/curator/create",
47
+ pending: "/curator/create",
48
+ };
49
+ const ROUTES = {
50
+ player: PLAYER_ROUTES,
51
+ curator: CURATOR_ROUTES,
52
+ display: {},
53
+ };
54
+ /** The canonical route for an actor in a given journey state. */
55
+ export function routeForState(actor, state) {
56
+ const route = ROUTES[actor][state];
57
+ if (!route) {
58
+ throw new Error(`No route mapped for actor="${actor}" state="${state}"`);
59
+ }
60
+ return route;
61
+ }
62
+ /** Convenience: membership + Phase → route, in one hop. */
63
+ export function routeForPhase(actor, membership, phase) {
64
+ return routeForState(actor, journeyState(membership, phase));
65
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Input sanitization utilities for user-generated content
3
+ */
4
+ export declare function isValidHexColor(color: string): boolean;
5
+ /**
6
+ * Strip all HTML tags from a string
7
+ * This prevents stored XSS by removing any HTML markup.
8
+ * Optimized with an early exit.
9
+ */
10
+ export declare function stripHtmlTags(text: string): string;
11
+ /**
12
+ * Validate and sanitize question text
13
+ * - Strips HTML tags
14
+ * - Enforces max length
15
+ * - Trims whitespace
16
+ */
17
+ export declare function sanitizeQuestionText(text: string): string;
18
+ /**
19
+ * Validate and sanitize answer text
20
+ * - Strips HTML tags
21
+ * - Enforces max length
22
+ * - Trims whitespace
23
+ */
24
+ export declare function sanitizeAnswerText(text: string): string;
25
+ /**
26
+ * Sanitize match item text (for image_matching questions)
27
+ * - Strips HTML tags
28
+ * - Enforces max length (same as answer text)
29
+ * - Trims whitespace
30
+ */
31
+ export declare function sanitizeMatchItemText(text: string): string;
32
+ /**
33
+ * Sanitize nickname
34
+ * - Strips HTML tags to prevent XSS
35
+ * - Enforces max length from game settings
36
+ * - Trims whitespace
37
+ */
38
+ export declare function sanitizeNickname(text: string | null | undefined): string;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Input sanitization utilities for user-generated content
3
+ */
4
+ import { GAME } from "@orakl/protocol";
5
+ import { HEX_COLOR_RE, HTML_TAGS_RE } from "./constants/patterns.constants.js";
6
+ const MAX_QUESTION_TEXT_LENGTH = 500;
7
+ const MAX_ANSWER_TEXT_LENGTH = 200;
8
+ export function isValidHexColor(color) {
9
+ return HEX_COLOR_RE.test(color);
10
+ }
11
+ /**
12
+ * Strip all HTML tags from a string
13
+ * This prevents stored XSS by removing any HTML markup.
14
+ * Optimized with an early exit.
15
+ */
16
+ export function stripHtmlTags(text) {
17
+ if (!text || !text.includes("<"))
18
+ return text;
19
+ return text.replace(HTML_TAGS_RE, "");
20
+ }
21
+ /**
22
+ * Validate and sanitize question text
23
+ * - Strips HTML tags
24
+ * - Enforces max length
25
+ * - Trims whitespace
26
+ */
27
+ export function sanitizeQuestionText(text) {
28
+ if (!text || typeof text !== "string") {
29
+ throw new Error("Question text must be a non-empty string");
30
+ }
31
+ // Strip HTML first
32
+ const stripped = stripHtmlTags(text);
33
+ // Trim whitespace
34
+ const trimmed = stripped.trim();
35
+ if (trimmed.length === 0) {
36
+ throw new Error("Question text cannot be empty");
37
+ }
38
+ if (trimmed.length > MAX_QUESTION_TEXT_LENGTH) {
39
+ throw new Error(`Question text exceeds maximum length of ${MAX_QUESTION_TEXT_LENGTH} characters`);
40
+ }
41
+ return trimmed;
42
+ }
43
+ /**
44
+ * Validate and sanitize answer text
45
+ * - Strips HTML tags
46
+ * - Enforces max length
47
+ * - Trims whitespace
48
+ */
49
+ export function sanitizeAnswerText(text) {
50
+ if (!text || typeof text !== "string") {
51
+ throw new Error("Answer text must be a non-empty string");
52
+ }
53
+ // Strip HTML first
54
+ const stripped = stripHtmlTags(text);
55
+ // Trim whitespace
56
+ const trimmed = stripped.trim();
57
+ if (trimmed.length === 0) {
58
+ throw new Error("Answer text cannot be empty");
59
+ }
60
+ if (trimmed.length > MAX_ANSWER_TEXT_LENGTH) {
61
+ throw new Error(`Answer text exceeds maximum length of ${MAX_ANSWER_TEXT_LENGTH} characters`);
62
+ }
63
+ return trimmed;
64
+ }
65
+ /**
66
+ * Sanitize match item text (for image_matching questions)
67
+ * - Strips HTML tags
68
+ * - Enforces max length (same as answer text)
69
+ * - Trims whitespace
70
+ */
71
+ export function sanitizeMatchItemText(text) {
72
+ if (!text || typeof text !== "string") {
73
+ throw new Error("Match item text must be a non-empty string");
74
+ }
75
+ // Strip HTML first
76
+ const stripped = stripHtmlTags(text);
77
+ // Trim whitespace
78
+ const trimmed = stripped.trim();
79
+ if (trimmed.length === 0) {
80
+ throw new Error("Match item text cannot be empty");
81
+ }
82
+ if (trimmed.length > MAX_ANSWER_TEXT_LENGTH) {
83
+ throw new Error(`Match item text exceeds maximum length of ${MAX_ANSWER_TEXT_LENGTH} characters`);
84
+ }
85
+ return trimmed;
86
+ }
87
+ /**
88
+ * Sanitize nickname
89
+ * - Strips HTML tags to prevent XSS
90
+ * - Enforces max length from game settings
91
+ * - Trims whitespace
92
+ */
93
+ export function sanitizeNickname(text) {
94
+ if (text === null ||
95
+ text === undefined ||
96
+ typeof text !== "string" ||
97
+ text.trim().length === 0) {
98
+ throw new Error("Missing or invalid nickname");
99
+ }
100
+ const stripped = stripHtmlTags(text);
101
+ const trimmed = stripped.trim();
102
+ if (trimmed.length === 0) {
103
+ throw new Error("Nickname cannot be empty after sanitization");
104
+ }
105
+ if (trimmed.length > GAME.player.maxNicknameLength) {
106
+ throw new Error(`Nickname too long (max ${GAME.player.maxNicknameLength} characters)`);
107
+ }
108
+ return trimmed;
109
+ }
@@ -0,0 +1 @@
1
+ export { type Difficulty, DifficultySchema, type ImageChoiceQuestion, type ImageMatchingQuestion, isHttpsUrl, type Media, MediaSchema, type MediaType, MediaTypeSchema, PostAnswerNoteSchema, type Question, QuestionSchema, type QuestionSource, QuestionSourceSchema, type QuestionType, QuestionTypeSchema, type Source, SourceSchema, type TextChoiceQuestion, type TrueFalseQuestion, } from "@orakl/protocol";
@@ -0,0 +1 @@
1
+ export { DifficultySchema, isHttpsUrl, MediaSchema, MediaTypeSchema, PostAnswerNoteSchema, QuestionSchema, QuestionSourceSchema, QuestionTypeSchema, SourceSchema, } from "@orakl/protocol";
@@ -0,0 +1,28 @@
1
+ export interface GuestEntitlements {
2
+ maxCategories: 3;
3
+ maxQuestions: 10;
4
+ difficulty: "all";
5
+ builtInOnly: true;
6
+ leaderboardEligible: false;
7
+ }
8
+ export interface AuthEntitlements {
9
+ maxCategories: null;
10
+ maxQuestions: null;
11
+ difficulty: "all" | "easy" | "medium" | "hard";
12
+ builtInOnly: false;
13
+ leaderboardEligible: true;
14
+ }
15
+ export type SoloEntitlements = GuestEntitlements | AuthEntitlements;
16
+ export declare function getEntitlements(isGuest: boolean): SoloEntitlements;
17
+ export interface SoloStartConfig {
18
+ categoryIds: string[];
19
+ questionCount: number;
20
+ mode: "normal" | "endless";
21
+ difficulty: "all" | "easy" | "medium" | "hard";
22
+ }
23
+ export interface EntitlementViolation {
24
+ code: "entitlement_exceeded";
25
+ message: string;
26
+ upgradeHint: string;
27
+ }
28
+ export declare function checkEntitlements(config: SoloStartConfig, entitlements: SoloEntitlements): EntitlementViolation | null;