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
This commit is contained in:
evanpelle
2025-08-04 16:48:41 -07:00
committed by GitHub
parent 3c63e3ffd8
commit 00668dd924
10 changed files with 282 additions and 379 deletions
+62 -126
View File
@@ -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<string, string>;
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<Pattern[]> {
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<string, StripeProduct>,
) {
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<Map<string, StripeProduct>> {
async function getCosmetics(): Promise<Cosmetics | undefined> {
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<string, StripeProduct>();
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);
}
}
+13 -12
View File
@@ -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`
<div
class="fixed z-[10000] px-3 py-2 rounded bg-black text-white text-sm pointer-events-none shadow-md"
style="top: ${this.hoverPosition.y + 12}px; left: ${this.hoverPosition
.x + 12}px;"
>
${this.hoveredPattern.lockedReason}
${translateText("territory_patterns.blocked.purchase")}
</div>
`;
}
@@ -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`
<div style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);">
@@ -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,
)}
</div>
</button>
${pattern.priceId !== undefined && pattern.lockedReason
${pattern.product !== null
? html`
<button
class="w-full mt-2 px-3 py-1 bg-green-500 hover:bg-green-600 text-white text-xs font-medium rounded transition-colors"
@click=${(e: Event) => {
e.stopPropagation();
handlePurchase(pattern.priceId!);
handlePurchase(pattern.product!.priceId);
}}
>
${translateText("territory_patterns.purchase")}
(${pattern.price})
(${pattern.product!.price})
</button>
`
: 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 };
}
+35 -30
View File
@@ -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<typeof CosmeticsSchema>;
export const COSMETICS: Cosmetics = CosmeticsSchema.parse(cosmetics_json);
export type Pattern = z.infer<typeof PatternSchema>;
export type Product = z.infer<typeof ProductSchema>;
+15 -4
View File
@@ -1,4 +1,4 @@
import { COSMETICS } from "./CosmeticSchemas";
import { Cosmetics } from "./CosmeticSchemas";
const ANIMATION_DURATIONS: Record<string, number> = {
rainbow: 4000,
@@ -11,7 +11,18 @@ const ANIMATION_DURATIONS: Record<string, number> = {
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);
+43 -45
View File
@@ -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<string, Pattern> = {};
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;
}
}
+68
View File
@@ -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<void> {
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;
}
}
}
+15 -16
View File
@@ -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}`);