项目文件夹

文件
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

346 行
12 KiB
TypeScript

/**
* Standalone Bun server for the edad-chat container deployment.
*
* Routes:
* GET / → public/index.html
* GET /style.css, etc. → public/* static
* GET /api/config → non-secret OAuth config (app_id, cloud_url)
* POST /api/auth/exchange → redeem the OAuth `code`, mint an app session
* POST /api/messages → forwarded to ELIZA_CLOUD_URL via @elizaos/cloud-sdk
* GET /api/history → this user's persisted chat history
* GET /health → "ok" for ECS health probes
*
* Auth (the supported Eliza Cloud app-OAuth model):
* 1. The browser sends the user to `/app-auth/authorize`; Cloud redirects back
* with a SINGLE-USE `code` (`eac_…`), not a durable token.
* 2. The browser POSTs that code to /api/auth/exchange ONCE. The server
* redeems it server-side at `GET /api/v1/app-auth/session` (which consumes
* the code and returns the user's identity), then mints its OWN signed app
* session token (see app-session.ts) that the browser reuses thereafter.
* 3. /api/messages validates that app session (identifying the user) and
* forwards upstream using the app OWNER's Cloud key (`ELIZAOS_CLOUD_API_KEY`)
* plus `x-app-id` + `x-affiliate-code`. Billing therefore hits the app's
* monetized credit pool (creator inference markup) and credits the
* affiliate — the owner key never leaves the server.
*
* (An earlier version read a `?token=` param and forwarded it as the upstream
* bearer. Cloud never returns `token` and the redeemed code is single-use, so
* that flow could never complete a sign-in — this is the corrected design.)
*/
import { join } from "node:path";
import { CloudApiError, ElizaCloudClient } from "@elizaos/cloud-sdk";
import { mintAppSession, verifyAppSession } from "./app-session.ts";
import { dbReady, getHistory, initDb, saveTurn, userRef } from "./db.ts";
const PORT = Number(process.env.PORT ?? 3000);
const PUBLIC_DIR = join(import.meta.dir, "public");
const CLOUD_URL = (
process.env.ELIZA_CLOUD_URL ?? "https://elizacloud.ai"
).replace(/\/+$/, "");
const AFFILIATE_CODE = process.env.ELIZA_AFFILIATE_CODE ?? "";
const APP_ID = process.env.ELIZA_APP_ID ?? "";
// The app owner's Cloud key — used SERVER-SIDE only as the upstream bearer so
// inference bills the app's monetized credit pool. Never sent to the browser.
const OWNER_CLOUD_KEY =
process.env.ELIZAOS_CLOUD_API_KEY ?? process.env.ELIZA_CLOUD_API_KEY ?? "";
// Secret for signing app session tokens. Defaults to the owner key so a single
// configured secret suffices; set EDAD_SESSION_SECRET to rotate independently.
const SESSION_SECRET = process.env.EDAD_SESSION_SECRET ?? OWNER_CLOUD_KEY;
// Sticky headers attached to every upstream call. Empty values are
// intentionally omitted: passing an unknown affiliate code makes upstream
// 500 with a raw DB error leak.
const STICKY_HEADERS: Record<string, string> = {
...(APP_ID ? { "x-app-id": APP_ID } : {}),
...(AFFILIATE_CODE ? { "x-affiliate-code": AFFILIATE_CODE } : {}),
"anthropic-version": "2023-06-01",
};
function jsonError(status: number, code: string, message: string): Response {
return new Response(JSON.stringify({ error: { code, message } }), {
status,
headers: { "content-type": "application/json" },
});
}
// Flatten a message `content` (string, or an array of {text} parts) to plain
// text for persistence. Defensive: unknown shapes collapse to "".
function flattenContent(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((c) =>
typeof c === "string"
? c
: typeof (c as { text?: unknown })?.text === "string"
? (c as { text: string }).text
: "",
)
.join(" ")
.trim();
}
return "";
}
/** The user's latest message text from the forwarded request body. */
function extractUserText(json: unknown): string {
const msgs = (
json as { messages?: Array<{ role?: string; content?: unknown }> }
)?.messages;
if (Array.isArray(msgs)) {
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i]?.role === "user") return flattenContent(msgs[i]?.content);
}
}
return "";
}
/** The assistant reply text from the upstream result (Anthropic-style shape). */
function extractReplyText(result: unknown): string {
const r = result as { content?: unknown; message?: { content?: unknown } };
return (
flattenContent(r?.content) || flattenContent(r?.message?.content) || ""
);
}
async function forwardMessages(
req: Request,
userId: string,
): Promise<Response> {
// Upstream bearer is the app OWNER's Cloud key (server-side only). The signed
// app session already authenticated `userId` to this app; upstream billing
// goes to the app's monetized credit pool via STICKY_HEADERS' x-app-id.
const cloud = new ElizaCloudClient({
baseUrl: CLOUD_URL,
bearerToken: OWNER_CLOUD_KEY,
defaultHeaders: STICKY_HEADERS,
});
try {
const json = await req.json();
const result = await cloud.routes.postApiV1Messages({ json });
// Persist the turn to this app's isolated per-tenant DB so history survives
// across sessions. No-op when the app has no DB (see db.ts); wrapped so a
// persistence error never affects the reply the user gets back.
if (dbReady()) {
const ref = userRef(userId);
await saveTurn(ref, "user", extractUserText(json));
await saveTurn(ref, "assistant", extractReplyText(result));
}
return Response.json(result);
} catch (err) {
if (err instanceof CloudApiError) {
return new Response(JSON.stringify(err.errorBody), {
status: err.statusCode,
headers: {
"content-type": "application/json",
"cache-control": "no-store",
},
});
}
return jsonError(
502,
"upstream_unreachable",
"eliza cloud didn't answer the phone. try again in a sec.",
);
}
}
async function handleApi(req: Request, segments: string[]): Promise<Response> {
// Local-only config endpoint — hands the browser the non-secret OAuth
// config it needs to start the "Sign in with Eliza Cloud" flow.
if (segments.length === 1 && segments[0] === "config") {
return Response.json(
{
app_id: APP_ID || null,
cloud_url: CLOUD_URL,
affiliate_code: AFFILIATE_CODE,
db_enabled: dbReady(),
},
{ headers: { "cache-control": "no-store" } },
);
}
// Redeem the single-use OAuth code for an app session. The code is consumed
// server-side (so a browser reload can't burn it) and we mint our own token.
if (
segments.length === 2 &&
segments[0] === "auth" &&
segments[1] === "exchange" &&
req.method === "POST"
) {
return handleAuthExchange(req);
}
const userId = verifyAppSession(
req.headers.get("x-app-session")?.trim(),
SESSION_SECRET,
);
if (!userId) {
return jsonError(
401,
"not_signed_in",
"dad needs you to sign in with eliza cloud first, champ. hit the sign-in button up top.",
);
}
if (
segments.length === 1 &&
segments[0] === "messages" &&
req.method === "POST"
) {
return forwardMessages(req, userId);
}
// Signed-in user's persisted chat history from this app's per-tenant DB.
// Empty when the app has no isolated DB — the UI just starts a fresh chat.
if (
segments.length === 1 &&
segments[0] === "history" &&
req.method === "GET"
) {
const messages = dbReady() ? await getHistory(userRef(userId)) : [];
return Response.json(
{ messages, db_enabled: dbReady() },
{ headers: { "cache-control": "no-store" } },
);
}
return jsonError(404, "not_found", "unknown route");
}
/**
* POST /api/auth/exchange — body `{ code }`. Redeem the single-use `eac_` code
* at Cloud's `GET /api/v1/app-auth/session` (which consumes it and returns the
* user's identity), then mint a signed app session token for the browser.
*/
async function handleAuthExchange(req: Request): Promise<Response> {
if (!OWNER_CLOUD_KEY || !SESSION_SECRET || !APP_ID) {
return jsonError(
500,
"not_configured",
"this app is missing its Cloud key / app id — the operator must set ELIZAOS_CLOUD_API_KEY and ELIZA_APP_ID.",
);
}
let code = "";
try {
const body = (await req.json()) as { code?: unknown };
code = typeof body.code === "string" ? body.code.trim() : "";
} catch {
return jsonError(400, "bad_request", "expected a JSON body with `code`.");
}
if (!code) {
return jsonError(400, "missing_code", "no authorization code provided.");
}
let res: Response;
try {
res = await fetch(`${CLOUD_URL}/api/v1/app-auth/session`, {
method: "GET",
headers: { authorization: `Bearer ${code}`, "x-app-id": APP_ID },
});
} catch {
return jsonError(
502,
"upstream_unreachable",
"couldn't reach eliza cloud to finish sign-in. try again in a sec.",
);
}
if (!res.ok) {
return jsonError(
401,
"exchange_failed",
"sign-in code was invalid or already used. try signing in again.",
);
}
const session = (await res.json().catch(() => null)) as {
user?: { id?: unknown; email?: unknown; name?: unknown };
} | null;
const userId =
session && typeof session.user?.id === "string" ? session.user.id : "";
if (!userId) {
return jsonError(
502,
"exchange_failed",
"cloud returned no user identity.",
);
}
return Response.json(
{
session: mintAppSession(userId, SESSION_SECRET),
user: {
id: userId,
email:
typeof session?.user?.email === "string" ? session.user.email : null,
name:
typeof session?.user?.name === "string" ? session.user.name : null,
},
},
{ headers: { "cache-control": "no-store" } },
);
}
async function serveStatic(
req: Request,
pathname: string,
): Promise<Response | null> {
const target = pathname === "/" ? "/index.html" : pathname;
if (target.includes("..") || !target.startsWith("/")) return null;
const file = Bun.file(join(PUBLIC_DIR, target));
if (!(await file.exists())) return null;
if (target === "/index.html") {
const html = await file.text();
const ogImageUrl = new URL("og-image.png", req.url).toString();
return new Response(
html.replaceAll('content="og-image.png"', `content="${ogImageUrl}"`),
{
headers: {
"cache-control": "no-store",
"content-type": "text/html; charset=utf-8",
},
},
);
}
return new Response(file, { headers: { "cache-control": "no-store" } });
}
// Connect the per-tenant DB (if any) before we start taking requests. Never
// throws — a missing/unreachable DB just means stateless mode (see db.ts).
await initDb();
const server = Bun.serve({
port: PORT,
hostname: "0.0.0.0",
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/health") {
return new Response("ok", { headers: { "content-type": "text/plain" } });
}
if (url.pathname.startsWith("/api/")) {
const segments = url.pathname
.slice("/api/".length)
.split("/")
.filter((s) => s !== "");
if (!segments.length || segments.some((s) => s.includes(".."))) {
return jsonError(404, "not_found", "unknown route");
}
return handleApi(req, segments);
}
const staticRes = await serveStatic(req, url.pathname);
if (staticRes) return staticRes;
return new Response("not found", { status: 404 });
},
});
console.log(
`[edad-chat] listening on http://${server.hostname}:${server.port}`,
);
console.log(`[edad-chat] cloud: ${CLOUD_URL}`);
console.log(`[edad-chat] app_id: ${APP_ID || "(unset)"}`);
console.log(`[edad-chat] affiliate: ${AFFILIATE_CODE || "(unset)"}`);