openvibers/source.openvibe.games Commits

Priority 9+10: script packages API, migration, and ItemType expansion

CO copilot-swe-agent[bot] committed 29 Jun 2026 · e5fcf39cbd · 7 files, +355 −32


Changes

ddiff --git a/backend/migrations/002_script_packages.sql b/backend/migrations/002_script_packages.sql
nnew file mode 100644
iindex 0000000..2065a66
---- /dev/null
++++ b/backend/migrations/002_script_packages.sql
@@@ -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)
++);
ddiff --git a/backend/package-lock.json b/backend/package-lock.json
iindex 162a255..7908ba9 100644
---- a/backend/package-lock.json
++++ b/backend/package-lock.json
@@@ -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": [
ddiff --git a/backend/src/app.ts b/backend/src/app.ts
iindex cce6288..4cf46cf 100644
---- a/backend/src/app.ts
++++ b/backend/src/app.ts
@@@ -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;
ddiff --git a/backend/src/domain.ts b/backend/src/domain.ts
iindex 8c53258..8d6ee9f 100644
---- a/backend/src/domain.ts
++++ b/backend/src/domain.ts
@@@ -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>;
}
ddiff --git a/backend/src/repository-memory.ts b/backend/src/repository-memory.ts
iindex eebcbb6..9aa357a 100644
---- a/backend/src/repository-memory.ts
++++ b/backend/src/repository-memory.ts
@@@ -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 {
ddiff --git a/backend/src/repository-pg.ts b/backend/src/repository-pg.ts
iindex 04f0bbe..8764091 100644
---- a/backend/src/repository-pg.ts
++++ b/backend/src/repository-pg.ts
@@@ -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(),
++ };
++}
ddiff --git a/backend/src/schemas.ts b/backend/src/schemas.ts
iindex 53070af..710cf3a 100644
---- a/backend/src/schemas.ts
++++ b/backend/src/schemas.ts
@@@ -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),
++});