# Wave C: orgs, email, wiki and polish (contract between workers) The global rules in `~/.cache/forge-orch/waveb-common.md` apply. Background research: - `~/.cache/forge-orch/orgs-study.md`: every place where the owner is assumed to be a user. - `~/.cache/forge-orch/email-study.md`: the im and iz mail designs. - `~/.cache/forge-orch/polish-audit.md`: what exists already. ## Shared-file rules (many workers in parallel) - **Migrations:** each worker owns exactly one number. Add one line to the array in `crates/forge-core/src/store/migrate.rs`, re-reading the file first. | Migration | Owner | |---|---| | 0025_orgs | O1 | | 0026_mail | M1 | | 0027_wiki | W1 | | 0028_discussions | D1 | | 0029_snippets | S1 | | 0030_ui_prefs | T1 | | 0031_advisories | A1 | | 0032_repo_transfers | P1 | - **Store code:** put it in a new `crates/forge-core/src/store/.rs`. Don't grow `store/mod.rs`, apart from the `mod` line and changes your section explicitly requires. - **Shared files** (`pages/mod.rs` route lists, `views/mod.rs` (shell and repo tabs), `names.rs` reserved words, `lib.rs`, `style/main.scss`): small hunks, and re-read the file right before every edit. - **SCSS:** each feature appends its own clearly commented block at the end of `style/main.scss`, or a partial if the file already uses partials. - **Audit:** every state-changing action writes an audit entry, as the existing features do. Use new action names, and grep the tests that assert audit counts. One of them asserts a total (48); if you add actions, update it. - **No JS anywhere.** Use forms and links only. - **Tests:** unit tests in the store module, plus one e2e file per feature in `crates/forge-web/tests/_e2e.rs` using `tests/common/mod.rs`. ## O1: organizations core (wave 1) **Model:** an org is a row in `users` with `kind = 'org'`. That keeps one namespace (`/{name}` works for both), keeps `repos.owner_id → users(id)`, and leaves every `owner_name` join unchanged. **Migration `0025_orgs.sql`:** ```sql ALTER TABLE users ADD COLUMN kind TEXT NOT NULL DEFAULT 'user' CHECK (kind IN ('user','org')); ALTER TABLE users ADD COLUMN description TEXT; -- only if no such column exists (check 0002_profile) CREATE TABLE org_settings (org_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, default_repo_role TEXT NOT NULL DEFAULT 'read' CHECK (default_repo_role IN ('none','read','write')), members_can_create_repos INTEGER NOT NULL DEFAULT 1); CREATE TABLE org_members (org_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('owner','member')), created_at TEXT NOT NULL, PRIMARY KEY (org_id, user_id)); CREATE TABLE teams (id TEXT PRIMARY KEY, org_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, name_folded TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', role TEXT NOT NULL CHECK (role IN ('read','triage','write','maintain','admin')), all_repos INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, UNIQUE (org_id, name_folded)); CREATE TABLE team_members (team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY (team_id, user_id)); CREATE TABLE team_repos (team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, PRIMARY KEY (team_id, repo_id)); ``` **Org rows:** - They get an unusable `password_hash` (`"!org"`). - **Hard rule:** an org can never authenticate. Every authentication path must reject `kind = 'org'` explicitly: - password login, sessions, tokens, SSH keys (including the add-key paths), LFS tokens, TOTP; - admin `create-user`/`reset-password`/`set-username` (refuse orgs); - the CI actor. Add a test for each. - The `User` struct gets `kind: AccountKind { User, Org }`, with a `is_org()` helper. **`store/orgs.rs`:** - `create_org(name, creator_id)`: the creator becomes owner, and the org gets the same name validation as a username, plus `org_settings` defaults. - `org_members`, `add_org_member`, `set_org_member_role`, and `remove_org_member`, which refuses to remove the last owner. - Teams CRUD, team members and team repos. - `orgs_for_user`. - `delete_org`, which refuses while the org owns repos. - `org_repo_role(org_id, user_id, repo_id) -> Option`: owner gives admin. Otherwise the max of `default_repo_role` (for members) and the roles of the user's teams that include the repo, or that have `all_repos`. **authz:** extend the existing authorization input (see `crates/forge-core/src/authz*` and its callers in `access.rs`) with `org_role: Option`, and load it in the same place collaborators are loaded. The effective role is the max of the collaborator role and the org role. An owner check (`owner_id == actor.id`) never matches an org. **Audit every existing site that compares `owner_id == user.id`** (orgs-study lists them: `internal.rs:526/820`, `repo.rs:282`, `admin_users.rs`, `notify.rs`) and route it through the effective role instead. **Notifications:** wherever "the repo owner" is a recipient, an org owner expands to the org's owners. **User deletion:** refuse to delete a user who is the last owner of any org. Show the message on the admin page and the CLI. **Tests:** - the role math; - auth refusal for orgs; - the last-owner rules; - a store-level `repo_by_owner_and_name` for an org-owned repo. ## O2: organizations UI (wave 2, after O1) **Routes:** the prefix `org` is already reserved. - `GET/POST /org/new`. Allowed for everyone, unless the config `[orgs] creation = "admin"`; the default is `"all"`. - `/org/{name}/members`: add by username (owners only), change role, remove, leave. - `/org/{name}/teams`, `/org/{name}/teams/{team}`: members, repos, role. - `/org/{name}/settings`: description, default role, members-can-create, delete. **Other pages:** - `/{name}` (`profile.rs`): when the target is an org, show the repos visible to the viewer, the members list and the teams count. - New repo form: an owner `