From 00668dd924d81f8e737ca20230e5b533bb468a6c Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 4 Aug 2025 16:48:41 -0700 Subject: [PATCH] Remove role based perms, fetch cosmetics.json from api (#1640) ## Description: * Fetch cosmetics.json from api * Remove all role based perms, we are only using flares now * Created Priviledge refresher which periodically polls /cosmetics.json endpoint. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- .gitignore | 1 + resources/cosmetics/roles.txt | 24 --- src/client/Cosmetics.ts | 188 +++++++--------------- src/client/TerritoryPatternsModal.ts | 25 +-- src/core/CosmeticSchemas.ts | 65 ++++---- src/core/CustomFlag.ts | 19 ++- src/server/Privilege.ts | 88 +++++----- src/server/PrivilegeRefresher.ts | 68 ++++++++ src/server/Worker.ts | 31 ++-- tests/server/Privilege.customFlag.test.ts | 152 ++++------------- 10 files changed, 282 insertions(+), 379 deletions(-) delete mode 100644 resources/cosmetics/roles.txt create mode 100644 src/server/PrivilegeRefresher.ts diff --git a/.gitignore b/.gitignore index df5f0dd28..8b3f0c0db 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ resources/.DS_Store .env* .DS_Store .clinic/ +CLAUDE.md diff --git a/resources/cosmetics/roles.txt b/resources/cosmetics/roles.txt deleted file mode 100644 index 6da05b458..000000000 --- a/resources/cosmetics/roles.txt +++ /dev/null @@ -1,24 +0,0 @@ -Admin 1286738076386856991 -OG 1286743849707769936 -Creator 1286745100411473930 -Bots 1286910984702791711 -Challenger 1292157381496799264 -OG100 1314802550314237952 -Contributor 1314972008362020957 -Ping 1316444187276738612 -Server Booster 1319387513206345770 -Content Creator 1320961080750637076 -Beta Tester 1327125593791397929 -Early Access Supporter 1330243292306341969 -Mod 1338654590043820148 -Support Staff 1343759662545244296 -DevChatAccess 1345831753528377425 -Member 1347621713852235808 -Active Contributor 1354828445489692692 -Retired Staff 1355753028099117147 -Head Mod 1357747869742010661 -Money Haters 1359441841371480176 -Translator 1367345579272831128 -Head Translator 1367345660852174930 -Development Stream Ping 1369340951109304340 -Core Contributor 1370238576868200488 diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 7e694207f..01a4a5de0 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -1,149 +1,85 @@ import { UserMeResponse } from "../core/ApiSchemas"; -import { COSMETICS } from "../core/CosmeticSchemas"; +import { Cosmetics, CosmeticsSchema, Pattern } from "../core/CosmeticSchemas"; import { getApiBase, getAuthHeader } from "./jwt"; -import { translateText } from "./Utils"; - -interface StripeProduct { - id: string; - object: "product"; - active: boolean; - created: number; - description: string | null; - images: string[]; - livemode: boolean; - metadata: Record; - name: string; - shippable: boolean | null; - type: "good" | "service"; - updated: number; - url: string | null; - price: string; - price_id: string; -} - -export interface Pattern { - name: string; - key: string; - roles: string[]; - price?: string; - priceId?: string; - lockedReason?: string; - notShown?: boolean; -} export async function patterns( userMe: UserMeResponse | null, ): Promise { - const patterns: Pattern[] = Object.entries(COSMETICS.patterns).map( - ([key, patternData]) => { - return { - name: patternData.name, - key, - roles: patternData.role_group - ? (COSMETICS.role_groups[patternData.role_group] ?? []) - : [], - }; - }, - ); + const cosmetics = await getCosmetics(); - const products = await listAllProducts(); - patterns.forEach((pattern) => { - addRestrictions(pattern, userMe, products); - }); + if (cosmetics === undefined) { + return []; + } + + const patterns: Pattern[] = []; + const playerFlares = new Set(userMe?.player.flares); + + for (const name in cosmetics.patterns) { + const patternData = cosmetics.patterns[name]; + const hasAccess = playerFlares.has(`pattern:${name}`); + if (hasAccess) { + // Remove product info because player already has access. + patternData.product = null; + patterns.push(patternData); + } else if (patternData.product !== null) { + // Player doesn't have access, but product is available for purchase. + patterns.push(patternData); + } + // If player doesn't have access and product is null, don't show it. + } return patterns; } -function addRestrictions( - pattern: Pattern, - userMe: UserMeResponse | null, - products: Map, -) { - if (userMe === null) { - if (products.has(`pattern:${pattern.name}`)) { - // Purchasable (flare-gated) patterns are shown as disabled - pattern.lockedReason = translateText("territory_patterns.blocked.login"); - } else { - // Role-gated patterns are not shown - pattern.notShown = true; - } - return; - } - const flares = userMe.player.flares ?? []; - if ( - flares.includes("pattern:*") || - flares.includes(`pattern:${pattern.name}`) - ) { - // Pattern is unlocked by flare - return; - } - - const myRoles = userMe.player.roles ?? []; - if ( - pattern.roles.some((authorizedRole) => myRoles.includes(authorizedRole)) - ) { - // Pattern is unlocked by role - return; - } - - const product = products.get(`pattern:${pattern.name}`); - if (product) { - pattern.price = product.price; - pattern.priceId = product.price_id; - pattern.lockedReason = translateText("territory_patterns.blocked.purchase"); - return; - } - - // Pattern is locked by role group and not purchasable, don't show it. - pattern.notShown = true; -} - export async function handlePurchase(priceId: string) { - try { - const response = await fetch( - `${getApiBase()}/stripe/create-checkout-session`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - authorization: getAuthHeader(), - }, - body: JSON.stringify({ - priceId: priceId, - successUrl: `${window.location.href}purchase-success`, - cancelUrl: `${window.location.href}purchase-cancel`, - }), + const response = await fetch( + `${getApiBase()}/stripe/create-checkout-session`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: getAuthHeader(), }, + body: JSON.stringify({ + priceId: priceId, + + successUrl: `${window.location.href}purchase-success`, + cancelUrl: `${window.location.href}purchase-cancel`, + }), + }, + ); + + if (!response.ok) { + console.error( + `Error purchasing pattern:${response.status} ${response.statusText}`, ); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + if (response.status === 401) { + alert("You are not logged in. Please log in to purchase a pattern."); + } else { + alert("Something went wrong. Please try again later."); } - - const { url } = await response.json(); - - // Redirect to Stripe checkout - window.location.href = url; - } catch (error) { - console.error("Purchase error:", error); - alert("Something went wrong. Please try again later."); + return; } + + const { url } = await response.json(); + + // Redirect to Stripe checkout + window.location.href = url; } -// Returns a map of flare -> product -export async function listAllProducts(): Promise> { +async function getCosmetics(): Promise { try { - const response = await fetch(`${getApiBase()}/stripe/products`); + const response = await fetch(`${getApiBase()}/cosmetics.json`); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + console.error(`HTTP error! status: ${response.status}`); + return; } - const products = (await response.json()) as StripeProduct[]; - const productMap = new Map(); - products.forEach((product) => { - productMap.set(product.metadata.flare, product); - }); - return productMap; + const result = CosmeticsSchema.safeParse(await response.json()); + if (!result.success) { + console.error(`Invalid cosmetics: ${result.error.message}`); + return; + } + return result.data; } catch (error) { - console.error("Failed to fetch products:", error); - return new Map(); + console.error("Error getting cosmetics:", error); } } diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index e116ea486..74e8e6857 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -3,11 +3,12 @@ import type { TemplateResult } from "lit"; import { html, LitElement, render } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { UserMeResponse } from "../core/ApiSchemas"; +import { Pattern } from "../core/CosmeticSchemas"; import { UserSettings } from "../core/game/UserSettings"; import { PatternDecoder } from "../core/PatternDecoder"; import "./components/Difficulties"; import "./components/Maps"; -import { handlePurchase, Pattern, patterns } from "./Cosmetics"; +import { handlePurchase, patterns } from "./Cosmetics"; import { translateText } from "./Utils"; @customElement("territory-patterns-modal") @@ -107,14 +108,14 @@ export class TerritoryPatternsModal extends LitElement { } private renderTooltip(): TemplateResult | null { - if (this.hoveredPattern && this.hoveredPattern.lockedReason) { + if (this.hoveredPattern && this.hoveredPattern.product !== undefined) { return html`
- ${this.hoveredPattern.lockedReason} + ${translateText("territory_patterns.blocked.purchase")}
`; } @@ -122,7 +123,7 @@ export class TerritoryPatternsModal extends LitElement { } private renderPatternButton(pattern: Pattern): TemplateResult { - const isSelected = this.selectedPattern === pattern.key; + const isSelected = this.selectedPattern === pattern.pattern; return html`
@@ -131,9 +132,9 @@ export class TerritoryPatternsModal extends LitElement { ${isSelected ? "bg-blue-500 text-white" : "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"} - ${pattern.lockedReason ? "opacity-50 cursor-not-allowed" : ""}" + ${pattern.product !== null ? "opacity-50 cursor-not-allowed" : ""}" @click=${() => - !pattern.lockedReason && this.selectPattern(pattern.key)} + pattern.product === null && this.selectPattern(pattern.pattern)} @mouseenter=${(e: MouseEvent) => this.handleMouseEnter(pattern, e)} @mousemove=${(e: MouseEvent) => this.handleMouseMove(e)} @mouseleave=${() => this.handleMouseLeave()} @@ -155,23 +156,23 @@ export class TerritoryPatternsModal extends LitElement { " > ${this.renderPatternPreview( - pattern.key, + pattern.pattern, this.buttonWidth, this.buttonWidth, )}
- ${pattern.priceId !== undefined && pattern.lockedReason + ${pattern.product !== null ? html` ` : null} @@ -183,7 +184,6 @@ export class TerritoryPatternsModal extends LitElement { const buttons: TemplateResult[] = []; for (const pattern of this.patterns) { if (!this.showChocoPattern && pattern.name === "choco") continue; - if (pattern.notShown === true) continue; const result = this.renderPatternButton(pattern); buttons.push(result); @@ -243,6 +243,7 @@ export class TerritoryPatternsModal extends LitElement { this.modalEl?.open(); window.addEventListener("keydown", this.handleKeyDown); this.isActive = true; + this.requestUpdate(); } public close() { @@ -332,7 +333,7 @@ export class TerritoryPatternsModal extends LitElement { } private handleMouseEnter(pattern: Pattern, event: MouseEvent) { - if (pattern.lockedReason) { + if (pattern.product !== null) { this.hoveredPattern = pattern; this.hoverPosition = { x: event.clientX, y: event.clientY }; } diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index 144800d57..f4318fb5e 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -1,36 +1,41 @@ -import { z } from "zod"; -import cosmetics_json from "../../resources/cosmetics/cosmetics.json" with { type: "json" }; +import { z } from "zod/v4"; import { RequiredPatternSchema } from "./Schemas"; +export const ProductSchema = z.object({ + productId: z.string(), + priceId: z.string(), + price: z.string(), +}); + +const PatternSchema = z.object({ + name: z.string(), + pattern: RequiredPatternSchema, + product: ProductSchema.nullable(), +}); + // Schema for resources/cosmetics/cosmetics.json export const CosmeticsSchema = z.object({ - role_groups: z.record(z.string(), z.string().array().min(1)), - patterns: z.record( - RequiredPatternSchema, - z.object({ - name: z.string(), - role_group: z.string().optional(), - }), - ), - flag: z.object({ - layers: z.record( - z.string(), - z.object({ - name: z.string(), - role_group: z.string().optional(), - flares: z.array(z.string()).optional(), - }), - ), - color: z.record( - z.string(), - z.object({ - color: z.string(), - name: z.string(), - role_group: z.string().optional(), - flares: z.array(z.string()).optional(), - }), - ), - }), + patterns: z.record(z.string(), PatternSchema), + flag: z + .object({ + layers: z.record( + z.string(), + z.object({ + name: z.string(), + flares: z.array(z.string()).optional(), + }), + ), + color: z.record( + z.string(), + z.object({ + color: z.string(), + name: z.string(), + flares: z.array(z.string()).optional(), + }), + ), + }) + .optional(), }); export type Cosmetics = z.infer; -export const COSMETICS: Cosmetics = CosmeticsSchema.parse(cosmetics_json); +export type Pattern = z.infer; +export type Product = z.infer; diff --git a/src/core/CustomFlag.ts b/src/core/CustomFlag.ts index efe89615a..3347e5e8f 100644 --- a/src/core/CustomFlag.ts +++ b/src/core/CustomFlag.ts @@ -1,4 +1,4 @@ -import { COSMETICS } from "./CosmeticSchemas"; +import { Cosmetics } from "./CosmeticSchemas"; const ANIMATION_DURATIONS: Record = { rainbow: 4000, @@ -11,7 +11,18 @@ const ANIMATION_DURATIONS: Record = { water: 6200, }; -export function renderPlayerFlag(flag: string, target: HTMLElement) { +// TODO: Pass in cosmetics as a parameter when +// remote cosmetics are implemented for custom flags +export function renderPlayerFlag( + flag: string, + target: HTMLElement, + cosmetics: Cosmetics | undefined = undefined, +) { + if (cosmetics === undefined) { + console.warn("No cosmetics provided for flag", flag); + return; + } + if (!flag.startsWith("!")) return; const code = flag.slice("!".length); @@ -26,7 +37,7 @@ export function renderPlayerFlag(flag: string, target: HTMLElement) { target.style.aspectRatio = "3/4"; for (const { layerKey, colorKey } of layers) { - const layerName = COSMETICS.flag.layers[layerKey]?.name ?? layerKey; + const layerName = cosmetics?.flag?.layers[layerKey]?.name ?? layerKey; const mask = `/flags/custom/${layerName}.svg`; if (!mask) continue; @@ -38,7 +49,7 @@ export function renderPlayerFlag(flag: string, target: HTMLElement) { layer.style.width = "100%"; layer.style.height = "100%"; - const colorValue = COSMETICS.flag.color[colorKey]?.color ?? colorKey; + const colorValue = cosmetics?.flag?.color[colorKey]?.color ?? colorKey; const isSpecial = !colorValue.startsWith("#") && !/^([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(colorValue); diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index f59aff7e6..88908215f 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -1,19 +1,36 @@ -import { Cosmetics } from "../core/CosmeticSchemas"; +import { Cosmetics, Pattern } from "../core/CosmeticSchemas"; import { PatternDecoder } from "../core/PatternDecoder"; -export class PrivilegeChecker { +export interface PrivilegeChecker { + isPatternAllowed( + base64: string, + flares: readonly string[] | undefined, + ): true | "restricted" | "unlisted" | "invalid"; + isCustomFlagAllowed( + flag: string, + flares: readonly string[] | undefined, + ): true | "restricted" | "invalid"; +} + +export class PrivilegeCheckerImpl implements PrivilegeChecker { + private b64ToPattern: Record = {}; + constructor( private cosmetics: Cosmetics, private b64urlDecode: (base64: string) => Uint8Array, - ) {} + ) { + for (const name in this.cosmetics.patterns) { + const pattern = this.cosmetics.patterns[name]; + this.b64ToPattern[pattern.pattern] = pattern; + } + } isPatternAllowed( base64: string, - roles: readonly string[] | undefined, flares: readonly string[] | undefined, ): true | "restricted" | "unlisted" | "invalid" { // Look for the pattern in the cosmetics.json config - const found = this.cosmetics.patterns[base64]; + const found = this.b64ToPattern[base64]; if (found === undefined) { try { // Ensure that the pattern will not throw for clients @@ -30,27 +47,9 @@ export class PrivilegeChecker { return "unlisted"; } - const { role_group, name } = found; - if (role_group === undefined) { - // Pattern has no restrictions - return true; - } - - for (const groupName of role_group) { - if ( - roles !== undefined && - roles.some((role) => - this.cosmetics.role_groups[groupName]?.includes(role), - ) - ) { - // Player is in a role group for this pattern - return true; - } - } - if ( flares !== undefined && - (flares.includes(`pattern:${name}`) || flares.includes("pattern:*")) + (flares.includes(`pattern:${found.name}`) || flares.includes("pattern:*")) ) { // Player has a flare for this pattern return true; @@ -61,7 +60,6 @@ export class PrivilegeChecker { isCustomFlagAllowed( flag: string, - roles: readonly string[] | undefined, flares: readonly string[] | undefined, ): true | "restricted" | "invalid" { if (!flag.startsWith("!")) return "invalid"; @@ -78,8 +76,8 @@ export class PrivilegeChecker { for (const segment of segments) { const [layerKey, colorKey] = segment.split("-"); if (!layerKey || !colorKey) return "invalid"; - const layer = this.cosmetics.flag.layers[layerKey]; - const color = this.cosmetics.flag.color[colorKey]; + const layer = this.cosmetics.flag?.layers[layerKey]; + const color = this.cosmetics.flag?.color[colorKey]; if (!layer || !color) return "invalid"; // Super-flare bypasses all restrictions @@ -90,17 +88,9 @@ export class PrivilegeChecker { // Check layer restrictions const layerSpec = layer; let layerAllowed = false; - if (!layerSpec.role_group && !layerSpec.flares) { + if (!layerSpec.flares) { layerAllowed = true; } else { - // By role - if (layerSpec.role_group) { - const allowedRoles = - this.cosmetics.role_groups[layerSpec.role_group] || []; - if (roles?.some((r) => allowedRoles.includes(r))) { - layerAllowed = true; - } - } // By flare if ( layerSpec.flares && @@ -117,17 +107,9 @@ export class PrivilegeChecker { // Check color restrictions const colorSpec = color; let colorAllowed = false; - if (!colorSpec.role_group && !colorSpec.flares) { + if (!colorSpec.flares) { colorAllowed = true; } else { - // By role - if (colorSpec.role_group) { - const allowedRoles = - this.cosmetics.role_groups[colorSpec.role_group] || []; - if (roles?.some((r) => allowedRoles.includes(r))) { - colorAllowed = true; - } - } // By flare if ( colorSpec.flares && @@ -149,3 +131,19 @@ export class PrivilegeChecker { return true; } } + +export class FailOpenPrivilegeChecker implements PrivilegeChecker { + isPatternAllowed( + name: string, + flares: readonly string[] | undefined, + ): true | "restricted" | "unlisted" | "invalid" { + return true; + } + + isCustomFlagAllowed( + flag: string, + flares: readonly string[] | undefined, + ): true | "restricted" | "invalid" { + return true; + } +} diff --git a/src/server/PrivilegeRefresher.ts b/src/server/PrivilegeRefresher.ts new file mode 100644 index 000000000..89bdcb1ac --- /dev/null +++ b/src/server/PrivilegeRefresher.ts @@ -0,0 +1,68 @@ +import { base64url } from "jose"; +import { Logger } from "winston"; +import { CosmeticsSchema } from "../core/CosmeticSchemas"; +import { + FailOpenPrivilegeChecker, + PrivilegeChecker, + PrivilegeCheckerImpl, +} from "./Privilege"; + +// Refreshes the privilege checker every 5 minutes. +// WARNING: This fails open if cosmetics.json is not available. +export class PrivilegeRefresher { + private privilegeChecker: PrivilegeChecker | null = null; + private failOpenPrivilegeChecker: PrivilegeChecker = + new FailOpenPrivilegeChecker(); + + private log: Logger; + + constructor( + private endpoint: string, + parentLog: Logger, + private refreshInterval: number = 1000 * 60 * 3, + ) { + this.log = parentLog.child({ comp: "privilege-refresher" }); + } + + public async start() { + this.log.info( + `Starting privilege refresher with interval ${this.refreshInterval}`, + ); + // Add some jitter to the initial load and the interval. + setTimeout(() => this.loadPrivilegeChecker(), Math.random() * 1000); + setInterval( + () => this.loadPrivilegeChecker(), + this.refreshInterval + Math.random() * 1000, + ); + } + + public get(): PrivilegeChecker { + return this.privilegeChecker ?? this.failOpenPrivilegeChecker; + } + + private async loadPrivilegeChecker(): Promise { + this.log.info(`Loading privilege checker from ${this.endpoint}`); + try { + const response = await fetch(this.endpoint); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const cosmeticsData = await response.json(); + const result = CosmeticsSchema.safeParse(cosmeticsData); + + if (!result.success) { + throw new Error(`Invalid cosmetics data: ${result.error.message}`); + } + + this.privilegeChecker = new PrivilegeCheckerImpl( + result.data, + base64url.decode, + ); + this.log.info(`Privilege checker loaded successfully`); + } catch (error) { + this.log.error(`Failed to fetch cosmetics from ${this.endpoint}:`, error); + throw error; + } + } +} diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 71985ed57..9f7da2eb1 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -2,14 +2,12 @@ import express, { NextFunction, Request, Response } from "express"; import rateLimit from "express-rate-limit"; import http from "http"; import ipAnonymize from "ip-anonymize"; -import { base64url } from "jose"; import path from "path"; import { fileURLToPath } from "url"; import { WebSocket, WebSocketServer } from "ws"; import { z } from "zod"; import { GameEnv } from "../core/configuration/Config"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; -import { COSMETICS } from "../core/CosmeticSchemas"; import { GameType } from "../core/game/Game"; import { ClientMessageSchema, @@ -25,7 +23,8 @@ import { GameManager } from "./GameManager"; import { gatekeeper, LimiterType } from "./Gatekeeper"; import { getUserMe, verifyClientToken } from "./jwt"; import { logger } from "./Logger"; -import { PrivilegeChecker } from "./Privilege"; + +import { PrivilegeRefresher } from "./PrivilegeRefresher"; import { initWorkerMetrics } from "./WorkerMetrics"; const config = getServerConfigFromServer(); @@ -34,7 +33,7 @@ const workerId = parseInt(process.env.WORKER_ID ?? "0"); const log = logger.child({ comp: `w_${workerId}` }); // Worker setup -export function startWorker() { +export async function startWorker() { log.info(`Worker starting...`); const __filename = fileURLToPath(import.meta.url); @@ -46,12 +45,16 @@ export function startWorker() { const gm = new GameManager(config, log); - const privilegeChecker = new PrivilegeChecker(COSMETICS, base64url.decode); - if (config.otelEnabled()) { initWorkerMetrics(gm); } + const privilegeRefresher = new PrivilegeRefresher( + config.jwtIssuer() + "/cosmetics.json", + log, + ); + privilegeRefresher.start(); + // Middleware to handle /wX path prefix app.use((req, res, next) => { // Extract the original path without the worker prefix @@ -396,11 +399,9 @@ export function startWorker() { // Check if the flag is allowed if (clientMsg.flag !== undefined) { if (clientMsg.flag.startsWith("!")) { - const allowed = privilegeChecker.isCustomFlagAllowed( - clientMsg.flag, - roles, - flares, - ); + const allowed = privilegeRefresher + .get() + .isCustomFlagAllowed(clientMsg.flag, flares); if (allowed !== true) { log.warn(`Custom flag ${allowed}: ${clientMsg.flag}`); ws.close(1002, `Custom flag ${allowed}`); @@ -411,11 +412,9 @@ export function startWorker() { // Check if the pattern is allowed if (clientMsg.pattern !== undefined) { - const allowed = privilegeChecker.isPatternAllowed( - clientMsg.pattern, - roles, - flares, - ); + const allowed = privilegeRefresher + .get() + .isPatternAllowed(clientMsg.pattern, flares); if (allowed !== true) { log.warn(`Pattern ${allowed}: ${clientMsg.pattern}`); ws.close(1002, `Pattern ${allowed}`); diff --git a/tests/server/Privilege.customFlag.test.ts b/tests/server/Privilege.customFlag.test.ts index 330f00f30..b314b499f 100644 --- a/tests/server/Privilege.customFlag.test.ts +++ b/tests/server/Privilege.customFlag.test.ts @@ -1,5 +1,5 @@ import type { Cosmetics } from "../../src/core/CosmeticSchemas"; -import { PrivilegeChecker } from "../../src/server/Privilege"; +import { PrivilegeCheckerImpl } from "../../src/server/Privilege"; describe("PrivilegeChecker.isCustomFlagAllowed (with mock cosmetics)", () => { const dummyPatternDecoder = (_base64: string) => { @@ -7,181 +7,89 @@ describe("PrivilegeChecker.isCustomFlagAllowed (with mock cosmetics)", () => { }; const mockCosmetics: Cosmetics = { - role_groups: { - donor: ["role_donor"], - admin: ["role_admin"], - }, patterns: {}, flag: { layers: { a: { name: "chocolate", - role_group: "donor", flares: ["cosmetic:flags"], }, b: { name: "center_hline" }, - c: { name: "admin_layer", role_group: "admin" }, + c: { name: "admin_layer" }, }, color: { - a: { color: "#ff0000", name: "red", role_group: "admin" }, + a: { color: "#ff0000", name: "red", flares: ["cosmetic:red"] }, b: { color: "#00ff00", name: "green" }, c: { color: "#0000ff", name: "blue", flares: ["cosmetic:blue"] }, }, }, }; - const checker = new PrivilegeChecker(mockCosmetics, dummyPatternDecoder); + const checker = new PrivilegeCheckerImpl(mockCosmetics, dummyPatternDecoder); it("allowed: unrestricted layer/color", () => { - expect(checker.isCustomFlagAllowed("!b-b", [], [])).toBe(true); - }); - - it("restricted: donor layer without role", () => { - expect(checker.isCustomFlagAllowed("!a-b", [], [])).toBe("restricted"); - }); - - it("allowed: donor layer with donor role", () => { - expect(checker.isCustomFlagAllowed("!a-b", ["role_donor"], [])).toBe(true); + expect(checker.isCustomFlagAllowed("!b-b", [])).toBe(true); }); it("allowed: donor layer with correct flare", () => { - expect(checker.isCustomFlagAllowed("!a-b", [], ["cosmetic:flags"])).toBe( - true, - ); - }); - - it("restricted: admin color without role", () => { - expect(checker.isCustomFlagAllowed("!b-a", [], [])).toBe("restricted"); - }); - - it("allowed: admin color with admin role", () => { - expect(checker.isCustomFlagAllowed("!b-a", ["role_admin"], [])).toBe(true); + expect(checker.isCustomFlagAllowed("!a-b", ["cosmetic:flags"])).toBe(true); }); it("allowed: color with correct flare", () => { - expect(checker.isCustomFlagAllowed("!b-c", [], ["cosmetic:blue"])).toBe( - true, - ); + expect(checker.isCustomFlagAllowed("!b-c", ["cosmetic:blue"])).toBe(true); }); it("invalid: non-existent layer", () => { - expect(checker.isCustomFlagAllowed("!zzz-a", ["role_donor"], [])).toBe( - "invalid", - ); + expect(checker.isCustomFlagAllowed("!zzz-a", [])).toBe("invalid"); }); it("invalid: non-existent color", () => { - expect(checker.isCustomFlagAllowed("!a-zzz", ["role_donor"], [])).toBe( - "invalid", - ); + expect(checker.isCustomFlagAllowed("!a-zzz", [])).toBe("invalid"); }); it("allowed: superFlare allows all listed", () => { - expect(checker.isCustomFlagAllowed("!a-a", [], ["flag:*"])).toBe(true); - expect(checker.isCustomFlagAllowed("!b-b", [], ["flag:*"])).toBe(true); - expect(checker.isCustomFlagAllowed("!c-a", [], ["flag:*"])).toBe(true); - expect(checker.isCustomFlagAllowed("!a-c", [], ["flag:*"])).toBe(true); + expect(checker.isCustomFlagAllowed("!a-a", ["flag:*"])).toBe(true); + expect(checker.isCustomFlagAllowed("!b-b", ["flag:*"])).toBe(true); + expect(checker.isCustomFlagAllowed("!c-a", ["flag:*"])).toBe(true); + expect(checker.isCustomFlagAllowed("!a-c", ["flag:*"])).toBe(true); }); it("invalid: superFlare does not allow non-existent", () => { - expect(checker.isCustomFlagAllowed("!zzz-zzz", [], ["flag:*"])).toBe( - "invalid", - ); + expect(checker.isCustomFlagAllowed("!zzz-zzz", ["flag:*"])).toBe("invalid"); }); it("allowed: flare flag:layer:chocolate allows chocolate layer", () => { - expect( - checker.isCustomFlagAllowed("!a-b", [], ["flag:layer:chocolate"]), - ).toBe(true); - }); - it("allowed: flare flag:color:blue allows blue color", () => { - expect(checker.isCustomFlagAllowed("!b-c", [], ["flag:color:blue"])).toBe( + expect(checker.isCustomFlagAllowed("!a-b", ["flag:layer:chocolate"])).toBe( true, ); }); + it("allowed: flare flag:color:blue allows blue color", () => { + expect(checker.isCustomFlagAllowed("!b-c", ["flag:color:blue"])).toBe(true); + }); it("restricted: only color flare, layer still restricted", () => { - expect(checker.isCustomFlagAllowed("!a-c", [], ["cosmetic:blue"])).toBe( + expect(checker.isCustomFlagAllowed("!a-c", ["cosmetic:blue"])).toBe( "restricted", ); }); it("restricted: only layer flare, color still restricted", () => { - expect(checker.isCustomFlagAllowed("!c-a", [], ["cosmetic:flags"])).toBe( - "restricted", - ); - }); - it("allowed: layer by role, color by flare", () => { - // layer a: role_group donor, color c: flares ["cosmetic:blue"] - expect( - checker.isCustomFlagAllowed("!a-c", ["role_donor"], ["cosmetic:blue"]), - ).toBe(true); - }); - it("restricted: layer by role, color by flare (missing flare)", () => { - expect(checker.isCustomFlagAllowed("!a-c", ["role_donor"], [])).toBe( - "restricted", - ); - }); - it("restricted: layer by role, color by flare (missing role)", () => { - expect(checker.isCustomFlagAllowed("!a-c", [], ["cosmetic:blue"])).toBe( - "restricted", - ); - }); - it("allowed: layer by flare, color by role", () => { - // layer a: flares ["cosmetic:flags"], color a: role_group admin - expect( - checker.isCustomFlagAllowed("!a-a", ["role_admin"], ["cosmetic:flags"]), - ).toBe(true); - }); - it("restricted: layer by flare, color by role (missing flare)", () => { - expect(checker.isCustomFlagAllowed("!a-a", ["role_admin"], [])).toBe( - "restricted", - ); - }); - it("restricted: layer by flare, color by role (missing role)", () => { - expect(checker.isCustomFlagAllowed("!a-a", [], ["cosmetic:flags"])).toBe( + expect(checker.isCustomFlagAllowed("!c-a", ["cosmetic:flags"])).toBe( "restricted", ); }); + it("allowed: two segments, both unrestricted", () => { - expect(checker.isCustomFlagAllowed("!b-b_b-b", [], [])).toBe(true); - }); - it("restricted: two segments, one restricted by layer role", () => { - expect(checker.isCustomFlagAllowed("!a-b_b-b", [], [])).toBe("restricted"); - expect(checker.isCustomFlagAllowed("!a-b_b-b", ["role_donor"], [])).toBe( - true, - ); - }); - it("restricted: two segments, one restricted by color role", () => { - expect(checker.isCustomFlagAllowed("!b-a_b-b", [], [])).toBe("restricted"); - expect(checker.isCustomFlagAllowed("!b-a_b-b", ["role_admin"], [])).toBe( - true, - ); - }); - it("allowed: two segments, one by role, one by flare", () => { - expect( - checker.isCustomFlagAllowed( - "!a-c_b-b", - ["role_donor"], - ["cosmetic:blue"], - ), - ).toBe(true); - expect(checker.isCustomFlagAllowed("!a-c_b-b", ["role_donor"], [])).toBe( - "restricted", - ); - expect(checker.isCustomFlagAllowed("!a-c_b-b", [], ["cosmetic:blue"])).toBe( - "restricted", - ); + expect(checker.isCustomFlagAllowed("!b-b_b-b", [])).toBe(true); }); it("allowed: two segments, both by flare", () => { expect( - checker.isCustomFlagAllowed( - "!a-c_a-c", - [], - ["cosmetic:flags", "cosmetic:blue"], - ), + checker.isCustomFlagAllowed("!a-c_a-c", [ + "cosmetic:flags", + "cosmetic:blue", + ]), ).toBe(true); - expect( - checker.isCustomFlagAllowed("!a-c_a-c", [], ["cosmetic:flags"]), - ).toBe("restricted"); - expect(checker.isCustomFlagAllowed("!a-c_a-c", [], ["cosmetic:blue"])).toBe( + expect(checker.isCustomFlagAllowed("!a-c_a-c", ["cosmetic:flags"])).toBe( + "restricted", + ); + expect(checker.isCustomFlagAllowed("!a-c_a-c", ["cosmetic:blue"])).toBe( "restricted", ); });