diff --git a/.env.example b/.env.example index b65a96a..54c6d4d 100644 --- a/.env.example +++ b/.env.example @@ -29,8 +29,9 @@ AGORA_OAUTH_CLIENT_SECRET= AGORA_OAUTH_USERINFO_URL= AGORA_OAUTH_SUBJECT_FIELD=sub AGORA_BFF_URL=https://agora-cli.agora.io -# Base64-encoded 32-byte AES key, stored only in the server secret manager. -CONNECTION_ENCRYPTION_KEY= +# Encrypts stored Agora Console tokens (Agora connections only). Base64-encoded 32-byte AES key, +# stored only in the server secret manager. Never change it once connections exist. Formerly CONNECTION_ENCRYPTION_KEY. +AGORA_CONSOLE_ENCRYPTION_KEY= # PostgreSQL for durable shared user/session/credit/payment state in production. # Local development without this uses .data/community.json (one Node process). @@ -80,8 +81,9 @@ AGORA_APP_CERTIFICATE= AGORA_TOKEN_TTL_SECONDS=3600 # Agora Signaling (RTM 2.x) carries movement, presence and live messages, using AGORA_APP_ID and AGORA_APP_CERTIFICATE. # Enable Signaling for the project in Agora Console. -# At least 32 random characters. Derives private channel names and per-space keys; keep it secret and stable. -SPACE_CHANNEL_SECRET= +# At least 32 random characters. Derives unlisted and private spaces' Signaling channel names and keys; +# keep it secret and stable. Formerly SPACE_CHANNEL_SECRET. +SPACES_SIGNALING_SECRET= # Set to true only after Agora enables per-channel Signaling permissions for this project. AGORA_RTM_PERMISSIONS=false # Comma-separated portal account IDs (account:) with platform admin rights in spaces. diff --git a/README.md b/README.md index d6443a1..3040eca 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Use HTTPS for remote callers, or `http://localhost:3002` for local development. ## Stoa spaces -The Stoa is a walkable plaza (`/stoa/`) with first-come rooms (`/stoa/room/`) and unlisted or private spaces (`/stoa/s/`). Calls use Agora RTC; movement, presence, and live messages use Agora Signaling. Enable Signaling for the Agora project and set `SPACE_CHANNEL_SECRET` to at least 32 random characters. The built-in maps in `worlds/` are generated by `npm run maps`. +The Stoa is a walkable plaza (`/stoa/`) with first-come rooms (`/stoa/room/`) and unlisted or private spaces (`/stoa/s/`). Calls use Agora RTC; movement, presence, and live messages use Agora Signaling. Enable Signaling for the Agora project and set `SPACES_SIGNALING_SECRET` to at least 32 random characters. The built-in maps in `worlds/` are generated by `npm run maps`. Built-in themes live in `themes/` (Agora, Minimal, Cyberpunk) and are validated at startup. Hosts can hand over hosting, remove people from the room, and decorate it. Signed-in people can start their own spaces (`/stoa/?start=` opens the dialog with a title filled in), invite others with links, manage members, change settings, and delete a space. Free accounts can own up to 3 spaces; premium plans can own up to 20. diff --git a/docs/platform.md b/docs/platform.md index e7dabbb..ffe793b 100644 --- a/docs/platform.md +++ b/docs/platform.md @@ -221,7 +221,7 @@ that is the registered issuer. The documented upstream userinfo 401 / changing callback `loginId` issue must be resolved before existing Agora tokens can identify portal users. The portal refuses to substitute email or the callback login ID. -Set `CONNECTION_ENCRYPTION_KEY` to a base64-encoded 32-byte server key. Agora OAuth +Set `AGORA_CONSOLE_ENCRYPTION_KEY` (formerly `CONNECTION_ENCRYPTION_KEY`, still read as a fallback) to a base64-encoded 32-byte server key. It is used only for Agora Console connections. Agora OAuth access/refresh tokens are encrypted with AES-256-GCM at rest. Keep and back up this key separately from the database. `AGORA_BFF_URL` defaults to the existing `https://agora-cli.agora.io` service used by Astation. OIDC sign-in alone does not diff --git a/scripts/connections.mjs b/scripts/connections.mjs index f69f438..239e043 100644 --- a/scripts/connections.mjs +++ b/scripts/connections.mjs @@ -2,7 +2,8 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; import { AppError } from "./store.mjs"; export function connectionsConfig(env = process.env) { - const key = Buffer.from(env.CONNECTION_ENCRYPTION_KEY || "", "base64"); + // Encrypts stored Agora Console tokens. CONNECTION_ENCRYPTION_KEY is the old name, still read so an existing deployment keeps its data. + const key = Buffer.from(env.AGORA_CONSOLE_ENCRYPTION_KEY || env.CONNECTION_ENCRYPTION_KEY || "", "base64"); let bffUrl = ""; try { const url = new URL(env.AGORA_BFF_URL || "https://agora-cli.agora.io"); diff --git a/scripts/spaces/signaling.mjs b/scripts/spaces/signaling.mjs index a473ad4..8f14280 100644 --- a/scripts/spaces/signaling.mjs +++ b/scripts/spaces/signaling.mjs @@ -4,8 +4,9 @@ import { AppError } from "../store.mjs"; // Signaling tokens grant login only. Isolation comes from secret-derived channel names and per-space keys; // AGORA_RTM_PERMISSIONS=true adds Agora's per-channel permissions on top when Agora enables them. +// SPACES_SIGNALING_SECRET derives unlisted and private spaces' channel names and keys. SPACE_CHANNEL_SECRET is its old name, still read. export function signalingConfig(env = process.env) { - const appId = env.AGORA_APP_ID || "", certificate = env.AGORA_APP_CERTIFICATE || "", secret = env.SPACE_CHANNEL_SECRET || ""; + const appId = env.AGORA_APP_ID || "", certificate = env.AGORA_APP_CERTIFICATE || "", secret = env.SPACES_SIGNALING_SECRET || env.SPACE_CHANNEL_SECRET || ""; return { appId, certificate, secret, permissions: env.AGORA_RTM_PERMISSIONS === "true", ttl: 900, ready: /^[a-f0-9]{32}$/i.test(appId) && /^[a-f0-9]{32}$/i.test(certificate) && secret.length >= 32 }; } diff --git a/tests/platform.test.mjs b/tests/platform.test.mjs index 25ab125..7b43306 100644 --- a/tests/platform.test.mjs +++ b/tests/platform.test.mjs @@ -154,7 +154,7 @@ test("Agora OAuth refuses changing loginId and email as identities; verified acc await assert.rejects(bad.agoraIdentity("upstream-token"), { status: 401 }); const auth = createAuth(config, { request: async () => Response.json({ sub: "permanent-agora-user", name: "Agora builder" }) }); const store = newStore(), login = await store.login(await auth.agoraIdentity("upstream-token")); - const connectionConfig = connectionsConfig({ CONNECTION_ENCRYPTION_KEY: randomBytes(32).toString("base64") }); + const connectionConfig = connectionsConfig({ AGORA_CONSOLE_ENCRYPTION_KEY: randomBytes(32).toString("base64") }); let refreshes = 0; const resources = createConnections(store, auth, connectionConfig, { request: async (url, options) => { if (url.endsWith("/token")) { refreshes++; return Response.json({ access_token: "renewed-upstream-token", refresh_token: "rotated-upstream-refresh", expires_in: 7200 }); } @@ -270,3 +270,10 @@ test("an app can use paid portal services without a public profile, with scopes assert.equal((await store.session(login.token)).profile, null); } finally { await new Promise((done) => { server.close(done); server.closeIdleConnections(); }); } }); +test("the Agora Console key is read under its new name, with the old name as a fallback", () => { + const key = randomBytes(32).toString("base64"), other = randomBytes(32).toString("base64"); + assert.equal(connectionsConfig({ AGORA_CONSOLE_ENCRYPTION_KEY: key }).key.toString("base64"), key); + assert.equal(connectionsConfig({ CONNECTION_ENCRYPTION_KEY: key }).key.toString("base64"), key); + assert.equal(connectionsConfig({ AGORA_CONSOLE_ENCRYPTION_KEY: key, CONNECTION_ENCRYPTION_KEY: other }).key.toString("base64"), key, "the new name wins"); + assert.equal(connectionsConfig({}).ready, false); +}); diff --git a/tests/spaces-api.test.mjs b/tests/spaces-api.test.mjs index 4e5bc81..345e90b 100644 --- a/tests/spaces-api.test.mjs +++ b/tests/spaces-api.test.mjs @@ -10,13 +10,13 @@ import { authConfig, createAuth } from "../scripts/auth.mjs"; import { createSignaling, signalingConfig } from "../scripts/spaces/signaling.mjs"; const root = new URL("../", import.meta.url).pathname; -const env = { AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32), SPACE_CHANNEL_SECRET: "s".repeat(32) }; +const env = { AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32), SPACES_SIGNALING_SECRET: "s".repeat(32) }; const profile = (name) => ({ name, bio: "Building voice agents with Rust.", intent: "Build a language practice voice agent with natural interruptions.", lookingFor: "Audio engineers", skills: ["Rust", "Voice AI"], location: "Remote", contact: "https://example.com/builder", monitor: false }); const absent = "/00000000-0000-4000-8000-000000000000"; let temporary; const close = (instance) => new Promise((done) => { instance.close(done); instance.closeIdleConnections(); }); async function start(options = {}) { - const instance = createAppServer(root, { monitor: false, storageFile: resolve(temporary, Math.random() + ".json"), models: createModelClient(modelConfig({})), calls: createAgoraCalls(agoraConfig(env)), auth: createAuth(authConfig({})), signaling: createSignaling(signalingConfig(env)), channelSecret: env.SPACE_CHANNEL_SECRET, ...options }); + const instance = createAppServer(root, { monitor: false, storageFile: resolve(temporary, Math.random() + ".json"), models: createModelClient(modelConfig({})), calls: createAgoraCalls(agoraConfig(env)), auth: createAuth(authConfig({})), signaling: createSignaling(signalingConfig(env)), channelSecret: env.SPACES_SIGNALING_SECRET, ...options }); await new Promise((done) => instance.listen(0, "127.0.0.1", done)); return { instance, url: "http://127.0.0.1:" + instance.address().port }; } diff --git a/tests/spaces-core.test.mjs b/tests/spaces-core.test.mjs index a10eca1..bd4bd7a 100644 --- a/tests/spaces-core.test.mjs +++ b/tests/spaces-core.test.mjs @@ -248,13 +248,13 @@ test("lot decorations stay inside the lot's interior and keep its doors clear", import tokenTypes from "agora-token/src/AccessToken2.js"; import { channelName, createSignaling, signalingConfig, signalingUser, spaceKey } from "../scripts/spaces/signaling.mjs"; -const signalingEnv = { AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32), SPACE_CHANNEL_SECRET: "s".repeat(32) }; +const signalingEnv = { AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32), SPACES_SIGNALING_SECRET: "s".repeat(32) }; const parse = (token) => { const parsed = new tokenTypes.AccessToken2(); assert.equal(parsed.from_string(token), true); assert.equal(parsed.verifySignature("b".repeat(32)), true); return parsed; }; test("Signaling needs Agora credentials and a long channel secret", () => { const config = signalingConfig(signalingEnv); assert.deepEqual([config.ready, config.ttl, config.permissions], [true, 900, false]); - assert.equal(signalingConfig({ ...signalingEnv, SPACE_CHANNEL_SECRET: "short" }).ready, false); + assert.equal(signalingConfig({ ...signalingEnv, SPACES_SIGNALING_SECRET: "short" }).ready, false); assert.equal(signalingConfig({}).ready, false); assert.equal(signalingConfig({ ...signalingEnv, AGORA_RTM_PERMISSIONS: "true" }).permissions, true); assert.throws(() => createSignaling(signalingConfig({})).issue("a-x", []), { status: 503 }); @@ -290,3 +290,9 @@ test("Signaling tokens are signed for the user and add channel permissions only const service = parse(scoped.token).getServices(tokenTypes.kRtm2ServiceType)[0]; assert.deepEqual(service.__permissions.details, { 0: { 0: ["ab-stoa-plaza", "ab-stoa-ai-agents"], 1: ["ab-stoa-ai-agents"] } }); }); +test("the spaces Signaling secret is read under its new name, with the old name as a fallback", () => { + const base = { AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32) }; + assert.equal(signalingConfig({ ...base, SPACES_SIGNALING_SECRET: "n".repeat(32) }).secret, "n".repeat(32)); + assert.equal(signalingConfig({ ...base, SPACE_CHANNEL_SECRET: "o".repeat(32) }).secret, "o".repeat(32)); + assert.equal(signalingConfig({ ...base, SPACES_SIGNALING_SECRET: "n".repeat(32), SPACE_CHANNEL_SECRET: "o".repeat(32) }).secret, "n".repeat(32), "the new name wins"); +}); diff --git a/tests/spaces-service.test.mjs b/tests/spaces-service.test.mjs index 8706662..e841819 100644 --- a/tests/spaces-service.test.mjs +++ b/tests/spaces-service.test.mjs @@ -11,7 +11,7 @@ import { createSpaces, loadWorlds } from "../scripts/spaces/service.mjs"; const root = new URL("../", import.meta.url).pathname; const worlds = loadWorlds(root); const secret = "s".repeat(32); -const env = { AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32), SPACE_CHANNEL_SECRET: secret }; +const env = { AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32), SPACES_SIGNALING_SECRET: secret }; const profile = (name) => ({ name, bio: "Building voice agents with Rust.", intent: "Build a language practice voice agent with natural interruptions.", lookingFor: "Audio engineers", skills: ["Rust", "Voice AI"], location: "Remote", contact: "https://example.com/builder", monitor: false }); const missing = { status: 404, message: "This space was not found." };