@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.
- package/LICENSE +662 -0
- package/dist/analytics.d.ts +21 -0
- package/dist/analytics.js +21 -0
- package/dist/avatar-helpers.d.ts +46 -0
- package/dist/avatar-helpers.js +69 -0
- package/dist/avatars.d.ts +6 -0
- package/dist/avatars.js +138 -0
- package/dist/badges.d.ts +126 -0
- package/dist/badges.js +667 -0
- package/dist/constants/auth.constants.d.ts +26 -0
- package/dist/constants/auth.constants.js +94 -0
- package/dist/constants/feature-flags.constants.d.ts +138 -0
- package/dist/constants/feature-flags.constants.js +119 -0
- package/dist/constants/font.constants.d.ts +9 -0
- package/dist/constants/font.constants.js +14 -0
- package/dist/constants/lobby.constants.d.ts +2 -0
- package/dist/constants/lobby.constants.js +438 -0
- package/dist/constants/patterns.constants.d.ts +4 -0
- package/dist/constants/patterns.constants.js +4 -0
- package/dist/constants/theme.constants.d.ts +24 -0
- package/dist/constants/theme.constants.js +69 -0
- package/dist/env.d.ts +3 -0
- package/dist/env.js +5 -0
- package/dist/font.d.ts +4 -0
- package/dist/font.js +8 -0
- package/dist/history-range.d.ts +13 -0
- package/dist/history-range.js +29 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +26 -0
- package/dist/lobby.d.ts +16 -0
- package/dist/lobby.js +50 -0
- package/dist/manage-questions.d.ts +52 -0
- package/dist/manage-questions.js +26 -0
- package/dist/math.d.ts +27 -0
- package/dist/math.js +30 -0
- package/dist/murmurhash.d.ts +9 -0
- package/dist/murmurhash.js +52 -0
- package/dist/pricing.d.ts +32 -0
- package/dist/pricing.js +32 -0
- package/dist/profile-tabs.d.ts +8 -0
- package/dist/profile-tabs.js +18 -0
- package/dist/quiz-routes.d.ts +28 -0
- package/dist/quiz-routes.js +65 -0
- package/dist/sanitize.d.ts +38 -0
- package/dist/sanitize.js +109 -0
- package/dist/schemas/question.schema.d.ts +1 -0
- package/dist/schemas/question.schema.js +1 -0
- package/dist/solo-entitlements.d.ts +28 -0
- package/dist/solo-entitlements.js +56 -0
- package/dist/theme.d.ts +39 -0
- package/dist/theme.js +139 -0
- package/dist/types/auth.d.ts +143 -0
- package/dist/types/auth.js +28 -0
- package/dist/utils.d.ts +50 -0
- package/dist/utils.js +133 -0
- 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;
|
package/dist/pricing.js
ADDED
|
@@ -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;
|
package/dist/sanitize.js
ADDED
|
@@ -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;
|