140 lines
10 KiB
Markdown
140 lines
10 KiB
Markdown
# 造物工坊 · Foundry Core
|
||
|
||
共享后端的第一版提供 Google 登录。各项目通过同一个 HTTPS API 登录,在私有 PostgreSQL 的认证库中维护独立账号和会话。项目业务库可分别配置独立的数据库和角色。
|
||
|
||
## 服务端配置
|
||
|
||
1. 在 Google Cloud 创建一个 Web application OAuth 客户端。授权重定向 URI 填写 `https://<API 域名>/auth/google/callback`。所有项目信任同一个客户端,因此共享 Google 同意页面的应用名称。
|
||
2. 在服务器私网准备 PostgreSQL 13 或以上版本。在认证库执行一次 `db/001-auth.sql`(使用 `psql -v ON_ERROR_STOP=1`),给运行账号授予这四张表所需的查询及增删改权限。
|
||
3. 按 `.env.example` 注入运行环境变量。每个项目配置一个固定的前端回调地址,本地开发可用 localhost。将 Google 客户端密钥和数据库连接串存入服务器的密钥管理设施。
|
||
4. 由 Gitea Actions 在 `ces-control` 上使用 Go 1.25 构建并检查。部署工作流仅处理 `main` 上影响后端或部署的改动,将二进制启动在服务器 Docker 私网,由 Traefik 提供 HTTPS。通过 `GET /healthz` 检查进程,通过 `GET /readyz` 检查认证库。
|
||
5. 代理避免记录 Google 回调的查询参数及认证请求头。设置入口限流,并定时删除三张临时表(`oauth_attempts`、`login_grants`、`project_sessions`)中 `expires_at <= now()` 的记录。
|
||
|
||
## Actions 部署配置
|
||
|
||
在仓库的 Actions Secrets 配置 `GOOGLE_CLIENT_ID` 和 `GOOGLE_CLIENT_SECRET`,在 Actions Variables 配置 `FOUNDRY_PROJECTS`。变量值为 JSON 对象,例如 `{"prompt":"http://localhost:17321/auth/callback"}`。部署读取服务器上预先配置的 `/opt/endoleap-infra/foundry/database.env`,OAuth 值由 Actions 在启动容器时注入。
|
||
|
||
Google OAuth 的授权回调地址为 `https://api.foundry.entroia.com/auth/google/callback`。工作流会验证数据库就绪、匿名请求返回 401 和 HTTPS 就绪。前端接入后,可在浏览器用 Google 测试账号验证完整登录、回调、兑换和会话。
|
||
|
||
## 前端接入
|
||
|
||
1. 在浏览器用 Web Crypto 生成 32 字节随机值,编码为无填充 Base64URL,作为 verifier 暂存在当前 tab 的 sessionStorage。对 verifier 字符串做 SHA-256 并用相同编码得到 challenge。
|
||
2. 顶层导航到 `GET /projects/<project>/auth/google?challenge=<challenge>`。Google 回到共享 API 后,API 将浏览器重定向至已配置的项目回调。
|
||
3. 回调页从 URL fragment 读取 `code` 或 `error`,用 history.replaceState 清掉 fragment。收到 code 时,向 `POST /projects/<project>/auth/exchange` 发送 `X-Foundry-Code` 和 `X-Foundry-Verifier` 请求头,随后清理暂存的 verifier。code 有效期一分钟,仅可使用一次。
|
||
4. 将返回的八小时会话凭证保存在内存中,使用 `Authorization: Bearer <access_token>` 调用 `GET /projects/<project>/auth/me`。通过 `POST /projects/<project>/auth/logout` 撤销会话并清理本地凭证。刷新页面后,内存凭证需要通过重新登录获取。
|
||
|
||
同一个 Google 账号首次进入不同项目时,会分别创建用户 ID。会话绑定其签发项目。Google 账号的标识 `sub` 用于认领同一项目的既有账号,邮箱只作为资料字段。当前注册模式为开放注册。
|
||
|
||
## 验收
|
||
|
||
用同一个 Google 测试账号分别登录两个项目,确认用户 ID 不同。用 A 的会话请求 B 的 `me` 应返回 401。验证兑换码重放被拒绝,并确认退出 A 后 B 的会话仍有效。
|
||
|
||
## Runtime HTTPS trust
|
||
|
||
The Go backend needs an operating-system CA bundle for Google and Slack HTTPS
|
||
requests. The `node:24-bookworm-slim` runtime does not include that trust store;
|
||
Node's own successful HTTPS checks do not validate Go's trust configuration.
|
||
`deploy/release.sh` mounts the deployment host's
|
||
`/etc/ssl/certs/ca-certificates.crt` read-only and sets `SSL_CERT_FILE` to that
|
||
path. The deployment fails before switching traffic if the host bundle is
|
||
missing or unreadable in the candidate. Keep TLS certificate verification
|
||
enabled and maintain the deployment host's trusted CA package.
|
||
|
||
## Codex execution
|
||
|
||
When enabled, `POST /projects/{project}/codex/exec` accepts a JSON object
|
||
containing only a nonempty `prompt` string and returns `{"result":"…"}`.
|
||
Use `Authorization: Bearer <token>` with a dedicated 32-byte random token
|
||
encoded as 64 hex characters. Ordinary OAuth sessions cannot execute Codex.
|
||
Missing, unknown and revoked tokens return 401. A valid token for a different
|
||
project returns 403. Requests are limited to 32 KiB, CLI output to 512 KiB,
|
||
and each execution to 90 seconds. The worker accepts one execution at a time
|
||
and returns 429 when busy.
|
||
|
||
On the deployment host, provision `/opt/endoleap-infra/foundry/codex` with:
|
||
|
||
- `grants/projects.json` containing a JSON object mapping lowercase hex SHA-256
|
||
digests of the **decoded** token bytes to configured project names. Use mode
|
||
0700 for the directory and 0600 for the file, readable by UID 1000. Rotate or
|
||
revoke tokens by atomically replacing the file within the mounted directory.
|
||
A revocation applies to subsequent requests.
|
||
- `home` owned by UID 1000 with an isolated Codex login in `auth.json`.
|
||
Keep this home dedicated to the worker. Supply the login only and keep user
|
||
configuration, MCP servers, skills, and other workload files out of it.
|
||
|
||
With that directory configured, the next normal Foundry deployment starts the
|
||
worker using the Linux binary built by ces-control and mounts the worker socket
|
||
and grants directory read-only. To replace an existing worker, stop and remove
|
||
its container, then run `bash deploy/codex-worker.sh /absolute/path/to/foundry`
|
||
with the desired ces-control build. The worker
|
||
runs the installed Codex CLI with fixed read-only, no-approval arguments and
|
||
disables command execution tools. It receives prompts via stdin and returns
|
||
the final agent message. The worker has no published port and Foundry receives
|
||
neither the Codex home nor the Docker socket. Keep both containers on the same
|
||
host for Unix socket access.
|
||
|
||
### Board Canvas snapshots and slugs
|
||
|
||
Apply `db/003-canvas.sql` through the normal migration runner before starting this
|
||
version. `GET /projects/{project}/workspaces/{workspace}/materials/{board}/canvas`
|
||
returns `{version, document}` and a quoted `ETag`. An untouched board returns
|
||
version 1 and an empty schema-1 graph. `PATCH` takes `{document: <full snapshot>}`
|
||
and the quoted `If-Match`; it atomically replaces the snapshot and returns
|
||
`{version}` with the new `ETag`. Canvas and material metadata have separate
|
||
versions. GET uses existing material read permissions; PATCH requires editor or
|
||
admin. Missing versions return 428, stale versions 412 `version_conflict`, invalid
|
||
graphs 400 `invalid_canvas`, and bodies over 5 MiB (UTF-8 bytes) 413. PNG/JPEG/WebP/
|
||
GIF data URLs up to 2 MiB decoded and HTTPS image URLs are supported. Temporary
|
||
node selection/drag/measurement state and mirrored styles are discarded.
|
||
|
||
Workspace and material create/get/list responses include `slug`. Create and
|
||
metadata PATCH accept an optional slug (1–100 lowercase ASCII letters/digits,
|
||
with single hyphens between segments; UUID-form custom values are reserved).
|
||
Workspace slugs are unique within a project, material slugs within a workspace;
|
||
duplicates return 409. Defaults use the ASCII name/title and UUID, falling back
|
||
to a generic prefix for names without ASCII letters/digits. Existing records are
|
||
backfilled. Renaming leaves slugs stable unless explicitly supplied. Workspace
|
||
and material path parameters accept either ID or slug, with unchanged permissions.
|
||
Canvas uses its board material's slug; there is no separate Canvas identity.
|
||
Slugs enable frontend SEO URLs but do not make private content public or add
|
||
search-engine metadata. Changing a custom slug does not retain an old alias.
|
||
|
||
Frontend follow-up: allowlist the Canvas proxy route and set its body limit to
|
||
at least 5 MiB; load the server snapshot, offer migration of old local drafts,
|
||
serialize autosaves and handle 412 explicitly. Restore node style dimensions
|
||
from width/height and render edges as Bézier curves. These frontend changes are
|
||
outside this Core delivery; local drafts alone are not database persistence.
|
||
|
||
Canvas schema 1 also supports frames: `type: "frame"` with `data.kind: "frame"`.
|
||
Frame names use `data.title`; optional `data.labelMode` is `hidden`, `fixed`, or
|
||
`scaled` (frontend default: `fixed`). Frames cannot rotate, connect to edges,
|
||
or belong to other frames. Any non-frame node can specify `data.frameId`, which
|
||
must reference a frame in the same snapshot; forward references are supported.
|
||
Membership is explicit and independent of geometry: moving a frame over existing
|
||
content does not add members. The frontend assigns membership on content drop
|
||
and implements label zoom behavior. Core preserves the supplied membership and
|
||
label mode without inferring them from positions. Renderer-only `zIndex` is
|
||
ignored alongside transient state; restore frame layering in the frontend.
|
||
This extends existing JSONB snapshots and requires no additional migration.
|
||
|
||
### Browser session lifetime
|
||
|
||
Sessions use random, revocable bearer tokens rather than JWTs. New email and OAuth
|
||
logins have a 30-day idle lifetime; authenticated requests atomically renew that
|
||
idle deadline, capped at 90 days from issuance. Idle-expired, absolute-expired,
|
||
revoked and wrong-project tokens are never revived. Logout removes the session.
|
||
|
||
Apply `db/004-session-lifetime.sql` before running this version. Existing sessions
|
||
keep their prior idle expiry, so expired logins remain expired. For legacy rows,
|
||
the absolute 90-day cap starts at migration time because issuance was not recorded;
|
||
an unexpired legacy session gains the new idle lifetime on its next authenticated
|
||
request. New rows record their actual creation time and absolute deadline.
|
||
|
||
Login and exchange responses return `expires_in: 2592000`. Protected platform
|
||
responses and `/auth/me` include `X-Foundry-Session-Expires-In` with the remaining
|
||
seconds after renewal, shortened near the absolute deadline. The frontend proxy
|
||
must renew its HttpOnly cookie using that value, with a maximum of 30 days. It must
|
||
not extend a cookie beyond the backend-provided remaining lifetime, or expose the
|
||
session token to client JavaScript. CORS exposes the lifetime header alongside
|
||
ETag where applicable. The absolute deadline stays server-authoritative.
|