From 0fd84f0f6e13f81efc77943043b1b434eaa9443b Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:14:44 +0800 Subject: [PATCH 01/16] Establish project-scoped account and collaboration schema --- .gitea/workflows/check.yml | 2 + .gitignore | 1 + db/002-platform.sql | 223 +++++++++++++++++++++++++++++++++++++ deploy/check-schema.sh | 58 ++++++++++ 4 files changed, 284 insertions(+) create mode 100644 db/002-platform.sql create mode 100644 deploy/check-schema.sh diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index f278f2a..588e2e8 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -17,6 +17,8 @@ jobs: test -z "$(gofmt -l cmd internal)" go test -mod=readonly ./... go build -mod=readonly -o bin/foundry ./cmd/foundry + - name: Verify schema migration in an isolated database + run: bash deploy/check-schema.sh - name: Smoke test HTTP routes env: PUBLIC_ORIGIN: https://api.example.com diff --git a/.gitignore b/.gitignore index 579c657..db3643f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .env /bin/ +/api-docs/ diff --git a/db/002-platform.sql b/db/002-platform.sql new file mode 100644 index 0000000..97bf9e4 --- /dev/null +++ b/db/002-platform.sql @@ -0,0 +1,223 @@ +BEGIN; + +-- Keep existing Google accounts and sessions valid during the expansion. +ALTER TABLE project_accounts ALTER COLUMN google_subject DROP NOT NULL; +ALTER TABLE project_accounts + ADD COLUMN username text, + ADD COLUMN display_name text NOT NULL DEFAULT '', + ADD COLUMN avatar_url text, + ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now(); +CREATE UNIQUE INDEX project_account_username ON project_accounts(project, lower(username)) + WHERE username IS NOT NULL; + +CREATE TABLE account_identities ( + project text NOT NULL, + provider text NOT NULL CHECK (provider IN ('google', 'slack')), + subject text NOT NULL, + account_id uuid NOT NULL, + PRIMARY KEY (project, provider, subject), + UNIQUE (project, provider, account_id), + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +INSERT INTO account_identities(project, provider, subject, account_id) +SELECT project, 'google', google_subject, id FROM project_accounts WHERE google_subject IS NOT NULL; + +CREATE TABLE email_credentials ( + project text NOT NULL, + email text NOT NULL CHECK (email = lower(email)), + account_id uuid NOT NULL, + password_hash text NOT NULL, + verified_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, email), + UNIQUE (project, account_id), + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +-- Registration is promoted to an account only after the single-use email check. +CREATE TABLE email_registrations ( + project text NOT NULL, + email text NOT NULL, + password_hash text NOT NULL, + token_hash text NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, email) +); +CREATE TABLE email_outbox ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + project text NOT NULL, + recipient text NOT NULL, + subject text NOT NULL, + body text NOT NULL, + attempts integer NOT NULL DEFAULT 0, + available_at timestamptz NOT NULL DEFAULT now(), + lease_until timestamptz, + sent_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX email_outbox_pending ON email_outbox(available_at) WHERE sent_at IS NULL; +CREATE TABLE auth_rate_limits ( + key_hash text PRIMARY KEY, + attempts integer NOT NULL CHECK (attempts > 0), + expires_at timestamptz NOT NULL +); + +CREATE TABLE onboarding_configs ( + project text PRIMARY KEY, + require_workspace boolean NOT NULL DEFAULT false, + questions jsonb NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(questions) = 'array'), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE onboarding_responses ( + project text NOT NULL, + account_id uuid NOT NULL, + answers jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(answers) = 'object'), + completed_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, account_id), + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); + +CREATE TABLE workspaces ( + project text NOT NULL, + id uuid NOT NULL DEFAULT gen_random_uuid(), + name text NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 160), + owner_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, id), + FOREIGN KEY (project, owner_id) REFERENCES project_accounts +); +CREATE TABLE workspace_members ( + project text NOT NULL, + workspace_id uuid NOT NULL, + account_id uuid NOT NULL, + role text NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')), + joined_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, workspace_id, account_id), + FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE, + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +CREATE INDEX workspace_members_account ON workspace_members(project, account_id); + +CREATE TABLE materials ( + project text NOT NULL, + workspace_id uuid NOT NULL, + id uuid NOT NULL DEFAULT gen_random_uuid(), + kind text NOT NULL CHECK (kind IN ('text', 'article', 'image', 'board', 'card')), + title text NOT NULL CHECK (length(title) <= 300), + description text NOT NULL DEFAULT '' CHECK (length(description) <= 10000), + content jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(content) = 'object'), + version bigint NOT NULL DEFAULT 1 CHECK (version > 0), + created_by uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + modified_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, workspace_id, id), + FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE, + FOREIGN KEY (project, created_by) REFERENCES project_accounts +); +CREATE INDEX material_modified ON materials(project, workspace_id, modified_at DESC, id); +CREATE INDEX material_created ON materials(project, workspace_id, created_at DESC, id); +CREATE INDEX material_alphabetical ON materials(project, workspace_id, lower(title), id); +CREATE TABLE material_opens ( + project text NOT NULL, + workspace_id uuid NOT NULL, + material_id uuid NOT NULL, + account_id uuid NOT NULL, + opened_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, workspace_id, material_id, account_id), + FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +CREATE INDEX material_opens_account ON material_opens(project, workspace_id, account_id, opened_at DESC); + +CREATE TABLE tags ( + project text NOT NULL, + workspace_id uuid NOT NULL, + id uuid NOT NULL DEFAULT gen_random_uuid(), + name text NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 80), + color text NOT NULL DEFAULT '', + PRIMARY KEY (project, workspace_id, id), + FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE +); +CREATE UNIQUE INDEX tag_name ON tags(project, workspace_id, lower(name)); +CREATE TABLE material_tags ( + project text NOT NULL, + workspace_id uuid NOT NULL, + material_id uuid NOT NULL, + tag_id uuid NOT NULL, + PRIMARY KEY (project, workspace_id, material_id, tag_id), + FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY (project, workspace_id, tag_id) REFERENCES tags ON DELETE CASCADE +); +CREATE INDEX material_tags_filter ON material_tags(project, workspace_id, tag_id, material_id); + +-- A board placement references a reusable material, independently of its content. +CREATE TABLE board_layouts ( + project text NOT NULL, + workspace_id uuid NOT NULL, + board_id uuid NOT NULL, + id uuid NOT NULL DEFAULT gen_random_uuid(), + material_id uuid NOT NULL, + x double precision NOT NULL CHECK (x BETWEEN -1000000 AND 1000000), + y double precision NOT NULL CHECK (y BETWEEN -1000000 AND 1000000), + width double precision NOT NULL CHECK (width BETWEEN 1 AND 100000), + height double precision NOT NULL CHECK (height BETWEEN 1 AND 100000), + z_index integer NOT NULL DEFAULT 0, + version bigint NOT NULL DEFAULT 1 CHECK (version > 0), + PRIMARY KEY (project, workspace_id, board_id, id), + FOREIGN KEY (project, workspace_id, board_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE, + CHECK (board_id <> material_id) +); +CREATE TABLE material_grants ( + project text NOT NULL, + workspace_id uuid NOT NULL, + material_id uuid NOT NULL, + account_id uuid NOT NULL, + role text NOT NULL CHECK (role IN ('editor', 'viewer')), + PRIMARY KEY (project, workspace_id, material_id, account_id), + FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +CREATE TABLE share_links ( + project text NOT NULL, + workspace_id uuid NOT NULL, + id uuid NOT NULL DEFAULT gen_random_uuid(), + material_id uuid, + token_hash text NOT NULL UNIQUE, + role text NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')), + created_by uuid NOT NULL, + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + max_uses integer CHECK (max_uses BETWEEN 1 AND 100000), + use_count integer NOT NULL DEFAULT 0 CHECK (use_count >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, workspace_id, id), + FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE, + FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY (project, created_by) REFERENCES project_accounts, + CHECK (material_id IS NULL OR role <> 'admin') +); +CREATE TABLE share_redemptions ( + project text NOT NULL, + workspace_id uuid NOT NULL, + link_id uuid NOT NULL, + account_id uuid NOT NULL, + redeemed_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, workspace_id, link_id, account_id), + FOREIGN KEY (project, workspace_id, link_id) REFERENCES share_links ON DELETE CASCADE, + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +CREATE TABLE link_clicks ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project text NOT NULL, + workspace_id uuid NOT NULL, + link_id uuid NOT NULL, + clicked_at timestamptz NOT NULL DEFAULT now(), + -- Store the referring origin only, without URL paths, queries or user tokens. + referrer_origin text NOT NULL DEFAULT '', + FOREIGN KEY (project, workspace_id, link_id) REFERENCES share_links ON DELETE CASCADE +); +CREATE INDEX link_clicks_summary ON link_clicks(project, workspace_id, link_id, clicked_at); + +COMMIT; diff --git a/deploy/check-schema.sh b/deploy/check-schema.sh new file mode 100644 index 0000000..09f1aff --- /dev/null +++ b/deploy/check-schema.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +container=foundry-schema-$(openssl rand -hex 8) +cleanup() { docker rm -f "$container" >/dev/null 2>&1 || true; } +trap cleanup EXIT +# No host port or shared network. The database is discarded after this check. +docker run -d --name "$container" --network none \ + -e POSTGRES_HOST_AUTH_METHOD=trust postgres:17-alpine >/dev/null +for attempt in {1..30}; do + if docker exec "$container" pg_isready -U postgres >/dev/null 2>&1; then break; fi + sleep 1 +done +psql() { docker exec -i "$container" psql -X -U postgres -v ON_ERROR_STOP=1; } +psql < db/001-auth.sql +psql <<'SQL' +INSERT INTO project_accounts(project,id,google_subject,email) +VALUES ('fixture','00000000-0000-0000-0000-000000000001','existing-google','existing@example.invalid'); +INSERT INTO project_sessions VALUES +('existing-session','fixture','00000000-0000-0000-0000-000000000001',now()+interval '1 hour'); +SQL +psql < db/002-platform.sql +psql <<'SQL' +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM project_sessions s JOIN project_accounts a + ON (s.project,s.account_id)=(a.project,a.id) + JOIN account_identities i ON (i.project,i.account_id)=(a.project,a.id) + WHERE s.token_hash='existing-session' AND i.provider='google' AND i.subject='existing-google' + ) THEN RAISE EXCEPTION 'Existing Google login was lost'; END IF; +END $$; +BEGIN; +INSERT INTO project_accounts(project,id,email) VALUES +('fixture','00000000-0000-0000-0000-000000000002','email@example.invalid'); +INSERT INTO email_credentials(project,email,account_id,password_hash) VALUES +('fixture','email@example.invalid','00000000-0000-0000-0000-000000000002','fixture-hash'); +INSERT INTO workspaces(project,id,name,owner_id) VALUES +('fixture','00000000-0000-0000-0000-000000000010','Fixture','00000000-0000-0000-0000-000000000001'); +INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES +('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000001','admin'); +INSERT INTO materials(project,workspace_id,id,kind,title,created_by) VALUES +('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000020','article','Article','00000000-0000-0000-0000-000000000001'), +('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000021','board','Board','00000000-0000-0000-0000-000000000001'); +INSERT INTO board_layouts(project,workspace_id,board_id,material_id,x,y,width,height) VALUES +('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000021','00000000-0000-0000-0000-000000000020',0,0,320,200); +DO $$ +BEGIN + BEGIN + INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES + ('other','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000001','admin'); + RAISE EXCEPTION 'Cross-project membership unexpectedly accepted'; + EXCEPTION WHEN foreign_key_violation THEN NULL; + END; +END $$; +ROLLBACK; +SQL +printf 'Migration preserved existing logins and enforced project isolation.\n' From e626a2359ee5310fe6350a70f7ff7cb414bab1ec Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:17:44 +0800 Subject: [PATCH 02/16] Add authenticated profiles and prefill Google account names --- cmd/foundry/main.go | 2 + internal/auth/auth.go | 27 ++++++-- internal/auth/session.go | 27 ++++++++ internal/platform/http.go | 142 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 internal/auth/session.go create mode 100644 internal/platform/http.go diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go index 0eac158..abf4427 100644 --- a/cmd/foundry/main.go +++ b/cmd/foundry/main.go @@ -11,6 +11,7 @@ import ( "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" "gitea.entroia.com/Zayden-Jung/foundry-core/internal/codex" + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/platform" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/stdlib" ) @@ -53,6 +54,7 @@ func main() { if err := codex.Register(mux, callbacks, os.Getenv("FOUNDRY_CODEX_SOCKET"), os.Getenv("FOUNDRY_CODEX_GRANTS")); err != nil { log.Fatal(err) } + platform.Register(mux, db, callbacks) mux.Handle("/", handler) server := &http.Server{ Addr: net.JoinHostPort(env("HOST", "127.0.0.1"), env("PORT", "8080")), diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e85fd71..a1ef605 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -153,7 +153,7 @@ func (s *service) start(w http.ResponseWriter, r *http.Request, project string) } query := url.Values{ "client_id": {s.clientID}, "redirect_uri": {s.redirectURI}, "response_type": {"code"}, - "scope": {"openid email"}, "state": {state}, "code_challenge": {digest(verifier)}, + "scope": {"openid email profile"}, "state": {state}, "code_challenge": {digest(verifier)}, "code_challenge_method": {"S256"}, "prompt": {"select_account"}, } http.SetCookie(w, oauthCookie(state, browser, 600)) @@ -240,6 +240,8 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error { Subject string `json:"sub"` Email *string `json:"email"` Verified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` } if err := s.googleJSON(ctx, "GET", "https://openidconnect.googleapis.com/v1/userinfo", nil, tokens.AccessToken, &user); err != nil { return err @@ -253,14 +255,29 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error { grant := token() _, err = s.db.ExecContext(ctx, ` - WITH account AS ( - INSERT INTO project_accounts (project, id, google_subject, email) VALUES ($1, gen_random_uuid(), $2, $3) - ON CONFLICT (project, google_subject) DO UPDATE SET email = EXCLUDED.email + WITH identity AS ( + SELECT gen_random_uuid() AS id + ), account AS ( + INSERT INTO project_accounts (project, id, google_subject, email, username, display_name, avatar_url) + SELECT $1, id, $2, $3, + left(COALESCE(NULLIF(regexp_replace(lower($6), '[^a-z0-9]', '', 'g'), ''), 'user'),7) + || '_' || replace(id::text,'-',''), + $6, NULLIF($7,'') FROM identity + ON CONFLICT (project, google_subject) DO UPDATE SET + email = EXCLUDED.email, + username = COALESCE(project_accounts.username, EXCLUDED.username), + display_name = CASE WHEN project_accounts.display_name='' THEN EXCLUDED.display_name ELSE project_accounts.display_name END, + avatar_url = COALESCE(project_accounts.avatar_url, EXCLUDED.avatar_url), + updated_at = now() RETURNING id + ), provider AS ( + INSERT INTO account_identities(project,provider,subject,account_id) + SELECT $1,'google',$2,id FROM account + ON CONFLICT (project,provider,subject) DO NOTHING ) INSERT INTO login_grants SELECT $4, $1, id, $5, now() + interval '1 minute' FROM account - `, project, user.Subject, user.Email, digest(grant), challenge) + `, project, user.Subject, user.Email, digest(grant), challenge, user.Name, user.Picture) if err != nil { return err } diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..da62d70 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,27 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "strings" +) + +var ErrInvalidSession = errors.New("invalid session") + +// Authenticate resolves only an unexpired session belonging to this project. +func Authenticate(ctx context.Context, db *sql.DB, project, authorization string) (string, error) { + value, ok := strings.CutPrefix(authorization, "Bearer ") + if !ok || !tokenPattern.MatchString(value) { + return "", ErrInvalidSession + } + var id string + err := db.QueryRowContext(ctx, ` +SELECT account_id::text FROM project_sessions +WHERE project=$1 AND token_hash=$2 AND expires_at>now() +`, project, digest(value)).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrInvalidSession + } + return id, err +} diff --git a/internal/platform/http.go b/internal/platform/http.go new file mode 100644 index 0000000..3269da6 --- /dev/null +++ b/internal/platform/http.go @@ -0,0 +1,142 @@ +package platform + +import ( + "database/sql" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/url" + "regexp" + "strings" + + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" + "github.com/jackc/pgx/v5/pgconn" +) + +type service struct { + db *sql.DB +} + +type response map[string]any + +func reply(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + if value != nil { + _ = json.NewEncoder(w).Encode(value) + } +} + +func reject(w http.ResponseWriter, status int, code string) { + reply(w, status, response{"error": code}) +} + +func decode(w http.ResponseWriter, r *http.Request, value any) bool { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + reject(w, 400, "invalid_request") + return false + } + if decoder.Decode(new(any)) != io.EOF { + reject(w, 400, "invalid_request") + return false + } + return true +} + +type endpoint func(http.ResponseWriter, *http.Request, string, string) error + +func (s *service) protected(callbacks map[string]string, handle endpoint) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + project := r.PathValue("project") + callback, exists := callbacks[project] + if !exists { + reject(w, 404, "not_found") + return + } + frontend, _ := url.Parse(callback) + w.Header().Set("Vary", "Origin") + if origin := r.Header.Get("Origin"); origin != "" { + if origin != frontend.Scheme+"://"+frontend.Host { + reject(w, 403, "origin_denied") + return + } + w.Header().Set("Access-Control-Allow-Origin", origin) + } + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match") + reply(w, 204, nil) + return + } + account, err := auth.Authenticate(r.Context(), s.db, project, r.Header.Get("Authorization")) + if errors.Is(err, auth.ErrInvalidSession) { + reject(w, 401, "invalid_session") + return + } + if err == nil { + err = handle(w, r, project, account) + } + if err != nil { + var pg *pgconn.PgError + switch { + case errors.Is(err, sql.ErrNoRows): + reject(w, 404, "not_found") + case errors.As(err, &pg) && pg.Code == "23505": + reject(w, 409, "already_exists") + default: + log.Printf("platform request failed (%T)", err) + reject(w, 503, "service_unavailable") + } + } + } +} + +func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string) { + s := &service{db: db} + mux.HandleFunc("/projects/{project}/profile", s.protected(callbacks, s.profile)) +} + +var usernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{2,39}$`) + +func (s *service) profile(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method == http.MethodPatch { + var input struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + } + if !decode(w, r, &input) { + return nil + } + input.Username = strings.ToLower(strings.TrimSpace(input.Username)) + input.DisplayName = strings.TrimSpace(input.DisplayName) + if !usernamePattern.MatchString(input.Username) || len(input.DisplayName) > 160 { + reject(w, 400, "invalid_profile") + return nil + } + _, err := s.db.ExecContext(r.Context(), ` +UPDATE project_accounts SET username=$3, display_name=$4, updated_at=now() +WHERE project=$1 AND id=$2 +`, project, account, input.Username, input.DisplayName) + if err != nil { + return err + } + } else if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + var username, email, avatar *string + var name string + err := s.db.QueryRowContext(r.Context(), ` +SELECT username, display_name, email, avatar_url FROM project_accounts WHERE project=$1 AND id=$2 +`, project, account).Scan(&username, &name, &email, &avatar) + if err != nil { + return err + } + reply(w, 200, response{"id": account, "project": project, "username": username, "display_name": name, "email": email, "avatar_url": avatar}) + return nil +} From 53c4dbee8ed838f1fe710e83a320989cdb43cc0b Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:19:19 +0800 Subject: [PATCH 03/16] Add workspace membership roles with serialized permission changes --- internal/platform/http.go | 6 + internal/platform/workspaces.go | 239 ++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 internal/platform/workspaces.go diff --git a/internal/platform/http.go b/internal/platform/http.go index 3269da6..8a468be 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -86,6 +86,8 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H switch { case errors.Is(err, sql.ErrNoRows): reject(w, 404, "not_found") + case errors.As(err, &pg) && (pg.Code == "22P02" || pg.Code == "23503" || pg.Code == "23514"): + reject(w, 400, "invalid_reference") case errors.As(err, &pg) && pg.Code == "23505": reject(w, 409, "already_exists") default: @@ -98,6 +100,10 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string) { s := &service{db: db} + mux.HandleFunc("/projects/{project}/workspaces", s.protected(callbacks, s.workspaces)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}", s.protected(callbacks, s.workspace)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/members", s.protected(callbacks, s.members)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/members/{account}", s.protected(callbacks, s.member)) mux.HandleFunc("/projects/{project}/profile", s.protected(callbacks, s.profile)) } diff --git a/internal/platform/workspaces.go b/internal/platform/workspaces.go new file mode 100644 index 0000000..b428f92 --- /dev/null +++ b/internal/platform/workspaces.go @@ -0,0 +1,239 @@ +package platform + +import ( + "context" + "database/sql" + "net/http" + "strings" +) + +func (s *service) workspaces(w http.ResponseWriter, r *http.Request, project, account string) error { + switch r.Method { + case http.MethodGet: + rows, err := s.db.QueryContext(r.Context(), ` +SELECT w.id::text,w.name,m.role,w.owner_id::text FROM workspaces w +JOIN workspace_members m ON (m.project,m.workspace_id)=(w.project,w.id) +WHERE m.project=$1 AND m.account_id=$2 ORDER BY lower(w.name),w.id LIMIT 200 +`, project, account) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, name, role, owner string + if err := rows.Scan(&id, &name, &role, &owner); err != nil { + return err + } + items = append(items, response{"id": id, "name": name, "role": role, "owner_id": owner}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + case http.MethodPost: + var input struct { + Name string `json:"name"` + } + if !decode(w, r, &input) { + return nil + } + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 160 { + reject(w, 400, "invalid_workspace") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + id, err := createWorkspace(r.Context(), tx, project, account, input.Name) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, response{"id": id, "name": input.Name, "role": "admin", "owner_id": account}) + default: + reject(w, 405, "method_not_allowed") + } + return nil +} + +func createWorkspace(ctx context.Context, tx *sql.Tx, project, account, name string) (string, error) { + var id string + err := tx.QueryRowContext(ctx, ` +INSERT INTO workspaces(project,name,owner_id) VALUES($1,$2,$3) RETURNING id::text +`, project, name, account).Scan(&id) + if err != nil { + return "", err + } + _, err = tx.ExecContext(ctx, ` +INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,'admin') +`, project, id, account) + return id, err +} + +func lockWorkspace(ctx context.Context, tx *sql.Tx, project, workspace, account string) (string, string, error) { + var owner, role string + err := tx.QueryRowContext(ctx, ` +SELECT owner_id::text FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE +`, project, workspace).Scan(&owner) + if err != nil { + return "", "", err + } + // Read membership after the workspace lock, so concurrent revocation is visible. + err = tx.QueryRowContext(ctx, ` +SELECT role FROM workspace_members WHERE project=$1 AND workspace_id=$2 AND account_id=$3 +`, project, workspace, account).Scan(&role) + return owner, role, err +} + +func (s *service) workspace(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace := r.PathValue("workspace") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + owner, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + switch r.Method { + case http.MethodGet: + var name string + if err := tx.QueryRowContext(r.Context(), "SELECT name FROM workspaces WHERE project=$1 AND id=$2", project, workspace).Scan(&name); err != nil { + return err + } + reply(w, 200, response{"id": workspace, "name": name, "role": role, "owner_id": owner}) + return nil + case http.MethodPatch: + if role != "admin" { + reject(w, 403, "forbidden") + return nil + } + var input struct { + Name string `json:"name"` + } + if !decode(w, r, &input) { + return nil + } + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 160 { + reject(w, 400, "invalid_workspace") + return nil + } + _, err = tx.ExecContext(r.Context(), "UPDATE workspaces SET name=$3,updated_at=now() WHERE project=$1 AND id=$2", project, workspace, input.Name) + case http.MethodDelete: + if owner != account { + reject(w, 403, "owner_required") + return nil + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM workspaces WHERE project=$1 AND id=$2", project, workspace) + default: + reject(w, 405, "method_not_allowed") + return nil + } + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} + +func (s *service) members(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + rows, err := s.db.QueryContext(r.Context(), ` +SELECT m.account_id::text,m.role,a.username,a.display_name FROM workspace_members m +JOIN project_accounts a ON (a.project,a.id)=(m.project,m.account_id) +WHERE m.project=$1 AND m.workspace_id=$2 AND EXISTS( + SELECT 1 FROM workspace_members caller WHERE caller.project=$1 AND caller.workspace_id=$2 AND caller.account_id=$3) +ORDER BY m.joined_at,m.account_id LIMIT 200 +`, project, r.PathValue("workspace"), account) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, role, name string + var username *string + if err := rows.Scan(&id, &role, &username, &name); err != nil { + return err + } + items = append(items, response{"account_id": id, "role": role, "username": username, "display_name": name}) + } + if err := rows.Err(); err != nil { + return err + } + if len(items) == 0 { + return sql.ErrNoRows + } + reply(w, 200, response{"items": items}) + return nil +} + +func (s *service) member(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace, target := r.PathValue("workspace"), r.PathValue("account") + if r.Method != http.MethodPatch && r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + owner, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if owner == target { + reject(w, 409, "owner_membership_required") + return nil + } + if role != "admin" && (r.Method != http.MethodDelete || target != account) { + reject(w, 403, "forbidden") + return nil + } + var result sql.Result + if r.Method == http.MethodPatch { + var input struct { + Role string `json:"role"` + } + if !decode(w, r, &input) { + return nil + } + if input.Role != "admin" && input.Role != "editor" && input.Role != "viewer" { + reject(w, 400, "invalid_role") + return nil + } + result, err = tx.ExecContext(r.Context(), "UPDATE workspace_members SET role=$4 WHERE project=$1 AND workspace_id=$2 AND account_id=$3", project, workspace, target, input.Role) + } else { + result, err = tx.ExecContext(r.Context(), "DELETE FROM workspace_members WHERE project=$1 AND workspace_id=$2 AND account_id=$3", project, workspace, target) + } + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} From 1b756d36ceaa4b78897921222f13c0fe8eeae09e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:24:30 +0800 Subject: [PATCH 04/16] Add configurable onboarding and scoped invitation links --- .gitea/workflows/deploy.yml | 1 + cmd/foundry/main.go | 6 +- db/002-platform.sql | 7 +- deploy/check-schema.sh | 6 + deploy/release.sh | 2 +- internal/platform/http.go | 14 +- internal/platform/integration_test.go | 120 +++++++++++ internal/platform/onboarding.go | 192 +++++++++++++++++ internal/platform/shares.go | 287 ++++++++++++++++++++++++++ 9 files changed, 624 insertions(+), 11 deletions(-) create mode 100644 internal/platform/integration_test.go create mode 100644 internal/platform/onboarding.go create mode 100644 internal/platform/shares.go diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 0839aca..564745f 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -36,4 +36,5 @@ jobs: GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} FOUNDRY_PROJECTS: ${{ vars.FOUNDRY_PROJECTS }} + FOUNDRY_ONBOARDING: ${{ vars.FOUNDRY_ONBOARDING }} run: bash deploy/release.sh bin/foundry diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go index abf4427..0d3ce4c 100644 --- a/cmd/foundry/main.go +++ b/cmd/foundry/main.go @@ -54,7 +54,11 @@ func main() { if err := codex.Register(mux, callbacks, os.Getenv("FOUNDRY_CODEX_SOCKET"), os.Getenv("FOUNDRY_CODEX_GRANTS")); err != nil { log.Fatal(err) } - platform.Register(mux, db, callbacks) + onboarding, err := platform.ParseOnboarding(os.Getenv("FOUNDRY_ONBOARDING"), callbacks) + if err != nil { + log.Fatal(err) + } + platform.Register(mux, db, callbacks, origin.Scheme+"://"+origin.Host, onboarding) mux.Handle("/", handler) server := &http.Server{ Addr: net.JoinHostPort(env("HOST", "127.0.0.1"), env("PORT", "8080")), diff --git a/db/002-platform.sql b/db/002-platform.sql index 97bf9e4..dc8af41 100644 --- a/db/002-platform.sql +++ b/db/002-platform.sql @@ -62,16 +62,11 @@ CREATE TABLE auth_rate_limits ( expires_at timestamptz NOT NULL ); -CREATE TABLE onboarding_configs ( - project text PRIMARY KEY, - require_workspace boolean NOT NULL DEFAULT false, - questions jsonb NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(questions) = 'array'), - updated_at timestamptz NOT NULL DEFAULT now() -); CREATE TABLE onboarding_responses ( project text NOT NULL, account_id uuid NOT NULL, answers jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(answers) = 'object'), + result jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(result) = 'object'), completed_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (project, account_id), FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE diff --git a/deploy/check-schema.sh b/deploy/check-schema.sh index 09f1aff..b97a56c 100644 --- a/deploy/check-schema.sh +++ b/deploy/check-schema.sh @@ -56,3 +56,9 @@ END $$; ROLLBACK; SQL printf 'Migration preserved existing logins and enforced project isolation.\n' +CGO_ENABLED=0 go test -mod=readonly -c -o bin/platform-tests ./internal/platform +docker run --rm --network "container:$container" --user 1000:1000 \ + --read-only --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=bind,source=$(pwd)/bin/platform-tests,target=/opt/platform-tests,readonly" \ + -e PLATFORM_TEST_DATABASE_URL=postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable \ + --entrypoint /opt/platform-tests postgres:17-alpine -test.v diff --git a/deploy/release.sh b/deploy/release.sh index 202127e..eddcf71 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -66,7 +66,7 @@ fi docker run -d --name "$candidate" "${codex_mounts[@]}" --network "$network" \ --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \ - -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS \ + -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS -e FOUNDRY_ONBOARDING \ --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ --read-only --memory 128m \ --mount "type=bind,source=$release/foundry,target=/opt/foundry,readonly" \ diff --git a/internal/platform/http.go b/internal/platform/http.go index 8a468be..58d24d2 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -16,7 +16,10 @@ import ( ) type service struct { - db *sql.DB + db *sql.DB + origin string + callbacks map[string]string + onboardingConfigs map[string]OnboardingConfig } type response map[string]any @@ -98,8 +101,13 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H } } -func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string) { - s := &service{db: db} +func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origin string, configs map[string]OnboardingConfig) { + s := &service{db: db, origin: origin, callbacks: callbacks, onboardingConfigs: configs} + mux.HandleFunc("/projects/{project}/onboarding", s.protected(callbacks, s.onboarding)) + mux.HandleFunc("/s/{token}", s.shortLink) + mux.HandleFunc("/projects/{project}/shares/redeem", s.protected(callbacks, s.redeemShare)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares", s.protected(callbacks, s.shares)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares/{link}", s.protected(callbacks, s.revokeShare)) mux.HandleFunc("/projects/{project}/workspaces", s.protected(callbacks, s.workspaces)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}", s.protected(callbacks, s.workspace)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/members", s.protected(callbacks, s.members)) diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go new file mode 100644 index 0000000..bd7b126 --- /dev/null +++ b/internal/platform/integration_test.go @@ -0,0 +1,120 @@ +package platform + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func TestOnboardingAndSharing(t *testing.T) { + dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("requires an isolated integration database") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + ctx := context.Background() + var raw [12]byte + rand.Read(raw[:]) + project := "test-" + base64.RawURLEncoding.EncodeToString(raw[:]) + accounts := []string{} + tokens := []string{} + for range 3 { + var id string + err = db.QueryRowContext(ctx, "INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text", project).Scan(&id) + if err != nil { + t.Fatal(err) + } + accounts = append(accounts, id) + var value [32]byte + rand.Read(value[:]) + token := base64.RawURLEncoding.EncodeToString(value[:]) + hash := sha256.Sum256([]byte(token)) + _, err = db.ExecContext(ctx, "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '1 hour')", base64.RawURLEncoding.EncodeToString(hash[:]), project, id) + if err != nil { + t.Fatal(err) + } + tokens = append(tokens, token) + } + defer func() { + _, _ = db.ExecContext(ctx, "DELETE FROM workspaces WHERE project=$1", project) + _, _ = db.ExecContext(ctx, "DELETE FROM project_accounts WHERE project=$1", project) + }() + callbacks := map[string]string{project: "https://app.example.com/auth/callback"} + mux := http.NewServeMux() + Register(mux, db, callbacks, "https://api.example.com", map[string]OnboardingConfig{ + project: {RequireWorkspace: true, Questions: []Question{{Key: "purpose", Required: true}}}, + }) + call := func(method, path, token string, body any, status int) response { + t.Helper() + encoded, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest(method, path, bytes.NewReader(encoded)) + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + if w.Code != status { + t.Fatalf("%s %s got %d %s want %d", method, path, w.Code, w.Body.String(), status) + } + out := response{} + if w.Body.Len() > 0 && w.Code != 302 { + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + } + return out + } + base := "/projects/" + project + call("GET", base+"/workspaces", "", nil, 401) + call("POST", base+"/onboarding", tokens[0], response{"answers": response{}}, 400) + first := call("POST", base+"/onboarding", tokens[0], response{"team_name": "Team", "answers": response{"purpose": "Research"}, "generate_invite": true}, 201) + workspace := first["result"].(map[string]any)["workspace_id"].(string) + retry := call("POST", base+"/onboarding", tokens[0], response{"team_name": "Team", "answers": response{"purpose": "Research"}, "generate_invite": true}, 200) + if retry["result"].(map[string]any)["workspace_id"] != workspace { + t.Fatal("onboarding created a second workspace") + } + workspacePath := base + "/workspaces/" + workspace + share := call("POST", workspacePath+"/shares", tokens[0], response{"role": "viewer", "max_uses": 1}, 201) + token := share["token"].(string) + call("GET", "/s/"+token, "", nil, 302) + call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) + call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) + call("POST", base+"/shares/redeem", tokens[2], response{"token": token}, 410) + call("PATCH", workspacePath, tokens[1], response{"name": "Forbidden"}, 403) + call("GET", workspacePath, tokens[2], nil, 404) + call("PATCH", workspacePath+"/members/"+accounts[0], tokens[0], response{"role": "viewer"}, 409) + call("DELETE", workspacePath+"/shares/"+share["id"].(string), tokens[0], nil, 204) + call("GET", "/s/"+token, "", nil, 404) + var material string + err = db.QueryRowContext(ctx, "INSERT INTO materials(project,workspace_id,kind,title,created_by) VALUES($1,$2,'article','Shared',$3) RETURNING id::text", project, workspace, accounts[0]).Scan(&material) + if err != nil { + t.Fatal(err) + } + contentShare := call("POST", workspacePath+"/shares", tokens[0], response{"role": "editor", "material_id": material}, 201) + call("POST", base+"/shares/redeem", tokens[2], response{"token": contentShare["token"]}, 200) + call("GET", workspacePath, tokens[2], nil, 404) + var count int + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM material_grants WHERE project=$1 AND account_id=$2 AND material_id=$3", project, accounts[2], material).Scan(&count); err != nil || count != 1 { + t.Fatalf("material grant missing %v", err) + } + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM link_clicks WHERE project=$1", project).Scan(&count); err != nil || count != 1 { + t.Fatalf("click accounting incorrect %v", err) + } +} diff --git a/internal/platform/onboarding.go b/internal/platform/onboarding.go new file mode 100644 index 0000000..c770c46 --- /dev/null +++ b/internal/platform/onboarding.go @@ -0,0 +1,192 @@ +package platform + +import ( + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "strings" +) + +type Question struct { + Key string `json:"key"` + Required bool `json:"required"` +} +type OnboardingConfig struct { + RequireWorkspace bool `json:"require_workspace"` + Questions []Question `json:"questions"` +} + +var surveyTypes = map[string]string{ + "team_activity": "string", "job_role": "string", "referral": "string", + "previous_use": "boolean", "purpose": "string", "company_size": "string", +} + +func ParseOnboarding(raw string, callbacks map[string]string) (map[string]OnboardingConfig, error) { + configs := map[string]OnboardingConfig{} + if strings.TrimSpace(raw) == "" { + return configs, nil + } + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&configs); err != nil { + return nil, errors.New("invalid FOUNDRY_ONBOARDING JSON") + } + if decoder.Decode(new(any)) != io.EOF { + return nil, errors.New("invalid FOUNDRY_ONBOARDING JSON") + } + for project, config := range configs { + if _, ok := callbacks[project]; !ok { + return nil, errors.New("onboarding project is not configured") + } + seen := map[string]bool{} + for _, question := range config.Questions { + if surveyTypes[question.Key] == "" || seen[question.Key] { + return nil, errors.New("invalid or duplicate onboarding question") + } + seen[question.Key] = true + } + } + return configs, nil +} + +func validAnswers(config OnboardingConfig, answers map[string]json.RawMessage) bool { + questions := map[string]Question{} + for _, question := range config.Questions { + questions[question.Key] = question + } + for key, value := range answers { + if _, ok := questions[key]; !ok { + return false + } + if surveyTypes[key] == "boolean" { + var answer *bool + if json.Unmarshal(value, &answer) != nil || answer == nil { + return false + } + } else { + var answer *string + if json.Unmarshal(value, &answer) != nil || answer == nil || len(*answer) > 2000 { + return false + } + } + } + for _, question := range config.Questions { + value, ok := answers[question.Key] + if question.Required { + if !ok { + return false + } + if surveyTypes[question.Key] == "string" { + var answer string + if json.Unmarshal(value, &answer) != nil || strings.TrimSpace(answer) == "" { + return false + } + } + } + } + return true +} + +func (s *service) onboarding(w http.ResponseWriter, r *http.Request, project, account string) error { + config := s.onboardingConfigs[project] + if config.Questions == nil { + config.Questions = []Question{} + } + if r.Method == http.MethodGet { + var answers, result json.RawMessage + err := s.db.QueryRowContext(r.Context(), "SELECT answers,result FROM onboarding_responses WHERE project=$1 AND account_id=$2", project, account).Scan(&answers, &result) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + reply(w, 200, response{"config": config, "completed": err == nil, "answers": answers, "result": result}) + return nil + } + if r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + var input struct { + Username *string `json:"username"` + TeamName string `json:"team_name"` + GenerateInvite bool `json:"generate_invite"` + Answers map[string]json.RawMessage `json:"answers"` + } + if !decode(w, r, &input) { + return nil + } + input.TeamName = strings.TrimSpace(input.TeamName) + if input.Answers == nil { + input.Answers = map[string]json.RawMessage{} + } + if input.Username != nil { + value := strings.ToLower(strings.TrimSpace(*input.Username)) + input.Username = &value + if !usernamePattern.MatchString(value) { + reject(w, 400, "invalid_username") + return nil + } + } + if !validAnswers(config, input.Answers) || len(input.TeamName) > 160 || (config.RequireWorkspace && input.TeamName == "") || (input.GenerateInvite && input.TeamName == "") { + reject(w, 400, "invalid_onboarding") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + var locked string + if err = tx.QueryRowContext(r.Context(), "SELECT id::text FROM project_accounts WHERE project=$1 AND id=$2 FOR UPDATE", project, account).Scan(&locked); err != nil { + return err + } + var prior json.RawMessage + err = tx.QueryRowContext(r.Context(), "SELECT result FROM onboarding_responses WHERE project=$1 AND account_id=$2", project, account).Scan(&prior) + if err == nil { + reply(w, 200, response{"completed": true, "result": prior, "already_completed": true}) + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + if input.Username != nil { + _, err = tx.ExecContext(r.Context(), "UPDATE project_accounts SET username=$3,updated_at=now() WHERE project=$1 AND id=$2", project, account, *input.Username) + if err != nil { + return err + } + } + result := response{} + var invitation string + if input.TeamName != "" { + workspace, err := createWorkspace(r.Context(), tx, project, account, input.TeamName) + if err != nil { + return err + } + result["workspace_id"] = workspace + if input.GenerateInvite { + token, hash := newLinkToken() + var id string + err = tx.QueryRowContext(r.Context(), ` +INSERT INTO share_links(project,workspace_id,token_hash,role,created_by,expires_at) +VALUES($1,$2,$3,'viewer',$4,now()+interval '7 days') RETURNING id::text +`, project, workspace, hash, account).Scan(&id) + if err != nil { + return err + } + result["invite_id"] = id + invitation = s.origin + "/s/" + token + } + } + answers, _ := json.Marshal(input.Answers) + resultJSON, _ := json.Marshal(result) + _, err = tx.ExecContext(r.Context(), "INSERT INTO onboarding_responses(project,account_id,answers,result) VALUES($1,$2,$3,$4)", project, account, answers, resultJSON) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, response{"completed": true, "result": result, "invite_url": invitation, "already_completed": false}) + return nil +} diff --git a/internal/platform/shares.go b/internal/platform/shares.go new file mode 100644 index 0000000..f660f99 --- /dev/null +++ b/internal/platform/shares.go @@ -0,0 +1,287 @@ +package platform + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "net/http" + "net/url" + "regexp" + "time" +) + +var linkTokenPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`) + +func newLinkToken() (string, string) { + var raw [16]byte + rand.Read(raw[:]) + value := base64.RawURLEncoding.EncodeToString(raw[:]) + return value, linkDigest(value) +} +func linkDigest(token string) string { + sum := sha256.Sum256([]byte(token)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +type shareInput struct { + Role string `json:"role"` + MaterialID *string `json:"material_id"` + ExpiresIn int `json:"expires_in"` + MaxUses *int `json:"max_uses"` +} + +func (s *service) shares(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace := r.PathValue("workspace") + if r.Method != http.MethodPost && r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "forbidden") + return nil + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), ` +SELECT l.id::text,l.material_id::text,l.role,l.expires_at,l.revoked_at,l.max_uses,l.use_count, + (SELECT count(*) FROM link_clicks c WHERE (c.project,c.workspace_id,c.link_id)=(l.project,l.workspace_id,l.id)) +FROM share_links l WHERE project=$1 AND workspace_id=$2 ORDER BY created_at DESC,id LIMIT 200 +`, project, workspace) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, role string + var material *string + var expires time.Time + var revoked *time.Time + var max *int + var uses, clicks int64 + if err := rows.Scan(&id, &material, &role, &expires, &revoked, &max, &uses, &clicks); err != nil { + return err + } + items = append(items, response{"id": id, "material_id": material, "role": role, "expires_at": expires, "revoked_at": revoked, "max_uses": max, "use_count": uses, "click_count": clicks}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + return nil + } + var input shareInput + if !decode(w, r, &input) { + return nil + } + if input.ExpiresIn == 0 { + input.ExpiresIn = 7 * 86400 + } + if (input.Role != "admin" && input.Role != "editor" && input.Role != "viewer") || + (input.MaterialID != nil && input.Role == "admin") || input.ExpiresIn < 60 || input.ExpiresIn > 30*86400 || + (input.MaxUses != nil && (*input.MaxUses < 1 || *input.MaxUses > 100000)) { + reject(w, 400, "invalid_share") + return nil + } + token, hash := newLinkToken() + var id string + var expires time.Time + err = tx.QueryRowContext(r.Context(), ` +INSERT INTO share_links(project,workspace_id,material_id,token_hash,role,created_by,expires_at,max_uses) +VALUES($1,$2,$3,$4,$5,$6,now()+$7*interval '1 second',$8) RETURNING id::text,expires_at +`, project, workspace, input.MaterialID, hash, input.Role, account, input.ExpiresIn, input.MaxUses).Scan(&id, &expires) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, response{"id": id, "url": s.origin + "/s/" + token, "token": token, "role": input.Role, "expires_at": expires}) + return nil +} + +func (s *service) revokeShare(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, r.PathValue("workspace"), account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "forbidden") + return nil + } + result, err := tx.ExecContext(r.Context(), ` +UPDATE share_links SET revoked_at=COALESCE(revoked_at,now()) WHERE project=$1 AND workspace_id=$2 AND id=$3 +`, project, r.PathValue("workspace"), r.PathValue("link")) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} + +// A public short link records only a referring origin and redirects to a fixed +// frontend. The fragment carries the invitation, never a login session. +func (s *service) shortLink(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return + } + token := r.PathValue("token") + if !linkTokenPattern.MatchString(token) { + reject(w, 404, "not_found") + return + } + ref := "" + if u, err := url.Parse(r.Referer()); err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" && u.User == nil { + ref = u.Scheme + "://" + u.Host + if len(ref) > 300 { + ref = "" + } + } + var project string + err := s.db.QueryRowContext(r.Context(), ` +WITH valid AS( + SELECT project,workspace_id,id FROM share_links + WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now() AND (max_uses IS NULL OR use_countnow(), (max_uses IS NULL OR use_count 0 { + if material == nil { + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,$4) +ON CONFLICT(project,workspace_id,account_id) DO UPDATE SET role=CASE + WHEN workspace_members.role='admin' OR EXCLUDED.role='admin' THEN 'admin' + WHEN workspace_members.role='editor' OR EXCLUDED.role='editor' THEN 'editor' ELSE 'viewer' END +`, project, workspace, account, role) + } else { + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO material_grants(project,workspace_id,material_id,account_id,role) VALUES($1,$2,$3,$4,$5) +ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET role=CASE + WHEN material_grants.role='editor' OR EXCLUDED.role='editor' THEN 'editor' ELSE 'viewer' END +`, project, workspace, *material, account, role) + } + if err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), "UPDATE share_links SET use_count=use_count+1 WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, id) + if err != nil { + return err + } + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 200, response{"workspace_id": workspace, "material_id": material, "role": role, "already_redeemed": n == 0}) + return nil +} From 0d0521f13dc131ffa565b1cd593092b577cb77d8 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:31:09 +0800 Subject: [PATCH 05/16] Implement verified email registration and queued TLS delivery --- .env.example | 7 + cmd/foundry/main.go | 8 +- db/002-platform.sql | 2 + deploy/release.sh | 6 +- internal/auth/auth.go | 16 +- internal/auth/email.go | 204 ++++++++++++++++++++++++++ internal/auth/mailer.go | 113 ++++++++++++++ internal/platform/integration_test.go | 31 +++- 8 files changed, 381 insertions(+), 6 deletions(-) create mode 100644 internal/auth/email.go create mode 100644 internal/auth/mailer.go diff --git a/.env.example b/.env.example index c879d5b..c752c26 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,10 @@ GOOGLE_CLIENT_SECRET=replace-me FOUNDRY_PROJECTS='{"notes":"http://localhost:3000/auth/callback","board":"http://localhost:3001/auth/callback"}' HOST=127.0.0.1 PORT=8080 + +# Optional email verification through TLS SMTP. +SMTP_HOST=smtpdm.aliyun.com +SMTP_PORT=465 +SMTP_USERNAME=noreply@notice.entroia.com +SMTP_PASSWORD=replace-me +SMTP_FROM=noreply@notice.entroia.com diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go index 0d3ce4c..6b25a5e 100644 --- a/cmd/foundry/main.go +++ b/cmd/foundry/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "log" "net" @@ -46,7 +47,12 @@ func main() { } db := stdlib.OpenDB(*config) defer db.Close() - handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret) + email, err := auth.EmailConfigFromEnv() + if err != nil { + log.Fatal(err) + } + go email.Run(context.Background(), db) + handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret, email) if err != nil { log.Fatal(err) } diff --git a/db/002-platform.sql b/db/002-platform.sql index dc8af41..3721b0d 100644 --- a/db/002-platform.sql +++ b/db/002-platform.sql @@ -52,6 +52,8 @@ CREATE TABLE email_outbox ( attempts integer NOT NULL DEFAULT 0, available_at timestamptz NOT NULL DEFAULT now(), lease_until timestamptz, + lease_id uuid, + expires_at timestamptz NOT NULL, sent_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() ); diff --git a/deploy/release.sh b/deploy/release.sh index eddcf71..3636858 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -44,6 +44,10 @@ docker network inspect "$network" >/dev/null release=$(mktemp -d "$foundry/release.XXXXXXXX") install -m 755 "$1" "$release/foundry" export HOST=0.0.0.0 PORT=8080 PUBLIC_ORIGIN="$origin" +mail_env=() +if [[ -s "$foundry/mail.env" ]]; then + mail_env=(--env-file "$foundry/mail.env") +fi codex_mounts=() codex_dir=$foundry/codex if [[ -d "$codex_dir" ]]; then @@ -64,7 +68,7 @@ if [[ -d "$codex_dir" ]]; then ) fi -docker run -d --name "$candidate" "${codex_mounts[@]}" --network "$network" \ +docker run -d --name "$candidate" "${codex_mounts[@]}" "${mail_env[@]}" --network "$network" \ --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \ -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS -e FOUNDRY_ONBOARDING \ --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ diff --git a/internal/auth/auth.go b/internal/auth/auth.go index a1ef605..e2d0428 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "golang.org/x/crypto/bcrypt" "io" "log" "net/http" @@ -26,10 +27,17 @@ type service struct { projects map[string]*url.URL redirectURI, clientID, secret string google *http.Client + email *EmailConfig + passwordSlots chan struct{} + dummyPassword []byte } -func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string) (http.Handler, error) { - s := &service{db: db, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret, +func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string, email *EmailConfig) (http.Handler, error) { + dummy, err := bcrypt.GenerateFromPassword([]byte("foundry-invalid-login-placeholder"), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + s := &service{db: db, email: email, passwordSlots: make(chan struct{}, 4), dummyPassword: dummy, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret, google: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} for name, raw := range callbacks { u, err := url.Parse(raw) @@ -120,8 +128,10 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error { switch { case r.Method == http.MethodOptions: w.Header().Set("Access-Control-Allow-Methods", "GET, POST") - w.Header().Set("Access-Control-Allow-Headers", "Authorization, X-Foundry-Code, X-Foundry-Verifier") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Foundry-Code, X-Foundry-Verifier") reply(w, 204, nil) + case r.Method == http.MethodPost && (action == "register" || action == "verify-email" || action == "login"): + return s.emailAction(w, r, project, action) case r.Method == http.MethodPost && action == "exchange": return s.exchange(w, r, project) case r.Method == http.MethodGet && action == "me": diff --git a/internal/auth/email.go b/internal/auth/email.go new file mode 100644 index 0000000..c69707a --- /dev/null +++ b/internal/auth/email.go @@ -0,0 +1,204 @@ +package auth + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "net/mail" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +func emailAddress(value string) (string, bool) { + value = strings.ToLower(strings.TrimSpace(value)) + address, err := mail.ParseAddress(value) + return value, err == nil && address.Address == value && len(value) <= 254 +} +func emailBody(w http.ResponseWriter, r *http.Request, input any) bool { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16384)) + decoder.DisallowUnknownFields() + if decoder.Decode(input) != nil || decoder.Decode(new(any)) != io.EOF { + _ = reject(w, 400, "invalid_request") + return false + } + return true +} + +func (s *service) rateLimit(ctx context.Context, key string, limit int) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, ` +INSERT INTO auth_rate_limits(key_hash,attempts,expires_at) VALUES($1,1,now()+interval '15 minutes') +ON CONFLICT(key_hash) DO UPDATE SET + attempts=CASE WHEN auth_rate_limits.expires_at<=now() THEN 1 ELSE auth_rate_limits.attempts+1 END, + expires_at=CASE WHEN auth_rate_limits.expires_at<=now() THEN now()+interval '15 minutes' ELSE auth_rate_limits.expires_at END +RETURNING attempts +`, digest(key)).Scan(&count) + return count <= limit, err +} + +func (s *service) emailAction(w http.ResponseWriter, r *http.Request, project, action string) error { + if action == "verify-email" { + return s.verifyEmail(w, r, project) + } + var input struct { + Email string `json:"email"` + Password string `json:"password"` + } + if !emailBody(w, r, &input) { + return nil + } + email, valid := emailAddress(input.Email) + if !valid || len(input.Password) < 12 || len(input.Password) > 72 { + return reject(w, 400, "invalid_credentials_format") + } + limit := 10 + if action == "register" { + if s.email == nil { + return reject(w, 503, "email_not_configured") + } + limit = 3 + } + allowed, err := s.rateLimit(r.Context(), action+":"+project+":"+email, limit) + if err != nil { + return err + } + if !allowed { + w.Header().Set("Retry-After", "900") + return reject(w, 429, "rate_limited") + } + select { + case s.passwordSlots <- struct{}{}: + defer func() { <-s.passwordSlots }() + default: + w.Header().Set("Retry-After", "2") + return reject(w, 429, "auth_busy") + } + if action == "register" { + return s.registerEmail(w, r, project, email, input.Password) + } + var account, hash string + err = s.db.QueryRowContext(r.Context(), "SELECT account_id::text,password_hash FROM email_credentials WHERE project=$1 AND email=$2", project, email).Scan(&account, &hash) + missing := errors.Is(err, sql.ErrNoRows) + if err != nil && !missing { + return err + } + passwordHash := []byte(hash) + if missing { + passwordHash = s.dummyPassword + } + if bcrypt.CompareHashAndPassword(passwordHash, []byte(input.Password)) != nil || missing { + return reject(w, 401, "invalid_credentials") + } + session := token() + _, err = s.db.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account) + if err != nil { + return err + } + reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800}) + return nil +} + +func (s *service) registerEmail(w http.ResponseWriter, r *http.Request, project, email, password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + verification := token() + result, err := tx.ExecContext(r.Context(), ` +INSERT INTO email_registrations(project,email,password_hash,token_hash,expires_at) +SELECT $1,$2,$3,$4,now()+interval '30 minutes' +WHERE NOT EXISTS(SELECT 1 FROM email_credentials WHERE project=$1 AND email=$2) +ON CONFLICT(project,email) DO UPDATE SET password_hash=EXCLUDED.password_hash,token_hash=EXCLUDED.token_hash,expires_at=EXCLUDED.expires_at +`, project, email, string(hash), digest(verification)) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n > 0 { + destination := *s.projects[project] + destination.Fragment = "verify_email=" + verification + body := "Confirm your email address for " + project + ".\r\n\r\n" + destination.String() + "\r\n\r\nThis link expires in 30 minutes. If you did not register, ignore this email." + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO email_outbox(project,recipient,subject,body,expires_at) VALUES($1,$2,'Verify your email', $3, now()+interval '30 minutes') +`, project, email, body) + if err != nil { + return err + } + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 202, payload{"status": "verification_requested"}) + return nil +} + +func (s *service) verifyEmail(w http.ResponseWriter, r *http.Request, project string) error { + var input struct { + Token string `json:"token"` + } + if !emailBody(w, r, &input) { + return nil + } + if !tokenPattern.MatchString(input.Token) { + return reject(w, 400, "invalid_verification") + } + allowed, err := s.rateLimit(r.Context(), "verify:"+project+":"+digest(input.Token), 10) + if err != nil { + return err + } + if !allowed { + return reject(w, 429, "rate_limited") + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + var email, hash string + err = tx.QueryRowContext(r.Context(), ` +DELETE FROM email_registrations WHERE project=$1 AND token_hash=$2 AND expires_at>now() RETURNING email,password_hash +`, project, digest(input.Token)).Scan(&email, &hash) + if errors.Is(err, sql.ErrNoRows) { + return reject(w, 400, "invalid_verification") + } + if err != nil { + return err + } + var account string + err = tx.QueryRowContext(r.Context(), ` +WITH identity AS(SELECT gen_random_uuid() AS id) +INSERT INTO project_accounts(project,id,email,username) +SELECT $1,id,$2, + left(COALESCE(NULLIF(regexp_replace(split_part($2,'@',1),'[^a-z0-9]','','g'),''),'user'),7) + || '_' || replace(id::text,'-','') FROM identity RETURNING id::text +`, project, email).Scan(&account) + if err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), "INSERT INTO email_credentials(project,email,account_id,password_hash) VALUES($1,$2,$3,$4)", project, email, account, hash) + if err != nil { + return err + } + session := token() + _, err = tx.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800}) + return nil +} diff --git a/internal/auth/mailer.go b/internal/auth/mailer.go new file mode 100644 index 0000000..77efda5 --- /dev/null +++ b/internal/auth/mailer.go @@ -0,0 +1,113 @@ +package auth + +import ( + "context" + "crypto/tls" + "database/sql" + "errors" + "fmt" + "log" + "net" + "net/mail" + "net/smtp" + "os" + "strings" + "time" +) + +type EmailConfig struct { + Host, Port, Username, Password, From string +} + +func EmailConfigFromEnv() (*EmailConfig, error) { + c := &EmailConfig{Host: os.Getenv("SMTP_HOST"), Port: os.Getenv("SMTP_PORT"), Username: os.Getenv("SMTP_USERNAME"), Password: os.Getenv("SMTP_PASSWORD"), From: os.Getenv("SMTP_FROM")} + if c.Host == "" && c.Port == "" && c.Username == "" && c.Password == "" && c.From == "" { + return nil, nil + } + address, err := mail.ParseAddress(c.From) + if c.Host == "" || c.Port != "465" || c.Username == "" || c.Password == "" || err != nil || address.Address != c.From || strings.ContainsAny(c.Host+c.Username+c.From, "\r\n") { + return nil, errors.New("invalid SMTP configuration; TLS port 465 and a plain sender address are required") + } + return c, nil +} + +func (c *EmailConfig) send(recipient, subject, body string) error { + if strings.ContainsAny(recipient+subject, "\r\n") { + return errors.New("invalid mail headers") + } + connection, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", net.JoinHostPort(c.Host, c.Port), &tls.Config{ServerName: c.Host, MinVersion: tls.VersionTLS12}) + if err != nil { + return err + } + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(30 * time.Second)) + client, err := smtp.NewClient(connection, c.Host) + if err != nil { + return err + } + defer client.Close() + if err = client.Auth(smtp.PlainAuth("", c.Username, c.Password, c.Host)); err != nil { + return err + } + if err = client.Mail(c.From); err != nil { + return err + } + if err = client.Rcpt(recipient); err != nil { + return err + } + writer, err := client.Data() + if err != nil { + return err + } + message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n", c.From, recipient, subject, body) + if _, err = writer.Write([]byte(message)); err != nil { + return err + } + if err = writer.Close(); err != nil { + return err + } + return client.Quit() +} + +func (c *EmailConfig) Run(ctx context.Context, db *sql.DB) { + if c == nil { + return + } + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := c.deliver(ctx, db); err != nil && !errors.Is(err, sql.ErrNoRows) { + log.Printf("email delivery failed (%T)", err) + } + } + } +} +func (c *EmailConfig) deliver(ctx context.Context, db *sql.DB) error { + var id, lease, recipient, subject, body string + var attempts int + err := db.QueryRowContext(ctx, ` +UPDATE email_outbox SET lease_id=gen_random_uuid(),lease_until=now()+interval '2 minutes',attempts=attempts+1 +WHERE id=(SELECT id FROM email_outbox WHERE sent_at IS NULL AND expires_at>now() AND attempts<10 + AND available_at<=now() AND (lease_until IS NULL OR lease_until<=now()) + ORDER BY available_at,id FOR UPDATE SKIP LOCKED LIMIT 1) +RETURNING id::text,lease_id::text,recipient,subject,body,attempts +`).Scan(&id, &lease, &recipient, &subject, &body, &attempts) + if err != nil { + return err + } + sendErr := c.send(recipient, subject, body) + if sendErr == nil { + _, err = db.ExecContext(ctx, "UPDATE email_outbox SET sent_at=now(),body='',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease) + return err + } + delay := 30 * (1 << min(attempts, 7)) + _, err = db.ExecContext(ctx, "UPDATE email_outbox SET available_at=now()+$3*interval '1 second',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease, delay) + if err != nil { + return err + } + return sendErr +} diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index bd7b126..ead8ccc 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -7,10 +7,13 @@ import ( "crypto/sha256" "database/sql" "encoding/base64" + "encoding/hex" "encoding/json" + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" "net/http" "net/http/httptest" "os" + "regexp" "testing" _ "github.com/jackc/pgx/v5/stdlib" @@ -29,7 +32,7 @@ func TestOnboardingAndSharing(t *testing.T) { ctx := context.Background() var raw [12]byte rand.Read(raw[:]) - project := "test-" + base64.RawURLEncoding.EncodeToString(raw[:]) + project := "test-" + hex.EncodeToString(raw[:]) accounts := []string{} tokens := []string{} for range 3 { @@ -52,9 +55,15 @@ func TestOnboardingAndSharing(t *testing.T) { defer func() { _, _ = db.ExecContext(ctx, "DELETE FROM workspaces WHERE project=$1", project) _, _ = db.ExecContext(ctx, "DELETE FROM project_accounts WHERE project=$1", project) + _, _ = db.ExecContext(ctx, "DELETE FROM email_outbox WHERE project=$1", project) }() callbacks := map[string]string{project: "https://app.example.com/auth/callback"} mux := http.NewServeMux() + authHandler, err := auth.New(db, callbacks, "https://api.example.com/auth/google/callback", "fixture", "fixture", &auth.EmailConfig{}) + if err != nil { + t.Fatal(err) + } + mux.Handle("/", authHandler) Register(mux, db, callbacks, "https://api.example.com", map[string]OnboardingConfig{ project: {RequireWorkspace: true, Questions: []Question{{Key: "purpose", Required: true}}}, }) @@ -110,6 +119,26 @@ func TestOnboardingAndSharing(t *testing.T) { contentShare := call("POST", workspacePath+"/shares", tokens[0], response{"role": "editor", "material_id": material}, 201) call("POST", base+"/shares/redeem", tokens[2], response{"token": contentShare["token"]}, 200) call("GET", workspacePath, tokens[2], nil, 404) + email := "fixture@example.invalid" + password := "a-long-fixture-password" + call("POST", base+"/auth/register", "", response{"email": email, "password": password}, 202) + call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 401) + var body string + if err := db.QueryRowContext(ctx, "SELECT body FROM email_outbox WHERE project=$1 AND recipient=$2 ORDER BY created_at DESC LIMIT 1", project, email).Scan(&body); err != nil { + t.Fatal(err) + } + match := regexp.MustCompile(`verify_email=([A-Za-z0-9_-]{43})`).FindStringSubmatch(body) + if len(match) != 2 { + t.Fatal("missing email verification link") + } + verified := call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 201) + call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 400) + call("GET", base+"/profile", verified["access_token"].(string), nil, 200) + call("POST", base+"/auth/login", "", response{"email": email, "password": "wrong-long-password"}, 401) + loggedIn := call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 200) + call("POST", base+"/auth/logout", loggedIn["access_token"].(string), nil, 204) + call("GET", base+"/profile", loggedIn["access_token"].(string), nil, 401) + var count int if err := db.QueryRowContext(ctx, "SELECT count(*) FROM material_grants WHERE project=$1 AND account_id=$2 AND material_id=$3", project, accounts[2], material).Scan(&count); err != nil || count != 1 { t.Fatalf("material grant missing %v", err) From bfcea1b3af34ef97fd8976e1a3d57adc41c6c45e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:36:53 +0800 Subject: [PATCH 06/16] Add structured materials, canvas layouts, tags and content permissions --- internal/platform/content.go | 129 +++++++++++ internal/platform/grants.go | 96 +++++++++ internal/platform/http.go | 11 + internal/platform/integration_test.go | 46 +++- internal/platform/layouts.go | 158 ++++++++++++++ internal/platform/materials.go | 297 ++++++++++++++++++++++++++ internal/platform/tags.go | 169 +++++++++++++++ 7 files changed, 905 insertions(+), 1 deletion(-) create mode 100644 internal/platform/content.go create mode 100644 internal/platform/grants.go create mode 100644 internal/platform/layouts.go create mode 100644 internal/platform/materials.go create mode 100644 internal/platform/tags.go diff --git a/internal/platform/content.go b/internal/platform/content.go new file mode 100644 index 0000000..262f330 --- /dev/null +++ b/internal/platform/content.go @@ -0,0 +1,129 @@ +package platform + +import ( + "bytes" + "encoding/json" + "io" + "net/url" + "regexp" +) + +type Paragraph struct { + ID string `json:"id"` + Text string `json:"text"` + Description string `json:"description,omitempty"` +} +type Section struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Paragraphs []Paragraph `json:"paragraphs"` +} +type Article struct { + Sections []Section `json:"sections"` +} +type Image struct { + URL string `json:"url"` + Alt string `json:"alt,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} +type CardPart struct { + Kind string `json:"kind"` + Text *string `json:"text,omitempty"` + Article *Article `json:"article,omitempty"` + Image *Image `json:"image,omitempty"` +} +type Content struct { + SchemaVersion int `json:"schema_version"` + Text *string `json:"text,omitempty"` + Article *Article `json:"article,omitempty"` + Image *Image `json:"image,omitempty"` + Parts []CardPart `json:"parts,omitempty"` +} + +var blockID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +func validArticle(article *Article) bool { + if article == nil || len(article.Sections) > 200 { + return false + } + seen := map[string]bool{} + total := 0 + for _, section := range article.Sections { + if !blockID.MatchString(section.ID) || seen[section.ID] || len(section.Title) > 300 || len(section.Description) > 10000 { + return false + } + seen[section.ID] = true + total += len(section.Paragraphs) + if total > 2000 { + return false + } + for _, paragraph := range section.Paragraphs { + if !blockID.MatchString(paragraph.ID) || seen[paragraph.ID] || len(paragraph.Text) > 100000 || len(paragraph.Description) > 10000 { + return false + } + seen[paragraph.ID] = true + } + } + return true +} +func validImage(image *Image) bool { + if image == nil || len(image.URL) > 2048 || len(image.Alt) > 2000 || image.Width < 0 || image.Width > 100000 || image.Height < 0 || image.Height > 100000 { + return false + } + u, err := url.Parse(image.URL) + return err == nil && u.Scheme == "https" && u.Hostname() != "" && u.User == nil && u.Fragment == "" +} +func validPart(part CardPart) bool { + switch part.Kind { + case "text": + return part.Text != nil && len(*part.Text) <= 100000 && part.Article == nil && part.Image == nil + case "article": + return part.Text == nil && part.Image == nil && validArticle(part.Article) + case "image": + return part.Text == nil && part.Article == nil && validImage(part.Image) + default: + return false + } +} +func normalizeContent(kind string, raw json.RawMessage) (json.RawMessage, bool) { + if raw = bytes.TrimSpace(raw); len(raw) == 0 || raw[0] != '{' { + return nil, false + } + var content Content + d := json.NewDecoder(bytes.NewReader(raw)) + d.DisallowUnknownFields() + if d.Decode(&content) != nil || d.Decode(new(any)) != io.EOF { + return nil, false + } + if content.SchemaVersion == 0 { + content.SchemaVersion = 1 + } + if content.SchemaVersion != 1 { + return nil, false + } + switch kind { + case "text", "article", "image": + if len(content.Parts) > 0 || !validPart(CardPart{Kind: kind, Text: content.Text, Article: content.Article, Image: content.Image}) { + return nil, false + } + case "board": + if content.Text != nil || content.Article != nil || content.Image != nil || len(content.Parts) > 0 { + return nil, false + } + case "card": + if content.Text != nil || content.Article != nil || content.Image != nil || len(content.Parts) < 1 || len(content.Parts) > 20 { + return nil, false + } + for _, part := range content.Parts { + if !validPart(part) { + return nil, false + } + } + default: + return nil, false + } + out, err := json.Marshal(content) + return out, err == nil +} diff --git a/internal/platform/grants.go b/internal/platform/grants.go new file mode 100644 index 0000000..6dde717 --- /dev/null +++ b/internal/platform/grants.go @@ -0,0 +1,96 @@ +package platform + +import "net/http" + +func (s *service) materialGrants(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace, material := r.PathValue("workspace"), r.PathValue("material") + if r.Method != http.MethodGet && r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "admin_required") + return nil + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), "SELECT account_id::text,role FROM material_grants WHERE project=$1 AND workspace_id=$2 AND material_id=$3 ORDER BY account_id LIMIT 1000", project, workspace, material) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, role string + if err := rows.Scan(&id, &role); err != nil { + return err + } + items = append(items, response{"account_id": id, "role": role}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + return nil + } + var input struct { + AccountID string `json:"account_id"` + Role string `json:"role"` + } + if !decode(w, r, &input) { + return nil + } + if input.Role != "editor" && input.Role != "viewer" { + reject(w, 400, "invalid_role") + return nil + } + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO material_grants(project,workspace_id,material_id,account_id,role) VALUES($1,$2,$3,$4,$5) +ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET role=EXCLUDED.role +`, project, workspace, material, input.AccountID, input.Role) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} +func (s *service) deleteMaterialGrant(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, material := r.PathValue("workspace"), r.PathValue("material") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "admin_required") + return nil + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM material_grants WHERE project=$1 AND workspace_id=$2 AND material_id=$3 AND account_id=$4", project, workspace, material, r.PathValue("account")) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} diff --git a/internal/platform/http.go b/internal/platform/http.go index 58d24d2..4067bdf 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -69,6 +69,7 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H return } w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Expose-Headers", "ETag") } if r.Method == http.MethodOptions { w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") @@ -103,6 +104,16 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origin string, configs map[string]OnboardingConfig) { s := &service{db: db, origin: origin, callbacks: callbacks, onboardingConfigs: configs} + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials", s.protected(callbacks, s.materials)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}", s.protected(callbacks, s.material)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/open", s.protected(callbacks, s.openMaterial)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/tags", s.protected(callbacks, s.tags)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/tags/{tag}", s.protected(callbacks, s.deleteTag)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/tags", s.protected(callbacks, s.materialTags)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/grants", s.protected(callbacks, s.materialGrants)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/grants/{account}", s.protected(callbacks, s.deleteMaterialGrant)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/layouts", s.protected(callbacks, s.layouts)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/layouts/{layout}", s.protected(callbacks, s.layouts)) mux.HandleFunc("/projects/{project}/onboarding", s.protected(callbacks, s.onboarding)) mux.HandleFunc("/s/{token}", s.shortLink) mux.HandleFunc("/projects/{project}/shares/redeem", s.protected(callbacks, s.redeemShare)) diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index ead8ccc..14cff61 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -67,7 +67,7 @@ func TestOnboardingAndSharing(t *testing.T) { Register(mux, db, callbacks, "https://api.example.com", map[string]OnboardingConfig{ project: {RequireWorkspace: true, Questions: []Question{{Key: "purpose", Required: true}}}, }) - call := func(method, path, token string, body any, status int) response { + call := func(method, path, token string, body any, status int, versions ...string) response { t.Helper() encoded, err := json.Marshal(body) if err != nil { @@ -77,6 +77,9 @@ func TestOnboardingAndSharing(t *testing.T) { if token != "" { r.Header.Set("Authorization", "Bearer "+token) } + if len(versions) > 0 { + r.Header.Set("If-Match", versions[0]) + } w := httptest.NewRecorder() mux.ServeHTTP(w, r) if w.Code != status { @@ -119,6 +122,47 @@ func TestOnboardingAndSharing(t *testing.T) { contentShare := call("POST", workspacePath+"/shares", tokens[0], response{"role": "editor", "material_id": material}, 201) call("POST", base+"/shares/redeem", tokens[2], response{"token": contentShare["token"]}, 200) call("GET", workspacePath, tokens[2], nil, 404) + articleContent := response{"schema_version": 1, "article": response{"sections": []any{response{ + "id": "chapter-1", "title": "Chapter", "description": "Chapter summary", "paragraphs": []any{response{"id": "p1", "text": "Paragraph", "description": "Paragraph summary"}}, + }}}} + created := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "article", "title": "Article", "content": articleContent}, 201) + articleID := created["id"].(string) + text := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "text", "title": "Alpha", "content": response{"text": "Original"}}, 201) + textPath := workspacePath + "/materials/" + text["id"].(string) + call("GET", textPath, tokens[2], nil, 404) + call("POST", textPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204) + call("GET", textPath, tokens[2], nil, 200) + changed := response{"title": "Beta", "content": response{"text": "Changed"}} + call("PATCH", textPath, tokens[2], changed, 403, `"1"`) + call("POST", textPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "editor"}, 204) + call("PATCH", textPath, tokens[2], changed, 428) + call("PATCH", textPath, tokens[2], changed, 204, `"1"`) + call("PATCH", textPath, tokens[0], changed, 412, `"1"`) + call("POST", workspacePath+"/materials", tokens[0], response{"kind": "article", "title": "Invalid", "content": response{"markdown": "raw markdown"}}, 400) + tag := call("POST", workspacePath+"/tags", tokens[0], response{"name": "Research", "color": "#123456"}, 201) + call("GET", workspacePath+"/tags", tokens[2], nil, 404) + call("POST", textPath+"/tags", tokens[2], response{"tags": []string{tag["id"].(string)}}, 204, `"2"`) + call("POST", textPath+"/open", tokens[2], nil, 204) + filtered := call("GET", workspacePath+"/materials?sort=opened&tag="+tag["id"].(string), tokens[2], nil, 200) + if len(filtered["items"].([]any)) != 1 { + t.Fatal("tag-filtered material list leaked inaccessible materials") + } + board := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Board", "content": response{}}, 201) + boardPath := workspacePath + "/materials/" + board["id"].(string) + layout := call("POST", boardPath+"/layouts", tokens[0], response{"material_id": articleID, "x": 1, "y": 2, "width": 320, "height": 200, "z_index": 1}, 201, `"1"`) + call("GET", boardPath+"/layouts", tokens[1], nil, 200) + call("POST", boardPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204) + hidden := call("GET", boardPath+"/layouts", tokens[2], nil, 200) + if len(hidden["items"].([]any)) != 0 { + t.Fatal("board share disclosed private child material") + } + call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 412, `"1"`) + call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 204, `"2"`) + call("DELETE", textPath+"/grants/"+accounts[2], tokens[0], nil, 204) + call("GET", textPath, tokens[2], nil, 404) + call("DELETE", workspacePath+"/tags/"+tag["id"].(string), tokens[0], nil, 204) + call("PATCH", textPath, tokens[0], changed, 412, `"3"`) + email := "fixture@example.invalid" password := "a-long-fixture-password" call("POST", base+"/auth/register", "", response{"email": email, "password": password}, 202) diff --git a/internal/platform/layouts.go b/internal/platform/layouts.go new file mode 100644 index 0000000..4674f1b --- /dev/null +++ b/internal/platform/layouts.go @@ -0,0 +1,158 @@ +package platform + +import ( + "database/sql" + "encoding/json" + "math" + "net/http" +) + +type placement struct { + MaterialID string `json:"material_id"` + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"width"` + Height float64 `json:"height"` + ZIndex int `json:"z_index"` +} + +func (p placement) valid() bool { + return !math.IsNaN(p.X) && !math.IsNaN(p.Y) && !math.IsNaN(p.Width) && !math.IsNaN(p.Height) && + p.X >= -1000000 && p.X <= 1000000 && p.Y >= -1000000 && p.Y <= 1000000 && + p.Width >= 1 && p.Width <= 100000 && p.Height >= 1 && p.Height <= 100000 && + p.ZIndex >= -1000000 && p.ZIndex <= 1000000 +} +func (s *service) layouts(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodPatch && r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, board, layout := r.PathValue("workspace"), r.PathValue("material"), r.PathValue("layout") + if (layout == "" && (r.Method == http.MethodPatch || r.Method == http.MethodDelete)) || (layout != "" && (r.Method == http.MethodGet || r.Method == http.MethodPost)) { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + kind, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account) + if err != nil { + return err + } + if kind != "board" { + reject(w, 400, "not_a_board") + return nil + } + var version int64 + if err = tx.QueryRowContext(r.Context(), "SELECT version FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, board).Scan(&version); err != nil { + return err + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), ` +SELECT jsonb_build_object('id',l.id,'material_id',l.material_id,'x',l.x,'y',l.y,'width',l.width,'height',l.height,'z_index',l.z_index,'version',l.version) +FROM board_layouts l WHERE l.project=$1 AND l.workspace_id=$2 AND l.board_id=$3 +AND (EXISTS(SELECT 1 FROM workspace_members m WHERE m.project=l.project AND m.workspace_id=l.workspace_id AND m.account_id=$4) + OR EXISTS(SELECT 1 FROM material_grants g WHERE (g.project,g.workspace_id,g.material_id)=(l.project,l.workspace_id,l.material_id) AND g.account_id=$4)) +ORDER BY l.z_index,l.id LIMIT 2000 +`, project, workspace, board, account) + if err != nil { + return err + } + defer rows.Close() + items := []json.RawMessage{} + for rows.Next() { + var item json.RawMessage + if err := rows.Scan(&item); err != nil { + return err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return err + } + setVersion(w, version) + reply(w, 200, response{"items": items, "board_version": version}) + return nil + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + expected, ok := expectedVersion(w, r) + if !ok { + return nil + } + if expected != version { + reject(w, 412, "version_conflict") + return nil + } + var input placement + if r.Method != http.MethodDelete { + if !decode(w, r, &input) { + return nil + } + if !input.valid() || input.MaterialID == board { + reject(w, 400, "invalid_layout") + return nil + } + refKind, _, err := lockMaterial(r.Context(), tx, project, workspace, input.MaterialID, account) + if err != nil { + return err + } + if refKind == "board" { + reject(w, 400, "nested_board_not_supported") + return nil + } + } + var result sql.Result + switch r.Method { + case http.MethodPost: + var count int + if err = tx.QueryRowContext(r.Context(), "SELECT count(*) FROM board_layouts WHERE project=$1 AND workspace_id=$2 AND board_id=$3", project, workspace, board).Scan(&count); err != nil { + return err + } + if count >= 2000 { + reject(w, 409, "board_full") + return nil + } + err = tx.QueryRowContext(r.Context(), ` +INSERT INTO board_layouts(project,workspace_id,board_id,material_id,x,y,width,height,z_index) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id::text +`, project, workspace, board, input.MaterialID, input.X, input.Y, input.Width, input.Height, input.ZIndex).Scan(&layout) + case http.MethodPatch: + result, err = tx.ExecContext(r.Context(), ` +UPDATE board_layouts SET material_id=$5,x=$6,y=$7,width=$8,height=$9,z_index=$10,version=version+1 +WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4 +`, project, workspace, board, layout, input.MaterialID, input.X, input.Y, input.Width, input.Height, input.ZIndex) + case http.MethodDelete: + result, err = tx.ExecContext(r.Context(), "DELETE FROM board_layouts WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4", project, workspace, board, layout) + } + if err != nil { + return err + } + if result != nil { + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + } + _, err = tx.ExecContext(r.Context(), "UPDATE materials SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, board) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + setVersion(w, version+1) + if r.Method == http.MethodPost { + reply(w, 201, response{"id": layout, "board_version": version + 1}) + } else { + reply(w, 204, nil) + } + return nil +} diff --git a/internal/platform/materials.go b/internal/platform/materials.go new file mode 100644 index 0000000..2d03c0b --- /dev/null +++ b/internal/platform/materials.go @@ -0,0 +1,297 @@ +package platform + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "strconv" +) + +func lockMaterial(ctx context.Context, tx *sql.Tx, project, workspace, material, account string) (string, string, error) { + var locked, kind, role string + err := tx.QueryRowContext(ctx, "SELECT id::text FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE", project, workspace).Scan(&locked) + if err != nil { + return "", "", err + } + err = tx.QueryRowContext(ctx, ` +SELECT m.kind,CASE + WHEN member.role='admin' THEN 'admin' + WHEN member.role='editor' OR access.role='editor' THEN 'editor' + ELSE 'viewer' END +FROM materials m +LEFT JOIN workspace_members member ON (member.project,member.workspace_id)=(m.project,m.workspace_id) AND member.account_id=$4 +LEFT JOIN material_grants access ON (access.project,access.workspace_id,access.material_id)=(m.project,m.workspace_id,m.id) AND access.account_id=$4 +WHERE m.project=$1 AND m.workspace_id=$2 AND m.id=$3 AND (member.role IS NOT NULL OR access.role IS NOT NULL) +FOR UPDATE OF m +`, project, workspace, material, account).Scan(&kind, &role) + return kind, role, err +} + +func expectedVersion(w http.ResponseWriter, r *http.Request) (int64, bool) { + header := r.Header.Get("If-Match") + if header == "" { + reject(w, 428, "version_required") + return 0, false + } + if len(header) < 3 || header[0] != '"' || header[len(header)-1] != '"' { + reject(w, 400, "invalid_version") + return 0, false + } + version, err := strconv.ParseInt(header[1:len(header)-1], 10, 64) + if err != nil || version < 1 { + reject(w, 400, "invalid_version") + return 0, false + } + return version, true +} +func setVersion(w http.ResponseWriter, version int64) { + w.Header().Set("ETag", strconv.Quote(strconv.FormatInt(version, 10))) +} + +func (s *service) materials(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method == http.MethodGet { + return s.listMaterials(w, r, project, account) + } + if r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + var input struct { + Kind string `json:"kind"` + Title string `json:"title"` + Description string `json:"description"` + Content json.RawMessage `json:"content"` + } + if !decode(w, r, &input) { + return nil + } + content, ok := normalizeContent(input.Kind, input.Content) + if !ok || len(input.Title) > 300 || len(input.Description) > 10000 { + reject(w, 400, "invalid_material") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + workspace := r.PathValue("workspace") + _, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + var id string + err = tx.QueryRowContext(r.Context(), ` +INSERT INTO materials(project,workspace_id,kind,title,description,content,created_by) +VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id::text +`, project, workspace, input.Kind, input.Title, input.Description, content, account).Scan(&id) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + setVersion(w, 1) + reply(w, 201, response{"id": id, "workspace_id": workspace, "kind": input.Kind, "version": 1}) + return nil +} + +func (s *service) material(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet && r.Method != http.MethodPatch && r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, material := r.PathValue("workspace"), r.PathValue("material") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + kind, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if r.Method == http.MethodGet { + var result json.RawMessage + var version int64 + err = tx.QueryRowContext(r.Context(), ` +SELECT jsonb_build_object('id',id,'workspace_id',workspace_id,'kind',kind,'title',title,'description',description, +'content',content,'version',version,'created_at',created_at,'modified_at',modified_at,'created_by',created_by),version +FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3 +`, project, workspace, material).Scan(&result, &version) + if err != nil { + return err + } + setVersion(w, version) + reply(w, 200, result) + return nil + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + version, ok := expectedVersion(w, r) + if !ok { + return nil + } + var result sql.Result + if r.Method == http.MethodPatch { + var input struct { + Title string `json:"title"` + Description string `json:"description"` + Content json.RawMessage `json:"content"` + } + if !decode(w, r, &input) { + return nil + } + content, ok := normalizeContent(kind, input.Content) + if !ok || len(input.Title) > 300 || len(input.Description) > 10000 { + reject(w, 400, "invalid_material") + return nil + } + result, err = tx.ExecContext(r.Context(), ` +UPDATE materials SET title=$4,description=$5,content=$6,version=version+1,modified_at=now() +WHERE project=$1 AND workspace_id=$2 AND id=$3 AND version=$7 +`, project, workspace, material, input.Title, input.Description, content, version) + } else { + if role != "admin" { + reject(w, 403, "admin_required") + return nil + } + result, err = tx.ExecContext(r.Context(), "DELETE FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3 AND version=$4", project, workspace, material, version) + } + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + reject(w, 412, "version_conflict") + return nil + } + if err = tx.Commit(); err != nil { + return err + } + if r.Method == http.MethodPatch { + setVersion(w, version+1) + } + reply(w, 204, nil) + return nil +} + +func (s *service) openMaterial(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, material := r.PathValue("workspace"), r.PathValue("material") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, _, err = lockMaterial(r.Context(), tx, project, workspace, material, account); err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO material_opens(project,workspace_id,material_id,account_id) VALUES($1,$2,$3,$4) +ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET opened_at=now() +`, project, workspace, material, account) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} + +func (s *service) listMaterials(w http.ResponseWriter, r *http.Request, project, account string) error { + sort := r.URL.Query().Get("sort") + orders := map[string]string{ + "": "m.modified_at DESC,m.id", + "modified": "m.modified_at DESC,m.id", + "created": "m.created_at DESC,m.id", + "alphabetical": "lower(m.title),m.id", + "opened": "opened.opened_at DESC NULLS LAST,m.id", + "tag": "COALESCE((SELECT min(lower(t.name)) FROM material_tags mt JOIN tags t ON (t.project,t.workspace_id,t.id)=(mt.project,mt.workspace_id,mt.tag_id) WHERE (mt.project,mt.workspace_id,mt.material_id)=(m.project,m.workspace_id,m.id)),''),lower(m.title),m.id", + } + order, ok := orders[sort] + if !ok { + reject(w, 400, "invalid_sort") + return nil + } + limit, offset := 50, 0 + var err error + if raw := r.URL.Query().Get("limit"); raw != "" { + limit, err = strconv.Atoi(raw) + } + if err != nil || limit < 1 || limit > 100 { + reject(w, 400, "invalid_pagination") + return nil + } + if raw := r.URL.Query().Get("offset"); raw != "" { + offset, err = strconv.Atoi(raw) + } + if err != nil || offset < 0 || offset > 1000000 { + reject(w, 400, "invalid_pagination") + return nil + } + query := r.URL.Query().Get("q") + kind := r.URL.Query().Get("kind") + if len(query) > 256 { + reject(w, 400, "invalid_filter") + return nil + } + switch kind { + case "", "text", "article", "image", "board", "card": + default: + reject(w, 400, "invalid_filter") + return nil + } + tag := r.URL.Query().Get("tag") + rows, err := s.db.QueryContext(r.Context(), ` +SELECT jsonb_build_object('id',m.id,'workspace_id',m.workspace_id,'kind',m.kind,'title',m.title, +'description',m.description,'version',m.version,'created_at',m.created_at,'modified_at',m.modified_at, +'opened_at',opened.opened_at,'tags',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',t.id,'name',t.name,'color',t.color) ORDER BY lower(t.name),t.id) +FROM material_tags mt JOIN tags t ON (t.project,t.workspace_id,t.id)=(mt.project,mt.workspace_id,mt.tag_id) +WHERE (mt.project,mt.workspace_id,mt.material_id)=(m.project,m.workspace_id,m.id)),'[]'::jsonb)) +FROM materials m LEFT JOIN material_opens opened ON (opened.project,opened.workspace_id,opened.material_id)=(m.project,m.workspace_id,m.id) AND opened.account_id=$3 +WHERE m.project=$1 AND m.workspace_id=$2 +AND (EXISTS(SELECT 1 FROM workspace_members member WHERE (member.project,member.workspace_id)=(m.project,m.workspace_id) AND member.account_id=$3) + OR EXISTS(SELECT 1 FROM material_grants g WHERE (g.project,g.workspace_id,g.material_id)=(m.project,m.workspace_id,m.id) AND g.account_id=$3)) +AND ($4='' OR m.title ILIKE '%'||$4||'%') AND ($5='' OR m.kind=$5) +AND ($6='' OR EXISTS(SELECT 1 FROM material_tags t WHERE (t.project,t.workspace_id,t.material_id)=(m.project,m.workspace_id,m.id) AND t.tag_id::text=$6)) +ORDER BY `+order+` LIMIT $7 OFFSET $8`, project, r.PathValue("workspace"), account, query, kind, tag, limit+1, offset) + if err != nil { + return err + } + defer rows.Close() + items := []json.RawMessage{} + for rows.Next() { + var value json.RawMessage + if err := rows.Scan(&value); err != nil { + return err + } + items = append(items, value) + } + if err := rows.Err(); err != nil { + return err + } + var next *int + if len(items) > limit { + items = items[:limit] + value := offset + limit + next = &value + } + reply(w, 200, response{"items": items, "next_offset": next}) + return nil +} diff --git a/internal/platform/tags.go b/internal/platform/tags.go new file mode 100644 index 0000000..32c95ea --- /dev/null +++ b/internal/platform/tags.go @@ -0,0 +1,169 @@ +package platform + +import ( + "net/http" + "regexp" + "strings" +) + +var colorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) + +func (s *service) tags(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + workspace := r.PathValue("workspace") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), "SELECT id::text,name,color FROM tags WHERE project=$1 AND workspace_id=$2 ORDER BY lower(name),id LIMIT 1000", project, workspace) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, name, color string + if err := rows.Scan(&id, &name, &color); err != nil { + return err + } + items = append(items, response{"id": id, "name": name, "color": color}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + return nil + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + var input struct { + Name string `json:"name"` + Color string `json:"color"` + } + if !decode(w, r, &input) { + return nil + } + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 80 || (input.Color != "" && !colorPattern.MatchString(input.Color)) { + reject(w, 400, "invalid_tag") + return nil + } + var id string + err = tx.QueryRowContext(r.Context(), "INSERT INTO tags(project,workspace_id,name,color) VALUES($1,$2,$3,$4) RETURNING id::text", project, workspace, input.Name, input.Color).Scan(&id) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, response{"id": id, "name": input.Name, "color": input.Color}) + return nil +} +func (s *service) deleteTag(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, r.PathValue("workspace"), account) + if err != nil { + return err + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + _, err = tx.ExecContext(r.Context(), ` +UPDATE materials m SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 + AND EXISTS(SELECT 1 FROM material_tags t WHERE (t.project,t.workspace_id,t.material_id)=(m.project,m.workspace_id,m.id) AND t.tag_id=$3) +`, project, r.PathValue("workspace"), r.PathValue("tag")) + if err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM tags WHERE project=$1 AND workspace_id=$2 AND id=$3", project, r.PathValue("workspace"), r.PathValue("tag")) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} +func (s *service) materialTags(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + var input struct { + Tags []string `json:"tags"` + } + if !decode(w, r, &input) { + return nil + } + if len(input.Tags) > 100 { + reject(w, 400, "too_many_tags") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + workspace, material := r.PathValue("workspace"), r.PathValue("material") + _, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + version, ok := expectedVersion(w, r) + if !ok { + return nil + } + result, err := tx.ExecContext(r.Context(), "UPDATE materials SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 AND id=$3 AND version=$4", project, workspace, material, version) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + reject(w, 412, "version_conflict") + return nil + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM material_tags WHERE project=$1 AND workspace_id=$2 AND material_id=$3", project, workspace, material) + if err != nil { + return err + } + for _, tag := range input.Tags { + _, err = tx.ExecContext(r.Context(), "INSERT INTO material_tags(project,workspace_id,material_id,tag_id) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING", project, workspace, material, tag) + if err != nil { + return err + } + } + if err = tx.Commit(); err != nil { + return err + } + setVersion(w, version+1) + reply(w, 204, nil) + return nil +} From e0ba9c08e8863309cffe2760341a8ba1b691eb3e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:39:23 +0800 Subject: [PATCH 07/16] Expose paginated share redirect events to workspace admins --- internal/platform/http.go | 1 + internal/platform/integration_test.go | 5 ++ internal/platform/shares.go | 69 +++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/internal/platform/http.go b/internal/platform/http.go index 4067bdf..f9872c7 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -114,6 +114,7 @@ func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origi mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/grants/{account}", s.protected(callbacks, s.deleteMaterialGrant)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/layouts", s.protected(callbacks, s.layouts)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/layouts/{layout}", s.protected(callbacks, s.layouts)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares/{link}/clicks", s.protected(callbacks, s.shareClicks)) mux.HandleFunc("/projects/{project}/onboarding", s.protected(callbacks, s.onboarding)) mux.HandleFunc("/s/{token}", s.shortLink) mux.HandleFunc("/projects/{project}/shares/redeem", s.protected(callbacks, s.redeemShare)) diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index 14cff61..3f2496d 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -106,6 +106,11 @@ func TestOnboardingAndSharing(t *testing.T) { share := call("POST", workspacePath+"/shares", tokens[0], response{"role": "viewer", "max_uses": 1}, 201) token := share["token"].(string) call("GET", "/s/"+token, "", nil, 302) + clicks := call("GET", workspacePath+"/shares/"+share["id"].(string)+"/clicks", tokens[0], nil, 200) + if len(clicks["items"].([]any)) != 1 { + t.Fatal("click event API did not return the redirect") + } + call("GET", workspacePath+"/shares/"+share["id"].(string)+"/clicks", tokens[2], nil, 404) call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) call("POST", base+"/shares/redeem", tokens[2], response{"token": token}, 410) diff --git a/internal/platform/shares.go b/internal/platform/shares.go index f660f99..6d5159a 100644 --- a/internal/platform/shares.go +++ b/internal/platform/shares.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "regexp" + "strconv" "time" ) @@ -285,3 +286,71 @@ ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET role=CASE reply(w, 200, response{"workspace_id": workspace, "material_id": material, "role": role, "already_redeemed": n == 0}) return nil } + +func (s *service) shareClicks(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + before := r.URL.Query().Get("before") + var cursor int64 + if before != "" { + value, err := strconv.ParseInt(before, 10, 64) + if err != nil || value < 1 { + reject(w, 400, "invalid_cursor") + return nil + } + cursor = value + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + workspace, link := r.PathValue("workspace"), r.PathValue("link") + _, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "forbidden") + return nil + } + var found string + if err = tx.QueryRowContext(r.Context(), "SELECT id::text FROM share_links WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, link).Scan(&found); err != nil { + return err + } + rows, err := tx.QueryContext(r.Context(), ` +SELECT id,clicked_at,referrer_origin FROM link_clicks WHERE project=$1 AND workspace_id=$2 AND link_id=$3 AND ($4::bigint=0 OR id<$4) +ORDER BY id DESC LIMIT 101 +`, project, workspace, link, cursor) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + var last int64 + for rows.Next() { + var id int64 + var at time.Time + var ref string + if err := rows.Scan(&id, &at, &ref); err != nil { + return err + } + items = append(items, response{"id": strconv.FormatInt(id, 10), "clicked_at": at, "referrer_origin": ref}) + if len(items) <= 100 { + last = id + } + } + if err := rows.Err(); err != nil { + return err + } + var next *string + if len(items) > 100 { + items = items[:100] + value := strconv.FormatInt(last, 10) + next = &value + } + reply(w, 200, response{"items": items, "next_before": next}) + return nil +} From 4a06944fec40845e424a726150e2a904edc25dce Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:40:41 +0800 Subject: [PATCH 08/16] Verify multimodal content boundaries and every material sort --- internal/platform/content_test.go | 39 +++++++++++++++++++++++++++ internal/platform/integration_test.go | 15 +++++++++++ 2 files changed, 54 insertions(+) create mode 100644 internal/platform/content_test.go diff --git a/internal/platform/content_test.go b/internal/platform/content_test.go new file mode 100644 index 0000000..1cd761c --- /dev/null +++ b/internal/platform/content_test.go @@ -0,0 +1,39 @@ +package platform + +import ( + "encoding/json" + "testing" +) + +func TestContentValidation(t *testing.T) { + cases := []struct { + name, kind, body string + valid bool + }{ + {"plain text", "text", `{"text":""}`, true}, + {"empty article draft", "article", `{"article":{"sections":[]}}`, true}, + {"paragraph descriptions", "article", `{"article":{"sections":[{"id":"s","title":"Section","description":"Summary","paragraphs":[{"id":"p","text":"Body","description":"Notes"}]}]}}`, true}, + {"duplicate paragraph IDs", "article", `{"article":{"sections":[{"id":"s","paragraphs":[{"id":"p","text":"One"},{"id":"p","text":"Two"}]}]}}`, false}, + {"markdown field", "article", `{"markdown":"# Heading"}`, false}, + {"unknown schema", "text", `{"schema_version":2,"text":"Text"}`, false}, + {"null content", "board", `null`, false}, + {"nested unknown field", "image", `{"image":{"url":"https://example.com/image.png","secret":"unexpected"}}`, false}, + {"insecure image", "image", `{"image":{"url":"http://example.com/image.png"}}`, false}, + {"URL credentials", "image", `{"image":{"url":"https://user:password@example.com/image.png"}}`, false}, + {"card and text", "card", `{"parts":[{"kind":"text","text":"Caption"}]}`, true}, + {"empty card", "card", `{"parts":[]}`, false}, + {"mismatched part", "card", `{"parts":[{"kind":"text","image":{"url":"https://example.com/image.png"}}]}`, false}, + {"board payload", "board", `{"text":"Wrong type"}`, false}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + out, ok := normalizeContent(test.kind, json.RawMessage(test.body)) + if ok != test.valid { + t.Fatalf("valid=%v, want %v", ok, test.valid) + } + if ok && !json.Valid(out) { + t.Fatal("normalized content is invalid JSON") + } + }) + } +} diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index 3f2496d..036a5a4 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -168,6 +168,21 @@ func TestOnboardingAndSharing(t *testing.T) { call("DELETE", workspacePath+"/tags/"+tag["id"].(string), tokens[0], nil, 204) call("PATCH", textPath, tokens[0], changed, 412, `"3"`) + imageContent := response{"image": response{"url": "https://assets.example.com/image.png", "alt": "Photo"}} + call("POST", workspacePath+"/materials", tokens[0], response{"kind": "image", "title": "Photo", "content": imageContent}, 201) + call("POST", workspacePath+"/materials", tokens[0], response{"kind": "card", "title": "Mixed card", "content": response{"parts": []any{ + response{"kind": "text", "text": "Caption"}, + response{"kind": "article", "article": articleContent["article"]}, + response{"kind": "image", "image": imageContent["image"]}, + }}}, 201) + for _, sort := range []string{"modified", "created", "alphabetical", "opened", "tag"} { + call("GET", workspacePath+"/materials?sort="+sort+"&limit=2", tokens[0], nil, 200) + } + images := call("GET", workspacePath+"/materials?kind=image&q=Photo", tokens[0], nil, 200) + if len(images["items"].([]any)) != 1 { + t.Fatal("kind/title filter returned incorrect materials") + } + email := "fixture@example.invalid" password := "a-long-fixture-password" call("POST", base+"/auth/register", "", response{"email": email, "password": password}, 202) From 206cd45098978a43b61064568544c81d27c3f4c1 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:43:45 +0800 Subject: [PATCH 09/16] Apply tracked schema migrations before deployment and test runtime grants --- .gitea/workflows/deploy.yml | 1 + deploy/check-schema.sh | 8 +++- deploy/migrate.py | 76 +++++++++++++++++++++++++++++++++++++ deploy/release.sh | 1 + 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 deploy/migrate.py diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 564745f..f2d5c54 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -8,6 +8,7 @@ on: - 'go.mod' - 'go.sum' - 'deploy/**' + - 'db/**' - '.gitea/workflows/deploy.yml' concurrency: diff --git a/deploy/check-schema.sh b/deploy/check-schema.sh index b97a56c..adec116 100644 --- a/deploy/check-schema.sh +++ b/deploy/check-schema.sh @@ -19,7 +19,11 @@ VALUES ('fixture','00000000-0000-0000-0000-000000000001','existing-google','exis INSERT INTO project_sessions VALUES ('existing-session','fixture','00000000-0000-0000-0000-000000000001',now()+interval '1 hour'); SQL -psql < db/002-platform.sql +psql <<'SQLROLE' +CREATE ROLE foundry_fixture LOGIN; +SQLROLE +python3 deploy/migrate.py --container "$container" --database postgres --role foundry_fixture +python3 deploy/migrate.py --container "$container" --database postgres --role foundry_fixture psql <<'SQL' DO $$ BEGIN @@ -60,5 +64,5 @@ CGO_ENABLED=0 go test -mod=readonly -c -o bin/platform-tests ./internal/platform docker run --rm --network "container:$container" --user 1000:1000 \ --read-only --cap-drop ALL --security-opt no-new-privileges \ --mount "type=bind,source=$(pwd)/bin/platform-tests,target=/opt/platform-tests,readonly" \ - -e PLATFORM_TEST_DATABASE_URL=postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable \ + -e PLATFORM_TEST_DATABASE_URL=postgres://foundry_fixture@127.0.0.1:5432/postgres?sslmode=disable \ --entrypoint /opt/platform-tests postgres:17-alpine -test.v diff --git a/deploy/migrate.py b/deploy/migrate.py new file mode 100644 index 0000000..7cc46ff --- /dev/null +++ b/deploy/migrate.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Apply checked migrations atomically from the trusted deployment runner.""" +import argparse +import hashlib +from pathlib import Path +import re +import subprocess + +parser = argparse.ArgumentParser() +parser.add_argument("--container", default="endoleap-dev-postgres-1") +parser.add_argument("--database", default="foundry_auth") +parser.add_argument("--role", default="foundry") +args = parser.parse_args() +for value in (args.database, args.role): + if not re.fullmatch(r"[a-z_][a-z0-9_]{0,62}", value): + raise SystemExit("Invalid deployment database or role") +migrations = sorted((Path(__file__).resolve().parent.parent / "db").glob("[0-9]*.sql")) +if not migrations: + raise SystemExit("No migrations found") + +script = [ + "\\set ON_ERROR_STOP on", + "BEGIN;", + "SET LOCAL lock_timeout='30s';", + "SELECT pg_advisory_xact_lock(731920164);", + "CREATE TABLE IF NOT EXISTS foundry_schema_migrations " + "(name text PRIMARY KEY, checksum text NOT NULL, applied_at timestamptz NOT NULL DEFAULT now());", +] +tables = set() +for index, path in enumerate(migrations): + source = path.read_text() + name = path.name + if not re.fullmatch(r"[0-9]+-[a-z0-9-]+\.sql", name): + raise SystemExit("Invalid migration name") + checksum = hashlib.sha256(source.encode()).hexdigest() + tables.update(re.findall(r"CREATE TABLE ([a-z_]+)", source)) + # The first deployed version predates migration tracking. + if index == 0: + script.extend([ + "SELECT bool_and(to_regclass('public.' || name) IS NOT NULL) AS baseline FROM " + "unnest(ARRAY['project_accounts','oauth_attempts','login_grants','project_sessions']) name \\gset", + "\\if :baseline", + f"INSERT INTO foundry_schema_migrations(name,checksum) VALUES('{name}','{checksum}') ON CONFLICT DO NOTHING;", + "\\endif", + ]) + script.extend([ + f"SELECT EXISTS(SELECT 1 FROM foundry_schema_migrations WHERE name='{name}') AS applied, " + f"EXISTS(SELECT 1 FROM foundry_schema_migrations WHERE name='{name}' AND checksum='{checksum}') AS valid \\gset", + "\\if :applied", + "\\if :valid", + "\\else", + "\\echo Migration checksum mismatch", + "\\quit 1", + "\\endif", + "\\else", + ]) + source = source.strip() + if not source.startswith("BEGIN;") or not source.endswith("COMMIT;"): + raise SystemExit("Migration must have explicit transaction boundaries") + script.append(source[len("BEGIN;"):-len("COMMIT;")]) + script.extend([ + f"INSERT INTO foundry_schema_migrations(name,checksum) VALUES('{name}','{checksum}');", + "\\endif", + ]) +for table in sorted(tables): + script.append(f'GRANT SELECT,INSERT,UPDATE,DELETE ON TABLE "{table}" TO "{args.role}";') +script.extend([ + f'GRANT USAGE,SELECT ON SEQUENCE link_clicks_id_seq TO "{args.role}";', + "COMMIT;", +]) +subprocess.run( + ["docker", "exec", "-i", args.container, "sh", "-c", + 'exec psql -X -U "${POSTGRES_USER:-postgres}" -d "$1" -v ON_ERROR_STOP=1', "sh", args.database], + input=("\n".join(script) + "\n").encode(), check=True, +) +print("Schema migrations and application grants verified.") diff --git a/deploy/release.sh b/deploy/release.sh index 3636858..d9ca918 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -40,6 +40,7 @@ export FOUNDRY_PROJECTS test -s "$foundry/database.env" docker network inspect "$network" >/dev/null +python3 "$(dirname "$0")/migrate.py" release=$(mktemp -d "$foundry/release.XXXXXXXX") install -m 755 "$1" "$release/foundry" From 5c0ad30a7a057e2a73e011ab2768a23b2a22dcfc Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:49:41 +0800 Subject: [PATCH 10/16] Add Slack OpenID login with verified identities and callback replay protection --- .env.example | 4 + cmd/foundry/main.go | 6 +- db/002-platform.sql | 4 + deploy/check-schema.sh | 14 +- deploy/release.sh | 6 +- internal/auth/auth.go | 39 ++--- internal/auth/identity.go | 67 ++++++++ internal/auth/slack.go | 226 ++++++++++++++++++++++++++ internal/auth/slack_test.go | 194 ++++++++++++++++++++++ internal/platform/integration_test.go | 2 +- 10 files changed, 524 insertions(+), 38 deletions(-) create mode 100644 internal/auth/identity.go create mode 100644 internal/auth/slack.go create mode 100644 internal/auth/slack_test.go diff --git a/.env.example b/.env.example index c752c26..c6447fe 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,7 @@ SMTP_PORT=465 SMTP_USERNAME=noreply@notice.entroia.com SMTP_PASSWORD=replace-me SMTP_FROM=noreply@notice.entroia.com + +# Optional Sign in with Slack. +SLACK_CLIENT_ID=replace-me +SLACK_CLIENT_SECRET=replace-me diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go index 6b25a5e..7feed0e 100644 --- a/cmd/foundry/main.go +++ b/cmd/foundry/main.go @@ -52,7 +52,11 @@ func main() { log.Fatal(err) } go email.Run(context.Background(), db) - handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret, email) + slack, err := auth.SlackConfigFromEnv(origin.ResolveReference(&url.URL{Path: "/auth/slack/callback"}).String()) + if err != nil { + log.Fatal(err) + } + handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret, email, slack) if err != nil { log.Fatal(err) } diff --git a/db/002-platform.sql b/db/002-platform.sql index 3721b0d..87f9def 100644 --- a/db/002-platform.sql +++ b/db/002-platform.sql @@ -11,6 +11,10 @@ ALTER TABLE project_accounts CREATE UNIQUE INDEX project_account_username ON project_accounts(project, lower(username)) WHERE username IS NOT NULL; +ALTER TABLE oauth_attempts + ADD COLUMN provider text NOT NULL DEFAULT 'google' CHECK (provider IN ('google','slack')), + ADD COLUMN oidc_nonce text; + CREATE TABLE account_identities ( project text NOT NULL, provider text NOT NULL CHECK (provider IN ('google', 'slack')), diff --git a/deploy/check-schema.sh b/deploy/check-schema.sh index adec116..32756c5 100644 --- a/deploy/check-schema.sh +++ b/deploy/check-schema.sh @@ -60,9 +60,11 @@ END $$; ROLLBACK; SQL printf 'Migration preserved existing logins and enforced project isolation.\n' -CGO_ENABLED=0 go test -mod=readonly -c -o bin/platform-tests ./internal/platform -docker run --rm --network "container:$container" --user 1000:1000 \ - --read-only --cap-drop ALL --security-opt no-new-privileges \ - --mount "type=bind,source=$(pwd)/bin/platform-tests,target=/opt/platform-tests,readonly" \ - -e PLATFORM_TEST_DATABASE_URL=postgres://foundry_fixture@127.0.0.1:5432/postgres?sslmode=disable \ - --entrypoint /opt/platform-tests postgres:17-alpine -test.v +for package in platform auth; do + CGO_ENABLED=0 go test -mod=readonly -c -o "bin/$package-tests" "./internal/$package" + docker run --rm --network "container:$container" --user 1000:1000 \ + --read-only --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=bind,source=$(pwd)/bin/$package-tests,target=/opt/integration-tests,readonly" \ + -e PLATFORM_TEST_DATABASE_URL=postgres://foundry_fixture@127.0.0.1:5432/postgres?sslmode=disable \ + --entrypoint /opt/integration-tests postgres:17-alpine -test.v +done diff --git a/deploy/release.sh b/deploy/release.sh index d9ca918..39433be 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -49,6 +49,10 @@ mail_env=() if [[ -s "$foundry/mail.env" ]]; then mail_env=(--env-file "$foundry/mail.env") fi +slack_env=() +if [[ -s "$foundry/slack.env" ]]; then + slack_env=(--env-file "$foundry/slack.env") +fi codex_mounts=() codex_dir=$foundry/codex if [[ -d "$codex_dir" ]]; then @@ -69,7 +73,7 @@ if [[ -d "$codex_dir" ]]; then ) fi -docker run -d --name "$candidate" "${codex_mounts[@]}" "${mail_env[@]}" --network "$network" \ +docker run -d --name "$candidate" "${codex_mounts[@]}" "${mail_env[@]}" "${slack_env[@]}" --network "$network" \ --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \ -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS -e FOUNDRY_ONBOARDING \ --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e2d0428..e2e5137 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -30,14 +30,15 @@ type service struct { email *EmailConfig passwordSlots chan struct{} dummyPassword []byte + slack *SlackConfig } -func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string, email *EmailConfig) (http.Handler, error) { +func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string, email *EmailConfig, slack *SlackConfig) (http.Handler, error) { dummy, err := bcrypt.GenerateFromPassword([]byte("foundry-invalid-login-placeholder"), bcrypt.DefaultCost) if err != nil { return nil, err } - s := &service{db: db, email: email, passwordSlots: make(chan struct{}, 4), dummyPassword: dummy, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret, + s := &service{db: db, email: email, slack: slack, passwordSlots: make(chan struct{}, 4), dummyPassword: dummy, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret, google: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} for name, raw := range callbacks { u, err := url.Parse(raw) @@ -60,6 +61,7 @@ func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string reply(w, 200, payload{"status": "ready"}) return nil })) + mux.HandleFunc("/auth/slack/callback", s.wrap(s.slackCallback)) mux.HandleFunc("GET /auth/google/callback", s.wrap(s.callback)) mux.HandleFunc("/projects/{project}/auth/{action}", s.wrap(s.project)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -114,6 +116,9 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error { if frontend == nil { return reject(w, 404, "not_found") } + if r.Method == http.MethodGet && action == "slack" { + return s.startSlack(w, r, project) + } if r.Method == http.MethodGet && action == "google" { return s.start(w, r, project) } @@ -156,7 +161,7 @@ func (s *service) start(w http.ResponseWriter, r *http.Request, project string) } state, browser, verifier := token(), token(), token() _, err := s.db.ExecContext(r.Context(), ` - INSERT INTO oauth_attempts VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes') + INSERT INTO oauth_attempts(state_hash,browser_hash,project,frontend_challenge,google_verifier,expires_at) VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes') `, digest(state), digest(browser), project, challenge, verifier) if err != nil { return err @@ -211,7 +216,7 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error { var project, challenge, verifier string err = s.db.QueryRowContext(ctx, ` DELETE FROM oauth_attempts - WHERE state_hash = $1 AND browser_hash = $2 AND expires_at > now() + WHERE state_hash = $1 AND browser_hash = $2 AND expires_at > now() AND provider='google' RETURNING project, frontend_challenge, google_verifier `, digest(state), digest(browser.Value)).Scan(&project, &challenge, &verifier) if errors.Is(err, sql.ErrNoRows) { @@ -263,31 +268,7 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error { user.Email = nil } - grant := token() - _, err = s.db.ExecContext(ctx, ` - WITH identity AS ( - SELECT gen_random_uuid() AS id - ), account AS ( - INSERT INTO project_accounts (project, id, google_subject, email, username, display_name, avatar_url) - SELECT $1, id, $2, $3, - left(COALESCE(NULLIF(regexp_replace(lower($6), '[^a-z0-9]', '', 'g'), ''), 'user'),7) - || '_' || replace(id::text,'-',''), - $6, NULLIF($7,'') FROM identity - ON CONFLICT (project, google_subject) DO UPDATE SET - email = EXCLUDED.email, - username = COALESCE(project_accounts.username, EXCLUDED.username), - display_name = CASE WHEN project_accounts.display_name='' THEN EXCLUDED.display_name ELSE project_accounts.display_name END, - avatar_url = COALESCE(project_accounts.avatar_url, EXCLUDED.avatar_url), - updated_at = now() - RETURNING id - ), provider AS ( - INSERT INTO account_identities(project,provider,subject,account_id) - SELECT $1,'google',$2,id FROM account - ON CONFLICT (project,provider,subject) DO NOTHING - ) - INSERT INTO login_grants - SELECT $4, $1, id, $5, now() + interval '1 minute' FROM account - `, project, user.Subject, user.Email, digest(grant), challenge, user.Name, user.Picture) + grant, err := s.identityGrant(ctx, project, "google", user.Subject, user.Email, user.Name, user.Picture, challenge) if err != nil { return err } diff --git a/internal/auth/identity.go b/internal/auth/identity.go new file mode 100644 index 0000000..d4ef1e9 --- /dev/null +++ b/internal/auth/identity.go @@ -0,0 +1,67 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "net/url" + "strings" +) + +func (s *service) identityGrant(ctx context.Context, project, provider, subject string, email *string, name, picture, challenge string) (string, error) { + name = strings.TrimSpace(name) + if len(name) > 160 { + name = "" + } + if u, err := url.Parse(picture); err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || len(picture) > 2048 { + picture = "" + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return "", err + } + defer tx.Rollback() + // Serialize first registration for the same provider identity. + if _, err = tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", project+":"+provider+":"+subject); err != nil { + return "", err + } + var account string + err = tx.QueryRowContext(ctx, "SELECT account_id::text FROM account_identities WHERE project=$1 AND provider=$2 AND subject=$3", project, provider, subject).Scan(&account) + if errors.Is(err, sql.ErrNoRows) { + var googleSubject *string + if provider == "google" { + googleSubject = &subject + } + err = tx.QueryRowContext(ctx, ` +WITH identity AS(SELECT gen_random_uuid() AS id) +INSERT INTO project_accounts(project,id,google_subject,email,username,display_name,avatar_url) +SELECT $1,id,$2,$3, + left(COALESCE(NULLIF(regexp_replace(lower($4),'[^a-z0-9]','','g'),''),'user'),7)||'_'||replace(id::text,'-',''), + $4,NULLIF($5,'') FROM identity RETURNING id::text +`, project, googleSubject, email, name, picture).Scan(&account) + if err != nil { + return "", err + } + _, err = tx.ExecContext(ctx, "INSERT INTO account_identities(project,provider,subject,account_id) VALUES($1,$2,$3,$4)", project, provider, subject, account) + } else if err == nil { + _, err = tx.ExecContext(ctx, ` +UPDATE project_accounts SET email=COALESCE($3,email), + display_name=CASE WHEN display_name='' THEN $4 ELSE display_name END, + avatar_url=COALESCE(avatar_url,NULLIF($5,'')), + username=COALESCE(username,left(COALESCE(NULLIF(regexp_replace(lower($4),'[^a-z0-9]','','g'),''),'user'),7)||'_'||replace(id::text,'-','')), + updated_at=now() WHERE project=$1 AND id=$2 +`, project, account, email, name, picture) + } + if err != nil { + return "", err + } + grant := token() + _, err = tx.ExecContext(ctx, "INSERT INTO login_grants VALUES($1,$2,$3,$4,now()+interval '1 minute')", digest(grant), project, account, challenge) + if err != nil { + return "", err + } + if err = tx.Commit(); err != nil { + return "", err + } + return grant, nil +} diff --git a/internal/auth/slack.go b/internal/auth/slack.go new file mode 100644 index 0000000..1a94da6 --- /dev/null +++ b/internal/auth/slack.go @@ -0,0 +1,226 @@ +package auth + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "math/big" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" +) + +type SlackConfig struct { + ClientID, Secret, RedirectURI string + mu sync.Mutex + keys map[string]*rsa.PublicKey + fetchedAt time.Time +} + +func SlackConfigFromEnv(redirect string) (*SlackConfig, error) { + id, secret := os.Getenv("SLACK_CLIENT_ID"), os.Getenv("SLACK_CLIENT_SECRET") + if id == "" && secret == "" { + return nil, nil + } + if id == "" || secret == "" || strings.ContainsAny(id+secret, "\r\n") { + return nil, errors.New("Slack requires client ID and secret") + } + return &SlackConfig{ClientID: id, Secret: secret, RedirectURI: redirect}, nil +} + +type slackClaims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience string `json:"aud"` + Expires int64 `json:"exp"` + Issued int64 `json:"iat"` + Nonce string `json:"nonce"` + Team string `json:"https://slack.com/team_id"` + Email *string `json:"email"` + Verified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` +} + +func (s *service) slackKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + c := s.slack + c.mu.Lock() + defer c.mu.Unlock() + if time.Since(c.fetchedAt) < time.Minute { + if key := c.keys[kid]; key != nil { + return key, nil + } + return nil, errors.New("unknown Slack signing key") + } + var document struct { + Keys []struct { + Type string `json:"kty"` + ID string `json:"kid"` + Algorithm string `json:"alg"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` + } + } + if err := s.googleJSON(ctx, "GET", "https://slack.com/openid/connect/keys", nil, "", &document); err != nil { + return nil, err + } + keys := map[string]*rsa.PublicKey{} + for _, key := range document.Keys { + n, nErr := base64.RawURLEncoding.DecodeString(key.N) + e, eErr := base64.RawURLEncoding.DecodeString(key.E) + if key.Type != "RSA" || (key.Algorithm != "" && key.Algorithm != "RS256") || (key.Use != "" && key.Use != "sig") || nErr != nil || eErr != nil || len(e) > 4 { + continue + } + modulus := new(big.Int).SetBytes(n) + exponent := new(big.Int).SetBytes(e).Int64() + if modulus.BitLen() < 2048 || modulus.BitLen() > 8192 || exponent < 3 || exponent > 2147483647 || exponent%2 == 0 { + continue + } + keys[key.ID] = &rsa.PublicKey{N: modulus, E: int(exponent)} + } + c.keys, c.fetchedAt = keys, time.Now() + if key := keys[kid]; key != nil { + return key, nil + } + return nil, errors.New("unknown Slack signing key") +} +func (s *service) verifySlack(ctx context.Context, value, nonce string) (slackClaims, error) { + var claims slackClaims + invalid := errors.New("invalid Slack identity token") + if len(value) > 32768 { + return claims, invalid + } + parts := strings.Split(value, ".") + if len(parts) != 3 { + return claims, invalid + } + header, err := base64.RawURLEncoding.DecodeString(parts[0]) + var h struct { + Algorithm string `json:"alg"` + ID string `json:"kid"` + } + if err != nil || json.Unmarshal(header, &h) != nil || h.Algorithm != "RS256" || h.ID == "" { + return claims, invalid + } + key, err := s.slackKey(ctx, h.ID) + if err != nil { + return claims, err + } + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + sum := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if err != nil || rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], signature) != nil { + return claims, invalid + } + body, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || json.Unmarshal(body, &claims) != nil { + return claims, invalid + } + now := time.Now().Unix() + if claims.Issuer != "https://slack.com" || claims.Audience != s.slack.ClientID || claims.Expires <= now || + claims.Issued <= 0 || claims.Issued > now+60 || claims.Expires <= claims.Issued || claims.Nonce != nonce || + claims.Subject == "" || len(claims.Subject) > 128 || claims.Team == "" || len(claims.Team) > 128 || + strings.ContainsAny(claims.Subject+claims.Team, ":\r\n") { + return claims, invalid + } + if !claims.Verified { + claims.Email = nil + } + return claims, nil +} +func (s *service) startSlack(w http.ResponseWriter, r *http.Request, project string) error { + if s.slack == nil { + return reject(w, 503, "slack_not_configured") + } + challenge := r.URL.Query().Get("challenge") + if !tokenPattern.MatchString(challenge) { + return reject(w, 400, "invalid_challenge") + } + state, browser, nonce := token(), token(), token() + _, err := s.db.ExecContext(r.Context(), ` +INSERT INTO oauth_attempts(state_hash,browser_hash,project,frontend_challenge,google_verifier,expires_at,provider,oidc_nonce) +VALUES($1,$2,$3,$4,'',now()+interval '10 minutes','slack',$5) +`, digest(state), digest(browser), project, challenge, nonce) + if err != nil { + return err + } + cookie := oauthCookie(state, browser, 600) + cookie.SameSite = http.SameSiteNoneMode + http.SetCookie(w, cookie) + query := url.Values{"response_type": {"code"}, "scope": {"openid profile email"}, "client_id": {s.slack.ClientID}, "state": {state}, "nonce": {nonce}, "redirect_uri": {s.slack.RedirectURI}} + http.Redirect(w, r, "https://slack.com/openid/connect/authorize?"+query.Encode(), 302) + return nil +} +func (s *service) slackCallback(w http.ResponseWriter, r *http.Request) error { + if s.slack == nil { + return reject(w, 503, "slack_not_configured") + } + if r.Method != http.MethodGet && r.Method != http.MethodPost { + return reject(w, 405, "method_not_allowed") + } + r.Body = http.MaxBytesReader(w, r.Body, 16384) + if r.ParseForm() != nil { + return reject(w, 400, "invalid_callback") + } + state := r.Form.Get("state") + cookie, err := r.Cookie(oauthCookie(state, "", 0).Name) + if !tokenPattern.MatchString(state) || err != nil || !tokenPattern.MatchString(cookie.Value) { + return reject(w, 400, "invalid_state") + } + var project, challenge, nonce string + err = s.db.QueryRowContext(r.Context(), ` +DELETE FROM oauth_attempts WHERE state_hash=$1 AND browser_hash=$2 AND expires_at>now() AND provider='slack' +RETURNING project,frontend_challenge,oidc_nonce +`, digest(state), digest(cookie.Value)).Scan(&project, &challenge, &nonce) + if errors.Is(err, sql.ErrNoRows) { + return reject(w, 400, "invalid_state") + } + if err != nil { + return err + } + frontend := s.projects[project] + if frontend == nil { + return reject(w, 400, "invalid_state") + } + expired := oauthCookie(state, "", -1) + expired.SameSite = http.SameSiteNoneMode + http.SetCookie(w, expired) + destination := *frontend + if r.Form.Get("code") == "" || r.Form.Has("error") { + destination.Fragment = "error=slack_login_cancelled" + http.Redirect(w, r, destination.String(), 302) + return nil + } + var tokens struct { + OK bool `json:"ok"` + IDToken string `json:"id_token"` + } + err = s.googleJSON(r.Context(), "POST", "https://slack.com/api/openid.connect.token", url.Values{ + "code": {r.Form.Get("code")}, "client_id": {s.slack.ClientID}, "client_secret": {s.slack.Secret}, "redirect_uri": {s.slack.RedirectURI}, "grant_type": {"authorization_code"}, + }, "", &tokens) + if err != nil { + return err + } + if !tokens.OK || tokens.IDToken == "" { + return errors.New("Slack token exchange failed") + } + claims, err := s.verifySlack(r.Context(), tokens.IDToken, nonce) + if err != nil { + return err + } + grant, err := s.identityGrant(r.Context(), project, "slack", claims.Team+":"+claims.Subject, claims.Email, claims.Name, claims.Picture, challenge) + if err != nil { + return err + } + destination.Fragment = "code=" + grant + http.Redirect(w, r, destination.String(), 302) + return nil +} diff --git a/internal/auth/slack_test.go b/internal/auth/slack_test.go new file mode 100644 index 0000000..fe2885d --- /dev/null +++ b/internal/auth/slack_test.go @@ -0,0 +1,194 @@ +package auth + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func signIdentity(t *testing.T, key *rsa.PrivateKey, claims slackClaims) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"fixture"}`)) + body, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + value := header + "." + base64.RawURLEncoding.EncodeToString(body) + hash := sha256.Sum256([]byte(value)) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:]) + if err != nil { + t.Fatal(err) + } + return value + "." + base64.RawURLEncoding.EncodeToString(sig) +} +func TestSlackIdentityVerification(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + s := &service{slack: &SlackConfig{ClientID: "fixture", keys: map[string]*rsa.PublicKey{"fixture": &key.PublicKey}, fetchedAt: time.Now()}} + good := slackClaims{Issuer: "https://slack.com", Subject: "U123", Team: "T123", Audience: "fixture", Expires: time.Now().Add(time.Minute).Unix(), Issued: time.Now().Unix(), Nonce: "nonce"} + for _, name := range []string{"valid", "issuer", "audience", "expired", "nonce", "signature", "algorithm"} { + t.Run(name, func(t *testing.T) { + claims := good + switch name { + case "issuer": + claims.Issuer = "https://attacker.invalid" + case "audience": + claims.Audience = "other-client" + case "expired": + claims.Expires = time.Now().Add(-time.Minute).Unix() + case "nonce": + claims.Nonce = "wrong" + } + value := signIdentity(t, key, claims) + if name == "signature" { + parts := strings.Split(value, ".") + sig, _ := base64.RawURLEncoding.DecodeString(parts[2]) + sig[0] ^= 1 + parts[2] = base64.RawURLEncoding.EncodeToString(sig) + value = strings.Join(parts, ".") + } + if name == "algorithm" { + parts := strings.Split(value, ".") + parts[0] = base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","kid":"fixture"}`)) + value = strings.Join(parts, ".") + } + _, err := s.verifySlack(context.Background(), value, "nonce") + if (err == nil) != (name == "valid") { + t.Fatalf("unexpected verification result %v", err) + } + }) + } +} +func TestSlackCallbackAndExchange(t *testing.T) { + dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("requires isolated integration database") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + var value [12]byte + rand.Read(value[:]) + project := "slack-" + hex.EncodeToString(value[:]) + defer db.Exec("DELETE FROM project_accounts WHERE project=$1", project) + frontend, _ := url.Parse("https://app.example.com/auth/callback") + config := &SlackConfig{ClientID: "fixture", Secret: "fixture-secret", RedirectURI: "https://api.example.com/auth/slack/callback", keys: map[string]*rsa.PublicKey{"fixture": &key.PublicKey}, fetchedAt: time.Now()} + verifier := token() + var nonce string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Error("unexpected Slack request method") + } + _ = r.ParseForm() + if r.Form.Get("client_id") != "fixture" || r.Form.Get("client_secret") != "fixture-secret" { + t.Error("incorrect Slack client credentials") + } + claims := slackClaims{Issuer: "https://slack.com", Subject: "U123", Team: "T123", Audience: "fixture", Expires: time.Now().Add(time.Minute).Unix(), Issued: time.Now().Unix(), Nonce: nonce, Name: "Slack User"} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "id_token": signIdentity(t, key, claims)}) + })) + defer upstream.Close() + s := &service{db: db, projects: map[string]*url.URL{project: frontend}, slack: config, google: &http.Client{Transport: rewriteTransport{destination: upstream.URL}}} + start := httptest.NewRequest("GET", "https://api.example.com/projects/"+project+"/auth/slack?challenge="+digest(verifier), nil) + out := httptest.NewRecorder() + if err = s.startSlack(out, start, project); err != nil { + t.Fatal(err) + } + if out.Code != 302 { + t.Fatalf("start status %d", out.Code) + } + location, _ := url.Parse(out.Header().Get("Location")) + nonce = location.Query().Get("nonce") + state := location.Query().Get("state") + if nonce == "" || state == "" { + t.Fatal("missing state or nonce") + } + cookies := out.Result().Cookies() + if len(cookies) != 1 || cookies[0].SameSite != http.SameSiteNoneMode || !cookies[0].Secure || !cookies[0].HttpOnly { + t.Fatal("incorrect Slack browser binding cookie") + } + body := url.Values{"state": {state}, "code": {"fixture-code"}} + callback := func() *httptest.ResponseRecorder { + r := httptest.NewRequest("POST", config.RedirectURI, strings.NewReader(body.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(cookies[0]) + w := httptest.NewRecorder() + if err := s.slackCallback(w, r); err != nil { + t.Fatal(err) + } + return w + } + success := callback() + if success.Code != 302 { + t.Fatalf("callback status %d %s", success.Code, success.Body.String()) + } + destination, _ := url.Parse(success.Header().Get("Location")) + fragment, _ := url.ParseQuery(destination.Fragment) + grant := fragment.Get("code") + if !tokenPattern.MatchString(grant) { + t.Fatal("callback missing one-time exchange code") + } + if replay := callback(); replay.Code != 400 { + t.Fatal("Slack callback replay accepted") + } + request := httptest.NewRequest("POST", "https://api.example.com/projects/"+project+"/auth/exchange", nil) + request.Header.Set("X-Foundry-Code", grant) + request.Header.Set("X-Foundry-Verifier", verifier) + exchange := httptest.NewRecorder() + if err = s.exchange(exchange, request, project); err != nil { + t.Fatal(err) + } + if exchange.Code != 200 { + t.Fatalf("exchange status %d", exchange.Code) + } + var account, username, name string + err = db.QueryRow("SELECT a.id::text,a.username,a.display_name FROM project_accounts a JOIN account_identities i ON (i.project,i.account_id)=(a.project,a.id) WHERE a.project=$1 AND i.provider='slack' AND i.subject='T123:U123'", project).Scan(&account, &username, &name) + if err != nil || username == "" || name != "Slack User" { + t.Fatalf("Slack profile prefill failed %v", err) + } + _, err = db.Exec("UPDATE project_accounts SET username='chosen-name',display_name='Chosen name' WHERE project=$1 AND id=$2", project, account) + if err != nil { + t.Fatal(err) + } + if _, err = s.identityGrant(context.Background(), project, "slack", "T123:U123", nil, "New Slack Name", "", digest(verifier)); err != nil { + t.Fatal(err) + } + if err = db.QueryRow("SELECT username,display_name FROM project_accounts WHERE project=$1 AND id=$2", project, account).Scan(&username, &name); err != nil { + t.Fatal(err) + } + if username != "chosen-name" || name != "Chosen name" { + t.Fatal("provider login overwrote chosen profile") + } +} + +type rewriteTransport struct{ destination string } + +func (t rewriteTransport) RoundTrip(r *http.Request) (*http.Response, error) { + cloned := r.Clone(r.Context()) + target, _ := url.Parse(t.destination) + cloned.URL.Scheme, cloned.URL.Host = target.Scheme, target.Host + return http.DefaultTransport.RoundTrip(cloned) +} diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index 036a5a4..d05381d 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -59,7 +59,7 @@ func TestOnboardingAndSharing(t *testing.T) { }() callbacks := map[string]string{project: "https://app.example.com/auth/callback"} mux := http.NewServeMux() - authHandler, err := auth.New(db, callbacks, "https://api.example.com/auth/google/callback", "fixture", "fixture", &auth.EmailConfig{}) + authHandler, err := auth.New(db, callbacks, "https://api.example.com/auth/google/callback", "fixture", "fixture", &auth.EmailConfig{}, nil) if err != nil { t.Fatal(err) } From fbe0910638914c990464d864977ce5ea7611aa07 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:51:56 +0800 Subject: [PATCH 11/16] Guard legacy Google accounts through provider login expansion --- internal/auth/slack_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/internal/auth/slack_test.go b/internal/auth/slack_test.go index fe2885d..938f604 100644 --- a/internal/auth/slack_test.go +++ b/internal/auth/slack_test.go @@ -192,3 +192,31 @@ func (t rewriteTransport) RoundTrip(r *http.Request) (*http.Response, error) { cloned.URL.Scheme, cloned.URL.Host = target.Scheme, target.Host return http.DefaultTransport.RoundTrip(cloned) } + +func TestGoogleIdentityKeepsLegacyAccount(t *testing.T) { + dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("requires isolated integration database") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + s := &service{db: db} + grant, err := s.identityGrant(context.Background(), "fixture", "google", "existing-google", nil, "Existing Google User", "", digest(token())) + if err != nil { + t.Fatal(err) + } + var account string + if err = db.QueryRow("SELECT account_id::text FROM login_grants WHERE project='fixture' AND token_hash=$1", digest(grant)).Scan(&account); err != nil { + t.Fatal(err) + } + if account != "00000000-0000-0000-0000-000000000001" { + t.Fatal("legacy Google identity changed account ID") + } + var count int + if err = db.QueryRow("SELECT count(*) FROM project_sessions WHERE project='fixture' AND token_hash='existing-session'").Scan(&count); err != nil || count != 1 { + t.Fatalf("existing Google session was lost %v", err) + } +} From f69575d48ffebb8f134ac0f0d4ee68d0ad93e5fd Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:53:50 +0800 Subject: [PATCH 12/16] Serialize email verification and resend to preserve registration state --- internal/auth/email.go | 13 +++++++ internal/auth/email_test.go | 72 +++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 internal/auth/email_test.go diff --git a/internal/auth/email.go b/internal/auth/email.go index c69707a..c94749c 100644 --- a/internal/auth/email.go +++ b/internal/auth/email.go @@ -112,6 +112,9 @@ func (s *service) registerEmail(w http.ResponseWriter, r *http.Request, project, return err } defer tx.Rollback() + if _, err = tx.ExecContext(r.Context(), "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", "email:"+project+":"+email); err != nil { + return err + } verification := token() result, err := tx.ExecContext(r.Context(), ` INSERT INTO email_registrations(project,email,password_hash,token_hash,expires_at) @@ -167,6 +170,16 @@ func (s *service) verifyEmail(w http.ResponseWriter, r *http.Request, project st } defer tx.Rollback() var email, hash string + err = tx.QueryRowContext(r.Context(), "SELECT email FROM email_registrations WHERE project=$1 AND token_hash=$2 AND expires_at>now()", project, digest(input.Token)).Scan(&email) + if errors.Is(err, sql.ErrNoRows) { + return reject(w, 400, "invalid_verification") + } + if err != nil { + return err + } + if _, err = tx.ExecContext(r.Context(), "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", "email:"+project+":"+email); err != nil { + return err + } err = tx.QueryRowContext(r.Context(), ` DELETE FROM email_registrations WHERE project=$1 AND token_hash=$2 AND expires_at>now() RETURNING email,password_hash `, project, digest(input.Token)).Scan(&email, &hash) diff --git a/internal/auth/email_test.go b/internal/auth/email_test.go new file mode 100644 index 0000000..b2b3227 --- /dev/null +++ b/internal/auth/email_test.go @@ -0,0 +1,72 @@ +package auth + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "net/http/httptest" + "net/url" + "os" + "regexp" + "strings" + "testing" +) + +func TestConcurrentRegistrationAndVerification(t *testing.T) { + dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("requires isolated integration database") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var raw [12]byte + rand.Read(raw[:]) + project := "email-" + hex.EncodeToString(raw[:]) + frontend, _ := url.Parse("https://app.example.com/auth/callback") + s := &service{db: db, projects: map[string]*url.URL{project: frontend}} + defer func() { + _, _ = db.Exec("DELETE FROM project_accounts WHERE project=$1", project) + _, _ = db.Exec("DELETE FROM email_registrations WHERE project=$1", project) + _, _ = db.Exec("DELETE FROM email_outbox WHERE project=$1", project) + }() + request := httptest.NewRequest("POST", "https://api.example.com/register", nil) + out := httptest.NewRecorder() + if err = s.registerEmail(out, request, project, "fixture@example.invalid", "long-fixture-password"); err != nil { + t.Fatal(err) + } + var body string + if err = db.QueryRow("SELECT body FROM email_outbox WHERE project=$1", project).Scan(&body); err != nil { + t.Fatal(err) + } + match := regexp.MustCompile(`verify_email=([A-Za-z0-9_-]{43})`).FindStringSubmatch(body) + if len(match) != 2 { + t.Fatal("missing verification link") + } + done := make(chan error, 2) + go func() { + r := httptest.NewRequest("POST", "https://api.example.com/register", nil) + done <- s.registerEmail(httptest.NewRecorder(), r, project, "fixture@example.invalid", "another-fixture-password") + }() + go func() { + r := httptest.NewRequest("POST", "https://api.example.com/verify-email", strings.NewReader(`{"token":"`+match[1]+`"}`)) + done <- s.verifyEmail(httptest.NewRecorder(), r, project) + }() + for range 2 { + if err := <-done; err != nil { + t.Fatal(err) + } + } + var accounts, pending int + if err = db.QueryRow("SELECT count(*) FROM email_credentials WHERE project=$1", project).Scan(&accounts); err != nil { + t.Fatal(err) + } + if err = db.QueryRow("SELECT count(*) FROM email_registrations WHERE project=$1", project).Scan(&pending); err != nil { + t.Fatal(err) + } + if accounts+pending != 1 { + t.Fatalf("concurrent registration left accounts=%d pending=%d", accounts, pending) + } +} From 9d5ab9e21527d0d8353d221fa1410dc49733f261 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:55:29 +0800 Subject: [PATCH 13/16] Fix shared OAuth exchange SQL by avoiding a reserved identifier --- internal/auth/auth.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e2e5137..f15c2c6 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -284,13 +284,13 @@ func (s *service) exchange(w http.ResponseWriter, r *http.Request, project strin } session := token() result, err := s.db.ExecContext(r.Context(), ` - WITH grant AS ( + WITH redeemed_grant AS ( DELETE FROM login_grants WHERE token_hash = $1 AND project = $2 AND frontend_challenge = $3 AND expires_at > now() RETURNING account_id ) INSERT INTO project_sessions - SELECT $4, $2, account_id, now() + interval '8 hours' FROM grant + SELECT $4, $2, account_id, now() + interval '8 hours' FROM redeemed_grant `, digest(code), project, digest(verifier), digest(session)) if err != nil { return err From 5b9af931c7bc20746e3ec3da1fb43a8ba8ba309d Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 19:03:55 +0800 Subject: [PATCH 14/16] Wait for final PostgreSQL startup before migration checks --- deploy/check-schema.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/check-schema.sh b/deploy/check-schema.sh index 32756c5..69de8a7 100644 --- a/deploy/check-schema.sh +++ b/deploy/check-schema.sh @@ -8,7 +8,7 @@ trap cleanup EXIT docker run -d --name "$container" --network none \ -e POSTGRES_HOST_AUTH_METHOD=trust postgres:17-alpine >/dev/null for attempt in {1..30}; do - if docker exec "$container" pg_isready -U postgres >/dev/null 2>&1; then break; fi + if docker exec "$container" pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1; then break; fi sleep 1 done psql() { docker exec -i "$container" psql -X -U postgres -v ON_ERROR_STOP=1; } From 6c69fcbaaa1adf67ddd92b4723a5999398f6e578 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 20:19:49 +0800 Subject: [PATCH 15/16] Inject Slack Actions secrets and verify live client credentials --- .gitea/workflows/check.yml | 47 +++++++++++++++++++++++++++++++++++++ .gitea/workflows/deploy.yml | 2 ++ deploy/release.sh | 12 +++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 588e2e8..7c005a3 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -17,6 +17,53 @@ jobs: test -z "$(gofmt -l cmd internal)" go test -mod=readonly ./... go build -mod=readonly -o bin/foundry ./cmd/foundry + - name: Verify configured Slack client credentials + if: gitea.event_name == 'push' + env: + SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }} + SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }} + run: | + python3 - <<'PYSLACK' + import json, os, secrets, sys, urllib.parse, urllib.request + client = os.environ.get("SLACK_CLIENT_ID", "") + secret = os.environ.get("SLACK_CLIENT_SECRET", "") + if not client and not secret: + print("Slack client verification skipped because credentials are not configured") + sys.exit(0) + if not client or not secret: + sys.exit("Both Slack client secrets must be configured") + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + opener = urllib.request.build_opener(NoRedirect) + code = "foundry-invalid-preflight-" + secrets.token_hex(16) + def probe(password): + body = urllib.parse.urlencode({ + "client_id": client, "client_secret": password, + "code": code, "grant_type": "authorization_code", + "redirect_uri": "https://api.foundry.entroia.com/auth/slack/callback" + }).encode() + request = urllib.request.Request( + "https://slack.com/api/openid.connect.token", data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}) + with opener.open(request, timeout=20) as response: + result = json.load(response) + return result.get("error", "") + try: + actual = probe(secret) + control = probe(secrets.token_hex(32)) + except Exception as error: + sys.exit("Slack preflight request failed (" + type(error).__name__ + ")") + if actual in ("invalid_code", "invalid_grant") and control in ("bad_client_secret", "invalid_client"): + print("Slack client credentials accepted; invalid-secret negative control rejected") + print("A real user authorization is still required to verify the complete login") + else: + known = {"invalid_client_id", "bad_client_secret", "invalid_client", "invalid_code", + "invalid_grant", "bad_redirect_uri", "accesslimited", "ratelimited"} + print("Configured credential result:", actual if actual in known else "unclassified") + print("Negative control result:", control if control in known else "unclassified") + sys.exit("Slack client preflight could not verify the configured credentials") + PYSLACK - name: Verify schema migration in an isolated database run: bash deploy/check-schema.sh - name: Smoke test HTTP routes diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index f2d5c54..928e4f3 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -36,6 +36,8 @@ jobs: env: GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }} + SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }} FOUNDRY_PROJECTS: ${{ vars.FOUNDRY_PROJECTS }} FOUNDRY_ONBOARDING: ${{ vars.FOUNDRY_ONBOARDING }} run: bash deploy/release.sh bin/foundry diff --git a/deploy/release.sh b/deploy/release.sh index 39433be..22f6be7 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -50,7 +50,17 @@ if [[ -s "$foundry/mail.env" ]]; then mail_env=(--env-file "$foundry/mail.env") fi slack_env=() -if [[ -s "$foundry/slack.env" ]]; then +if [[ -n "${SLACK_CLIENT_ID:-}" || -n "${SLACK_CLIENT_SECRET:-}" ]]; then + for name in SLACK_CLIENT_ID SLACK_CLIENT_SECRET; do + value=${!name-} + if [[ -z "$value" || "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + printf 'Missing or invalid %s\n' "$name" >&2 + exit 1 + fi + done + export SLACK_CLIENT_ID SLACK_CLIENT_SECRET + slack_env=(-e SLACK_CLIENT_ID -e SLACK_CLIENT_SECRET) +elif [[ -s "$foundry/slack.env" ]]; then slack_env=(--env-file "$foundry/slack.env") fi codex_mounts=() From 155ce28c3705b50c6855715f63df1d53f1e72e08 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 20:22:51 +0800 Subject: [PATCH 16/16] Report inconclusive Slack placeholder-code checks accurately --- .gitea/workflows/check.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 7c005a3..f3ad8e9 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -17,7 +17,7 @@ jobs: test -z "$(gofmt -l cmd internal)" go test -mod=readonly ./... go build -mod=readonly -o bin/foundry ./cmd/foundry - - name: Verify configured Slack client credentials + - name: Check Slack OAuth configuration and endpoint if: gitea.event_name == 'push' env: SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }} @@ -57,6 +57,10 @@ jobs: if actual in ("invalid_code", "invalid_grant") and control in ("bad_client_secret", "invalid_client"): print("Slack client credentials accepted; invalid-secret negative control rejected") print("A real user authorization is still required to verify the complete login") + elif actual in ("invalid_code", "invalid_grant") and control in ("invalid_code", "invalid_grant"): + print("Both Slack Actions secrets are available and the OAuth endpoint is reachable") + print("Slack rejected the placeholder code before validating the client secret") + print("Client-secret validity requires a real user authorization; this is not a complete login test") else: known = {"invalid_client_id", "bad_client_secret", "invalid_client", "invalid_code", "invalid_grant", "bad_redirect_uri", "accesslimited", "ratelimited"}