Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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:<uuid>) with platform admin rights in spaces.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>`) and unlisted or private spaces (`/stoa/s/<id>`). 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/<slug>`) and unlisted or private spaces (`/stoa/s/<id>`). 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=<title>` 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.

Expand Down
2 changes: 1 addition & 1 deletion docs/platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion scripts/connections.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
3 changes: 2 additions & 1 deletion scripts/spaces/signaling.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down
9 changes: 8 additions & 1 deletion tests/platform.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }); }
Expand Down Expand Up @@ -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);
});
4 changes: 2 additions & 2 deletions tests/spaces-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
10 changes: 8 additions & 2 deletions tests/spaces-core.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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");
});
2 changes: 1 addition & 1 deletion tests/spaces-service.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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." };

Expand Down
Loading