Compare commits

...

32 Commits

Author SHA1 Message Date
codex-bot 460b05f187 feat(canvas): support unframed image snapshot nodes
Foundry Go checks / check (push) Successful in 3m24s
Foundry Go checks / check (pull_request) Successful in 3m27s
2026-11-06 12:38:45 +08:00
Zayden-Jung 022086beb8 Merge pull request '支持 Canvas 框选区、成员归属与名称显示模式' (#9) from codex/canvas-frames into main
Foundry Go checks / check (push) Successful in 3m28s
Deploy Foundry Core / deploy (push) Successful in 3m4s
Reviewed-on: #9
2026-11-06 03:21:48 +00:00
codex-bot 6f504ebe15 Support canvas frame nodes and explicit membership snapshots
Foundry Go checks / check (push) Successful in 3m13s
Foundry Go checks / check (pull_request) Successful in 4m24s
2026-11-06 11:20:05 +08:00
Zayden-Jung 57aab71d45 Merge pull request 'Canvas 快照持久化与 workspace/board SEO slug' (#7) from codex/canvas-persistence into main
Foundry Go checks / check (push) Successful in 2m38s
Deploy Foundry Core / deploy (push) Successful in 3m45s
Foundry Go checks / check (pull_request) Successful in 3m20s
Reviewed-on: #7
2026-11-05 18:57:43 +00:00
codex-bot 6f36ef27bb Add versioned board canvas persistence and stable SEO slugs
Foundry Go checks / check (push) Successful in 7m30s
Foundry Go checks / check (pull_request) Successful in 8m43s
2026-11-06 02:47:27 +08:00
Zayden-Jung e8ed5e4f9f Merge pull request '修复 Google/Slack OAuth 回调的系统 CA 证书缺失' (#6) from codex/fix-oauth-ca into main
Foundry Go checks / check (push) Successful in 2m47s
Deploy Foundry Core / deploy (push) Successful in 2m30s
Reviewed-on: #6
2026-11-05 15:16:11 +00:00
codex-bot b1e0d783e6 Mount system CA roots for OAuth HTTPS requests
Foundry Go checks / check (push) Successful in 3m22s
Foundry Go checks / check (pull_request) Successful in 3m16s
2026-11-05 21:39:11 +08:00
Zayden-Jung 4043c89520 Merge pull request 'Foundry 用户认证、workspace 分享与结构化物料 API' (#5) from codex/foundry-platform into main
Foundry Go checks / check (push) Successful in 2m3s
Deploy Foundry Core / deploy (push) Successful in 2m15s
Reviewed-on: #5
2026-11-05 12:30:25 +00:00
codex-bot 155ce28c37 Report inconclusive Slack placeholder-code checks accurately
Foundry Go checks / check (push) Successful in 2m9s
Foundry Go checks / check (pull_request) Successful in 1m57s
2026-11-05 20:22:51 +08:00
codex-bot 6c69fcbaaa Inject Slack Actions secrets and verify live client credentials
Foundry Go checks / check (push) Failing after 1m37s
Foundry Go checks / check (pull_request) Successful in 2m3s
2026-11-05 20:19:49 +08:00
codex-bot 5b9af931c7 Wait for final PostgreSQL startup before migration checks
Foundry Go checks / check (push) Successful in 2m3s
Foundry Go checks / check (pull_request) Successful in 1m57s
2026-11-05 19:03:55 +08:00
codex-bot 9d5ab9e215 Fix shared OAuth exchange SQL by avoiding a reserved identifier
Foundry Go checks / check (push) Successful in 2m10s
Foundry Go checks / check (pull_request) Successful in 2m11s
2026-11-05 18:55:29 +08:00
codex-bot f69575d48f Serialize email verification and resend to preserve registration state
Foundry Go checks / check (push) Failing after 2m1s
Foundry Go checks / check (pull_request) Failing after 1m50s
2026-11-05 18:53:50 +08:00
codex-bot fbe0910638 Guard legacy Google accounts through provider login expansion
Foundry Go checks / check (push) Failing after 1m59s
Foundry Go checks / check (pull_request) Failing after 1m56s
2026-11-05 18:51:56 +08:00
codex-bot 5c0ad30a7a Add Slack OpenID login with verified identities and callback replay protection
Foundry Go checks / check (push) Failing after 2m2s
Foundry Go checks / check (pull_request) Failing after 2m4s
2026-11-05 18:49:41 +08:00
codex-bot 206cd45098 Apply tracked schema migrations before deployment and test runtime grants
Foundry Go checks / check (push) Successful in 1m48s
Foundry Go checks / check (pull_request) Failing after 1m38s
2026-11-05 18:43:45 +08:00
codex-bot 4a06944fec Verify multimodal content boundaries and every material sort
Foundry Go checks / check (push) Successful in 2m6s
Foundry Go checks / check (pull_request) Successful in 1m49s
2026-11-05 18:40:41 +08:00
codex-bot e0ba9c08e8 Expose paginated share redirect events to workspace admins
Foundry Go checks / check (push) Successful in 1m47s
Foundry Go checks / check (pull_request) Successful in 1m51s
2026-11-05 18:39:23 +08:00
codex-bot bfcea1b3af Add structured materials, canvas layouts, tags and content permissions
Foundry Go checks / check (push) Successful in 1m51s
Foundry Go checks / check (pull_request) Successful in 1m56s
2026-11-05 18:36:53 +08:00
codex-bot 0d0521f13d Implement verified email registration and queued TLS delivery
Foundry Go checks / check (push) Successful in 1m52s
Foundry Go checks / check (pull_request) Successful in 1m53s
2026-11-05 18:31:09 +08:00
codex-bot 1b756d36ce Add configurable onboarding and scoped invitation links
Foundry Go checks / check (push) Successful in 1m47s
Foundry Go checks / check (pull_request) Successful in 1m57s
2026-11-05 18:24:30 +08:00
codex-bot 53c4dbee8e Add workspace membership roles with serialized permission changes
Foundry Go checks / check (push) Successful in 1m38s
Foundry Go checks / check (pull_request) Successful in 1m43s
2026-11-05 18:19:19 +08:00
codex-bot e626a2359e Add authenticated profiles and prefill Google account names
Foundry Go checks / check (push) Successful in 1m42s
Foundry Go checks / check (pull_request) Successful in 1m51s
2026-11-05 18:17:44 +08:00
codex-bot 0fd84f0f6e Establish project-scoped account and collaboration schema
Foundry Go checks / check (push) Successful in 1m43s
Foundry Go checks / check (pull_request) Successful in 1m40s
2026-11-05 18:14:44 +08:00
Zayden-Jung c30a3cd197 Merge pull request '为 Foundry 添加项目级 Codex HTTP 接口' (#4) from codex/foundry-codex-http into main
Foundry Go checks / check (push) Successful in 1m27s
Deploy Foundry Core / deploy (push) Successful in 2m22s
Reviewed-on: #4
2026-11-05 07:33:05 +00:00
codex-bot 08aa0d69b6 Set worker directory ownership by numeric container UID
Foundry Go checks / check (push) Successful in 1m30s
Foundry Go checks / check (pull_request) Successful in 1m34s
2026-11-05 11:40:09 +08:00
codex-bot 81c2d2e6df Enforce the output limit on subprocess stdout copies
Foundry Go checks / check (push) Successful in 1m52s
Foundry Go checks / check (pull_request) Successful in 1m28s
2026-11-05 11:36:54 +08:00
codex-bot d80854deae Start the configured Codex worker during Foundry deployment 2026-11-05 11:36:46 +08:00
codex-bot 9e5d805f5a Add project-scoped Codex execution over a private socket
Foundry Go checks / check (push) Successful in 1m35s
Foundry Go checks / check (pull_request) Successful in 1m22s
2026-11-05 02:29:49 +08:00
Zayden-Jung 4d5e6e2512 Merge pull request '接受 Actions Variables 中格式化的项目回调 JSON' (#3) from codex/foundry-project-config into main
Foundry Go checks / check (push) Successful in 1m44s
Deploy Foundry Core / deploy (push) Successful in 2m18s
Reviewed-on: #3
2026-11-04 17:51:55 +00:00
codex-bot cc8ddba7aa Accept formatted Foundry project configuration from Actions
Foundry Go checks / check (pull_request) Successful in 1m21s
Foundry Go checks / check (push) Successful in 1m22s
2026-11-05 01:44:44 +08:00
Zayden-Jung c7ec5f7eb2 Merge pull request '部署检查只验证 Foundry 后端就绪' (#2) from codex/foundry-backend-smoke into main
Foundry Go checks / check (push) Successful in 1m23s
Deploy Foundry Core / deploy (push) Failing after 1m24s
Reviewed-on: #2
2026-11-04 17:03:24 +00:00
37 changed files with 4638 additions and 24 deletions
+11
View File
@@ -5,3 +5,14 @@ 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
# Optional Sign in with Slack.
SLACK_CLIENT_ID=replace-me
SLACK_CLIENT_SECRET=replace-me
+53
View File
@@ -17,6 +17,59 @@ jobs:
test -z "$(gofmt -l cmd internal)"
go test -mod=readonly ./...
go build -mod=readonly -o bin/foundry ./cmd/foundry
- name: Check Slack OAuth configuration and endpoint
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")
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"}
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
env:
PUBLIC_ORIGIN: https://api.example.com
+4
View File
@@ -8,6 +8,7 @@ on:
- 'go.mod'
- 'go.sum'
- 'deploy/**'
- 'db/**'
- '.gitea/workflows/deploy.yml'
concurrency:
@@ -35,5 +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
+1
View File
@@ -1,2 +1,3 @@
.env
/bin/
/api-docs/
+93
View File
@@ -28,3 +28,96 @@ 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
containing only a nonempty `prompt` string and returns `{"result":"…"}`.
Use `Authorization: Bearer <token>` with a dedicated 32-byte random token
encoded as 64 hex characters. Ordinary OAuth sessions cannot execute Codex.
Missing, unknown and revoked tokens return 401. A valid token for a different
project returns 403. Requests are limited to 32 KiB, CLI output to 512 KiB,
and each execution to 90 seconds. The worker accepts one execution at a time
and returns 429 when busy.
On the deployment host, provision `/opt/endoleap-infra/foundry/codex` with:
- `grants/projects.json` containing a JSON object mapping lowercase hex SHA-256
digests of the **decoded** token bytes to configured project names. Use mode
0700 for the directory and 0600 for the file, readable by UID 1000. Rotate or
revoke tokens by atomically replacing the file within the mounted directory.
A revocation applies to subsequent requests.
- `home` owned by UID 1000 with an isolated Codex login in `auth.json`.
Keep this home dedicated to the worker. Supply the login only and keep user
configuration, MCP servers, skills, and other workload files out of it.
With that directory configured, the next normal Foundry deployment starts the
worker using the Linux binary built by ces-control and mounts the worker socket
and grants directory read-only. To replace an existing worker, stop and remove
its container, then run `bash deploy/codex-worker.sh /absolute/path/to/foundry`
with the desired ces-control build. The worker
runs the installed Codex CLI with fixed read-only, no-approval arguments and
disables command execution tools. It receives prompts via stdin and returns
the final agent message. The worker has no published port and Foundry receives
neither the Codex home nor the Docker socket. Keep both containers on the same
host for Unix socket access.
### Board Canvas snapshots and slugs
Apply `db/003-canvas.sql` through the normal migration runner before starting this
version. `GET /projects/{project}/workspaces/{workspace}/materials/{board}/canvas`
returns `{version, document}` and a quoted `ETag`. An untouched board returns
version 1 and an empty schema-1 graph. `PATCH` takes `{document: <full snapshot>}`
and the quoted `If-Match`; it atomically replaces the snapshot and returns
`{version}` with the new `ETag`. Canvas and material metadata have separate
versions. GET uses existing material read permissions; PATCH requires editor or
admin. Missing versions return 428, stale versions 412 `version_conflict`, invalid
graphs 400 `invalid_canvas`, and bodies over 5 MiB (UTF-8 bytes) 413. PNG/JPEG/WebP/
GIF data URLs up to 2 MiB decoded and HTTPS image URLs are supported. Temporary
node selection/drag/measurement state and mirrored styles are discarded.
Workspace and material create/get/list responses include `slug`. Create and
metadata PATCH accept an optional slug (1–100 lowercase ASCII letters/digits,
with single hyphens between segments; UUID-form custom values are reserved).
Workspace slugs are unique within a project, material slugs within a workspace;
duplicates return 409. Defaults use the ASCII name/title and UUID, falling back
to a generic prefix for names without ASCII letters/digits. Existing records are
backfilled. Renaming leaves slugs stable unless explicitly supplied. Workspace
and material path parameters accept either ID or slug, with unchanged permissions.
Canvas uses its board material's slug; there is no separate Canvas identity.
Slugs enable frontend SEO URLs but do not make private content public or add
search-engine metadata. Changing a custom slug does not retain an old alias.
Frontend follow-up: allowlist the Canvas proxy route and set its body limit to
at least 5 MiB; load the server snapshot, offer migration of old local drafts,
serialize autosaves and handle 412 explicitly. Restore node style dimensions
from width/height and render edges as Bézier curves. These frontend changes are
outside this Core delivery; local drafts alone are not database persistence.
Canvas schema 1 supports unframed images: `type: "plainImage"` with
`data.kind: "plain-image"`. They require the same validated `data.src` as image
cards, support geometry/rotation/frame membership, and cannot connect to edges.
This is a snapshot-format extension with no additional migration.
Canvas schema 1 also supports frames: `type: "frame"` with `data.kind: "frame"`.
Frame names use `data.title`; optional `data.labelMode` is `hidden`, `fixed`, or
`scaled` (frontend default: `fixed`). Frames cannot rotate, connect to edges,
or belong to other frames. Any non-frame node can specify `data.frameId`, which
must reference a frame in the same snapshot; forward references are supported.
Membership is explicit and independent of geometry: moving a frame over existing
content does not add members. The frontend assigns membership on content drop
and implements label zoom behavior. Core preserves the supplied membership and
label mode without inferring them from positions. Renderer-only `zIndex` is
ignored alongside transient state; restore frame layering in the frontend.
This extends existing JSONB snapshots and requires no additional migration.
+28 -3
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"log"
"net"
@@ -10,6 +11,8 @@ import (
"time"
"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"
)
@@ -22,6 +25,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 != "" {
@@ -41,14 +47,33 @@ 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)
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)
}
mux := http.NewServeMux()
if err := codex.Register(mux, callbacks, os.Getenv("FOUNDRY_CODEX_SOCKET"), os.Getenv("FOUNDRY_CODEX_GRANTS")); err != nil {
log.Fatal(err)
}
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")),
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())
}
+224
View File
@@ -0,0 +1,224 @@
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;
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')),
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,
lease_id uuid,
expires_at timestamptz NOT NULL,
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_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
);
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;
+29
View File
@@ -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;
+70
View File
@@ -0,0 +1,70 @@
#!/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 -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; }
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 <<'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
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'
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
+24
View File
@@ -0,0 +1,24 @@
#!/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 "$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.
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
+76
View File
@@ -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.")
+77 -3
View File
@@ -8,24 +8,92 @@ 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"
# 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"
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" \
mail_env=()
if [[ -s "$foundry/mail.env" ]]; then
mail_env=(--env-file "$foundry/mail.env")
fi
slack_env=()
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=()
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=(
--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[@]}" "${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 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
@@ -36,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 {
@@ -51,6 +121,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); });
'
+26 -18
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"golang.org/x/crypto/bcrypt"
"io"
"log"
"net/http"
@@ -26,10 +27,18 @@ type service struct {
projects map[string]*url.URL
redirectURI, clientID, secret string
google *http.Client
email *EmailConfig
passwordSlots chan struct{}
dummyPassword []byte
slack *SlackConfig
}
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, 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, 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)
@@ -52,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) {
@@ -106,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)
}
@@ -120,8 +133,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":
@@ -146,14 +161,14 @@ 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
}
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))
@@ -201,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) {
@@ -240,6 +255,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
@@ -251,16 +268,7 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error {
user.Email = nil
}
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
RETURNING id
)
INSERT INTO login_grants
SELECT $4, $1, id, $5, now() + interval '1 minute' FROM account
`, project, user.Subject, user.Email, digest(grant), challenge)
grant, err := s.identityGrant(ctx, project, "google", user.Subject, user.Email, user.Name, user.Picture, challenge)
if err != nil {
return err
}
@@ -276,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
+217
View File
@@ -0,0 +1,217 @@
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()
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)
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(), "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)
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
}
+72
View File
@@ -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)
}
}
+67
View File
@@ -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
}
+113
View File
@@ -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
}
+27
View File
@@ -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
}
+226
View File
@@ -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
}
+222
View File
@@ -0,0 +1,222 @@
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)
}
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)
}
}
+143
View File
@@ -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
}
+69
View File
@@ -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")
}
+134
View File
@@ -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 {
buffer bytes.Buffer
exceeded bool
}
func (b *outputBuffer) Write(p []byte) (int, error) {
if len(p) > maxOutput-b.buffer.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.buffer.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))
}
}
+325
View File
@@ -0,0 +1,325 @@
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", "zIndex"} {
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", "frameId", "labelMode") {
return nil, false
}
kind, ok := d["kind"].(string)
if !ok {
return nil, false
}
typ := "material"
if kind == "plain-text" {
typ = "plainText"
} else if kind == "plain-image" {
typ = "plainImage"
} else if kind == "icon" {
typ = "icon"
} else if kind == "frame" {
typ = "frame"
} 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
}
if frameID, exists := d["frameId"]; exists {
value, ok := frameID.(string)
if kind == "frame" || !ok || !canvasID.MatchString(value) {
return nil, false
}
}
if mode, exists := d["labelMode"]; exists && (kind != "frame" || !canvasChoice(mode, "hidden", "fixed", "scaled")) {
return nil, false
}
if kind == "frame" {
if rotation, exists := d["rotation"]; exists && rotation != float64(0) {
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" || kind == "plain-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
}
}
}
}
}
// Resolve memberships after all nodes so forward references are valid.
// Geometry never creates membership: only explicit frameId is persisted.
for _, value := range nodes {
data := value.(map[string]any)["data"].(map[string]any)
if id, exists := data["frameId"]; exists && kinds[id.(string)] != "frame" {
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
}
+107
View File
@@ -0,0 +1,107 @@
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)
}
}
}
// Frame nodes follow the frontend contract: membership is data, not containment.
const frameFixture = `{"schema_version":1,"nodes":[{"id":"text","type":"material","position":{"x":100,"y":100},"data":{"kind":"text","title":"成员","text":"正文","frameId":"frame"}},{"id":"plain","type":"plainText","position":{"x":0,"y":0},"data":{"kind":"plain-text","title":"纯文本","text":"文字","frameId":"frame"}},{"id":"icon","type":"icon","position":{"x":10,"y":10},"data":{"kind":"icon","title":"图标","text":"","icon":"Star","frameId":"frame"}},{"id":"frame","type":"frame","position":{"x":0,"y":0},"width":600,"height":400,"zIndex":-1,"style":{"width":600,"height":400,"pointerEvents":"none"},"data":{"kind":"frame","title":"框选区","text":"","rotation":0,"labelMode":"fixed"}},{"id":"outside","type":"material","position":{"x":100,"y":100},"data":{"kind":"script","title":"重叠但未加入","text":""}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`
func TestCanvasFrames(t *testing.T) {
for _, mode := range []string{"hidden", "fixed", "scaled"} {
t.Run(mode, func(t *testing.T) {
out, ok := normalizeCanvas(json.RawMessage(strings.Replace(frameFixture, `"labelMode":"fixed"`, `"labelMode":"`+mode+`"`, 1)))
if !ok {
t.Fatal("valid frame rejected")
}
var doc map[string]any
json.Unmarshal(out, &doc)
nodes := doc["nodes"].([]any)
if nodes[0].(map[string]any)["data"].(map[string]any)["frameId"] != "frame" || nodes[3].(map[string]any)["data"].(map[string]any)["labelMode"] != mode {
t.Fatal("frame data lost")
}
if _, exists := nodes[4].(map[string]any)["data"].(map[string]any)["frameId"]; exists {
t.Fatal("position inferred membership")
}
if _, exists := nodes[3].(map[string]any)["zIndex"]; exists {
t.Fatal("rendering state persisted")
}
})
}
cases := [][2]string{
{`"labelMode":"fixed"`, `"labelMode":"invalid"`},
{`"labelMode":"fixed"`, `"labelMode":null`},
{`"rotation":0`, `"rotation":1`},
{`"frameId":"frame"`, `"frameId":"missing"`},
{`"frameId":"frame"`, `"frameId":"text"`},
{`"frameId":"frame"`, `"frameId":null`},
{`"frameId":"frame"`, `"frameId":""`},
{`"labelMode":"fixed"`, `"labelMode":"fixed","frameId":"frame"`},
{`"type":"frame"`, `"type":"material"`},
{`"edges":[]`, `"edges":[{"id":"e","source":"text","target":"frame"}]`},
}
for _, c := range cases {
t.Run(c[1], func(t *testing.T) {
if _, ok := normalizeCanvas(json.RawMessage(strings.Replace(frameFixture, c[0], c[1], 1))); ok {
t.Fatal("invalid frame accepted")
}
})
}
withoutMode := strings.Replace(frameFixture, `,"labelMode":"fixed"`, "", 1)
if _, ok := normalizeCanvas(json.RawMessage(withoutMode)); !ok {
t.Fatal("optional labelMode rejected")
}
}
func TestCanvasPlainImages(t *testing.T) {
fixture := `{"schema_version":1,"nodes":[{"id":"photo","type":"plainImage","position":{"x":20,"y":30},"width":320,"height":240,"data":{"kind":"plain-image","title":"Photo","text":"","src":"https://example.com/photo.png","rotation":30,"frameId":"frame"}},{"id":"frame","type":"frame","position":{"x":0,"y":0},"width":640,"height":400,"data":{"kind":"frame","title":"Frame","text":"","labelMode":"fixed"}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`
if _, ok := normalizeCanvas(json.RawMessage(fixture)); !ok {
t.Fatal("plain image rejected")
}
for _, change := range [][2]string{
{`"type":"plainImage"`, `"type":"material"`},
{`,"src":"https://example.com/photo.png"`, ``},
{`"src":"https://example.com/photo.png"`, `"src":"javascript:bad"`},
{`"edges":[]`, `"edges":[{"id":"e","source":"photo","target":"photo"}]`},
} {
if _, ok := normalizeCanvas(json.RawMessage(strings.Replace(fixture, change[0], change[1], 1))); ok {
t.Fatal("invalid pure image accepted")
}
}
}
+129
View File
@@ -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
}
+39
View File
@@ -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")
}
})
}
}
+96
View File
@@ -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
}
+183
View File
@@ -0,0 +1,183 @@
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
origin string
callbacks map[string]string
onboardingConfigs map[string]OnboardingConfig
}
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)
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")
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 && 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)
}
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 == "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:
log.Printf("platform request failed (%T)", err)
reject(w, 503, "service_unavailable")
}
}
}
}
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}/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))
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}/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))
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))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/members/{account}", s.protected(callbacks, s.member))
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
}
+316
View File
@@ -0,0 +1,316 @@
package platform
import (
"bytes"
"context"
"crypto/rand"
"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"
"strconv"
"strings"
"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-" + hex.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)
_, _ = 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{}, nil)
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}}},
})
call := func(method, path, token string, body any, status int, versions ...string) 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)
}
if len(versions) > 0 {
r.Header.Set("If-Match", versions[0])
}
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)
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)
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)
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)
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")
}
// Frames round-trip through PostgreSQL with all label modes and explicit memberships.
for index, mode := range []string{"hidden", "fixed", "scaled"} {
version := strconv.Itoa(4 + index)
raw := strings.Replace(frameFixture, `"labelMode":"fixed"`, `"labelMode":"`+mode+`"`, 1)
call("PATCH", canvasPath, tokens[0], response{"document": json.RawMessage(raw)}, 200, `"`+version+`"`)
persisted := call("GET", canvasPath, tokens[0], nil, 200)
normalized, _ := normalizeCanvas(json.RawMessage(raw))
want, _ := json.Marshal(json.RawMessage(normalized))
got, _ := json.Marshal(persisted["document"])
if !bytes.Equal(want, got) {
t.Fatal("frame snapshot not preserved")
}
}
invalidFrame := strings.Replace(frameFixture, `"frameId":"frame"`, `"frameId":"missing"`, 1)
rejected := call("PATCH", canvasPath, tokens[0], response{"document": json.RawMessage(invalidFrame)}, 400, `"7"`)
if rejected["error"] != "invalid_canvas" {
t.Fatal("unexpected frame error")
}
if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(7) {
t.Fatal("invalid frame changed saved version")
}
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)
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, `"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)
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)
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)
}
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)
}
}
+158
View File
@@ -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
}
+299
View File
@@ -0,0 +1,299 @@
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"`
Slug *string `json:"slug,omitempty"`
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 || !validSlug(input.Slug) || 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, slug string
err = tx.QueryRowContext(r.Context(), `
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
}
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, "slug": slug})
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,'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)
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 {
Slug *string `json:"slug,omitempty"`
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 || !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 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, input.Slug)
} 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,'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)
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
}
+192
View File
@@ -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
}
+356
View File
@@ -0,0 +1,356 @@
package platform
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"net/http"
"net/url"
"regexp"
"strconv"
"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_count<max_uses)
), click AS(
INSERT INTO link_clicks(project,workspace_id,link_id,referrer_origin)
SELECT project,workspace_id,id,$2 FROM valid RETURNING project
)
SELECT project FROM click
`, linkDigest(token), ref).Scan(&project)
if err != nil {
if err == sql.ErrNoRows {
reject(w, 404, "not_found")
} else {
reject(w, 503, "service_unavailable")
}
return
}
callback, ok := s.callbacks[project]
if !ok {
reject(w, 404, "not_found")
return
}
destination, err := url.Parse(callback)
if err != nil {
reject(w, 503, "service_unavailable")
return
}
destination.Fragment = "invite=" + token
http.Redirect(w, r, destination.String(), 302)
}
func (s *service) redeemShare(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 {
Token string `json:"token"`
}
if !decode(w, r, &input) {
return nil
}
if !linkTokenPattern.MatchString(input.Token) {
reject(w, 404, "not_found")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
var workspace string
// Lock workspace before the link, matching revocation and role updates.
err = tx.QueryRowContext(r.Context(), `
SELECT w.id::text FROM workspaces w JOIN share_links l ON (w.project,w.id)=(l.project,l.workspace_id)
WHERE l.project=$1 AND l.token_hash=$2 FOR UPDATE OF w
`, project, linkDigest(input.Token)).Scan(&workspace)
if err != nil {
return err
}
var id, role string
var material *string
var allowed, capacity, redeemed bool
err = tx.QueryRowContext(r.Context(), `
SELECT id::text,role,material_id::text,
revoked_at IS NULL AND expires_at>now(), (max_uses IS NULL OR use_count<max_uses),
EXISTS(SELECT 1 FROM share_redemptions d WHERE (d.project,d.workspace_id,d.link_id)=(share_links.project,share_links.workspace_id,share_links.id) AND d.account_id=$4)
FROM share_links WHERE project=$1 AND workspace_id=$2 AND token_hash=$3 FOR UPDATE
`, project, workspace, linkDigest(input.Token), account).Scan(&id, &role, &material, &allowed, &capacity, &redeemed)
if err != nil {
return err
}
if !allowed || (!capacity && !redeemed) {
reject(w, 410, "share_unavailable")
return nil
}
result, err := tx.ExecContext(r.Context(), `
INSERT INTO share_redemptions(project,workspace_id,link_id,account_id) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING
`, project, workspace, id, account)
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n > 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
}
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
}
+12
View File
@@ -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))
}
+169
View File
@@ -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
}
+251
View File
@@ -0,0 +1,251 @@
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,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)
if err != nil {
return err
}
defer rows.Close()
items := []response{}
for rows.Next() {
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, "slug": slug})
}
if err := rows.Err(); err != nil {
return err
}
reply(w, 200, response{"items": items})
case http.MethodPost:
var input struct {
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 || !validSlug(input.Slug) {
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
}
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, "slug": slug})
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, 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, "slug": slug})
return nil
case http.MethodPatch:
if role != "admin" {
reject(w, 403, "forbidden")
return nil
}
var input struct {
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 || !validSlug(input.Slug) {
reject(w, 400, "invalid_workspace")
return nil
}
_, 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")
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
}