openvibers/source.openvibe.games Commits
Priority 9+10: script packages API, migration, and ItemType expansion
Changes
nnew file mode 100644@@@ -0,0 +1,43 @@++-- 002_script_packages.sql++-- Adds script package registry tables and expands the shop_items item_type++-- constraint to include the new gamemode-cosmetic item types.++-- Apply after 001_init.sql.++++-- Priority 10: expand shop_items item_type to include gamemode cosmetics.++ALTER TABLE shop_items++ DROP CONSTRAINT IF EXISTS shop_items_item_type_check;++ALTER TABLE shop_items++ ADD CONSTRAINT shop_items_item_type_check++ CHECK (item_type IN (++ 'player_model', 'trail', 'nameplate',++ 'title', 'spray', 'emote', 'fortwars_part', 'traitortown_cosmetic'++ ));++++-- Priority 9: trusted script package registry.++-- Packages are admin-uploaded and must be explicitly enabled before the++-- runtime will load them. Community uploads require a separate sandboxing++-- review step; only trusted=true packages are served.++CREATE TABLE IF NOT EXISTS script_packages (++ package_id TEXT PRIMARY KEY,++ package_type TEXT NOT NULL CHECK (package_type IN ('gamemode', 'addon', 'library')),++ display_name TEXT NOT NULL,++ description TEXT NOT NULL DEFAULT '',++ version TEXT NOT NULL,++ author_steam_id BIGINT,++ manifest_json JSONB NOT NULL DEFAULT '{}'::jsonb,++ trusted BOOLEAN NOT NULL DEFAULT false,++ enabled BOOLEAN NOT NULL DEFAULT false,++ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),++ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()++);++++CREATE TABLE IF NOT EXISTS script_package_files (++ package_id TEXT NOT NULL REFERENCES script_packages(package_id) ON DELETE CASCADE,++ path TEXT NOT NULL,++ sha256 TEXT NOT NULL,++ size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0),++ realm TEXT NOT NULL CHECK (realm IN ('server', 'client', 'shared')),++ content TEXT NOT NULL,++ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),++ PRIMARY KEY (package_id, path)++);@@@ -775,9 +775,6 @@"arm64"],"dev": true,-- "libc": [-- "glibc"-- ],"license": "MIT","optional": true,"os": [@@@ -795,9 +792,6 @@"arm64"],"dev": true,-- "libc": [-- "musl"-- ],"license": "MIT","optional": true,"os": [@@@ -815,9 +809,6 @@"ppc64"],"dev": true,-- "libc": [-- "glibc"-- ],"license": "MIT","optional": true,"os": [@@@ -835,9 +826,6 @@"s390x"],"dev": true,-- "libc": [-- "glibc"-- ],"license": "MIT","optional": true,"os": [@@@ -855,9 +843,6 @@"x64"],"dev": true,-- "libc": [-- "glibc"-- ],"license": "MIT","optional": true,"os": [@@@ -875,9 +860,6 @@"x64"],"dev": true,-- "libc": [-- "musl"-- ],"license": "MIT","optional": true,"os": [@@@ -1787,9 +1769,6 @@"arm64"],"dev": true,-- "libc": [-- "glibc"-- ],"license": "MPL-2.0","optional": true,"os": [@@@ -1811,9 +1790,6 @@"arm64"],"dev": true,-- "libc": [-- "musl"-- ],"license": "MPL-2.0","optional": true,"os": [@@@ -1835,9 +1811,6 @@"x64"],"dev": true,-- "libc": [-- "glibc"-- ],"license": "MPL-2.0","optional": true,"os": [@@@ -1859,9 +1832,6 @@"x64"],"dev": true,-- "libc": [-- "musl"-- ],"license": "MPL-2.0","optional": true,"os": [@@@ -17,10 +17,13 @@ import {leaderboardQuerySchema,listServersQuerySchema,matchEndSchema,++ packageIdParamSchema,partyInviteSchema,partyTravelSchema,registerServerSchema,travelRequestSchema,++ upsertScriptPackageFileSchema,++ upsertScriptPackageSchema,upsertShopItemSchema,validateJoinTokenSchema,} from "./schemas.js";@@@ -305,6 +308,49 @@ export async function createApp(options: AppOptions): Promise<FastifyInstance> {return { events: await options.repository.listAuditEvents(query) };});++ // Script packages++ app.get("/v1/scripts/packages", async () => {++ return { packages: await options.repository.listScriptPackages() };++ });++++ app.get("/v1/scripts/packages/:packageId", async (request, reply) => {++ const { packageId } = packageIdParamSchema.parse(request.params);++ const pkg = await options.repository.getScriptPackage(packageId);++ if (!pkg) return reply.code(404).send({ error: "not_found" });++ return pkg;++ });++++ app.post("/v1/admin/scripts/packages", async (request, reply) => {++ if (!requireAdmin(request, reply)) return;++ const body = upsertScriptPackageSchema.parse(request.body);++ return options.repository.upsertScriptPackage(body);++ });++++ app.post("/v1/admin/scripts/packages/:packageId/files", async (request, reply) => {++ if (!requireAdmin(request, reply)) return;++ const { packageId } = packageIdParamSchema.parse(request.params);++ const body = upsertScriptPackageFileSchema.parse(request.body);++ const pkg = await options.repository.getScriptPackage(packageId);++ if (!pkg) return reply.code(404).send({ error: "package_not_found" });++ return options.repository.upsertScriptPackageFile({ ...body, packageId });++ });++++ app.post("/v1/admin/scripts/packages/:packageId/enable", async (request, reply) => {++ if (!requireAdmin(request, reply)) return;++ const { packageId } = packageIdParamSchema.parse(request.params);++ const pkg = await options.repository.setScriptPackageEnabled(packageId, true);++ if (!pkg) return reply.code(404).send({ error: "not_found" });++ return pkg;++ });++++ app.post("/v1/admin/scripts/packages/:packageId/disable", async (request, reply) => {++ if (!requireAdmin(request, reply)) return;++ const { packageId } = packageIdParamSchema.parse(request.params);++ const pkg = await options.repository.setScriptPackageEnabled(packageId, false);++ if (!pkg) return reply.code(404).send({ error: "not_found" });++ return pkg;++ });++app.get("/metrics", async (_request, reply) => {const servers = await options.repository.listServers();const open = servers.filter((server) => server.state === "open").length;@@@ -4,7 +4,62 @@ export type GameMode = (typeof modes)[number];export type ServerState = "starting" | "open" | "full" | "ending" | "offline";--export type ItemType = "player_model" | "trail" | "nameplate";++export type ItemType =++ | "player_model"++ | "trail"++ | "nameplate"++ | "title"++ | "spray"++ | "emote"++ | "fortwars_part"++ | "traitortown_cosmetic";++++export type PackageType = "gamemode" | "addon" | "library";++export type PackageRealm = "server" | "client" | "shared";++++export interface ScriptPackage {++ packageId: string;++ packageType: PackageType;++ displayName: string;++ description: string;++ version: string;++ authorSteamId: string | null;++ manifestJson: Record<string, unknown>;++ trusted: boolean;++ enabled: boolean;++ createdAt: string;++ updatedAt: string;++}++++export interface ScriptPackageFile {++ packageId: string;++ path: string;++ sha256: string;++ sizeBytes: number;++ realm: PackageRealm;++ content: string;++ createdAt: string;++}++++export interface UpsertScriptPackageInput {++ packageId: string;++ packageType: PackageType;++ displayName: string;++ description: string;++ version: string;++ authorSteamId?: string | null;++ manifestJson?: Record<string, unknown>;++ trusted?: boolean;++}++++export interface UpsertScriptPackageFileInput {++ packageId: string;++ path: string;++ sha256: string;++ sizeBytes: number;++ realm: PackageRealm;++ content: string;++}export interface Player {steamId: string;@@@ -187,4 +242,11 @@ export interface OpenVibeRepository {reason: string;}): Promise<AuditEvent>;listAuditEvents(options: { limit: number }): Promise<AuditEvent[]>;++++ // Script packages++ listScriptPackages(): Promise<ScriptPackage[]>;++ getScriptPackage(packageId: string): Promise<ScriptPackage | null>;++ upsertScriptPackage(input: UpsertScriptPackageInput): Promise<ScriptPackage>;++ upsertScriptPackageFile(input: UpsertScriptPackageFileInput): Promise<ScriptPackageFile>;++ setScriptPackageEnabled(packageId: string, enabled: boolean): Promise<ScriptPackage | null>;}@@@ -15,8 +15,12 @@ import {Player,PlayerProfile,RegisterServerInput,++ ScriptPackage,++ ScriptPackageFile,ShopItem,TravelReservation,++ UpsertScriptPackageFileInput,++ UpsertScriptPackageInput,UpsertShopItemInput,} from "./domain.js";@@@ -71,6 +75,8 @@ export class MemoryOpenVibeRepository implements OpenVibeRepository {private invites = new Map<string, PartyInvite>();private auditEvents: AuditEvent[] = [];private rewardKeys = new Set<string>();++ private scriptPackages = new Map<string, ScriptPackage>();++ private scriptPackageFiles = new Map<string, ScriptPackageFile[]>();async upsertDevPlayer(input: { steamId: string; displayName: string }): Promise<PlayerProfile> {const existing = this.players.get(input.steamId);@@@ -460,6 +466,57 @@ export class MemoryOpenVibeRepository implements OpenVibeRepository {async listAuditEvents(options: { limit: number }): Promise<AuditEvent[]> {return this.auditEvents.slice(0, options.limit).map((event) => ({ ...event }));}++++ async listScriptPackages(): Promise<ScriptPackage[]> {++ return Array.from(this.scriptPackages.values()).map((p) => ({ ...p }));++ }++++ async getScriptPackage(packageId: string): Promise<ScriptPackage | null> {++ const pkg = this.scriptPackages.get(packageId);++ return pkg ? { ...pkg } : null;++ }++++ async upsertScriptPackage(input: UpsertScriptPackageInput): Promise<ScriptPackage> {++ const now = new Date().toISOString();++ const existing = this.scriptPackages.get(input.packageId);++ const pkg: ScriptPackage = {++ packageId: input.packageId,++ packageType: input.packageType,++ displayName: input.displayName,++ description: input.description,++ version: input.version,++ authorSteamId: input.authorSteamId ?? null,++ manifestJson: input.manifestJson ?? {},++ trusted: input.trusted ?? false,++ enabled: existing?.enabled ?? false,++ createdAt: existing?.createdAt ?? now,++ updatedAt: now,++ };++ this.scriptPackages.set(pkg.packageId, pkg);++ return { ...pkg };++ }++++ async upsertScriptPackageFile(input: UpsertScriptPackageFileInput): Promise<ScriptPackageFile> {++ const now = new Date().toISOString();++ const file: ScriptPackageFile = { ...input, createdAt: now };++ const files = this.scriptPackageFiles.get(input.packageId) ?? [];++ const idx = files.findIndex((f) => f.path === input.path);++ if (idx >= 0) {++ files[idx] = file;++ } else {++ files.push(file);++ }++ this.scriptPackageFiles.set(input.packageId, files);++ return { ...file };++ }++++ async setScriptPackageEnabled(packageId: string, enabled: boolean): Promise<ScriptPackage | null> {++ const pkg = this.scriptPackages.get(packageId);++ if (!pkg) return null;++ const updated: ScriptPackage = { ...pkg, enabled, updatedAt: new Date().toISOString() };++ this.scriptPackages.set(packageId, updated);++ return { ...updated };++ }}export class RepositoryError extends Error {@@@ -15,8 +15,12 @@ import {Player,PlayerProfile,RegisterServerInput,++ ScriptPackage,++ ScriptPackageFile,ShopItem,TravelReservation,++ UpsertScriptPackageFileInput,++ UpsertScriptPackageInput,UpsertShopItemInput,} from "./domain.js";import { RepositoryError } from "./repository-memory.js";@@@ -649,6 +653,87 @@ export class PgOpenVibeRepository implements OpenVibeRepository {);return result.rows.map(mapAuditEvent);}++++ async listScriptPackages(): Promise<ScriptPackage[]> {++ const result = await this.pool.query(++ `SELECT package_id, package_type, display_name, description, version,++ author_steam_id, manifest_json, trusted, enabled, created_at, updated_at++ FROM script_packages++ ORDER BY created_at ASC`,++ );++ return result.rows.map(mapScriptPackage);++ }++++ async getScriptPackage(packageId: string): Promise<ScriptPackage | null> {++ const result = await this.pool.query(++ `SELECT package_id, package_type, display_name, description, version,++ author_steam_id, manifest_json, trusted, enabled, created_at, updated_at++ FROM script_packages WHERE package_id = $1`,++ [packageId],++ );++ return result.rows[0] ? mapScriptPackage(result.rows[0]) : null;++ }++++ async upsertScriptPackage(input: UpsertScriptPackageInput): Promise<ScriptPackage> {++ const result = await this.pool.query(++ `++ INSERT INTO script_packages++ (package_id, package_type, display_name, description, version, author_steam_id, manifest_json, trusted)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)++ ON CONFLICT (package_id) DO UPDATE SET++ package_type = EXCLUDED.package_type,++ display_name = EXCLUDED.display_name,++ description = EXCLUDED.description,++ version = EXCLUDED.version,++ author_steam_id = EXCLUDED.author_steam_id,++ manifest_json = EXCLUDED.manifest_json,++ trusted = EXCLUDED.trusted,++ updated_at = now()++ RETURNING package_id, package_type, display_name, description, version,++ author_steam_id, manifest_json, trusted, enabled, created_at, updated_at++ `,++ [++ input.packageId,++ input.packageType,++ input.displayName,++ input.description,++ input.version,++ input.authorSteamId ?? null,++ JSON.stringify(input.manifestJson ?? {}),++ input.trusted ?? false,++ ],++ );++ return mapScriptPackage(result.rows[0]);++ }++++ async upsertScriptPackageFile(input: UpsertScriptPackageFileInput): Promise<ScriptPackageFile> {++ const result = await this.pool.query(++ `++ INSERT INTO script_package_files (package_id, path, sha256, size_bytes, realm, content)++ VALUES ($1, $2, $3, $4, $5, $6)++ ON CONFLICT (package_id, path) DO UPDATE SET++ sha256 = EXCLUDED.sha256,++ size_bytes = EXCLUDED.size_bytes,++ realm = EXCLUDED.realm,++ content = EXCLUDED.content++ RETURNING package_id, path, sha256, size_bytes, realm, content, created_at++ `,++ [input.packageId, input.path, input.sha256, input.sizeBytes, input.realm, input.content],++ );++ return mapScriptPackageFile(result.rows[0]);++ }++++ async setScriptPackageEnabled(packageId: string, enabled: boolean): Promise<ScriptPackage | null> {++ const result = await this.pool.query(++ `UPDATE script_packages++ SET enabled = $2, updated_at = now()++ WHERE package_id = $1++ RETURNING package_id, package_type, display_name, description, version,++ author_steam_id, manifest_json, trusted, enabled, created_at, updated_at`,++ [packageId, enabled],++ );++ return result.rows[0] ? mapScriptPackage(result.rows[0]) : null;++ }}function mapPlayer(row: Record<string, unknown>): Player {@@@ -734,3 +819,31 @@ function mapAuditEvent(row: Record<string, unknown>): AuditEvent {createdAt: new Date(row.created_at as string | Date).toISOString(),};}++++function mapScriptPackage(row: Record<string, unknown>): ScriptPackage {++ return {++ packageId: String(row.package_id),++ packageType: row.package_type as ScriptPackage["packageType"],++ displayName: String(row.display_name),++ description: String(row.description),++ version: String(row.version),++ authorSteamId: row.author_steam_id ? String(row.author_steam_id) : null,++ manifestJson: (row.manifest_json ?? {}) as Record<string, unknown>,++ trusted: Boolean(row.trusted),++ enabled: Boolean(row.enabled),++ createdAt: new Date(row.created_at as string | Date).toISOString(),++ updatedAt: new Date(row.updated_at as string | Date).toISOString(),++ };++}++++function mapScriptPackageFile(row: Record<string, unknown>): ScriptPackageFile {++ return {++ packageId: String(row.package_id),++ path: String(row.path),++ sha256: String(row.sha256),++ sizeBytes: Number(row.size_bytes),++ realm: row.realm as ScriptPackageFile["realm"],++ content: String(row.content),++ createdAt: new Date(row.created_at as string | Date).toISOString(),++ };++}@@@ -105,7 +105,16 @@ export const leaderboardQuerySchema = z.object({export const upsertShopItemSchema = z.object({itemId: z.string().min(1).max(80),-- itemType: z.enum(["player_model", "trail", "nameplate"]),++ itemType: z.enum([++ "player_model",++ "trail",++ "nameplate",++ "title",++ "spray",++ "emote",++ "fortwars_part",++ "traitortown_cosmetic",++ ]),displayName: z.string().trim().min(1).max(120),description: z.string().max(500).default(""),assetPath: z.string().min(1).max(255),@@@ -123,3 +132,26 @@ export const auditEventSchema = z.object({export const auditQuerySchema = z.object({limit: z.coerce.number().int().min(1).max(200).default(50),});++++export const packageIdParamSchema = z.object({++ packageId: z.string().min(1).max(80),++});++++export const upsertScriptPackageSchema = z.object({++ packageId: z.string().min(1).max(80),++ packageType: z.enum(["gamemode", "addon", "library"]),++ displayName: z.string().trim().min(1).max(120),++ description: z.string().max(500).default(""),++ version: z.string().trim().min(1).max(40),++ authorSteamId: steamId.optional().nullable(),++ manifestJson: z.record(z.string(), z.unknown()).optional(),++ trusted: z.boolean().optional(),++});++++export const upsertScriptPackageFileSchema = z.object({++ path: z.string().min(1).max(255),++ sha256: z.string().regex(/^[0-9a-fA-F]{64}$/, "sha256 must be 64 hex chars"),++ sizeBytes: z.number().int().min(0).max(1_048_576),++ realm: z.enum(["server", "client", "shared"]),++ content: z.string().max(1_048_576),++});
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+-- 002_script_packages.sql
+-- Adds script package registry tables and expands the shop_items item_type
+-- constraint to include the new gamemode-cosmetic item types.
+-- Apply after 001_init.sql.
+
+-- Priority 10: expand shop_items item_type to include gamemode cosmetics.
+ALTER TABLE shop_items
+ DROP CONSTRAINT IF EXISTS shop_items_item_type_check;
+ALTER TABLE shop_items
+ ADD CONSTRAINT shop_items_item_type_check
+ CHECK (item_type IN (
+ 'player_model', 'trail', 'nameplate',
+ 'title', 'spray', 'emote', 'fortwars_part', 'traitortown_cosmetic'
+ ));
+
+-- Priority 9: trusted script package registry.
+-- Packages are admin-uploaded and must be explicitly enabled before the
+-- runtime will load them. Community uploads require a separate sandboxing
+-- review step; only trusted=true packages are served.
+CREATE TABLE IF NOT EXISTS script_packages (
+ package_id TEXT PRIMARY KEY,
+ package_type TEXT NOT NULL CHECK (package_type IN ('gamemode', 'addon', 'library')),
+ display_name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ version TEXT NOT NULL,
+ author_steam_id BIGINT,
+ manifest_json JSONB NOT NULL DEFAULT '{}'::jsonb,
+ trusted BOOLEAN NOT NULL DEFAULT false,
+ enabled BOOLEAN NOT NULL DEFAULT false,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS script_package_files (
+ package_id TEXT NOT NULL REFERENCES script_packages(package_id) ON DELETE CASCADE,
+ path TEXT NOT NULL,
+ sha256 TEXT NOT NULL,
+ size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0),
+ realm TEXT NOT NULL CHECK (realm IN ('server', 'client', 'shared')),
+ content TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (package_id, path)
+);
@@ -775,9 +775,6 @@
@@ -775,9 +775,6 @@
"arm64"
"arm64"
],
],
"dev": true,
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"license": "MIT",
"optional": true,
"optional": true,
"os": [
"os": [
@@ -795,9 +792,6 @@
@@ -795,9 +792,6 @@
"arm64"
"arm64"
],
],
"dev": true,
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"license": "MIT",
"optional": true,
"optional": true,
"os": [
"os": [
@@ -815,9 +809,6 @@
@@ -815,9 +809,6 @@
"ppc64"
"ppc64"
],
],
"dev": true,
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"license": "MIT",
"optional": true,
"optional": true,
"os": [
"os": [
@@ -835,9 +826,6 @@
@@ -835,9 +826,6 @@
"s390x"
"s390x"
],
],
"dev": true,
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"license": "MIT",
"optional": true,
"optional": true,
"os": [
"os": [
@@ -855,9 +843,6 @@
@@ -855,9 +843,6 @@
"x64"
"x64"
],
],
"dev": true,
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"license": "MIT",
"optional": true,
"optional": true,
"os": [
"os": [
@@ -875,9 +860,6 @@
@@ -875,9 +860,6 @@
"x64"
"x64"
],
],
"dev": true,
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"license": "MIT",
"optional": true,
"optional": true,
"os": [
"os": [
@@ -1787,9 +1769,6 @@
@@ -1787,9 +1769,6 @@
"arm64"
"arm64"
],
],
"dev": true,
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"license": "MPL-2.0",
"optional": true,
"optional": true,
"os": [
"os": [
@@ -1811,9 +1790,6 @@
@@ -1811,9 +1790,6 @@
"arm64"
"arm64"
],
],
"dev": true,
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"license": "MPL-2.0",
"optional": true,
"optional": true,
"os": [
"os": [
@@ -1835,9 +1811,6 @@
@@ -1835,9 +1811,6 @@
"x64"
"x64"
],
],
"dev": true,
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"license": "MPL-2.0",
"optional": true,
"optional": true,
"os": [
"os": [
@@ -1859,9 +1832,6 @@
@@ -1859,9 +1832,6 @@
"x64"
"x64"
],
],
"dev": true,
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"license": "MPL-2.0",
"optional": true,
"optional": true,
"os": [
"os": [
@@ -17,10 +17,13 @@ import {
@@ -17,10 +17,13 @@ import {
leaderboardQuerySchema,
leaderboardQuerySchema,
listServersQuerySchema,
listServersQuerySchema,
matchEndSchema,
matchEndSchema,
+ packageIdParamSchema,
partyInviteSchema,
partyInviteSchema,
partyTravelSchema,
partyTravelSchema,
registerServerSchema,
registerServerSchema,
travelRequestSchema,
travelRequestSchema,
+ upsertScriptPackageFileSchema,
+ upsertScriptPackageSchema,
upsertShopItemSchema,
upsertShopItemSchema,
validateJoinTokenSchema,
validateJoinTokenSchema,
} from "./schemas.js";
} from "./schemas.js";
@@ -305,6 +308,49 @@ export async function createApp(options: AppOptions): Promise<FastifyInstance> {
@@ -305,6 +308,49 @@ export async function createApp(options: AppOptions): Promise<FastifyInstance> {
return { events: await options.repository.listAuditEvents(query) };
return { events: await options.repository.listAuditEvents(query) };
});
});
+ // Script packages
+ app.get("/v1/scripts/packages", async () => {
+ return { packages: await options.repository.listScriptPackages() };
+ });
+
+ app.get("/v1/scripts/packages/:packageId", async (request, reply) => {
+ const { packageId } = packageIdParamSchema.parse(request.params);
+ const pkg = await options.repository.getScriptPackage(packageId);
+ if (!pkg) return reply.code(404).send({ error: "not_found" });
+ return pkg;
+ });
+
+ app.post("/v1/admin/scripts/packages", async (request, reply) => {
+ if (!requireAdmin(request, reply)) return;
+ const body = upsertScriptPackageSchema.parse(request.body);
+ return options.repository.upsertScriptPackage(body);
+ });
+
+ app.post("/v1/admin/scripts/packages/:packageId/files", async (request, reply) => {
+ if (!requireAdmin(request, reply)) return;
+ const { packageId } = packageIdParamSchema.parse(request.params);
+ const body = upsertScriptPackageFileSchema.parse(request.body);
+ const pkg = await options.repository.getScriptPackage(packageId);
+ if (!pkg) return reply.code(404).send({ error: "package_not_found" });
+ return options.repository.upsertScriptPackageFile({ ...body, packageId });
+ });
+
+ app.post("/v1/admin/scripts/packages/:packageId/enable", async (request, reply) => {
+ if (!requireAdmin(request, reply)) return;
+ const { packageId } = packageIdParamSchema.parse(request.params);
+ const pkg = await options.repository.setScriptPackageEnabled(packageId, true);
+ if (!pkg) return reply.code(404).send({ error: "not_found" });
+ return pkg;
+ });
+
+ app.post("/v1/admin/scripts/packages/:packageId/disable", async (request, reply) => {
+ if (!requireAdmin(request, reply)) return;
+ const { packageId } = packageIdParamSchema.parse(request.params);
+ const pkg = await options.repository.setScriptPackageEnabled(packageId, false);
+ if (!pkg) return reply.code(404).send({ error: "not_found" });
+ return pkg;
+ });
+
app.get("/metrics", async (_request, reply) => {
app.get("/metrics", async (_request, reply) => {
const servers = await options.repository.listServers();
const servers = await options.repository.listServers();
const open = servers.filter((server) => server.state === "open").length;
const open = servers.filter((server) => server.state === "open").length;
@@ -4,7 +4,62 @@ export type GameMode = (typeof modes)[number];
@@ -4,7 +4,62 @@ export type GameMode = (typeof modes)[number];
export type ServerState = "starting" | "open" | "full" | "ending" | "offline";
export type ServerState = "starting" | "open" | "full" | "ending" | "offline";
-export type ItemType = "player_model" | "trail" | "nameplate";
+export type ItemType =
+ | "player_model"
+ | "trail"
+ | "nameplate"
+ | "title"
+ | "spray"
+ | "emote"
+ | "fortwars_part"
+ | "traitortown_cosmetic";
+
+export type PackageType = "gamemode" | "addon" | "library";
+export type PackageRealm = "server" | "client" | "shared";
+
+export interface ScriptPackage {
+ packageId: string;
+ packageType: PackageType;
+ displayName: string;
+ description: string;
+ version: string;
+ authorSteamId: string | null;
+ manifestJson: Record<string, unknown>;
+ trusted: boolean;
+ enabled: boolean;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ScriptPackageFile {
+ packageId: string;
+ path: string;
+ sha256: string;
+ sizeBytes: number;
+ realm: PackageRealm;
+ content: string;
+ createdAt: string;
+}
+
+export interface UpsertScriptPackageInput {
+ packageId: string;
+ packageType: PackageType;
+ displayName: string;
+ description: string;
+ version: string;
+ authorSteamId?: string | null;
+ manifestJson?: Record<string, unknown>;
+ trusted?: boolean;
+}
+
+export interface UpsertScriptPackageFileInput {
+ packageId: string;
+ path: string;
+ sha256: string;
+ sizeBytes: number;
+ realm: PackageRealm;
+ content: string;
+}
export interface Player {
export interface Player {
steamId: string;
steamId: string;
@@ -187,4 +242,11 @@ export interface OpenVibeRepository {
@@ -187,4 +242,11 @@ export interface OpenVibeRepository {
reason: string;
reason: string;
}): Promise<AuditEvent>;
}): Promise<AuditEvent>;
listAuditEvents(options: { limit: number }): Promise<AuditEvent[]>;
listAuditEvents(options: { limit: number }): Promise<AuditEvent[]>;
+
+ // Script packages
+ listScriptPackages(): Promise<ScriptPackage[]>;
+ getScriptPackage(packageId: string): Promise<ScriptPackage | null>;
+ upsertScriptPackage(input: UpsertScriptPackageInput): Promise<ScriptPackage>;
+ upsertScriptPackageFile(input: UpsertScriptPackageFileInput): Promise<ScriptPackageFile>;
+ setScriptPackageEnabled(packageId: string, enabled: boolean): Promise<ScriptPackage | null>;
}
}
@@ -15,8 +15,12 @@ import {
@@ -15,8 +15,12 @@ import {
Player,
Player,
PlayerProfile,
PlayerProfile,
RegisterServerInput,
RegisterServerInput,
+ ScriptPackage,
+ ScriptPackageFile,
ShopItem,
ShopItem,
TravelReservation,
TravelReservation,
+ UpsertScriptPackageFileInput,
+ UpsertScriptPackageInput,
UpsertShopItemInput,
UpsertShopItemInput,
} from "./domain.js";
} from "./domain.js";
@@ -71,6 +75,8 @@ export class MemoryOpenVibeRepository implements OpenVibeRepository {
@@ -71,6 +75,8 @@ export class MemoryOpenVibeRepository implements OpenVibeRepository {
private invites = new Map<string, PartyInvite>();
private invites = new Map<string, PartyInvite>();
private auditEvents: AuditEvent[] = [];
private auditEvents: AuditEvent[] = [];
private rewardKeys = new Set<string>();
private rewardKeys = new Set<string>();
+ private scriptPackages = new Map<string, ScriptPackage>();
+ private scriptPackageFiles = new Map<string, ScriptPackageFile[]>();
async upsertDevPlayer(input: { steamId: string; displayName: string }): Promise<PlayerProfile> {
async upsertDevPlayer(input: { steamId: string; displayName: string }): Promise<PlayerProfile> {
const existing = this.players.get(input.steamId);
const existing = this.players.get(input.steamId);
@@ -460,6 +466,57 @@ export class MemoryOpenVibeRepository implements OpenVibeRepository {
@@ -460,6 +466,57 @@ export class MemoryOpenVibeRepository implements OpenVibeRepository {
async listAuditEvents(options: { limit: number }): Promise<AuditEvent[]> {
async listAuditEvents(options: { limit: number }): Promise<AuditEvent[]> {
return this.auditEvents.slice(0, options.limit).map((event) => ({ ...event }));
return this.auditEvents.slice(0, options.limit).map((event) => ({ ...event }));
}
}
+
+ async listScriptPackages(): Promise<ScriptPackage[]> {
+ return Array.from(this.scriptPackages.values()).map((p) => ({ ...p }));
+ }
+
+ async getScriptPackage(packageId: string): Promise<ScriptPackage | null> {
+ const pkg = this.scriptPackages.get(packageId);
+ return pkg ? { ...pkg } : null;
+ }
+
+ async upsertScriptPackage(input: UpsertScriptPackageInput): Promise<ScriptPackage> {
+ const now = new Date().toISOString();
+ const existing = this.scriptPackages.get(input.packageId);
+ const pkg: ScriptPackage = {
+ packageId: input.packageId,
+ packageType: input.packageType,
+ displayName: input.displayName,
+ description: input.description,
+ version: input.version,
+ authorSteamId: input.authorSteamId ?? null,
+ manifestJson: input.manifestJson ?? {},
+ trusted: input.trusted ?? false,
+ enabled: existing?.enabled ?? false,
+ createdAt: existing?.createdAt ?? now,
+ updatedAt: now,
+ };
+ this.scriptPackages.set(pkg.packageId, pkg);
+ return { ...pkg };
+ }
+
+ async upsertScriptPackageFile(input: UpsertScriptPackageFileInput): Promise<ScriptPackageFile> {
+ const now = new Date().toISOString();
+ const file: ScriptPackageFile = { ...input, createdAt: now };
+ const files = this.scriptPackageFiles.get(input.packageId) ?? [];
+ const idx = files.findIndex((f) => f.path === input.path);
+ if (idx >= 0) {
+ files[idx] = file;
+ } else {
+ files.push(file);
+ }
+ this.scriptPackageFiles.set(input.packageId, files);
+ return { ...file };
+ }
+
+ async setScriptPackageEnabled(packageId: string, enabled: boolean): Promise<ScriptPackage | null> {
+ const pkg = this.scriptPackages.get(packageId);
+ if (!pkg) return null;
+ const updated: ScriptPackage = { ...pkg, enabled, updatedAt: new Date().toISOString() };
+ this.scriptPackages.set(packageId, updated);
+ return { ...updated };
+ }
}
}
export class RepositoryError extends Error {
export class RepositoryError extends Error {
@@ -15,8 +15,12 @@ import {
@@ -15,8 +15,12 @@ import {
Player,
Player,
PlayerProfile,
PlayerProfile,
RegisterServerInput,
RegisterServerInput,
+ ScriptPackage,
+ ScriptPackageFile,
ShopItem,
ShopItem,
TravelReservation,
TravelReservation,
+ UpsertScriptPackageFileInput,
+ UpsertScriptPackageInput,
UpsertShopItemInput,
UpsertShopItemInput,
} from "./domain.js";
} from "./domain.js";
import { RepositoryError } from "./repository-memory.js";
import { RepositoryError } from "./repository-memory.js";
@@ -649,6 +653,87 @@ export class PgOpenVibeRepository implements OpenVibeRepository {
@@ -649,6 +653,87 @@ export class PgOpenVibeRepository implements OpenVibeRepository {
);
);
return result.rows.map(mapAuditEvent);
return result.rows.map(mapAuditEvent);
}
}
+
+ async listScriptPackages(): Promise<ScriptPackage[]> {
+ const result = await this.pool.query(
+ `SELECT package_id, package_type, display_name, description, version,
+ author_steam_id, manifest_json, trusted, enabled, created_at, updated_at
+ FROM script_packages
+ ORDER BY created_at ASC`,
+ );
+ return result.rows.map(mapScriptPackage);
+ }
+
+ async getScriptPackage(packageId: string): Promise<ScriptPackage | null> {
+ const result = await this.pool.query(
+ `SELECT package_id, package_type, display_name, description, version,
+ author_steam_id, manifest_json, trusted, enabled, created_at, updated_at
+ FROM script_packages WHERE package_id = $1`,
+ [packageId],
+ );
+ return result.rows[0] ? mapScriptPackage(result.rows[0]) : null;
+ }
+
+ async upsertScriptPackage(input: UpsertScriptPackageInput): Promise<ScriptPackage> {
+ const result = await this.pool.query(
+ `
+ INSERT INTO script_packages
+ (package_id, package_type, display_name, description, version, author_steam_id, manifest_json, trusted)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ ON CONFLICT (package_id) DO UPDATE SET
+ package_type = EXCLUDED.package_type,
+ display_name = EXCLUDED.display_name,
+ description = EXCLUDED.description,
+ version = EXCLUDED.version,
+ author_steam_id = EXCLUDED.author_steam_id,
+ manifest_json = EXCLUDED.manifest_json,
+ trusted = EXCLUDED.trusted,
+ updated_at = now()
+ RETURNING package_id, package_type, display_name, description, version,
+ author_steam_id, manifest_json, trusted, enabled, created_at, updated_at
+ `,
+ [
+ input.packageId,
+ input.packageType,
+ input.displayName,
+ input.description,
+ input.version,
+ input.authorSteamId ?? null,
+ JSON.stringify(input.manifestJson ?? {}),
+ input.trusted ?? false,
+ ],
+ );
+ return mapScriptPackage(result.rows[0]);
+ }
+
+ async upsertScriptPackageFile(input: UpsertScriptPackageFileInput): Promise<ScriptPackageFile> {
+ const result = await this.pool.query(
+ `
+ INSERT INTO script_package_files (package_id, path, sha256, size_bytes, realm, content)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ ON CONFLICT (package_id, path) DO UPDATE SET
+ sha256 = EXCLUDED.sha256,
+ size_bytes = EXCLUDED.size_bytes,
+ realm = EXCLUDED.realm,
+ content = EXCLUDED.content
+ RETURNING package_id, path, sha256, size_bytes, realm, content, created_at
+ `,
+ [input.packageId, input.path, input.sha256, input.sizeBytes, input.realm, input.content],
+ );
+ return mapScriptPackageFile(result.rows[0]);
+ }
+
+ async setScriptPackageEnabled(packageId: string, enabled: boolean): Promise<ScriptPackage | null> {
+ const result = await this.pool.query(
+ `UPDATE script_packages
+ SET enabled = $2, updated_at = now()
+ WHERE package_id = $1
+ RETURNING package_id, package_type, display_name, description, version,
+ author_steam_id, manifest_json, trusted, enabled, created_at, updated_at`,
+ [packageId, enabled],
+ );
+ return result.rows[0] ? mapScriptPackage(result.rows[0]) : null;
+ }
}
}
function mapPlayer(row: Record<string, unknown>): Player {
function mapPlayer(row: Record<string, unknown>): Player {
@@ -734,3 +819,31 @@ function mapAuditEvent(row: Record<string, unknown>): AuditEvent {
@@ -734,3 +819,31 @@ function mapAuditEvent(row: Record<string, unknown>): AuditEvent {
createdAt: new Date(row.created_at as string | Date).toISOString(),
createdAt: new Date(row.created_at as string | Date).toISOString(),
};
};
}
}
+
+function mapScriptPackage(row: Record<string, unknown>): ScriptPackage {
+ return {
+ packageId: String(row.package_id),
+ packageType: row.package_type as ScriptPackage["packageType"],
+ displayName: String(row.display_name),
+ description: String(row.description),
+ version: String(row.version),
+ authorSteamId: row.author_steam_id ? String(row.author_steam_id) : null,
+ manifestJson: (row.manifest_json ?? {}) as Record<string, unknown>,
+ trusted: Boolean(row.trusted),
+ enabled: Boolean(row.enabled),
+ createdAt: new Date(row.created_at as string | Date).toISOString(),
+ updatedAt: new Date(row.updated_at as string | Date).toISOString(),
+ };
+}
+
+function mapScriptPackageFile(row: Record<string, unknown>): ScriptPackageFile {
+ return {
+ packageId: String(row.package_id),
+ path: String(row.path),
+ sha256: String(row.sha256),
+ sizeBytes: Number(row.size_bytes),
+ realm: row.realm as ScriptPackageFile["realm"],
+ content: String(row.content),
+ createdAt: new Date(row.created_at as string | Date).toISOString(),
+ };
+}
@@ -105,7 +105,16 @@ export const leaderboardQuerySchema = z.object({
@@ -105,7 +105,16 @@ export const leaderboardQuerySchema = z.object({
export const upsertShopItemSchema = z.object({
export const upsertShopItemSchema = z.object({
itemId: z.string().min(1).max(80),
itemId: z.string().min(1).max(80),
- itemType: z.enum(["player_model", "trail", "nameplate"]),
+ itemType: z.enum([
+ "player_model",
+ "trail",
+ "nameplate",
+ "title",
+ "spray",
+ "emote",
+ "fortwars_part",
+ "traitortown_cosmetic",
+ ]),
displayName: z.string().trim().min(1).max(120),
displayName: z.string().trim().min(1).max(120),
description: z.string().max(500).default(""),
description: z.string().max(500).default(""),
assetPath: z.string().min(1).max(255),
assetPath: z.string().min(1).max(255),
@@ -123,3 +132,26 @@ export const auditEventSchema = z.object({
@@ -123,3 +132,26 @@ export const auditEventSchema = z.object({
export const auditQuerySchema = z.object({
export const auditQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(200).default(50),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
});
+
+export const packageIdParamSchema = z.object({
+ packageId: z.string().min(1).max(80),
+});
+
+export const upsertScriptPackageSchema = z.object({
+ packageId: z.string().min(1).max(80),
+ packageType: z.enum(["gamemode", "addon", "library"]),
+ displayName: z.string().trim().min(1).max(120),
+ description: z.string().max(500).default(""),
+ version: z.string().trim().min(1).max(40),
+ authorSteamId: steamId.optional().nullable(),
+ manifestJson: z.record(z.string(), z.unknown()).optional(),
+ trusted: z.boolean().optional(),
+});
+
+export const upsertScriptPackageFileSchema = z.object({
+ path: z.string().min(1).max(255),
+ sha256: z.string().regex(/^[0-9a-fA-F]{64}$/, "sha256 must be 64 hex chars"),
+ sizeBytes: z.number().int().min(0).max(1_048_576),
+ realm: z.enum(["server", "client", "shared"]),
+ content: z.string().max(1_048_576),
+});