Imported from HugoGT/labs_office (
AGENTS.md). Install upstream withnpx skills add HugoGT/labs_office. Copyright stays with the author.
AGENTS.md
Single source of truth for AI coding agents and contributors working in this repository. Humans should start with README.md; this file is the operational contract.
Project overview
Oficina Virtual is an internal, Gather-style 2D top-down virtual office. A React + Phaser SPA renders the map and avatars, a Node (Colyseus + Express) server syncs real avatar positions and exposes an HTTP API, and a self-hosted LiveKit SFU provides proximity audio/video, with LiveKit Egress recording spaces to Google Cloud Storage. Product requirements live in PRD-Oficina-Virtual.md (Spanish; architecture in section 6, roadmap in section 12).
Repo layout
src/ SPA (Vite + React + Phaser)
main.tsx, App.tsx entrypoint; App resolves auth and route once at startup
routing/route.ts two routes only: office (default) and /dashboard (no router lib)
auth/ Firebase/Identity Platform auth port + adapter
components/ React UI (OfficeShell, GameCanvas, BottomBar, VideoTiles, RecBadge, ...)
dashboard/ admin panel (/dashboard): *Port.ts + *Client.ts + *Panel.tsx
game/ Phaser scene, map, proximity, LiveKit and Colyseus clients, protocol
hooks/ React hooks bridging game/clients to UI (useProximityAudio, useDesks, ...)
test/ Vitest setup files
server/src/ Node server, run directly by Node type stripping (no build step)
main.ts entrypoint (PORT, default 2567)
createOfficeServer.ts wiring: Express routes + Colyseus transport on one http.Server
OfficeRoom.ts Colyseus room (positions, status, calls, recording state)
admin/ decor/ desks/ directory/ recording/ spaces/ feature modules (ports and adapters)
directory/schema.sql Postgres schema, applied idempotently on every start
e2e/ real-process E2E harness (node:test + Playwright)
docker-compose.yml full local stack (include of infra/livekit + postgres + server + web images)
infra/livekit/ local LiveKit + Egress + Redis docker compose stack
infra/gcp/ deployed `test` environment: Terraform, VM compose, Caddy, office-deploy
prototype/ pre-port standalone prototype, reference only (not built, not run)
public/assets/ static art (Kenney tileset)
.github/workflows/ ci.yml (verify) and deploy-test.yml (deploy after green CI on main)
Shared code: the server imports src/game/officeProtocol.ts and src/game/mapData.ts directly. Changes there affect both client and server.
Tech stack
- Node 24 (CI and Docker images), pnpm 11 (
packageManagerinpackage.json), ESM ("type": "module"). - Frontend: Vite 8, React 19, TypeScript 7, Phaser 3.90.0 (pinned to line 3 on purpose),
livekit-client2.22.3,colyseus.js0.16.22,firebase(onlyfirebase/auth). - Server:
@colyseus/core0.16.24 +@colyseus/ws-transport, Express 4,livekit-server-sdk,pg,jose(ID token verification),@google-cloud/storage. - Tests: Vitest 4 (projects
unitjsdom,servernode,browserChromium via@vitest/browser-playwright), Testing Library, Playwright for E2E. - Infra: Docker Compose, LiveKit server v1.13.x + Egress + Redis, Caddy (custom image with
layer4), Postgres 17 (local Docker; Cloud SQL with private IP when deployed), Terraform on GCP (single Compute Engine VM + Cloud SQL), GitHub Actions with Workload Identity Federation. pnpm-workspace.yamlis not a monorepo: it exists only foroverridesandallowBuilds.
Commands
All from the repo root. Every script below exists in package.json.
| Command | What it does |
|---|---|
pnpm install |
Install dependencies (CI uses pnpm install --frozen-lockfile) |
pnpm dev |
Vite dev server on http://localhost:5173 (host: true) |
pnpm server |
Colyseus + HTTP server on port 2567 (PORT overrides). Does NOT load .env |
pnpm build |
tsc -b + server typecheck + vite build to dist/ |
pnpm build:e2e |
Instrumented build (--mode e2e, reads .env.e2e) to dist-e2e/ |
pnpm preview |
Serve dist/ |
pnpm typecheck |
Client (tsc -b --noEmit) and server (tsconfig.server.json) typecheck |
pnpm test |
Vitest unit project (jsdom) |
pnpm test:watch |
unit project in watch mode |
pnpm test:server |
Vitest server project (Node, real Colyseus on an ephemeral port) |
pnpm test:browser |
Vitest browser project (headless Chromium) |
pnpm test:all |
All three Vitest projects (what CI runs) |
pnpm test:coverage |
All projects with v8 coverage |
pnpm test:harness |
Pure unit tests of the E2E harness helpers |
pnpm test:e2e |
Two-client E2E against real server + vite preview (needs dist-e2e/) |
pnpm test:e2e:audio |
Two-client audio and "No molestar" E2E; needs a real LiveKit and VITE_LIVEKIT_E2E=1 (the screen share scenario also needs DATABASE_URL) |
pnpm e2e |
test:harness + build + build:e2e + test:e2e |
pnpm test:mux |
Local Docker harness for the Caddy TURN/TLS multiplexer. Never runs in CI |
There is no lint or format script and no ESLint/Prettier/Biome config. pnpm typecheck (strict, noUnusedLocals, noUnusedParameters, verbatimModuleSyntax) is the static gate. Do not add a linter unless asked.
First-time browser setup: pnpm exec playwright install chromium (CI uses --with-deps).
Pre-PR check that mirrors CI: pnpm typecheck && pnpm test:all && pnpm test:harness && pnpm build && pnpm build:e2e && pnpm test:e2e.
Full local stack (whole app in Docker, no hot reload; reads only the root .env):
cp .env.example .env # set LIVEKIT_API_SECRET (openssl rand -hex 32)
docker compose up -d --build # SPA http://localhost:8080, server :2567, Postgres :5432 (loopback)
docker compose down -v # -v also wipes the Postgres volume
It builds infra/gcp/docker/{colyseus,web}.Dockerfile, pulls LiveKit + Egress + Redis in with Compose include of infra/livekit/docker-compose.yml (interpolated from the root .env), and hardcodes DATABASE_URL, LIVEKIT_URL (browser, ws://localhost:7880), LIVEKIT_API_URL (http://livekit:7880) and VITE_COLYSEUS_URL (ws://localhost:2567). Same ports as the hybrid path below: run one or the other.
Local LiveKit stack only (optional, for real audio/video and recording with the app on the host):
cp infra/livekit/.env.example infra/livekit/.env # regenerate LIVEKIT_API_SECRET
docker compose -f infra/livekit/docker-compose.yml up -d
To run the server with the root .env: node --env-file=.env server/src/main.ts (Node flag; pnpm server does not read .env). Vite reads .env on its own for VITE_*.
Environment variables
Names only; see .env.example for semantics. Never commit values. Everything is optional in local dev: an unset feature degrades instead of failing.
Root .env (server and SPA):
- Client (baked at build time):
VITE_COLYSEUS_URL,VITE_LIVEKIT_URL,VITE_FIREBASE_API_KEY,VITE_FIREBASE_PROJECT_ID,VITE_FIREBASE_AUTH_DOMAIN - LiveKit:
LIVEKIT_API_KEY,LIVEKIT_API_SECRET,LIVEKIT_URL,LIVEKIT_API_URL - Recording:
RECORDING_GCS_BUCKET,GOOGLE_APPLICATION_CREDENTIALS(commented out; ADC is found automatically) - CORS:
ALLOWED_ORIGIN - Auth:
FIREBASE_PROJECT_ID - Directory:
DATABASE_URL,DATABASE_SSL_CA_FILE(commented out; set only when the database requires TLS),BOOTSTRAP_SUPERADMIN_EMAIL,IDENTITY_ADMIN_CREDENTIALS,IDENTITY_ADMIN_USE_METADATA - Server process:
PORT,NODE_ENV,OFFICE_RECONNECTION_WINDOW_SECONDS(commented out in.env.example; an emptyPORTmeans port 0, a random port)
infra/livekit/.env: LIVEKIT_API_KEY, LIVEKIT_API_SECRET, GCS_CREDENTIALS_FILE, GCS_BUCKET. Also read by vite.config.ts and e2e/harness.mjs to mint real LiveKit tokens from Node. The full stack (root docker-compose.yml) does not read it: it interpolates the included LiveKit services, GCS_CREDENTIALS_FILE included, from the root .env.
Test-only: .env.e2e (committed, no secrets: VITE_E2E_HOOK, VITE_COLYSEUS_URL=ws://localhost:2599, empty Firebase keys), VITE_LIVEKIT_E2E (enables LiveKit-gated tests), E2E_READINESS_TIMEOUT_MS (harness readiness deadline, default 15000).
Degradation rules worth knowing:
- No
FIREBASE_PROJECT_ID: server runs without auth. Set: fails closed. SPA shows login only when bothVITE_FIREBASE_API_KEYandVITE_FIREBASE_PROJECT_IDare set. - No
DATABASE_URL: no directory (no roles, no expiry), no spaces/desks/decor store, so every space shares the corridor LiveKit room and there is nothing per-space to record. - No
RECORDING_GCS_BUCKET:/recordings/*answers 503recording-not-configured. - No identity admin credentials: creating invited accounts answers 503; the rest of the dashboard works.
VITE_COLYSEUS_URLunset: client derivesws(s)://<host>:2567; set but empty: multiplayer disabled on purpose.GET /healthreportsauthanddirectoryasenabled/disabled.
Architecture
Runtime flow:
- The SPA (
src/App.tsx) resolves auth config and route once.AuthGategates both the office (OfficeShell) and the dashboard (DashboardRoute), each loaded withReact.lazyso the dashboard never downloads Phaser. OfficeShellowns theOfficeBridgebetween React and Phaser (src/game/officeBridge.ts,OfficeScene.ts). The client joins the Colyseus roomOfficeRoomover WebSocket, sending the Firebase ID token when auth is on.OfficeRoomtreats the client as untrusted: everymoveis validated and clamped; partially valid messages are dropped. Messages:move,status,spacesversion,call,callrespond. The room also carries recording state visible to all occupants.- Proximity audio/video: the client asks
POST /livekit/token; the server decides the LiveKit room from the session's tracked position (liveSessions.ts,sessionGuard.ts), ignoring any room the client sends. Room naming islivekitRoomFor(spaceId)insrc/game/officeProtocol.ts:nullis the shared corridor room, a space getsoffice-livekit-space-<id>. - Recording:
POST /recordings/start|stopstarts a LiveKit Egress room composite (MP4) uploaded to GCS throughserver/src/recording/egressPort.ts;POST /recordings/urlreturns a 10-minute V4 signed URL (view or download) for participants only. Retention isRECORDING_RETENTION_DAYS = 30(src/game/officeProtocol.ts), enforced by the bucket lifecycle rule;server/src/recording/retention.test.tsfails if Terraform orinfra/gcp/recordings-lifecycle.jsondrift from it. - Admin API under
/admin/*(session, invitations, users, spaces, desks, assets) backs the/dashboardSPA route. There is no UI link to/dashboard; it is reached by URL. - Access removal (#93):
GET /admin/userslists every directory user (with a server-computedremovableflag) andPOST /admin/users/:id/revokerevokes anyone the caller outranks percanRemove(server/src/directory/accessDecision.ts: superadmin removes admin/employee/guest, admin removes employee/guest, nobody removes the superadmin or themself). It setsstatus = 'revoked'with arevoke-useraudit entry, evicts the account's live sessions through theSessionEvictorport (server/src/sessionEviction.ts, implemented byOfficeRoomwith close codeSESSION_REVOKED_CLOSE_CODE4101), then disables the Identity Platform account.POST /admin/invitations/:id/revokeis unchanged and still only touches invitations. - Account passwords (#94): invitation and user creation create the Identity Platform account with a random password that is never returned, then send a password-reset email through
IdentityAdmin.sendPasswordReset(accounts:sendOobCode). Responses carryemailSentinstead of a password; a failed send keeps the account and can be retried withPOST /admin/users/:id/password-reset. The login's "forgot password" link usesAuthPort.sendPasswordResetand shows the same confirmation whether or not the account exists. - Self-chosen display name (#100): the Login screen claims a unique display name through
POST /me/display-name(canonicalized and compared case/whitespace-insensitively;server/src/directory/schema.sqlenforces the actual uniqueness with a partial unique index,pgDirectory/memoryDirectorytranslate a collision toDisplayNameTakenError). Sign-in itself never writesusers.display_nameanymore (resolveOnLoginonly reads);OfficeRoom.onAuthresolves the directory row once and carries itsdisplayNamealongside the verified identity, andonJoinlabels from it, falling back to the token-derived name only when nobody has chosen one yet. Without a directory the typed name is discarded and the office behaves exactly as before this feature.
HTTP routes (all in server/src/createOfficeServer.ts): GET /health, POST /livekit/token, GET /spaces, GET /desks, POST /desks/:id/claim, GET|POST /me/desk, POST /me/desk/release, GET|POST /me/display-name, GET /assets, POST /recordings/{start,stop,url}, and /admin/* (including GET /admin/users, POST /admin/users/:id/revoke and POST /admin/users/:id/password-reset).
Server module pattern (hexagonal), per feature folder in server/src/:
*Port.ts: interface the routes depend on.pg*.ts: Postgres adapter used at runtime (built indirectory/fromEnv.tswhenDATABASE_URLis set; unset means the feature is absent).memory*.ts: in-memory adapter for tests and injection, with the same behavior contract.*Rules.ts: pure domain rules and typed errors.*Routes.ts: HTTP handlers as pure functions returning{ status, body }, tested without Express;createOfficeServer.tsadapts them.fromEnv.ts/*FromEnv: build adapters fromprocess.env. Only wiring code reads env; rooms and routes get dependencies injected.
Client follows the same idea: *Port.ts + *Client.ts (with injected fetch) + UI component; pure helpers (proximity.ts, reconnectPolicy.ts, route.ts) take inputs instead of touching window.
Infra: locally infra/livekit/ runs LiveKit + Egress + Redis, and the root docker-compose.yml adds Postgres, the server and the SPA on top of it. Deployed, one GCE VM runs caddy, web (nginx SPA), colyseus, livekit, redis, egress via infra/gcp/docker-compose.yml; the directory database is a Cloud SQL PostgreSQL 17 instance (infra/gcp/terraform/database.tf) reached over its private IP with TLS (DATABASE_SSL_CA_FILE), with automated backups and point-in-time recovery. Caddy terminates TLS and multiplexes app.*, lk.*, turn.* sslip.io hostnames on 443 by SNI.
Coding conventions
- TypeScript strict everywhere. Server files import with explicit
.tsextensions (Node type stripping + ESM); client files import without extensions. @colyseus/schemaclasses must not use class fields: declare fields via a mergedinterfaceand create instances through factories (seeserver/src/schema.ts,server/README.md). Class fields silently break serialization.- Naming: React components and Phaser scenes in PascalCase files (
OfficeShell.tsx,OfficeScene.ts); modules in camelCase (livekitRoom.ts); hooksuseX.tsinsrc/hooks/; ports*Port.ts; adaptersmemory*/pg*; route modules*Routes.ts. - Named exports are the norm;
AppandDashboardRouteare default exports because ofReact.lazy. - UI copy is Spanish. Recent code comments, commit messages and docs are English; match the surrounding file when editing old Spanish comments.
- Comments explain why (decisions, issue numbers like
#24), not what. - Dependencies are pinned deliberately (Phaser 3, Colyseus 0.16 line,
@colyseus/core0.16.24 override, digest-pinned Docker images). Readserver/README.mdbefore bumping Colyseus. - Border-radius follows a 3-tier scale defined as CSS custom properties in
src/index.css:--radius-panel(12px, floating panel/card containers),--radius-control(8px, buttons/inputs/selects/tiles/list rows),--radius-tag(6px, small badges/labels overlaid on content). Use the token matching the element's role, never a literal px value, exceptborder-radius: 50%on circles (dots, avatars), which is not part of this scale. The Phaser minimap stays square on purpose: it's a canvas camera viewport, not a DOM box, so no CSS radius applies to it.
Testing:
- Strict TDD is expected: write the failing test first, then the code. Every behavior change ships with tests.
- Tests are co-located next to the source:
foo.ts+foo.test.ts. - Suffix picks the Vitest project:
*.test.ts(x)runs under jsdom (unit);*.browser.test.ts(x)runs in real Chromium (anything that imports Phaser, which cannot load under jsdom);server/**/*.test.tsandsrc/**/*.node.test.tsrun under Node (server). - No infrastructure in unit/server tests: Postgres adapters are tested against an injected query executor; routes are tested as pure functions; Colyseus tests start a real server on an ephemeral port.
- LiveKit-dependent tests use
describe.skipIf(!import.meta.env.VITE_LIVEKIT_E2E). - E2E (
e2e/*.e2e.test.mjs) uses real processes only: server on fixed port 2599,vite previewofdist-e2e/, and Playwright contexts. The test hook is compiled in only for--mode e2evia the__OFFICE_E2E__define;e2e/bundle-hook-absent.e2e.test.mjsasserts it is absent fromdist/.
Git workflow
mainis protected by theprotect-mainruleset: changes land only through pull requests; direct pushes, force pushes and branch deletion are blocked, with no bypass actors. Approvals are not required, but the PR is.- Branch from
main:feat/<issue>-slug,fix/<issue>-slug,docs/<slug>,ci/<slug>,test/<slug>,refactor/<slug>. Include the issue number when there is one (e.g.feat/20-screen-share). - Conventional commits with a scope:
feat(recording): ...,fix(infra): ...,test(desks): .... Common scopes:game,infra,office,spaces,desks,server,video,decor,dashboard,e2e,directory,hooks,auth. Imperative, lowercase subject. - Small, focused commits; keep tests green at each commit.
- CI (
.github/workflows/ci.yml) runs on PRs tomainand pushes tomain: typecheck,test:all,test:harness, build,build:e2e,test:e2e, then a real LiveKit container fortest:e2e:audio(blocking). - Never commit
.envfiles (only.env.exampleand.env.e2eare tracked), credentials,dist*/, or.codegraph/.
Deployment
Only a test environment exists (infra/gcp/README.md is the full runbook).
deploy-test.ymltriggers onworkflow_runof CI completing successfully for a push tomain(or manually viagh workflow run deploy-test.yml, which skips CI). It deploysworkflow_run.head_sha, not the tip ofmain.- It builds and pushes
caddy,colyseusandwebimages tagged with the commit SHA (neverlatest) to Artifact Registry, then SSHes over IAP and runsoffice-deploy <sha>, then smoke checkshttps://<APP_HOST>/health. infra/gcp/scripts/office-deploy.sh(installed on the VM as/usr/local/bin/office-deploy, refreshed from instance metadata on every deploy) is idempotent: reads config (compose, Caddyfile,livekit.yaml.tpl) from instance metadata, reads secrets from Secret Manager, atomically rewrites/opt/office/.env(0600),docker compose pull+up -d, reloads Caddy, restarts LiveKit only iflivekit.yamlchanged, prunes old images.- Terraform (
infra/gcp/terraform/) is applied by a human, never by CI. Config files reach the VM as metadata written byterraform apply; image rollback alone (office-deploy <old-sha>) does not revert config. - Secrets (LiveKit key/secret, DB password, optional identity admin key) live only in Secret Manager, never in the repo, GitHub secrets or Terraform state.
- GitHub repository variables (not secrets):
GCP_PROJECT_ID,GCP_WORKLOAD_IDENTITY_PROVIDER,GCP_DEPLOYER_SA,APP_HOST, optionalFIREBASE_API_KEY,FIREBASE_PROJECT_ID,FIREBASE_AUTH_DOMAIN,GCP_ZONE,GCP_REGION,GCP_INSTANCE,GCP_AR_REPOSITORY.
Gotchas
- LiveKit reads
livekit.yamlonly at startup and Caddy does not watch its Caddyfile. Both are bind mounts, sodocker compose up -dkeeps old config.office-deployreloads Caddy and restarts LiveKit when the renderedlivekit.yamlchecksum changes (a restart drops live calls). If you change deploy config handling, preserve both. - A new server route needs its own
handleininfra/gcp/Caddyfile, before the final catch-allhandle. Otherwise it returnsindex.htmlwith 200 (browser showsUnexpected token '<', server logs nothing). office-deployon disk is only rewritten at VM boot; the workflow refreshes it from metadata before running. Keep that step if you touch the workflow.pnpm serverdoes not load.env; export variables or usenode --env-file=.env server/src/main.ts.VITE_COLYSEUS_URLis commented out in.env.exampleon purpose: Vite exposes an emptyVITE_COLYSEUS_URL=as"", whichresolveOfficeEndpointtreats as "multiplayer off" (unset derivesws(s)://<host>:2567). Never ship it uncommented and empty. Local Docker setup: README "Running locally with Docker".- Phaser cannot be imported under jsdom; anything touching it must be a
*.browser.test.ts(x). vite.config.tsrepeatsdefineper Vitest project on purpose (test.projectsdoes not inherit it);__OFFICE_E2E__must be defined in each..env.e2ekeeps Firebase keys empty on purpose so a developer's real.envdoes not leak into the e2e bundle.@colyseus/core0.16.25 is published broken and there is no JS client for Colyseus 0.17: stay on 0.16.24 (override inpnpm-workspace.yaml). Do not use thecolyseusmeta-package.- Recording on the default
e2-mediumVM is refused by Egress admission (every start returns 502egress-failed);e2-standard-4allows one concurrent recording. - Signed URLs need the VM service account to have
roles/iam.serviceAccountTokenCreatoron itself; service account keys are forbidden in the GCP project. Locally, use impersonated ADC (infra/livekit/README.md, "Local recording"); plain user ADC cannot sign. - Egress needs the upload destination in each request; the storage block in its config is not a default destination.
- The deployed directory DB is Cloud SQL (#72). Its user password comes from the
db-passwordsecret through an ephemeral resource into write-onlypassword_wo(never in state; needs Terraform >= 1.11). Rotating: new secret version, bumpdb_password_version,terraform apply, redeploy. Backups: daily + 7 days of PITR (infra/gcp/README.md, "Backups and restore"). google_compute_instance.officeignoresmetadata_startup_script(ForceNew: an edit used to replace the VM, which wiped the directory in #72). Roll startup script changes out withgcloud compute instances add-metadata ... startup-script=; replacing the VM is an explicitterraform apply -replace=....- Login fails closed (#72):
resolveOnLoginnever creates rows except the bootstrap superadmin; every other account must be added from/dashboardor it gets 401not-provisioned. - Secret values in Secret Manager must have no trailing newline (use
printf/tr -d '\n'), or LiveKit token signatures fail silently. - Local
infra/livekit/livekit.yamlpublishes only UDP 50000-50019 and has TURN disabled; the deployed config isinfra/gcp/livekit.yaml.tpl. - Password-reset emails (#94) depend on Identity Platform console settings that no code manages: the Password reset email template, the IAM permission
firebaseauth.users.sendEmailon the server identity, and authorized domains if a continue URL is ever added (none is sent today). Seeinfra/gcp/README.md, "Password-reset email".
