From 4e5832c14b21807af90f5c2730427d0870bb455e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 00:18:53 +0800 Subject: [PATCH 01/25] Deploy Foundry Core from main with Actions credentials --- .gitea/workflows/deploy.yml | 39 ++++++++++++++++++ README.md | 8 +++- deploy/release.sh | 73 +++++++++++++++++++++++++++++++++ deploy/route.sh | 80 +++++++++++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 deploy/release.sh create mode 100644 deploy/route.sh diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..c728eeb --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,39 @@ +name: Deploy Foundry Core +on: + push: + branches: [main] + paths: + - 'cmd/**' + - 'internal/**' + - 'go.mod' + - 'go.sum' + - 'deploy/**' + - '.gitea/workflows/deploy.yml' + +concurrency: + group: foundry-core-main-deploy + cancel-in-progress: false + +jobs: + deploy: + if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push' + runs-on: ces-control + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: false + - name: Verify and build + run: | + set -eu + test -z "$(gofmt -l cmd internal)" + go test -mod=readonly ./... + CGO_ENABLED=0 go build -mod=readonly -o bin/foundry ./cmd/foundry + - name: Deploy and verify OAuth entry + env: + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + FOUNDRY_PROJECTS: ${{ vars.FOUNDRY_PROJECTS }} + run: bash deploy/release.sh bin/foundry diff --git a/README.md b/README.md index 447473c..740d98e 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,15 @@ 1. 在 Google Cloud 创建一个 Web application OAuth 客户端。授权重定向 URI 填写 `https:///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 构建并检查。部署时运行 `foundry` 二进制,通过 HTTPS 反向代理转发至服务监听地址。通过 `GET /healthz` 检查进程,通过 `GET /readyz` 检查认证库。 +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、Google 授权跳转参数和 HTTPS 就绪。完整登录还需在浏览器用 Google 测试账号走完回调、兑换和会话验证。 + ## 前端接入 1. 在浏览器用 Web Crypto 生成 32 字节随机值,编码为无填充 Base64URL,作为 verifier 暂存在当前 tab 的 sessionStorage。对 verifier 字符串做 SHA-256 并用相同编码得到 challenge。 diff --git a/deploy/release.sh b/deploy/release.sh new file mode 100644 index 0000000..0d9633d --- /dev/null +++ b/deploy/release.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +base=/opt/endoleap-infra +foundry=$base/foundry +origin=https://api.foundry.entroia.com +candidate=foundry-core-candidate +network=endoleap-dev_default + +for name in GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET FOUNDRY_PROJECTS; do + value=${!name-} + if [[ -z "$value" || "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + printf 'Missing or invalid %s\n' "$name" >&2 + exit 1 + fi +done +test -s "$foundry/database.env" +docker network inspect "$network" >/dev/null + +release=$(mktemp -d "$foundry/release.XXXXXXXX") +install -m 755 "$1" "$release/foundry" +export HOST=0.0.0.0 PORT=8080 PUBLIC_ORIGIN="$origin" +docker run -d --name "$candidate" --network "$network" \ + --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \ + -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS \ + --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ + --read-only --memory 128m \ + --mount "type=bind,source=$release/foundry,target=/opt/foundry,readonly" \ + node:24-bookworm-slim /opt/foundry >/dev/null + +cleanup_candidate() { + docker rm -f "${live:-$candidate}" >/dev/null 2>&1 || true +} +trap cleanup_candidate EXIT + +docker exec "$candidate" node -e ' + const assert = require("node:assert/strict"); + async function verify() { + const base = "http://127.0.0.1:8080"; + for (let attempt = 0; ; attempt++) { + try { + const ready = await fetch(base + "/readyz", {signal: AbortSignal.timeout(3000)}); + if (ready.ok) break; + } catch (error) { + if (attempt === 9) throw error; + } + assert.ok(attempt < 9); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + const project = Object.keys(JSON.parse(process.env.FOUNDRY_PROJECTS))[0]; + const route = base + "/projects/" + encodeURIComponent(project) + "/auth"; + assert.equal((await fetch(route + "/me")).status, 401); + const challenge = require("node:crypto").randomBytes(32).toString("base64url"); + const start = await fetch(route + "/google?challenge=" + challenge, {redirect: "manual"}); + assert.ok([302, 303].includes(start.status)); + const location = new URL(start.headers.get("location")); + assert.equal(location.hostname, "accounts.google.com"); + assert.equal(location.searchParams.get("client_id"), process.env.GOOGLE_CLIENT_ID); + assert.equal(location.searchParams.get("redirect_uri"), process.env.PUBLIC_ORIGIN + "/auth/google/callback"); + } + verify().catch(() => { console.error("Foundry readiness or OAuth entry check failed"); process.exit(1); }); +' + +# Keep the previous running container until the new candidate has passed checks. +# Traefik reads the route atomically from its watched config directory. +id=$(docker inspect -f '{{.Id}}' "$candidate") +live=foundry-core-${id:0:12} +docker rename "$candidate" "$live" +docker update --restart unless-stopped "$live" >/dev/null +bash "$(dirname "$0")/route.sh" "$live" +trap - EXIT +printf 'Foundry readiness, unauthenticated rejection and Google redirect verified.\n' diff --git a/deploy/route.sh b/deploy/route.sh new file mode 100644 index 0000000..5a7c29b --- /dev/null +++ b/deploy/route.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 +cd /opt/endoleap-infra +live=$1 +[[ "$live" =~ ^foundry-core-[a-f0-9]{12}$ ]] + +install -d -m 700 dev/foundry-edge +edge=dev/foundry-edge +route=$edge/foundry.yml +previous= +if [[ -f "$route" ]]; then + previous=$(sed -n 's@.*url: http://\(foundry-core-[a-f0-9]\{12\}\):8080.*@\1@p' "$route") +fi +backup=$(mktemp) +if [[ -f "$route" ]]; then cp "$route" "$backup"; fi +rollback() { + if [[ -s "$backup" ]]; then + cp "$backup" "$edge/.restore" + mv "$edge/.restore" "$route" + else + rm -f "$route" + fi + rm -f "$backup" +} +trap rollback EXIT + +touch "$edge/acme.json" +chmod 600 "$edge/acme.json" +cat > "$edge/.foundry.yml" < foundry-edge.yml <<'YAML' +services: + traefik: + command: + - --entrypoints.web.address=:8080 + - --entrypoints.websecure.address=:8443 + - --ping=true + - --ping.entrypoint=web + - --api.dashboard=false + - --providers.file.directory=/edge + - --certificatesresolvers.public.acme.storage=/edge/acme.json + - --certificatesresolvers.public.acme.tlschallenge=true + ports: + - "443:8443" + volumes: + - ./dev/foundry-edge:/edge +YAML +docker compose -p endoleap-dev --env-file dev/env -f compose.yml -f dev.yml -f foundry-edge.yml up -d --no-deps traefik + +curl --fail --silent --show-error --output /dev/null --max-time 6 \ + --retry 15 --retry-delay 4 --retry-all-errors \ + --resolve api.foundry.entroia.com:443:127.0.0.1 \ + https://api.foundry.entroia.com/readyz + +trap - EXIT +rm -f "$backup" +if [[ "$previous" =~ ^foundry-core-[a-f0-9]{12}$ && "$previous" != "$live" ]]; then + docker stop "$previous" >/dev/null || printf 'Previous container needs manual cleanup.\n' >&2 +fi -- 2.52.0 From 33719a549b3540b8ac65a98b713dcc969cde7d7a Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 00:57:53 +0800 Subject: [PATCH 02/25] Keep deployment smoke checks backend-only --- .gitea/workflows/deploy.yml | 2 +- README.md | 2 +- deploy/release.sh | 11 ++--------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c728eeb..0839aca 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -31,7 +31,7 @@ jobs: test -z "$(gofmt -l cmd internal)" go test -mod=readonly ./... CGO_ENABLED=0 go build -mod=readonly -o bin/foundry ./cmd/foundry - - name: Deploy and verify OAuth entry + - name: Deploy and verify backend readiness env: GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} diff --git a/README.md b/README.md index 740d98e..856d3f8 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ 在仓库的 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、Google 授权跳转参数和 HTTPS 就绪。完整登录还需在浏览器用 Google 测试账号走完回调、兑换和会话验证。 +Google OAuth 的授权回调地址为 `https://api.foundry.entroia.com/auth/google/callback`。工作流会验证数据库就绪、匿名请求返回 401 和 HTTPS 就绪。前端接入后,可在浏览器用 Google 测试账号验证完整登录、回调、兑换和会话。 ## 前端接入 diff --git a/deploy/release.sh b/deploy/release.sh index 0d9633d..a610ee4 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -51,15 +51,8 @@ docker exec "$candidate" node -e ' const project = Object.keys(JSON.parse(process.env.FOUNDRY_PROJECTS))[0]; const route = base + "/projects/" + encodeURIComponent(project) + "/auth"; assert.equal((await fetch(route + "/me")).status, 401); - const challenge = require("node:crypto").randomBytes(32).toString("base64url"); - const start = await fetch(route + "/google?challenge=" + challenge, {redirect: "manual"}); - assert.ok([302, 303].includes(start.status)); - const location = new URL(start.headers.get("location")); - assert.equal(location.hostname, "accounts.google.com"); - assert.equal(location.searchParams.get("client_id"), process.env.GOOGLE_CLIENT_ID); - assert.equal(location.searchParams.get("redirect_uri"), process.env.PUBLIC_ORIGIN + "/auth/google/callback"); } - verify().catch(() => { console.error("Foundry readiness or OAuth entry check failed"); process.exit(1); }); + verify().catch(() => { console.error("Foundry readiness or anonymous access check failed"); process.exit(1); }); ' # Keep the previous running container until the new candidate has passed checks. @@ -70,4 +63,4 @@ docker rename "$candidate" "$live" docker update --restart unless-stopped "$live" >/dev/null bash "$(dirname "$0")/route.sh" "$live" trap - EXIT -printf 'Foundry readiness, unauthenticated rejection and Google redirect verified.\n' +printf 'Foundry readiness and unauthenticated rejection verified.\n' -- 2.52.0 From cc8ddba7aa4fafc294edd29706c3a2ded80b748a Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 01:44:44 +0800 Subject: [PATCH 03/25] Accept formatted Foundry project configuration from Actions --- deploy/release.sh | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/deploy/release.sh b/deploy/release.sh index a610ee4..14260ae 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -8,13 +8,36 @@ origin=https://api.foundry.entroia.com candidate=foundry-core-candidate network=endoleap-dev_default -for name in GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET FOUNDRY_PROJECTS; do +for name in GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET; do value=${!name-} if [[ -z "$value" || "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then printf 'Missing or invalid %s\n' "$name" >&2 exit 1 fi done +# Accept formatted JSON from Actions Variables and never print its contents. +FOUNDRY_PROJECTS=$(python3 - <<'PYCONFIG' +import json +import os +import sys + +raw = os.environ.get("FOUNDRY_PROJECTS", "") +if not raw.strip(): + sys.exit("FOUNDRY_PROJECTS was not injected from Actions Variables") +try: + projects = json.loads(raw) +except ValueError: + sys.exit("FOUNDRY_PROJECTS must be valid JSON") +if not isinstance(projects, dict) or not projects or any( + not isinstance(callback, str) or not callback.strip() + for callback in projects.values() +): + sys.exit("FOUNDRY_PROJECTS must map project names to callback URLs") +print(json.dumps(projects, separators=(",", ":"))) +PYCONFIG +) +export FOUNDRY_PROJECTS + test -s "$foundry/database.env" docker network inspect "$network" >/dev/null -- 2.52.0 From 9e5d805f5af9d5b005fbbe63008e7511bd13b0a7 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 02:29:49 +0800 Subject: [PATCH 04/25] Add project-scoped Codex execution over a private socket --- README.md | 32 ++++++++ cmd/foundry/main.go | 13 +++- deploy/codex-worker.sh | 23 ++++++ deploy/release.sh | 15 +++- internal/codex/http.go | 143 ++++++++++++++++++++++++++++++++++++ internal/codex/http_test.go | 69 +++++++++++++++++ internal/codex/worker.go | 134 +++++++++++++++++++++++++++++++++ 7 files changed, 426 insertions(+), 3 deletions(-) create mode 100644 deploy/codex-worker.sh create mode 100644 internal/codex/http.go create mode 100644 internal/codex/http_test.go create mode 100644 internal/codex/worker.go diff --git a/README.md b/README.md index 856d3f8..017cc29 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,35 @@ Google OAuth 的授权回调地址为 `https://api.foundry.entroia.com/auth/goog ## 验收 用同一个 Google 测试账号分别登录两个项目,确认用户 ID 不同。用 A 的会话请求 B 的 `me` 应返回 401。验证兑换码重放被拒绝,并确认退出 A 后 B 的会话仍有效。 + +## 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 ` 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. + +Start the single worker with `bash deploy/codex-worker.sh /absolute/path/to/foundry` +using the Linux binary built by ces-control. To replace the worker, stop and remove +the existing `foundry-codex-worker` container first. The next normal Foundry +deployment mounts the worker socket and grants directory read-only. 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. diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go index b354ce0..0eac158 100644 --- a/cmd/foundry/main.go +++ b/cmd/foundry/main.go @@ -10,6 +10,7 @@ import ( "time" "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/codex" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/stdlib" ) @@ -22,6 +23,9 @@ func env(key, fallback string) string { } func main() { + if len(os.Args) == 2 && os.Args[1] == "codex-worker" { + log.Fatal(codex.ServeWorker("/run/foundry-codex/worker.sock")) + } origin, err := url.Parse(os.Getenv("PUBLIC_ORIGIN")) if err != nil || origin.Scheme != "https" || origin.Hostname() == "" || origin.User != nil || (origin.Path != "" && origin.Path != "/") || origin.RawQuery != "" || origin.Fragment != "" { @@ -45,10 +49,15 @@ func main() { if err != nil { log.Fatal(err) } + mux := http.NewServeMux() + if err := codex.Register(mux, callbacks, os.Getenv("FOUNDRY_CODEX_SOCKET"), os.Getenv("FOUNDRY_CODEX_GRANTS")); err != nil { + log.Fatal(err) + } + mux.Handle("/", handler) server := &http.Server{ Addr: net.JoinHostPort(env("HOST", "127.0.0.1"), env("PORT", "8080")), - Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, - WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, + Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, + WriteTimeout: 110 * time.Second, IdleTimeout: 60 * time.Second, } log.Fatal(server.ListenAndServe()) } diff --git a/deploy/codex-worker.sh b/deploy/codex-worker.sh new file mode 100644 index 0000000..8e39da9 --- /dev/null +++ b/deploy/codex-worker.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +base=/opt/endoleap-infra +dir=$base/foundry/codex +binary=$(realpath "$1") +test -x "$binary" +test -s "$dir/home/auth.json" +test -s "$dir/grants/projects.json" +install -d -m 700 -o 1000 -g 1000 "$dir/run" + +# The host operator supplies an isolated Codex home and starts a single worker. +# The Docker default bridge supplies egress independently of Foundry's network. +docker run -d --name foundry-codex-worker --restart unless-stopped --init \ + --network bridge --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ + --read-only --pids-limit 128 --memory 384m --tmpfs /tmp:rw,nosuid,nodev \ + --tmpfs /work:ro,nosuid,nodev --workdir /work \ + --mount "type=bind,source=$binary,target=/opt/foundry,readonly" \ + --mount "type=bind,source=$base/codex-cli,target=/opt/codex,readonly" \ + --mount "type=bind,source=$dir/home,target=/home/node/.codex" \ + --mount "type=bind,source=$dir/run,target=/run/foundry-codex" \ + --mount "type=bind,source=/etc/ssl/certs/ca-certificates.crt,target=/etc/ssl/certs/ca-certificates.crt,readonly" \ + node:24-bookworm-slim /opt/foundry codex-worker diff --git a/deploy/release.sh b/deploy/release.sh index 14260ae..fed1fd4 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -44,7 +44,20 @@ docker network inspect "$network" >/dev/null release=$(mktemp -d "$foundry/release.XXXXXXXX") install -m 755 "$1" "$release/foundry" export HOST=0.0.0.0 PORT=8080 PUBLIC_ORIGIN="$origin" -docker run -d --name "$candidate" --network "$network" \ +codex_mounts=() +codex_dir=$foundry/codex +if [[ -d "$codex_dir" ]]; then + test -S "$codex_dir/run/worker.sock" + test -s "$codex_dir/grants/projects.json" + codex_mounts=( + --mount "type=bind,source=$codex_dir/run,target=/run/foundry-codex,readonly" + --mount "type=bind,source=$codex_dir/grants,target=/etc/foundry-codex,readonly" + -e FOUNDRY_CODEX_SOCKET=/run/foundry-codex/worker.sock + -e FOUNDRY_CODEX_GRANTS=/etc/foundry-codex/projects.json + ) +fi + +docker run -d --name "$candidate" "${codex_mounts[@]}" --network "$network" \ --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \ -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS \ --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ diff --git a/internal/codex/http.go b/internal/codex/http.go new file mode 100644 index 0000000..45c93a0 --- /dev/null +++ b/internal/codex/http.go @@ -0,0 +1,143 @@ +package codex + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "os" + "strings" + "time" +) + +const ( + maxRequest = 32 << 10 + maxOutput = 512 << 10 + timeout = 90 * time.Second +) + +type task struct { + Prompt string `json:"prompt"` +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func fail(w http.ResponseWriter, status int, code string) { + writeJSON(w, status, map[string]string{"error": code}) +} + +func readTask(w http.ResponseWriter, r *http.Request, limit int64) (task, bool) { + var input task + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, limit)) + decoder.DisallowUnknownFields() + err := decoder.Decode(&input) + if err == nil { + err = decoder.Decode(new(any)) + if err == io.EOF && strings.TrimSpace(input.Prompt) != "" { + return input, true + } + } + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + fail(w, 413, "request_too_large") + } else { + fail(w, 400, "invalid_task") + } + return task{}, false +} + +// Register enables the public endpoint only when both configuration files are supplied. +// Each credential digest belongs to exactly one project. +func Register(mux *http.ServeMux, projects map[string]string, socket, grantsFile string) error { + if socket == "" && grantsFile == "" { + return nil + } + if socket == "" || grantsFile == "" { + return errors.New("Codex requires both a socket and a grants file") + } + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socket) + }, + DisableKeepAlives: true, + } + client := &http.Client{ + Transport: transport, + Timeout: timeout + 2*time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + mux.HandleFunc("POST /projects/{project}/codex/exec", func(w http.ResponseWriter, r *http.Request) { + scheme, token, _ := strings.Cut(r.Header.Get("Authorization"), " ") + key, err := hex.DecodeString(token) + if !strings.EqualFold(scheme, "Bearer") || err != nil || len(key) != 32 { + w.Header().Set("WWW-Authenticate", "Bearer") + fail(w, 401, "unauthorized") + return + } + data, err := os.ReadFile(grantsFile) + var grants map[string]string + if err != nil || json.Unmarshal(data, &grants) != nil { + fail(w, 503, "codex_unavailable") + return + } + hash := sha256.Sum256(key) + var scope string + for digest, project := range grants { + value, err := hex.DecodeString(digest) + if err == nil && subtle.ConstantTimeCompare(hash[:], value) == 1 { + scope = project + } + } + if scope == "" { + w.Header().Set("WWW-Authenticate", "Bearer") + fail(w, 401, "unauthorized") + return + } + project := r.PathValue("project") + if _, exists := projects[project]; !exists || scope != project { + fail(w, 403, "forbidden") + return + } + input, ok := readTask(w, r, maxRequest) + if !ok { + return + } + body, _ := json.Marshal(input) + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, "http://codex/exec", bytes.NewReader(body)) + if err != nil { + fail(w, 502, "codex_unavailable") + return + } + resp, err := client.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + fail(w, 504, "codex_timeout") + } else { + fail(w, 502, "codex_unavailable") + } + return + } + defer resp.Body.Close() + data, err = io.ReadAll(io.LimitReader(resp.Body, maxOutput+1)) + if err != nil || len(data) > maxOutput || !json.Valid(data) { + fail(w, 502, "invalid_codex_response") + return + } + if retry := resp.Header.Get("Retry-After"); retry != "" { + w.Header().Set("Retry-After", retry) + } + writeJSON(w, resp.StatusCode, json.RawMessage(data)) + }) + return nil +} diff --git a/internal/codex/http_test.go b/internal/codex/http_test.go new file mode 100644 index 0000000..718a06c --- /dev/null +++ b/internal/codex/http_test.go @@ -0,0 +1,69 @@ +package codex + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestProjectAuthorizationAndForwarding(t *testing.T) { + dir := t.TempDir() + socket := filepath.Join(dir, "worker.sock") + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + worker := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/exec" || r.Header.Get("Authorization") != "" { + fail(w, 500, "unexpected_worker_request") + return + } + input, ok := readTask(w, r, 6*maxRequest) + if ok { + writeJSON(w, 200, map[string]string{"result": input.Prompt}) + } + })} + go worker.Serve(listener) + t.Cleanup(func() { _ = worker.Close() }) + + key := make([]byte, 32) + token := hex.EncodeToString(key) + grants := filepath.Join(dir, "grants.json") + writeGrants := func(data string) { + t.Helper() + if err := os.WriteFile(grants, []byte(data), 0600); err != nil { + t.Fatal(err) + } + } + writeGrants(fmt.Sprintf(`{"%x":"notes"}`, sha256.Sum256(key))) + mux := http.NewServeMux() + if err := Register(mux, map[string]string{"notes": "", "board": ""}, socket, grants); err != nil { + t.Fatal(err) + } + check := func(project, credential, body string, status int, response string) { + t.Helper() + r := httptest.NewRequest("POST", "/projects/"+project+"/codex/exec", strings.NewReader(body)) + if credential != "" { + r.Header.Set("Authorization", "Bearer "+credential) + } + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + if w.Code != status || !strings.Contains(w.Body.String(), response) { + t.Fatalf("got %d %s, want %d containing %q", w.Code, w.Body.String(), status, response) + } + } + check("notes", "", `{"prompt":"hello"}`, 401, "unauthorized") + check("board", token, `{"prompt":"hello"}`, 403, "forbidden") + check("notes", token, `{"prompt":"hello","sandbox":"danger"}`, 400, "invalid_task") + check("notes", token, `{"prompt":"`+strings.Repeat("x", maxRequest)+`"}`, 413, "request_too_large") + check("notes", token, `{"prompt":"hello"}`, 200, `"result":"hello"`) + writeGrants("{}") + check("notes", token, `{"prompt":"hello"}`, 401, "unauthorized") +} diff --git a/internal/codex/worker.go b/internal/codex/worker.go new file mode 100644 index 0000000..116f1dd --- /dev/null +++ b/internal/codex/worker.go @@ -0,0 +1,134 @@ +package codex + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +const executable = "/opt/codex/node_modules/.bin/codex" + +var errOutputLimit = errors.New("Codex output limit exceeded") + +type outputBuffer struct { + bytes.Buffer + exceeded bool +} + +func (b *outputBuffer) Write(p []byte) (int, error) { + if len(p) > maxOutput-b.Len() { + b.exceeded = true + return 0, errOutputLimit + } + return b.Buffer.Write(p) +} + +func ServeWorker(socket string) error { + syscall.Umask(0077) + if err := os.Remove(socket); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + listener, err := net.Listen("unix", socket) + if err != nil { + return err + } + defer listener.Close() + if err := os.Chmod(socket, 0600); err != nil { + return err + } + slot := make(chan struct{}, 1) + mux := http.NewServeMux() + mux.HandleFunc("POST /exec", func(w http.ResponseWriter, r *http.Request) { + select { + case slot <- struct{}{}: + defer func() { <-slot }() + default: + w.Header().Set("Retry-After", "5") + fail(w, 429, "codex_busy") + return + } + input, ok := readTask(w, r, 6*maxRequest) + if ok { + execute(w, r, input.Prompt) + } + }) + server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: timeout + 15*time.Second} + return server.Serve(listener) +} + +func execute(w http.ResponseWriter, r *http.Request, prompt string) { + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, executable, "--ask-for-approval", "never", + "--disable", "shell_tool", "--disable", "apps", "--disable", "plugins", + "--disable", "multi_agent", "--disable", "hooks", "--disable", "memories", + "-c", `web_search="disabled"`, "exec", "--json", "--sandbox", "read-only", "--skip-git-repo-check", "--ephemeral", "-") + cmd.Dir = "/work" + cmd.Env = []string{"PATH=/usr/local/bin:/usr/bin:/bin", "HOME=/home/node", "CODEX_HOME=/home/node/.codex", "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"} + cmd.Stdin = strings.NewReader(prompt) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } + return err + } + cmd.WaitDelay = 2 * time.Second + var output outputBuffer + cmd.Stdout, cmd.Stderr = &output, io.Discard + err := cmd.Run() + switch { + case output.exceeded: + fail(w, 502, "codex_output_too_large") + case errors.Is(ctx.Err(), context.DeadlineExceeded): + fail(w, 504, "codex_timeout") + case err != nil: + fail(w, 502, "codex_failed") + default: + var result string + var completed bool + for _, line := range bytes.Split(bytes.TrimSpace(output.Bytes()), []byte("\n")) { + var event struct { + Type string `json:"type"` + Item struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"item"` + } + if json.Unmarshal(line, &event) != nil { + fail(w, 502, "invalid_codex_response") + return + } + if event.Type == "item.completed" && event.Item.Type == "agent_message" { + result = event.Item.Text + } + if event.Type == "turn.failed" || event.Type == "error" { + fail(w, 502, "codex_failed") + return + } + if event.Type == "turn.completed" { + completed = true + } + } + if !completed || result == "" { + fail(w, 502, "codex_failed") + return + } + response, _ := json.Marshal(map[string]string{"result": result}) + if len(response) >= maxOutput { + fail(w, 502, "codex_output_too_large") + return + } + writeJSON(w, 200, json.RawMessage(response)) + } +} -- 2.52.0 From d80854deae4ef36ca15cce11ceaf03fc32786ce3 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 11:36:46 +0800 Subject: [PATCH 05/25] Start the configured Codex worker during Foundry deployment --- README.md | 9 +++++---- deploy/release.sh | 11 +++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 017cc29..f797b83 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,11 @@ On the deployment host, provision `/opt/endoleap-infra/foundry/codex` with: 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. -Start the single worker with `bash deploy/codex-worker.sh /absolute/path/to/foundry` -using the Linux binary built by ces-control. To replace the worker, stop and remove -the existing `foundry-codex-worker` container first. The next normal Foundry -deployment mounts the worker socket and grants directory read-only. The worker +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 diff --git a/deploy/release.sh b/deploy/release.sh index fed1fd4..202127e 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -47,6 +47,13 @@ export HOST=0.0.0.0 PORT=8080 PUBLIC_ORIGIN="$origin" codex_mounts=() codex_dir=$foundry/codex if [[ -d "$codex_dir" ]]; then + if ! docker inspect -f '{{.State.Running}}' foundry-codex-worker 2>/dev/null | grep -qx true; then + bash "$(dirname "$0")/codex-worker.sh" "$release/foundry" >/dev/null + fi + for attempt in {1..20}; do + [[ ! -S "$codex_dir/run/worker.sock" ]] || break + sleep 0.25 + done test -S "$codex_dir/run/worker.sock" test -s "$codex_dir/grants/projects.json" codex_mounts=( @@ -87,6 +94,10 @@ docker exec "$candidate" node -e ' const project = Object.keys(JSON.parse(process.env.FOUNDRY_PROJECTS))[0]; const route = base + "/projects/" + encodeURIComponent(project) + "/auth"; assert.equal((await fetch(route + "/me")).status, 401); + if (process.env.FOUNDRY_CODEX_SOCKET) { + const exec = base + "/projects/" + encodeURIComponent(project) + "/codex/exec"; + assert.equal((await fetch(exec, {method: "POST"})).status, 401); + } } verify().catch(() => { console.error("Foundry readiness or anonymous access check failed"); process.exit(1); }); ' -- 2.52.0 From 81c2d2e6dfdea8323708eb9c94adb029d6ab4546 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 11:36:54 +0800 Subject: [PATCH 06/25] Enforce the output limit on subprocess stdout copies --- internal/codex/worker.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/codex/worker.go b/internal/codex/worker.go index 116f1dd..f4065d3 100644 --- a/internal/codex/worker.go +++ b/internal/codex/worker.go @@ -20,16 +20,16 @@ const executable = "/opt/codex/node_modules/.bin/codex" var errOutputLimit = errors.New("Codex output limit exceeded") type outputBuffer struct { - bytes.Buffer + buffer bytes.Buffer exceeded bool } func (b *outputBuffer) Write(p []byte) (int, error) { - if len(p) > maxOutput-b.Len() { + if len(p) > maxOutput-b.buffer.Len() { b.exceeded = true return 0, errOutputLimit } - return b.Buffer.Write(p) + return b.buffer.Write(p) } func ServeWorker(socket string) error { @@ -97,7 +97,7 @@ func execute(w http.ResponseWriter, r *http.Request, prompt string) { default: var result string var completed bool - for _, line := range bytes.Split(bytes.TrimSpace(output.Bytes()), []byte("\n")) { + for _, line := range bytes.Split(bytes.TrimSpace(output.buffer.Bytes()), []byte("\n")) { var event struct { Type string `json:"type"` Item struct { -- 2.52.0 From 08aa0d69b692331c2146ccb0f87b4e7ffafe1231 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 11:40:09 +0800 Subject: [PATCH 07/25] Set worker directory ownership by numeric container UID --- deploy/codex-worker.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deploy/codex-worker.sh b/deploy/codex-worker.sh index 8e39da9..2747092 100644 --- a/deploy/codex-worker.sh +++ b/deploy/codex-worker.sh @@ -7,7 +7,8 @@ binary=$(realpath "$1") test -x "$binary" test -s "$dir/home/auth.json" test -s "$dir/grants/projects.json" -install -d -m 700 -o 1000 -g 1000 "$dir/run" +install -d -m 700 "$dir/run" +chown 1000:1000 "$dir/run" # The host operator supplies an isolated Codex home and starts a single worker. # The Docker default bridge supplies egress independently of Foundry's network. -- 2.52.0 From 0fd84f0f6e13f81efc77943043b1b434eaa9443b Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:14:44 +0800 Subject: [PATCH 08/25] Establish project-scoped account and collaboration schema --- .gitea/workflows/check.yml | 2 + .gitignore | 1 + db/002-platform.sql | 223 +++++++++++++++++++++++++++++++++++++ deploy/check-schema.sh | 58 ++++++++++ 4 files changed, 284 insertions(+) create mode 100644 db/002-platform.sql create mode 100644 deploy/check-schema.sh diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index f278f2a..588e2e8 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -17,6 +17,8 @@ jobs: test -z "$(gofmt -l cmd internal)" go test -mod=readonly ./... go build -mod=readonly -o bin/foundry ./cmd/foundry + - name: Verify schema migration in an isolated database + run: bash deploy/check-schema.sh - name: Smoke test HTTP routes env: PUBLIC_ORIGIN: https://api.example.com diff --git a/.gitignore b/.gitignore index 579c657..db3643f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .env /bin/ +/api-docs/ diff --git a/db/002-platform.sql b/db/002-platform.sql new file mode 100644 index 0000000..97bf9e4 --- /dev/null +++ b/db/002-platform.sql @@ -0,0 +1,223 @@ +BEGIN; + +-- Keep existing Google accounts and sessions valid during the expansion. +ALTER TABLE project_accounts ALTER COLUMN google_subject DROP NOT NULL; +ALTER TABLE project_accounts + ADD COLUMN username text, + ADD COLUMN display_name text NOT NULL DEFAULT '', + ADD COLUMN avatar_url text, + ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now(); +CREATE UNIQUE INDEX project_account_username ON project_accounts(project, lower(username)) + WHERE username IS NOT NULL; + +CREATE TABLE account_identities ( + project text NOT NULL, + provider text NOT NULL CHECK (provider IN ('google', 'slack')), + subject text NOT NULL, + account_id uuid NOT NULL, + PRIMARY KEY (project, provider, subject), + UNIQUE (project, provider, account_id), + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +INSERT INTO account_identities(project, provider, subject, account_id) +SELECT project, 'google', google_subject, id FROM project_accounts WHERE google_subject IS NOT NULL; + +CREATE TABLE email_credentials ( + project text NOT NULL, + email text NOT NULL CHECK (email = lower(email)), + account_id uuid NOT NULL, + password_hash text NOT NULL, + verified_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, email), + UNIQUE (project, account_id), + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +-- Registration is promoted to an account only after the single-use email check. +CREATE TABLE email_registrations ( + project text NOT NULL, + email text NOT NULL, + password_hash text NOT NULL, + token_hash text NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, email) +); +CREATE TABLE email_outbox ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + project text NOT NULL, + recipient text NOT NULL, + subject text NOT NULL, + body text NOT NULL, + attempts integer NOT NULL DEFAULT 0, + available_at timestamptz NOT NULL DEFAULT now(), + lease_until timestamptz, + sent_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX email_outbox_pending ON email_outbox(available_at) WHERE sent_at IS NULL; +CREATE TABLE auth_rate_limits ( + key_hash text PRIMARY KEY, + attempts integer NOT NULL CHECK (attempts > 0), + expires_at timestamptz NOT NULL +); + +CREATE TABLE onboarding_configs ( + project text PRIMARY KEY, + require_workspace boolean NOT NULL DEFAULT false, + questions jsonb NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(questions) = 'array'), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE onboarding_responses ( + project text NOT NULL, + account_id uuid NOT NULL, + answers jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(answers) = 'object'), + completed_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, account_id), + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); + +CREATE TABLE workspaces ( + project text NOT NULL, + id uuid NOT NULL DEFAULT gen_random_uuid(), + name text NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 160), + owner_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, id), + FOREIGN KEY (project, owner_id) REFERENCES project_accounts +); +CREATE TABLE workspace_members ( + project text NOT NULL, + workspace_id uuid NOT NULL, + account_id uuid NOT NULL, + role text NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')), + joined_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, workspace_id, account_id), + FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE, + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +CREATE INDEX workspace_members_account ON workspace_members(project, account_id); + +CREATE TABLE materials ( + project text NOT NULL, + workspace_id uuid NOT NULL, + id uuid NOT NULL DEFAULT gen_random_uuid(), + kind text NOT NULL CHECK (kind IN ('text', 'article', 'image', 'board', 'card')), + title text NOT NULL CHECK (length(title) <= 300), + description text NOT NULL DEFAULT '' CHECK (length(description) <= 10000), + content jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(content) = 'object'), + version bigint NOT NULL DEFAULT 1 CHECK (version > 0), + created_by uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + modified_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, workspace_id, id), + FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE, + FOREIGN KEY (project, created_by) REFERENCES project_accounts +); +CREATE INDEX material_modified ON materials(project, workspace_id, modified_at DESC, id); +CREATE INDEX material_created ON materials(project, workspace_id, created_at DESC, id); +CREATE INDEX material_alphabetical ON materials(project, workspace_id, lower(title), id); +CREATE TABLE material_opens ( + project text NOT NULL, + workspace_id uuid NOT NULL, + material_id uuid NOT NULL, + account_id uuid NOT NULL, + opened_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, workspace_id, material_id, account_id), + FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +CREATE INDEX material_opens_account ON material_opens(project, workspace_id, account_id, opened_at DESC); + +CREATE TABLE tags ( + project text NOT NULL, + workspace_id uuid NOT NULL, + id uuid NOT NULL DEFAULT gen_random_uuid(), + name text NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 80), + color text NOT NULL DEFAULT '', + PRIMARY KEY (project, workspace_id, id), + FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE +); +CREATE UNIQUE INDEX tag_name ON tags(project, workspace_id, lower(name)); +CREATE TABLE material_tags ( + project text NOT NULL, + workspace_id uuid NOT NULL, + material_id uuid NOT NULL, + tag_id uuid NOT NULL, + PRIMARY KEY (project, workspace_id, material_id, tag_id), + FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY (project, workspace_id, tag_id) REFERENCES tags ON DELETE CASCADE +); +CREATE INDEX material_tags_filter ON material_tags(project, workspace_id, tag_id, material_id); + +-- A board placement references a reusable material, independently of its content. +CREATE TABLE board_layouts ( + project text NOT NULL, + workspace_id uuid NOT NULL, + board_id uuid NOT NULL, + id uuid NOT NULL DEFAULT gen_random_uuid(), + material_id uuid NOT NULL, + x double precision NOT NULL CHECK (x BETWEEN -1000000 AND 1000000), + y double precision NOT NULL CHECK (y BETWEEN -1000000 AND 1000000), + width double precision NOT NULL CHECK (width BETWEEN 1 AND 100000), + height double precision NOT NULL CHECK (height BETWEEN 1 AND 100000), + z_index integer NOT NULL DEFAULT 0, + version bigint NOT NULL DEFAULT 1 CHECK (version > 0), + PRIMARY KEY (project, workspace_id, board_id, id), + FOREIGN KEY (project, workspace_id, board_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE, + CHECK (board_id <> material_id) +); +CREATE TABLE material_grants ( + project text NOT NULL, + workspace_id uuid NOT NULL, + material_id uuid NOT NULL, + account_id uuid NOT NULL, + role text NOT NULL CHECK (role IN ('editor', 'viewer')), + PRIMARY KEY (project, workspace_id, material_id, account_id), + FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +CREATE TABLE share_links ( + project text NOT NULL, + workspace_id uuid NOT NULL, + id uuid NOT NULL DEFAULT gen_random_uuid(), + material_id uuid, + token_hash text NOT NULL UNIQUE, + role text NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')), + created_by uuid NOT NULL, + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + max_uses integer CHECK (max_uses BETWEEN 1 AND 100000), + use_count integer NOT NULL DEFAULT 0 CHECK (use_count >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, workspace_id, id), + FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE, + FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY (project, created_by) REFERENCES project_accounts, + CHECK (material_id IS NULL OR role <> 'admin') +); +CREATE TABLE share_redemptions ( + project text NOT NULL, + workspace_id uuid NOT NULL, + link_id uuid NOT NULL, + account_id uuid NOT NULL, + redeemed_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, workspace_id, link_id, account_id), + FOREIGN KEY (project, workspace_id, link_id) REFERENCES share_links ON DELETE CASCADE, + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +CREATE TABLE link_clicks ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project text NOT NULL, + workspace_id uuid NOT NULL, + link_id uuid NOT NULL, + clicked_at timestamptz NOT NULL DEFAULT now(), + -- Store the referring origin only, without URL paths, queries or user tokens. + referrer_origin text NOT NULL DEFAULT '', + FOREIGN KEY (project, workspace_id, link_id) REFERENCES share_links ON DELETE CASCADE +); +CREATE INDEX link_clicks_summary ON link_clicks(project, workspace_id, link_id, clicked_at); + +COMMIT; diff --git a/deploy/check-schema.sh b/deploy/check-schema.sh new file mode 100644 index 0000000..09f1aff --- /dev/null +++ b/deploy/check-schema.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +container=foundry-schema-$(openssl rand -hex 8) +cleanup() { docker rm -f "$container" >/dev/null 2>&1 || true; } +trap cleanup EXIT +# No host port or shared network. The database is discarded after this check. +docker run -d --name "$container" --network none \ + -e POSTGRES_HOST_AUTH_METHOD=trust postgres:17-alpine >/dev/null +for attempt in {1..30}; do + if docker exec "$container" pg_isready -U postgres >/dev/null 2>&1; then break; fi + sleep 1 +done +psql() { docker exec -i "$container" psql -X -U postgres -v ON_ERROR_STOP=1; } +psql < db/001-auth.sql +psql <<'SQL' +INSERT INTO project_accounts(project,id,google_subject,email) +VALUES ('fixture','00000000-0000-0000-0000-000000000001','existing-google','existing@example.invalid'); +INSERT INTO project_sessions VALUES +('existing-session','fixture','00000000-0000-0000-0000-000000000001',now()+interval '1 hour'); +SQL +psql < db/002-platform.sql +psql <<'SQL' +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM project_sessions s JOIN project_accounts a + ON (s.project,s.account_id)=(a.project,a.id) + JOIN account_identities i ON (i.project,i.account_id)=(a.project,a.id) + WHERE s.token_hash='existing-session' AND i.provider='google' AND i.subject='existing-google' + ) THEN RAISE EXCEPTION 'Existing Google login was lost'; END IF; +END $$; +BEGIN; +INSERT INTO project_accounts(project,id,email) VALUES +('fixture','00000000-0000-0000-0000-000000000002','email@example.invalid'); +INSERT INTO email_credentials(project,email,account_id,password_hash) VALUES +('fixture','email@example.invalid','00000000-0000-0000-0000-000000000002','fixture-hash'); +INSERT INTO workspaces(project,id,name,owner_id) VALUES +('fixture','00000000-0000-0000-0000-000000000010','Fixture','00000000-0000-0000-0000-000000000001'); +INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES +('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000001','admin'); +INSERT INTO materials(project,workspace_id,id,kind,title,created_by) VALUES +('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000020','article','Article','00000000-0000-0000-0000-000000000001'), +('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000021','board','Board','00000000-0000-0000-0000-000000000001'); +INSERT INTO board_layouts(project,workspace_id,board_id,material_id,x,y,width,height) VALUES +('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000021','00000000-0000-0000-0000-000000000020',0,0,320,200); +DO $$ +BEGIN + BEGIN + INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES + ('other','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000001','admin'); + RAISE EXCEPTION 'Cross-project membership unexpectedly accepted'; + EXCEPTION WHEN foreign_key_violation THEN NULL; + END; +END $$; +ROLLBACK; +SQL +printf 'Migration preserved existing logins and enforced project isolation.\n' -- 2.52.0 From e626a2359ee5310fe6350a70f7ff7cb414bab1ec Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:17:44 +0800 Subject: [PATCH 09/25] Add authenticated profiles and prefill Google account names --- cmd/foundry/main.go | 2 + internal/auth/auth.go | 27 ++++++-- internal/auth/session.go | 27 ++++++++ internal/platform/http.go | 142 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 internal/auth/session.go create mode 100644 internal/platform/http.go diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go index 0eac158..abf4427 100644 --- a/cmd/foundry/main.go +++ b/cmd/foundry/main.go @@ -11,6 +11,7 @@ import ( "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" "gitea.entroia.com/Zayden-Jung/foundry-core/internal/codex" + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/platform" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/stdlib" ) @@ -53,6 +54,7 @@ func main() { if err := codex.Register(mux, callbacks, os.Getenv("FOUNDRY_CODEX_SOCKET"), os.Getenv("FOUNDRY_CODEX_GRANTS")); err != nil { log.Fatal(err) } + platform.Register(mux, db, callbacks) mux.Handle("/", handler) server := &http.Server{ Addr: net.JoinHostPort(env("HOST", "127.0.0.1"), env("PORT", "8080")), diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e85fd71..a1ef605 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -153,7 +153,7 @@ func (s *service) start(w http.ResponseWriter, r *http.Request, project string) } query := url.Values{ "client_id": {s.clientID}, "redirect_uri": {s.redirectURI}, "response_type": {"code"}, - "scope": {"openid email"}, "state": {state}, "code_challenge": {digest(verifier)}, + "scope": {"openid email profile"}, "state": {state}, "code_challenge": {digest(verifier)}, "code_challenge_method": {"S256"}, "prompt": {"select_account"}, } http.SetCookie(w, oauthCookie(state, browser, 600)) @@ -240,6 +240,8 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error { Subject string `json:"sub"` Email *string `json:"email"` Verified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` } if err := s.googleJSON(ctx, "GET", "https://openidconnect.googleapis.com/v1/userinfo", nil, tokens.AccessToken, &user); err != nil { return err @@ -253,14 +255,29 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error { grant := token() _, err = s.db.ExecContext(ctx, ` - WITH account AS ( - INSERT INTO project_accounts (project, id, google_subject, email) VALUES ($1, gen_random_uuid(), $2, $3) - ON CONFLICT (project, google_subject) DO UPDATE SET email = EXCLUDED.email + WITH identity AS ( + SELECT gen_random_uuid() AS id + ), account AS ( + INSERT INTO project_accounts (project, id, google_subject, email, username, display_name, avatar_url) + SELECT $1, id, $2, $3, + left(COALESCE(NULLIF(regexp_replace(lower($6), '[^a-z0-9]', '', 'g'), ''), 'user'),7) + || '_' || replace(id::text,'-',''), + $6, NULLIF($7,'') FROM identity + ON CONFLICT (project, google_subject) DO UPDATE SET + email = EXCLUDED.email, + username = COALESCE(project_accounts.username, EXCLUDED.username), + display_name = CASE WHEN project_accounts.display_name='' THEN EXCLUDED.display_name ELSE project_accounts.display_name END, + avatar_url = COALESCE(project_accounts.avatar_url, EXCLUDED.avatar_url), + updated_at = now() RETURNING id + ), provider AS ( + INSERT INTO account_identities(project,provider,subject,account_id) + SELECT $1,'google',$2,id FROM account + ON CONFLICT (project,provider,subject) DO NOTHING ) INSERT INTO login_grants SELECT $4, $1, id, $5, now() + interval '1 minute' FROM account - `, project, user.Subject, user.Email, digest(grant), challenge) + `, project, user.Subject, user.Email, digest(grant), challenge, user.Name, user.Picture) if err != nil { return err } diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..da62d70 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,27 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "strings" +) + +var ErrInvalidSession = errors.New("invalid session") + +// Authenticate resolves only an unexpired session belonging to this project. +func Authenticate(ctx context.Context, db *sql.DB, project, authorization string) (string, error) { + value, ok := strings.CutPrefix(authorization, "Bearer ") + if !ok || !tokenPattern.MatchString(value) { + return "", ErrInvalidSession + } + var id string + err := db.QueryRowContext(ctx, ` +SELECT account_id::text FROM project_sessions +WHERE project=$1 AND token_hash=$2 AND expires_at>now() +`, project, digest(value)).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrInvalidSession + } + return id, err +} diff --git a/internal/platform/http.go b/internal/platform/http.go new file mode 100644 index 0000000..3269da6 --- /dev/null +++ b/internal/platform/http.go @@ -0,0 +1,142 @@ +package platform + +import ( + "database/sql" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/url" + "regexp" + "strings" + + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" + "github.com/jackc/pgx/v5/pgconn" +) + +type service struct { + db *sql.DB +} + +type response map[string]any + +func reply(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + if value != nil { + _ = json.NewEncoder(w).Encode(value) + } +} + +func reject(w http.ResponseWriter, status int, code string) { + reply(w, status, response{"error": code}) +} + +func decode(w http.ResponseWriter, r *http.Request, value any) bool { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + reject(w, 400, "invalid_request") + return false + } + if decoder.Decode(new(any)) != io.EOF { + reject(w, 400, "invalid_request") + return false + } + return true +} + +type endpoint func(http.ResponseWriter, *http.Request, string, string) error + +func (s *service) protected(callbacks map[string]string, handle endpoint) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + project := r.PathValue("project") + callback, exists := callbacks[project] + if !exists { + reject(w, 404, "not_found") + return + } + frontend, _ := url.Parse(callback) + w.Header().Set("Vary", "Origin") + if origin := r.Header.Get("Origin"); origin != "" { + if origin != frontend.Scheme+"://"+frontend.Host { + reject(w, 403, "origin_denied") + return + } + w.Header().Set("Access-Control-Allow-Origin", origin) + } + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match") + reply(w, 204, nil) + return + } + account, err := auth.Authenticate(r.Context(), s.db, project, r.Header.Get("Authorization")) + if errors.Is(err, auth.ErrInvalidSession) { + reject(w, 401, "invalid_session") + return + } + if err == nil { + err = handle(w, r, project, account) + } + if err != nil { + var pg *pgconn.PgError + switch { + case errors.Is(err, sql.ErrNoRows): + reject(w, 404, "not_found") + case errors.As(err, &pg) && pg.Code == "23505": + reject(w, 409, "already_exists") + default: + log.Printf("platform request failed (%T)", err) + reject(w, 503, "service_unavailable") + } + } + } +} + +func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string) { + s := &service{db: db} + mux.HandleFunc("/projects/{project}/profile", s.protected(callbacks, s.profile)) +} + +var usernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{2,39}$`) + +func (s *service) profile(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method == http.MethodPatch { + var input struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + } + if !decode(w, r, &input) { + return nil + } + input.Username = strings.ToLower(strings.TrimSpace(input.Username)) + input.DisplayName = strings.TrimSpace(input.DisplayName) + if !usernamePattern.MatchString(input.Username) || len(input.DisplayName) > 160 { + reject(w, 400, "invalid_profile") + return nil + } + _, err := s.db.ExecContext(r.Context(), ` +UPDATE project_accounts SET username=$3, display_name=$4, updated_at=now() +WHERE project=$1 AND id=$2 +`, project, account, input.Username, input.DisplayName) + if err != nil { + return err + } + } else if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + var username, email, avatar *string + var name string + err := s.db.QueryRowContext(r.Context(), ` +SELECT username, display_name, email, avatar_url FROM project_accounts WHERE project=$1 AND id=$2 +`, project, account).Scan(&username, &name, &email, &avatar) + if err != nil { + return err + } + reply(w, 200, response{"id": account, "project": project, "username": username, "display_name": name, "email": email, "avatar_url": avatar}) + return nil +} -- 2.52.0 From 53c4dbee8ed838f1fe710e83a320989cdb43cc0b Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:19:19 +0800 Subject: [PATCH 10/25] Add workspace membership roles with serialized permission changes --- internal/platform/http.go | 6 + internal/platform/workspaces.go | 239 ++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 internal/platform/workspaces.go diff --git a/internal/platform/http.go b/internal/platform/http.go index 3269da6..8a468be 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -86,6 +86,8 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H switch { case errors.Is(err, sql.ErrNoRows): reject(w, 404, "not_found") + case errors.As(err, &pg) && (pg.Code == "22P02" || pg.Code == "23503" || pg.Code == "23514"): + reject(w, 400, "invalid_reference") case errors.As(err, &pg) && pg.Code == "23505": reject(w, 409, "already_exists") default: @@ -98,6 +100,10 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string) { s := &service{db: db} + mux.HandleFunc("/projects/{project}/workspaces", s.protected(callbacks, s.workspaces)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}", s.protected(callbacks, s.workspace)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/members", s.protected(callbacks, s.members)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/members/{account}", s.protected(callbacks, s.member)) mux.HandleFunc("/projects/{project}/profile", s.protected(callbacks, s.profile)) } diff --git a/internal/platform/workspaces.go b/internal/platform/workspaces.go new file mode 100644 index 0000000..b428f92 --- /dev/null +++ b/internal/platform/workspaces.go @@ -0,0 +1,239 @@ +package platform + +import ( + "context" + "database/sql" + "net/http" + "strings" +) + +func (s *service) workspaces(w http.ResponseWriter, r *http.Request, project, account string) error { + switch r.Method { + case http.MethodGet: + rows, err := s.db.QueryContext(r.Context(), ` +SELECT w.id::text,w.name,m.role,w.owner_id::text FROM workspaces w +JOIN workspace_members m ON (m.project,m.workspace_id)=(w.project,w.id) +WHERE m.project=$1 AND m.account_id=$2 ORDER BY lower(w.name),w.id LIMIT 200 +`, project, account) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, name, role, owner string + if err := rows.Scan(&id, &name, &role, &owner); err != nil { + return err + } + items = append(items, response{"id": id, "name": name, "role": role, "owner_id": owner}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + case http.MethodPost: + var input struct { + Name string `json:"name"` + } + if !decode(w, r, &input) { + return nil + } + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 160 { + reject(w, 400, "invalid_workspace") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + id, err := createWorkspace(r.Context(), tx, project, account, input.Name) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, response{"id": id, "name": input.Name, "role": "admin", "owner_id": account}) + default: + reject(w, 405, "method_not_allowed") + } + return nil +} + +func createWorkspace(ctx context.Context, tx *sql.Tx, project, account, name string) (string, error) { + var id string + err := tx.QueryRowContext(ctx, ` +INSERT INTO workspaces(project,name,owner_id) VALUES($1,$2,$3) RETURNING id::text +`, project, name, account).Scan(&id) + if err != nil { + return "", err + } + _, err = tx.ExecContext(ctx, ` +INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,'admin') +`, project, id, account) + return id, err +} + +func lockWorkspace(ctx context.Context, tx *sql.Tx, project, workspace, account string) (string, string, error) { + var owner, role string + err := tx.QueryRowContext(ctx, ` +SELECT owner_id::text FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE +`, project, workspace).Scan(&owner) + if err != nil { + return "", "", err + } + // Read membership after the workspace lock, so concurrent revocation is visible. + err = tx.QueryRowContext(ctx, ` +SELECT role FROM workspace_members WHERE project=$1 AND workspace_id=$2 AND account_id=$3 +`, project, workspace, account).Scan(&role) + return owner, role, err +} + +func (s *service) workspace(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace := r.PathValue("workspace") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + owner, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + switch r.Method { + case http.MethodGet: + var name string + if err := tx.QueryRowContext(r.Context(), "SELECT name FROM workspaces WHERE project=$1 AND id=$2", project, workspace).Scan(&name); err != nil { + return err + } + reply(w, 200, response{"id": workspace, "name": name, "role": role, "owner_id": owner}) + return nil + case http.MethodPatch: + if role != "admin" { + reject(w, 403, "forbidden") + return nil + } + var input struct { + Name string `json:"name"` + } + if !decode(w, r, &input) { + return nil + } + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 160 { + reject(w, 400, "invalid_workspace") + return nil + } + _, err = tx.ExecContext(r.Context(), "UPDATE workspaces SET name=$3,updated_at=now() WHERE project=$1 AND id=$2", project, workspace, input.Name) + case http.MethodDelete: + if owner != account { + reject(w, 403, "owner_required") + return nil + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM workspaces WHERE project=$1 AND id=$2", project, workspace) + default: + reject(w, 405, "method_not_allowed") + return nil + } + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} + +func (s *service) members(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + rows, err := s.db.QueryContext(r.Context(), ` +SELECT m.account_id::text,m.role,a.username,a.display_name FROM workspace_members m +JOIN project_accounts a ON (a.project,a.id)=(m.project,m.account_id) +WHERE m.project=$1 AND m.workspace_id=$2 AND EXISTS( + SELECT 1 FROM workspace_members caller WHERE caller.project=$1 AND caller.workspace_id=$2 AND caller.account_id=$3) +ORDER BY m.joined_at,m.account_id LIMIT 200 +`, project, r.PathValue("workspace"), account) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, role, name string + var username *string + if err := rows.Scan(&id, &role, &username, &name); err != nil { + return err + } + items = append(items, response{"account_id": id, "role": role, "username": username, "display_name": name}) + } + if err := rows.Err(); err != nil { + return err + } + if len(items) == 0 { + return sql.ErrNoRows + } + reply(w, 200, response{"items": items}) + return nil +} + +func (s *service) member(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace, target := r.PathValue("workspace"), r.PathValue("account") + if r.Method != http.MethodPatch && r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + owner, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if owner == target { + reject(w, 409, "owner_membership_required") + return nil + } + if role != "admin" && (r.Method != http.MethodDelete || target != account) { + reject(w, 403, "forbidden") + return nil + } + var result sql.Result + if r.Method == http.MethodPatch { + var input struct { + Role string `json:"role"` + } + if !decode(w, r, &input) { + return nil + } + if input.Role != "admin" && input.Role != "editor" && input.Role != "viewer" { + reject(w, 400, "invalid_role") + return nil + } + result, err = tx.ExecContext(r.Context(), "UPDATE workspace_members SET role=$4 WHERE project=$1 AND workspace_id=$2 AND account_id=$3", project, workspace, target, input.Role) + } else { + result, err = tx.ExecContext(r.Context(), "DELETE FROM workspace_members WHERE project=$1 AND workspace_id=$2 AND account_id=$3", project, workspace, target) + } + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} -- 2.52.0 From 1b756d36ceaa4b78897921222f13c0fe8eeae09e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:24:30 +0800 Subject: [PATCH 11/25] Add configurable onboarding and scoped invitation links --- .gitea/workflows/deploy.yml | 1 + cmd/foundry/main.go | 6 +- db/002-platform.sql | 7 +- deploy/check-schema.sh | 6 + deploy/release.sh | 2 +- internal/platform/http.go | 14 +- internal/platform/integration_test.go | 120 +++++++++++ internal/platform/onboarding.go | 192 +++++++++++++++++ internal/platform/shares.go | 287 ++++++++++++++++++++++++++ 9 files changed, 624 insertions(+), 11 deletions(-) create mode 100644 internal/platform/integration_test.go create mode 100644 internal/platform/onboarding.go create mode 100644 internal/platform/shares.go diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 0839aca..564745f 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -36,4 +36,5 @@ jobs: GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} FOUNDRY_PROJECTS: ${{ vars.FOUNDRY_PROJECTS }} + FOUNDRY_ONBOARDING: ${{ vars.FOUNDRY_ONBOARDING }} run: bash deploy/release.sh bin/foundry diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go index abf4427..0d3ce4c 100644 --- a/cmd/foundry/main.go +++ b/cmd/foundry/main.go @@ -54,7 +54,11 @@ func main() { if err := codex.Register(mux, callbacks, os.Getenv("FOUNDRY_CODEX_SOCKET"), os.Getenv("FOUNDRY_CODEX_GRANTS")); err != nil { log.Fatal(err) } - platform.Register(mux, db, callbacks) + onboarding, err := platform.ParseOnboarding(os.Getenv("FOUNDRY_ONBOARDING"), callbacks) + if err != nil { + log.Fatal(err) + } + platform.Register(mux, db, callbacks, origin.Scheme+"://"+origin.Host, onboarding) mux.Handle("/", handler) server := &http.Server{ Addr: net.JoinHostPort(env("HOST", "127.0.0.1"), env("PORT", "8080")), diff --git a/db/002-platform.sql b/db/002-platform.sql index 97bf9e4..dc8af41 100644 --- a/db/002-platform.sql +++ b/db/002-platform.sql @@ -62,16 +62,11 @@ CREATE TABLE auth_rate_limits ( expires_at timestamptz NOT NULL ); -CREATE TABLE onboarding_configs ( - project text PRIMARY KEY, - require_workspace boolean NOT NULL DEFAULT false, - questions jsonb NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(questions) = 'array'), - updated_at timestamptz NOT NULL DEFAULT now() -); CREATE TABLE onboarding_responses ( project text NOT NULL, account_id uuid NOT NULL, answers jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(answers) = 'object'), + result jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(result) = 'object'), completed_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (project, account_id), FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE diff --git a/deploy/check-schema.sh b/deploy/check-schema.sh index 09f1aff..b97a56c 100644 --- a/deploy/check-schema.sh +++ b/deploy/check-schema.sh @@ -56,3 +56,9 @@ END $$; ROLLBACK; SQL printf 'Migration preserved existing logins and enforced project isolation.\n' +CGO_ENABLED=0 go test -mod=readonly -c -o bin/platform-tests ./internal/platform +docker run --rm --network "container:$container" --user 1000:1000 \ + --read-only --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=bind,source=$(pwd)/bin/platform-tests,target=/opt/platform-tests,readonly" \ + -e PLATFORM_TEST_DATABASE_URL=postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable \ + --entrypoint /opt/platform-tests postgres:17-alpine -test.v diff --git a/deploy/release.sh b/deploy/release.sh index 202127e..eddcf71 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -66,7 +66,7 @@ fi docker run -d --name "$candidate" "${codex_mounts[@]}" --network "$network" \ --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \ - -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS \ + -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS -e FOUNDRY_ONBOARDING \ --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ --read-only --memory 128m \ --mount "type=bind,source=$release/foundry,target=/opt/foundry,readonly" \ diff --git a/internal/platform/http.go b/internal/platform/http.go index 8a468be..58d24d2 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -16,7 +16,10 @@ import ( ) type service struct { - db *sql.DB + db *sql.DB + origin string + callbacks map[string]string + onboardingConfigs map[string]OnboardingConfig } type response map[string]any @@ -98,8 +101,13 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H } } -func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string) { - s := &service{db: db} +func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origin string, configs map[string]OnboardingConfig) { + s := &service{db: db, origin: origin, callbacks: callbacks, onboardingConfigs: configs} + mux.HandleFunc("/projects/{project}/onboarding", s.protected(callbacks, s.onboarding)) + mux.HandleFunc("/s/{token}", s.shortLink) + mux.HandleFunc("/projects/{project}/shares/redeem", s.protected(callbacks, s.redeemShare)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares", s.protected(callbacks, s.shares)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares/{link}", s.protected(callbacks, s.revokeShare)) mux.HandleFunc("/projects/{project}/workspaces", s.protected(callbacks, s.workspaces)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}", s.protected(callbacks, s.workspace)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/members", s.protected(callbacks, s.members)) diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go new file mode 100644 index 0000000..bd7b126 --- /dev/null +++ b/internal/platform/integration_test.go @@ -0,0 +1,120 @@ +package platform + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func TestOnboardingAndSharing(t *testing.T) { + dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("requires an isolated integration database") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + ctx := context.Background() + var raw [12]byte + rand.Read(raw[:]) + project := "test-" + base64.RawURLEncoding.EncodeToString(raw[:]) + accounts := []string{} + tokens := []string{} + for range 3 { + var id string + err = db.QueryRowContext(ctx, "INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text", project).Scan(&id) + if err != nil { + t.Fatal(err) + } + accounts = append(accounts, id) + var value [32]byte + rand.Read(value[:]) + token := base64.RawURLEncoding.EncodeToString(value[:]) + hash := sha256.Sum256([]byte(token)) + _, err = db.ExecContext(ctx, "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '1 hour')", base64.RawURLEncoding.EncodeToString(hash[:]), project, id) + if err != nil { + t.Fatal(err) + } + tokens = append(tokens, token) + } + defer func() { + _, _ = db.ExecContext(ctx, "DELETE FROM workspaces WHERE project=$1", project) + _, _ = db.ExecContext(ctx, "DELETE FROM project_accounts WHERE project=$1", project) + }() + callbacks := map[string]string{project: "https://app.example.com/auth/callback"} + mux := http.NewServeMux() + Register(mux, db, callbacks, "https://api.example.com", map[string]OnboardingConfig{ + project: {RequireWorkspace: true, Questions: []Question{{Key: "purpose", Required: true}}}, + }) + call := func(method, path, token string, body any, status int) response { + t.Helper() + encoded, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest(method, path, bytes.NewReader(encoded)) + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + if w.Code != status { + t.Fatalf("%s %s got %d %s want %d", method, path, w.Code, w.Body.String(), status) + } + out := response{} + if w.Body.Len() > 0 && w.Code != 302 { + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + } + return out + } + base := "/projects/" + project + call("GET", base+"/workspaces", "", nil, 401) + call("POST", base+"/onboarding", tokens[0], response{"answers": response{}}, 400) + first := call("POST", base+"/onboarding", tokens[0], response{"team_name": "Team", "answers": response{"purpose": "Research"}, "generate_invite": true}, 201) + workspace := first["result"].(map[string]any)["workspace_id"].(string) + retry := call("POST", base+"/onboarding", tokens[0], response{"team_name": "Team", "answers": response{"purpose": "Research"}, "generate_invite": true}, 200) + if retry["result"].(map[string]any)["workspace_id"] != workspace { + t.Fatal("onboarding created a second workspace") + } + workspacePath := base + "/workspaces/" + workspace + share := call("POST", workspacePath+"/shares", tokens[0], response{"role": "viewer", "max_uses": 1}, 201) + token := share["token"].(string) + call("GET", "/s/"+token, "", nil, 302) + call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) + call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) + call("POST", base+"/shares/redeem", tokens[2], response{"token": token}, 410) + call("PATCH", workspacePath, tokens[1], response{"name": "Forbidden"}, 403) + call("GET", workspacePath, tokens[2], nil, 404) + call("PATCH", workspacePath+"/members/"+accounts[0], tokens[0], response{"role": "viewer"}, 409) + call("DELETE", workspacePath+"/shares/"+share["id"].(string), tokens[0], nil, 204) + call("GET", "/s/"+token, "", nil, 404) + var material string + err = db.QueryRowContext(ctx, "INSERT INTO materials(project,workspace_id,kind,title,created_by) VALUES($1,$2,'article','Shared',$3) RETURNING id::text", project, workspace, accounts[0]).Scan(&material) + if err != nil { + t.Fatal(err) + } + contentShare := call("POST", workspacePath+"/shares", tokens[0], response{"role": "editor", "material_id": material}, 201) + call("POST", base+"/shares/redeem", tokens[2], response{"token": contentShare["token"]}, 200) + call("GET", workspacePath, tokens[2], nil, 404) + var count int + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM material_grants WHERE project=$1 AND account_id=$2 AND material_id=$3", project, accounts[2], material).Scan(&count); err != nil || count != 1 { + t.Fatalf("material grant missing %v", err) + } + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM link_clicks WHERE project=$1", project).Scan(&count); err != nil || count != 1 { + t.Fatalf("click accounting incorrect %v", err) + } +} diff --git a/internal/platform/onboarding.go b/internal/platform/onboarding.go new file mode 100644 index 0000000..c770c46 --- /dev/null +++ b/internal/platform/onboarding.go @@ -0,0 +1,192 @@ +package platform + +import ( + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "strings" +) + +type Question struct { + Key string `json:"key"` + Required bool `json:"required"` +} +type OnboardingConfig struct { + RequireWorkspace bool `json:"require_workspace"` + Questions []Question `json:"questions"` +} + +var surveyTypes = map[string]string{ + "team_activity": "string", "job_role": "string", "referral": "string", + "previous_use": "boolean", "purpose": "string", "company_size": "string", +} + +func ParseOnboarding(raw string, callbacks map[string]string) (map[string]OnboardingConfig, error) { + configs := map[string]OnboardingConfig{} + if strings.TrimSpace(raw) == "" { + return configs, nil + } + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&configs); err != nil { + return nil, errors.New("invalid FOUNDRY_ONBOARDING JSON") + } + if decoder.Decode(new(any)) != io.EOF { + return nil, errors.New("invalid FOUNDRY_ONBOARDING JSON") + } + for project, config := range configs { + if _, ok := callbacks[project]; !ok { + return nil, errors.New("onboarding project is not configured") + } + seen := map[string]bool{} + for _, question := range config.Questions { + if surveyTypes[question.Key] == "" || seen[question.Key] { + return nil, errors.New("invalid or duplicate onboarding question") + } + seen[question.Key] = true + } + } + return configs, nil +} + +func validAnswers(config OnboardingConfig, answers map[string]json.RawMessage) bool { + questions := map[string]Question{} + for _, question := range config.Questions { + questions[question.Key] = question + } + for key, value := range answers { + if _, ok := questions[key]; !ok { + return false + } + if surveyTypes[key] == "boolean" { + var answer *bool + if json.Unmarshal(value, &answer) != nil || answer == nil { + return false + } + } else { + var answer *string + if json.Unmarshal(value, &answer) != nil || answer == nil || len(*answer) > 2000 { + return false + } + } + } + for _, question := range config.Questions { + value, ok := answers[question.Key] + if question.Required { + if !ok { + return false + } + if surveyTypes[question.Key] == "string" { + var answer string + if json.Unmarshal(value, &answer) != nil || strings.TrimSpace(answer) == "" { + return false + } + } + } + } + return true +} + +func (s *service) onboarding(w http.ResponseWriter, r *http.Request, project, account string) error { + config := s.onboardingConfigs[project] + if config.Questions == nil { + config.Questions = []Question{} + } + if r.Method == http.MethodGet { + var answers, result json.RawMessage + err := s.db.QueryRowContext(r.Context(), "SELECT answers,result FROM onboarding_responses WHERE project=$1 AND account_id=$2", project, account).Scan(&answers, &result) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + reply(w, 200, response{"config": config, "completed": err == nil, "answers": answers, "result": result}) + return nil + } + if r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + var input struct { + Username *string `json:"username"` + TeamName string `json:"team_name"` + GenerateInvite bool `json:"generate_invite"` + Answers map[string]json.RawMessage `json:"answers"` + } + if !decode(w, r, &input) { + return nil + } + input.TeamName = strings.TrimSpace(input.TeamName) + if input.Answers == nil { + input.Answers = map[string]json.RawMessage{} + } + if input.Username != nil { + value := strings.ToLower(strings.TrimSpace(*input.Username)) + input.Username = &value + if !usernamePattern.MatchString(value) { + reject(w, 400, "invalid_username") + return nil + } + } + if !validAnswers(config, input.Answers) || len(input.TeamName) > 160 || (config.RequireWorkspace && input.TeamName == "") || (input.GenerateInvite && input.TeamName == "") { + reject(w, 400, "invalid_onboarding") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + var locked string + if err = tx.QueryRowContext(r.Context(), "SELECT id::text FROM project_accounts WHERE project=$1 AND id=$2 FOR UPDATE", project, account).Scan(&locked); err != nil { + return err + } + var prior json.RawMessage + err = tx.QueryRowContext(r.Context(), "SELECT result FROM onboarding_responses WHERE project=$1 AND account_id=$2", project, account).Scan(&prior) + if err == nil { + reply(w, 200, response{"completed": true, "result": prior, "already_completed": true}) + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + if input.Username != nil { + _, err = tx.ExecContext(r.Context(), "UPDATE project_accounts SET username=$3,updated_at=now() WHERE project=$1 AND id=$2", project, account, *input.Username) + if err != nil { + return err + } + } + result := response{} + var invitation string + if input.TeamName != "" { + workspace, err := createWorkspace(r.Context(), tx, project, account, input.TeamName) + if err != nil { + return err + } + result["workspace_id"] = workspace + if input.GenerateInvite { + token, hash := newLinkToken() + var id string + err = tx.QueryRowContext(r.Context(), ` +INSERT INTO share_links(project,workspace_id,token_hash,role,created_by,expires_at) +VALUES($1,$2,$3,'viewer',$4,now()+interval '7 days') RETURNING id::text +`, project, workspace, hash, account).Scan(&id) + if err != nil { + return err + } + result["invite_id"] = id + invitation = s.origin + "/s/" + token + } + } + answers, _ := json.Marshal(input.Answers) + resultJSON, _ := json.Marshal(result) + _, err = tx.ExecContext(r.Context(), "INSERT INTO onboarding_responses(project,account_id,answers,result) VALUES($1,$2,$3,$4)", project, account, answers, resultJSON) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, response{"completed": true, "result": result, "invite_url": invitation, "already_completed": false}) + return nil +} diff --git a/internal/platform/shares.go b/internal/platform/shares.go new file mode 100644 index 0000000..f660f99 --- /dev/null +++ b/internal/platform/shares.go @@ -0,0 +1,287 @@ +package platform + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "net/http" + "net/url" + "regexp" + "time" +) + +var linkTokenPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`) + +func newLinkToken() (string, string) { + var raw [16]byte + rand.Read(raw[:]) + value := base64.RawURLEncoding.EncodeToString(raw[:]) + return value, linkDigest(value) +} +func linkDigest(token string) string { + sum := sha256.Sum256([]byte(token)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +type shareInput struct { + Role string `json:"role"` + MaterialID *string `json:"material_id"` + ExpiresIn int `json:"expires_in"` + MaxUses *int `json:"max_uses"` +} + +func (s *service) shares(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace := r.PathValue("workspace") + if r.Method != http.MethodPost && r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "forbidden") + return nil + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), ` +SELECT l.id::text,l.material_id::text,l.role,l.expires_at,l.revoked_at,l.max_uses,l.use_count, + (SELECT count(*) FROM link_clicks c WHERE (c.project,c.workspace_id,c.link_id)=(l.project,l.workspace_id,l.id)) +FROM share_links l WHERE project=$1 AND workspace_id=$2 ORDER BY created_at DESC,id LIMIT 200 +`, project, workspace) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, role string + var material *string + var expires time.Time + var revoked *time.Time + var max *int + var uses, clicks int64 + if err := rows.Scan(&id, &material, &role, &expires, &revoked, &max, &uses, &clicks); err != nil { + return err + } + items = append(items, response{"id": id, "material_id": material, "role": role, "expires_at": expires, "revoked_at": revoked, "max_uses": max, "use_count": uses, "click_count": clicks}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + return nil + } + var input shareInput + if !decode(w, r, &input) { + return nil + } + if input.ExpiresIn == 0 { + input.ExpiresIn = 7 * 86400 + } + if (input.Role != "admin" && input.Role != "editor" && input.Role != "viewer") || + (input.MaterialID != nil && input.Role == "admin") || input.ExpiresIn < 60 || input.ExpiresIn > 30*86400 || + (input.MaxUses != nil && (*input.MaxUses < 1 || *input.MaxUses > 100000)) { + reject(w, 400, "invalid_share") + return nil + } + token, hash := newLinkToken() + var id string + var expires time.Time + err = tx.QueryRowContext(r.Context(), ` +INSERT INTO share_links(project,workspace_id,material_id,token_hash,role,created_by,expires_at,max_uses) +VALUES($1,$2,$3,$4,$5,$6,now()+$7*interval '1 second',$8) RETURNING id::text,expires_at +`, project, workspace, input.MaterialID, hash, input.Role, account, input.ExpiresIn, input.MaxUses).Scan(&id, &expires) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, response{"id": id, "url": s.origin + "/s/" + token, "token": token, "role": input.Role, "expires_at": expires}) + return nil +} + +func (s *service) revokeShare(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, r.PathValue("workspace"), account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "forbidden") + return nil + } + result, err := tx.ExecContext(r.Context(), ` +UPDATE share_links SET revoked_at=COALESCE(revoked_at,now()) WHERE project=$1 AND workspace_id=$2 AND id=$3 +`, project, r.PathValue("workspace"), r.PathValue("link")) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} + +// A public short link records only a referring origin and redirects to a fixed +// frontend. The fragment carries the invitation, never a login session. +func (s *service) shortLink(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return + } + token := r.PathValue("token") + if !linkTokenPattern.MatchString(token) { + reject(w, 404, "not_found") + return + } + ref := "" + if u, err := url.Parse(r.Referer()); err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" && u.User == nil { + ref = u.Scheme + "://" + u.Host + if len(ref) > 300 { + ref = "" + } + } + var project string + err := s.db.QueryRowContext(r.Context(), ` +WITH valid AS( + SELECT project,workspace_id,id FROM share_links + WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now() AND (max_uses IS NULL OR use_countnow(), (max_uses IS NULL OR use_count 0 { + if material == nil { + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,$4) +ON CONFLICT(project,workspace_id,account_id) DO UPDATE SET role=CASE + WHEN workspace_members.role='admin' OR EXCLUDED.role='admin' THEN 'admin' + WHEN workspace_members.role='editor' OR EXCLUDED.role='editor' THEN 'editor' ELSE 'viewer' END +`, project, workspace, account, role) + } else { + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO material_grants(project,workspace_id,material_id,account_id,role) VALUES($1,$2,$3,$4,$5) +ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET role=CASE + WHEN material_grants.role='editor' OR EXCLUDED.role='editor' THEN 'editor' ELSE 'viewer' END +`, project, workspace, *material, account, role) + } + if err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), "UPDATE share_links SET use_count=use_count+1 WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, id) + if err != nil { + return err + } + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 200, response{"workspace_id": workspace, "material_id": material, "role": role, "already_redeemed": n == 0}) + return nil +} -- 2.52.0 From 0d0521f13dc131ffa565b1cd593092b577cb77d8 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:31:09 +0800 Subject: [PATCH 12/25] Implement verified email registration and queued TLS delivery --- .env.example | 7 + cmd/foundry/main.go | 8 +- db/002-platform.sql | 2 + deploy/release.sh | 6 +- internal/auth/auth.go | 16 +- internal/auth/email.go | 204 ++++++++++++++++++++++++++ internal/auth/mailer.go | 113 ++++++++++++++ internal/platform/integration_test.go | 31 +++- 8 files changed, 381 insertions(+), 6 deletions(-) create mode 100644 internal/auth/email.go create mode 100644 internal/auth/mailer.go diff --git a/.env.example b/.env.example index c879d5b..c752c26 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,10 @@ GOOGLE_CLIENT_SECRET=replace-me FOUNDRY_PROJECTS='{"notes":"http://localhost:3000/auth/callback","board":"http://localhost:3001/auth/callback"}' HOST=127.0.0.1 PORT=8080 + +# Optional email verification through TLS SMTP. +SMTP_HOST=smtpdm.aliyun.com +SMTP_PORT=465 +SMTP_USERNAME=noreply@notice.entroia.com +SMTP_PASSWORD=replace-me +SMTP_FROM=noreply@notice.entroia.com diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go index 0d3ce4c..6b25a5e 100644 --- a/cmd/foundry/main.go +++ b/cmd/foundry/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "log" "net" @@ -46,7 +47,12 @@ func main() { } db := stdlib.OpenDB(*config) defer db.Close() - handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret) + email, err := auth.EmailConfigFromEnv() + if err != nil { + log.Fatal(err) + } + go email.Run(context.Background(), db) + handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret, email) if err != nil { log.Fatal(err) } diff --git a/db/002-platform.sql b/db/002-platform.sql index dc8af41..3721b0d 100644 --- a/db/002-platform.sql +++ b/db/002-platform.sql @@ -52,6 +52,8 @@ CREATE TABLE email_outbox ( attempts integer NOT NULL DEFAULT 0, available_at timestamptz NOT NULL DEFAULT now(), lease_until timestamptz, + lease_id uuid, + expires_at timestamptz NOT NULL, sent_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() ); diff --git a/deploy/release.sh b/deploy/release.sh index eddcf71..3636858 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -44,6 +44,10 @@ docker network inspect "$network" >/dev/null release=$(mktemp -d "$foundry/release.XXXXXXXX") install -m 755 "$1" "$release/foundry" export HOST=0.0.0.0 PORT=8080 PUBLIC_ORIGIN="$origin" +mail_env=() +if [[ -s "$foundry/mail.env" ]]; then + mail_env=(--env-file "$foundry/mail.env") +fi codex_mounts=() codex_dir=$foundry/codex if [[ -d "$codex_dir" ]]; then @@ -64,7 +68,7 @@ if [[ -d "$codex_dir" ]]; then ) fi -docker run -d --name "$candidate" "${codex_mounts[@]}" --network "$network" \ +docker run -d --name "$candidate" "${codex_mounts[@]}" "${mail_env[@]}" --network "$network" \ --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \ -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS -e FOUNDRY_ONBOARDING \ --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ diff --git a/internal/auth/auth.go b/internal/auth/auth.go index a1ef605..e2d0428 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "golang.org/x/crypto/bcrypt" "io" "log" "net/http" @@ -26,10 +27,17 @@ type service struct { projects map[string]*url.URL redirectURI, clientID, secret string google *http.Client + email *EmailConfig + passwordSlots chan struct{} + dummyPassword []byte } -func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string) (http.Handler, error) { - s := &service{db: db, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret, +func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string, email *EmailConfig) (http.Handler, error) { + dummy, err := bcrypt.GenerateFromPassword([]byte("foundry-invalid-login-placeholder"), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + s := &service{db: db, email: email, passwordSlots: make(chan struct{}, 4), dummyPassword: dummy, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret, google: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} for name, raw := range callbacks { u, err := url.Parse(raw) @@ -120,8 +128,10 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error { switch { case r.Method == http.MethodOptions: w.Header().Set("Access-Control-Allow-Methods", "GET, POST") - w.Header().Set("Access-Control-Allow-Headers", "Authorization, X-Foundry-Code, X-Foundry-Verifier") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Foundry-Code, X-Foundry-Verifier") reply(w, 204, nil) + case r.Method == http.MethodPost && (action == "register" || action == "verify-email" || action == "login"): + return s.emailAction(w, r, project, action) case r.Method == http.MethodPost && action == "exchange": return s.exchange(w, r, project) case r.Method == http.MethodGet && action == "me": diff --git a/internal/auth/email.go b/internal/auth/email.go new file mode 100644 index 0000000..c69707a --- /dev/null +++ b/internal/auth/email.go @@ -0,0 +1,204 @@ +package auth + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "net/mail" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +func emailAddress(value string) (string, bool) { + value = strings.ToLower(strings.TrimSpace(value)) + address, err := mail.ParseAddress(value) + return value, err == nil && address.Address == value && len(value) <= 254 +} +func emailBody(w http.ResponseWriter, r *http.Request, input any) bool { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16384)) + decoder.DisallowUnknownFields() + if decoder.Decode(input) != nil || decoder.Decode(new(any)) != io.EOF { + _ = reject(w, 400, "invalid_request") + return false + } + return true +} + +func (s *service) rateLimit(ctx context.Context, key string, limit int) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, ` +INSERT INTO auth_rate_limits(key_hash,attempts,expires_at) VALUES($1,1,now()+interval '15 minutes') +ON CONFLICT(key_hash) DO UPDATE SET + attempts=CASE WHEN auth_rate_limits.expires_at<=now() THEN 1 ELSE auth_rate_limits.attempts+1 END, + expires_at=CASE WHEN auth_rate_limits.expires_at<=now() THEN now()+interval '15 minutes' ELSE auth_rate_limits.expires_at END +RETURNING attempts +`, digest(key)).Scan(&count) + return count <= limit, err +} + +func (s *service) emailAction(w http.ResponseWriter, r *http.Request, project, action string) error { + if action == "verify-email" { + return s.verifyEmail(w, r, project) + } + var input struct { + Email string `json:"email"` + Password string `json:"password"` + } + if !emailBody(w, r, &input) { + return nil + } + email, valid := emailAddress(input.Email) + if !valid || len(input.Password) < 12 || len(input.Password) > 72 { + return reject(w, 400, "invalid_credentials_format") + } + limit := 10 + if action == "register" { + if s.email == nil { + return reject(w, 503, "email_not_configured") + } + limit = 3 + } + allowed, err := s.rateLimit(r.Context(), action+":"+project+":"+email, limit) + if err != nil { + return err + } + if !allowed { + w.Header().Set("Retry-After", "900") + return reject(w, 429, "rate_limited") + } + select { + case s.passwordSlots <- struct{}{}: + defer func() { <-s.passwordSlots }() + default: + w.Header().Set("Retry-After", "2") + return reject(w, 429, "auth_busy") + } + if action == "register" { + return s.registerEmail(w, r, project, email, input.Password) + } + var account, hash string + err = s.db.QueryRowContext(r.Context(), "SELECT account_id::text,password_hash FROM email_credentials WHERE project=$1 AND email=$2", project, email).Scan(&account, &hash) + missing := errors.Is(err, sql.ErrNoRows) + if err != nil && !missing { + return err + } + passwordHash := []byte(hash) + if missing { + passwordHash = s.dummyPassword + } + if bcrypt.CompareHashAndPassword(passwordHash, []byte(input.Password)) != nil || missing { + return reject(w, 401, "invalid_credentials") + } + session := token() + _, err = s.db.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account) + if err != nil { + return err + } + reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800}) + return nil +} + +func (s *service) registerEmail(w http.ResponseWriter, r *http.Request, project, email, password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + verification := token() + result, err := tx.ExecContext(r.Context(), ` +INSERT INTO email_registrations(project,email,password_hash,token_hash,expires_at) +SELECT $1,$2,$3,$4,now()+interval '30 minutes' +WHERE NOT EXISTS(SELECT 1 FROM email_credentials WHERE project=$1 AND email=$2) +ON CONFLICT(project,email) DO UPDATE SET password_hash=EXCLUDED.password_hash,token_hash=EXCLUDED.token_hash,expires_at=EXCLUDED.expires_at +`, project, email, string(hash), digest(verification)) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n > 0 { + destination := *s.projects[project] + destination.Fragment = "verify_email=" + verification + body := "Confirm your email address for " + project + ".\r\n\r\n" + destination.String() + "\r\n\r\nThis link expires in 30 minutes. If you did not register, ignore this email." + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO email_outbox(project,recipient,subject,body,expires_at) VALUES($1,$2,'Verify your email', $3, now()+interval '30 minutes') +`, project, email, body) + if err != nil { + return err + } + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 202, payload{"status": "verification_requested"}) + return nil +} + +func (s *service) verifyEmail(w http.ResponseWriter, r *http.Request, project string) error { + var input struct { + Token string `json:"token"` + } + if !emailBody(w, r, &input) { + return nil + } + if !tokenPattern.MatchString(input.Token) { + return reject(w, 400, "invalid_verification") + } + allowed, err := s.rateLimit(r.Context(), "verify:"+project+":"+digest(input.Token), 10) + if err != nil { + return err + } + if !allowed { + return reject(w, 429, "rate_limited") + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + var email, hash string + err = tx.QueryRowContext(r.Context(), ` +DELETE FROM email_registrations WHERE project=$1 AND token_hash=$2 AND expires_at>now() RETURNING email,password_hash +`, project, digest(input.Token)).Scan(&email, &hash) + if errors.Is(err, sql.ErrNoRows) { + return reject(w, 400, "invalid_verification") + } + if err != nil { + return err + } + var account string + err = tx.QueryRowContext(r.Context(), ` +WITH identity AS(SELECT gen_random_uuid() AS id) +INSERT INTO project_accounts(project,id,email,username) +SELECT $1,id,$2, + left(COALESCE(NULLIF(regexp_replace(split_part($2,'@',1),'[^a-z0-9]','','g'),''),'user'),7) + || '_' || replace(id::text,'-','') FROM identity RETURNING id::text +`, project, email).Scan(&account) + if err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), "INSERT INTO email_credentials(project,email,account_id,password_hash) VALUES($1,$2,$3,$4)", project, email, account, hash) + if err != nil { + return err + } + session := token() + _, err = tx.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800}) + return nil +} diff --git a/internal/auth/mailer.go b/internal/auth/mailer.go new file mode 100644 index 0000000..77efda5 --- /dev/null +++ b/internal/auth/mailer.go @@ -0,0 +1,113 @@ +package auth + +import ( + "context" + "crypto/tls" + "database/sql" + "errors" + "fmt" + "log" + "net" + "net/mail" + "net/smtp" + "os" + "strings" + "time" +) + +type EmailConfig struct { + Host, Port, Username, Password, From string +} + +func EmailConfigFromEnv() (*EmailConfig, error) { + c := &EmailConfig{Host: os.Getenv("SMTP_HOST"), Port: os.Getenv("SMTP_PORT"), Username: os.Getenv("SMTP_USERNAME"), Password: os.Getenv("SMTP_PASSWORD"), From: os.Getenv("SMTP_FROM")} + if c.Host == "" && c.Port == "" && c.Username == "" && c.Password == "" && c.From == "" { + return nil, nil + } + address, err := mail.ParseAddress(c.From) + if c.Host == "" || c.Port != "465" || c.Username == "" || c.Password == "" || err != nil || address.Address != c.From || strings.ContainsAny(c.Host+c.Username+c.From, "\r\n") { + return nil, errors.New("invalid SMTP configuration; TLS port 465 and a plain sender address are required") + } + return c, nil +} + +func (c *EmailConfig) send(recipient, subject, body string) error { + if strings.ContainsAny(recipient+subject, "\r\n") { + return errors.New("invalid mail headers") + } + connection, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", net.JoinHostPort(c.Host, c.Port), &tls.Config{ServerName: c.Host, MinVersion: tls.VersionTLS12}) + if err != nil { + return err + } + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(30 * time.Second)) + client, err := smtp.NewClient(connection, c.Host) + if err != nil { + return err + } + defer client.Close() + if err = client.Auth(smtp.PlainAuth("", c.Username, c.Password, c.Host)); err != nil { + return err + } + if err = client.Mail(c.From); err != nil { + return err + } + if err = client.Rcpt(recipient); err != nil { + return err + } + writer, err := client.Data() + if err != nil { + return err + } + message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n", c.From, recipient, subject, body) + if _, err = writer.Write([]byte(message)); err != nil { + return err + } + if err = writer.Close(); err != nil { + return err + } + return client.Quit() +} + +func (c *EmailConfig) Run(ctx context.Context, db *sql.DB) { + if c == nil { + return + } + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := c.deliver(ctx, db); err != nil && !errors.Is(err, sql.ErrNoRows) { + log.Printf("email delivery failed (%T)", err) + } + } + } +} +func (c *EmailConfig) deliver(ctx context.Context, db *sql.DB) error { + var id, lease, recipient, subject, body string + var attempts int + err := db.QueryRowContext(ctx, ` +UPDATE email_outbox SET lease_id=gen_random_uuid(),lease_until=now()+interval '2 minutes',attempts=attempts+1 +WHERE id=(SELECT id FROM email_outbox WHERE sent_at IS NULL AND expires_at>now() AND attempts<10 + AND available_at<=now() AND (lease_until IS NULL OR lease_until<=now()) + ORDER BY available_at,id FOR UPDATE SKIP LOCKED LIMIT 1) +RETURNING id::text,lease_id::text,recipient,subject,body,attempts +`).Scan(&id, &lease, &recipient, &subject, &body, &attempts) + if err != nil { + return err + } + sendErr := c.send(recipient, subject, body) + if sendErr == nil { + _, err = db.ExecContext(ctx, "UPDATE email_outbox SET sent_at=now(),body='',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease) + return err + } + delay := 30 * (1 << min(attempts, 7)) + _, err = db.ExecContext(ctx, "UPDATE email_outbox SET available_at=now()+$3*interval '1 second',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease, delay) + if err != nil { + return err + } + return sendErr +} diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index bd7b126..ead8ccc 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -7,10 +7,13 @@ import ( "crypto/sha256" "database/sql" "encoding/base64" + "encoding/hex" "encoding/json" + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" "net/http" "net/http/httptest" "os" + "regexp" "testing" _ "github.com/jackc/pgx/v5/stdlib" @@ -29,7 +32,7 @@ func TestOnboardingAndSharing(t *testing.T) { ctx := context.Background() var raw [12]byte rand.Read(raw[:]) - project := "test-" + base64.RawURLEncoding.EncodeToString(raw[:]) + project := "test-" + hex.EncodeToString(raw[:]) accounts := []string{} tokens := []string{} for range 3 { @@ -52,9 +55,15 @@ func TestOnboardingAndSharing(t *testing.T) { defer func() { _, _ = db.ExecContext(ctx, "DELETE FROM workspaces WHERE project=$1", project) _, _ = db.ExecContext(ctx, "DELETE FROM project_accounts WHERE project=$1", project) + _, _ = db.ExecContext(ctx, "DELETE FROM email_outbox WHERE project=$1", project) }() callbacks := map[string]string{project: "https://app.example.com/auth/callback"} mux := http.NewServeMux() + authHandler, err := auth.New(db, callbacks, "https://api.example.com/auth/google/callback", "fixture", "fixture", &auth.EmailConfig{}) + if err != nil { + t.Fatal(err) + } + mux.Handle("/", authHandler) Register(mux, db, callbacks, "https://api.example.com", map[string]OnboardingConfig{ project: {RequireWorkspace: true, Questions: []Question{{Key: "purpose", Required: true}}}, }) @@ -110,6 +119,26 @@ func TestOnboardingAndSharing(t *testing.T) { contentShare := call("POST", workspacePath+"/shares", tokens[0], response{"role": "editor", "material_id": material}, 201) call("POST", base+"/shares/redeem", tokens[2], response{"token": contentShare["token"]}, 200) call("GET", workspacePath, tokens[2], nil, 404) + email := "fixture@example.invalid" + password := "a-long-fixture-password" + call("POST", base+"/auth/register", "", response{"email": email, "password": password}, 202) + call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 401) + var body string + if err := db.QueryRowContext(ctx, "SELECT body FROM email_outbox WHERE project=$1 AND recipient=$2 ORDER BY created_at DESC LIMIT 1", project, email).Scan(&body); err != nil { + t.Fatal(err) + } + match := regexp.MustCompile(`verify_email=([A-Za-z0-9_-]{43})`).FindStringSubmatch(body) + if len(match) != 2 { + t.Fatal("missing email verification link") + } + verified := call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 201) + call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 400) + call("GET", base+"/profile", verified["access_token"].(string), nil, 200) + call("POST", base+"/auth/login", "", response{"email": email, "password": "wrong-long-password"}, 401) + loggedIn := call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 200) + call("POST", base+"/auth/logout", loggedIn["access_token"].(string), nil, 204) + call("GET", base+"/profile", loggedIn["access_token"].(string), nil, 401) + var count int if err := db.QueryRowContext(ctx, "SELECT count(*) FROM material_grants WHERE project=$1 AND account_id=$2 AND material_id=$3", project, accounts[2], material).Scan(&count); err != nil || count != 1 { t.Fatalf("material grant missing %v", err) -- 2.52.0 From bfcea1b3af34ef97fd8976e1a3d57adc41c6c45e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:36:53 +0800 Subject: [PATCH 13/25] Add structured materials, canvas layouts, tags and content permissions --- internal/platform/content.go | 129 +++++++++++ internal/platform/grants.go | 96 +++++++++ internal/platform/http.go | 11 + internal/platform/integration_test.go | 46 +++- internal/platform/layouts.go | 158 ++++++++++++++ internal/platform/materials.go | 297 ++++++++++++++++++++++++++ internal/platform/tags.go | 169 +++++++++++++++ 7 files changed, 905 insertions(+), 1 deletion(-) create mode 100644 internal/platform/content.go create mode 100644 internal/platform/grants.go create mode 100644 internal/platform/layouts.go create mode 100644 internal/platform/materials.go create mode 100644 internal/platform/tags.go diff --git a/internal/platform/content.go b/internal/platform/content.go new file mode 100644 index 0000000..262f330 --- /dev/null +++ b/internal/platform/content.go @@ -0,0 +1,129 @@ +package platform + +import ( + "bytes" + "encoding/json" + "io" + "net/url" + "regexp" +) + +type Paragraph struct { + ID string `json:"id"` + Text string `json:"text"` + Description string `json:"description,omitempty"` +} +type Section struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Paragraphs []Paragraph `json:"paragraphs"` +} +type Article struct { + Sections []Section `json:"sections"` +} +type Image struct { + URL string `json:"url"` + Alt string `json:"alt,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} +type CardPart struct { + Kind string `json:"kind"` + Text *string `json:"text,omitempty"` + Article *Article `json:"article,omitempty"` + Image *Image `json:"image,omitempty"` +} +type Content struct { + SchemaVersion int `json:"schema_version"` + Text *string `json:"text,omitempty"` + Article *Article `json:"article,omitempty"` + Image *Image `json:"image,omitempty"` + Parts []CardPart `json:"parts,omitempty"` +} + +var blockID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +func validArticle(article *Article) bool { + if article == nil || len(article.Sections) > 200 { + return false + } + seen := map[string]bool{} + total := 0 + for _, section := range article.Sections { + if !blockID.MatchString(section.ID) || seen[section.ID] || len(section.Title) > 300 || len(section.Description) > 10000 { + return false + } + seen[section.ID] = true + total += len(section.Paragraphs) + if total > 2000 { + return false + } + for _, paragraph := range section.Paragraphs { + if !blockID.MatchString(paragraph.ID) || seen[paragraph.ID] || len(paragraph.Text) > 100000 || len(paragraph.Description) > 10000 { + return false + } + seen[paragraph.ID] = true + } + } + return true +} +func validImage(image *Image) bool { + if image == nil || len(image.URL) > 2048 || len(image.Alt) > 2000 || image.Width < 0 || image.Width > 100000 || image.Height < 0 || image.Height > 100000 { + return false + } + u, err := url.Parse(image.URL) + return err == nil && u.Scheme == "https" && u.Hostname() != "" && u.User == nil && u.Fragment == "" +} +func validPart(part CardPart) bool { + switch part.Kind { + case "text": + return part.Text != nil && len(*part.Text) <= 100000 && part.Article == nil && part.Image == nil + case "article": + return part.Text == nil && part.Image == nil && validArticle(part.Article) + case "image": + return part.Text == nil && part.Article == nil && validImage(part.Image) + default: + return false + } +} +func normalizeContent(kind string, raw json.RawMessage) (json.RawMessage, bool) { + if raw = bytes.TrimSpace(raw); len(raw) == 0 || raw[0] != '{' { + return nil, false + } + var content Content + d := json.NewDecoder(bytes.NewReader(raw)) + d.DisallowUnknownFields() + if d.Decode(&content) != nil || d.Decode(new(any)) != io.EOF { + return nil, false + } + if content.SchemaVersion == 0 { + content.SchemaVersion = 1 + } + if content.SchemaVersion != 1 { + return nil, false + } + switch kind { + case "text", "article", "image": + if len(content.Parts) > 0 || !validPart(CardPart{Kind: kind, Text: content.Text, Article: content.Article, Image: content.Image}) { + return nil, false + } + case "board": + if content.Text != nil || content.Article != nil || content.Image != nil || len(content.Parts) > 0 { + return nil, false + } + case "card": + if content.Text != nil || content.Article != nil || content.Image != nil || len(content.Parts) < 1 || len(content.Parts) > 20 { + return nil, false + } + for _, part := range content.Parts { + if !validPart(part) { + return nil, false + } + } + default: + return nil, false + } + out, err := json.Marshal(content) + return out, err == nil +} diff --git a/internal/platform/grants.go b/internal/platform/grants.go new file mode 100644 index 0000000..6dde717 --- /dev/null +++ b/internal/platform/grants.go @@ -0,0 +1,96 @@ +package platform + +import "net/http" + +func (s *service) materialGrants(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace, material := r.PathValue("workspace"), r.PathValue("material") + if r.Method != http.MethodGet && r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "admin_required") + return nil + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), "SELECT account_id::text,role FROM material_grants WHERE project=$1 AND workspace_id=$2 AND material_id=$3 ORDER BY account_id LIMIT 1000", project, workspace, material) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, role string + if err := rows.Scan(&id, &role); err != nil { + return err + } + items = append(items, response{"account_id": id, "role": role}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + return nil + } + var input struct { + AccountID string `json:"account_id"` + Role string `json:"role"` + } + if !decode(w, r, &input) { + return nil + } + if input.Role != "editor" && input.Role != "viewer" { + reject(w, 400, "invalid_role") + return nil + } + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO material_grants(project,workspace_id,material_id,account_id,role) VALUES($1,$2,$3,$4,$5) +ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET role=EXCLUDED.role +`, project, workspace, material, input.AccountID, input.Role) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} +func (s *service) deleteMaterialGrant(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, material := r.PathValue("workspace"), r.PathValue("material") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "admin_required") + return nil + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM material_grants WHERE project=$1 AND workspace_id=$2 AND material_id=$3 AND account_id=$4", project, workspace, material, r.PathValue("account")) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} diff --git a/internal/platform/http.go b/internal/platform/http.go index 58d24d2..4067bdf 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -69,6 +69,7 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H return } w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Expose-Headers", "ETag") } if r.Method == http.MethodOptions { w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") @@ -103,6 +104,16 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origin string, configs map[string]OnboardingConfig) { s := &service{db: db, origin: origin, callbacks: callbacks, onboardingConfigs: configs} + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials", s.protected(callbacks, s.materials)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}", s.protected(callbacks, s.material)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/open", s.protected(callbacks, s.openMaterial)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/tags", s.protected(callbacks, s.tags)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/tags/{tag}", s.protected(callbacks, s.deleteTag)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/tags", s.protected(callbacks, s.materialTags)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/grants", s.protected(callbacks, s.materialGrants)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/grants/{account}", s.protected(callbacks, s.deleteMaterialGrant)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/layouts", s.protected(callbacks, s.layouts)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/layouts/{layout}", s.protected(callbacks, s.layouts)) mux.HandleFunc("/projects/{project}/onboarding", s.protected(callbacks, s.onboarding)) mux.HandleFunc("/s/{token}", s.shortLink) mux.HandleFunc("/projects/{project}/shares/redeem", s.protected(callbacks, s.redeemShare)) diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index ead8ccc..14cff61 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -67,7 +67,7 @@ func TestOnboardingAndSharing(t *testing.T) { Register(mux, db, callbacks, "https://api.example.com", map[string]OnboardingConfig{ project: {RequireWorkspace: true, Questions: []Question{{Key: "purpose", Required: true}}}, }) - call := func(method, path, token string, body any, status int) response { + call := func(method, path, token string, body any, status int, versions ...string) response { t.Helper() encoded, err := json.Marshal(body) if err != nil { @@ -77,6 +77,9 @@ func TestOnboardingAndSharing(t *testing.T) { if token != "" { r.Header.Set("Authorization", "Bearer "+token) } + if len(versions) > 0 { + r.Header.Set("If-Match", versions[0]) + } w := httptest.NewRecorder() mux.ServeHTTP(w, r) if w.Code != status { @@ -119,6 +122,47 @@ func TestOnboardingAndSharing(t *testing.T) { contentShare := call("POST", workspacePath+"/shares", tokens[0], response{"role": "editor", "material_id": material}, 201) call("POST", base+"/shares/redeem", tokens[2], response{"token": contentShare["token"]}, 200) call("GET", workspacePath, tokens[2], nil, 404) + articleContent := response{"schema_version": 1, "article": response{"sections": []any{response{ + "id": "chapter-1", "title": "Chapter", "description": "Chapter summary", "paragraphs": []any{response{"id": "p1", "text": "Paragraph", "description": "Paragraph summary"}}, + }}}} + created := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "article", "title": "Article", "content": articleContent}, 201) + articleID := created["id"].(string) + text := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "text", "title": "Alpha", "content": response{"text": "Original"}}, 201) + textPath := workspacePath + "/materials/" + text["id"].(string) + call("GET", textPath, tokens[2], nil, 404) + call("POST", textPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204) + call("GET", textPath, tokens[2], nil, 200) + changed := response{"title": "Beta", "content": response{"text": "Changed"}} + call("PATCH", textPath, tokens[2], changed, 403, `"1"`) + call("POST", textPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "editor"}, 204) + call("PATCH", textPath, tokens[2], changed, 428) + call("PATCH", textPath, tokens[2], changed, 204, `"1"`) + call("PATCH", textPath, tokens[0], changed, 412, `"1"`) + call("POST", workspacePath+"/materials", tokens[0], response{"kind": "article", "title": "Invalid", "content": response{"markdown": "raw markdown"}}, 400) + tag := call("POST", workspacePath+"/tags", tokens[0], response{"name": "Research", "color": "#123456"}, 201) + call("GET", workspacePath+"/tags", tokens[2], nil, 404) + call("POST", textPath+"/tags", tokens[2], response{"tags": []string{tag["id"].(string)}}, 204, `"2"`) + call("POST", textPath+"/open", tokens[2], nil, 204) + filtered := call("GET", workspacePath+"/materials?sort=opened&tag="+tag["id"].(string), tokens[2], nil, 200) + if len(filtered["items"].([]any)) != 1 { + t.Fatal("tag-filtered material list leaked inaccessible materials") + } + board := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Board", "content": response{}}, 201) + boardPath := workspacePath + "/materials/" + board["id"].(string) + layout := call("POST", boardPath+"/layouts", tokens[0], response{"material_id": articleID, "x": 1, "y": 2, "width": 320, "height": 200, "z_index": 1}, 201, `"1"`) + call("GET", boardPath+"/layouts", tokens[1], nil, 200) + call("POST", boardPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204) + hidden := call("GET", boardPath+"/layouts", tokens[2], nil, 200) + if len(hidden["items"].([]any)) != 0 { + t.Fatal("board share disclosed private child material") + } + call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 412, `"1"`) + call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 204, `"2"`) + call("DELETE", textPath+"/grants/"+accounts[2], tokens[0], nil, 204) + call("GET", textPath, tokens[2], nil, 404) + call("DELETE", workspacePath+"/tags/"+tag["id"].(string), tokens[0], nil, 204) + call("PATCH", textPath, tokens[0], changed, 412, `"3"`) + email := "fixture@example.invalid" password := "a-long-fixture-password" call("POST", base+"/auth/register", "", response{"email": email, "password": password}, 202) diff --git a/internal/platform/layouts.go b/internal/platform/layouts.go new file mode 100644 index 0000000..4674f1b --- /dev/null +++ b/internal/platform/layouts.go @@ -0,0 +1,158 @@ +package platform + +import ( + "database/sql" + "encoding/json" + "math" + "net/http" +) + +type placement struct { + MaterialID string `json:"material_id"` + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"width"` + Height float64 `json:"height"` + ZIndex int `json:"z_index"` +} + +func (p placement) valid() bool { + return !math.IsNaN(p.X) && !math.IsNaN(p.Y) && !math.IsNaN(p.Width) && !math.IsNaN(p.Height) && + p.X >= -1000000 && p.X <= 1000000 && p.Y >= -1000000 && p.Y <= 1000000 && + p.Width >= 1 && p.Width <= 100000 && p.Height >= 1 && p.Height <= 100000 && + p.ZIndex >= -1000000 && p.ZIndex <= 1000000 +} +func (s *service) layouts(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodPatch && r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, board, layout := r.PathValue("workspace"), r.PathValue("material"), r.PathValue("layout") + if (layout == "" && (r.Method == http.MethodPatch || r.Method == http.MethodDelete)) || (layout != "" && (r.Method == http.MethodGet || r.Method == http.MethodPost)) { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + kind, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account) + if err != nil { + return err + } + if kind != "board" { + reject(w, 400, "not_a_board") + return nil + } + var version int64 + if err = tx.QueryRowContext(r.Context(), "SELECT version FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, board).Scan(&version); err != nil { + return err + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), ` +SELECT jsonb_build_object('id',l.id,'material_id',l.material_id,'x',l.x,'y',l.y,'width',l.width,'height',l.height,'z_index',l.z_index,'version',l.version) +FROM board_layouts l WHERE l.project=$1 AND l.workspace_id=$2 AND l.board_id=$3 +AND (EXISTS(SELECT 1 FROM workspace_members m WHERE m.project=l.project AND m.workspace_id=l.workspace_id AND m.account_id=$4) + OR EXISTS(SELECT 1 FROM material_grants g WHERE (g.project,g.workspace_id,g.material_id)=(l.project,l.workspace_id,l.material_id) AND g.account_id=$4)) +ORDER BY l.z_index,l.id LIMIT 2000 +`, project, workspace, board, account) + if err != nil { + return err + } + defer rows.Close() + items := []json.RawMessage{} + for rows.Next() { + var item json.RawMessage + if err := rows.Scan(&item); err != nil { + return err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return err + } + setVersion(w, version) + reply(w, 200, response{"items": items, "board_version": version}) + return nil + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + expected, ok := expectedVersion(w, r) + if !ok { + return nil + } + if expected != version { + reject(w, 412, "version_conflict") + return nil + } + var input placement + if r.Method != http.MethodDelete { + if !decode(w, r, &input) { + return nil + } + if !input.valid() || input.MaterialID == board { + reject(w, 400, "invalid_layout") + return nil + } + refKind, _, err := lockMaterial(r.Context(), tx, project, workspace, input.MaterialID, account) + if err != nil { + return err + } + if refKind == "board" { + reject(w, 400, "nested_board_not_supported") + return nil + } + } + var result sql.Result + switch r.Method { + case http.MethodPost: + var count int + if err = tx.QueryRowContext(r.Context(), "SELECT count(*) FROM board_layouts WHERE project=$1 AND workspace_id=$2 AND board_id=$3", project, workspace, board).Scan(&count); err != nil { + return err + } + if count >= 2000 { + reject(w, 409, "board_full") + return nil + } + err = tx.QueryRowContext(r.Context(), ` +INSERT INTO board_layouts(project,workspace_id,board_id,material_id,x,y,width,height,z_index) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id::text +`, project, workspace, board, input.MaterialID, input.X, input.Y, input.Width, input.Height, input.ZIndex).Scan(&layout) + case http.MethodPatch: + result, err = tx.ExecContext(r.Context(), ` +UPDATE board_layouts SET material_id=$5,x=$6,y=$7,width=$8,height=$9,z_index=$10,version=version+1 +WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4 +`, project, workspace, board, layout, input.MaterialID, input.X, input.Y, input.Width, input.Height, input.ZIndex) + case http.MethodDelete: + result, err = tx.ExecContext(r.Context(), "DELETE FROM board_layouts WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4", project, workspace, board, layout) + } + if err != nil { + return err + } + if result != nil { + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + } + _, err = tx.ExecContext(r.Context(), "UPDATE materials SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, board) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + setVersion(w, version+1) + if r.Method == http.MethodPost { + reply(w, 201, response{"id": layout, "board_version": version + 1}) + } else { + reply(w, 204, nil) + } + return nil +} diff --git a/internal/platform/materials.go b/internal/platform/materials.go new file mode 100644 index 0000000..2d03c0b --- /dev/null +++ b/internal/platform/materials.go @@ -0,0 +1,297 @@ +package platform + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "strconv" +) + +func lockMaterial(ctx context.Context, tx *sql.Tx, project, workspace, material, account string) (string, string, error) { + var locked, kind, role string + err := tx.QueryRowContext(ctx, "SELECT id::text FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE", project, workspace).Scan(&locked) + if err != nil { + return "", "", err + } + err = tx.QueryRowContext(ctx, ` +SELECT m.kind,CASE + WHEN member.role='admin' THEN 'admin' + WHEN member.role='editor' OR access.role='editor' THEN 'editor' + ELSE 'viewer' END +FROM materials m +LEFT JOIN workspace_members member ON (member.project,member.workspace_id)=(m.project,m.workspace_id) AND member.account_id=$4 +LEFT JOIN material_grants access ON (access.project,access.workspace_id,access.material_id)=(m.project,m.workspace_id,m.id) AND access.account_id=$4 +WHERE m.project=$1 AND m.workspace_id=$2 AND m.id=$3 AND (member.role IS NOT NULL OR access.role IS NOT NULL) +FOR UPDATE OF m +`, project, workspace, material, account).Scan(&kind, &role) + return kind, role, err +} + +func expectedVersion(w http.ResponseWriter, r *http.Request) (int64, bool) { + header := r.Header.Get("If-Match") + if header == "" { + reject(w, 428, "version_required") + return 0, false + } + if len(header) < 3 || header[0] != '"' || header[len(header)-1] != '"' { + reject(w, 400, "invalid_version") + return 0, false + } + version, err := strconv.ParseInt(header[1:len(header)-1], 10, 64) + if err != nil || version < 1 { + reject(w, 400, "invalid_version") + return 0, false + } + return version, true +} +func setVersion(w http.ResponseWriter, version int64) { + w.Header().Set("ETag", strconv.Quote(strconv.FormatInt(version, 10))) +} + +func (s *service) materials(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method == http.MethodGet { + return s.listMaterials(w, r, project, account) + } + if r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + var input struct { + Kind string `json:"kind"` + Title string `json:"title"` + Description string `json:"description"` + Content json.RawMessage `json:"content"` + } + if !decode(w, r, &input) { + return nil + } + content, ok := normalizeContent(input.Kind, input.Content) + if !ok || len(input.Title) > 300 || len(input.Description) > 10000 { + reject(w, 400, "invalid_material") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + workspace := r.PathValue("workspace") + _, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + var id string + err = tx.QueryRowContext(r.Context(), ` +INSERT INTO materials(project,workspace_id,kind,title,description,content,created_by) +VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id::text +`, project, workspace, input.Kind, input.Title, input.Description, content, account).Scan(&id) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + setVersion(w, 1) + reply(w, 201, response{"id": id, "workspace_id": workspace, "kind": input.Kind, "version": 1}) + return nil +} + +func (s *service) material(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet && r.Method != http.MethodPatch && r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, material := r.PathValue("workspace"), r.PathValue("material") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + kind, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if r.Method == http.MethodGet { + var result json.RawMessage + var version int64 + err = tx.QueryRowContext(r.Context(), ` +SELECT jsonb_build_object('id',id,'workspace_id',workspace_id,'kind',kind,'title',title,'description',description, +'content',content,'version',version,'created_at',created_at,'modified_at',modified_at,'created_by',created_by),version +FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3 +`, project, workspace, material).Scan(&result, &version) + if err != nil { + return err + } + setVersion(w, version) + reply(w, 200, result) + return nil + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + version, ok := expectedVersion(w, r) + if !ok { + return nil + } + var result sql.Result + if r.Method == http.MethodPatch { + var input struct { + Title string `json:"title"` + Description string `json:"description"` + Content json.RawMessage `json:"content"` + } + if !decode(w, r, &input) { + return nil + } + content, ok := normalizeContent(kind, input.Content) + if !ok || len(input.Title) > 300 || len(input.Description) > 10000 { + reject(w, 400, "invalid_material") + return nil + } + result, err = tx.ExecContext(r.Context(), ` +UPDATE materials SET title=$4,description=$5,content=$6,version=version+1,modified_at=now() +WHERE project=$1 AND workspace_id=$2 AND id=$3 AND version=$7 +`, project, workspace, material, input.Title, input.Description, content, version) + } else { + if role != "admin" { + reject(w, 403, "admin_required") + return nil + } + result, err = tx.ExecContext(r.Context(), "DELETE FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3 AND version=$4", project, workspace, material, version) + } + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + reject(w, 412, "version_conflict") + return nil + } + if err = tx.Commit(); err != nil { + return err + } + if r.Method == http.MethodPatch { + setVersion(w, version+1) + } + reply(w, 204, nil) + return nil +} + +func (s *service) openMaterial(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, material := r.PathValue("workspace"), r.PathValue("material") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, _, err = lockMaterial(r.Context(), tx, project, workspace, material, account); err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO material_opens(project,workspace_id,material_id,account_id) VALUES($1,$2,$3,$4) +ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET opened_at=now() +`, project, workspace, material, account) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} + +func (s *service) listMaterials(w http.ResponseWriter, r *http.Request, project, account string) error { + sort := r.URL.Query().Get("sort") + orders := map[string]string{ + "": "m.modified_at DESC,m.id", + "modified": "m.modified_at DESC,m.id", + "created": "m.created_at DESC,m.id", + "alphabetical": "lower(m.title),m.id", + "opened": "opened.opened_at DESC NULLS LAST,m.id", + "tag": "COALESCE((SELECT min(lower(t.name)) FROM material_tags mt JOIN tags t ON (t.project,t.workspace_id,t.id)=(mt.project,mt.workspace_id,mt.tag_id) WHERE (mt.project,mt.workspace_id,mt.material_id)=(m.project,m.workspace_id,m.id)),''),lower(m.title),m.id", + } + order, ok := orders[sort] + if !ok { + reject(w, 400, "invalid_sort") + return nil + } + limit, offset := 50, 0 + var err error + if raw := r.URL.Query().Get("limit"); raw != "" { + limit, err = strconv.Atoi(raw) + } + if err != nil || limit < 1 || limit > 100 { + reject(w, 400, "invalid_pagination") + return nil + } + if raw := r.URL.Query().Get("offset"); raw != "" { + offset, err = strconv.Atoi(raw) + } + if err != nil || offset < 0 || offset > 1000000 { + reject(w, 400, "invalid_pagination") + return nil + } + query := r.URL.Query().Get("q") + kind := r.URL.Query().Get("kind") + if len(query) > 256 { + reject(w, 400, "invalid_filter") + return nil + } + switch kind { + case "", "text", "article", "image", "board", "card": + default: + reject(w, 400, "invalid_filter") + return nil + } + tag := r.URL.Query().Get("tag") + rows, err := s.db.QueryContext(r.Context(), ` +SELECT jsonb_build_object('id',m.id,'workspace_id',m.workspace_id,'kind',m.kind,'title',m.title, +'description',m.description,'version',m.version,'created_at',m.created_at,'modified_at',m.modified_at, +'opened_at',opened.opened_at,'tags',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',t.id,'name',t.name,'color',t.color) ORDER BY lower(t.name),t.id) +FROM material_tags mt JOIN tags t ON (t.project,t.workspace_id,t.id)=(mt.project,mt.workspace_id,mt.tag_id) +WHERE (mt.project,mt.workspace_id,mt.material_id)=(m.project,m.workspace_id,m.id)),'[]'::jsonb)) +FROM materials m LEFT JOIN material_opens opened ON (opened.project,opened.workspace_id,opened.material_id)=(m.project,m.workspace_id,m.id) AND opened.account_id=$3 +WHERE m.project=$1 AND m.workspace_id=$2 +AND (EXISTS(SELECT 1 FROM workspace_members member WHERE (member.project,member.workspace_id)=(m.project,m.workspace_id) AND member.account_id=$3) + OR EXISTS(SELECT 1 FROM material_grants g WHERE (g.project,g.workspace_id,g.material_id)=(m.project,m.workspace_id,m.id) AND g.account_id=$3)) +AND ($4='' OR m.title ILIKE '%'||$4||'%') AND ($5='' OR m.kind=$5) +AND ($6='' OR EXISTS(SELECT 1 FROM material_tags t WHERE (t.project,t.workspace_id,t.material_id)=(m.project,m.workspace_id,m.id) AND t.tag_id::text=$6)) +ORDER BY `+order+` LIMIT $7 OFFSET $8`, project, r.PathValue("workspace"), account, query, kind, tag, limit+1, offset) + if err != nil { + return err + } + defer rows.Close() + items := []json.RawMessage{} + for rows.Next() { + var value json.RawMessage + if err := rows.Scan(&value); err != nil { + return err + } + items = append(items, value) + } + if err := rows.Err(); err != nil { + return err + } + var next *int + if len(items) > limit { + items = items[:limit] + value := offset + limit + next = &value + } + reply(w, 200, response{"items": items, "next_offset": next}) + return nil +} diff --git a/internal/platform/tags.go b/internal/platform/tags.go new file mode 100644 index 0000000..32c95ea --- /dev/null +++ b/internal/platform/tags.go @@ -0,0 +1,169 @@ +package platform + +import ( + "net/http" + "regexp" + "strings" +) + +var colorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) + +func (s *service) tags(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + workspace := r.PathValue("workspace") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), "SELECT id::text,name,color FROM tags WHERE project=$1 AND workspace_id=$2 ORDER BY lower(name),id LIMIT 1000", project, workspace) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, name, color string + if err := rows.Scan(&id, &name, &color); err != nil { + return err + } + items = append(items, response{"id": id, "name": name, "color": color}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + return nil + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + var input struct { + Name string `json:"name"` + Color string `json:"color"` + } + if !decode(w, r, &input) { + return nil + } + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 80 || (input.Color != "" && !colorPattern.MatchString(input.Color)) { + reject(w, 400, "invalid_tag") + return nil + } + var id string + err = tx.QueryRowContext(r.Context(), "INSERT INTO tags(project,workspace_id,name,color) VALUES($1,$2,$3,$4) RETURNING id::text", project, workspace, input.Name, input.Color).Scan(&id) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, response{"id": id, "name": input.Name, "color": input.Color}) + return nil +} +func (s *service) deleteTag(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, r.PathValue("workspace"), account) + if err != nil { + return err + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + _, err = tx.ExecContext(r.Context(), ` +UPDATE materials m SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 + AND EXISTS(SELECT 1 FROM material_tags t WHERE (t.project,t.workspace_id,t.material_id)=(m.project,m.workspace_id,m.id) AND t.tag_id=$3) +`, project, r.PathValue("workspace"), r.PathValue("tag")) + if err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM tags WHERE project=$1 AND workspace_id=$2 AND id=$3", project, r.PathValue("workspace"), r.PathValue("tag")) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} +func (s *service) materialTags(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + var input struct { + Tags []string `json:"tags"` + } + if !decode(w, r, &input) { + return nil + } + if len(input.Tags) > 100 { + reject(w, 400, "too_many_tags") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + workspace, material := r.PathValue("workspace"), r.PathValue("material") + _, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + version, ok := expectedVersion(w, r) + if !ok { + return nil + } + result, err := tx.ExecContext(r.Context(), "UPDATE materials SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 AND id=$3 AND version=$4", project, workspace, material, version) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + reject(w, 412, "version_conflict") + return nil + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM material_tags WHERE project=$1 AND workspace_id=$2 AND material_id=$3", project, workspace, material) + if err != nil { + return err + } + for _, tag := range input.Tags { + _, err = tx.ExecContext(r.Context(), "INSERT INTO material_tags(project,workspace_id,material_id,tag_id) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING", project, workspace, material, tag) + if err != nil { + return err + } + } + if err = tx.Commit(); err != nil { + return err + } + setVersion(w, version+1) + reply(w, 204, nil) + return nil +} -- 2.52.0 From e0ba9c08e8863309cffe2760341a8ba1b691eb3e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:39:23 +0800 Subject: [PATCH 14/25] Expose paginated share redirect events to workspace admins --- internal/platform/http.go | 1 + internal/platform/integration_test.go | 5 ++ internal/platform/shares.go | 69 +++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/internal/platform/http.go b/internal/platform/http.go index 4067bdf..f9872c7 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -114,6 +114,7 @@ func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origi mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/grants/{account}", s.protected(callbacks, s.deleteMaterialGrant)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/layouts", s.protected(callbacks, s.layouts)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/layouts/{layout}", s.protected(callbacks, s.layouts)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares/{link}/clicks", s.protected(callbacks, s.shareClicks)) mux.HandleFunc("/projects/{project}/onboarding", s.protected(callbacks, s.onboarding)) mux.HandleFunc("/s/{token}", s.shortLink) mux.HandleFunc("/projects/{project}/shares/redeem", s.protected(callbacks, s.redeemShare)) diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index 14cff61..3f2496d 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -106,6 +106,11 @@ func TestOnboardingAndSharing(t *testing.T) { share := call("POST", workspacePath+"/shares", tokens[0], response{"role": "viewer", "max_uses": 1}, 201) token := share["token"].(string) call("GET", "/s/"+token, "", nil, 302) + clicks := call("GET", workspacePath+"/shares/"+share["id"].(string)+"/clicks", tokens[0], nil, 200) + if len(clicks["items"].([]any)) != 1 { + t.Fatal("click event API did not return the redirect") + } + call("GET", workspacePath+"/shares/"+share["id"].(string)+"/clicks", tokens[2], nil, 404) call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) call("POST", base+"/shares/redeem", tokens[2], response{"token": token}, 410) diff --git a/internal/platform/shares.go b/internal/platform/shares.go index f660f99..6d5159a 100644 --- a/internal/platform/shares.go +++ b/internal/platform/shares.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "regexp" + "strconv" "time" ) @@ -285,3 +286,71 @@ ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET role=CASE reply(w, 200, response{"workspace_id": workspace, "material_id": material, "role": role, "already_redeemed": n == 0}) return nil } + +func (s *service) shareClicks(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + before := r.URL.Query().Get("before") + var cursor int64 + if before != "" { + value, err := strconv.ParseInt(before, 10, 64) + if err != nil || value < 1 { + reject(w, 400, "invalid_cursor") + return nil + } + cursor = value + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + workspace, link := r.PathValue("workspace"), r.PathValue("link") + _, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "forbidden") + return nil + } + var found string + if err = tx.QueryRowContext(r.Context(), "SELECT id::text FROM share_links WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, link).Scan(&found); err != nil { + return err + } + rows, err := tx.QueryContext(r.Context(), ` +SELECT id,clicked_at,referrer_origin FROM link_clicks WHERE project=$1 AND workspace_id=$2 AND link_id=$3 AND ($4::bigint=0 OR id<$4) +ORDER BY id DESC LIMIT 101 +`, project, workspace, link, cursor) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + var last int64 + for rows.Next() { + var id int64 + var at time.Time + var ref string + if err := rows.Scan(&id, &at, &ref); err != nil { + return err + } + items = append(items, response{"id": strconv.FormatInt(id, 10), "clicked_at": at, "referrer_origin": ref}) + if len(items) <= 100 { + last = id + } + } + if err := rows.Err(); err != nil { + return err + } + var next *string + if len(items) > 100 { + items = items[:100] + value := strconv.FormatInt(last, 10) + next = &value + } + reply(w, 200, response{"items": items, "next_before": next}) + return nil +} -- 2.52.0 From 4a06944fec40845e424a726150e2a904edc25dce Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:40:41 +0800 Subject: [PATCH 15/25] Verify multimodal content boundaries and every material sort --- internal/platform/content_test.go | 39 +++++++++++++++++++++++++++ internal/platform/integration_test.go | 15 +++++++++++ 2 files changed, 54 insertions(+) create mode 100644 internal/platform/content_test.go diff --git a/internal/platform/content_test.go b/internal/platform/content_test.go new file mode 100644 index 0000000..1cd761c --- /dev/null +++ b/internal/platform/content_test.go @@ -0,0 +1,39 @@ +package platform + +import ( + "encoding/json" + "testing" +) + +func TestContentValidation(t *testing.T) { + cases := []struct { + name, kind, body string + valid bool + }{ + {"plain text", "text", `{"text":""}`, true}, + {"empty article draft", "article", `{"article":{"sections":[]}}`, true}, + {"paragraph descriptions", "article", `{"article":{"sections":[{"id":"s","title":"Section","description":"Summary","paragraphs":[{"id":"p","text":"Body","description":"Notes"}]}]}}`, true}, + {"duplicate paragraph IDs", "article", `{"article":{"sections":[{"id":"s","paragraphs":[{"id":"p","text":"One"},{"id":"p","text":"Two"}]}]}}`, false}, + {"markdown field", "article", `{"markdown":"# Heading"}`, false}, + {"unknown schema", "text", `{"schema_version":2,"text":"Text"}`, false}, + {"null content", "board", `null`, false}, + {"nested unknown field", "image", `{"image":{"url":"https://example.com/image.png","secret":"unexpected"}}`, false}, + {"insecure image", "image", `{"image":{"url":"http://example.com/image.png"}}`, false}, + {"URL credentials", "image", `{"image":{"url":"https://user:password@example.com/image.png"}}`, false}, + {"card and text", "card", `{"parts":[{"kind":"text","text":"Caption"}]}`, true}, + {"empty card", "card", `{"parts":[]}`, false}, + {"mismatched part", "card", `{"parts":[{"kind":"text","image":{"url":"https://example.com/image.png"}}]}`, false}, + {"board payload", "board", `{"text":"Wrong type"}`, false}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + out, ok := normalizeContent(test.kind, json.RawMessage(test.body)) + if ok != test.valid { + t.Fatalf("valid=%v, want %v", ok, test.valid) + } + if ok && !json.Valid(out) { + t.Fatal("normalized content is invalid JSON") + } + }) + } +} diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index 3f2496d..036a5a4 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -168,6 +168,21 @@ func TestOnboardingAndSharing(t *testing.T) { call("DELETE", workspacePath+"/tags/"+tag["id"].(string), tokens[0], nil, 204) call("PATCH", textPath, tokens[0], changed, 412, `"3"`) + imageContent := response{"image": response{"url": "https://assets.example.com/image.png", "alt": "Photo"}} + call("POST", workspacePath+"/materials", tokens[0], response{"kind": "image", "title": "Photo", "content": imageContent}, 201) + call("POST", workspacePath+"/materials", tokens[0], response{"kind": "card", "title": "Mixed card", "content": response{"parts": []any{ + response{"kind": "text", "text": "Caption"}, + response{"kind": "article", "article": articleContent["article"]}, + response{"kind": "image", "image": imageContent["image"]}, + }}}, 201) + for _, sort := range []string{"modified", "created", "alphabetical", "opened", "tag"} { + call("GET", workspacePath+"/materials?sort="+sort+"&limit=2", tokens[0], nil, 200) + } + images := call("GET", workspacePath+"/materials?kind=image&q=Photo", tokens[0], nil, 200) + if len(images["items"].([]any)) != 1 { + t.Fatal("kind/title filter returned incorrect materials") + } + email := "fixture@example.invalid" password := "a-long-fixture-password" call("POST", base+"/auth/register", "", response{"email": email, "password": password}, 202) -- 2.52.0 From 206cd45098978a43b61064568544c81d27c3f4c1 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:43:45 +0800 Subject: [PATCH 16/25] Apply tracked schema migrations before deployment and test runtime grants --- .gitea/workflows/deploy.yml | 1 + deploy/check-schema.sh | 8 +++- deploy/migrate.py | 76 +++++++++++++++++++++++++++++++++++++ deploy/release.sh | 1 + 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 deploy/migrate.py diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 564745f..f2d5c54 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -8,6 +8,7 @@ on: - 'go.mod' - 'go.sum' - 'deploy/**' + - 'db/**' - '.gitea/workflows/deploy.yml' concurrency: diff --git a/deploy/check-schema.sh b/deploy/check-schema.sh index b97a56c..adec116 100644 --- a/deploy/check-schema.sh +++ b/deploy/check-schema.sh @@ -19,7 +19,11 @@ VALUES ('fixture','00000000-0000-0000-0000-000000000001','existing-google','exis INSERT INTO project_sessions VALUES ('existing-session','fixture','00000000-0000-0000-0000-000000000001',now()+interval '1 hour'); SQL -psql < db/002-platform.sql +psql <<'SQLROLE' +CREATE ROLE foundry_fixture LOGIN; +SQLROLE +python3 deploy/migrate.py --container "$container" --database postgres --role foundry_fixture +python3 deploy/migrate.py --container "$container" --database postgres --role foundry_fixture psql <<'SQL' DO $$ BEGIN @@ -60,5 +64,5 @@ CGO_ENABLED=0 go test -mod=readonly -c -o bin/platform-tests ./internal/platform docker run --rm --network "container:$container" --user 1000:1000 \ --read-only --cap-drop ALL --security-opt no-new-privileges \ --mount "type=bind,source=$(pwd)/bin/platform-tests,target=/opt/platform-tests,readonly" \ - -e PLATFORM_TEST_DATABASE_URL=postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable \ + -e PLATFORM_TEST_DATABASE_URL=postgres://foundry_fixture@127.0.0.1:5432/postgres?sslmode=disable \ --entrypoint /opt/platform-tests postgres:17-alpine -test.v diff --git a/deploy/migrate.py b/deploy/migrate.py new file mode 100644 index 0000000..7cc46ff --- /dev/null +++ b/deploy/migrate.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Apply checked migrations atomically from the trusted deployment runner.""" +import argparse +import hashlib +from pathlib import Path +import re +import subprocess + +parser = argparse.ArgumentParser() +parser.add_argument("--container", default="endoleap-dev-postgres-1") +parser.add_argument("--database", default="foundry_auth") +parser.add_argument("--role", default="foundry") +args = parser.parse_args() +for value in (args.database, args.role): + if not re.fullmatch(r"[a-z_][a-z0-9_]{0,62}", value): + raise SystemExit("Invalid deployment database or role") +migrations = sorted((Path(__file__).resolve().parent.parent / "db").glob("[0-9]*.sql")) +if not migrations: + raise SystemExit("No migrations found") + +script = [ + "\\set ON_ERROR_STOP on", + "BEGIN;", + "SET LOCAL lock_timeout='30s';", + "SELECT pg_advisory_xact_lock(731920164);", + "CREATE TABLE IF NOT EXISTS foundry_schema_migrations " + "(name text PRIMARY KEY, checksum text NOT NULL, applied_at timestamptz NOT NULL DEFAULT now());", +] +tables = set() +for index, path in enumerate(migrations): + source = path.read_text() + name = path.name + if not re.fullmatch(r"[0-9]+-[a-z0-9-]+\.sql", name): + raise SystemExit("Invalid migration name") + checksum = hashlib.sha256(source.encode()).hexdigest() + tables.update(re.findall(r"CREATE TABLE ([a-z_]+)", source)) + # The first deployed version predates migration tracking. + if index == 0: + script.extend([ + "SELECT bool_and(to_regclass('public.' || name) IS NOT NULL) AS baseline FROM " + "unnest(ARRAY['project_accounts','oauth_attempts','login_grants','project_sessions']) name \\gset", + "\\if :baseline", + f"INSERT INTO foundry_schema_migrations(name,checksum) VALUES('{name}','{checksum}') ON CONFLICT DO NOTHING;", + "\\endif", + ]) + script.extend([ + f"SELECT EXISTS(SELECT 1 FROM foundry_schema_migrations WHERE name='{name}') AS applied, " + f"EXISTS(SELECT 1 FROM foundry_schema_migrations WHERE name='{name}' AND checksum='{checksum}') AS valid \\gset", + "\\if :applied", + "\\if :valid", + "\\else", + "\\echo Migration checksum mismatch", + "\\quit 1", + "\\endif", + "\\else", + ]) + source = source.strip() + if not source.startswith("BEGIN;") or not source.endswith("COMMIT;"): + raise SystemExit("Migration must have explicit transaction boundaries") + script.append(source[len("BEGIN;"):-len("COMMIT;")]) + script.extend([ + f"INSERT INTO foundry_schema_migrations(name,checksum) VALUES('{name}','{checksum}');", + "\\endif", + ]) +for table in sorted(tables): + script.append(f'GRANT SELECT,INSERT,UPDATE,DELETE ON TABLE "{table}" TO "{args.role}";') +script.extend([ + f'GRANT USAGE,SELECT ON SEQUENCE link_clicks_id_seq TO "{args.role}";', + "COMMIT;", +]) +subprocess.run( + ["docker", "exec", "-i", args.container, "sh", "-c", + 'exec psql -X -U "${POSTGRES_USER:-postgres}" -d "$1" -v ON_ERROR_STOP=1', "sh", args.database], + input=("\n".join(script) + "\n").encode(), check=True, +) +print("Schema migrations and application grants verified.") diff --git a/deploy/release.sh b/deploy/release.sh index 3636858..d9ca918 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -40,6 +40,7 @@ export FOUNDRY_PROJECTS test -s "$foundry/database.env" docker network inspect "$network" >/dev/null +python3 "$(dirname "$0")/migrate.py" release=$(mktemp -d "$foundry/release.XXXXXXXX") install -m 755 "$1" "$release/foundry" -- 2.52.0 From 5c0ad30a7a057e2a73e011ab2768a23b2a22dcfc Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:49:41 +0800 Subject: [PATCH 17/25] Add Slack OpenID login with verified identities and callback replay protection --- .env.example | 4 + cmd/foundry/main.go | 6 +- db/002-platform.sql | 4 + deploy/check-schema.sh | 14 +- deploy/release.sh | 6 +- internal/auth/auth.go | 39 ++--- internal/auth/identity.go | 67 ++++++++ internal/auth/slack.go | 226 ++++++++++++++++++++++++++ internal/auth/slack_test.go | 194 ++++++++++++++++++++++ internal/platform/integration_test.go | 2 +- 10 files changed, 524 insertions(+), 38 deletions(-) create mode 100644 internal/auth/identity.go create mode 100644 internal/auth/slack.go create mode 100644 internal/auth/slack_test.go diff --git a/.env.example b/.env.example index c752c26..c6447fe 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,7 @@ SMTP_PORT=465 SMTP_USERNAME=noreply@notice.entroia.com SMTP_PASSWORD=replace-me SMTP_FROM=noreply@notice.entroia.com + +# Optional Sign in with Slack. +SLACK_CLIENT_ID=replace-me +SLACK_CLIENT_SECRET=replace-me diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go index 6b25a5e..7feed0e 100644 --- a/cmd/foundry/main.go +++ b/cmd/foundry/main.go @@ -52,7 +52,11 @@ func main() { log.Fatal(err) } go email.Run(context.Background(), db) - handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret, email) + slack, err := auth.SlackConfigFromEnv(origin.ResolveReference(&url.URL{Path: "/auth/slack/callback"}).String()) + if err != nil { + log.Fatal(err) + } + handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret, email, slack) if err != nil { log.Fatal(err) } diff --git a/db/002-platform.sql b/db/002-platform.sql index 3721b0d..87f9def 100644 --- a/db/002-platform.sql +++ b/db/002-platform.sql @@ -11,6 +11,10 @@ ALTER TABLE project_accounts CREATE UNIQUE INDEX project_account_username ON project_accounts(project, lower(username)) WHERE username IS NOT NULL; +ALTER TABLE oauth_attempts + ADD COLUMN provider text NOT NULL DEFAULT 'google' CHECK (provider IN ('google','slack')), + ADD COLUMN oidc_nonce text; + CREATE TABLE account_identities ( project text NOT NULL, provider text NOT NULL CHECK (provider IN ('google', 'slack')), diff --git a/deploy/check-schema.sh b/deploy/check-schema.sh index adec116..32756c5 100644 --- a/deploy/check-schema.sh +++ b/deploy/check-schema.sh @@ -60,9 +60,11 @@ END $$; ROLLBACK; SQL printf 'Migration preserved existing logins and enforced project isolation.\n' -CGO_ENABLED=0 go test -mod=readonly -c -o bin/platform-tests ./internal/platform -docker run --rm --network "container:$container" --user 1000:1000 \ - --read-only --cap-drop ALL --security-opt no-new-privileges \ - --mount "type=bind,source=$(pwd)/bin/platform-tests,target=/opt/platform-tests,readonly" \ - -e PLATFORM_TEST_DATABASE_URL=postgres://foundry_fixture@127.0.0.1:5432/postgres?sslmode=disable \ - --entrypoint /opt/platform-tests postgres:17-alpine -test.v +for package in platform auth; do + CGO_ENABLED=0 go test -mod=readonly -c -o "bin/$package-tests" "./internal/$package" + docker run --rm --network "container:$container" --user 1000:1000 \ + --read-only --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=bind,source=$(pwd)/bin/$package-tests,target=/opt/integration-tests,readonly" \ + -e PLATFORM_TEST_DATABASE_URL=postgres://foundry_fixture@127.0.0.1:5432/postgres?sslmode=disable \ + --entrypoint /opt/integration-tests postgres:17-alpine -test.v +done diff --git a/deploy/release.sh b/deploy/release.sh index d9ca918..39433be 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -49,6 +49,10 @@ mail_env=() if [[ -s "$foundry/mail.env" ]]; then mail_env=(--env-file "$foundry/mail.env") fi +slack_env=() +if [[ -s "$foundry/slack.env" ]]; then + slack_env=(--env-file "$foundry/slack.env") +fi codex_mounts=() codex_dir=$foundry/codex if [[ -d "$codex_dir" ]]; then @@ -69,7 +73,7 @@ if [[ -d "$codex_dir" ]]; then ) fi -docker run -d --name "$candidate" "${codex_mounts[@]}" "${mail_env[@]}" --network "$network" \ +docker run -d --name "$candidate" "${codex_mounts[@]}" "${mail_env[@]}" "${slack_env[@]}" --network "$network" \ --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \ -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS -e FOUNDRY_ONBOARDING \ --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e2d0428..e2e5137 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -30,14 +30,15 @@ type service struct { email *EmailConfig passwordSlots chan struct{} dummyPassword []byte + slack *SlackConfig } -func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string, email *EmailConfig) (http.Handler, error) { +func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string, email *EmailConfig, slack *SlackConfig) (http.Handler, error) { dummy, err := bcrypt.GenerateFromPassword([]byte("foundry-invalid-login-placeholder"), bcrypt.DefaultCost) if err != nil { return nil, err } - s := &service{db: db, email: email, passwordSlots: make(chan struct{}, 4), dummyPassword: dummy, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret, + s := &service{db: db, email: email, slack: slack, passwordSlots: make(chan struct{}, 4), dummyPassword: dummy, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret, google: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} for name, raw := range callbacks { u, err := url.Parse(raw) @@ -60,6 +61,7 @@ func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string reply(w, 200, payload{"status": "ready"}) return nil })) + mux.HandleFunc("/auth/slack/callback", s.wrap(s.slackCallback)) mux.HandleFunc("GET /auth/google/callback", s.wrap(s.callback)) mux.HandleFunc("/projects/{project}/auth/{action}", s.wrap(s.project)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -114,6 +116,9 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error { if frontend == nil { return reject(w, 404, "not_found") } + if r.Method == http.MethodGet && action == "slack" { + return s.startSlack(w, r, project) + } if r.Method == http.MethodGet && action == "google" { return s.start(w, r, project) } @@ -156,7 +161,7 @@ func (s *service) start(w http.ResponseWriter, r *http.Request, project string) } state, browser, verifier := token(), token(), token() _, err := s.db.ExecContext(r.Context(), ` - INSERT INTO oauth_attempts VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes') + INSERT INTO oauth_attempts(state_hash,browser_hash,project,frontend_challenge,google_verifier,expires_at) VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes') `, digest(state), digest(browser), project, challenge, verifier) if err != nil { return err @@ -211,7 +216,7 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error { var project, challenge, verifier string err = s.db.QueryRowContext(ctx, ` DELETE FROM oauth_attempts - WHERE state_hash = $1 AND browser_hash = $2 AND expires_at > now() + WHERE state_hash = $1 AND browser_hash = $2 AND expires_at > now() AND provider='google' RETURNING project, frontend_challenge, google_verifier `, digest(state), digest(browser.Value)).Scan(&project, &challenge, &verifier) if errors.Is(err, sql.ErrNoRows) { @@ -263,31 +268,7 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error { user.Email = nil } - grant := token() - _, err = s.db.ExecContext(ctx, ` - WITH identity AS ( - SELECT gen_random_uuid() AS id - ), account AS ( - INSERT INTO project_accounts (project, id, google_subject, email, username, display_name, avatar_url) - SELECT $1, id, $2, $3, - left(COALESCE(NULLIF(regexp_replace(lower($6), '[^a-z0-9]', '', 'g'), ''), 'user'),7) - || '_' || replace(id::text,'-',''), - $6, NULLIF($7,'') FROM identity - ON CONFLICT (project, google_subject) DO UPDATE SET - email = EXCLUDED.email, - username = COALESCE(project_accounts.username, EXCLUDED.username), - display_name = CASE WHEN project_accounts.display_name='' THEN EXCLUDED.display_name ELSE project_accounts.display_name END, - avatar_url = COALESCE(project_accounts.avatar_url, EXCLUDED.avatar_url), - updated_at = now() - RETURNING id - ), provider AS ( - INSERT INTO account_identities(project,provider,subject,account_id) - SELECT $1,'google',$2,id FROM account - ON CONFLICT (project,provider,subject) DO NOTHING - ) - INSERT INTO login_grants - SELECT $4, $1, id, $5, now() + interval '1 minute' FROM account - `, project, user.Subject, user.Email, digest(grant), challenge, user.Name, user.Picture) + grant, err := s.identityGrant(ctx, project, "google", user.Subject, user.Email, user.Name, user.Picture, challenge) if err != nil { return err } diff --git a/internal/auth/identity.go b/internal/auth/identity.go new file mode 100644 index 0000000..d4ef1e9 --- /dev/null +++ b/internal/auth/identity.go @@ -0,0 +1,67 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "net/url" + "strings" +) + +func (s *service) identityGrant(ctx context.Context, project, provider, subject string, email *string, name, picture, challenge string) (string, error) { + name = strings.TrimSpace(name) + if len(name) > 160 { + name = "" + } + if u, err := url.Parse(picture); err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || len(picture) > 2048 { + picture = "" + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return "", err + } + defer tx.Rollback() + // Serialize first registration for the same provider identity. + if _, err = tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", project+":"+provider+":"+subject); err != nil { + return "", err + } + var account string + err = tx.QueryRowContext(ctx, "SELECT account_id::text FROM account_identities WHERE project=$1 AND provider=$2 AND subject=$3", project, provider, subject).Scan(&account) + if errors.Is(err, sql.ErrNoRows) { + var googleSubject *string + if provider == "google" { + googleSubject = &subject + } + err = tx.QueryRowContext(ctx, ` +WITH identity AS(SELECT gen_random_uuid() AS id) +INSERT INTO project_accounts(project,id,google_subject,email,username,display_name,avatar_url) +SELECT $1,id,$2,$3, + left(COALESCE(NULLIF(regexp_replace(lower($4),'[^a-z0-9]','','g'),''),'user'),7)||'_'||replace(id::text,'-',''), + $4,NULLIF($5,'') FROM identity RETURNING id::text +`, project, googleSubject, email, name, picture).Scan(&account) + if err != nil { + return "", err + } + _, err = tx.ExecContext(ctx, "INSERT INTO account_identities(project,provider,subject,account_id) VALUES($1,$2,$3,$4)", project, provider, subject, account) + } else if err == nil { + _, err = tx.ExecContext(ctx, ` +UPDATE project_accounts SET email=COALESCE($3,email), + display_name=CASE WHEN display_name='' THEN $4 ELSE display_name END, + avatar_url=COALESCE(avatar_url,NULLIF($5,'')), + username=COALESCE(username,left(COALESCE(NULLIF(regexp_replace(lower($4),'[^a-z0-9]','','g'),''),'user'),7)||'_'||replace(id::text,'-','')), + updated_at=now() WHERE project=$1 AND id=$2 +`, project, account, email, name, picture) + } + if err != nil { + return "", err + } + grant := token() + _, err = tx.ExecContext(ctx, "INSERT INTO login_grants VALUES($1,$2,$3,$4,now()+interval '1 minute')", digest(grant), project, account, challenge) + if err != nil { + return "", err + } + if err = tx.Commit(); err != nil { + return "", err + } + return grant, nil +} diff --git a/internal/auth/slack.go b/internal/auth/slack.go new file mode 100644 index 0000000..1a94da6 --- /dev/null +++ b/internal/auth/slack.go @@ -0,0 +1,226 @@ +package auth + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "math/big" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" +) + +type SlackConfig struct { + ClientID, Secret, RedirectURI string + mu sync.Mutex + keys map[string]*rsa.PublicKey + fetchedAt time.Time +} + +func SlackConfigFromEnv(redirect string) (*SlackConfig, error) { + id, secret := os.Getenv("SLACK_CLIENT_ID"), os.Getenv("SLACK_CLIENT_SECRET") + if id == "" && secret == "" { + return nil, nil + } + if id == "" || secret == "" || strings.ContainsAny(id+secret, "\r\n") { + return nil, errors.New("Slack requires client ID and secret") + } + return &SlackConfig{ClientID: id, Secret: secret, RedirectURI: redirect}, nil +} + +type slackClaims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience string `json:"aud"` + Expires int64 `json:"exp"` + Issued int64 `json:"iat"` + Nonce string `json:"nonce"` + Team string `json:"https://slack.com/team_id"` + Email *string `json:"email"` + Verified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` +} + +func (s *service) slackKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + c := s.slack + c.mu.Lock() + defer c.mu.Unlock() + if time.Since(c.fetchedAt) < time.Minute { + if key := c.keys[kid]; key != nil { + return key, nil + } + return nil, errors.New("unknown Slack signing key") + } + var document struct { + Keys []struct { + Type string `json:"kty"` + ID string `json:"kid"` + Algorithm string `json:"alg"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` + } + } + if err := s.googleJSON(ctx, "GET", "https://slack.com/openid/connect/keys", nil, "", &document); err != nil { + return nil, err + } + keys := map[string]*rsa.PublicKey{} + for _, key := range document.Keys { + n, nErr := base64.RawURLEncoding.DecodeString(key.N) + e, eErr := base64.RawURLEncoding.DecodeString(key.E) + if key.Type != "RSA" || (key.Algorithm != "" && key.Algorithm != "RS256") || (key.Use != "" && key.Use != "sig") || nErr != nil || eErr != nil || len(e) > 4 { + continue + } + modulus := new(big.Int).SetBytes(n) + exponent := new(big.Int).SetBytes(e).Int64() + if modulus.BitLen() < 2048 || modulus.BitLen() > 8192 || exponent < 3 || exponent > 2147483647 || exponent%2 == 0 { + continue + } + keys[key.ID] = &rsa.PublicKey{N: modulus, E: int(exponent)} + } + c.keys, c.fetchedAt = keys, time.Now() + if key := keys[kid]; key != nil { + return key, nil + } + return nil, errors.New("unknown Slack signing key") +} +func (s *service) verifySlack(ctx context.Context, value, nonce string) (slackClaims, error) { + var claims slackClaims + invalid := errors.New("invalid Slack identity token") + if len(value) > 32768 { + return claims, invalid + } + parts := strings.Split(value, ".") + if len(parts) != 3 { + return claims, invalid + } + header, err := base64.RawURLEncoding.DecodeString(parts[0]) + var h struct { + Algorithm string `json:"alg"` + ID string `json:"kid"` + } + if err != nil || json.Unmarshal(header, &h) != nil || h.Algorithm != "RS256" || h.ID == "" { + return claims, invalid + } + key, err := s.slackKey(ctx, h.ID) + if err != nil { + return claims, err + } + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + sum := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if err != nil || rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], signature) != nil { + return claims, invalid + } + body, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || json.Unmarshal(body, &claims) != nil { + return claims, invalid + } + now := time.Now().Unix() + if claims.Issuer != "https://slack.com" || claims.Audience != s.slack.ClientID || claims.Expires <= now || + claims.Issued <= 0 || claims.Issued > now+60 || claims.Expires <= claims.Issued || claims.Nonce != nonce || + claims.Subject == "" || len(claims.Subject) > 128 || claims.Team == "" || len(claims.Team) > 128 || + strings.ContainsAny(claims.Subject+claims.Team, ":\r\n") { + return claims, invalid + } + if !claims.Verified { + claims.Email = nil + } + return claims, nil +} +func (s *service) startSlack(w http.ResponseWriter, r *http.Request, project string) error { + if s.slack == nil { + return reject(w, 503, "slack_not_configured") + } + challenge := r.URL.Query().Get("challenge") + if !tokenPattern.MatchString(challenge) { + return reject(w, 400, "invalid_challenge") + } + state, browser, nonce := token(), token(), token() + _, err := s.db.ExecContext(r.Context(), ` +INSERT INTO oauth_attempts(state_hash,browser_hash,project,frontend_challenge,google_verifier,expires_at,provider,oidc_nonce) +VALUES($1,$2,$3,$4,'',now()+interval '10 minutes','slack',$5) +`, digest(state), digest(browser), project, challenge, nonce) + if err != nil { + return err + } + cookie := oauthCookie(state, browser, 600) + cookie.SameSite = http.SameSiteNoneMode + http.SetCookie(w, cookie) + query := url.Values{"response_type": {"code"}, "scope": {"openid profile email"}, "client_id": {s.slack.ClientID}, "state": {state}, "nonce": {nonce}, "redirect_uri": {s.slack.RedirectURI}} + http.Redirect(w, r, "https://slack.com/openid/connect/authorize?"+query.Encode(), 302) + return nil +} +func (s *service) slackCallback(w http.ResponseWriter, r *http.Request) error { + if s.slack == nil { + return reject(w, 503, "slack_not_configured") + } + if r.Method != http.MethodGet && r.Method != http.MethodPost { + return reject(w, 405, "method_not_allowed") + } + r.Body = http.MaxBytesReader(w, r.Body, 16384) + if r.ParseForm() != nil { + return reject(w, 400, "invalid_callback") + } + state := r.Form.Get("state") + cookie, err := r.Cookie(oauthCookie(state, "", 0).Name) + if !tokenPattern.MatchString(state) || err != nil || !tokenPattern.MatchString(cookie.Value) { + return reject(w, 400, "invalid_state") + } + var project, challenge, nonce string + err = s.db.QueryRowContext(r.Context(), ` +DELETE FROM oauth_attempts WHERE state_hash=$1 AND browser_hash=$2 AND expires_at>now() AND provider='slack' +RETURNING project,frontend_challenge,oidc_nonce +`, digest(state), digest(cookie.Value)).Scan(&project, &challenge, &nonce) + if errors.Is(err, sql.ErrNoRows) { + return reject(w, 400, "invalid_state") + } + if err != nil { + return err + } + frontend := s.projects[project] + if frontend == nil { + return reject(w, 400, "invalid_state") + } + expired := oauthCookie(state, "", -1) + expired.SameSite = http.SameSiteNoneMode + http.SetCookie(w, expired) + destination := *frontend + if r.Form.Get("code") == "" || r.Form.Has("error") { + destination.Fragment = "error=slack_login_cancelled" + http.Redirect(w, r, destination.String(), 302) + return nil + } + var tokens struct { + OK bool `json:"ok"` + IDToken string `json:"id_token"` + } + err = s.googleJSON(r.Context(), "POST", "https://slack.com/api/openid.connect.token", url.Values{ + "code": {r.Form.Get("code")}, "client_id": {s.slack.ClientID}, "client_secret": {s.slack.Secret}, "redirect_uri": {s.slack.RedirectURI}, "grant_type": {"authorization_code"}, + }, "", &tokens) + if err != nil { + return err + } + if !tokens.OK || tokens.IDToken == "" { + return errors.New("Slack token exchange failed") + } + claims, err := s.verifySlack(r.Context(), tokens.IDToken, nonce) + if err != nil { + return err + } + grant, err := s.identityGrant(r.Context(), project, "slack", claims.Team+":"+claims.Subject, claims.Email, claims.Name, claims.Picture, challenge) + if err != nil { + return err + } + destination.Fragment = "code=" + grant + http.Redirect(w, r, destination.String(), 302) + return nil +} diff --git a/internal/auth/slack_test.go b/internal/auth/slack_test.go new file mode 100644 index 0000000..fe2885d --- /dev/null +++ b/internal/auth/slack_test.go @@ -0,0 +1,194 @@ +package auth + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func signIdentity(t *testing.T, key *rsa.PrivateKey, claims slackClaims) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"fixture"}`)) + body, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + value := header + "." + base64.RawURLEncoding.EncodeToString(body) + hash := sha256.Sum256([]byte(value)) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:]) + if err != nil { + t.Fatal(err) + } + return value + "." + base64.RawURLEncoding.EncodeToString(sig) +} +func TestSlackIdentityVerification(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + s := &service{slack: &SlackConfig{ClientID: "fixture", keys: map[string]*rsa.PublicKey{"fixture": &key.PublicKey}, fetchedAt: time.Now()}} + good := slackClaims{Issuer: "https://slack.com", Subject: "U123", Team: "T123", Audience: "fixture", Expires: time.Now().Add(time.Minute).Unix(), Issued: time.Now().Unix(), Nonce: "nonce"} + for _, name := range []string{"valid", "issuer", "audience", "expired", "nonce", "signature", "algorithm"} { + t.Run(name, func(t *testing.T) { + claims := good + switch name { + case "issuer": + claims.Issuer = "https://attacker.invalid" + case "audience": + claims.Audience = "other-client" + case "expired": + claims.Expires = time.Now().Add(-time.Minute).Unix() + case "nonce": + claims.Nonce = "wrong" + } + value := signIdentity(t, key, claims) + if name == "signature" { + parts := strings.Split(value, ".") + sig, _ := base64.RawURLEncoding.DecodeString(parts[2]) + sig[0] ^= 1 + parts[2] = base64.RawURLEncoding.EncodeToString(sig) + value = strings.Join(parts, ".") + } + if name == "algorithm" { + parts := strings.Split(value, ".") + parts[0] = base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","kid":"fixture"}`)) + value = strings.Join(parts, ".") + } + _, err := s.verifySlack(context.Background(), value, "nonce") + if (err == nil) != (name == "valid") { + t.Fatalf("unexpected verification result %v", err) + } + }) + } +} +func TestSlackCallbackAndExchange(t *testing.T) { + dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("requires isolated integration database") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + var value [12]byte + rand.Read(value[:]) + project := "slack-" + hex.EncodeToString(value[:]) + defer db.Exec("DELETE FROM project_accounts WHERE project=$1", project) + frontend, _ := url.Parse("https://app.example.com/auth/callback") + config := &SlackConfig{ClientID: "fixture", Secret: "fixture-secret", RedirectURI: "https://api.example.com/auth/slack/callback", keys: map[string]*rsa.PublicKey{"fixture": &key.PublicKey}, fetchedAt: time.Now()} + verifier := token() + var nonce string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Error("unexpected Slack request method") + } + _ = r.ParseForm() + if r.Form.Get("client_id") != "fixture" || r.Form.Get("client_secret") != "fixture-secret" { + t.Error("incorrect Slack client credentials") + } + claims := slackClaims{Issuer: "https://slack.com", Subject: "U123", Team: "T123", Audience: "fixture", Expires: time.Now().Add(time.Minute).Unix(), Issued: time.Now().Unix(), Nonce: nonce, Name: "Slack User"} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "id_token": signIdentity(t, key, claims)}) + })) + defer upstream.Close() + s := &service{db: db, projects: map[string]*url.URL{project: frontend}, slack: config, google: &http.Client{Transport: rewriteTransport{destination: upstream.URL}}} + start := httptest.NewRequest("GET", "https://api.example.com/projects/"+project+"/auth/slack?challenge="+digest(verifier), nil) + out := httptest.NewRecorder() + if err = s.startSlack(out, start, project); err != nil { + t.Fatal(err) + } + if out.Code != 302 { + t.Fatalf("start status %d", out.Code) + } + location, _ := url.Parse(out.Header().Get("Location")) + nonce = location.Query().Get("nonce") + state := location.Query().Get("state") + if nonce == "" || state == "" { + t.Fatal("missing state or nonce") + } + cookies := out.Result().Cookies() + if len(cookies) != 1 || cookies[0].SameSite != http.SameSiteNoneMode || !cookies[0].Secure || !cookies[0].HttpOnly { + t.Fatal("incorrect Slack browser binding cookie") + } + body := url.Values{"state": {state}, "code": {"fixture-code"}} + callback := func() *httptest.ResponseRecorder { + r := httptest.NewRequest("POST", config.RedirectURI, strings.NewReader(body.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(cookies[0]) + w := httptest.NewRecorder() + if err := s.slackCallback(w, r); err != nil { + t.Fatal(err) + } + return w + } + success := callback() + if success.Code != 302 { + t.Fatalf("callback status %d %s", success.Code, success.Body.String()) + } + destination, _ := url.Parse(success.Header().Get("Location")) + fragment, _ := url.ParseQuery(destination.Fragment) + grant := fragment.Get("code") + if !tokenPattern.MatchString(grant) { + t.Fatal("callback missing one-time exchange code") + } + if replay := callback(); replay.Code != 400 { + t.Fatal("Slack callback replay accepted") + } + request := httptest.NewRequest("POST", "https://api.example.com/projects/"+project+"/auth/exchange", nil) + request.Header.Set("X-Foundry-Code", grant) + request.Header.Set("X-Foundry-Verifier", verifier) + exchange := httptest.NewRecorder() + if err = s.exchange(exchange, request, project); err != nil { + t.Fatal(err) + } + if exchange.Code != 200 { + t.Fatalf("exchange status %d", exchange.Code) + } + var account, username, name string + err = db.QueryRow("SELECT a.id::text,a.username,a.display_name FROM project_accounts a JOIN account_identities i ON (i.project,i.account_id)=(a.project,a.id) WHERE a.project=$1 AND i.provider='slack' AND i.subject='T123:U123'", project).Scan(&account, &username, &name) + if err != nil || username == "" || name != "Slack User" { + t.Fatalf("Slack profile prefill failed %v", err) + } + _, err = db.Exec("UPDATE project_accounts SET username='chosen-name',display_name='Chosen name' WHERE project=$1 AND id=$2", project, account) + if err != nil { + t.Fatal(err) + } + if _, err = s.identityGrant(context.Background(), project, "slack", "T123:U123", nil, "New Slack Name", "", digest(verifier)); err != nil { + t.Fatal(err) + } + if err = db.QueryRow("SELECT username,display_name FROM project_accounts WHERE project=$1 AND id=$2", project, account).Scan(&username, &name); err != nil { + t.Fatal(err) + } + if username != "chosen-name" || name != "Chosen name" { + t.Fatal("provider login overwrote chosen profile") + } +} + +type rewriteTransport struct{ destination string } + +func (t rewriteTransport) RoundTrip(r *http.Request) (*http.Response, error) { + cloned := r.Clone(r.Context()) + target, _ := url.Parse(t.destination) + cloned.URL.Scheme, cloned.URL.Host = target.Scheme, target.Host + return http.DefaultTransport.RoundTrip(cloned) +} diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index 036a5a4..d05381d 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -59,7 +59,7 @@ func TestOnboardingAndSharing(t *testing.T) { }() callbacks := map[string]string{project: "https://app.example.com/auth/callback"} mux := http.NewServeMux() - authHandler, err := auth.New(db, callbacks, "https://api.example.com/auth/google/callback", "fixture", "fixture", &auth.EmailConfig{}) + authHandler, err := auth.New(db, callbacks, "https://api.example.com/auth/google/callback", "fixture", "fixture", &auth.EmailConfig{}, nil) if err != nil { t.Fatal(err) } -- 2.52.0 From fbe0910638914c990464d864977ce5ea7611aa07 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:51:56 +0800 Subject: [PATCH 18/25] Guard legacy Google accounts through provider login expansion --- internal/auth/slack_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/internal/auth/slack_test.go b/internal/auth/slack_test.go index fe2885d..938f604 100644 --- a/internal/auth/slack_test.go +++ b/internal/auth/slack_test.go @@ -192,3 +192,31 @@ func (t rewriteTransport) RoundTrip(r *http.Request) (*http.Response, error) { cloned.URL.Scheme, cloned.URL.Host = target.Scheme, target.Host return http.DefaultTransport.RoundTrip(cloned) } + +func TestGoogleIdentityKeepsLegacyAccount(t *testing.T) { + dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("requires isolated integration database") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + s := &service{db: db} + grant, err := s.identityGrant(context.Background(), "fixture", "google", "existing-google", nil, "Existing Google User", "", digest(token())) + if err != nil { + t.Fatal(err) + } + var account string + if err = db.QueryRow("SELECT account_id::text FROM login_grants WHERE project='fixture' AND token_hash=$1", digest(grant)).Scan(&account); err != nil { + t.Fatal(err) + } + if account != "00000000-0000-0000-0000-000000000001" { + t.Fatal("legacy Google identity changed account ID") + } + var count int + if err = db.QueryRow("SELECT count(*) FROM project_sessions WHERE project='fixture' AND token_hash='existing-session'").Scan(&count); err != nil || count != 1 { + t.Fatalf("existing Google session was lost %v", err) + } +} -- 2.52.0 From f69575d48ffebb8f134ac0f0d4ee68d0ad93e5fd Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:53:50 +0800 Subject: [PATCH 19/25] Serialize email verification and resend to preserve registration state --- internal/auth/email.go | 13 +++++++ internal/auth/email_test.go | 72 +++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 internal/auth/email_test.go diff --git a/internal/auth/email.go b/internal/auth/email.go index c69707a..c94749c 100644 --- a/internal/auth/email.go +++ b/internal/auth/email.go @@ -112,6 +112,9 @@ func (s *service) registerEmail(w http.ResponseWriter, r *http.Request, project, return err } defer tx.Rollback() + if _, err = tx.ExecContext(r.Context(), "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", "email:"+project+":"+email); err != nil { + return err + } verification := token() result, err := tx.ExecContext(r.Context(), ` INSERT INTO email_registrations(project,email,password_hash,token_hash,expires_at) @@ -167,6 +170,16 @@ func (s *service) verifyEmail(w http.ResponseWriter, r *http.Request, project st } defer tx.Rollback() var email, hash string + err = tx.QueryRowContext(r.Context(), "SELECT email FROM email_registrations WHERE project=$1 AND token_hash=$2 AND expires_at>now()", project, digest(input.Token)).Scan(&email) + if errors.Is(err, sql.ErrNoRows) { + return reject(w, 400, "invalid_verification") + } + if err != nil { + return err + } + if _, err = tx.ExecContext(r.Context(), "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", "email:"+project+":"+email); err != nil { + return err + } err = tx.QueryRowContext(r.Context(), ` DELETE FROM email_registrations WHERE project=$1 AND token_hash=$2 AND expires_at>now() RETURNING email,password_hash `, project, digest(input.Token)).Scan(&email, &hash) diff --git a/internal/auth/email_test.go b/internal/auth/email_test.go new file mode 100644 index 0000000..b2b3227 --- /dev/null +++ b/internal/auth/email_test.go @@ -0,0 +1,72 @@ +package auth + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "net/http/httptest" + "net/url" + "os" + "regexp" + "strings" + "testing" +) + +func TestConcurrentRegistrationAndVerification(t *testing.T) { + dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("requires isolated integration database") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var raw [12]byte + rand.Read(raw[:]) + project := "email-" + hex.EncodeToString(raw[:]) + frontend, _ := url.Parse("https://app.example.com/auth/callback") + s := &service{db: db, projects: map[string]*url.URL{project: frontend}} + defer func() { + _, _ = db.Exec("DELETE FROM project_accounts WHERE project=$1", project) + _, _ = db.Exec("DELETE FROM email_registrations WHERE project=$1", project) + _, _ = db.Exec("DELETE FROM email_outbox WHERE project=$1", project) + }() + request := httptest.NewRequest("POST", "https://api.example.com/register", nil) + out := httptest.NewRecorder() + if err = s.registerEmail(out, request, project, "fixture@example.invalid", "long-fixture-password"); err != nil { + t.Fatal(err) + } + var body string + if err = db.QueryRow("SELECT body FROM email_outbox WHERE project=$1", project).Scan(&body); err != nil { + t.Fatal(err) + } + match := regexp.MustCompile(`verify_email=([A-Za-z0-9_-]{43})`).FindStringSubmatch(body) + if len(match) != 2 { + t.Fatal("missing verification link") + } + done := make(chan error, 2) + go func() { + r := httptest.NewRequest("POST", "https://api.example.com/register", nil) + done <- s.registerEmail(httptest.NewRecorder(), r, project, "fixture@example.invalid", "another-fixture-password") + }() + go func() { + r := httptest.NewRequest("POST", "https://api.example.com/verify-email", strings.NewReader(`{"token":"`+match[1]+`"}`)) + done <- s.verifyEmail(httptest.NewRecorder(), r, project) + }() + for range 2 { + if err := <-done; err != nil { + t.Fatal(err) + } + } + var accounts, pending int + if err = db.QueryRow("SELECT count(*) FROM email_credentials WHERE project=$1", project).Scan(&accounts); err != nil { + t.Fatal(err) + } + if err = db.QueryRow("SELECT count(*) FROM email_registrations WHERE project=$1", project).Scan(&pending); err != nil { + t.Fatal(err) + } + if accounts+pending != 1 { + t.Fatalf("concurrent registration left accounts=%d pending=%d", accounts, pending) + } +} -- 2.52.0 From 9d5ab9e21527d0d8353d221fa1410dc49733f261 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:55:29 +0800 Subject: [PATCH 20/25] Fix shared OAuth exchange SQL by avoiding a reserved identifier --- internal/auth/auth.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e2e5137..f15c2c6 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -284,13 +284,13 @@ func (s *service) exchange(w http.ResponseWriter, r *http.Request, project strin } session := token() result, err := s.db.ExecContext(r.Context(), ` - WITH grant AS ( + WITH redeemed_grant AS ( DELETE FROM login_grants WHERE token_hash = $1 AND project = $2 AND frontend_challenge = $3 AND expires_at > now() RETURNING account_id ) INSERT INTO project_sessions - SELECT $4, $2, account_id, now() + interval '8 hours' FROM grant + SELECT $4, $2, account_id, now() + interval '8 hours' FROM redeemed_grant `, digest(code), project, digest(verifier), digest(session)) if err != nil { return err -- 2.52.0 From 5b9af931c7bc20746e3ec3da1fb43a8ba8ba309d Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 19:03:55 +0800 Subject: [PATCH 21/25] Wait for final PostgreSQL startup before migration checks --- deploy/check-schema.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/check-schema.sh b/deploy/check-schema.sh index 32756c5..69de8a7 100644 --- a/deploy/check-schema.sh +++ b/deploy/check-schema.sh @@ -8,7 +8,7 @@ trap cleanup EXIT docker run -d --name "$container" --network none \ -e POSTGRES_HOST_AUTH_METHOD=trust postgres:17-alpine >/dev/null for attempt in {1..30}; do - if docker exec "$container" pg_isready -U postgres >/dev/null 2>&1; then break; fi + if docker exec "$container" pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1; then break; fi sleep 1 done psql() { docker exec -i "$container" psql -X -U postgres -v ON_ERROR_STOP=1; } -- 2.52.0 From 6c69fcbaaa1adf67ddd92b4723a5999398f6e578 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 20:19:49 +0800 Subject: [PATCH 22/25] Inject Slack Actions secrets and verify live client credentials --- .gitea/workflows/check.yml | 47 +++++++++++++++++++++++++++++++++++++ .gitea/workflows/deploy.yml | 2 ++ deploy/release.sh | 12 +++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 588e2e8..7c005a3 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -17,6 +17,53 @@ jobs: test -z "$(gofmt -l cmd internal)" go test -mod=readonly ./... go build -mod=readonly -o bin/foundry ./cmd/foundry + - name: Verify configured Slack client credentials + if: gitea.event_name == 'push' + env: + SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }} + SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }} + run: | + python3 - <<'PYSLACK' + import json, os, secrets, sys, urllib.parse, urllib.request + client = os.environ.get("SLACK_CLIENT_ID", "") + secret = os.environ.get("SLACK_CLIENT_SECRET", "") + if not client and not secret: + print("Slack client verification skipped because credentials are not configured") + sys.exit(0) + if not client or not secret: + sys.exit("Both Slack client secrets must be configured") + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + opener = urllib.request.build_opener(NoRedirect) + code = "foundry-invalid-preflight-" + secrets.token_hex(16) + def probe(password): + body = urllib.parse.urlencode({ + "client_id": client, "client_secret": password, + "code": code, "grant_type": "authorization_code", + "redirect_uri": "https://api.foundry.entroia.com/auth/slack/callback" + }).encode() + request = urllib.request.Request( + "https://slack.com/api/openid.connect.token", data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}) + with opener.open(request, timeout=20) as response: + result = json.load(response) + return result.get("error", "") + try: + actual = probe(secret) + control = probe(secrets.token_hex(32)) + except Exception as error: + sys.exit("Slack preflight request failed (" + type(error).__name__ + ")") + if actual in ("invalid_code", "invalid_grant") and control in ("bad_client_secret", "invalid_client"): + print("Slack client credentials accepted; invalid-secret negative control rejected") + print("A real user authorization is still required to verify the complete login") + else: + known = {"invalid_client_id", "bad_client_secret", "invalid_client", "invalid_code", + "invalid_grant", "bad_redirect_uri", "accesslimited", "ratelimited"} + print("Configured credential result:", actual if actual in known else "unclassified") + print("Negative control result:", control if control in known else "unclassified") + sys.exit("Slack client preflight could not verify the configured credentials") + PYSLACK - name: Verify schema migration in an isolated database run: bash deploy/check-schema.sh - name: Smoke test HTTP routes diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index f2d5c54..928e4f3 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -36,6 +36,8 @@ jobs: env: GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }} + SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }} FOUNDRY_PROJECTS: ${{ vars.FOUNDRY_PROJECTS }} FOUNDRY_ONBOARDING: ${{ vars.FOUNDRY_ONBOARDING }} run: bash deploy/release.sh bin/foundry diff --git a/deploy/release.sh b/deploy/release.sh index 39433be..22f6be7 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -50,7 +50,17 @@ if [[ -s "$foundry/mail.env" ]]; then mail_env=(--env-file "$foundry/mail.env") fi slack_env=() -if [[ -s "$foundry/slack.env" ]]; then +if [[ -n "${SLACK_CLIENT_ID:-}" || -n "${SLACK_CLIENT_SECRET:-}" ]]; then + for name in SLACK_CLIENT_ID SLACK_CLIENT_SECRET; do + value=${!name-} + if [[ -z "$value" || "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + printf 'Missing or invalid %s\n' "$name" >&2 + exit 1 + fi + done + export SLACK_CLIENT_ID SLACK_CLIENT_SECRET + slack_env=(-e SLACK_CLIENT_ID -e SLACK_CLIENT_SECRET) +elif [[ -s "$foundry/slack.env" ]]; then slack_env=(--env-file "$foundry/slack.env") fi codex_mounts=() -- 2.52.0 From 155ce28c3705b50c6855715f63df1d53f1e72e08 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 20:22:51 +0800 Subject: [PATCH 23/25] Report inconclusive Slack placeholder-code checks accurately --- .gitea/workflows/check.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 7c005a3..f3ad8e9 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -17,7 +17,7 @@ jobs: test -z "$(gofmt -l cmd internal)" go test -mod=readonly ./... go build -mod=readonly -o bin/foundry ./cmd/foundry - - name: Verify configured Slack client credentials + - name: Check Slack OAuth configuration and endpoint if: gitea.event_name == 'push' env: SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }} @@ -57,6 +57,10 @@ jobs: if actual in ("invalid_code", "invalid_grant") and control in ("bad_client_secret", "invalid_client"): print("Slack client credentials accepted; invalid-secret negative control rejected") print("A real user authorization is still required to verify the complete login") + elif actual in ("invalid_code", "invalid_grant") and control in ("invalid_code", "invalid_grant"): + print("Both Slack Actions secrets are available and the OAuth endpoint is reachable") + print("Slack rejected the placeholder code before validating the client secret") + print("Client-secret validity requires a real user authorization; this is not a complete login test") else: known = {"invalid_client_id", "bad_client_secret", "invalid_client", "invalid_code", "invalid_grant", "bad_redirect_uri", "accesslimited", "ratelimited"} -- 2.52.0 From b1e0d783e6466eddbb26dc5bf621fc7d7cca7eb8 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 21:39:11 +0800 Subject: [PATCH 24/25] Mount system CA roots for OAuth HTTPS requests --- README.md | 11 +++++++++++ deploy/release.sh | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/README.md b/README.md index f797b83..5efc091 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,17 @@ Google OAuth 的授权回调地址为 `https://api.foundry.entroia.com/auth/goog 用同一个 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 diff --git a/deploy/release.sh b/deploy/release.sh index 22f6be7..cace2aa 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -39,6 +39,10 @@ PYCONFIG export FOUNDRY_PROJECTS test -s "$foundry/database.env" +# Go's HTTPS client uses the OS trust store, not Node's bundled roots. +# The slim Node runtime has no system CA bundle of its own. +ca_bundle=/etc/ssl/certs/ca-certificates.crt +test -s "$ca_bundle" docker network inspect "$network" >/dev/null python3 "$(dirname "$0")/migrate.py" @@ -88,6 +92,8 @@ docker run -d --name "$candidate" "${codex_mounts[@]}" "${mail_env[@]}" "${slack -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS -e FOUNDRY_ONBOARDING \ --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ --read-only --memory 128m \ + --mount "type=bind,source=$ca_bundle,target=/etc/ssl/certs/ca-certificates.crt,readonly" \ + -e SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ --mount "type=bind,source=$release/foundry,target=/opt/foundry,readonly" \ node:24-bookworm-slim /opt/foundry >/dev/null @@ -98,7 +104,9 @@ trap cleanup_candidate EXIT docker exec "$candidate" node -e ' const assert = require("node:assert/strict"); + const fs = require("node:fs"); async function verify() { + assert.ok(fs.readFileSync(process.env.SSL_CERT_FILE, "utf8").includes("-----BEGIN CERTIFICATE-----")); const base = "http://127.0.0.1:8080"; for (let attempt = 0; ; attempt++) { try { -- 2.52.0 From 6f36ef27bbaf50d2c502863f4bff781f1a773d66 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 6 Nov 2026 02:47:27 +0800 Subject: [PATCH 25/25] Add versioned board canvas persistence and stable SEO slugs --- README.md | 32 +++ db/003-canvas.sql | 29 +++ internal/platform/canvas.go | 299 ++++++++++++++++++++++++++ internal/platform/canvas_test.go | 41 ++++ internal/platform/http.go | 15 ++ internal/platform/integration_test.go | 84 +++++++- internal/platform/materials.go | 24 ++- internal/platform/slug.go | 12 ++ internal/platform/workspaces.go | 38 ++-- 9 files changed, 548 insertions(+), 26 deletions(-) create mode 100644 db/003-canvas.sql create mode 100644 internal/platform/canvas.go create mode 100644 internal/platform/canvas_test.go create mode 100644 internal/platform/slug.go diff --git a/README.md b/README.md index 5efc091..8f6a90d 100644 --- a/README.md +++ b/README.md @@ -72,3 +72,35 @@ 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: }` +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. diff --git a/db/003-canvas.sql b/db/003-canvas.sql new file mode 100644 index 0000000..73a3d1d --- /dev/null +++ b/db/003-canvas.sql @@ -0,0 +1,29 @@ +BEGIN; +ALTER TABLE materials ADD COLUMN canvas_version bigint NOT NULL DEFAULT 1 CHECK (canvas_version > 0); +ALTER TABLE materials ADD COLUMN canvas_document jsonb; +ALTER TABLE materials ADD CONSTRAINT canvas_board_only CHECK (canvas_document IS NULL OR kind='board'); +ALTER TABLE workspaces ADD COLUMN slug text; +UPDATE workspaces SET slug=COALESCE(NULLIF(trim(both '-' from left(trim(both '-' from regexp_replace(lower(name),'[^a-z0-9]+','-','g')),50)),''),'workspace') || '-' || id::text; +ALTER TABLE workspaces ALTER COLUMN slug SET NOT NULL; + +ALTER TABLE workspaces ADD CONSTRAINT workspace_slug_unique UNIQUE(project,slug); +ALTER TABLE workspaces ADD CONSTRAINT workspace_slug_valid CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND length(slug)<=100); +ALTER TABLE materials ADD COLUMN slug text; +UPDATE materials SET slug=COALESCE(NULLIF(trim(both '-' from left(trim(both '-' from regexp_replace(lower(title),'[^a-z0-9]+','-','g')),50)),''),'canvas') || '-' || id::text; +ALTER TABLE materials ALTER COLUMN slug SET NOT NULL; + +ALTER TABLE materials ADD CONSTRAINT material_slug_unique UNIQUE(project,workspace_id,slug); +ALTER TABLE materials ADD CONSTRAINT material_slug_valid CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND length(slug)<=100); +CREATE FUNCTION foundry_default_slug() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE label text; +BEGIN + IF NEW.slug IS NULL THEN + IF TG_TABLE_NAME='workspaces' THEN label=NEW.name; ELSE label=NEW.title; END IF; + label=left(trim(both '-' from regexp_replace(lower(label),'[^a-z0-9]+','-','g')),50); + NEW.slug=COALESCE(NULLIF(trim(both '-' from label),''),'canvas') || '-' || NEW.id::text; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER workspace_default_slug BEFORE INSERT ON workspaces FOR EACH ROW EXECUTE FUNCTION foundry_default_slug(); +CREATE TRIGGER material_default_slug BEFORE INSERT ON materials FOR EACH ROW EXECUTE FUNCTION foundry_default_slug(); +COMMIT; diff --git a/internal/platform/canvas.go b/internal/platform/canvas.go new file mode 100644 index 0000000..a0e02a4 --- /dev/null +++ b/internal/platform/canvas.go @@ -0,0 +1,299 @@ +package platform + +import ( + "encoding/base64" + "encoding/json" + "io" + "math" + "net/http" + "net/url" + "regexp" + "strings" + "unicode/utf8" +) + +const canvasLimit = 5 << 20 + +var canvasColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) +var canvasID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) +var canvasIcon = regexp.MustCompile(`^[A-Z][A-Za-z0-9]{0,63}$`) + +func canvasNumber(v any, low, high float64) bool { + n, ok := v.(float64) + return ok && !math.IsNaN(n) && !math.IsInf(n, 0) && n >= low && n <= high +} +func canvasString(v any, limit int) bool { + s, ok := v.(string) + return ok && utf8.RuneCountInString(s) <= limit +} +func canvasChoice(v any, choices ...string) bool { + for _, s := range choices { + if v == s { + return true + } + } + return false +} +func canvasFields(m map[string]any, fields ...string) bool { + if m == nil { + return false + } + for k := range m { + found := false + for _, f := range fields { + if k == f { + found = true + break + } + } + if !found { + return false + } + } + return true +} +func canvasImage(v any) bool { + s, ok := v.(string) + if !ok { + return false + } + if strings.HasPrefix(s, "https://") { + u, e := url.Parse(s) + return e == nil && u.Hostname() != "" && u.User == nil && len(s) < 2048 + } + for _, mime := range []string{"png", "jpeg", "webp", "gif"} { + prefix := "data:image/" + mime + ";base64," + if strings.HasPrefix(s, prefix) { + b, e := base64.StdEncoding.DecodeString(strings.TrimPrefix(s, prefix)) + return e == nil && len(b) > 0 && len(b) <= 2<<20 && http.DetectContentType(b) == "image/"+mime + } + } + return false +} +func normalizeCanvas(raw json.RawMessage) (json.RawMessage, bool) { + var doc map[string]any + if json.Unmarshal(raw, &doc) != nil || !canvasFields(doc, "schema_version", "nodes", "edges", "viewport") || doc["schema_version"] != float64(1) { + return nil, false + } + nodes, ok := doc["nodes"].([]any) + if !ok || len(nodes) > 500 { + return nil, false + } + edges, ok := doc["edges"].([]any) + if !ok || len(edges) > 2000 { + return nil, false + } + viewport, ok := doc["viewport"].(map[string]any) + if !ok || !canvasFields(viewport, "x", "y", "zoom") || !canvasNumber(viewport["x"], -math.MaxFloat64, math.MaxFloat64) || !canvasNumber(viewport["y"], -math.MaxFloat64, math.MaxFloat64) || !canvasNumber(viewport["zoom"], .1, 4) { + return nil, false + } + kinds := map[string]string{} + for _, v := range nodes { + n, ok := v.(map[string]any) + if !ok { + return nil, false + } + if style, ok := n["style"].(map[string]any); ok { + if _, exists := n["width"]; !exists { + if v, exists := style["width"]; exists { + n["width"] = v + } + } + if _, exists := n["height"]; !exists { + if v, exists := style["height"]; exists { + n["height"] = v + } + } + } + for _, key := range []string{"selected", "dragging", "measured", "style"} { + delete(n, key) + } + if !canvasFields(n, "id", "type", "position", "width", "height", "data") { + return nil, false + } + id, ok := n["id"].(string) + if !ok || !canvasID.MatchString(id) || kinds[id] != "" { + return nil, false + } + p, ok := n["position"].(map[string]any) + if !ok || !canvasFields(p, "x", "y") || !canvasNumber(p["x"], -1e6, 1e6) || !canvasNumber(p["y"], -1e6, 1e6) { + return nil, false + } + _, w := n["width"] + _, h := n["height"] + if w != h || (w && (!canvasNumber(n["width"], 20, 3000) || !canvasNumber(n["height"], 20, 3000))) { + return nil, false + } + d, ok := n["data"].(map[string]any) + if !ok || !canvasFields(d, "kind", "title", "text", "src", "icon", "rotation", "fontSize", "textStyle") { + return nil, false + } + kind, ok := d["kind"].(string) + if !ok { + return nil, false + } + typ := "material" + if kind == "plain-text" { + typ = "plainText" + } else if kind == "icon" { + typ = "icon" + } else if !canvasChoice(kind, "text", "image", "script") { + return nil, false + } + if n["type"] != typ || !canvasString(d["title"], 300) || !canvasString(d["text"], 100000) { + return nil, false + } + kinds[id] = kind + if r, exists := d["rotation"]; exists && (!canvasNumber(r, 0, 360) || r == float64(360)) { + return nil, false + } + if f, exists := d["fontSize"]; exists && !canvasNumber(f, 8, 160) { + return nil, false + } + if kind == "icon" { + s, ok := d["icon"].(string) + if !ok || !canvasIcon.MatchString(s) { + return nil, false + } + } + if src, exists := d["src"]; (exists || kind == "image") && !canvasImage(src) { + return nil, false + } + if value, exists := d["textStyle"]; exists { + style, ok := value.(map[string]any) + if !ok || !canvasFields(style, "color", "background", "bold", "italic", "underline", "align", "fontFamily", "lineHeight") { + return nil, false + } + for k, v := range style { + switch k { + case "color", "background": + s, ok := v.(string) + if !ok || !(canvasColor.MatchString(s) || (k == "background" && s == "transparent")) { + return nil, false + } + case "bold", "italic", "underline": + if _, ok := v.(bool); !ok { + return nil, false + } + case "align": + if !canvasChoice(v, "left", "center", "right") { + return nil, false + } + case "fontFamily": + if !canvasChoice(v, "sans", "serif", "mono") { + return nil, false + } + case "lineHeight": + if !canvasNumber(v, 1, 3) { + return nil, false + } + } + } + } + } + seen := map[string]bool{} + for _, v := range edges { + e, ok := v.(map[string]any) + if !ok { + return nil, false + } + delete(e, "selected") + if !canvasFields(e, "id", "source", "target", "sourceHandle", "targetHandle", "type") { + return nil, false + } + id, ok := e["id"].(string) + if !ok || !canvasID.MatchString(id) || seen[id] { + return nil, false + } + seen[id] = true + for _, key := range []string{"source", "target"} { + s, ok := e[key].(string) + if !ok || !canvasChoice(kinds[s], "text", "image", "script") { + return nil, false + } + } + for _, key := range []string{"sourceHandle", "targetHandle"} { + if h, exists := e[key]; exists && h != nil && !canvasChoice(h, "", "top", "right", "bottom", "left") { + return nil, false + } + } + if typ, exists := e["type"]; exists && !canvasChoice(typ, "default", "bezier") { + return nil, false + } + } + out, err := json.Marshal(doc) + return out, err == nil +} + +func (s *service) canvas(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet && r.Method != http.MethodPatch { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + workspace, material := r.PathValue("workspace"), r.PathValue("material") + kind, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if kind != "board" { + reject(w, 400, "invalid_material") + return nil + } + var version int64 + var document json.RawMessage + err = tx.QueryRowContext(r.Context(), `SELECT canvas_version,COALESCE(canvas_document,'{"schema_version":1,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}'::jsonb) FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, material).Scan(&version, &document) + if err != nil { + return err + } + if r.Method == http.MethodGet { + setVersion(w, version) + reply(w, 200, response{"version": version, "document": document}) + return nil + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + expected, ok := expectedVersion(w, r) + if !ok { + return nil + } + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, canvasLimit)) + if err != nil { + reject(w, 413, "canvas_too_large") + return nil + } + var input struct { + Document json.RawMessage `json:"document"` + } + decoder := json.NewDecoder(strings.NewReader(string(body))) + decoder.DisallowUnknownFields() + if !utf8.Valid(body) || decoder.Decode(&input) != nil || decoder.Decode(new(any)) != io.EOF { + reject(w, 400, "invalid_canvas") + return nil + } + document, ok = normalizeCanvas(input.Document) + if !ok { + reject(w, 400, "invalid_canvas") + return nil + } + if expected != version { + reject(w, 412, "version_conflict") + return nil + } + _, err = tx.ExecContext(r.Context(), `UPDATE materials SET canvas_document=$4,canvas_version=canvas_version+1 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, material, document) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + setVersion(w, version+1) + reply(w, 200, response{"version": version + 1}) + return nil +} diff --git a/internal/platform/canvas_test.go b/internal/platform/canvas_test.go new file mode 100644 index 0000000..7cba1d9 --- /dev/null +++ b/internal/platform/canvas_test.go @@ -0,0 +1,41 @@ +package platform + +import ( + "encoding/json" + "strings" + "testing" +) + +const canvasFixture = `{"schema_version":1,"nodes":[{"id":"a","type":"material","position":{"x":100,"y":200},"width":320,"height":220,"selected":true,"data":{"kind":"text","title":"灵感笔记","text":"正文","rotation":30,"fontSize":24,"textStyle":{"color":"#37352f","background":"transparent","bold":true,"italic":false,"underline":false,"align":"left","fontFamily":"sans","lineHeight":1.5}}},{"id":"b","type":"material","position":{"x":0,"y":0},"data":{"kind":"script","title":"Script","text":""}},{"id":"icon","type":"icon","position":{"x":500,"y":200},"width":80,"height":80,"data":{"kind":"icon","title":"火箭","text":"","icon":"Rocket","rotation":0}}],"edges":[{"id":"e","source":"a","target":"b","sourceHandle":"top","targetHandle":"bottom"}],"viewport":{"x":0,"y":0,"zoom":1}}` + +func TestCanvasValidation(t *testing.T) { + out, ok := normalizeCanvas(json.RawMessage(canvasFixture)) + if !ok || strings.Contains(string(out), "selected") { + t.Fatal("valid snapshot rejected or UI state retained") + } + cases := [][2]string{{`"rotation":30`, `"rotation":360`}, {`"width":320`, `"width":19`}, {`"color":"#37352f"`, `"color":"red"`}, {`"bold":true`, `"bold":"true"`}, {`"target":"b"`, `"target":"missing"`}, {`"target":"b"`, `"target":"icon"`}, {`"zoom":1`, `"zoom":0`}, {`"sourceHandle":"top"`, `"sourceHandle":"unknown"`}, {`"fontSize":24`, `"fontSize":161`}, {`"lineHeight":1.5`, `"lineHeight":4`}, {`"id":"b"`, `"id":"a"`}, {`"schema_version":1`, `"schema_version":2`}} + for _, c := range cases { + t.Run(c[1], func(t *testing.T) { + if _, ok := normalizeCanvas(json.RawMessage(strings.Replace(canvasFixture, c[0], c[1], 1))); ok { + t.Fatal("accepted invalid snapshot") + } + }) + } + for _, bad := range []string{`null`, `{}`, `{"schema_version":1,"nodes":null,"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`} { + if _, ok := normalizeCanvas(json.RawMessage(bad)); ok { + t.Fatal("accepted missing document fields") + } + } +} +func TestSlugValidation(t *testing.T) { + for _, v := range []string{"my-board", "workspace-2026"} { + if !validSlug(&v) { + t.Fatal(v) + } + } + for _, v := range []string{"", "Bad Slug", "../board", strings.Repeat("a", 101)} { + if validSlug(&v) { + t.Fatal(v) + } + } +} diff --git a/internal/platform/http.go b/internal/platform/http.go index f9872c7..87e9678 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -82,6 +82,20 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H reject(w, 401, "invalid_session") return } + if err == nil && r.PathValue("workspace") != "" { + var id string + err = s.db.QueryRowContext(r.Context(), "SELECT id::text FROM workspaces WHERE project=$1 AND (id::text=$2 OR slug=$2)", project, r.PathValue("workspace")).Scan(&id) + if err == nil { + r.SetPathValue("workspace", id) + } + } + if err == nil && r.PathValue("material") != "" { + var id string + err = s.db.QueryRowContext(r.Context(), "SELECT id::text FROM materials WHERE project=$1 AND workspace_id=$2 AND (id::text=$3 OR slug=$3)", project, r.PathValue("workspace"), r.PathValue("material")).Scan(&id) + if err == nil { + r.SetPathValue("material", id) + } + } if err == nil { err = handle(w, r, project, account) } @@ -106,6 +120,7 @@ func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origi s := &service{db: db, origin: origin, callbacks: callbacks, onboardingConfigs: configs} mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials", s.protected(callbacks, s.materials)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}", s.protected(callbacks, s.material)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas", s.protected(callbacks, s.canvas)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/open", s.protected(callbacks, s.openMaterial)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/tags", s.protected(callbacks, s.tags)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/tags/{tag}", s.protected(callbacks, s.deleteTag)) diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index d05381d..5292294 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -14,6 +14,7 @@ import ( "net/http/httptest" "os" "regexp" + "strings" "testing" _ "github.com/jackc/pgx/v5/stdlib" @@ -154,7 +155,86 @@ func TestOnboardingAndSharing(t *testing.T) { } board := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Board", "content": response{}}, 201) boardPath := workspacePath + "/materials/" + board["id"].(string) - layout := call("POST", boardPath+"/layouts", tokens[0], response{"material_id": articleID, "x": 1, "y": 2, "width": 320, "height": 200, "z_index": 1}, 201, `"1"`) + + canvasPath := boardPath + "/canvas" + empty := call("GET", canvasPath, tokens[0], nil, 200) + if empty["version"] != float64(1) { + t.Fatal("old board not initialized at version 1") + } + snapshot := response{"document": json.RawMessage(canvasFixture)} + call("PATCH", canvasPath, "", snapshot, 401, `"1"`) + call("PATCH", canvasPath, tokens[1], snapshot, 403, `"1"`) + call("PATCH", canvasPath, tokens[2], snapshot, 404, `"1"`) + call("PATCH", canvasPath, tokens[0], snapshot, 428) + call("PATCH", canvasPath, tokens[0], snapshot, 200, `"1"`) + call("PATCH", canvasPath, tokens[0], snapshot, 412, `"1"`) + saved := call("GET", canvasPath, tokens[0], nil, 200) + expected, _ := normalizeCanvas(json.RawMessage(canvasFixture)) + expectedJSON, _ := json.Marshal(json.RawMessage(expected)) + actualJSON, _ := json.Marshal(saved["document"]) + if !bytes.Equal(expectedJSON, actualJSON) { + t.Fatal("snapshot failed round trip") + } + call("PATCH", canvasPath, tokens[0], response{"document": response{}}, 400, `"2"`) + call("PATCH", boardPath, tokens[0], response{"title": "Renamed", "slug": "my-canvas", "content": response{}}, 204, `"1"`) + if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(2) { + t.Fatal("rename changed canvas version") + } + slugPath := workspacePath + "/materials/my-canvas" + if call("GET", slugPath, tokens[0], nil, 200)["title"] != "Renamed" { + t.Fatal("slug lookup failed") + } + // A PNG at the local upload limit must survive JSON and database storage. + var imageDoc map[string]any + json.Unmarshal([]byte(canvasFixture), &imageDoc) + imageBytes := make([]byte, 2<<20) + copy(imageBytes, []byte{137, 80, 78, 71, 13, 10, 26, 10}) + data := imageDoc["nodes"].([]any)[0].(map[string]any)["data"].(map[string]any) + data["kind"] = "image" + data["src"] = "data:image/png;base64," + base64.StdEncoding.EncodeToString(imageBytes) + call("PATCH", canvasPath, tokens[0], response{"document": imageDoc}, 200, `"2"`) + restored := call("GET", canvasPath, tokens[0], nil, 200) + if restored["document"].(map[string]any)["nodes"].([]any)[0].(map[string]any)["data"].(map[string]any)["src"] != data["src"] { + t.Fatal("large image changed") + } + call("PATCH", canvasPath, tokens[0], response{"document": strings.Repeat("x", canvasLimit)}, 413, `"3"`) + if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(3) { + t.Fatal("rejected snapshot changed version") + } + // Restore the material version expected by the pre-existing layout checks below. + + // Exercise two independent HTTP transactions against the same expected version. + encodedSnapshot, _ := json.Marshal(snapshot) + statuses := make(chan int, 2) + start := make(chan struct{}) + for range 2 { + go func() { + <-start + request := httptest.NewRequest("PATCH", canvasPath, bytes.NewReader(encodedSnapshot)) + request.Header.Set("Authorization", "Bearer "+tokens[0]) + request.Header.Set("If-Match", `"3"`) + recorder := httptest.NewRecorder() + mux.ServeHTTP(recorder, request) + statuses <- recorder.Code + }() + } + close(start) + one, two := <-statuses, <-statuses + if !((one == 200 && two == 412) || (one == 412 && two == 200)) { + t.Fatalf("concurrent writes: %d %d", one, two) + } + if call("GET", boardPath, tokens[0], nil, 200)["title"] != "Renamed" { + t.Fatal("canvas save changed title") + } + call("PATCH", workspacePath, tokens[0], response{"name": "Team", "slug": "research-team"}, 204) + call("GET", base+"/workspaces/research-team/materials/my-canvas/canvas", tokens[0], nil, 200) + call("PATCH", workspacePath, tokens[1], response{"name": "Team", "slug": "stolen"}, 403) + call("POST", base+"/workspaces", tokens[0], response{"name": "Duplicate", "slug": "research-team"}, 409) + call("PATCH", workspacePath, tokens[0], response{"name": "Renamed Team"}, 204) + if call("GET", workspacePath, tokens[0], nil, 200)["slug"] != "research-team" { + t.Fatal("rename changed slug") + } + layout := call("POST", boardPath+"/layouts", tokens[0], response{"material_id": articleID, "x": 1, "y": 2, "width": 320, "height": 200, "z_index": 1}, 201, `"2"`) call("GET", boardPath+"/layouts", tokens[1], nil, 200) call("POST", boardPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204) hidden := call("GET", boardPath+"/layouts", tokens[2], nil, 200) @@ -162,7 +242,7 @@ func TestOnboardingAndSharing(t *testing.T) { t.Fatal("board share disclosed private child material") } call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 412, `"1"`) - call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 204, `"2"`) + call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 204, `"3"`) call("DELETE", textPath+"/grants/"+accounts[2], tokens[0], nil, 204) call("GET", textPath, tokens[2], nil, 404) call("DELETE", workspacePath+"/tags/"+tag["id"].(string), tokens[0], nil, 204) diff --git a/internal/platform/materials.go b/internal/platform/materials.go index 2d03c0b..bd41eda 100644 --- a/internal/platform/materials.go +++ b/internal/platform/materials.go @@ -59,6 +59,7 @@ func (s *service) materials(w http.ResponseWriter, r *http.Request, project, acc } var input struct { Kind string `json:"kind"` + Slug *string `json:"slug,omitempty"` Title string `json:"title"` Description string `json:"description"` Content json.RawMessage `json:"content"` @@ -67,7 +68,7 @@ func (s *service) materials(w http.ResponseWriter, r *http.Request, project, acc return nil } content, ok := normalizeContent(input.Kind, input.Content) - if !ok || len(input.Title) > 300 || len(input.Description) > 10000 { + if !ok || !validSlug(input.Slug) || len(input.Title) > 300 || len(input.Description) > 10000 { reject(w, 400, "invalid_material") return nil } @@ -85,11 +86,11 @@ func (s *service) materials(w http.ResponseWriter, r *http.Request, project, acc reject(w, 403, "forbidden") return nil } - var id string + var id, slug string err = tx.QueryRowContext(r.Context(), ` -INSERT INTO materials(project,workspace_id,kind,title,description,content,created_by) -VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id::text -`, project, workspace, input.Kind, input.Title, input.Description, content, account).Scan(&id) +INSERT INTO materials(project,workspace_id,kind,title,description,content,created_by,slug) +VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id::text,slug +`, project, workspace, input.Kind, input.Title, input.Description, content, account, input.Slug).Scan(&id, &slug) if err != nil { return err } @@ -97,7 +98,7 @@ VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id::text return err } setVersion(w, 1) - reply(w, 201, response{"id": id, "workspace_id": workspace, "kind": input.Kind, "version": 1}) + reply(w, 201, response{"id": id, "workspace_id": workspace, "kind": input.Kind, "version": 1, "slug": slug}) return nil } @@ -120,7 +121,7 @@ func (s *service) material(w http.ResponseWriter, r *http.Request, project, acco var result json.RawMessage var version int64 err = tx.QueryRowContext(r.Context(), ` -SELECT jsonb_build_object('id',id,'workspace_id',workspace_id,'kind',kind,'title',title,'description',description, +SELECT jsonb_build_object('id',id,'workspace_id',workspace_id,'kind',kind,'slug',slug,'title',title,'description',description, 'content',content,'version',version,'created_at',created_at,'modified_at',modified_at,'created_by',created_by),version FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3 `, project, workspace, material).Scan(&result, &version) @@ -142,6 +143,7 @@ FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3 var result sql.Result if r.Method == http.MethodPatch { var input struct { + Slug *string `json:"slug,omitempty"` Title string `json:"title"` Description string `json:"description"` Content json.RawMessage `json:"content"` @@ -150,14 +152,14 @@ FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3 return nil } content, ok := normalizeContent(kind, input.Content) - if !ok || len(input.Title) > 300 || len(input.Description) > 10000 { + if !ok || !validSlug(input.Slug) || len(input.Title) > 300 || len(input.Description) > 10000 { reject(w, 400, "invalid_material") return nil } result, err = tx.ExecContext(r.Context(), ` -UPDATE materials SET title=$4,description=$5,content=$6,version=version+1,modified_at=now() +UPDATE materials SET slug=COALESCE($8,slug),title=$4,description=$5,content=$6,version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 AND id=$3 AND version=$7 -`, project, workspace, material, input.Title, input.Description, content, version) +`, project, workspace, material, input.Title, input.Description, content, version, input.Slug) } else { if role != "admin" { reject(w, 403, "admin_required") @@ -259,7 +261,7 @@ func (s *service) listMaterials(w http.ResponseWriter, r *http.Request, project, } tag := r.URL.Query().Get("tag") rows, err := s.db.QueryContext(r.Context(), ` -SELECT jsonb_build_object('id',m.id,'workspace_id',m.workspace_id,'kind',m.kind,'title',m.title, +SELECT jsonb_build_object('id',m.id,'workspace_id',m.workspace_id,'kind',m.kind,'slug',m.slug,'title',m.title, 'description',m.description,'version',m.version,'created_at',m.created_at,'modified_at',m.modified_at, 'opened_at',opened.opened_at,'tags',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',t.id,'name',t.name,'color',t.color) ORDER BY lower(t.name),t.id) FROM material_tags mt JOIN tags t ON (t.project,t.workspace_id,t.id)=(mt.project,mt.workspace_id,mt.tag_id) diff --git a/internal/platform/slug.go b/internal/platform/slug.go new file mode 100644 index 0000000..eec8d55 --- /dev/null +++ b/internal/platform/slug.go @@ -0,0 +1,12 @@ +package platform + +import "regexp" + +var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +var uuidSlugPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +// Slugs remain stable across renames unless explicitly changed by an authorized edit. +func validSlug(slug *string) bool { + return slug == nil || (len(*slug) <= 100 && slugPattern.MatchString(*slug) && !uuidSlugPattern.MatchString(*slug)) +} diff --git a/internal/platform/workspaces.go b/internal/platform/workspaces.go index b428f92..2062efe 100644 --- a/internal/platform/workspaces.go +++ b/internal/platform/workspaces.go @@ -11,7 +11,7 @@ func (s *service) workspaces(w http.ResponseWriter, r *http.Request, project, ac switch r.Method { case http.MethodGet: rows, err := s.db.QueryContext(r.Context(), ` -SELECT w.id::text,w.name,m.role,w.owner_id::text FROM workspaces w +SELECT w.id::text,w.name,m.role,w.owner_id::text,w.slug FROM workspaces w JOIN workspace_members m ON (m.project,m.workspace_id)=(w.project,w.id) WHERE m.project=$1 AND m.account_id=$2 ORDER BY lower(w.name),w.id LIMIT 200 `, project, account) @@ -21,11 +21,11 @@ WHERE m.project=$1 AND m.account_id=$2 ORDER BY lower(w.name),w.id LIMIT 200 defer rows.Close() items := []response{} for rows.Next() { - var id, name, role, owner string - if err := rows.Scan(&id, &name, &role, &owner); err != nil { + var id, name, role, owner, slug string + if err := rows.Scan(&id, &name, &role, &owner, &slug); err != nil { return err } - items = append(items, response{"id": id, "name": name, "role": role, "owner_id": owner}) + items = append(items, response{"id": id, "name": name, "role": role, "owner_id": owner, "slug": slug}) } if err := rows.Err(); err != nil { return err @@ -33,13 +33,14 @@ WHERE m.project=$1 AND m.account_id=$2 ORDER BY lower(w.name),w.id LIMIT 200 reply(w, 200, response{"items": items}) case http.MethodPost: var input struct { - Name string `json:"name"` + Name string `json:"name"` + Slug *string `json:"slug,omitempty"` } if !decode(w, r, &input) { return nil } input.Name = strings.TrimSpace(input.Name) - if input.Name == "" || len(input.Name) > 160 { + if input.Name == "" || len(input.Name) > 160 || !validSlug(input.Slug) { reject(w, 400, "invalid_workspace") return nil } @@ -52,10 +53,20 @@ WHERE m.project=$1 AND m.account_id=$2 ORDER BY lower(w.name),w.id LIMIT 200 if err != nil { return err } + var slug string + if input.Slug != nil { + _, err = tx.ExecContext(r.Context(), "UPDATE workspaces SET slug=$3 WHERE project=$1 AND id=$2", project, id, *input.Slug) + if err != nil { + return err + } + } + if err = tx.QueryRowContext(r.Context(), "SELECT slug FROM workspaces WHERE project=$1 AND id=$2", project, id).Scan(&slug); err != nil { + return err + } if err = tx.Commit(); err != nil { return err } - reply(w, 201, response{"id": id, "name": input.Name, "role": "admin", "owner_id": account}) + reply(w, 201, response{"id": id, "name": input.Name, "role": "admin", "owner_id": account, "slug": slug}) default: reject(w, 405, "method_not_allowed") } @@ -104,11 +115,11 @@ func (s *service) workspace(w http.ResponseWriter, r *http.Request, project, acc } switch r.Method { case http.MethodGet: - var name string - if err := tx.QueryRowContext(r.Context(), "SELECT name FROM workspaces WHERE project=$1 AND id=$2", project, workspace).Scan(&name); err != nil { + var name, slug string + if err := tx.QueryRowContext(r.Context(), "SELECT name,slug FROM workspaces WHERE project=$1 AND id=$2", project, workspace).Scan(&name, &slug); err != nil { return err } - reply(w, 200, response{"id": workspace, "name": name, "role": role, "owner_id": owner}) + reply(w, 200, response{"id": workspace, "name": name, "role": role, "owner_id": owner, "slug": slug}) return nil case http.MethodPatch: if role != "admin" { @@ -116,17 +127,18 @@ func (s *service) workspace(w http.ResponseWriter, r *http.Request, project, acc return nil } var input struct { - Name string `json:"name"` + Name string `json:"name"` + Slug *string `json:"slug,omitempty"` } if !decode(w, r, &input) { return nil } input.Name = strings.TrimSpace(input.Name) - if input.Name == "" || len(input.Name) > 160 { + if input.Name == "" || len(input.Name) > 160 || !validSlug(input.Slug) { reject(w, 400, "invalid_workspace") return nil } - _, err = tx.ExecContext(r.Context(), "UPDATE workspaces SET name=$3,updated_at=now() WHERE project=$1 AND id=$2", project, workspace, input.Name) + _, err = tx.ExecContext(r.Context(), "UPDATE workspaces SET name=$3,slug=COALESCE($4,slug),updated_at=now() WHERE project=$1 AND id=$2", project, workspace, input.Name, input.Slug) case http.MethodDelete: if owner != account { reject(w, 403, "owner_required") -- 2.52.0