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
+1
View File
@@ -9,3 +9,4 @@ resources/.DS_Store
.env*
.DS_Store
.clinic/
CLAUDE.md
-24
View File
@@ -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
+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}`);
+30 -122
View File
@@ -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",
);
});