diff --git a/admin/README.md b/admin/README.md index c07d60f4..dce92176 100644 --- a/admin/README.md +++ b/admin/README.md @@ -50,9 +50,14 @@ actual HTTPS origin; local fixtures do not prove them. ## Reviewer journey -1. The owner invites an individual email; each person sets their own password. +1. The owner adds a reviewer by email. A new One Concept account receives an + invitation to this website and sets a password here. An existing confirmed + learner uses their current email and password. The team allows 10 active + reviewers in total, including the owner. 2. The person submits their registered name. An authorized owner approves it. -3. They verify an authenticator before opening private editorial content. +3. Authenticator use starts off for new reviewer memberships. A reviewer may + turn on the requirement in **Settings**; when on, they verify a code before + opening private editorial content. 4. They filter **Review queue** by topic, subtopic, status, assigned reviewer, deadline or title. Counts cover all matching records, independently of pages. 5. They open the complete explanation, example, curriculum, references, @@ -94,8 +99,11 @@ the publication checklist. - Owners assign a reviewer and an optional deadline with an audited note. Dates are entered/displayed in the browser's local timezone and stored in UTC. Deadlines never auto-approve content. Deadline notification emails are #279. -- **Reviewers** manages invitations, name approval, explicit permissions and - revocation. Server safeguards protect the last owner and stale account edits. +- **Reviewers** manages invitations, name approval, explicit permissions, + revocation, and permanent reviewer-access removal. Removal preserves the + person's learner account and historical review evidence, clears open review + assignments, and blocks further workspace access. Server safeguards protect + the last administrator and stale account edits. - **Settings** shows the approved identity, pending name, environment and access. Name changes affect future signatures; historical evidence remains immutable. diff --git a/admin/src/App.tsx b/admin/src/App.tsx index b2ed643a..e6e536ad 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Session, SupabaseClient } from "@supabase/supabase-js"; import { Api, ApiError } from "./api"; -import { Login, Mfa } from "./Auth"; +import { AccountSetup, Login, Mfa } from "./Auth"; import { Notice, message } from "./ui"; import type { Me } from "./types"; import { Settings } from "./Settings"; @@ -28,13 +28,13 @@ export function App({ auth, api, environment, - passwordSetup: initialSetup = false, + passwordSetup: initialSetup = null, initialError = "", }: { auth: SupabaseClient; api: Api; environment: string; - passwordSetup?: boolean; + passwordSetup?: "invite" | "recovery" | "setup" | null; initialError?: string; }) { const [session, setSession] = useState(null), @@ -86,7 +86,8 @@ export function App({ dirty.current = false; setEpoch((n) => n + 1); } - if (event === "PASSWORD_RECOVERY") setSetup(true); + if (event === "SIGNED_OUT") setSetup(null); + if (event === "PASSWORD_RECOVERY") setSetup("recovery"); }); api.onDenied = (status) => { api.setToken( @@ -196,19 +197,23 @@ export function App({ Opening One Concept Review… ); - if (!session || setup) + if (session && setup) + return ( + { + setSetup(null); + navigate({ view: "queue" }); + }} + /> + ); + if (!session) return ( <> {error && {error}} - { - setSetup(false); - navigate({ view: "queue" }); - }} - /> + ); const can = (cap: string) => !!me?.member.capabilities.includes(cap as never); diff --git a/admin/src/Auth.tsx b/admin/src/Auth.tsx index ebf72e80..a874f5c0 100644 --- a/admin/src/Auth.tsx +++ b/admin/src/Auth.tsx @@ -3,12 +3,8 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { Field, Notice, message } from "./ui"; export function Login({ auth, - passwordSetup, - onPasswordSet, }: { auth: SupabaseClient; - passwordSetup: boolean; - onPasswordSet: () => void; }) { const [email, setEmail] = useState(""), [password, setPassword] = useState(""), @@ -22,24 +18,19 @@ export function Login({ setError(""); setNotice(""); try { - const result = passwordSetup - ? await auth.auth.updateUser({ password }) - : recovery - ? await auth.auth.resetPasswordForEmail(email, { - redirectTo: location.origin + "/auth/callback", - }) - : await auth.auth.signInWithPassword({ email, password }); + const result = recovery + ? await auth.auth.resetPasswordForEmail(email, { + redirectTo: location.origin + "/auth/callback", + }) + : await auth.auth.signInWithPassword({ email, password }); if (result.error) throw new Error( - passwordSetup - ? "Password could not be updated. Check password requirements and try again." - : recovery - ? "Unable to request a recovery email. Try again later." - : "Unable to sign in. Check your credentials or contact your administrator.", + recovery + ? "Unable to request a recovery email. Try again later." + : "Unable to sign in. Check your credentials or contact your administrator.", ); setPassword(""); - if (passwordSetup) onPasswordSet(); - else if (recovery) + if (recovery) setNotice( "If this address can recover an account, a password reset email will arrive shortly.", ); @@ -75,21 +66,14 @@ export function Login({

INVITED REVIEWERS

