openvibers/source.openvibe.games Commits

Priority 11: listScriptPackageFiles endpoint and script package tests

CO copilot-swe-agent[bot] committed 29 Jun 2026 · 4ed22a07d2 · 7 files, +170 −2


Changes

ddiff --git a/.github/workflows/windows-source-sdk-dlls.yml b/.github/workflows/windows-source-sdk-dlls.yml
iindex e8dcbc3..5fb4124 100644
---- a/.github/workflows/windows-source-sdk-dlls.yml
++++ b/.github/workflows/windows-source-sdk-dlls.yml
@@@ -10,6 +10,7 @@ on:
push:
branches:
- codex/openvibe-next-steps
++ - copilot/continue-windows-dll-work
paths:
- ".github/workflows/windows-source-sdk-dlls.yml"
- "tools/bootstrap-source-sdk-2013-windows.ps1"
ddiff --git a/PROJECT_INDEX.md b/PROJECT_INDEX.md
iindex 33df357..7aeedea 100644
---- a/PROJECT_INDEX.md
++++ b/PROJECT_INDEX.md
@@@ -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.mjs
tools/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.
ddiff --git a/backend/src/app.test.ts b/backend/src/app.test.ts
iindex bc7ccfc..1833866 100644
---- a/backend/src/app.test.ts
++++ b/backend/src/app.test.ts
@@@ -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();
++ });
});
ddiff --git a/backend/src/app.ts b/backend/src/app.ts
iindex 4cf46cf..4cf3afc 100644
---- a/backend/src/app.ts
++++ b/backend/src/app.ts
@@@ -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);
ddiff --git a/backend/src/domain.ts b/backend/src/domain.ts
iindex 8d6ee9f..324bd20 100644
---- a/backend/src/domain.ts
++++ b/backend/src/domain.ts
@@@ -246,6 +246,7 @@ export interface OpenVibeRepository {
// Script packages
listScriptPackages(): 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>;
ddiff --git a/backend/src/repository-memory.ts b/backend/src/repository-memory.ts
iindex 9aa357a..2ab3549 100644
---- a/backend/src/repository-memory.ts
++++ b/backend/src/repository-memory.ts
@@@ -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 };
ddiff --git a/backend/src/repository-pg.ts b/backend/src/repository-pg.ts
iindex 8764091..a748e2b 100644
---- a/backend/src/repository-pg.ts
++++ b/backend/src/repository-pg.ts
@@@ -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(
`