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'