- {passwordSetup - ? "Set your password" - : recovery + {recovery ? "Recover your account" : "Welcome back"}

-

- {passwordSetup - ? "Choose a private password. Your administrator never needs to know it." - : "Sign in with your own invited account to continue."} -

+

{recovery ? "Enter your account email to receive a password reset link." : "Sign in with your own invited account to continue."}

{error && {error}} {notice && {notice}} - {!passwordSetup && ( - + setEmail(e.target.value)} /> - - )} - {(!recovery || passwordSetup) && ( + + {!recovery && ( setPassword(e.target.value)} /> @@ -116,14 +97,11 @@ export function Login({ - {!passwordSetup && ( - - )} +

Private workspace · Your learning app account does not automatically @@ -145,6 +122,67 @@ export function Login({ ); } + +export function AccountSetup({ + auth, + email, + flow, + onPasswordSet, +}: { + auth: SupabaseClient; + email: string; + flow: "invite" | "recovery" | "setup"; + onPasswordSet: () => void; +}) { + const [password, setPassword] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (password !== confirmation) { + setError("The passwords do not match."); + return; + } + setBusy(true); + setError(""); + try { + const { error } = await auth.auth.updateUser({ password }); + if (error) + throw new Error("Password could not be saved. Check password requirements and try again."); + setPassword(""); + setConfirmation(""); + onPasswordSet(); + } catch (e) { + setError(message(e)); + } finally { + setBusy(false); + } + } + return ( +

+
+
1 One Concept
+

ONE ACCOUNT · TWO EXPERIENCES

+

Learn and review
with one account.

+

Your verified email and password work in both the One Concept learning app and this review workspace. Reviewer access is granted by the owner.

+
+
+
+

{flow === "invite" ? "INVITATION ACCEPTED" : "ACCOUNT SECURITY"}

+

{flow === "invite" ? "Finish creating your account" : "Set your password"}

+

{flow === "invite" ? "Your email is confirmed. Choose a password to finish setup." : "Choose a new password for your One Concept account."}

+ {error && {error}} + + setPassword(e.target.value)} /> + setConfirmation(e.target.value)} /> + +
+

{flow === "invite" ? "After setup, submit your reviewer name for owner approval in Settings. Authenticator use starts off and can be enabled there." : "Your existing reviewer permissions and security settings remain in place."}

+
+
+ ); +} export function Mfa({ auth, onVerified, diff --git a/admin/src/Review.tsx b/admin/src/Review.tsx index 1a774f59..fcc62bde 100644 --- a/admin/src/Review.tsx +++ b/admin/src/Review.tsx @@ -600,6 +600,13 @@ export function Review({ onChange={(e) => setNote(e.target.value)} />
+ {revision && detail.status === "pending_review" && ( +

+ Add comment saves feedback only. Request changes queues an AI + correction for the next worker run when generation is enabled. + The new draft still needs human review. +

+ )} {revision && ["changes_requested", "validation_failed", "rejected"].includes( detail.status, diff --git a/admin/src/Settings.tsx b/admin/src/Settings.tsx index cc1c9d05..53843817 100644 --- a/admin/src/Settings.tsx +++ b/admin/src/Settings.tsx @@ -57,7 +57,7 @@ export function Settings({ Your name is waiting for an administrator’s approval.{" "} {me.member.approved_name ? "Your current approved name still applies." - : "You can start reviewing after approval and authenticator verification."} + : "You can start reviewing after approval. If you require an authenticator, verify it too."} )} {saved && Name submitted for approval.} diff --git a/admin/src/Team.tsx b/admin/src/Team.tsx index 90b3fb3a..0014fd63 100644 --- a/admin/src/Team.tsx +++ b/admin/src/Team.tsx @@ -28,7 +28,7 @@ export function Team({ api, me }: { api: Api; me: Me }) { await api.request("/reviewers", "POST", { email, capabilities: caps }); setEmail(""); setNotice( - "Membership created. New accounts receive an invitation; existing accounts use their own sign-in or recovery.", + "Reviewer access created. New users receive a link to set their password; existing One Concept users sign in with their current email and password.", ); setRefresh((n) => n + 1); } catch (e) { @@ -49,8 +49,8 @@ export function Team({ api, me }: { api: Api; me: Me }) {

Invite a reviewer

- Reviewers set their own passwords. Start with your three-person team - and add others whenever needed. + Up to 10 active reviewers, including the owner, can use this workspace. + New users set their own password; existing One Concept users keep theirs.

{error && {error}} {notice && {notice}} @@ -175,6 +175,23 @@ function MemberRow({ setBusy(false); } } + async function remove() { + if (!confirm(`Permanently remove reviewer access for ${member.invited_email}? Their One Concept learning account and past review history will remain.`)) + return; + setBusy(true); + setError(""); + try { + await api.request( + `/reviewers/${member.user_id}?expected_version=${member.version}`, + "DELETE", + ); + changed(); + } catch (e) { + setError(message(e)); + } finally { + setBusy(false); + } + } return (
@@ -208,6 +225,11 @@ function MemberRow({ + {!self && ( + + )}
); } diff --git a/admin/src/api.ts b/admin/src/api.ts index f8ca134b..8f1e43c6 100644 --- a/admin/src/api.ts +++ b/admin/src/api.ts @@ -81,6 +81,8 @@ export class Api { response.status === 409 ? code === "review_conflict" && typeof detail.message === "string" ? detail.message + : typeof detail === "string" + ? detail : "This item changed or the action is no longer allowed. Reload and review the latest version before deciding." : response.status === 422 ? "Check the required fields and complete lesson package." diff --git a/admin/src/main.tsx b/admin/src/main.tsx index d04a86f4..fbbbcfb3 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -11,8 +11,11 @@ const fragment = new URLSearchParams(location.hash.slice(1)); const code = new URLSearchParams(location.search).get("code"); const ownerDemo = new URLSearchParams(location.search).get("demo") === "owner"; const callback = location.pathname === "/auth/callback"; -if (location.hash || code) - history.replaceState(null, "", callback ? "/auth/callback" : "/"); +const hasTokens = fragment.has("access_token") && fragment.has("refresh_token"); +const hasAuthFragment = fragment.has("access_token") || fragment.has("refresh_token") || fragment.has("error"); +const authReturn = hasAuthFragment || !!code; +if (authReturn) + history.replaceState(null, "", "/auth/callback"); async function start() { // The demo is selected before constructing Auth or any production API client. if (ownerDemo) { @@ -29,21 +32,34 @@ async function start() { }, }); let initialError = ""; - if (fragment.has("access_token") && fragment.has("refresh_token")) { - const { error } = await auth.auth.setSession({ + let passwordSetup: "invite" | "recovery" | "setup" | null = null; + if (hasTokens) { + const { data, error } = await auth.auth.setSession({ access_token: fragment.get("access_token")!, refresh_token: fragment.get("refresh_token")!, }); if (error) initialError = "This invitation or recovery link is no longer valid. Request a new one."; - } else if (code && callback) { - const { error } = await auth.auth.exchangeCodeForSession(code); + else if (data.session) + passwordSetup = fragment.get("type") === "invite" + ? "invite" + : fragment.get("type") === "recovery" ? "recovery" : "setup"; + else + initialError = "This email link did not open an account session. Request a new one."; + } else if (code) { + const { data, error } = await auth.auth.exchangeCodeForSession(code); if (error) initialError = - "This recovery link could not be verified. Open it in the browser that requested it, or request a new one."; - } else if (fragment.has("error")) + "This email link could not be verified. Open it in the browser that requested it, or request a new one."; + else if (data.session) + passwordSetup = "setup"; + else + initialError = "This email link did not open an account session. Request a new one."; + } else if (hasAuthFragment) initialError = "This sign-in link has expired or could not be verified."; + else if (callback) + initialError = "Open your invitation or recovery link from the email, or sign in with your existing account."; root.render( , ); diff --git a/admin/tests/fixture.ts b/admin/tests/fixture.ts index c95335ec..ebd86dc4 100644 --- a/admin/tests/fixture.ts +++ b/admin/tests/fixture.ts @@ -70,6 +70,7 @@ export async function fixture( mfa?: boolean; enrollment?: boolean; onboarding?: boolean; + requireMfa?: boolean; userId?: string; email?: string; name?: string; @@ -90,7 +91,7 @@ export async function fixture( ], requested_name: options.onboarding ? null : (options.name || "Amina Khan"), approved_name: options.onboarding ? null : (options.name || "Amina Khan"), - require_mfa: true, + require_mfa: options.requireMfa ?? true, version: 1, }; const freshState = { @@ -173,7 +174,7 @@ export async function fixture( headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*", - "access-control-allow-methods": "GET,POST,PATCH,PUT,OPTIONS", + "access-control-allow-methods": "GET,POST,PATCH,PUT,DELETE,OPTIONS", }, }); await context.route("https://review-auth.test/**", async (route) => { @@ -213,7 +214,7 @@ export async function fixture( const url = new URL(route.request().url()), path = url.pathname.replace("/v1/editorial", ""), method = route.request().method(), - body = method === "GET" ? null : route.request().postDataJSON(); + body = method === "GET" || method === "DELETE" ? null : route.request().postDataJSON(); const detail = { id: rid, concept_id: cid, @@ -285,6 +286,15 @@ export async function fixture( : actorMember, method === "GET" ? 200 : 201, ); + if (path.startsWith("/reviewers/") && method === "DELETE") { + const subject = path.split("/")[2]; + const index = state.members.findIndex((item) => item.user_id === subject); + if (index < 0) return fulfill(route, { detail: "Reviewer not found" }, 404); + if (Number(url.searchParams.get("expected_version")) !== state.members[index].version) + return fulfill(route, { detail: "Account changed" }, 409); + state.members.splice(index, 1); + return fulfill(route, { removed_user_id: subject, unassigned_count: 0 }); + } if (path === "/queue") { const kind = url.searchParams.get("kind"), status = url.searchParams.get("status"), diff --git a/admin/tests/invitation-setup.spec.ts b/admin/tests/invitation-setup.spec.ts new file mode 100644 index 00000000..933d5122 --- /dev/null +++ b/admin/tests/invitation-setup.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from "@playwright/test"; +import { fixture, login } from "./fixture"; + +test("a direct callback without a verified email link cannot open account setup", async ({ page, context }) => { + await fixture(context); + await page.goto("/auth/callback"); + await expect(page.getByText("Open your invitation or recovery link from the email", { exact: false })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Finish creating your account" })).toHaveCount(0); +}); + +test("an incomplete email return removes its credential fragment and stays on sign in", async ({ page, context }) => { + await fixture(context); + await page.goto("/auth/callback#access_token=partial-secret&type=invite"); + await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible(); + await expect(page.getByText("This sign-in link has expired or could not be verified.")).toBeVisible(); + expect(new URL(page.url()).hash).toBe(""); +}); + +test("a verified invitation opens dedicated setup for the shared learning and review account", async ({ page, context }) => { + await fixture(context, { onboarding: true, requireMfa: false }); + await login(page); + await expect(page.getByRole("heading", { name: "Let’s set up your reviewer profile" })).toBeVisible(); + const tokens = await page.evaluate(() => { + const session = JSON.parse(localStorage.getItem("one-concept-review-auth") || "null"); + return { access: session.access_token as string, refresh: session.refresh_token as string }; + }); + await page.goto(`/auth/callback#access_token=${encodeURIComponent(tokens.access)}&refresh_token=${encodeURIComponent(tokens.refresh)}&type=invite`); + await expect(page.getByRole("heading", { name: "Finish creating your account" })).toBeVisible(); + await expect(page.getByLabel("Verified email")).toHaveValue("reviewer@example.test"); + await expect(page).toHaveURL(/\/auth\/callback$/); + await page.getByLabel("Password", { exact: true }).fill("new-private-password"); + await page.getByLabel("Confirm password").fill("different-password"); + await page.getByRole("button", { name: "Save password and continue" }).click(); + await expect(page.getByText("The passwords do not match.")).toBeVisible(); + await page.getByLabel("Confirm password").fill("new-private-password"); + await page.getByRole("button", { name: "Save password and continue" }).click(); + await expect(page.getByRole("heading", { name: "Let’s set up your reviewer profile" })).toBeVisible(); + await expect(page).toHaveURL(/\?view=queue$/); +}); + +test("a recovery email returns to the same password setup without creating a second account", async ({ page, context }) => { + await fixture(context); + await login(page); + await expect(page.getByRole("heading", { name: "A little care. Better learning." })).toBeVisible(); + const tokens = await page.evaluate(() => { + const session = JSON.parse(localStorage.getItem("one-concept-review-auth") || "null"); + return { access: session.access_token as string, refresh: session.refresh_token as string }; + }); + await page.goto(`/auth/callback#access_token=${encodeURIComponent(tokens.access)}&refresh_token=${encodeURIComponent(tokens.refresh)}&type=recovery`); + await expect(page.getByRole("heading", { name: "Set your password" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Finish creating your account" })).toHaveCount(0); +}); diff --git a/admin/tests/team-controls.spec.ts b/admin/tests/team-controls.spec.ts new file mode 100644 index 00000000..ca02da2d --- /dev/null +++ b/admin/tests/team-controls.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from "@playwright/test"; +import { fixture, login } from "./fixture"; + +test("a first-time reviewer can use the workspace with authenticator off", async ({ + page, + context, +}) => { + await fixture(context, { caps: ["review"], requireMfa: false, mfa: true }); + await login(page); + await expect(page.getByRole("heading", { name: "A little care. Better learning." })).toBeVisible(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await expect(page.getByRole("switch", { name: "Require authenticator" })).not.toBeChecked(); +}); + +test("owner confirms permanent reviewer removal and the account leaves the list", async ({ + page, + context, +}) => { + const state = await fixture(context, { requireMfa: false }); + const reviewerId = "10000000-0000-4000-8000-000000000002"; + state.members.push({ ...state.member, user_id: reviewerId, + invited_email: "peer@example.test", requested_name: "Peer Reviewer", + approved_name: "Peer Reviewer", capabilities: ["review"], version: 2 }); + await login(page); + await page.getByRole("button", { name: "Reviewers", exact: true }).click(); + const row = page.locator("details.member").filter({ hasText: "peer@example.test" }); + await row.locator("summary").click(); + page.once("dialog", async (dialog) => { + expect(dialog.message()).toContain("learning account and past review history will remain"); + await dialog.accept(); + }); + await row.getByRole("button", { name: "Permanently remove reviewer" }).click(); + await expect(page.getByText("peer@example.test")).toHaveCount(0); + expect(state.members).toHaveLength(1); +}); diff --git a/admin/tests/workspace.spec.ts b/admin/tests/workspace.spec.ts index 50c556be..e8052773 100644 --- a/admin/tests/workspace.spec.ts +++ b/admin/tests/workspace.spec.ts @@ -449,7 +449,7 @@ test("owner assigns a deadline, invites a reviewer and requests bounded drafts", .getByRole("button", { name: "Invite reviewer", exact: true }) .click(); await expect( - page.getByText("Membership created.", { exact: false }), + page.getByText("Reviewer access created.", { exact: false }), ).toBeVisible(); await page.getByRole("button", { name: "AI requests", exact: true }).click(); await page @@ -568,13 +568,14 @@ test("invitation callback clears URL credentials and allows private password set "&type=invite", ); await expect( - page.getByRole("heading", { name: "Set your password" }), + page.getByRole("heading", { name: "Finish creating your account" }), ).toBeVisible(); expect(new URL(page.url()).hash).toBe(""); await page .getByLabel("Password", { exact: true }) .fill("new-private-fixture-password"); - await page.getByRole("button", { name: "Save password" }).click(); + await page.getByLabel("Confirm password").fill("new-private-fixture-password"); + await page.getByRole("button", { name: "Save password and continue" }).click(); await expect( page.getByRole("heading", { name: "A little care. Better learning." }), ).toBeVisible(); diff --git a/backend/app/api/v1/editorial.py b/backend/app/api/v1/editorial.py index ae62e512..2933ec0b 100644 --- a/backend/app/api/v1/editorial.py +++ b/backend/app/api/v1/editorial.py @@ -20,6 +20,7 @@ Member, MemberPage, ProfileInput, + ReviewerRemoval, VersionInput, ) from app.services.editorial_accounts import authorize @@ -150,6 +151,18 @@ async def access( return member +@router.delete("/reviewers/{user_id}", response_model=ReviewerRemoval) +async def remove_reviewer( + user_id: UUID, user: User, db: DB, settings: Config, + expected_version: int = Query(ge=1), +): + result = await management.remove_reviewer( + db, user, settings, user_id, expected_version + ) + await db.commit() + return result + + @router.post("/reviewers/{user_id}/approve-profile", response_model=Member) async def approve_profile( user_id: UUID, body: VersionInput, user: User, db: DB, settings: Config diff --git a/backend/app/db/schema.py b/backend/app/db/schema.py index 992b5da4..7d7cef2a 100644 --- a/backend/app/db/schema.py +++ b/backend/app/db/schema.py @@ -115,6 +115,7 @@ async def snapshot(connection) -> dict: 'edge_api_editorial_owner_reviewers', 'edge_api_editorial_owner_events', 'edge_api_editorial_reviewer_access', + 'edge_api_editorial_reviewer_remove', 'edge_api_editorial_invite_prepare', 'edge_api_editorial_invite_finish', 'edge_api_editorial_cancel_generation_job', diff --git a/backend/app/schemas/editorial.py b/backend/app/schemas/editorial.py index 0885b24a..157cbb4b 100644 --- a/backend/app/schemas/editorial.py +++ b/backend/app/schemas/editorial.py @@ -86,6 +86,11 @@ class Member(BaseModel): updated_at: datetime +class ReviewerRemoval(BaseModel): + removed_user_id: UUID + unassigned_count: int = Field(ge=0) + + class Me(BaseModel): member: Member onboarding_required: bool diff --git a/backend/app/services/editorial_accounts.py b/backend/app/services/editorial_accounts.py index e1ea0b00..73e90c17 100644 --- a/backend/app/services/editorial_accounts.py +++ b/backend/app/services/editorial_accounts.py @@ -149,8 +149,8 @@ async def bootstrap_owner(db: AsyncSession, email: str, name: str) -> Member: row = rows[0] await db.execute( text("""insert into public.editorial_memberships - (user_id,invited_email,capabilities,requested_name,approved_name) - values (:uid,:email,:caps,:name,:name)"""), + (user_id,invited_email,capabilities,requested_name,approved_name,require_mfa) + values (:uid,:email,:caps,:name,:name,false)"""), {"uid": row["id"], "email": row["email"], "caps": CAPABILITIES, "name": name}, ) await audit( diff --git a/backend/app/services/editorial_management.py b/backend/app/services/editorial_management.py index ec2c4706..a8d374bb 100644 --- a/backend/app/services/editorial_management.py +++ b/backend/app/services/editorial_management.py @@ -1,5 +1,6 @@ """Versioned account mutations. All authority is rechecked under the account lock.""" +import json import time from uuid import UUID @@ -9,33 +10,53 @@ from app.config import Settings from app.deps import CurrentUser -from app.schemas.editorial import AccessInput, AuthenticatorInput, InviteInput, Member, ProfileInput +from app.schemas.editorial import ( + AccessInput, AuthenticatorInput, InviteInput, Member, ProfileInput, + ReviewerRemoval, +) from app.services.editorial_accounts import audit, authorize, check_version, membership from app.services.editorial_invites import invite_auth_user +MAX_ACTIVE_REVIEWERS = 10 + + +async def require_team_slot(db: AsyncSession) -> None: + # Account mutations hold the same advisory lock, so concurrent invitations + # and reactivations cannot both claim the last slot. + active = await db.scalar( + text("select count(*) from public.editorial_memberships where status='active'") + ) + if active >= MAX_ACTIVE_REVIEWERS: + raise HTTPException(409, "The team already has 10 active reviewers") + async def invite( db: AsyncSession, actor: CurrentUser, settings: Settings, body: InviteInput ) -> Member: await authorize(db, actor, settings, "manage_reviewers", mutation=True) - # An existing Auth account is explicitly enrolled by this owner request. - # Retry after an ambiguous provider response binds the same account instead - # of sending more mail; confirmation/onboarding are still required. + await require_team_slot(db) + # Confirmed learners keep their account and password. An unconfirmed Auth + # identity may be an earlier invite whose membership transaction failed; + # resend its invitation so it still has a usable confirmation/setup link. rows = ( ( await db.execute( - text("select id from auth.users where lower(email)=:email"), + text("select id,email_confirmed_at from auth.users where lower(email)=:email"), {"email": body.email}, ) ) - .scalars() + .mappings() .all() ) if len(rows) > 1: raise HTTPException( 409, "Resolve duplicate Auth email identities before inviting" ) - uid = rows[0] if rows else await invite_auth_user(body.email, settings) + uid = ( + rows[0]["id"] + if rows and rows[0]["email_confirmed_at"] is not None + else await invite_auth_user(body.email, settings) + ) # Validate provider identity against the authoritative project DB as well. matches = await db.scalar( text( @@ -56,8 +77,8 @@ async def invite( ) caps = sorted(set(body.capabilities)) await db.execute( - text("""insert into editorial_memberships(user_id,invited_email,capabilities) - values (:id,:email,:caps)"""), + text("""insert into editorial_memberships(user_id,invited_email,capabilities,require_mfa) + values (:id,:email,:caps,false)"""), {"id": uid, "email": body.email, "caps": caps}, ) await audit(db, actor.id, uid, "invite", {"capabilities": caps}) @@ -186,6 +207,8 @@ async def change_access( ) if current.status == body.status and set(current.capabilities) == set(caps): return current + if current.status != "active" and body.status == "active": + await require_team_slot(db) await db.execute( text("""update editorial_memberships set status=:status,capabilities=:caps, version=version+1,updated_at=now() where user_id=:id"""), @@ -205,3 +228,40 @@ async def change_access( }, ) return await membership(db, uid) + + +async def remove_reviewer( + db: AsyncSession, + actor: CurrentUser, + settings: Settings, + uid: UUID, + expected_version: int, +) -> ReviewerRemoval: + await authorize(db, actor, settings, "manage_reviewers") + payload = await db.scalar( + text("""select public.edge_api_editorial_reviewer_remove( + cast(:actor as uuid),cast(:session as uuid),:aal, + cast(:subject as uuid),:version)"""), + {"actor": actor.id, "session": actor.session_id, "aal": actor.aal, + "subject": uid, "version": expected_version}, + ) + if isinstance(payload, str): + payload = json.loads(payload) + errors = { + "invalid": (422, "Invalid reviewer removal"), + "session_required": (401, "A confirmed account and current session are required"), + "unavailable": (403, "Editorial access is unavailable"), + "permission": (403, "Editorial permission required"), + "profile_required": (403, "Complete profile setup and obtain name approval"), + "mfa_required": (403, "Complete MFA verification for editorial actions"), + "self_removal": (403, "You cannot remove your own reviewer access"), + "reviewer_not_found": (404, "Reviewer not found"), + "version_conflict": (409, "Account changed; reload before removing"), + "last_admin": (409, "Keep another confirmed, approved administrator before removing this access"), + } + if isinstance(payload, dict) and payload.get("error") in errors: + status, detail = errors[payload["error"]] + raise HTTPException(status, detail) + if not isinstance(payload, dict) or "removed_user_id" not in payload: + raise HTTPException(503, "Reviewer removal is unavailable") + return ReviewerRemoval.model_validate(payload) diff --git a/backend/app/services/editorial_notifications.py b/backend/app/services/editorial_notifications.py index 23b59585..dea9a58d 100644 --- a/backend/app/services/editorial_notifications.py +++ b/backend/app/services/editorial_notifications.py @@ -127,7 +127,7 @@ async def prepare(db, settings): url = f"{settings.editorial_email_dashboard_url.rstrip('/')}/?view=review&kind=revisions&id={item['revision_id']}" body.extend([f"Topic: {item['topic_name']} | Due: {due}", url, ""]) body.append( - "Sign in with your invited account and authenticator. Review and approval happen only in the workspace." + "Sign in with your account. If you enabled an authenticator requirement, verify it there. Review and approval happen only in the workspace." ) bid = uuid4() await db.execute( diff --git a/backend/email-templates/invite-reviewer.html b/backend/email-templates/invite-reviewer.html new file mode 100644 index 00000000..5e1830dd --- /dev/null +++ b/backend/email-templates/invite-reviewer.html @@ -0,0 +1,39 @@ + + + + + + You're invited to One Concept Review + + + +
Accept your invitation and choose a password for the review workspace.
+ + +
+ + + + +
+

One Concept.

+

Review workspace

+
+

You're invited to review.

+

Accept this invitation to confirm your email address. The One Concept Review website will then ask you to choose your own password. The same account works in the One Concept learning app.

+ + +
+ Accept invitation +
+

After setup, sign in at One Concept Review with this email and your password.

+
+

If you were not expecting this invitation, you can ignore this email.

+

Button not working? Copy this verification link into your browser:

+

+ {{ .ConfirmationURL }} +

+
+
+ + diff --git a/backend/migrations/0131_editorial_reviewer_limit.sql b/backend/migrations/0131_editorial_reviewer_limit.sql new file mode 100644 index 00000000..5b4f1e25 --- /dev/null +++ b/backend/migrations/0131_editorial_reviewer_limit.sql @@ -0,0 +1,181 @@ +-- Enforce ten active editorial memberships, including the owner. +-- New reviewer invitations start with an optional authenticator. +begin; + +create or replace function public.edge_api_editorial_invite_prepare( + p_user_id uuid, p_session_id uuid, p_aal text, p_email text +) +returns jsonb +language plpgsql stable security definer +set search_path = '' +as $$ +declare + v_actor jsonb; + v_count bigint; + v_identity uuid; +begin + v_actor := public.edge_api_editorial_authorize( + p_user_id,p_session_id,p_aal,'manage_reviewers'); + if v_actor ? 'error' then return v_actor; end if; + if p_email is null or length(p_email)>254 or p_email<>lower(btrim(p_email)) + or p_email !~ '^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$' then + return jsonb_build_object('error','invalid'); + end if; + select count(*),min(id::text)::uuid into v_count,v_identity from auth.users + where lower(email)=p_email; + if v_count>1 then return jsonb_build_object('error','duplicate_identity'); end if; + if v_identity is not null and exists(select 1 from public.editorial_memberships + where user_id=v_identity) then + return jsonb_build_object('error','reviewer_exists'); + end if; + if (select count(*) from public.editorial_memberships where status='active')>=10 then + return jsonb_build_object('error','team_full'); + end if; + return jsonb_build_object('user_id',v_identity, + 'needs_invite',v_identity is null); +end +$$; + +create or replace function public.edge_api_editorial_invite_finish( + p_user_id uuid, p_session_id uuid, p_aal text, + p_email text, p_invited_id uuid, p_capabilities text[] +) +returns jsonb +language plpgsql volatile security definer +set search_path = '' +as $$ +declare + v_actor jsonb; + v_caps text[]; + v_count bigint; +begin + if p_invited_id is null or p_email is null or length(p_email)>254 + or p_email<>lower(btrim(p_email)) + or p_email !~ '^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$' + or p_capabilities is null or cardinality(p_capabilities)>5 + or array_position(p_capabilities,null) is not null + or not (p_capabilities <@ array['review','approve','publish', + 'request_generation','manage_reviewers']::text[]) then + return jsonb_build_object('error','invalid'); + end if; + select coalesce(array_agg(cap order by cap),array[]::text[]) into v_caps + from (select distinct unnest(p_capabilities) as cap) dedup; + v_actor := public.edge_api_editorial_authorize( + p_user_id,p_session_id,p_aal,'manage_reviewers'); + if v_actor ? 'error' then return v_actor; end if; + perform pg_catalog.pg_advisory_xact_lock(274,263); + v_actor := public.edge_api_editorial_authorize( + p_user_id,p_session_id,p_aal,'manage_reviewers'); + if v_actor ? 'error' then return v_actor; end if; + select count(*) into v_count from auth.users + where lower(email)=p_email; + if v_count>1 then return jsonb_build_object('error','duplicate_identity'); end if; + if v_count=0 or not exists(select 1 from auth.users + where id=p_invited_id and lower(email)=p_email) then + return jsonb_build_object('error','identity_not_ready'); + end if; + if exists(select 1 from public.editorial_memberships + where user_id=p_invited_id) then + return jsonb_build_object('error','reviewer_exists'); + end if; + if (select count(*) from public.editorial_memberships where status='active')>=10 then + return jsonb_build_object('error','team_full'); + end if; + insert into public.editorial_memberships(user_id,invited_email,capabilities,require_mfa) + values(p_invited_id,p_email,v_caps,false); + insert into public.editorial_account_events(actor_id,subject_id,action,details) + values(p_user_id,p_invited_id,'invite', + jsonb_build_object('capabilities',v_caps)); + return (select to_jsonb(m) from public.editorial_memberships m + where m.user_id=p_invited_id); +end +$$; + +revoke all on function public.edge_api_editorial_invite_prepare( + uuid,uuid,text,text) from public, anon, authenticated; +revoke all on function public.edge_api_editorial_invite_finish( + uuid,uuid,text,text,uuid,text[]) from public, anon, authenticated; +grant execute on function public.edge_api_editorial_invite_prepare( + uuid,uuid,text,text) to service_role; +grant execute on function public.edge_api_editorial_invite_finish( + uuid,uuid,text,text,uuid,text[]) to service_role; + +create or replace function public.edge_api_editorial_reviewer_access( + p_user_id uuid, p_session_id uuid, p_aal text, + p_subject_id uuid, p_expected_version integer, + p_status text, p_capabilities text[] +) +returns jsonb +language plpgsql volatile security definer +set search_path = '' +as $$ +declare + v_actor jsonb; + v_subject public.editorial_memberships%rowtype; + v_caps text[]; + v_another boolean; +begin + if p_subject_id is null or p_expected_version is null or p_expected_version<1 + or p_status is null or p_status not in ('active','revoked') + or p_capabilities is null or cardinality(p_capabilities)>5 + or array_position(p_capabilities,null) is not null + or not (p_capabilities <@ array['review','approve','publish', + 'request_generation','manage_reviewers']::text[]) then + return jsonb_build_object('error','invalid'); + end if; + select coalesce(array_agg(cap order by cap),array[]::text[]) into v_caps + from (select distinct unnest(p_capabilities) as cap) dedup; + v_actor := public.edge_api_editorial_authorize( + p_user_id,p_session_id,p_aal,'manage_reviewers'); + if v_actor ? 'error' then return v_actor; end if; + perform pg_catalog.pg_advisory_xact_lock(274,263); + v_actor := public.edge_api_editorial_authorize( + p_user_id,p_session_id,p_aal,'manage_reviewers'); + if v_actor ? 'error' then return v_actor; end if; + select * into v_subject from public.editorial_memberships + where user_id=p_subject_id for update; + if not found then return jsonb_build_object('error','unavailable'); end if; + if v_subject.version<>p_expected_version then + return jsonb_build_object('error','version_conflict'); + end if; + if 'manage_reviewers'=any(v_subject.capabilities) + and v_subject.status='active' + and (p_status='revoked' or not 'manage_reviewers'=any(v_caps)) then + select exists(select 1 from public.editorial_memberships m + join auth.users u on u.id=m.user_id + where m.user_id<>p_subject_id and m.status='active' + and m.approved_name is not null + and 'manage_reviewers'=any(m.capabilities) + and u.email_confirmed_at is not null + and (u.banned_until is null or u.banned_until<=now())) into v_another; + if not v_another then return jsonb_build_object('error','last_admin'); end if; + end if; + if v_subject.status=p_status and ( + select coalesce(array_agg(distinct cap order by cap),array[]::text[]) + from unnest(v_subject.capabilities) cap + ) = v_caps then + return to_jsonb(v_subject); + end if; + if v_subject.status<>'active' and p_status='active' + and (select count(*) from public.editorial_memberships where status='active')>=10 then + return jsonb_build_object('error','team_full'); + end if; + update public.editorial_memberships set status=p_status, + capabilities=v_caps,version=version+1,updated_at=now() + where user_id=p_subject_id; + insert into public.editorial_account_events(actor_id,subject_id,action,details) + values(p_user_id,p_subject_id,'access_changed',jsonb_build_object( + 'previous_status',v_subject.status,'status',p_status, + 'previous_capabilities',v_subject.capabilities, + 'capabilities',v_caps,'version',v_subject.version+1)); + return (select to_jsonb(m) from public.editorial_memberships m + where m.user_id=p_subject_id); +end +$$; + +revoke all on function public.edge_api_editorial_reviewer_access( + uuid,uuid,text,uuid,integer,text,text[]) from public, anon, authenticated; +grant execute on function public.edge_api_editorial_reviewer_access( + uuid,uuid,text,uuid,integer,text,text[]) to service_role; + +commit; diff --git a/backend/migrations/0132_editorial_reviewer_removal.sql b/backend/migrations/0132_editorial_reviewer_removal.sql new file mode 100644 index 00000000..9bda1fa4 --- /dev/null +++ b/backend/migrations/0132_editorial_reviewer_removal.sql @@ -0,0 +1,87 @@ +-- A reviewer can leave the private team without deleting their learning account +-- or the immutable evidence of reviews and legacy work they requested. +begin; + +alter table public.editorial_legacy_batches + drop constraint editorial_legacy_batches_requested_by_fkey; +alter table public.editorial_legacy_batches + add constraint editorial_legacy_batches_requested_by_fkey + foreign key (requested_by) references auth.users(id) on delete restrict; + +alter table public.editorial_account_events + drop constraint editorial_account_events_action_check; +alter table public.editorial_account_events + add constraint editorial_account_events_action_check check (action in ( + 'bootstrap','invite','profile_requested','profile_approved','access_changed', + 'notification_policy','notification_timezone','notification_retry', + 'authenticator_choice','reviewer_removed' + )); + +create function public.edge_api_editorial_reviewer_remove( + p_user_id uuid, p_session_id uuid, p_aal text, + p_subject_id uuid, p_expected_version integer +) +returns jsonb +language plpgsql volatile security definer +set search_path = '' +as $$ +declare + v_actor jsonb; + v_subject public.editorial_memberships%rowtype; + v_another boolean; + v_unassigned integer; +begin + if p_subject_id is null or p_expected_version is null or p_expected_version<1 then + return jsonb_build_object('error','invalid'); + end if; + v_actor := public.edge_api_editorial_authorize( + p_user_id,p_session_id,p_aal,'manage_reviewers'); + if v_actor ? 'error' then return v_actor; end if; + if p_subject_id=p_user_id then return jsonb_build_object('error','self_removal'); end if; + perform pg_catalog.pg_advisory_xact_lock(274,263); + v_actor := public.edge_api_editorial_authorize( + p_user_id,p_session_id,p_aal,'manage_reviewers'); + if v_actor ? 'error' then return v_actor; end if; + perform pg_catalog.pg_advisory_xact_lock(195,1); + perform pg_catalog.pg_advisory_xact_lock(279,263); + select * into v_subject from public.editorial_memberships + where user_id=p_subject_id for update; + if not found then return jsonb_build_object('error','reviewer_not_found'); end if; + if v_subject.version<>p_expected_version then + return jsonb_build_object('error','version_conflict'); + end if; + if v_subject.status='active' and 'manage_reviewers'=any(v_subject.capabilities) then + select exists(select 1 from public.editorial_memberships m + join auth.users u on u.id=m.user_id + where m.user_id<>p_subject_id and m.status='active' + and m.approved_name is not null + and 'manage_reviewers'=any(m.capabilities) + and u.email_confirmed_at is not null + and (u.banned_until is null or u.banned_until<=pg_catalog.statement_timestamp())) + into v_another; + if not v_another then return jsonb_build_object('error','last_admin'); end if; + end if; + update public.concept_revisions set assigned_to=null,review_due_at=null, + review_assigned_at=null + where assigned_to=p_subject_id and status not in ('published','retired'); + get diagnostics v_unassigned = row_count; + update public.editorial_notification_outbox set suppressed=true + where recipient_id=p_subject_id and batch_id is null and not suppressed; + delete from public.editorial_memberships where user_id=p_subject_id; + insert into public.editorial_account_events(actor_id,subject_id,action,details) + values(p_user_id,p_subject_id,'reviewer_removed',jsonb_build_object( + 'previous_status',v_subject.status, + 'previous_capabilities',v_subject.capabilities, + 'previous_version',v_subject.version, + 'unassigned_count',v_unassigned)); + return jsonb_build_object('removed_user_id',p_subject_id, + 'unassigned_count',v_unassigned); +end +$$; + +revoke all on function public.edge_api_editorial_reviewer_remove( + uuid,uuid,text,uuid,integer) from public, anon, authenticated; +grant execute on function public.edge_api_editorial_reviewer_remove( + uuid,uuid,text,uuid,integer) to service_role; + +commit; diff --git a/backend/migrations/0133_optional_authenticator_email_copy.sql b/backend/migrations/0133_optional_authenticator_email_copy.sql new file mode 100644 index 00000000..01ea2250 --- /dev/null +++ b/backend/migrations/0133_optional_authenticator_email_copy.sql @@ -0,0 +1,116 @@ +-- Match assignment email copy to the optional reviewer authenticator setting. +begin; + +create or replace function public.edge_worker_prepare_email( + p_production boolean, p_recipients text[], p_dashboard_origin text, + p_limit integer default 10 +) +returns integer language plpgsql volatile security definer set search_path='' as $$ +declare + v_recipient record; + v_item record; + v_ids uuid[]; + v_body text; + v_batch uuid; + v_count integer := 0; +begin + if p_production is null or p_recipients is null or + p_dashboard_origin is null or length(p_dashboard_origin)>512 or + p_dashboard_origin !~ '^https://[^/?#]+$' or + p_limit is null or p_limit not between 1 and 10 then + raise exception 'Invalid editorial mail preparation settings'; + end if; + perform pg_advisory_xact_lock(274,263); + perform pg_advisory_xact_lock(195,1); + perform pg_advisory_xact_lock(279,263); + + insert into public.editorial_notification_outbox( + revision_id,recipient_id,epoch,ordinal) + select r.id,r.assigned_to,r.notification_epoch, + least(p.max_reminders,floor(extract(epoch from (now()-r.review_assigned_at)) + /3600/p.reminder_hours)::integer) + from public.concept_revisions r + cross join public.editorial_notification_policy p + where r.assigned_to is not null and r.status in ('draft','pending_review') + and p.max_reminders>0 + and r.review_assigned_at<=now()+make_interval(hours=>-p.reminder_hours) + on conflict do nothing; + + update public.editorial_notification_outbox o set suppressed=true + where not o.suppressed and o.batch_id is null + and not public.edge_worker_email_entry_eligible(o.id); + + for v_recipient in + select o.recipient_id,u.email,m.notification_timezone + from public.editorial_notification_outbox o + join public.editorial_memberships m on m.user_id=o.recipient_id + join auth.users u on u.id=m.user_id + where o.batch_id is null and not o.suppressed + and o.created_at<=now()-interval '5 minutes' + and public.edge_worker_email_entry_eligible(o.id) + and (p_production or lower(u.email)=any(p_recipients)) + and not exists ( + select 1 from public.editorial_notification_outbox newer + where newer.revision_id=o.revision_id and newer.epoch=o.epoch + and not newer.suppressed and newer.batch_id is null + and newer.ordinal>o.ordinal) + group by o.recipient_id,u.email,m.notification_timezone + order by o.recipient_id limit p_limit + loop + v_ids := '{}'::uuid[]; + v_body := ''; + for v_item in + select picked.id,picked.revision_id,picked.ordinal, + r.review_due_at,t.name as topic_name + from ( + select distinct on (o.revision_id) + o.id,o.revision_id,o.ordinal + from public.editorial_notification_outbox o + where o.recipient_id=v_recipient.recipient_id + and o.batch_id is null and not o.suppressed + and o.created_at<=now()-interval '5 minutes' + and public.edge_worker_email_entry_eligible(o.id) + and not exists ( + select 1 from public.editorial_notification_outbox newer + where newer.revision_id=o.revision_id and newer.epoch=o.epoch + and not newer.suppressed and newer.batch_id is null + and newer.ordinal>o.ordinal) + order by o.revision_id,o.ordinal desc + ) picked + join public.concept_revisions r on r.id=picked.revision_id + join public.concepts c on c.id=r.concept_id + join public.topics t on t.id=c.topic_id + order by picked.revision_id limit 25 + loop + v_ids := array_append(v_ids,v_item.id); + v_body := v_body || 'Topic: ' || v_item.topic_name || ' | Due: ' || + case when v_item.review_due_at is null then 'No deadline' + else to_char(v_item.review_due_at at time zone + v_recipient.notification_timezone,'YYYY-MM-DD HH24:MI') || + ' ' || v_recipient.notification_timezone end || E'\n' || + p_dashboard_origin || '/?view=review&kind=revisions&id=' || + v_item.revision_id::text || E'\n\n'; + end loop; + if cardinality(v_ids)=0 then continue; end if; + v_body := 'You have ' || cardinality(v_ids)::text || + E' assigned lesson(s) ready for review.\n\n' || v_body || + 'Sign in with your account. If you enabled an authenticator requirement, verify it there. Review and approval happen only in the workspace.'; + insert into public.editorial_email_batches( + recipient_id,recipient_email,subject,body) + values(v_recipient.recipient_id,v_recipient.email, + 'One Concept: assigned lessons to review',v_body) + returning id into v_batch; + update public.editorial_notification_outbox set batch_id=v_batch + where id=any(v_ids); + update public.editorial_notification_outbox o set suppressed=true + from public.editorial_notification_outbox chosen + where chosen.id=any(v_ids) and o.batch_id is null + and o.revision_id=chosen.revision_id and o.epoch=chosen.epoch + and o.ordinal254 or p_email<>lower(btrim(p_email)) + or p_email !~ '^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$' then + return jsonb_build_object('error','invalid'); + end if; + select count(*),min(id::text)::uuid,min(email_confirmed_at) + into v_count,v_identity,v_confirmed_at from auth.users + where lower(email)=p_email; + if v_count>1 then return jsonb_build_object('error','duplicate_identity'); end if; + if v_identity is not null and exists(select 1 from public.editorial_memberships + where user_id=v_identity) then + return jsonb_build_object('error','reviewer_exists'); + end if; + if (select count(*) from public.editorial_memberships where status='active')>=10 then + return jsonb_build_object('error','team_full'); + end if; + return jsonb_build_object('user_id',v_identity, + 'needs_invite',v_confirmed_at is null); +end +$$; + +commit; diff --git a/backend/schema/contract.json b/backend/schema/contract.json index dd202b08..47997c20 100644 --- a/backend/schema/contract.json +++ b/backend/schema/contract.json @@ -129,7 +129,11 @@ "0127_supabase_legacy_enrichment_worker.sql": "6cbd73098ab8cc42c85a9c2d6584c27f2d76cc0484217f25da9db02209f18e55", "0128_supabase_worker_owner_reporting.sql": "2df8d9f6ae0f9f99a6210f1d015651f00d9dc8373ba1edeaef84f1e2d5fc0afd", "0129_supabase_worker_run_retention.sql": "21deceb956f640102aba8728b65f64da8c84c4675f18ed8bbe3f7917172b9524", - "0130_supabase_email_ambiguous_delivery.sql": "c9916265e8cc34832b2f666a0d6584185c7fccac2e5cc7ee12392de40b0f5bd5" + "0130_supabase_email_ambiguous_delivery.sql": "c9916265e8cc34832b2f666a0d6584185c7fccac2e5cc7ee12392de40b0f5bd5", + "0131_editorial_reviewer_limit.sql": "9ddbb37745e11fd0ae3128367acca721f532129ad114e04082d7d9484fcda25e", + "0132_editorial_reviewer_removal.sql": "0dd9748ce7b6b9e9f754d1f0f7071b47418c0d9ab4aa71fe1a6b3452df4bd2be", + "0133_optional_authenticator_email_copy.sql": "4001debc2422f6a2496f2c3805ba2ec6508a65a3ae9fbc4472252a283edcbda3", + "0134_editorial_reinvite_unconfirmed.sql": "d91f5247c4d0395a744255a2985ed14a31d0f4f22663a3e40a950492851780fa" }, "schema": { "columns": { @@ -2546,7 +2550,7 @@ "validated": true }, "editorial_account_events.editorial_account_events_action_check": { - "definition": "CHECK ((action = ANY (ARRAY['bootstrap'::text, 'invite'::text, 'profile_requested'::text, 'profile_approved'::text, 'access_changed'::text, 'notification_policy'::text, 'notification_timezone'::text, 'notification_retry'::text, 'authenticator_choice'::text])))", + "definition": "CHECK ((action = ANY (ARRAY['bootstrap'::text, 'invite'::text, 'profile_requested'::text, 'profile_approved'::text, 'access_changed'::text, 'notification_policy'::text, 'notification_timezone'::text, 'notification_retry'::text, 'authenticator_choice'::text, 'reviewer_removed'::text])))", "validated": true }, "editorial_account_events.editorial_account_events_pkey": { @@ -2698,7 +2702,7 @@ "validated": true }, "editorial_legacy_batches.editorial_legacy_batches_requested_by_fkey": { - "definition": "FOREIGN KEY (requested_by) REFERENCES public.editorial_memberships(user_id) ON DELETE RESTRICT", + "definition": "FOREIGN KEY (requested_by) REFERENCES auth.users(id) ON DELETE RESTRICT", "validated": true }, "editorial_legacy_batches.editorial_legacy_batches_status_check": { @@ -3270,10 +3274,10 @@ "definition": "CREATE OR REPLACE FUNCTION public.edge_api_editorial_generation_supply(p_user_id uuid, p_session_id uuid, p_aal text, p_topic_id uuid, p_floor integer, p_review_capacity integer, p_generation_enabled boolean, p_provider_configured boolean)\n RETURNS jsonb\n LANGUAGE plpgsql\n STABLE\n SET search_path TO ''\nAS $function$\ndeclare\n v_actor jsonb;\n v_active boolean;\n v_published bigint;\n v_drafts bigint;\n v_pending bigint;\n v_generating bigint;\n v_failed bigint;\n v_ready bigint;\n v_review_load bigint;\n v_target integer;\nbegin\n if p_floor is null or p_floor < 0\n or p_review_capacity is null or p_review_capacity < 1 or p_review_capacity > 250\n or p_generation_enabled is null or p_provider_configured is null then\n return jsonb_build_object('error','invalid_config');\n end if;\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'review');\n if v_actor ? 'error' then return v_actor; end if;\n select is_active into v_active from public.topics where id=p_topic_id;\n if not found then return jsonb_build_object('error','topic_not_found'); end if;\n select\n (select count(*) from public.concepts c join public.subtopics s on s.id=c.subtopic_id\n where c.topic_id=p_topic_id and s.is_active and c.status='published'),\n (select count(*) from public.concepts c join public.subtopics s on s.id=c.subtopic_id\n where c.topic_id=p_topic_id and s.is_active and c.status='draft'),\n (select count(*) from public.concept_backlog b join public.subtopics s on s.id=b.subtopic_id\n where b.topic_id=p_topic_id and s.is_active and b.status='pending'\n and b.attempts<3+(select count(*) from public.content_retry_log l where l.backlog_id=b.id)),\n (select count(*) from public.concept_backlog b join public.subtopics s on s.id=b.subtopic_id\n where b.topic_id=p_topic_id and s.is_active and b.status='generating'),\n (select count(*) from public.concept_backlog b join public.subtopics s on s.id=b.subtopic_id\n where b.topic_id=p_topic_id and s.is_active and b.status='failed'),\n (select count(*) from public.concept_revisions r join public.concepts c on c.id=r.concept_id\n join public.subtopics s on s.id=c.subtopic_id\n where c.topic_id=p_topic_id and s.is_active and c.status<>'archived'\n and r.base_version=c.content_version\n and r.status in ('draft','pending_review','changes_requested',\n 'validation_failed','approved'))\n into v_published,v_drafts,v_pending,v_generating,v_failed,v_ready;\n select\n (select count(*) from public.concepts c join public.subtopics s on s.id=c.subtopic_id\n where c.topic_id=p_topic_id and s.is_active and (c.status='draft' or\n (c.status='published' and exists(select 1 from public.concept_revisions r\n where r.concept_id=c.id and r.base_version=c.content_version\n and r.status in ('draft','generating','validation_failed',\n 'pending_review','changes_requested','approved'))))) +\n (select count(*) from public.concept_backlog b join public.subtopics s on s.id=b.subtopic_id\n where b.topic_id=p_topic_id and s.is_active and b.status='generating')\n into v_review_load;\n select greatest(p_floor,coalesce(\n (select target_count from public.content_supply_targets\n where topic_id=p_topic_id and expires_at>now()),0)) into v_target;\n return jsonb_build_object(\n 'topic_id',p_topic_id,'active',v_active,\n 'published',v_published,'drafts',v_drafts,'pending',v_pending,\n 'generating',v_generating,'failed',v_failed,'ready_for_review',v_ready,\n 'target',v_target,'review_load',v_review_load,\n 'review_capacity',p_review_capacity,\n 'review_blocked',v_review_load>=p_review_capacity,\n 'planning_required',v_active and v_pending=0 and v_generating=0\n and v_published+v_drafts254\n or p_email<>lower(btrim(p_email))\n or p_email !~ '^[^[:space:]@]+@[^[:space:]@]+\\.[^[:space:]@]+$'\n or p_capabilities is null or cardinality(p_capabilities)>5\n or array_position(p_capabilities,null) is not null\n or not (p_capabilities <@ array['review','approve','publish',\n 'request_generation','manage_reviewers']::text[]) then\n return jsonb_build_object('error','invalid');\n end if;\n select coalesce(array_agg(cap order by cap),array[]::text[]) into v_caps\n from (select distinct unnest(p_capabilities) as cap) dedup;\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'manage_reviewers');\n if v_actor ? 'error' then return v_actor; end if;\n perform pg_catalog.pg_advisory_xact_lock(274,263);\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'manage_reviewers');\n if v_actor ? 'error' then return v_actor; end if;\n select count(*) into v_count from auth.users\n where lower(email)=p_email;\n if v_count>1 then return jsonb_build_object('error','duplicate_identity'); end if;\n if v_count=0 or not exists(select 1 from auth.users\n where id=p_invited_id and lower(email)=p_email) then\n return jsonb_build_object('error','identity_not_ready');\n end if;\n if exists(select 1 from public.editorial_memberships\n where user_id=p_invited_id) then\n return jsonb_build_object('error','reviewer_exists');\n end if;\n insert into public.editorial_memberships(user_id,invited_email,capabilities)\n values(p_invited_id,p_email,v_caps);\n insert into public.editorial_account_events(actor_id,subject_id,action,details)\n values(p_user_id,p_invited_id,'invite',\n jsonb_build_object('capabilities',v_caps));\n return (select to_jsonb(m) from public.editorial_memberships m\n where m.user_id=p_invited_id);\nend\n$function$\n" + "definition": "CREATE OR REPLACE FUNCTION public.edge_api_editorial_invite_finish(p_user_id uuid, p_session_id uuid, p_aal text, p_email text, p_invited_id uuid, p_capabilities text[])\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\ndeclare\n v_actor jsonb;\n v_caps text[];\n v_count bigint;\nbegin\n if p_invited_id is null or p_email is null or length(p_email)>254\n or p_email<>lower(btrim(p_email))\n or p_email !~ '^[^[:space:]@]+@[^[:space:]@]+\\.[^[:space:]@]+$'\n or p_capabilities is null or cardinality(p_capabilities)>5\n or array_position(p_capabilities,null) is not null\n or not (p_capabilities <@ array['review','approve','publish',\n 'request_generation','manage_reviewers']::text[]) then\n return jsonb_build_object('error','invalid');\n end if;\n select coalesce(array_agg(cap order by cap),array[]::text[]) into v_caps\n from (select distinct unnest(p_capabilities) as cap) dedup;\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'manage_reviewers');\n if v_actor ? 'error' then return v_actor; end if;\n perform pg_catalog.pg_advisory_xact_lock(274,263);\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'manage_reviewers');\n if v_actor ? 'error' then return v_actor; end if;\n select count(*) into v_count from auth.users\n where lower(email)=p_email;\n if v_count>1 then return jsonb_build_object('error','duplicate_identity'); end if;\n if v_count=0 or not exists(select 1 from auth.users\n where id=p_invited_id and lower(email)=p_email) then\n return jsonb_build_object('error','identity_not_ready');\n end if;\n if exists(select 1 from public.editorial_memberships\n where user_id=p_invited_id) then\n return jsonb_build_object('error','reviewer_exists');\n end if;\n if (select count(*) from public.editorial_memberships where status='active')>=10 then\n return jsonb_build_object('error','team_full');\n end if;\n insert into public.editorial_memberships(user_id,invited_email,capabilities,require_mfa)\n values(p_invited_id,p_email,v_caps,false);\n insert into public.editorial_account_events(actor_id,subject_id,action,details)\n values(p_user_id,p_invited_id,'invite',\n jsonb_build_object('capabilities',v_caps));\n return (select to_jsonb(m) from public.editorial_memberships m\n where m.user_id=p_invited_id);\nend\n$function$\n" }, "edge_api_editorial_invite_prepare": { - "definition": "CREATE OR REPLACE FUNCTION public.edge_api_editorial_invite_prepare(p_user_id uuid, p_session_id uuid, p_aal text, p_email text)\n RETURNS jsonb\n LANGUAGE plpgsql\n STABLE SECURITY DEFINER\n SET search_path TO ''\nAS $function$\ndeclare\n v_actor jsonb;\n v_count bigint;\n v_identity uuid;\nbegin\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'manage_reviewers');\n if v_actor ? 'error' then return v_actor; end if;\n if p_email is null or length(p_email)>254 or p_email<>lower(btrim(p_email))\n or p_email !~ '^[^[:space:]@]+@[^[:space:]@]+\\.[^[:space:]@]+$' then\n return jsonb_build_object('error','invalid');\n end if;\n select count(*),min(id::text)::uuid into v_count,v_identity from auth.users\n where lower(email)=p_email;\n if v_count>1 then return jsonb_build_object('error','duplicate_identity'); end if;\n if v_identity is not null and exists(select 1 from public.editorial_memberships\n where user_id=v_identity) then\n return jsonb_build_object('error','reviewer_exists');\n end if;\n return jsonb_build_object('user_id',v_identity,\n 'needs_invite',v_identity is null);\nend\n$function$\n" + "definition": "CREATE OR REPLACE FUNCTION public.edge_api_editorial_invite_prepare(p_user_id uuid, p_session_id uuid, p_aal text, p_email text)\n RETURNS jsonb\n LANGUAGE plpgsql\n STABLE SECURITY DEFINER\n SET search_path TO ''\nAS $function$\ndeclare\n v_actor jsonb;\n v_count bigint;\n v_identity uuid;\n v_confirmed_at timestamptz;\nbegin\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'manage_reviewers');\n if v_actor ? 'error' then return v_actor; end if;\n if p_email is null or length(p_email)>254 or p_email<>lower(btrim(p_email))\n or p_email !~ '^[^[:space:]@]+@[^[:space:]@]+\\.[^[:space:]@]+$' then\n return jsonb_build_object('error','invalid');\n end if;\n select count(*),min(id::text)::uuid,min(email_confirmed_at)\n into v_count,v_identity,v_confirmed_at from auth.users\n where lower(email)=p_email;\n if v_count>1 then return jsonb_build_object('error','duplicate_identity'); end if;\n if v_identity is not null and exists(select 1 from public.editorial_memberships\n where user_id=v_identity) then\n return jsonb_build_object('error','reviewer_exists');\n end if;\n if (select count(*) from public.editorial_memberships where status='active')>=10 then\n return jsonb_build_object('error','team_full');\n end if;\n return jsonb_build_object('user_id',v_identity,\n 'needs_invite',v_confirmed_at is null);\nend\n$function$\n" }, "edge_api_editorial_job_public": { "definition": "CREATE OR REPLACE FUNCTION public.edge_api_editorial_job_public(p_job public.editorial_generation_jobs)\n RETURNS jsonb\n LANGUAGE plpgsql\n STABLE\n SET search_path TO ''\nAS $function$\ndeclare\n v_stamp text;\n v_payload text;\nbegin\n v_stamp := public.edge_api_editorial_utc_iso(p_job.updated_at);\n -- Python json.dumps(sort_keys=True, separators=(',', ':')) over the four\n -- version fields, with FastAPI's UTC datetime encoding.\n v_payload := format('{\"attempts\":%s,\"id\":%s,\"status\":%s,\"updated_at\":%s}',\n p_job.attempts, to_jsonb(p_job.id)::text, to_jsonb(p_job.status)::text,\n to_jsonb(v_stamp)::text);\n return jsonb_build_object(\n 'id',p_job.id,'source_revision_id',p_job.source_revision_id,\n 'concept_id',p_job.concept_id,'topic_id',p_job.topic_id,\n 'feedback_event_id',p_job.feedback_event_id,\n 'request_event_id',p_job.request_event_id,'status',p_job.status,\n 'attempts',p_job.attempts,\n 'available_at',public.edge_api_editorial_utc_iso(p_job.available_at),\n 'result_revision_id',p_job.result_revision_id,'failure_code',p_job.failure_code,\n 'created_at',public.edge_api_editorial_utc_iso(p_job.created_at),\n 'updated_at',v_stamp,\n 'token',encode(pg_catalog.sha256(convert_to(v_payload,'UTF8')),'hex')\n );\nend\n$function$\n" @@ -3333,7 +3337,10 @@ "definition": "CREATE OR REPLACE FUNCTION public.edge_api_editorial_request_generation(p_user_id uuid, p_session_id uuid, p_aal text, p_topic_id uuid, p_request_id uuid, p_note text, p_count integer, p_enabled boolean, p_review_limit integer, p_batch_limit integer, p_active_days integer, p_fingerprint text)\n RETURNS jsonb\n LANGUAGE plpgsql\n SET search_path TO ''\nAS $function$\ndeclare\n v_actor jsonb;\n v_receipt public.editorial_request_receipts%rowtype;\n v_planned integer;\n v_inventory integer;\n v_load integer;\n v_slots integer;\n v_count integer;\n v_target integer;\n v_event uuid;\n v_result jsonb;\n v_note text;\nbegin\n if p_topic_id is null or p_request_id is null or p_note is null\n or length(p_note)<10 or length(p_note)>4000\n or p_count is null or p_count<1 or p_count>10\n or p_review_limit is null or p_review_limit<1 or p_review_limit>250\n or p_batch_limit is null or p_batch_limit<1 or p_batch_limit>25\n or p_active_days is null or p_active_days<1 or p_active_days>365\n or p_fingerprint is null or p_fingerprint !~ '^[a-f0-9]{64}$' then\n return jsonb_build_object('error','invalid');\n end if;\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'request_generation');\n if v_actor ? 'error' then return v_actor; end if;\n perform pg_catalog.pg_advisory_xact_lock(274,263);\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'request_generation');\n if v_actor ? 'error' then return v_actor; end if;\n perform pg_catalog.pg_advisory_xact_lock(195,1);\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'request_generation');\n if v_actor ? 'error' then return v_actor; end if;\n select * into v_receipt from public.editorial_request_receipts\n where actor_id=p_user_id and request_id=p_request_id;\n if found then\n if v_receipt.fingerprint<>p_fingerprint then\n return jsonb_build_object('error','receipt_conflict');\n end if;\n return v_receipt.result;\n end if;\n if not coalesce(p_enabled,false) then\n return jsonb_build_object('error','generation_disabled');\n end if;\n if not exists(select 1 from public.topics\n where id=p_topic_id and is_active) then\n return jsonb_build_object('error','review_conflict','message',\n 'Choose an active topic');\n end if;\n select count(*)::integer into v_planned\n from public.concept_backlog b join public.subtopics s on s.id=b.subtopic_id\n where b.topic_id=p_topic_id and b.status='pending' and s.is_active\n and b.attempts<3+(select count(*) from public.content_retry_log l\n where l.backlog_id=b.id);\n if v_planned=0 then\n return jsonb_build_object('error','review_conflict','message',\n 'No pending curriculum is available; plan lessons before requesting generation');\n end if;\n select count(*)::integer into v_inventory\n from public.concepts c join public.subtopics s\n on s.id=c.subtopic_id and s.is_active\n where c.topic_id=p_topic_id and c.status in ('published','draft');\n select (\n select count(*) from public.concepts c\n join public.subtopics s on s.id=c.subtopic_id\n where c.topic_id=p_topic_id and s.is_active and\n (c.status='draft' or (c.status='published' and exists(\n select 1 from public.concept_revisions r\n where r.concept_id=c.id and r.base_version=c.content_version\n and r.status in ('draft','generating','validation_failed',\n 'pending_review','changes_requested','approved'))))\n ) + (\n select count(*) from public.concept_backlog b\n join public.subtopics s on s.id=b.subtopic_id\n where b.topic_id=p_topic_id and s.is_active and b.status='generating'\n ) into v_load;\n v_slots := greatest(0,p_review_limit-v_load);\n if v_slots=0 then\n return jsonb_build_object('error','review_conflict','message',\n 'Review backlog is full; review or archive existing drafts first');\n end if;\n v_note := regexp_replace(p_note,'^[[:space:]]+|[[:space:]]+$','','g');\n if length(v_note)<10 then\n return jsonb_build_object('error','review_conflict','message',\n 'Record a substantive note of at least 10 characters');\n end if;\n v_count := least(p_count,v_planned,p_batch_limit,v_slots);\n insert into public.content_supply_targets(topic_id,target_count,expires_at)\n values(p_topic_id,v_inventory+v_count,\n now()+make_interval(days=>p_active_days))\n on conflict(topic_id) do update set\n target_count=greatest(excluded.target_count,\n case when public.content_supply_targets.expires_at>now()\n then public.content_supply_targets.target_count else 0 end),\n requested_at=now(),expires_at=excluded.expires_at\n returning target_count into v_target;\n insert into public.editorial_workflow_events\n (topic_id,actor_id,registered_name,action,note,details)\n values(p_topic_id,p_user_id,v_actor->>'approved_name',\n 'generation_requested',v_note,jsonb_build_object(\n 'requested_count',p_count,'bounded_count',v_count,'target',v_target))\n returning id into v_event;\n v_result := jsonb_build_object('event_id',v_event,\n 'status','demand_recorded','target',v_target);\n insert into public.editorial_request_receipts\n (actor_id,request_id,fingerprint,result)\n values(p_user_id,p_request_id,p_fingerprint,v_result);\n return v_result;\nend\n$function$\n" }, "edge_api_editorial_reviewer_access": { - "definition": "CREATE OR REPLACE FUNCTION public.edge_api_editorial_reviewer_access(p_user_id uuid, p_session_id uuid, p_aal text, p_subject_id uuid, p_expected_version integer, p_status text, p_capabilities text[])\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\ndeclare\n v_actor jsonb;\n v_subject public.editorial_memberships%rowtype;\n v_caps text[];\n v_another boolean;\nbegin\n if p_subject_id is null or p_expected_version is null or p_expected_version<1\n or p_status is null or p_status not in ('active','revoked')\n or p_capabilities is null or cardinality(p_capabilities)>5\n or array_position(p_capabilities,null) is not null\n or not (p_capabilities <@ array['review','approve','publish',\n 'request_generation','manage_reviewers']::text[]) then\n return jsonb_build_object('error','invalid');\n end if;\n select coalesce(array_agg(cap order by cap),array[]::text[]) into v_caps\n from (select distinct unnest(p_capabilities) as cap) dedup;\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'manage_reviewers');\n if v_actor ? 'error' then return v_actor; end if;\n perform pg_catalog.pg_advisory_xact_lock(274,263);\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'manage_reviewers');\n if v_actor ? 'error' then return v_actor; end if;\n select * into v_subject from public.editorial_memberships\n where user_id=p_subject_id for update;\n if not found then return jsonb_build_object('error','unavailable'); end if;\n if v_subject.version<>p_expected_version then\n return jsonb_build_object('error','version_conflict');\n end if;\n if 'manage_reviewers'=any(v_subject.capabilities)\n and v_subject.status='active'\n and (p_status='revoked' or not 'manage_reviewers'=any(v_caps)) then\n select exists(select 1 from public.editorial_memberships m\n join auth.users u on u.id=m.user_id\n where m.user_id<>p_subject_id and m.status='active'\n and m.approved_name is not null\n and 'manage_reviewers'=any(m.capabilities)\n and u.email_confirmed_at is not null\n and (u.banned_until is null or u.banned_until<=now())) into v_another;\n if not v_another then return jsonb_build_object('error','last_admin'); end if;\n end if;\n if v_subject.status=p_status and (\n select coalesce(array_agg(distinct cap order by cap),array[]::text[])\n from unnest(v_subject.capabilities) cap\n ) = v_caps then\n return to_jsonb(v_subject);\n end if;\n update public.editorial_memberships set status=p_status,\n capabilities=v_caps,version=version+1,updated_at=now()\n where user_id=p_subject_id;\n insert into public.editorial_account_events(actor_id,subject_id,action,details)\n values(p_user_id,p_subject_id,'access_changed',jsonb_build_object(\n 'previous_status',v_subject.status,'status',p_status,\n 'previous_capabilities',v_subject.capabilities,\n 'capabilities',v_caps,'version',v_subject.version+1));\n return (select to_jsonb(m) from public.editorial_memberships m\n where m.user_id=p_subject_id);\nend\n$function$\n" + "definition": "CREATE OR REPLACE FUNCTION public.edge_api_editorial_reviewer_access(p_user_id uuid, p_session_id uuid, p_aal text, p_subject_id uuid, p_expected_version integer, p_status text, p_capabilities text[])\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\ndeclare\n v_actor jsonb;\n v_subject public.editorial_memberships%rowtype;\n v_caps text[];\n v_another boolean;\nbegin\n if p_subject_id is null or p_expected_version is null or p_expected_version<1\n or p_status is null or p_status not in ('active','revoked')\n or p_capabilities is null or cardinality(p_capabilities)>5\n or array_position(p_capabilities,null) is not null\n or not (p_capabilities <@ array['review','approve','publish',\n 'request_generation','manage_reviewers']::text[]) then\n return jsonb_build_object('error','invalid');\n end if;\n select coalesce(array_agg(cap order by cap),array[]::text[]) into v_caps\n from (select distinct unnest(p_capabilities) as cap) dedup;\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'manage_reviewers');\n if v_actor ? 'error' then return v_actor; end if;\n perform pg_catalog.pg_advisory_xact_lock(274,263);\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'manage_reviewers');\n if v_actor ? 'error' then return v_actor; end if;\n select * into v_subject from public.editorial_memberships\n where user_id=p_subject_id for update;\n if not found then return jsonb_build_object('error','unavailable'); end if;\n if v_subject.version<>p_expected_version then\n return jsonb_build_object('error','version_conflict');\n end if;\n if 'manage_reviewers'=any(v_subject.capabilities)\n and v_subject.status='active'\n and (p_status='revoked' or not 'manage_reviewers'=any(v_caps)) then\n select exists(select 1 from public.editorial_memberships m\n join auth.users u on u.id=m.user_id\n where m.user_id<>p_subject_id and m.status='active'\n and m.approved_name is not null\n and 'manage_reviewers'=any(m.capabilities)\n and u.email_confirmed_at is not null\n and (u.banned_until is null or u.banned_until<=now())) into v_another;\n if not v_another then return jsonb_build_object('error','last_admin'); end if;\n end if;\n if v_subject.status=p_status and (\n select coalesce(array_agg(distinct cap order by cap),array[]::text[])\n from unnest(v_subject.capabilities) cap\n ) = v_caps then\n return to_jsonb(v_subject);\n end if;\n if v_subject.status<>'active' and p_status='active'\n and (select count(*) from public.editorial_memberships where status='active')>=10 then\n return jsonb_build_object('error','team_full');\n end if;\n update public.editorial_memberships set status=p_status,\n capabilities=v_caps,version=version+1,updated_at=now()\n where user_id=p_subject_id;\n insert into public.editorial_account_events(actor_id,subject_id,action,details)\n values(p_user_id,p_subject_id,'access_changed',jsonb_build_object(\n 'previous_status',v_subject.status,'status',p_status,\n 'previous_capabilities',v_subject.capabilities,\n 'capabilities',v_caps,'version',v_subject.version+1));\n return (select to_jsonb(m) from public.editorial_memberships m\n where m.user_id=p_subject_id);\nend\n$function$\n" + }, + "edge_api_editorial_reviewer_remove": { + "definition": "CREATE OR REPLACE FUNCTION public.edge_api_editorial_reviewer_remove(p_user_id uuid, p_session_id uuid, p_aal text, p_subject_id uuid, p_expected_version integer)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\ndeclare\n v_actor jsonb;\n v_subject public.editorial_memberships%rowtype;\n v_another boolean;\n v_unassigned integer;\nbegin\n if p_subject_id is null or p_expected_version is null or p_expected_version<1 then\n return jsonb_build_object('error','invalid');\n end if;\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'manage_reviewers');\n if v_actor ? 'error' then return v_actor; end if;\n if p_subject_id=p_user_id then return jsonb_build_object('error','self_removal'); end if;\n perform pg_catalog.pg_advisory_xact_lock(274,263);\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'manage_reviewers');\n if v_actor ? 'error' then return v_actor; end if;\n perform pg_catalog.pg_advisory_xact_lock(195,1);\n perform pg_catalog.pg_advisory_xact_lock(279,263);\n select * into v_subject from public.editorial_memberships\n where user_id=p_subject_id for update;\n if not found then return jsonb_build_object('error','reviewer_not_found'); end if;\n if v_subject.version<>p_expected_version then\n return jsonb_build_object('error','version_conflict');\n end if;\n if v_subject.status='active' and 'manage_reviewers'=any(v_subject.capabilities) then\n select exists(select 1 from public.editorial_memberships m\n join auth.users u on u.id=m.user_id\n where m.user_id<>p_subject_id and m.status='active'\n and m.approved_name is not null\n and 'manage_reviewers'=any(m.capabilities)\n and u.email_confirmed_at is not null\n and (u.banned_until is null or u.banned_until<=pg_catalog.statement_timestamp()))\n into v_another;\n if not v_another then return jsonb_build_object('error','last_admin'); end if;\n end if;\n update public.concept_revisions set assigned_to=null,review_due_at=null,\n review_assigned_at=null\n where assigned_to=p_subject_id and status not in ('published','retired');\n get diagnostics v_unassigned = row_count;\n update public.editorial_notification_outbox set suppressed=true\n where recipient_id=p_subject_id and batch_id is null and not suppressed;\n delete from public.editorial_memberships where user_id=p_subject_id;\n insert into public.editorial_account_events(actor_id,subject_id,action,details)\n values(p_user_id,p_subject_id,'reviewer_removed',jsonb_build_object(\n 'previous_status',v_subject.status,\n 'previous_capabilities',v_subject.capabilities,\n 'previous_version',v_subject.version,\n 'unassigned_count',v_unassigned));\n return jsonb_build_object('removed_user_id',p_subject_id,\n 'unassigned_count',v_unassigned);\nend\n$function$\n" }, "edge_api_editorial_revision_action": { "definition": "CREATE OR REPLACE FUNCTION public.edge_api_editorial_revision_action(p_user_id uuid, p_session_id uuid, p_aal text, p_revision_id uuid, p_request_id uuid, p_note text, p_expected_token text, p_action text, p_quality jsonb, p_assignee_id uuid, p_due_at timestamp with time zone, p_has_due boolean, p_body_valid boolean, p_auto_correction boolean, p_generation_enabled boolean, p_queue_limit integer, p_fingerprint text)\n RETURNS jsonb\n LANGUAGE plpgsql\n SET search_path TO ''\nAS $function$\ndeclare\n v_actor jsonb;\n v_capability text;\n v_receipt public.editorial_request_receipts%rowtype;\n v_revision public.concept_revisions%rowtype;\n v_concept public.concepts%rowtype;\n v_assignee public.editorial_memberships%rowtype;\n v_approval public.editorial_revision_events%rowtype;\n v_topic_active boolean;\n v_subtopic_active boolean;\n v_subtopic_slug text;\n v_snapshot jsonb;\n v_status text;\n v_note text;\n v_problem text;\n v_event uuid;\n v_request_event uuid;\n v_version integer;\n v_generation_status text;\n v_source_token text;\n v_result jsonb;\nbegin\n if p_revision_id is null or p_request_id is null or p_note is null\n or length(p_note)<10 or length(p_note)>4000\n or p_expected_token is null or p_expected_token !~ '^[a-f0-9]{64}$'\n or p_action is null or p_action not in\n ('submit','comment','assign','changes_requested','rejected',\n 'approved','publish','approve_and_publish','retired')\n or ((p_action in ('approved','approve_and_publish')) is distinct from\n (p_quality is not null))\n or (p_quality is not null and not coalesce(\n public.edge_api_editorial_quality_valid(p_quality),false))\n or (p_action<>'assign' and (p_assignee_id is not null or coalesce(p_has_due,false)))\n or p_queue_limit is null or p_queue_limit<1 or p_queue_limit>250\n or p_fingerprint is null or p_fingerprint !~ '^[a-f0-9]{64}$' then\n return jsonb_build_object('error','invalid');\n end if;\n v_capability := case\n when p_action in ('approved','approve_and_publish','retired') then 'approve'\n when p_action='publish' then 'publish'\n when p_action='assign' then 'manage_reviewers'\n else 'review' end;\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,v_capability);\n if v_actor ? 'error' then return v_actor; end if;\n if p_action='approve_and_publish' then\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'publish');\n if v_actor ? 'error' then return v_actor; end if;\n end if;\n perform pg_catalog.pg_advisory_xact_lock(274,263);\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,v_capability);\n if v_actor ? 'error' then return v_actor; end if;\n perform pg_catalog.pg_advisory_xact_lock(195,1);\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,v_capability);\n if v_actor ? 'error' then return v_actor; end if;\n if p_action='approve_and_publish' then\n v_actor := public.edge_api_editorial_authorize(\n p_user_id,p_session_id,p_aal,'publish');\n if v_actor ? 'error' then return v_actor; end if;\n end if;\n select * into v_receipt from public.editorial_request_receipts\n where actor_id=p_user_id and request_id=p_request_id;\n if found then\n if v_receipt.fingerprint<>p_fingerprint then\n return jsonb_build_object('error','receipt_conflict');\n end if;\n return v_receipt.result;\n end if;\n select r.* into v_revision from public.concept_revisions r\n where r.id=p_revision_id for update;\n if not found then return jsonb_build_object('error','revision_not_found'); end if;\n select c.* into v_concept from public.concepts c\n join public.topics t on t.id=c.topic_id\n join public.subtopics s on s.id=c.subtopic_id\n where c.id=v_revision.concept_id for update of c,t,s;\n select t.is_active,s.is_active,s.slug into\n v_topic_active,v_subtopic_active,v_subtopic_slug\n from public.topics t join public.subtopics s on s.topic_id=t.id\n where s.id=v_concept.subtopic_id;\n v_snapshot := to_jsonb(v_concept)-array['created_at','published_at','status'];\n if public.edge_api_editorial_revision_token(p_revision_id)<>p_expected_token then\n return jsonb_build_object('error','stale_revision');\n end if;\n if v_revision.status='generating' then\n return jsonb_build_object('error','review_conflict',\n 'message','Generation is still running; reload when the draft is ready');\n end if;\n v_note := regexp_replace(p_note,'^[[:space:]]+|[[:space:]]+$','','g');\n if length(v_note)<10 then\n return jsonb_build_object('error','review_conflict',\n 'message','Record a substantive review note (at least 10 characters)');\n end if;\n if p_action in ('submit','assign','approved','changes_requested',\n 'rejected','publish','approve_and_publish') and\n not (p_action='publish' and v_revision.status='published') and\n (v_revision.base_version<>v_concept.content_version\n or v_concept.status='archived' or not v_topic_active\n or not v_subtopic_active) then\n return jsonb_build_object('error','review_conflict',\n 'message','Revision is stale or content is retired');\n end if;\n\n if p_action='submit' then\n if v_revision.status<>'draft' then\n return jsonb_build_object('error','review_conflict',\n 'message','Only a new draft can be submitted; corrections need a new revision');\n end if;\n v_status := case when coalesce(p_body_valid,false)\n and v_revision.body->>'subtopic_slug'=v_subtopic_slug\n then 'pending_review' else 'validation_failed' end;\n insert into public.editorial_revision_events\n (revision_id,concept_id,base_version,actor_id,registered_name,action,\n revision_body,source_snapshot,note)\n values(p_revision_id,v_revision.concept_id,v_revision.base_version,p_user_id,\n v_actor->>'approved_name',v_status,v_revision.body,v_snapshot,v_note);\n update public.concept_revisions set status=v_status where id=p_revision_id;\n elsif p_action in ('approved','changes_requested','rejected','retired',\n 'approve_and_publish') then\n if (p_action='retired' and v_revision.status not in\n ('draft','validation_failed','pending_review','changes_requested',\n 'approved','rejected')) or\n (p_action<>'retired' and v_revision.status<>'pending_review') then\n return jsonb_build_object('error','review_conflict',\n 'message','Invalid revision transition; reload before reviewing');\n end if;\n if p_action in ('approved','approve_and_publish') and\n (not coalesce(p_body_valid,false) or\n v_revision.body->>'subtopic_slug' is distinct from v_subtopic_slug) then\n return jsonb_build_object('error','review_conflict',\n 'message','Approval requires matching taxonomy and the complete quality checklist');\n end if;\n if p_action='approve_and_publish' then\n v_problem := public.edge_api_editorial_candidate_problem(\n v_concept.id,v_concept.slug,v_revision.body);\n if v_problem is not null then\n return jsonb_build_object('error','review_conflict','message',v_problem);\n end if;\n end if;\n v_status := case when p_action='approve_and_publish' then 'approved'\n else p_action end;\n insert into public.editorial_revision_events\n (revision_id,concept_id,base_version,actor_id,registered_name,action,\n revision_body,source_snapshot,note,checklist_version,quality_review)\n values(p_revision_id,v_revision.concept_id,v_revision.base_version,p_user_id,\n v_actor->>'approved_name',v_status,v_revision.body,v_snapshot,v_note,\n case when v_status='approved' then 1 else null end,\n case when v_status='approved' then p_quality else null end)\n returning id into v_event;\n update public.concept_revisions set status=v_status where id=p_revision_id;\n if p_action='changes_requested' and coalesce(p_auto_correction,false) then\n if not coalesce(p_generation_enabled,false) then\n v_generation_status := 'disabled';\n elsif exists(select 1 from public.concept_revisions r\n where r.concept_id=v_revision.concept_id and\n r.created_at>v_revision.created_at)\n or exists(select 1 from public.editorial_generation_jobs j\n where j.source_revision_id=p_revision_id or\n (j.concept_id=v_revision.concept_id and\n j.status in ('pending','generating')))\n or (select count(*) from public.editorial_generation_jobs j\n where j.topic_id=v_concept.topic_id and\n j.status in ('pending','generating'))>=p_queue_limit\n or not coalesce(p_body_valid,false) then\n v_generation_status := 'unavailable';\n else\n insert into public.editorial_workflow_events\n (revision_id,concept_id,topic_id,actor_id,registered_name,\n action,note,details)\n values(p_revision_id,v_revision.concept_id,v_concept.topic_id,p_user_id,\n v_actor->>'approved_name','generation_requested',v_note,\n jsonb_build_object('kind','automatic_review',\n 'feedback_event_id',v_event)) returning id into v_request_event;\n v_source_token := encode(pg_catalog.sha256(convert_to(\n public.edge_api_editorial_python_json(jsonb_build_object(\n 'revision',public.edge_api_editorial_revision_token(p_revision_id),\n 'siblings',(select coalesce(jsonb_agg(r.id order by r.id),'[]'::jsonb)\n from public.concept_revisions r\n where r.concept_id=v_revision.concept_id))),\n 'UTF8')),'hex');\n insert into public.editorial_generation_jobs\n (source_revision_id,concept_id,topic_id,feedback_event_id,\n request_event_id,requested_by,source_token,source_body,feedback)\n values(p_revision_id,v_revision.concept_id,v_concept.topic_id,v_event,\n v_request_event,p_user_id,v_source_token,v_revision.body,v_note);\n v_generation_status := 'queued';\n end if;\n end if;\n elsif p_action='assign' then\n if v_revision.status in ('published','retired') then\n return jsonb_build_object('error','review_conflict',\n 'message','Only open review work can be assigned');\n end if;\n if p_assignee_id is not null then\n select * into v_assignee from public.editorial_memberships\n where user_id=p_assignee_id;\n if not found or v_assignee.status<>'active' or\n v_assignee.approved_name is null or\n not (v_assignee.capabilities @> array['review']::text[]) then\n return jsonb_build_object('error','review_conflict',\n 'message','Choose an active, approved reviewer');\n end if;\n end if;\n update public.concept_revisions set assigned_to=p_assignee_id,\n review_due_at=case when coalesce(p_has_due,false) then p_due_at\n else v_revision.review_due_at end where id=p_revision_id;\n insert into public.editorial_workflow_events\n (revision_id,concept_id,actor_id,registered_name,action,note,details)\n values(p_revision_id,v_revision.concept_id,p_user_id,\n v_actor->>'approved_name','assigned',v_note,jsonb_build_object(\n 'previous_assignee',v_revision.assigned_to,\n 'assignee',p_assignee_id,\n 'previous_due_at',public.edge_api_editorial_utc_iso(v_revision.review_due_at),\n 'review_due_at',public.edge_api_editorial_utc_iso(\n case when coalesce(p_has_due,false) then p_due_at\n else v_revision.review_due_at end)));\n elsif p_action='comment' then\n insert into public.editorial_workflow_events\n (revision_id,concept_id,actor_id,registered_name,action,note,details)\n values(p_revision_id,v_revision.concept_id,p_user_id,\n v_actor->>'approved_name','comment',v_note,'{}'::jsonb);\n end if;\n\n if p_action in ('publish','approve_and_publish') then\n select * into v_approval from public.editorial_revision_events\n where revision_id=p_revision_id and action='approved';\n if not found then\n return jsonb_build_object('error','review_conflict',\n 'message','Authenticated approval of this exact revision is required');\n end if;\n if v_revision.status='published' then\n select content_version into v_version from public.editorial_publications\n where approval_id=v_approval.id;\n if v_version is null then\n return jsonb_build_object('error','review_conflict',\n 'message','Published revision has no authenticated provenance');\n end if;\n else\n if p_action='publish' and v_revision.status<>'approved' then\n return jsonb_build_object('error','review_conflict',\n 'message','Only an approved, non-retired revision can be published');\n end if;\n if v_approval.revision_body<>v_revision.body or\n v_approval.base_version<>v_revision.base_version or\n v_approval.source_snapshot<>v_snapshot then\n return jsonb_build_object('error','review_conflict',\n 'message','Approved content or source version changed; prepare a new revision');\n end if;\n if not coalesce(p_body_valid,false) then\n return jsonb_build_object('error','review_conflict',\n 'message','Revision body is invalid');\n end if;\n v_problem := public.edge_api_editorial_candidate_problem(\n v_concept.id,v_concept.slug,v_revision.body);\n if v_problem is not null then\n return jsonb_build_object('error','review_conflict','message',v_problem);\n end if;\n insert into public.concept_revisions\n (concept_id,base_version,body,status,review_note)\n select c.id,c.content_version-1,jsonb_build_object(\n 'title',c.title,'summary',c.summary,'example',c.example,\n 'flashcard',c.flashcard,'mcqs',c.mcqs,'curriculum',c.curriculum,\n 'subtopic_slug',s.slug,'model',c.model,'prompt_version',c.prompt_version),\n 'published',\n 'Legacy version captured before correction; original review was not recorded.'\n from public.concepts c join public.subtopics s on s.id=c.subtopic_id\n where c.id=v_concept.id and c.content_version>0\n and not exists(select 1 from public.concept_revisions r\n where r.concept_id=c.id and r.status='published'\n and r.base_version=c.content_version-1);\n update public.concepts set\n title=v_revision.body->>'title',summary=v_revision.body->>'summary',\n example=v_revision.body->>'example',\n flashcard=v_revision.body->'learning_package'->'flashcard',\n mcqs=v_revision.body->'learning_package'->'mcqs',\n curriculum=v_revision.body->'curriculum',\n difficulty=(v_revision.body->'curriculum'->>'difficulty')::integer,\n model=v_revision.body->>'model',\n prompt_version=v_revision.body->>'prompt_version',\n content_version=content_version+1,status='published',published_at=now()\n where id=v_concept.id returning content_version into v_version;\n update public.concept_revisions set status='published',\n reviewed_by=v_approval.registered_name,\n review_note=v_approval.note,\n quality_review=v_approval.quality_review,\n reviewed_at=now() where id=p_revision_id;\n insert into public.editorial_publications\n (concept_id,content_version,approval_id,snapshot)\n select c.id,c.content_version,v_approval.id,\n to_jsonb(c)-array['created_at','published_at','status']\n from public.concepts c where c.id=v_concept.id;\n insert into public.editorial_revision_events\n (revision_id,concept_id,base_version,actor_id,registered_name,\n action,revision_body,source_snapshot,note)\n values(p_revision_id,v_revision.concept_id,v_revision.base_version,\n p_user_id,v_actor->>'approved_name','published',\n v_revision.body,v_snapshot,v_note);\n end if;\n end if;\n select r.status into v_status from public.concept_revisions r\n where r.id=p_revision_id;\n v_result := jsonb_build_object(\n 'revision_id',p_revision_id,'status',v_status,\n 'token',public.edge_api_editorial_revision_token(p_revision_id),\n 'published_version',v_version,\n 'generation_status',v_generation_status);\n insert into public.editorial_request_receipts\n (actor_id,request_id,fingerprint,result)\n values(p_user_id,p_request_id,p_fingerprint,v_result);\n return v_result;\nend\n$function$\n" diff --git a/backend/tests/test_edge_api_editorial_reviewer_invite.py b/backend/tests/test_edge_api_editorial_reviewer_invite.py index 0e7a353b..bba9fd7b 100644 --- a/backend/tests/test_edge_api_editorial_reviewer_invite.py +++ b/backend/tests/test_edge_api_editorial_reviewer_invite.py @@ -17,6 +17,25 @@ def _json(value): return json.loads(value) if isinstance(value, str) else value +@pytest.mark.asyncio +async def test_unconfirmed_existing_identity_needs_an_invitation(api, session): + reviewer, _, email = await auth_account(session, confirmed=False) + prepare = text("""select public.edge_api_editorial_invite_prepare( + :uid,:sid,:aal,:email)""") + args = {"uid": api.owner.id, "sid": api.owner.session_id, + "aal": "aal2", "email": email} + assert _json(await session.scalar(prepare, args)) == { + "user_id": str(reviewer), "needs_invite": True, + } + await session.execute( + text("update auth.users set email_confirmed_at=now() where id=:reviewer"), + {"reviewer": reviewer}, + ) + assert _json(await session.scalar(prepare, args)) == { + "user_id": str(reviewer), "needs_invite": False, + } + + @pytest.mark.asyncio async def test_invite_existing_identity_rechecks_authority_and_audits(api, session): reviewer, _, email = await auth_account(session) @@ -35,6 +54,7 @@ async def test_invite_existing_identity_rechecks_authority_and_audits(api, sessi assert member["user_id"] == str(reviewer) assert member["invited_email"] == email assert member["capabilities"] == ["approve", "review"] + assert member["require_mfa"] is False assert member["version"] == 1 await session.commit() saved = await api.client.get(f"{content.ROOT}/reviewers", headers=api.headers()) diff --git a/backend/tests/test_editorial_api.py b/backend/tests/test_editorial_api.py index 74d8fcb0..3c89235d 100644 --- a/backend/tests/test_editorial_api.py +++ b/backend/tests/test_editorial_api.py @@ -103,7 +103,9 @@ async def test_multi_reviewer_onboarding_and_editable_profile(api, session): uid, sid, member = await enroll(api, session) own = api.headers(uid, sid, "aal1") me = await api.client.get("/v1/editorial/me", headers=own) - assert me.json()["onboarding_required"] and me.json()["mfa_required"] + assert me.json()["onboarding_required"] + assert me.json()["mfa_required"] is False + assert member["require_mfa"] is False assert me.headers["cache-control"] == "no-store" member = await profile_and_approve(api, uid, sid) assert member["approved_name"] == "Reviewer Name" @@ -491,6 +493,8 @@ async def test_invitation_failure_has_no_membership_and_retry_is_safe( async def provider(address, settings): nonlocal calls calls += 1 + if calls == 2: + return uid await session.execute( text("insert into auth.users(id,email) values (:id,:email)"), {"id": uid, "email": email}, @@ -516,7 +520,10 @@ async def provider(address, settings): retried = await api.client.post( "/v1/editorial/reviewers", headers=api.headers(), json={"email": email} ) - assert retried.status_code == 201 and calls == 1 + assert retried.status_code == 201 and calls == 2 + # The existing unconfirmed Auth identity is invited again, not silently + # enrolled without a usable confirmation and password setup link. + assert retried.json()["user_id"] == str(uid) # Invitation is not equivalent to confirmation or approved onboarding. assert ( await api.client.get("/v1/editorial/me", headers=api.headers(uid, sid)) diff --git a/backend/tests/test_editorial_authenticator.py b/backend/tests/test_editorial_authenticator.py index 27bf7fc5..5ec6fb7f 100644 --- a/backend/tests/test_editorial_authenticator.py +++ b/backend/tests/test_editorial_authenticator.py @@ -54,10 +54,13 @@ async def test_reviewer_can_change_only_their_own_authenticator_choice(api, sess uid, sid, _ = await enroll(api, session) reviewer = await profile_and_approve(api, uid, sid) initial = (await api.client.get("/v1/editorial/me", headers=api.headers(uid, sid, "aal1"))).json() - assert initial["member"]["require_mfa"] is True + assert initial["member"]["require_mfa"] is False + enabled = await api.client.patch("/v1/editorial/me/authenticator", headers=api.headers(uid, sid, "aal1"), + json={"expected_version": reviewer["version"], "require_mfa": True}) + assert enabled.status_code == 200, enabled.text reviewer_fresh = api.headers(uid, sid, "aal2", amr=[{"method": "totp", "timestamp": int(time.time())}]) changed = await api.client.patch("/v1/editorial/me/authenticator", headers=reviewer_fresh, - json={"expected_version": reviewer["version"], "require_mfa": False}) + json={"expected_version": enabled.json()["version"], "require_mfa": False}) assert changed.status_code == 200, changed.text assert changed.json()["user_id"] == str(uid) assert (await api.client.get("/v1/editorial/queue", headers=api.headers(uid, sid, "aal1"))).status_code == 200 diff --git a/backend/tests/test_editorial_authority.py b/backend/tests/test_editorial_authority.py index 394953b8..9776d7b7 100644 --- a/backend/tests/test_editorial_authority.py +++ b/backend/tests/test_editorial_authority.py @@ -27,6 +27,11 @@ async def editorial(session, user): {"sid": sid, "uid": user}, ) await bootstrap_owner(session, f"{user}@example.invalid", "Initial Owner") + # Most authority regressions exercise an existing owner who opted into MFA. + await session.execute( + text("update public.editorial_memberships set require_mfa=true where user_id=:id"), + {"id": user}, + ) await session.commit() settings = Settings( _env_file=None, @@ -140,6 +145,18 @@ async def test_bootstrap_requires_confirmed_account(session, user): assert exc.value.status_code == 409 +async def test_first_owner_starts_with_optional_authenticator(session, user): + await session.execute( + text("truncate editorial_legacy_batch_entries, editorial_legacy_batches, editorial_memberships, editorial_account_events") + ) + await session.execute( + text("update auth.users set email_confirmed_at=now() where id=:id"), + {"id": user}, + ) + member = await bootstrap_owner(session, f"{user}@example.invalid", "Initial Owner") + assert member.require_mfa is False + + async def test_bootstrap_cannot_replace_deleted_team(session, editorial): user, _ = editorial await session.execute(text("delete from editorial_memberships")) diff --git a/backend/tests/test_editorial_email_template.py b/backend/tests/test_editorial_email_template.py index 04d37e03..9663ed91 100644 --- a/backend/tests/test_editorial_email_template.py +++ b/backend/tests/test_editorial_email_template.py @@ -16,7 +16,7 @@ def test_email_has_plain_fallback_and_safe_accessible_html(): "You have 1 assigned lesson(s) ready for review.\n\n" 'Topic: & Engineering | Due: 2026-10-04 18:00 PKT\n' + url - + "\n\nSign in with your invited account and authenticator.\n" + + "\n\nSign in with your account. If you enabled an authenticator requirement, verify it there.\n" ) msg = editorial_mail.message( SimpleNamespace( diff --git a/backend/tests/test_editorial_notifications.py b/backend/tests/test_editorial_notifications.py index 27400e8e..83116423 100644 --- a/backend/tests/test_editorial_notifications.py +++ b/backend/tests/test_editorial_notifications.py @@ -170,6 +170,7 @@ async def test_claim_crash_recovery_and_revocation_suppresses(api, session, draf await session.commit() batch = await notices.claim(session, settings) assert batch and batch["attempts"] == 1 + assert "If you enabled an authenticator requirement" in batch["body"] await session.execute( text( "update editorial_email_batches set lease_until=now()-interval '1 minute' where id=:id" diff --git a/backend/tests/test_editorial_team_controls.py b/backend/tests/test_editorial_team_controls.py new file mode 100644 index 00000000..93482f3b --- /dev/null +++ b/backend/tests/test_editorial_team_controls.py @@ -0,0 +1,137 @@ +"""Reviewer capacity and removal use real PostgreSQL authority and FK rules.""" + +import json +from uuid import uuid4 + +from sqlalchemy import text + +from tests import test_editorial_content_api as content +from tests.test_editorial_api import auth_account, enroll, profile_and_approve + + +api = content.api +editorial = content.editorial +draft = content.draft + + +async def test_ten_active_reviewers_including_owner_and_reactivation(api, session): + reviewers = [await enroll(api, session) for _ in range(9)] + assert all(member["require_mfa"] is False for _, _, member in reviewers) + candidate, _, email = await auth_account(session) + response = await api.client.post( + "/v1/editorial/reviewers", headers=api.headers(), + json={"email": email, "capabilities": ["review"]}, + ) + assert response.status_code == 409 + assert "10 active" in response.json()["detail"] + assert await session.scalar(text("""select count(*) from public.editorial_memberships + where status='active'""")) == 10 + assert not await session.scalar(text("""select exists(select 1 from public.editorial_memberships + where user_id=:id)"""), {"id": candidate}) + prepared = await session.scalar(text("""select public.edge_api_editorial_invite_prepare( + :owner,:session,'aal2',:email)"""), + {"owner": api.owner.id, "session": api.owner.session_id, "email": email}) + if isinstance(prepared, str): + prepared = json.loads(prepared) + assert prepared == {"error": "team_full"} + + removed, _, member = reviewers[0] + revoked = await api.client.patch( + f"/v1/editorial/reviewers/{removed}/access", headers=api.headers(), + json={"expected_version": member["version"], "status": "revoked", + "capabilities": ["review"]}, + ) + assert revoked.status_code == 200, revoked.text + added = await api.client.post( + "/v1/editorial/reviewers", headers=api.headers(), + json={"email": email, "capabilities": ["review"]}, + ) + assert added.status_code == 201, added.text + assert added.json()["require_mfa"] is False + reactivated = await api.client.patch( + f"/v1/editorial/reviewers/{removed}/access", headers=api.headers(), + json={"expected_version": revoked.json()["version"], "status": "active", + "capabilities": ["review"]}, + ) + assert reactivated.status_code == 409 + assert await session.scalar(text("""select status from public.editorial_memberships + where user_id=:id"""), {"id": removed}) == "revoked" + edge_reactivation = await session.scalar(text("""select public.edge_api_editorial_reviewer_access( + :owner,:session,'aal2',:subject,:version,'active',array['review']::text[])"""), + {"owner": api.owner.id, "session": api.owner.session_id, + "subject": removed, "version": revoked.json()["version"]}) + if isinstance(edge_reactivation, str): + edge_reactivation = json.loads(edge_reactivation) + assert edge_reactivation == {"error": "team_full"} + + +async def test_removal_keeps_learning_account_and_review_evidence(api, session, draft): + reviewer, sid, member = await enroll(api, session) + member = await profile_and_approve(api, reviewer, sid) + rid = await content.rid_for(session, draft[1]) + assigned, _ = await content.act(api, rid, "assign", assignee_id=str(reviewer)) + assert assigned.status_code == 200, assigned.text + topic = await session.scalar(text("select id from public.topics order by id limit 1")) + batch_id = uuid4() + await session.execute(text("""insert into public.editorial_legacy_batches + (id,topic_id,name,requested_by,model,prompt_version,quota_limit) + values(:id,:topic,:name,:reviewer,'test','v1',1)"""), + {"id": batch_id, "topic": topic, "name": f"batch-{batch_id.hex[:12]}", + "reviewer": reviewer}) + await session.commit() + + stale = await api.client.delete( + f"/v1/editorial/reviewers/{reviewer}?expected_version={member['version']-1}", + headers=api.headers(), + ) + assert stale.status_code == 409 + removed = await api.client.delete( + f"/v1/editorial/reviewers/{reviewer}?expected_version={member['version']}", + headers=api.headers(), + ) + assert removed.status_code == 200, removed.text + assert removed.json() == {"removed_user_id": str(reviewer), "unassigned_count": 1} + assert (await api.client.get("/v1/editorial/me", headers=api.headers(reviewer, sid))).status_code == 403 + assert await session.scalar(text("select exists(select 1 from auth.users where id=:id)"), + {"id": reviewer}) + assert await session.scalar(text("select exists(select 1 from public.profiles where id=:id)"), + {"id": reviewer}) + assert await session.scalar(text("""select exists(select 1 from public.editorial_legacy_batches + where id=:id and requested_by=:reviewer)"""), + {"id": batch_id, "reviewer": reviewer}) + assignment = (await session.execute(text("""select assigned_to,review_due_at + from public.concept_revisions where id=:id"""), {"id": rid})).one() + assert assignment == (None, None) + assert await session.scalar(text("""select count(*) from public.editorial_account_events + where subject_id=:id and action='reviewer_removed'"""), {"id": reviewer}) == 1 + assert (await api.client.get("/v1/editorial/reviewers", headers=api.headers())).json()["items"] == [ + (await api.client.get("/v1/editorial/me", headers=api.headers())).json()["member"] + ] + assert (await api.client.delete( + f"/v1/editorial/reviewers/{reviewer}?expected_version={member['version']}", + headers=api.headers(), + )).status_code == 404 + assert (await api.client.delete( + f"/v1/editorial/reviewers/{api.owner.id}?expected_version=1", + headers=api.headers(), + )).status_code == 403 + + +async def test_edge_removal_runs_as_service_role(api, session): + reviewer, _, member = await enroll(api, session) + args = {"owner": api.owner.id, "session": api.owner.session_id, + "reviewer": reviewer, "version": member["version"]} + await session.execute(text("set local role service_role")) + removed = await session.scalar(text("""select public.edge_api_editorial_reviewer_remove( + :owner,:session,'aal2',:reviewer,:version)"""), args) + await session.execute(text("reset role")) + if isinstance(removed, str): + removed = json.loads(removed) + assert removed == {"removed_user_id": str(reviewer), "unassigned_count": 0} + assert await session.scalar(text("select exists(select 1 from auth.users where id=:reviewer)"), args) + assert await session.scalar(text("""select prosecdef from pg_proc + where oid=to_regprocedure('public.edge_api_editorial_reviewer_remove(uuid,uuid,text,uuid,integer)')""")) + assert await session.scalar(text("""select has_function_privilege('service_role', + 'public.edge_api_editorial_reviewer_remove(uuid,uuid,text,uuid,integer)', 'EXECUTE')""")) + assert not await session.scalar(text("""select has_function_privilege('authenticated', + 'public.edge_api_editorial_reviewer_remove(uuid,uuid,text,uuid,integer)', 'EXECUTE')""")) diff --git a/backend/tests/test_schema_contract.py b/backend/tests/test_schema_contract.py index 7bf1ab4c..18a6677b 100644 --- a/backend/tests/test_schema_contract.py +++ b/backend/tests/test_schema_contract.py @@ -53,6 +53,7 @@ async def test_migrations_produce_reviewed_schema_contract(database): ("create policy leaked_drafts on public.concept_revisions for select using (true)", "Unexpected policy:"), ("alter table auth.users disable trigger on_auth_user_created", "triggers: auth.users.on_auth_user_created"), ("alter function public.edge_api_health() rename to edge_api_health_tampered", "functions: edge_api_health"), + ("alter function public.edge_api_editorial_reviewer_remove(uuid,uuid,text,uuid,integer) rename to edge_api_editorial_reviewer_remove_tampered", "functions: edge_api_editorial_reviewer_remove"), ]) async def test_false_ledger_cannot_hide_missing_or_unsafe_schema(database, damage, expected): # applied.txt remains unchanged and fully populated in every case. diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md index e4f239fd..cd28a06e 100644 --- a/docs/CODEBASE_MAP.md +++ b/docs/CODEBASE_MAP.md @@ -22,7 +22,7 @@ learned history, streaks, likes, saved concepts, and push reminders. | Supabase scheduled workers | `supabase/functions/worker/` validates signed Cron calls; migrations 0119–0120 own the disabled run ledger and HTTP reconciliation; migrations 0128–0129 feed owner reporting and bound run history; migration 0130 fences uncertain Gmail delivery; `backend/ops/supabase_workers/` installs inactive platform Cron jobs. See `docs/SUPABASE_WORKER_MIGRATION.md` for staged handover. | | Documentation | Root `README.md`, `RELEASING.md`, `CONTRIBUTING.md`, `docs/ARCHITECTURE.md`, `docs/ROADMAP.md`, and the backend/mobile guides. | | Agent guidance | Root `AGENTS.md`; `mobile/AGENTS.md` adds Expo documentation requirements and `mobile/CLAUDE.md` references it. | -| Authentication email | `backend/email-templates/` contains branded signup, recovery, and password-changed HTML; `docs/EMAIL_TEMPLATES.md` covers manual Supabase installation and activation checks. Templates use the configured sender and are not installed by app deployment. | +| Authentication email | `backend/email-templates/` contains branded signup, reviewer invitation, recovery, and password-changed HTML; `docs/EMAIL_TEMPLATES.md` covers manual Supabase installation and activation checks. Templates use the configured sender and are not installed by app deployment. | | Engineering handbook | `docs/handbook/ONE_CONCEPT_HANDBOOK.md` explains the full stack and learning lifecycle; `docs/handbook/build_pdf.py` renders the printable guide with vector diagrams. Build and verification instructions are in `docs/handbook/README.md`. | ## Editorial identities (#274) @@ -33,7 +33,12 @@ email, membership, capabilities and MFA before account-lock acquisition and again afterward, using current-query time for session deadlines; `editorial_management.py` serializes versioned account mutations and records their audit events. `editorial_invites.py` -is the server-only Auth invitation adapter. Migration `0032_editorial_accounts.sql` +is the server-only Auth invitation adapter. New memberships start with optional +MFA and the account lock caps active reviewer memberships at 10 including the +owner. Migration 0131 applies the same rules to the Edge API; migration 0134 +resends invites to existing unconfirmed Auth users. Migration 0132 removes +reviewer membership without deleting Auth or historical evidence, and +clears open assignments. Migration `0032_editorial_accounts.sql` keeps memberships and account events inaccessible to browser roles. The one-time owner CLI is `python -m app.workers.editorial_accounts`; see [editorial-accounts.md](editorial-accounts.md) for activation and frontend handoff. @@ -96,7 +101,9 @@ for compatibility, historical offline semantics and rollout. `main.tsx` supplies the current Supabase session to `api.ts` before private requests; the API client refreshes an idle token and fences account changes and late denials without discarding recoverable review work. -`Auth.tsx` handles invitation/recovery, password and MFA; `Settings.tsx` handles +`Auth.tsx` renders a dedicated verified invitation/recovery password setup form, +direct sign-in and MFA; `main.tsx` removes callback credentials before rendering +and opens setup only after a valid Supabase session exchange. `Settings.tsx` handles registered identity, and `AuthenticatorSetting.tsx` lets owners and reviewers choose whether MFA is required for their own account. `editorial_accounts.py` enforces that persisted choice for every editorial capability. Disabling it diff --git a/docs/EDITORIAL_ROLLOUT.md b/docs/EDITORIAL_ROLLOUT.md index 1ee19512..e7fdcab0 100644 --- a/docs/EDITORIAL_ROLLOUT.md +++ b/docs/EDITORIAL_ROLLOUT.md @@ -90,7 +90,8 @@ In Railway's **api** variables: - Append the exact Netlify HTTPS origin to the comma-separated `ALLOWED_ORIGINS`, retaining existing origins. No `*` and no callback path here. -- Set `EDITORIAL_INVITE_REDIRECT_URL` to that origin plus `/auth/callback`. +- Set `EDITORIAL_INVITE_REDIRECT_URL` to + `https://coding-moves-one-concept-review.netlify.app/auth/callback`. - Set `EDITORIAL_EMAIL_DASHBOARD_URL` to the exact origin, with no path. - Keep `EDITORIAL_ENABLED=false` and `EDITORIAL_EMAIL_ENABLED=false` until the account and sender steps below. Keep `GENERATION_ENABLED` unchanged. @@ -102,7 +103,9 @@ setup. Do not replace its start command with a schema check; it must remain rollout. Supabase Auth → URL Configuration → Redirect URLs must allow the exact `https://.netlify.app/auth/callback` URL for invitations and recovery; retain the mobile app's existing redirects. Supabase's existing Auth SMTP and -HTML templates stay as configured. +HTML templates need the reviewer invitation update in +[EMAIL_TEMPLATES.md](EMAIL_TEMPLATES.md). The Supabase Site URL stays on the +working learner fallback; the invite call supplies the reviewer callback. Wait for the matching API deployment, then check the site root and a direct `/auth/callback` reload. Inspect HTTPS response headers on both. Verify the @@ -130,10 +133,22 @@ can invite each reviewer through **Reviewers**, approve their registered name, and grant only the needed capabilities (`review`, plus `approve`/`publish` where appropriate). Assignment requires `review`; a separate `approve`-only or `publish`-only member can still read the private lesson and shared queue needed -for that decision, with the same approved-name and MFA checks. Reviewers use -individual accounts and MFA; no GitHub or database +for that decision, with the same approved-name and optional authenticator +checks. New reviewer accounts start with the authenticator requirement off and +can enable it in **Settings**. Reviewers use individual accounts; no GitHub or database console access is required. Set their timezone in **Settings**. +Before sending a real invitation, deploy migrations 0131–0134 with the matching +API and Edge code, then install the **Invite user** HTML template from +`backend/email-templates/invite-reviewer.html`. A new reviewer should receive +the website callback, set a password, and sign in. An existing confirmed +learner should use their current email and password after the owner adds their +reviewer membership. Check that the tenth active reviewer is accepted when the +owner is counted and an eleventh is denied. Remove a test reviewer's access +through **Reviewers**, then verify their learning account remains usable and +the review workspace denies access. Never delete their Supabase Auth user as a +substitute for reviewer removal. + Test the website on its actual HTTPS origin: root/callback/review deep links, sign-out and sign-in return, permission denial, topic filter, one existing private draft, comments and shared decision state. Do not publish a test lesson diff --git a/docs/EMAIL_TEMPLATES.md b/docs/EMAIL_TEMPLATES.md index 74fd8087..b7fe05c0 100644 --- a/docs/EMAIL_TEMPLATES.md +++ b/docs/EMAIL_TEMPLATES.md @@ -1,6 +1,6 @@ # One Concept email templates -These three templates customize emails sent by Supabase Auth. They work with +These four templates customize emails sent by Supabase Auth. They work with the project's configured sender; installing the HTML does not require Resend, a purchased domain, or an app update. Provider setup remains separate in [draft PR #187](https://github.com/Coding-Moves/one-concept/pull/187). @@ -20,10 +20,11 @@ stores their reviewed source; it does not synchronize production settings. | Supabase template | Subject | HTML source | | --- | --- | --- | | Confirm sign up | `Confirm your email — One Concept` | [confirm-signup.html](../backend/email-templates/confirm-signup.html) | +| Invite user | `You're invited to One Concept Review` | [invite-reviewer.html](../backend/email-templates/invite-reviewer.html) | | Reset password | `Reset your password — One Concept` | [reset-password.html](../backend/email-templates/reset-password.html) | | Password changed (Security) | `Your password was changed — One Concept` | [password-changed.html](../backend/email-templates/password-changed.html) | -Keep every `{{ .ConfirmationURL }}` placeholder in signup and recovery. Supabase +Keep every `{{ .ConfirmationURL }}` placeholder in signup, invitation, and recovery. Supabase fills in the appropriate verification URL; the button and copyable fallback must both pass through that verification. Password changed reports a completed change and intentionally has no token or reset-link placeholder. It directs @@ -32,6 +33,16 @@ unrecognized changes to Forgot password in the app and the existing support emai Signup and recovery both request a landing page on the mobile build's configured API origin. The Site URL remains the fallback for earlier app versions, and the corresponding HTTPS destinations must be allowed in Supabase's URL Configuration. +Reviewer invitations instead request the exact +`https://coding-moves-one-concept-review.netlify.app/auth/callback` URL from the +server. Add it to the allowed Redirect URLs and set the API's +`EDITORIAL_INVITE_REDIRECT_URL` to that value. The invitation template does not +use Supabase's `{{ .SiteURL }}`, which is why its visible copy no longer says +`localhost:3000`. Keep the Supabase Site URL on the working public learner +fallback described in [AUTH_REDIRECTS.md](AUTH_REDIRECTS.md); a template cannot +repair a missing allowed redirect. Use **Reviewers → Invite reviewer** in the +review website; manually sending a Supabase Dashboard invitation does not grant +editorial membership. Do not replace verification links with those landing-page URLs. Follow the [authentication redirect runbook](AUTH_REDIRECTS.md) before testing the emails. See [Supabase email templates](https://supabase.com/docs/guides/auth/auth-email-templates). @@ -50,6 +61,10 @@ Before announcing the new emails to users, test with accounts and inboxes you control under the configured sender's limits: - Confirm signup, follow the email's button, and successfully sign in to the app. +- Invite a new reviewer through the review website, follow the email button to + the public callback, set a password, and sign in. Invite an existing confirmed + learner separately and verify that their existing password opens the workspace + after their reviewer name is approved. - Request Forgot password, set a new password through the recovery email, and sign in with it. Verify the separate Password changed notification arrives. - Check the design in a real inbox on phone and desktop, the full fallback links, diff --git a/docs/SUPABASE_API_ROUTE_PARITY.md b/docs/SUPABASE_API_ROUTE_PARITY.md index 32e5310f..4cba6d76 100644 --- a/docs/SUPABASE_API_ROUTE_PARITY.md +++ b/docs/SUPABASE_API_ROUTE_PARITY.md @@ -20,6 +20,7 @@ The mobile client calls learner endpoints; the owner/reviewer site calls `/v1/ed | PATCH | `/v1/editorial/me/authenticator` | Owner/reviewer site / current session + active membership + recent TOTP to disable | Candidate; local handler/SQL tests, staging role checks pending | | GET, POST | `/v1/editorial/reviewers` | Owner/reviewer site / role + capability + MFA | Candidate; local handler/SQL tests and staging POST invitation passed | | PATCH | `/v1/editorial/reviewers/{user_id}/access` | Owner/reviewer site / role + capability + MFA | Candidate; local handler/SQL tests, staging role checks pending | +| DELETE | `/v1/editorial/reviewers/{user_id}` | Owner/reviewer site / manage_reviewers and current version | Candidate; local handler/SQL tests, staging role checks pending | | POST | `/v1/editorial/reviewers/{user_id}/approve-profile` | Owner/reviewer site / manage_reviewers capability + MFA | Candidate; local handler/SQL and staging approval checks passed | | GET | `/v1/editorial/queue` | Owner/reviewer site / role + capability + MFA | Candidate; local handler/SQL and staging reviewer/MFA checks passed | | GET | `/v1/editorial/taxonomy` | Owner/reviewer site / role + capability + MFA | Candidate; local handler/SQL tests, staging role checks pending | diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index cc003623..0e940570 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -7,6 +7,18 @@ claims as completed work. ## Current status +### Reviewer invitations and account controls — 2026-10-22 + +- **Coder review of PR #436:** found that the Edge invitation path skipped the email for an existing unconfirmed Auth identity, unlike FastAPI. Commit `a886f69` adds migration 0134, so Edge requests a fresh invite until that account confirms its email, plus PostgreSQL and handler regressions. The schema contract was regenerated from disposable PostgreSQL 16. The three focused invitation tests, 37 schema/reviewer-access checks, and the Edge handler suite passed locally. Production application and a real email/callback check remain pending. +- **Follow-up scope:** audit both email-return and direct sign-in paths, add a distinct verified invite/password setup page shared with the learner identity, and clarify AI feedback actions. The owner confirmed the requested future supply behavior is to top the private reviewer draft queue up to ten when it falls below ten, not generate ten extra drafts. That worker behavior is not part of the invitation PR: existing refill is demand/plan-driven, generation is disabled in production, and a safe change must preserve curriculum, cost caps, review capacity, and Railway/Supabase worker parity. Track it in #437. +- **Follow-up implementation:** `d1d1520` resends an invitation for an existing unconfirmed Auth identity, including an ambiguous earlier provider result, instead of silently granting membership without a confirmation link; `4668eaf` adds a verified email callback account setup screen with password confirmation and strips incomplete credential fragments; `ddcc3cf` explains that comments save feedback while **Request changes** queues a private AI correction when generation is enabled; `4d5b97d` explains in the invite email that the same Auth account opens the learning app. Supabase's auth-user trigger already creates the learner profile for new invitees. AI output still needs human approval/publication. +- **Follow-up validation:** focused PostgreSQL invitation retry test passed. Hosted browser CI initially found a stale form assertion and then a token-read test race; `d6b7489` waits for completed sign-in before reading storage. All 54 reviewer-site browser tests, 17 component tests, fixture production build/typecheck, backend lint/tests, and mobile typecheck/tests passed on [quality run 39725506087](https://github.com/Coding-Moves/one-concept/actions/runs/39725506087). The live site home and callback return HTTP 200; no real invitation email or Supabase production setting was changed. PR #436 and #437 remain open. +- **Outcome:** route new reviewer invitations to the public One Concept Review callback, make authenticator use optional by default for newly invited reviewers, cap active reviewer memberships at ten including the owner, and let an owner permanently remove reviewer access while preserving the person's learning account and historical review evidence. +- **Scope:** one focused feature branch from current `develop`. The existing reviewer site remains the password setup and sign-in surface; existing confirmed One Concept users keep their Auth identity. Live Supabase URL configuration and email-template installation are separate activation steps, not source-controlled secrets. +- **Implementation:** `e4f0cab` adds the reviewer invitation template and exact callback guide; `23fd3ce` makes new owner/reviewer memberships MFA-optional and enforces the ten-active-member limit in FastAPI and Edge; `0670ffb` adds versioned permanent membership removal, clears open assignments, and retains learning accounts and review evidence; `66a8bd0` adjusts assignment email copy for optional MFA. The owner supplied `https://coding-moves-one-concept-review.netlify.app` as the review-site origin and confirmed ten active memberships total, including the owner. +- **Validation:** 45 focused PostgreSQL tests passed before the Edge execution-mode fix; 41 further PostgreSQL/notification/schema tests passed afterward. The final schema-contract inventory, including the service-only removal RPC, passed 23 tests. Edge handler tests: 95 passed. Review site: 17 component tests, 50 browser tests, typecheck and fixture production build passed. Supabase function TypeScript and Ruff `F,E9` passed. +- **Activation:** the source fixes are on this feature branch. Production Supabase invitation template, callback allowlist and Railway API redirect setting still require the exact changes in [EMAIL_TEMPLATES.md](EMAIL_TEMPLATES.md); no live invitation or user account was changed during this work. + ### Register protected production workflows on the default branch — 2026-10-21 - After release PR #434 merged into `main` at `abc2dcf`, the new public Netlify pages returned HTML with the expected security headers while their API setting still pointed to Railway. GitHub did not list the new production deploy or inactive-Cron workflow because the repository default branch is `develop` and those workflow files existed only on `main`. GitHub requires a `workflow_dispatch` file on the default branch even when the run targets another ref. diff --git a/docs/editorial-accounts.md b/docs/editorial-accounts.md index 6a38a7a6..5bf24ab0 100644 --- a/docs/editorial-accounts.md +++ b/docs/editorial-accounts.md @@ -1,5 +1,28 @@ # Reviewer accounts and permissions (#274) +## Current reviewer access + +On the active One Concept Review website, the owner uses **Reviewers → Invite +reviewer**. A new Auth email receives a link to the website's `/auth/callback` +and sets its own password there. An existing confirmed One Concept learner is +granted reviewer membership on the same Auth identity and signs in with the +existing password. The owner can keep at most **10 active reviewer memberships +including their own**. New memberships start with the authenticator requirement +off; each reviewer can turn it on in **Settings**. Existing members keep their +chosen setting. + +**Permanently remove reviewer** deletes only that person's editorial membership. +Their Supabase Auth identity, learner data, account audit events, legacy work and +historical review evidence remain. Open review assignments are cleared and +pending review notification entries are suppressed. The current version must +match; self-removal and deletion of the last qualified administrator are denied. +This is distinct from versioned revocation, which retains the membership for +later reactivation. + +The live callback allowlist, invitation template and API redirect setting must +be configured as in [EMAIL_TEMPLATES.md](EMAIL_TEMPLATES.md). Merging source code +alone does not change those Supabase or deployment settings. + This is the identity foundation for [#263](https://github.com/Coding-Moves/one-concept/issues/263). It adds private account APIs and an operator bootstrap command. The login, invitation callback, MFA and profile **screens are delivered in #278**. Do not @@ -55,9 +78,12 @@ Supabase-managed Auth tables are read, never altered by the application migratio `status=active` means membership has not been revoked; it does **not** imply confirmed email, completed onboarding or MFA. `/me` exposes the onboarding and MFA state for the future frontend. Users without an approved name cannot exercise -editorial capabilities. All capability checks require a signed `aal2` token. +editorial capabilities. Capability checks require a signed `aal2` token only +when the member has enabled the authenticator requirement. Reading one's own account and requesting a name change work at `aal1` so a new -reviewer can complete onboarding before privileged actions. +reviewer can complete onboarding before privileged actions. A current `aal1` +session is sufficient for privileged actions when the authenticator requirement +is off. ## Permission contract diff --git a/supabase/functions/api/handler.test.mjs b/supabase/functions/api/handler.test.mjs index 6b08476c..a836ea23 100644 --- a/supabase/functions/api/handler.test.mjs +++ b/supabase/functions/api/handler.test.mjs @@ -1173,9 +1173,50 @@ test('reviewer access requires a versioned capability update and current owner', body: JSON.stringify({ expected_version: 1, status: 'revoked', capabilities: [] }), }))).status, 409); + payload = { error: 'team_full' }; + assert.equal((await handler(request(path, { method: 'PATCH', headers, + body: JSON.stringify({ expected_version: 2, status: 'active', + capabilities: ['review'] }), + }))).status, 409); assert.equal((await handler(request(path, { method: 'POST', headers }))).status, 405); }); +test('permanent reviewer removal validates identity and version before the service RPC', async () => { + const sessionId = 'a5a94932-62e0-4780-91c7-af48b93e27ab'; + const reviewerId = 'fb7214e4-2505-41d3-a47b-98c0d05dcf07'; + const token = editorialToken({ sub: userId, session_id: sessionId, aal: 'aal1' }); + const calls = []; + let payload = { removed_user_id: reviewerId, unassigned_count: 2 }; + const handler = createHandler({ ...config, editorialEnabled: true }, async (url, init) => { + calls.push({ url, init }); + if (url.endsWith('/auth/v1/user')) return Response.json({ id: userId }); + return Response.json(payload); + }); + const headers = { authorization: `Bearer ${token}` }; + const path = `/v1/editorial/reviewers/${reviewerId}`; + assert.equal((await handler(request(`${path}?expected_version=1`, { + method: 'DELETE', + }))).status, 401); + for (const suffix of ['?expected_version=0', '?expected_version=1.2', '']) { + assert.equal((await handler(request(path + suffix, { + method: 'DELETE', headers, + }))).status, 422); + } + assert.equal((await handler(request(`${path}?expected_version=1`, { + method: 'DELETE', headers, + }))).status, 200); + assert.match(calls.at(-1).url, /\/rpc\/edge_api_editorial_reviewer_remove$/); + assert.deepEqual(JSON.parse(calls.at(-1).init.body), { + p_user_id: userId, p_session_id: sessionId, p_aal: 'aal1', + p_subject_id: reviewerId, p_expected_version: 1, + }); + payload = { error: 'last_admin' }; + assert.equal((await handler(request(`${path}?expected_version=1`, { + method: 'DELETE', headers, + }))).status, 409); + assert.equal((await handler(request(path, { method: 'PATCH', headers }))).status, 405); +}); + test('reviewer invitation verifies Auth identity before adding membership', async () => { const sessionId = 'a5a94932-62e0-4780-91c7-af48b93e27ab'; const reviewerId = 'fb7214e4-2505-41d3-a47b-98c0d05dcf07'; @@ -1237,6 +1278,25 @@ test('reviewer invitation blocks an unapproved callback before contacting Auth', assert.equal(calls.filter(call => call.url.includes('/auth/v1/invite')).length, 0); }); +test('reviewer invitation rejects a full team before sending email', async () => { + const sessionId = 'a5a94932-62e0-4780-91c7-af48b93e27ab'; + const token = editorialToken({ sub: userId, session_id: sessionId, aal: 'aal2' }); + const calls = []; + const handler = createHandler({ ...config, editorialEnabled: true }, async (url, init) => { + calls.push({ url, init }); + if (url.endsWith('/auth/v1/user')) return Response.json({ id: userId }); + return Response.json({ error: 'team_full' }); + }); + const result = await handler(request('/v1/editorial/reviewers', { + method: 'POST', headers: { authorization: `Bearer ${token}`, + 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'friend@example.com' }), + })); + assert.equal(result.status, 409); + assert.equal((await result.json()).detail, 'The team already has 10 active reviewers'); + assert.equal(calls.filter(call => call.url.includes('/auth/v1/invite')).length, 0); +}); + test('existing Auth accounts can be enrolled without sending another invitation', async () => { const sessionId = 'a5a94932-62e0-4780-91c7-af48b93e27ab'; const reviewerId = 'fb7214e4-2505-41d3-a47b-98c0d05dcf07'; @@ -1260,6 +1320,34 @@ test('existing Auth accounts can be enrolled without sending another invitation' assert.match(calls.at(-1).url, /\/rpc\/edge_api_editorial_invite_finish$/); }); +test('unconfirmed Auth accounts receive a new invitation before enrollment', async () => { + const sessionId = 'a5a94932-62e0-4780-91c7-af48b93e27ab'; + const reviewerId = 'fb7214e4-2505-41d3-a47b-98c0d05dcf07'; + const token = editorialToken({ sub: userId, session_id: sessionId, aal: 'aal2' }); + const calls = []; + const handler = createHandler({ ...config, editorialEnabled: true, + editorialInviteRedirectUrl: 'https://review.example.com/invited' }, async (url, init) => { + calls.push({ url, init }); + if (url.endsWith('/auth/v1/user')) return Response.json({ id: userId }); + if (url.endsWith('/rpc/edge_api_editorial_invite_prepare')) { + return Response.json({ user_id: reviewerId, needs_invite: true }); + } + if (url.includes('/auth/v1/invite?')) { + return Response.json({ id: reviewerId, email: 'friend@example.com' }); + } + return Response.json({ user_id: reviewerId, status: 'active', version: 1 }); + }); + const result = await handler(request('/v1/editorial/reviewers', { + method: 'POST', headers: { authorization: `Bearer ${token}`, + 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'friend@example.com' }), + })); + assert.equal(result.status, 201); + assert.equal(calls.filter(call => call.url.includes('/auth/v1/invite?')).length, 1); + assert.match(calls.at(-1).url, /\/rpc\/edge_api_editorial_invite_finish$/); + assert.equal(JSON.parse(calls.at(-1).init.body).p_invited_id, reviewerId); +}); + test('editorial profile requests normalize the name and require the current version', async () => { const sessionId = 'a5a94932-62e0-4780-91c7-af48b93e27ab'; const token = editorialToken({ sub: userId, session_id: sessionId, aal: 'aal1' }); diff --git a/supabase/functions/api/handler.ts b/supabase/functions/api/handler.ts index f4d480c1..c696e67c 100644 --- a/supabase/functions/api/handler.ts +++ b/supabase/functions/api/handler.ts @@ -170,7 +170,7 @@ function editorialClaims(token: string, verifiedUserId: string): { } catch { return null; } } -async function rpc(name: 'edge_api_request_retry_after' | 'edge_api_health' | 'edge_api_topics' | 'edge_api_concept' | 'edge_api_set_interaction' | 'edge_api_notifications' | 'edge_api_set_notifications' | 'edge_api_push_token' | 'edge_api_stats' | 'edge_api_history' | 'edge_api_saved' | 'edge_api_subtopic_progress' | 'edge_api_analytics' | 'edge_api_mark_subtopics_seen' | 'edge_api_daily' | 'edge_api_complete_daily' | 'edge_api_achievements' | 'edge_api_achievements_seen' | 'edge_api_complete_review' | 'edge_api_state' | 'edge_api_set_topics' | 'edge_api_patch_profile' | 'edge_api_set_avatar' | 'edge_api_sharing_get' | 'edge_api_sharing_put' | 'edge_api_public_profile' | 'edge_api_connection_settings_get' | 'edge_api_connection_settings_put' | 'edge_api_connections' | 'edge_api_connection_status' | 'edge_api_connection_request' | 'edge_api_connection_block_token' | 'edge_api_connection_action' | 'edge_api_connection_unblock' | 'edge_api_relationship_status' | 'edge_api_relationship_connect' | 'edge_api_relationship_remove' | 'edge_api_relationship_block' | 'edge_api_relationships' | 'edge_api_weekly_quiz' | 'edge_api_submit_weekly_quiz' | 'edge_api_subtopic_quiz' | 'edge_api_submit_subtopic_quiz' | 'edge_api_subtopic_quiz_attempts' | 'edge_api_editorial_me' | 'edge_api_editorial_reviewers' | 'edge_api_editorial_taxonomy' | 'edge_api_editorial_activity' | 'edge_api_editorial_legacy_entries' | 'edge_api_editorial_queue' | 'edge_api_editorial_revision_history' | 'edge_api_editorial_generation_jobs' | 'edge_api_editorial_generation_job' | 'edge_api_editorial_cancel_generation_job' | 'edge_api_editorial_generation_supply' | 'edge_api_editorial_legacy_eligibility' | 'edge_api_editorial_concept_timeline' | 'edge_api_editorial_legacy_batches' | 'edge_api_editorial_legacy_batch_action' | 'edge_api_editorial_create_legacy_batch' | 'edge_api_editorial_request_generation' | 'edge_api_editorial_concept_detail_source' | 'edge_api_editorial_revision_detail_source' | 'edge_api_editorial_stage_revision' | 'edge_api_editorial_revision_generation' | 'edge_api_editorial_concept_action' | 'edge_api_editorial_revision_action' | 'edge_api_editorial_notification_policy' | 'edge_api_editorial_notifications_overview' | 'edge_api_editorial_notification_retry' | 'edge_api_editorial_owner_operations' | 'edge_api_editorial_owner_overview' | 'edge_api_editorial_owner_reviewers' | 'edge_api_editorial_owner_events' | 'edge_api_editorial_reviewer_access' | 'edge_api_editorial_invite_prepare' | 'edge_api_editorial_invite_finish' | 'edge_api_editorial_request_profile' | 'edge_api_editorial_notification_timezone' | 'edge_api_editorial_authenticator' | 'edge_api_editorial_approve_profile', args: Record, config: ApiConfig, fetcher: Fetcher): Promise { +async function rpc(name: 'edge_api_request_retry_after' | 'edge_api_health' | 'edge_api_topics' | 'edge_api_concept' | 'edge_api_set_interaction' | 'edge_api_notifications' | 'edge_api_set_notifications' | 'edge_api_push_token' | 'edge_api_stats' | 'edge_api_history' | 'edge_api_saved' | 'edge_api_subtopic_progress' | 'edge_api_analytics' | 'edge_api_mark_subtopics_seen' | 'edge_api_daily' | 'edge_api_complete_daily' | 'edge_api_achievements' | 'edge_api_achievements_seen' | 'edge_api_complete_review' | 'edge_api_state' | 'edge_api_set_topics' | 'edge_api_patch_profile' | 'edge_api_set_avatar' | 'edge_api_sharing_get' | 'edge_api_sharing_put' | 'edge_api_public_profile' | 'edge_api_connection_settings_get' | 'edge_api_connection_settings_put' | 'edge_api_connections' | 'edge_api_connection_status' | 'edge_api_connection_request' | 'edge_api_connection_block_token' | 'edge_api_connection_action' | 'edge_api_connection_unblock' | 'edge_api_relationship_status' | 'edge_api_relationship_connect' | 'edge_api_relationship_remove' | 'edge_api_relationship_block' | 'edge_api_relationships' | 'edge_api_weekly_quiz' | 'edge_api_submit_weekly_quiz' | 'edge_api_subtopic_quiz' | 'edge_api_submit_subtopic_quiz' | 'edge_api_subtopic_quiz_attempts' | 'edge_api_editorial_me' | 'edge_api_editorial_reviewers' | 'edge_api_editorial_taxonomy' | 'edge_api_editorial_activity' | 'edge_api_editorial_legacy_entries' | 'edge_api_editorial_queue' | 'edge_api_editorial_revision_history' | 'edge_api_editorial_generation_jobs' | 'edge_api_editorial_generation_job' | 'edge_api_editorial_cancel_generation_job' | 'edge_api_editorial_generation_supply' | 'edge_api_editorial_legacy_eligibility' | 'edge_api_editorial_concept_timeline' | 'edge_api_editorial_legacy_batches' | 'edge_api_editorial_legacy_batch_action' | 'edge_api_editorial_create_legacy_batch' | 'edge_api_editorial_request_generation' | 'edge_api_editorial_concept_detail_source' | 'edge_api_editorial_revision_detail_source' | 'edge_api_editorial_stage_revision' | 'edge_api_editorial_revision_generation' | 'edge_api_editorial_concept_action' | 'edge_api_editorial_revision_action' | 'edge_api_editorial_notification_policy' | 'edge_api_editorial_notifications_overview' | 'edge_api_editorial_notification_retry' | 'edge_api_editorial_owner_operations' | 'edge_api_editorial_owner_overview' | 'edge_api_editorial_owner_reviewers' | 'edge_api_editorial_owner_events' | 'edge_api_editorial_reviewer_access' | 'edge_api_editorial_reviewer_remove' | 'edge_api_editorial_invite_prepare' | 'edge_api_editorial_invite_finish' | 'edge_api_editorial_request_profile' | 'edge_api_editorial_notification_timezone' | 'edge_api_editorial_authenticator' | 'edge_api_editorial_approve_profile', args: Record, config: ApiConfig, fetcher: Fetcher): Promise { let result: Response; try { result = await fetcher(`${config.supabaseUrl}/rest/v1/rpc/${name}`, { @@ -1114,6 +1114,7 @@ export function createHandler(config: ApiConfig, fetcher: Fetcher = fetch, const editorialAuthenticator = path === '/v1/editorial/me/authenticator'; const editorialApproveProfile = /^\/v1\/editorial\/reviewers\/([^/]+)\/approve-profile$/.exec(path); const editorialReviewerAccess = /^\/v1\/editorial\/reviewers\/([^/]+)\/access$/.exec(path); + const editorialReviewerRemove = /^\/v1\/editorial\/reviewers\/([^/]+)$/.exec(path); const editorial = editorialMe || editorialReviewers || editorialTaxonomy || editorialActivity || editorialQueue || editorialHistory !== null || editorialConcept !== null || editorialConceptAction !== null || editorialRevision !== null || editorialRevisionAction !== null @@ -1126,7 +1127,7 @@ export function createHandler(config: ApiConfig, fetcher: Fetcher = fetch, || editorialOwnerEvents || editorialLegacyEntries !== null || editorialProfile || editorialTimezone || editorialAuthenticator || editorialApproveProfile !== null - || editorialReviewerAccess !== null; + || editorialReviewerAccess !== null || editorialReviewerRemove !== null; const state = path === '/v1/me/state'; const profile = path === '/v1/me'; const avatar = path === '/v1/me/avatar'; @@ -1160,6 +1161,8 @@ export function createHandler(config: ApiConfig, fetcher: Fetcher = fetch, ? request.method === 'GET' || request.method === 'POST' : editorialLegacyBatches ? request.method === 'GET' || request.method === 'POST' + : editorialReviewerRemove + ? request.method === 'DELETE' : editorialProfile || editorialTimezone || editorialAuthenticator || editorialNotificationPolicy || editorialReviewerAccess ? request.method === 'PATCH' @@ -1232,6 +1235,37 @@ export function createHandler(config: ApiConfig, fetcher: Fetcher = fetch, if (!config.editorialEnabled) return editorialResponse({ detail: 'Editorial access is disabled' }, 503, cors); const claims = editorialClaims(token, userId); if (!claims) return editorialResponse({ detail: 'A current Supabase session is required' }, 401, cors, true); + if (editorialReviewerRemove) { + const rawVersion = requestUrl.searchParams.get('expected_version'); + const version = Number(rawVersion); + if (!UUID.test(editorialReviewerRemove[1]) || !rawVersion + || !/^[1-9][0-9]*$/.test(rawVersion) || !Number.isSafeInteger(version)) { + return editorialResponse({ detail: 'Invalid reviewer removal' }, 422, cors); + } + const data = await rpc('edge_api_editorial_reviewer_remove', { + p_user_id: userId, p_session_id: claims.sessionId, p_aal: claims.aal, + p_subject_id: editorialReviewerRemove[1], p_expected_version: version, + }, config, fetcher) as JsonObject | null; + if (data === null) return unavailable(cors); + if (typeof data.error === 'string') { + const failures: Record = { + invalid: [422, 'Invalid reviewer removal'], + session_required: [401, 'A confirmed account and current session are required'], + unavailable: [403, 'Editorial access is unavailable'], + permission: [403, 'Editorial permission required'], + profile_required: [403, 'Complete profile setup and obtain name approval'], + mfa_required: [403, 'Complete MFA verification for editorial actions'], + self_removal: [403, 'You cannot remove your own reviewer access'], + reviewer_not_found: [404, 'Reviewer not found'], + version_conflict: [409, 'Account changed; reload before removing'], + last_admin: [409, 'Keep another confirmed, approved administrator before removing this access'], + }; + const failure = failures[data.error]; + return failure ? editorialResponse({ detail: failure[1] }, failure[0], cors) + : unavailable(cors); + } + return editorialResponse(data, 200, cors); + } if (editorialRevisionAction) { if (!UUID.test(editorialRevisionAction[1])) { return editorialResponse({ detail: 'Invalid revision ID' }, 422, cors); @@ -1651,6 +1685,7 @@ export function createHandler(config: ApiConfig, fetcher: Fetcher = fetch, invalid: [422, 'Invalid reviewer invitation'], duplicate_identity: [409, 'Resolve duplicate Auth email identities before inviting'], reviewer_exists: [409, 'Reviewer already exists; use their versioned access settings'], + team_full: [409, 'The team already has 10 active reviewers'], identity_not_ready: [502, 'Invited identity is not yet available; retry after checking Auth'], }; if (typeof data.error !== 'string') return null; @@ -1959,6 +1994,9 @@ export function createHandler(config: ApiConfig, fetcher: Fetcher = fetch, if (data.error === 'last_admin') { return editorialResponse({ detail: 'Keep another confirmed, approved administrator before removing this access' }, 409, cors); } + if (data.error === 'team_full') { + return editorialResponse({ detail: 'The team already has 10 active reviewers' }, 409, cors); + } if (data.error === 'policy_conflict') { return editorialResponse({ detail: 'Policy changed; refresh before saving' }, 409, cors); }