openvibers/source.openvibe.games Commits
Priority 11: listScriptPackageFiles endpoint and script package tests
Changes
@@@ -10,6 +10,7 @@ on:push:branches:- codex/openvibe-next-steps++ - copilot/continue-windows-dll-workpaths:- ".github/workflows/windows-source-sdk-dlls.yml"- "tools/bootstrap-source-sdk-2013-windows.ps1"@@@ -41,6 +41,7 @@ openvibe-source/| Prop Hunt disguise | Implemented | `ov_prophunt_disguise`, `ov_prophunt_reset_disguise` || Fort Wars placement | Implemented | `ov_fortwars_spawn` || VScript game modes | Implemented prototypes | `game/openvibe.games/scripts/vscripts/ov_*.nut` |++| Script packages API | Implemented | `GET /v1/scripts/packages`, `GET /v1/scripts/packages/:id`, `GET /v1/scripts/packages/:id/files`, admin upsert/enable/disable routes || Maps | Compiled | `game/openvibe.games/maps/*.bsp` || Production infra | Scaffolded | `infra/docker-compose.yml`, `infra/production/docker-stack.yml`, `infra/kubernetes/openvibe.yaml` |@@@ -88,10 +89,10 @@ node tools/smoke-api.mjstools/dev-down.sh```--## Verified On June 28, 2026++## Verified On June 29, 2026- Backend TypeScript build passed.--- Backend Vitest suite passed: 11 tests.++- Backend Vitest suite passed: 12 tests.- Source SDK 2013 Linux64 build passed with OpenVibe C++ patch applied.- SRCDS smoke test passed for all five maps.- Full dev stack registered all five servers and returned valid travel targets for every mode.@@@ -395,4 +395,147 @@ describe("OpenVibe API vertical slice", () => {await app.close();});++++ it("script packages: create, list, get, upload files, enable/disable, and 404 on missing", async () => {++ const adminSecret = "test-admin-secret";++ const app = await createApp({++ repository: new MemoryOpenVibeRepository(),++ devAuthEnabled: true,++ adminSecret,++ });++ await app.ready();++++ // List returns empty initially++ const empty = await app.inject({ method: "GET", url: "/v1/scripts/packages" });++ expect(empty.statusCode).toBe(200);++ expect(empty.json().packages).toHaveLength(0);++++ // GET unknown package returns 404++ const miss = await app.inject({ method: "GET", url: "/v1/scripts/packages/no-such-pkg" });++ expect(miss.statusCode).toBe(404);++++ // GET files for unknown package returns 404++ const missFils = await app.inject({++ method: "GET",++ url: "/v1/scripts/packages/no-such-pkg/files",++ });++ expect(missFils.statusCode).toBe(404);++++ // Admin: create a package++ const create = await app.inject({++ method: "POST",++ url: "/v1/admin/scripts/packages",++ headers: { "x-admin-secret": adminSecret },++ payload: {++ packageId: "test-gamemode",++ packageType: "gamemode",++ displayName: "Test Gamemode",++ description: "A test gamemode package.",++ version: "1.0.0",++ },++ });++ expect(create.statusCode).toBe(200);++ expect(create.json().packageId).toBe("test-gamemode");++ expect(create.json().enabled).toBe(false);++++ // List now returns the package++ const listed = await app.inject({ method: "GET", url: "/v1/scripts/packages" });++ expect(listed.json().packages).toHaveLength(1);++++ // GET by id works++ const got = await app.inject({ method: "GET", url: "/v1/scripts/packages/test-gamemode" });++ expect(got.statusCode).toBe(200);++ expect(got.json().displayName).toBe("Test Gamemode");++++ // Files list is empty before upload++ const emptyFiles = await app.inject({++ method: "GET",++ url: "/v1/scripts/packages/test-gamemode/files",++ });++ expect(emptyFiles.statusCode).toBe(200);++ expect(emptyFiles.json().files).toHaveLength(0);++++ // Upload a file with wrong admin secret fails++ const badUpload = await app.inject({++ method: "POST",++ url: "/v1/admin/scripts/packages/test-gamemode/files",++ headers: { "x-admin-secret": "wrong" },++ payload: {++ path: "gamemodes/test/server.js",++ sha256: "abc123",++ sizeBytes: 42,++ realm: "server",++ content: "OV.log('hello');",++ },++ });++ expect(badUpload.statusCode).toBe(403);++++ // Upload a file to unknown package returns 404++ const unknownPkg = await app.inject({++ method: "POST",++ url: "/v1/admin/scripts/packages/no-such-pkg/files",++ headers: { "x-admin-secret": adminSecret },++ payload: {++ path: "gamemodes/test/server.js",++ sha256: "a".repeat(64),++ sizeBytes: 42,++ realm: "server",++ content: "OV.log('hello');",++ },++ });++ expect(unknownPkg.statusCode).toBe(404);++++ // Upload a file successfully++ const upload = await app.inject({++ method: "POST",++ url: "/v1/admin/scripts/packages/test-gamemode/files",++ headers: { "x-admin-secret": adminSecret },++ payload: {++ path: "gamemodes/test/server.js",++ sha256: "a".repeat(64),++ sizeBytes: 42,++ realm: "server",++ content: "OV.log('hello');",++ },++ });++ expect(upload.statusCode).toBe(200);++ expect(upload.json().path).toBe("gamemodes/test/server.js");++ expect(upload.json().realm).toBe("server");++++ // Files list now has the uploaded file++ const withFile = await app.inject({++ method: "GET",++ url: "/v1/scripts/packages/test-gamemode/files",++ });++ expect(withFile.json().files).toHaveLength(1);++ expect(withFile.json().files[0].sha256).toBe("a".repeat(64));++++ // Enable the package++ const enabled = await app.inject({++ method: "POST",++ url: "/v1/admin/scripts/packages/test-gamemode/enable",++ headers: { "x-admin-secret": adminSecret },++ });++ expect(enabled.statusCode).toBe(200);++ expect(enabled.json().enabled).toBe(true);++++ // Disable the package++ const disabled = await app.inject({++ method: "POST",++ url: "/v1/admin/scripts/packages/test-gamemode/disable",++ headers: { "x-admin-secret": adminSecret },++ });++ expect(disabled.statusCode).toBe(200);++ expect(disabled.json().enabled).toBe(false);++++ // Enable/disable on unknown package returns 404++ const enableMiss = await app.inject({++ method: "POST",++ url: "/v1/admin/scripts/packages/no-such-pkg/enable",++ headers: { "x-admin-secret": adminSecret },++ });++ expect(enableMiss.statusCode).toBe(404);++++ await app.close();++ });});@@@ -320,6 +320,13 @@ export async function createApp(options: AppOptions): Promise<FastifyInstance> {return pkg;});++ app.get("/v1/scripts/packages/:packageId/files", 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 { files: await options.repository.listScriptPackageFiles(packageId) };++ });++app.post("/v1/admin/scripts/packages", async (request, reply) => {if (!requireAdmin(request, reply)) return;const body = upsertScriptPackageSchema.parse(request.body);@@@ -246,6 +246,7 @@ export interface OpenVibeRepository {// Script packageslistScriptPackages(): Promise<ScriptPackage[]>;getScriptPackage(packageId: string): Promise<ScriptPackage | null>;++ listScriptPackageFiles(packageId: string): Promise<ScriptPackageFile[]>;upsertScriptPackage(input: UpsertScriptPackageInput): Promise<ScriptPackage>;upsertScriptPackageFile(input: UpsertScriptPackageFileInput): Promise<ScriptPackageFile>;setScriptPackageEnabled(packageId: string, enabled: boolean): Promise<ScriptPackage | null>;@@@ -496,6 +496,10 @@ export class MemoryOpenVibeRepository implements OpenVibeRepository {return { ...pkg };}++ async listScriptPackageFiles(packageId: string): Promise<ScriptPackageFile[]> {++ return (this.scriptPackageFiles.get(packageId) ?? []).map((f) => ({ ...f }));++ }++async upsertScriptPackageFile(input: UpsertScriptPackageFileInput): Promise<ScriptPackageFile> {const now = new Date().toISOString();const file: ScriptPackageFile = { ...input, createdAt: now };@@@ -706,6 +706,17 @@ export class PgOpenVibeRepository implements OpenVibeRepository {return mapScriptPackage(result.rows[0]);}++ async listScriptPackageFiles(packageId: string): Promise<ScriptPackageFile[]> {++ const result = await this.pool.query(++ `SELECT package_id, path, sha256, size_bytes, realm, content, created_at++ FROM script_package_files++ WHERE package_id = $1++ ORDER BY path`,++ [packageId],++ );++ return result.rows.map(mapScriptPackageFile);++ }++async upsertScriptPackageFile(input: UpsertScriptPackageFileInput): Promise<ScriptPackageFile> {const result = await this.pool.query(`
@@ -10,6 +10,7 @@ on:
@@ -10,6 +10,7 @@ on:
push:
push:
branches:
branches:
- codex/openvibe-next-steps
- codex/openvibe-next-steps
+ - copilot/continue-windows-dll-work
paths:
paths:
- ".github/workflows/windows-source-sdk-dlls.yml"
- ".github/workflows/windows-source-sdk-dlls.yml"
- "tools/bootstrap-source-sdk-2013-windows.ps1"
- "tools/bootstrap-source-sdk-2013-windows.ps1"
@@ -41,6 +41,7 @@ openvibe-source/
@@ -41,6 +41,7 @@ openvibe-source/
| Prop Hunt disguise | Implemented | `ov_prophunt_disguise`, `ov_prophunt_reset_disguise` |
| Prop Hunt disguise | Implemented | `ov_prophunt_disguise`, `ov_prophunt_reset_disguise` |
| Fort Wars placement | Implemented | `ov_fortwars_spawn` |
| Fort Wars placement | Implemented | `ov_fortwars_spawn` |
| VScript game modes | Implemented prototypes | `game/openvibe.games/scripts/vscripts/ov_*.nut` |
| VScript game modes | Implemented prototypes | `game/openvibe.games/scripts/vscripts/ov_*.nut` |
+| Script packages API | Implemented | `GET /v1/scripts/packages`, `GET /v1/scripts/packages/:id`, `GET /v1/scripts/packages/:id/files`, admin upsert/enable/disable routes |
| Maps | Compiled | `game/openvibe.games/maps/*.bsp` |
| Maps | Compiled | `game/openvibe.games/maps/*.bsp` |
| Production infra | Scaffolded | `infra/docker-compose.yml`, `infra/production/docker-stack.yml`, `infra/kubernetes/openvibe.yaml` |
| Production infra | Scaffolded | `infra/docker-compose.yml`, `infra/production/docker-stack.yml`, `infra/kubernetes/openvibe.yaml` |
@@ -88,10 +89,10 @@ node tools/smoke-api.mjs
@@ -88,10 +89,10 @@ node tools/smoke-api.mjs
tools/dev-down.sh
tools/dev-down.sh
```
```
-## Verified On June 28, 2026
+## Verified On June 29, 2026
- Backend TypeScript build passed.
- Backend TypeScript build passed.
-- Backend Vitest suite passed: 11 tests.
+- Backend Vitest suite passed: 12 tests.
- Source SDK 2013 Linux64 build passed with OpenVibe C++ patch applied.
- Source SDK 2013 Linux64 build passed with OpenVibe C++ patch applied.
- SRCDS smoke test passed for all five maps.
- SRCDS smoke test passed for all five maps.
- Full dev stack registered all five servers and returned valid travel targets for every mode.
- Full dev stack registered all five servers and returned valid travel targets for every mode.
@@ -395,4 +395,147 @@ describe("OpenVibe API vertical slice", () => {
@@ -395,4 +395,147 @@ describe("OpenVibe API vertical slice", () => {
await app.close();
await app.close();
});
});
+
+ it("script packages: create, list, get, upload files, enable/disable, and 404 on missing", async () => {
+ const adminSecret = "test-admin-secret";
+ const app = await createApp({
+ repository: new MemoryOpenVibeRepository(),
+ devAuthEnabled: true,
+ adminSecret,
+ });
+ await app.ready();
+
+ // List returns empty initially
+ const empty = await app.inject({ method: "GET", url: "/v1/scripts/packages" });
+ expect(empty.statusCode).toBe(200);
+ expect(empty.json().packages).toHaveLength(0);
+
+ // GET unknown package returns 404
+ const miss = await app.inject({ method: "GET", url: "/v1/scripts/packages/no-such-pkg" });
+ expect(miss.statusCode).toBe(404);
+
+ // GET files for unknown package returns 404
+ const missFils = await app.inject({
+ method: "GET",
+ url: "/v1/scripts/packages/no-such-pkg/files",
+ });
+ expect(missFils.statusCode).toBe(404);
+
+ // Admin: create a package
+ const create = await app.inject({
+ method: "POST",
+ url: "/v1/admin/scripts/packages",
+ headers: { "x-admin-secret": adminSecret },
+ payload: {
+ packageId: "test-gamemode",
+ packageType: "gamemode",
+ displayName: "Test Gamemode",
+ description: "A test gamemode package.",
+ version: "1.0.0",
+ },
+ });
+ expect(create.statusCode).toBe(200);
+ expect(create.json().packageId).toBe("test-gamemode");
+ expect(create.json().enabled).toBe(false);
+
+ // List now returns the package
+ const listed = await app.inject({ method: "GET", url: "/v1/scripts/packages" });
+ expect(listed.json().packages).toHaveLength(1);
+
+ // GET by id works
+ const got = await app.inject({ method: "GET", url: "/v1/scripts/packages/test-gamemode" });
+ expect(got.statusCode).toBe(200);
+ expect(got.json().displayName).toBe("Test Gamemode");
+
+ // Files list is empty before upload
+ const emptyFiles = await app.inject({
+ method: "GET",
+ url: "/v1/scripts/packages/test-gamemode/files",
+ });
+ expect(emptyFiles.statusCode).toBe(200);
+ expect(emptyFiles.json().files).toHaveLength(0);
+
+ // Upload a file with wrong admin secret fails
+ const badUpload = await app.inject({
+ method: "POST",
+ url: "/v1/admin/scripts/packages/test-gamemode/files",
+ headers: { "x-admin-secret": "wrong" },
+ payload: {
+ path: "gamemodes/test/server.js",
+ sha256: "abc123",
+ sizeBytes: 42,
+ realm: "server",
+ content: "OV.log('hello');",
+ },
+ });
+ expect(badUpload.statusCode).toBe(403);
+
+ // Upload a file to unknown package returns 404
+ const unknownPkg = await app.inject({
+ method: "POST",
+ url: "/v1/admin/scripts/packages/no-such-pkg/files",
+ headers: { "x-admin-secret": adminSecret },
+ payload: {
+ path: "gamemodes/test/server.js",
+ sha256: "a".repeat(64),
+ sizeBytes: 42,
+ realm: "server",
+ content: "OV.log('hello');",
+ },
+ });
+ expect(unknownPkg.statusCode).toBe(404);
+
+ // Upload a file successfully
+ const upload = await app.inject({
+ method: "POST",
+ url: "/v1/admin/scripts/packages/test-gamemode/files",
+ headers: { "x-admin-secret": adminSecret },
+ payload: {
+ path: "gamemodes/test/server.js",
+ sha256: "a".repeat(64),
+ sizeBytes: 42,
+ realm: "server",
+ content: "OV.log('hello');",
+ },
+ });
+ expect(upload.statusCode).toBe(200);
+ expect(upload.json().path).toBe("gamemodes/test/server.js");
+ expect(upload.json().realm).toBe("server");
+
+ // Files list now has the uploaded file
+ const withFile = await app.inject({
+ method: "GET",
+ url: "/v1/scripts/packages/test-gamemode/files",
+ });
+ expect(withFile.json().files).toHaveLength(1);
+ expect(withFile.json().files[0].sha256).toBe("a".repeat(64));
+
+ // Enable the package
+ const enabled = await app.inject({
+ method: "POST",
+ url: "/v1/admin/scripts/packages/test-gamemode/enable",
+ headers: { "x-admin-secret": adminSecret },
+ });
+ expect(enabled.statusCode).toBe(200);
+ expect(enabled.json().enabled).toBe(true);
+
+ // Disable the package
+ const disabled = await app.inject({
+ method: "POST",
+ url: "/v1/admin/scripts/packages/test-gamemode/disable",
+ headers: { "x-admin-secret": adminSecret },
+ });
+ expect(disabled.statusCode).toBe(200);
+ expect(disabled.json().enabled).toBe(false);
+
+ // Enable/disable on unknown package returns 404
+ const enableMiss = await app.inject({
+ method: "POST",
+ url: "/v1/admin/scripts/packages/no-such-pkg/enable",
+ headers: { "x-admin-secret": adminSecret },
+ });
+ expect(enableMiss.statusCode).toBe(404);
+
+ await app.close();
+ });
});
});
@@ -320,6 +320,13 @@ export async function createApp(options: AppOptions): Promise<FastifyInstance> {
@@ -320,6 +320,13 @@ export async function createApp(options: AppOptions): Promise<FastifyInstance> {
return pkg;
return pkg;
});
});
+ app.get("/v1/scripts/packages/:packageId/files", 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 { files: await options.repository.listScriptPackageFiles(packageId) };
+ });
+
app.post("/v1/admin/scripts/packages", async (request, reply) => {
app.post("/v1/admin/scripts/packages", async (request, reply) => {
if (!requireAdmin(request, reply)) return;
if (!requireAdmin(request, reply)) return;
const body = upsertScriptPackageSchema.parse(request.body);
const body = upsertScriptPackageSchema.parse(request.body);
@@ -246,6 +246,7 @@ export interface OpenVibeRepository {
@@ -246,6 +246,7 @@ export interface OpenVibeRepository {
// Script packages
// Script packages
listScriptPackages(): Promise<ScriptPackage[]>;
listScriptPackages(): Promise<ScriptPackage[]>;
getScriptPackage(packageId: string): Promise<ScriptPackage | null>;
getScriptPackage(packageId: string): Promise<ScriptPackage | null>;
+ listScriptPackageFiles(packageId: string): Promise<ScriptPackageFile[]>;
upsertScriptPackage(input: UpsertScriptPackageInput): Promise<ScriptPackage>;
upsertScriptPackage(input: UpsertScriptPackageInput): Promise<ScriptPackage>;
upsertScriptPackageFile(input: UpsertScriptPackageFileInput): Promise<ScriptPackageFile>;
upsertScriptPackageFile(input: UpsertScriptPackageFileInput): Promise<ScriptPackageFile>;
setScriptPackageEnabled(packageId: string, enabled: boolean): Promise<ScriptPackage | null>;
setScriptPackageEnabled(packageId: string, enabled: boolean): Promise<ScriptPackage | null>;
@@ -496,6 +496,10 @@ export class MemoryOpenVibeRepository implements OpenVibeRepository {
@@ -496,6 +496,10 @@ export class MemoryOpenVibeRepository implements OpenVibeRepository {
return { ...pkg };
return { ...pkg };
}
}
+ async listScriptPackageFiles(packageId: string): Promise<ScriptPackageFile[]> {
+ return (this.scriptPackageFiles.get(packageId) ?? []).map((f) => ({ ...f }));
+ }
+
async upsertScriptPackageFile(input: UpsertScriptPackageFileInput): Promise<ScriptPackageFile> {
async upsertScriptPackageFile(input: UpsertScriptPackageFileInput): Promise<ScriptPackageFile> {
const now = new Date().toISOString();
const now = new Date().toISOString();
const file: ScriptPackageFile = { ...input, createdAt: now };
const file: ScriptPackageFile = { ...input, createdAt: now };
@@ -706,6 +706,17 @@ export class PgOpenVibeRepository implements OpenVibeRepository {
@@ -706,6 +706,17 @@ export class PgOpenVibeRepository implements OpenVibeRepository {
return mapScriptPackage(result.rows[0]);
return mapScriptPackage(result.rows[0]);
}
}
+ async listScriptPackageFiles(packageId: string): Promise<ScriptPackageFile[]> {
+ const result = await this.pool.query(
+ `SELECT package_id, path, sha256, size_bytes, realm, content, created_at
+ FROM script_package_files
+ WHERE package_id = $1
+ ORDER BY path`,
+ [packageId],
+ );
+ return result.rows.map(mapScriptPackageFile);
+ }
+
async upsertScriptPackageFile(input: UpsertScriptPackageFileInput): Promise<ScriptPackageFile> {
async upsertScriptPackageFile(input: UpsertScriptPackageFileInput): Promise<ScriptPackageFile> {
const result = await this.pool.query(
const result = await this.pool.query(
`
`