# Agent 1 — Phase 1 report: platform invites, roles and org sharing Date: 2026-10-14 · Branch: `dev` ## What was done ### 1.1 `platform_invites` (`db/models_org.py`, migration `a1b2c3d4e5f1`) The door into the product (D12). Unlike an org invite — which joins someone to an org that already exists — this one **mints the platform account**, so it is a capability: the token is stored only as a hash, it is single-use, it expires after `PLATFORM_INVITE_TTL_HOURS`, and the owner can revoke it. The email is optional; when pinned, the account it creates is bound to it. ### 1.2 `PlatformInviteService` `issue / list_pending / revoke / redeem`. Two rules carry over from the org-invite flow unchanged, deliberately: * **A link can never reset the password of an existing account.** A redeemed link whose email already belongs to somebody is refused; they sign in first. * **Every rejection gives the same answer.** "No such link", "already used" and "revoked" are indistinguishable to a caller, so a probe cannot enumerate. `revoke` refuses to un-redeem, and `redeem` is atomic (`mark_used` rejects a second caller rather than silently overwriting), so two workers redeeming the same link cannot both win. ### 1.2 Endpoints (`admin_users.py`, owner-only via the router's dependency) `POST /admin/platform-invites` (returns `invite_url` **once**), `GET /admin/platform-invites`, `POST /admin/platform-invites/{id}/revoke`. All audited. `owner` is refused as a platform role — the seat moves only via a deliberate transfer. ### 1.3 `invites.py` — one lookup path, two kinds `_load_any_invite` resolves a token to whichever kind it is, and the preview and accept routes both branch on it. A platform invite carries `kind: "platform"` and no organization. Redemption either mints a manager (anonymous, with the password — and the email the holder types when the owner pinned none) or raises an existing account to the invited role. ### 1.4 `GET /auth/me` gains `needs_onboarding` True for a manager with zero organizations and not the owner. The client reads it through `fetchNeedsOnboarding()` rather than recomputing the rule locally, so it cannot drift from the server's. ### 1.5 Org sharing (`organizations.py`) * `POST /transfer-ownership` — the current org owner only, to an **existing admin member**, with the org slug typed back, both role changes and the assignment in one transaction so there is never a moment with no owner. Audited. * `POST /leave` — the **last owner cannot leave**: an organization with no owner is invisible to everyone, because its `owner_user_id` points at a non-member. * `GET/DELETE /invites` — list and revoke. Revoking is expressed as *expiry* rather than a `revoked_at` column, because adding one would mean editing the locked `db/models.py`; a revoked link and an expired link are both simply unusable, and it cannot un-accept somebody who already joined. ### Frontend `/accept-invite` titles a platform invite **ساخت حساب مدیر**, shows the pinned email when there is one, and asks for an address when there is none. `/admin/users` gains a "دعوت مدیر جدید" button → modal → the link shown once, plus a pending-links card with revoke. `/onboarding` redirects anyone who already has an organization. `lib/api-org.ts` is no longer a stub; contracts gained `PlatformInvite`, `IssuedPlatformInvite`, `needs_onboarding` and the `kind` discriminator. ## Files changed Backend: `db/models_org.py`, `alembic/versions/a1b2c3d4e5f1_org_platform.py`, `domain/identity/models.py` (`PlatformInvite`), `domain/audit/models.py` (`MEMBER_INVITE_REVOKE`), `application/services/platform_users.py` (`PlatformInviteService`), `infrastructure/repositories/identity.py` (`PlatformInviteRepository`; `InviteRepository.get/revoke`), `interfaces/http/routers/{invites,auth,organizations,admin_users}.py`. Frontend: `packages/contracts/src/index.ts`, `apps/web/src/lib/{api.ts,api-org.ts}`, `apps/web/src/app/accept-invite/page.tsx`, `onboarding/page.tsx`, `(app)/admin/users/page.tsx`, `src/test/org-sharing.test.tsx`, `src/test/accept-invite.test.tsx` (one assertion widened for the new argument). ## Test output ``` apps/api: 520 passed, 1 skipped (was 502; +18) ruff check . -> All checks passed! mypy app -> Success: no issues found in 136 source files alembic: single head; upgrade / downgrade -1 / upgrade / check all clean apps/web: 83 passed (was 78; +5) tsc --noEmit -> clean · next lint -> no warnings ``` ## Deviations from the TODO 1. **Org-invite revocation is expiry, not a flag.** `db/models.py` is locked for this workstream and `invites` has no `revoked_at`. Semantically honest and strictly safer: it cannot un-accept a member. 2. **The migration chains after the delivery workstream's head** (`a3b4c5d6e7f4`) rather than the foundation. The two workstreams are being built sequentially here, so this keeps one head rather than introducing a merge revision that §8.3 only asks for when genuinely parallel branches exist. 3. **`admin_users.py` holds the platform-invite endpoints.** The TODO names that file for them, and it already carries the router-level `require_platform_owner` dependency — adding a second owner-gated router would duplicate the guarantee rather than strengthen it. ## Open questions / CHANGE REQUESTs - **`deploy_target.py` / `repo_workspace.py` field additions** (from Phase 5) remain open: `DeployTargetSpec` would benefit from `post_deploy_command` and the org/project ids. Nothing is broken; they are passed as explicit arguments. - **`MeResponse` should carry the org role** so the web can gate the production buttons client-side. Noted in the Phase 6 report as well. ## Known limitations — 1.10 NOT DONE **`(app)/settings` has no "اشتراک‌گذاری سازمان" section.** Phase 1's frontend is otherwise complete (1.7–1.9, 1.11); the settings section — members with a role selector, invite form, pending invites with revoke, transfer-ownership modal that requires typing the org slug, leave org — is not built. The *endpoints* it needs (`transfer-ownership`, `leave`, `GET/DELETE /invites`) are done and tested, so the remaining work is presentational. The same is true of the `/tasks/[id]`-style extra test named in 1.11 — `src/test/org-sharing.test.tsx` covers the invite screen instead, which is where the phase's risk actually is.