Establish project-scoped account and collaboration schema
Foundry Go checks / check (push) Successful in 1m43s
Foundry Go checks / check (pull_request) Successful in 1m40s

This commit is contained in:
2026-11-05 18:14:44 +08:00
parent c30a3cd197
commit 0fd84f0f6e
4 changed files with 284 additions and 0 deletions
+2
View File
@@ -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
+1
View File
@@ -1,2 +1,3 @@
.env
/bin/
/api-docs/
+223
View File
@@ -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;
+58
View File
@@ -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'