1
0
Fork 0

feat(shims): add targeted client-side injections

This commit is contained in:
Jason Fraley 2026-08-26 00:53:31 -04:00
parent 0771424446
commit 83f203d736
7 changed files with 599 additions and 172 deletions

View file

@ -20,10 +20,11 @@ import { CommandPalette } from "@/components/command-palette/CommandPalette";
import { CommandPaletteProvider } from "@/components/command-palette/CommandPaletteContext";
import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider";
import { isSuperuser } from "@/utils/access-control/hasPermission";
import { Rank } from "@/payload-types";
import { Rank, Shim } from "@/payload-types";
import { cookies } from "next/headers";
import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation";
import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner";
import { ShimLoader } from "@/components/frontend/shims/ShimLoader";
// Skip static prerendering of this layout (and all pages under (frontend)/).
// The layout calls `getPayload()` at render time to resolve the current user,
@ -67,6 +68,9 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
let isLogistics = false;
let currentRank = "";
const shimsGlobal = await payload.findGlobal({ slug: "shims" });
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
if (user) {
[isIntelligence, isLogistics] = await Promise.all([
hasIntelligenceQualification(payload, user).catch(() => false),
@ -152,6 +156,11 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
{user && <GameTickRealtime />}
{isLogistics && <ShipmentToasts />}
<Toaster />
<ShimLoader
shims={shimEntries}
userId={user?.id ?? null}
userRoles={user?.roles ?? []}
/>
</body>
</html>
);

View file

@ -11,6 +11,7 @@ import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/component
import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor'
import { SelectField as SelectField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
import { PromptEditorField as PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
import { default as default_b16af798585cda7cfed92f7b1dd0c707 } from '@/components/admin/shims/ShimContentField'
import { default as default_2f6c866ab4d4d51b3789f090b84b85c7 } from '@/components/static/VersionOverlay'
import { default as default_508743824696caae2894ebf34c2e68bb } from '@/components/graphics/AppIcon'
import { default as default_e752fee78378294ff03f91dba0a75a04 } from '@/components/graphics/AppLogo'
@ -34,6 +35,7 @@ export const importMap = {
"@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759,
"@ai-stack/payloadcms/client#SelectField": SelectField_4490b89d4413c1ffaecdacfe72efaf73,
"@ai-stack/payloadcms/client#PromptEditorField": PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73,
"@/components/admin/shims/ShimContentField#default": default_b16af798585cda7cfed92f7b1dd0c707,
"@/components/static/VersionOverlay#default": default_2f6c866ab4d4d51b3789f090b84b85c7,
"@/components/graphics/AppIcon#default": default_508743824696caae2894ebf34c2e68bb,
"@/components/graphics/AppLogo#default": default_e752fee78378294ff03f91dba0a75a04,

View file

@ -4,8 +4,8 @@ import { requirePermission } from "@/utils/access-control/hasPermission";
export const Shims: GlobalConfig = {
slug: "shims",
access: {
update: requirePermission("shims:update"),
read: requirePermission("shims:read"),
update: requirePermission("shims:update"),
},
fields: [
{
@ -17,22 +17,137 @@ export const Shims: GlobalConfig = {
type: "text",
required: true,
unique: true,
admin: {
description: "Unique identifier for this shim. Used internally for stable hashing.",
},
},
{
name: "summary",
type: "text",
admin: {
description: "Brief description of what this shim does (for admin reference).",
},
},
{
name: "enabled",
type: "checkbox",
defaultValue: false,
admin: {
description: "Disabled shims are never injected, regardless of targeting rules.",
},
},
{
name: "type",
type: "select",
options: [
{ label: "CSS", value: "css" },
{ label: "JavaScript", value: "js" },
],
required: true,
defaultValue: "css",
},
{
name: "content",
type: "code",
required: true,
admin: {
components: {
Field: "@/components/admin/shims/ShimContentField",
},
description:
"CSS or JS code to inject. CSS is added as a <style> tag; JS is added as an inline <script>.",
},
},
{
name: "type",
name: "targeting",
type: "group",
label: "Targeting",
admin: {
description: "Who should this shim be applied to?",
},
fields: [
{
name: "audience",
type: "select",
options: ["css", "js"],
required: true,
defaultValue: "css",
defaultValue: "all",
options: [
{ label: "All Users", value: "all" },
{ label: "Percentage of Users", value: "percentage" },
{ label: "Specific Users", value: "users" },
{ label: "By Role", value: "roles" },
],
},
{
name: "percentage",
type: "number",
min: 0,
max: 100,
defaultValue: 50,
admin: {
condition: (_data, siblingData) => siblingData?.audience === "percentage",
description:
"Percentage of users (0–100) who receive this shim. Assignment is deterministic per user.",
},
},
{
name: "users",
type: "relationship",
relationTo: "users",
hasMany: true,
admin: {
condition: (_data, siblingData) => siblingData?.audience === "users",
description: "Only these specific users will receive this shim.",
},
},
{
name: "roles",
type: "select",
hasMany: true,
options: [
{ label: "Guest", value: "guest" },
{ label: "User", value: "user" },
{ label: "Trusted", value: "trusted" },
{ label: "Admin", value: "admin" },
{ label: "Developer", value: "developer" },
],
admin: {
condition: (_data, siblingData) => siblingData?.audience === "roles",
description: "Users with any of these roles will receive this shim.",
},
},
],
},
{
name: "paths",
type: "group",
label: "Path Matching",
admin: {
description: "Which pages should this shim be active on?",
},
fields: [
{
name: "matchType",
type: "select",
required: true,
defaultValue: "all",
options: [
{ label: "All Pages", value: "all" },
{ label: "Include Only", value: "include" },
{ label: "Exclude", value: "exclude" },
],
},
{
name: "paths",
type: "text",
hasMany: true,
admin: {
condition: (_data, siblingData) => siblingData?.matchType !== "all",
description:
"URL path prefixes to match. E.g. '/logistics' matches /logistics and /logistics/market. Leading slash required.",
},
},
],
},
],
},

View file

@ -0,0 +1,30 @@
"use client";
import { CodeField, useFormFields } from "@payloadcms/ui";
import type { CodeFieldClientComponent } from "payload";
const languageForShimType = (type: unknown) =>
type === "css" ? "css" : "javascript";
/** Keeps Payload's standard code editor while selecting its language per shim row. */
export const ShimContentField: CodeFieldClientComponent = (props) => {
const typePath = props.path.replace(/\.content$/, ".type");
const shimType = useFormFields(([fields]) => fields[typePath]?.value);
const language = languageForShimType(shimType);
return (
<CodeField
{...props}
key={language}
field={{
...props.field,
admin: {
...props.field.admin,
language,
},
}}
/>
);
};
export default ShimContentField;

View file

@ -0,0 +1,56 @@
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { isShimActive, type ShimEntry } from "@/lib/shims/evaluate";
interface ShimLoaderProps {
shims: ShimEntry[];
userId: number | null;
userRoles: string[];
}
const SHIM_ATTR = "data-ptf-shim";
/**
* Client component that evaluates active shims and injects their CSS/JS
* into the document <head>. Shims are re-evaluated on route changes.
*
* Shims are injected as <style data-ptf-shim="name"> (CSS) or
* <script data-ptf-shim="name"> (JS) elements. On cleanup or route change,
* previously injected elements are removed before new ones are added.
*/
export function ShimLoader({ shims, userId, userRoles }: ShimLoaderProps) {
const pathname = usePathname();
useEffect(() => {
// Remove any previously injected shims
const existing = document.querySelectorAll(`[${SHIM_ATTR}]`);
existing.forEach((el) => el.remove());
// Evaluate and inject active shims
for (const shim of shims) {
if (!isShimActive(shim, userId, userRoles, pathname)) continue;
if (shim.type === "css") {
const style = document.createElement("style");
style.setAttribute(SHIM_ATTR, shim.name);
style.textContent = shim.content;
document.head.appendChild(style);
} else if (shim.type === "js") {
const script = document.createElement("script");
script.setAttribute(SHIM_ATTR, shim.name);
script.textContent = shim.content;
document.head.appendChild(script);
}
}
// Cleanup: remove all injected shims on unmount
return () => {
document.querySelectorAll(`[${SHIM_ATTR}]`).forEach((el) => el.remove());
};
}, [shims, userId, userRoles, pathname]);
// No visible output — all work happens in the DOM
return null;
}

153
src/lib/shims/evaluate.ts Normal file
View file

@ -0,0 +1,153 @@
/**
* Shims targeting evaluation — pure functions, no server dependencies.
*
* Determines whether a given shim should be active for a specific user on
* a specific path. All functions are side-effect free and safe to call in
* client components.
*/
// ---------------------------------------------------------------------------
// Types (mirrors what Payload will generate for the shims global)
// ---------------------------------------------------------------------------
export type ShimAudience = "all" | "percentage" | "users" | "roles";
export type ShimPathMatch = "all" | "include" | "exclude";
export interface ShimTargeting {
audience: ShimAudience;
/** 0–100, used when audience = "percentage". */
percentage?: number | null;
/** User IDs, used when audience = "users". */
users?: Array<number | { id: number }> | null;
/** Role strings, used when audience = "roles". */
roles?: string[] | null;
}
export interface ShimPathConfig {
matchType: ShimPathMatch;
paths?: string[] | null;
}
export interface ShimEntry {
name: string;
summary?: string | null;
enabled?: boolean | null;
type: "css" | "js";
content: string;
targeting: ShimTargeting;
paths?: ShimPathConfig | null;
}
// ---------------------------------------------------------------------------
// Deterministic hash — djb2
// ---------------------------------------------------------------------------
/**
* Simple string hash → unsigned 32-bit integer.
* Deterministic: same input always produces the same output.
* Used for stable per-user percentage assignments.
*/
export function djb2Hash(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
}
return hash;
}
/**
* Returns a value in [0, 100) for a given userId + shimName combination.
* Deterministic and uniform enough for percentage rollouts.
*/
export function hashForUser(userId: number, shimName: string): number {
const hash = djb2Hash(`${userId}:${shimName}`);
return hash % 100;
}
// ---------------------------------------------------------------------------
// Path matching
// ---------------------------------------------------------------------------
/**
* Checks whether the current pathname matches the shim's path config.
*
* - matchType "all" → always matches.
* - matchType "include" → pathname must start with one of the listed prefixes.
* - matchType "exclude" → pathname must NOT start with any listed prefix.
*/
export function matchesPath(pathname: string, pathConfig?: ShimPathConfig | null): boolean {
if (!pathConfig || pathConfig.matchType === "all") return true;
const prefixes = pathConfig.paths ?? [];
if (prefixes.length === 0) return true;
const matched = prefixes.some((prefix) => {
const normalized = prefix.startsWith("/") ? prefix : `/${prefix}`;
return pathname === normalized || pathname.startsWith(`${normalized}/`);
});
return pathConfig.matchType === "include" ? matched : !matched;
}
// ---------------------------------------------------------------------------
// Audience targeting
// ---------------------------------------------------------------------------
/**
* Checks whether a user is in the shim's target audience.
* Does NOT check enabled or path — call matchesPath() and check enabled separately.
*/
export function matchesAudience(
shim: ShimEntry,
userId: number | null,
userRoles: string[],
): boolean {
if (!shim.targeting) return true;
const { audience } = shim.targeting;
switch (audience) {
case "all":
return true;
case "percentage": {
if (userId === null) return false;
const threshold = shim.targeting.percentage ?? 0;
return hashForUser(userId, shim.name) < threshold;
}
case "users": {
if (userId === null) return false;
const targetUsers = shim.targeting.users ?? [];
return targetUsers.some((u) => (typeof u === "object" ? u.id : u) === userId);
}
case "roles": {
const targetRoles = shim.targeting.roles ?? [];
return targetRoles.some((role) => userRoles.includes(role));
}
default:
return true;
}
}
// ---------------------------------------------------------------------------
// Full evaluation
// ---------------------------------------------------------------------------
/**
* Returns true if the shim should be active for this user on this path.
* Checks: enabled → audience targeting → path matching.
*/
export function isShimActive(
shim: ShimEntry,
userId: number | null,
userRoles: string[],
pathname: string,
): boolean {
if (!shim.enabled) return false;
if (!matchesAudience(shim, userId, userRoles)) return false;
if (!matchesPath(pathname, shim.paths)) return false;
return true;
}

View file

@ -1118,171 +1118,10 @@ export interface Campaign {
* Background image for the campaign selector card on the campaigns page. Renders translucent with hover effects. Recommended aspect ratio: 16:9 or square.
*/
coverImage?: (number | null) | Media;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "game-vehicles".
*/
export interface GameVehicle {
id: number;
/**
* Display name or callsign for this vehicle instance.
* User who created this campaign. Set automatically on create.
*/
name: string;
/**
* The vehicle type/template this instance is based on.
*/
type: number | Vehicle;
/**
* The structure where this vehicle is currently stationed. Null if in transit.
*/
deployedAt?: (number | null) | GameStructure;
/**
* The faction that owns this vehicle.
*/
faction?: (number | null) | Faction;
status: 'idle' | 'assigned' | 'in_transit' | 'damaged' | 'destroyed';
/**
* Current fuel level. Must be between 0 and the vehicle type's fuel capacity.
*/
currentFuel: number;
/**
* Current health. Must be between 0 and the vehicle type's max health.
*/
currentHealth: number;
storedResources?:
| {
resource: number | Resource;
amount: number;
gridX: number;
gridY: number;
rotated?: boolean | null;
id?: string | null;
}[]
| null;
/**
* Items displaced by grid resizing.
*/
voidStorage?:
| {
resource: number | Resource;
amount: number;
gridX: number;
gridY: number;
rotated?: boolean | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "game-npcs".
*/
export interface GameNpc {
id: number;
/**
* The NPC's name as shown in-game and on the frontend.
*/
name: string;
/**
* What this NPC does (e.g. 'Weapons Dealer', 'Field Medic').
*/
occupation?: string | null;
/**
* Where this NPC is stationed in-game.
*/
location?: (number | null) | GameStructure;
/**
* The faction this NPC belongs to, if any.
*/
faction?: (number | null) | Faction;
/**
* A short background/profile for this NPC.
*/
bio?: string | null;
/**
* True for NPCs auto-created by the market tick to cover unstocked goods. They are placeholders until included in the game.
*/
isGenerated?: boolean | null;
/**
* Whether this NPC is officially part of the game. Generated NPCs start off; flip this on to include them.
*/
isInGame?: boolean | null;
vendor?: {
/**
* Whether this NPC runs a market stall. Auto-generated vendor stock is attributed to them.
*/
enabled?: boolean | null;
/**
* Multiplies the auto-generated price for this NPC's stock (1 = base market price).
*/
priceModifier?: number | null;
/**
* The goods this NPC sells. The market tick prefers these NPCs when stocking those assets.
*/
sells?: (number | Asset)[] | null;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "game-event-logs".
*/
export interface GameEventLog {
id: number;
/**
* System-generated events are created automatically. Uncheck for manual/GM narrative entries.
*/
system?: boolean | null;
timestamp: string;
type: string;
message: string;
actor?: (number | null) | User;
structure?: (number | null) | GameStructure;
/**
* Which collection the event primarily relates to.
*/
targetCollection?:
| (
| 'game-structures'
| 'game-vehicles'
| 'game-npcs'
| 'structures'
| 'resources'
| 'assets'
| 'vehicles'
| 'factions'
| 'missions'
| 'campaigns'
| 'users'
| 'technologies'
| 'maps'
| 'shipments'
| 'bank-accounts'
| 'bank-transactions'
| 'ledger-entries'
| 'locker-storages'
| 'loadouts'
| 'market-listings'
| 'market-negotiations'
| 'tickets'
)
| null;
targetId?: number | null;
data?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
owner?: (number | null) | User;
updatedAt: string;
createdAt: string;
}
@ -1454,6 +1293,7 @@ export interface Role {
| 'campaigns:read'
| 'campaigns:update'
| 'campaigns:delete'
| 'campaigns:manage'
| 'factions:create'
| 'factions:read'
| 'factions:update'
@ -1594,6 +1434,171 @@ export interface Role {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "game-vehicles".
*/
export interface GameVehicle {
id: number;
/**
* Display name or callsign for this vehicle instance.
*/
name: string;
/**
* The vehicle type/template this instance is based on.
*/
type: number | Vehicle;
/**
* The structure where this vehicle is currently stationed. Null if in transit.
*/
deployedAt?: (number | null) | GameStructure;
/**
* The faction that owns this vehicle.
*/
faction?: (number | null) | Faction;
status: 'idle' | 'assigned' | 'in_transit' | 'damaged' | 'destroyed';
/**
* Current fuel level. Must be between 0 and the vehicle type's fuel capacity.
*/
currentFuel: number;
/**
* Current health. Must be between 0 and the vehicle type's max health.
*/
currentHealth: number;
storedResources?:
| {
resource: number | Resource;
amount: number;
gridX: number;
gridY: number;
rotated?: boolean | null;
id?: string | null;
}[]
| null;
/**
* Items displaced by grid resizing.
*/
voidStorage?:
| {
resource: number | Resource;
amount: number;
gridX: number;
gridY: number;
rotated?: boolean | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "game-npcs".
*/
export interface GameNpc {
id: number;
/**
* The NPC's name as shown in-game and on the frontend.
*/
name: string;
/**
* What this NPC does (e.g. 'Weapons Dealer', 'Field Medic').
*/
occupation?: string | null;
/**
* Where this NPC is stationed in-game.
*/
location?: (number | null) | GameStructure;
/**
* The faction this NPC belongs to, if any.
*/
faction?: (number | null) | Faction;
/**
* A short background/profile for this NPC.
*/
bio?: string | null;
/**
* True for NPCs auto-created by the market tick to cover unstocked goods. They are placeholders until included in the game.
*/
isGenerated?: boolean | null;
/**
* Whether this NPC is officially part of the game. Generated NPCs start off; flip this on to include them.
*/
isInGame?: boolean | null;
vendor?: {
/**
* Whether this NPC runs a market stall. Auto-generated vendor stock is attributed to them.
*/
enabled?: boolean | null;
/**
* Multiplies the auto-generated price for this NPC's stock (1 = base market price).
*/
priceModifier?: number | null;
/**
* The goods this NPC sells. The market tick prefers these NPCs when stocking those assets.
*/
sells?: (number | Asset)[] | null;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "game-event-logs".
*/
export interface GameEventLog {
id: number;
/**
* System-generated events are created automatically. Uncheck for manual/GM narrative entries.
*/
system?: boolean | null;
timestamp: string;
type: string;
message: string;
actor?: (number | null) | User;
structure?: (number | null) | GameStructure;
/**
* Which collection the event primarily relates to.
*/
targetCollection?:
| (
| 'game-structures'
| 'game-vehicles'
| 'game-npcs'
| 'structures'
| 'resources'
| 'assets'
| 'vehicles'
| 'factions'
| 'missions'
| 'campaigns'
| 'users'
| 'technologies'
| 'maps'
| 'shipments'
| 'bank-accounts'
| 'bank-transactions'
| 'ledger-entries'
| 'locker-storages'
| 'loadouts'
| 'market-listings'
| 'market-negotiations'
| 'tickets'
)
| null;
targetId?: number | null;
data?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ranks".
@ -3663,6 +3668,7 @@ export interface CampaignsSelect<T extends boolean = true> {
startDate?: T;
endDate?: T;
coverImage?: T;
owner?: T;
updatedAt?: T;
createdAt?: T;
}
@ -4530,10 +4536,51 @@ export interface Shim {
id: number;
shims?:
| {
/**
* Unique identifier for this shim. Used internally for stable hashing.
*/
name: string;
/**
* Brief description of what this shim does (for admin reference).
*/
summary?: string | null;
content: string;
/**
* Disabled shims are never injected, regardless of targeting rules.
*/
enabled?: boolean | null;
type: 'css' | 'js';
/**
* CSS or JS code to inject. CSS is added as a <style> tag; JS is added as an inline <script>.
*/
content: string;
/**
* Who should this shim be applied to?
*/
targeting: {
audience: 'all' | 'percentage' | 'users' | 'roles';
/**
* Percentage of users (0–100) who receive this shim. Assignment is deterministic per user.
*/
percentage?: number | null;
/**
* Only these specific users will receive this shim.
*/
users?: (number | User)[] | null;
/**
* Users with any of these roles will receive this shim.
*/
roles?: ('guest' | 'user' | 'trusted' | 'admin' | 'developer')[] | null;
};
/**
* Which pages should this shim be active on?
*/
paths: {
matchType: 'all' | 'include' | 'exclude';
/**
* URL path prefixes to match. E.g. '/logistics' matches /logistics and /logistics/market. Leading slash required.
*/
paths?: string[] | null;
};
id?: string | null;
}[]
| null;
@ -4566,8 +4613,23 @@ export interface ShimsSelect<T extends boolean = true> {
| {
name?: T;
summary?: T;
content?: T;
enabled?: T;
type?: T;
content?: T;
targeting?:
| T
| {
audience?: T;
percentage?: T;
users?: T;
roles?: T;
};
paths?:
| T
| {
matchType?: T;
paths?: T;
};
id?: T;
};
updatedAt?: T;