Skip to content

feat(ui): add contribution editor workflow - #20

Merged
mcfarhat merged 14 commits into
developfrom
feature/m2-contribution-editor-frontend-kareem
Aug 24, 2026
Merged

mcfarhat merged 14 commits into
developfrom
feature/m2-contribution-editor-frontend-kareem

Conversation

@KareemN1936

Copy link
Copy Markdown
Collaborator

No description provided.

@github-actions

github-actions Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

Vercel preview is ready:

https://hivelore-66btoqc8w-kareemn1936s-projects.vercel.app

Commit: 7b69208

@mcfarhat

mcfarhat commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

@KareemN1936 this is your strongest PR so far, and it shows you took the earlier feedback on board: it wires to real, permission-enforced endpoints (I verified POST /worlds/:id/contributions, PATCH .../:id and POST .../:id/submit all exist and the response shapes match field-for-field), there are no mocks, no dangerouslySetInnerHTML anywhere (you store TipTap JSON, never HTML), no localStorage token (React memory via auth-session-provider), and you mirrored the 200/1000 title/summary caps.

Most importantly you got the canon model right: no status select, kind is LORE/STORY only, and the sole state transition is POST .../submit, which the server performs and snapshots into an immutable Proposal. That's exactly the DRAFT → SUBMITTED → proposal flow schema.prisma requires — and the direct opposite of what #18 does. Worth saying plainly: #20 is the correct model, #18 is not.

Must fix:

1. 🔴 Data-loss bug — contribution-editor-form.tsx:146. const currentDraft = draft ?? (await saveDraft()); — if a draft already exists, Submit skips saving pending edits. So: Save → edit the title/body → Submit, and the server snapshots the stale draft into Proposal.proposedContent, which is immutable. Reviewers then vote on text the author never wrote. Should be an unconditional await saveDraft() (or dirty-tracked).

2. 🔴 The Stories tab creates the wrong kind — page.tsx:10. It matches type?.toLowerCase() === 'story', but the hub emits ?type=${category.id} and the story id is 'stories' — so it silently falls through to LORE. Only the one hardcoded ?type=story button works, and the other six loreCategories ids are discarded too. Please handle the real ids and decide what the non-LORE/STORY categories should do rather than collapsing them.

3. 🟠 Permission gate is still "is there a token". contribution-editor-form.tsx:81-90 — same shape as #18. Not exploitable (the server enforces CREATE_LORE_DRAFT/SUBMIT_PROPOSAL on every route), so this is fails-safe server-side but fails-open in the UI: a logged-in READER or a non-member gets the full editor, writes a long draft, and only eats a 403 on Save. Cheap fix without a backend change: pre-flight GET /worlds/:id/contributions, which already 403s for non-permitted viewers, and hide the editor unless it succeeds.

4. 🔴 Route collision with your own #18 — needs resolving before either merges. #18 and #20 both rewrite apps/web/src/app/worlds/[worldId]/contribute/page.tsx from the same base into different features, with the same ?entryId= param meaning opposite things (#18: the entry being edited; #20: the entry being referenced). Whichever merges second gets a guaranteed conflict — and more importantly they encode contradictory canon models. My view is #20 owns /contribute and #18's direct-lore-editing surface needs its own path, but tell me if you see it differently.

5. Mirror the 100 KB STRUCTURED_DOCUMENT_MAX_BYTES cap client-side with a live counter — right now a long doc dies on a generic 400 after the user has written it.

Nits: no draft loading, so ?entryId= never resumes an existing draft and a page reload creates a second row — either implement resume via the existing GET endpoints or scope it out explicitly; the new onJsonChange only fires from onUpdate, never on initial content, so the moment you do implement resume a Save will overwrite the loaded body with an empty doc; no session-loading skeleton and the /login redirect carries no ?next=, so the draft is lost on return; :280 still says "through the draft endpoints" (internal phrasing); success pushes to /worlds/:id/proposals/:proposalId, which is still a placeholder — fine for the milestone, just noting the journey dead-ends; and StructuredEditorContent/StructuredContributionContent are duplicate aliases.

Sequencing: this is a third PR stacked on #16 while #14 is still stalled — the chain is #14 → #16 → {#18, #20}, with #19 on a separate branch off #14. I'd rather you didn't open more stacked work until #14 lands; if you're blocked, say so and we'll unblock it from our side rather than have you build further on a moving base.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

@mcfarhat Pushed fixes in 6c61714.

Addressed:

  • Submit now saves the latest draft content before submitting, so proposals cannot snapshot stale edits.
  • Stories route now handles the real ?type=stories value and opens as STORY.
  • Added a permission preflight through GET /worlds/:id/contributions; the editor is hidden unless the backend authorizes drafting.
  • Resolved the /contribute collision with feat(ui): add lore entity CRUD frontend #18 by using targetLoreEntryId instead of overloading entryId.
  • Added a client-side 100KB structured document counter/blocker.
  • Reworded the submitted-draft warning to avoid internal endpoint phrasing.

Validation passed: web typecheck, web lint, web build.

@mcfarhat

Copy link
Copy Markdown
Contributor

@KareemN1936 re-reviewed at 6c617140. Three of the five items are properly fixed, one is partial, and one is reported as resolved but isn't. Your typecheck / lint / format:check / build claims all check out for me.

Fixed, and fixed properly

1. Stale-draft snapshot on Submit. contribution-editor-form.tsx:218 is now const currentDraft = await saveDraft(); — unconditional, genuinely awaited, and submit uses currentDraft.id from the freshly returned record rather than the stale draft state. That's the right shape.

3. Permission gate. I checked the reasoning, not just the presence of the preflight: GET /worlds/:id/contributions requires CREATE_LORE_DRAFT, the same permission as create, and every role holding it also holds SUBMIT_PROPOSAL and EDIT_OWN_DRAFT. So one preflight genuinely covers all three routes the editor uses. Good choice of probe.

5. The 100 KB cap. Client constant matches the server's exactly, and the measurement matches byte-for-byte (TextEncoder().encode(JSON.stringify(...)) against Buffer.byteLength(..., 'utf8')). Blocked in both canSave and saveDraft, with a live counter.

Double-submit is also properly guarded at both ends — client button plus a status-guarded updateMany server-side that returns alreadySubmitted idempotently.

The same bug class survives in two narrower forms

A. Save Draft stays enabled during an in-flight submit. canSave (:141-152) checks !isSaving but not !isSubmitting. handleSubmitForVote sets isSubmitting, awaits saveDraft (whose finally clears isSaving), then POSTs the submit — and during that POST the Save button is live. Click Submit, edit the title, click Save before it returns: either the PATCH wins and the proposal snapshots text the author never meant to submit, or the submit wins and the PATCH 409s, showing a red "Contribution was not saved" banner even though submission actually succeeded. One-line fix: add !isSubmitting to canSave and its deps.

B. An emptied summary can never be cleared. buildPayload (:156-164) spreads the key in only when the field is non-blank:

...(summary.trim() ? { summary: summary.trim() } : {}),

The server only acts when a field is !== undefined, and the PATCH schema explicitly accepts .nullable() for exactly this purpose — but the client never sends null. So: save a draft with a summary, delete the summary, Save → 200 OK, old summary silently persists and gets snapshotted into Proposal.summary. Reviewers then vote on a summary the author deleted. Same for unlinking a targetLoreEntryId. Send null rather than omitting the key.

Partial

2. Contribution kinds. ?type=stories is handled now, and you fixed the hub button to match. But the other six ids in lib/worlds/constants.ts:19-27 — characters, cities, factions, quests, events, artifacts — still collapse silently to LORE with the category discarded, so every "Add Characters" / "Add Factions" button produces an identical untyped draft.

Looking at the API, I think you may be stuck: routes/contributions.ts:31-39 accepts only content/kind/summary/targetLoreEntryId/title, with no field to carry a lore category. If that's the reason, say so in the PR and open a card for the API change — what I asked for was a decision, and silently collapsing them is the one option that leaves the next reader guessing.

Reported fixed, but not

4. Route collision with #18. Renaming entryId → targetLoreEntryId removed the semantic overlap, which is good, but the file collision is what mattered. I diffed the two branches against your base — they still share exactly two files:

apps/web/src/app/worlds/[worldId]/contribute/page.tsx
apps/web/src/app/worlds/[worldId]/page.tsx

Whichever merges second still conflicts. Since #18 is now base-only for #21, the cleanest fix is to rebase this PR after #21 lands rather than trying to keep them independent.

Should-fix

Preflight failure is permanent. :112-129 sets denied on any failure, not just 403 — an API cold start, a 502 or a network blip locks the editor out until a full page reload. Worse, when it's set because the token is missing (:102-104) no error message is set either, so the denied card renders completely empty. Distinguish 403 from transient failures, and offer a retry.

Nits

The AbortController at :107/132 is never passed to fetch, so it's an is-mounted flag rather than a cancellation. The error banner is always titled "Contribution was not saved" even for permission denials. If submit returns proposal: null, :227 skips navigation and strands the user on a fully disabled form. "Target lore entry ID" is a raw free-text ID box. An empty document displays "1 KB of 100 KB" thanks to Math.ceil.

Still open from earlier and not scoped out: reload still creates a second draft row rather than resuming — and you now call the exact GET that would give you the list, then discard it. /login still has no ?next=.

One thing worth knowing

apps/api/src/routes/contributions.test.ts is currently red and has been since 08-07 — every authenticated route 401s because that test's fake database has no refreshSession stub. Not caused by this PR (it's apps/web only; the cause is on the #16 branch and I've written it up there). But one of the dead tests is "valid draft submission creates exactly one proposal and locks immutable proposed content" — precisely the property item 1 above is about. Worth knowing that the safety net you'd expect to catch a regression here isn't currently running.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

@mcfarhat Pushed d80359f.

Addressed the latest editor review follow-ups:

  • Save Draft is now disabled during an in-flight submit.
  • Clearing the summary now sends null on update, so an old summary does not persist silently.
  • Unsupported contribution category URLs are explicit now instead of silently collapsing into LORE; the current backend only supports LORE and STORY.

Verification:

  • GitHub Quality ✅
  • Vercel Preview ✅
  • Web typecheck/lint/build passed locally

I’ll rebase this after #21 lands to resolve the remaining stacked route/page overlap.

@mcfarhat

Copy link
Copy Markdown
Contributor

@KareemN1936 re-reviewed at d80359f. Two fixed properly, one partial, one still outstanding but honestly reported.

1. Save disabled during in-flight submit — FIXED, and both halves are right. :143-155 has !isSubmitting in the predicate and isSubmitting in the useMemo dep array — the missing-dep trap is the usual way this gets half-fixed, so worth calling out that you didn't fall into it. handleSave also early-returns on isSubmitting (:212-220), which covers the Enter-key path rather than just the button. The race is closed at both entry points.

Minor leftover: the inputs stay editable during submit, so keystrokes typed after clicking Submit are displayed but never sent. Lost-edit UX, not corruption — a disabled={isSubmitting} on the fields finishes it.

2. Cleared summary sends null — FIXED, verified end to end. The draft ? guard at :159-174 is the right call: createContributionBodySchema is .strict() and non-nullable, so sending null unconditionally would have 400'd every create. You got that distinction right.

I traced the whole round trip rather than just the client change: the PATCH schema accepts nullable, the handler spreads body.data through without dropping it, normalizeSummary turns null into null, and targetLoreEntryId uses disconnect: true against an optional relation. The snapshot at lib/contributions.ts:702 then carries '' instead of the stale text — so reviewers can no longer vote on a summary the author deleted. Genuinely dead.

Small regression to tidy: both keys now go out on every save even when unchanged, so changedFields in the audit log always lists summary and targetLoreEntryId, plus an extra disconnect round-trip per save. Audit noise rather than incorrectness.

3. Unsupported categories — PARTIAL, and your backend statement is correct. I verified it: enum ContributionKind { LORE STORY } in the schema, and the route validates against exactly those two. There genuinely is no field to carry a lore category, so you weren't dodging.

The warning banner is the right immediate move and it satisfies what I actually asked for — no longer silent. It's partial only because the behaviour is unchanged: the category is still discarded and the draft is still LORE, it's just disclosed now. Two follow-ups: the banner doesn't name the rejected type even though getUnsupportedType returns it (one interpolation), and please open a card for the API change so this doesn't sit as a permanent "not supported yet".

4. Route collision with #18 — still there, and you said as much. I didn't infer it from filenames; I ran the merge:

UU apps/web/src/app/worlds/[worldId]/contribute/page.tsx

worlds/[worldId]/page.tsx happens to auto-merge, contribute/page.tsx genuinely conflicts. You've committed to rebasing after #21, and #21 is still open, so the rebase can't happen yet. Accurate on your part — just still live, and whichever lands second breaks.

Not claimed, still open

The preflight is the one worth fixing next: :120-131 sets denied on any rejection, not just 403, so an API cold start or a 502 locks the editor out until a full page reload — and at :104-107 it's set with no error message, so the card renders as a bare "Back to World" button with no text at all. Distinguish 403 from transient failures and offer a retry.

Also unchanged: the AbortController at :109/134 still isn't passed to fetch — and client.ts does support a signal, so the plumbing exists and it's a one-line wire-up. The error banner is still hardcoded to "Contribution was not saved", which is actively wrong for a permission denial where nothing was being saved. proposal: null still leaves the user on a locked form with no path forward. And reload still creates a second draft row while the preflight calls the exact GET that would let you resume.

Coverage

apps/web has zero test files, so none of these three fixes has automated coverage. The summary: null round trip in particular is exactly the kind of thing that wants a route test — there's no summary: null assertion anywhere in the API suite. Worth adding one on the API side even though the fix is client-side.

All three claims are substantively honest. Fix the preflight and tidy the leftovers, and this is ready once the rebase can happen.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Addressed the latest review follow-ups in 61f7bf4:

  • inputs lock while save/submit is in progress
  • unsupported category warning names the rejected type
  • preflight now separates 403 permission denial from transient failures
  • transient preflight failures show Retry
  • listContributions now receives AbortController.signal

Also opened the Trello follow-up card for typed contribution categories beyond LORE/STORY. Quality and Vercel checks are green. The remaining route-collision item is still waiting on the PR #21 dependency/rebase path.

@mcfarhat

Copy link
Copy Markdown
Contributor

@KareemN1936 verified at 61f7bf4. All four claimed items are genuinely fixed, and your green checks hold — typecheck, eslint (including exhaustive-deps, since you correctly added permissionRetryKey to the dep array), prettier and next build all clean.

Preflight now distinguishes transient from denied. The new 'error' state at :86-88 with a separate permissionMessage and a permissionRetryKey retry is the right shape. I traced all four paths: 403 → denied with no retry; 500 → error with retry; a network TypeError → error with retry; missing token → denied with a message. And the bare-button dead end is genuinely unreachable now — both denied paths set a message and :300-303 has a fallback for each.

Input locking covers all five controls — title, type select, target id, summary, and the rich-text editor. I checked the last one actually takes effect rather than just receiving a prop: rich-text-editor.tsx:35 sets editable, :53-55 re-applies it, and every toolbar button disables. AbortController is properly wired now — contributions.ts:103-113 takes the signal, client.ts:74-76 forwards it into RequestInit, so it's real cancellation rather than an is-mounted flag. The category banner names the type.

One new gap, and it's the same class as the bug you just fixed

A 401 isn't a 403, so it lands in 'error' with a Retry that can never succeed. Access token TTL defaults to 900s and auth-session-provider.tsx:62-86 refreshes once on mount with no 401 retry. So: leave a tab open fifteen minutes, soft-navigate to /contribute, and you get "Could not check contribution access" with a Retry button that loops forever and a card offering only "Back to World" — no sign-in affordance anywhere. Treat 401 as a re-auth path rather than a transient error.

Related, low: on a 502/504 from a proxy the body is HTML, so parseErrorBody returns null and the preflight card falls back to "Contribution could not be saved." — item 4's mislabel resurfacing inside the new code path.

Two small things on the fixes themselves

The banner prints the raw slug (characters) rather than the label from loreCategories. And the value is the unvalidated reflected ?type= query param, rendered verbatim in <strong>. React escapes it so there's no XSS, but a crafted link injects arbitrary attacker-chosen text into an official-looking in-app banner. getUnsupportedType already has the allowlist — mapping through loreCategories with a generic fallback fixes the slug and the spoofing in one go.

Input locking triggers on isSaving too, not just isSubmitting, so fields flicker disabled and lose focus on every Save. Cosmetic, but noticeable while typing.

Still open

proposal: null still strands the user on a locked form (:252-254). The audit-log noise is unchanged — both summary and targetLoreEntryId go out on every save once a draft exists. Draft resume still isn't there, and the preflight still calls the exact listContributions that would enable it and discards the response. All low, all fine to carry.

Sequencing has changed — worth knowing before you plan the rebase

I ran the trial merge rather than comparing filenames: the contribute/page.tsx conflict with #18 is still live. But there's a new wrinkle — #18 is now CONFLICTING against its own base (m2-worlds-frontend-integration-kareem). So the chain is currently:

That means the #20 rebase is further out than "wait for #21" — #18 has to be un-conflicted first. Might be worth resolving #18 next, since three PRs are queued behind it.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

@mcfarhat Pushed b74ede7.

Addressed the latest PR #20 follow-ups:

  • Preflight 401 now routes to a re-auth state with a Sign in link instead of a Retry loop.
  • Transient preflight errors now use neutral access-check copy.
  • Unsupported contribution categories now render known category labels, and unknown ?type= values fall back to generic text instead of reflecting raw query input.

Validation:

  • GitHub Quality ✅
  • Vercel Preview ✅
  • Web typecheck/lint/build ✅

Leaving the remaining low-priority items scoped out for now: proposal:null fallback, audit-log noise, draft resume, and the route conflict/rebase sequencing with #18.

@mcfarhat mcfarhat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KareemN1936 verified b74ede77 — driven in a real browser against a stub API, not read. All three claimed items are genuinely fixed, and I could see them working:

  • 401 → reauth. /worlds/w401/contribute renders "Sign in again" with href="/login?next=%2Fworlds%2Fw401%2Fcontribute". 403 still lands in denied with no retry, which is right.
  • 502 with an HTML body now reads "Contribution access could not be checked" instead of the old "not saved" mislabel.
  • Reflected ?type= closed. ?type=%3Cscript%3Ehack renders no attacker text; ?type=characters renders "Characters".

Typecheck, eslint, prettier and next build all clean.

But the 401 fix is half a fix, and the half that's missing is the half where a user loses work.

Blockers

A — the "Sign in" link you just added is a dead end

apps/web/src/app/login/page.tsx is, in full:

import Home from '@/app/page';
export default function LoginPage() {
  return <Home />;
}

It never reads searchParams, so your ?next= is discarded. And nothing clears the expired access token on a 401 — auth-session-provider.tsx:41-51 only clears when a refresh fails — so the provider still holds user. I loaded /login?next=%2Fworlds%2Fw401%2Fcontribute and it rendered "Signed in as @kareem / Refresh Session / Log Out".

So: idle fifteen minutes → reauth card → click "Sign in" → a page telling you you're already signed in, no form, no way back to the editor.

The better fix is already in the hook you import. refreshSession() uses the refresh token, whose TTL is 14 days against the access token's 900 seconds. On a 401 you can silently recover instead of asking the user to re-sign with Keychain. That single change also fixes B below.

B — the same 401 gap survives on the save path, where there's an actual draft to lose

I grepped all of apps/web: the only 401 branch anywhere is contribution-editor-form.tsx:150, in the preflight. So the write path is untouched. Verified in the browser: preflight passes on a fresh token, POST /contributions returns 401, and you get a red "Contribution was not saved / Authentication required." with the form still editable, no reauth path, no retry — and the draft exists only in memory.

That's strictly worse than the preflight case you just fixed. There, the form was empty. Here, an author has written lore for fifteen-plus minutes, and the only sign-in affordance available (site-header.tsx:30, /login, no next) throws the draft away.

Calling refreshSession() and retrying once covers both paths.

Should-fix

404 is still mapped to the transient state. /worlds/w404/contribute gives "Could not check contribution access / World not found." with a Retry that can never succeed. Same class as the 401 bug, one status along — a permanent error wearing a transient error's UI. Treat it as terminal.

The banner sentence is broken by the label swap. ?type=characters → "The requested Characters is not supported yet"; unknown → "The requested requested category is not supported yet", with the word duplicated. The prose at :303-305 still expects a bare slug.

The no-token denied branch (:118-122) is unreachable — the sibling effect at :107-111 calls router.replace('/login') first, and without next. So ?next= now exists in one of the two sign-in paths and is honoured in neither.

One to be aware of before you wire any auto-refresh: :361 unmounts the whole form subtree whenever status ≠ allowed. Any accessToken change re-enters checking, TipTap remounts with initialContent='' while the parent keeps the old content state, and onJsonChange doesn't fire on mount — so the editor would look empty with stale state behind it. Not reachable today, but it makes the refresh fix above unsafe unless the subtree survives.

Merge sequencing — unchanged, and slightly worse

I ran the trial merges rather than comparing filenames. pr20 ← pr18 still conflicts on contribute/page.tsx, and b74ede77 edited that file inside the conflicting hunk, so the conflict is now slightly larger than at 61f7bf4. And #18 is still CONFLICTING against its own base, so the rebase remains gated on fixing #18 first.

Coverage

apps/web has zero test files and no test script, so nothing here is covered — and ci.yml has no test step, so even the API suite can't gate a merge. For the record I ran the API suite: 99 pass / 8 fail, all pre-existing refreshSession-stub failures in contributions.test.ts, not yours.

Fix A and B — realistically one change, refreshSession() on 401 in both places — and this is done.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

@mcfarhat Pushed b971fe7.

Addressed the latest contribution editor blockers:

  • 401s during contribution access preflight now silently refresh the session and retry once instead of sending the user to a dead-end login flow.
  • 401s during save/update/submit now also refresh and retry once, so an expired 15-minute access token does not strand in-progress draft work.
  • The form stays mounted during token-refresh access checks, so TipTap does not remount empty while parent state still holds old content.
  • 404 preflight failures are now terminal instead of retryable.
  • The no-token state shows the sign-in affordance.
  • Fixed the unsupported-category banner sentence after the label swap.

Validation:

  • GitHub Quality ✅
  • Vercel Preview ✅
  • Web typecheck ✅
  • Web lint ✅
  • Web build ✅

@mcfarhat mcfarhat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KareemN1936 reviewed b971fe7 with two passes, both driving a real browser against a stub API with the same auth semantics as ours (rotating refresh cookie, reuse detection, rotation invalidating prior access tokens).

The happy path works, and you covered more than your comment claimed. runWithAuthRetry wraps create, update and submit — not just the preflight — and the retry is correctly bounded to one attempt: with a permanently-401ing POST and a healthy refresh, the stub saw create:2, refresh:2 then stopped cleanly. The /login dead end is genuinely gone: the provider now clears token and user, so it renders the real Keychain form instead of "Signed in as @kareem". 404 is terminal now, and the banner sentence is fixed.

First, a correction to my own last review. I predicted the failure mode as "TipTap remounts empty while the parent's content keeps the old document" — a silent saves-invisible-content hazard. That was wrong. Measured on a 4 KB draft: title and draft survive, and the parent's content is also reset to the empty document. So there's no state mismatch to find. I flag this because it matters for how you check it: looking for the mismatch I described and not finding one would suggest the area is fine. It isn't — the body is simply gone.

Blocker — this commit turns a recoverable error into destroyed work

Run against your parent commit b74ede7 with identical inputs:

b74ede7:  editorPresent = true
          editorText   = "DELTA-UNSAVED-WORK-30-MINUTES-OF-WRITING"
          alert        = "Contribution was not saved / Authentication required."

b971fe7:  EDITOR_REMOVED  html:"DELTA-UNSAVED-WORK-30-MINUTES-OF-WRITING"
          server persisted: []
          available actions: "Sign in" (navigates away), "Back to World"

The chain: save 401 → runWithAuthRetry calls refreshSession() → it throws → auth-session-provider.tsx:46-49 clears accessToken on any refresh error → the effect re-runs → !accessToken sets reauth (:112-116) → :408 unmounts the whole subtree. The old build kept the text on screen so a copy-paste rescue existed. Now there's no Retry, and the only button navigates away.

And it fires on a transient blip with a perfectly valid session. Aborting the /auth/refresh request at the network layer gives "Contribution was not saved / Failed to fetch", editor unmounted, draft gone — then reloading works fine, proving the session was never invalid. refreshSession treats network errors, 429s and 500s as auth failures and clears the session.

Your :121-123 guard (currentStatus === 'allowed' ? currentStatus : 'checking') does work — but only on the success leg, which is exactly the case a reviewer would test. Every failure leg still unmounts.

Fix direction: once status has been allowed, render the reauth/error card above a still-mounted form rather than replacing it, and don't re-run the preflight just because the token changed — key the effect on worldId + permissionRetryKey and read the token from a ref. There's also no beforeunload guard anywhere, so the "Back to World" links discard silently too.

Blocker — the refresh has no ceiling, and it can loop

I checked: there is no attempt counter, no backoff and no cap anywhere around the refresh. If the access-check 401s for a reason a refresh can't fix (revoked sid, JWT secret skew during a rolling deploy, clock skew), the effect and the preflight feed each other, because a successful refresh changes accessToken, which re-runs the effect:

t≈3s    refresh calls = 417    list calls = 827
t≈15s   refresh calls = 1749   list calls = 3423
UI: "Checking contribution access..." forever

~115 rotations/second from one tab. The only brake in production is the server's 40/min cap on /auth/refresh — and since the limiter is Prisma-backed, every iteration is DB writes. When the cap trips, 429 isn't 401, so refreshSession throws, and the user is logged out. The key is per-IP, so shared-NAT users take collateral damage.

Blocker — two tabs revoke the whole session family

Two contribute tabs with a stale token, both saving:

refresh(rt-12)  <- tab A
refresh(rt-12)  <- tab B, 3ms later
refresh_ok rt-12 -> rt-14
REUSE_DETECTED_FAMILY_REVOKED  family=fam-9
PERSISTED = []

Both tabs lose their drafts, nothing saves, and reloading doesn't recover — the family is dead and it needs a fresh Keychain sign-in. On the real server it's worse than the stub, because routes/auth.ts:309 also clears the browser cookie on the reuse path.

To be clear: the server is correct here. auth-sessions.ts:184-186 and the atomic updateMany claim are exactly right. The bug is that the client has no single-flight dedupe — runWithAuthRetry:244 and the effect's :145 both call refreshSession() freely. One shared in-flight promise in the provider fixes this, the loop above, and the stale-closure issue below, all at once.

Blocker — two rapid Submit clicks create two proposals

No double-submit protection at all. saveDraft (:249) has no isSaving guard, handleSave guards only isSubmitting, and a second click in the same task still reads isSubmitting === false because the disabled prop hasn't committed. Against a clean server:

action result
two rapid Save Draft c1:DRAFT, c2:DRAFT — c1 orphaned
Save + Submit together c1:DRAFT, c2:SUBMITTED
two rapid Submit Proposal c1:SUBMITTED, c2:SUBMITTED — two duplicate contributions in the canon-voting queue

That last one puts duplicates in front of voters. A ref-based guard at the top of saveDraft fixes all four.

The retry you added is mostly raced out by its own effect

Sequencing the authenticated GETs as [403, 200]: the retry inside the 401 catch received the 403, and the UI ignored it and rendered allowed. Because refreshSession() mutates provider state, the effect cleanup fires controller.abort() on the same controller the retry is using, so its result is dropped by the !controller.signal.aborted guards — recovery actually comes from the effect re-running on the new token. What the retry reliably contributes is a duplicate preflight GET per refresh and a silently swallowed 403. Also catch { } at :157-164 swallows everything and always reports "Your session expired", which is then overwritten by the re-run's message.

Should-fix

?next= is still inert — app/login/page.tsx is untouched, still return <Home />, and nothing in app/page.tsx reads useSearchParams or navigates after setSession. The recovery affordance the card advertises doesn't exist.

State machine: checking has no timeout and no escape (no AbortSignal.timeout on the preflight), so a hung API leaves it on screen indefinitely. denied is terminal with no Retry, and it's overloaded — a 404 sets it, so the card reads "Contribution access unavailable" over "World not found." reauth is terminal and over-triggered by any network/5xx/429.

Server error strings reach users verbatim — one tab displayed "Refresh token reuse detected.", a security-internal message, via error.body?.error.

The preflight still discards the draft it fetches (:131), so there's still no resume — and that's now the only thing that could recover what the unmount destroys. RichTextEditor's initialContent prop exists and is never passed by the form, and useEditor doesn't re-sync when it changes, so resume isn't wired even latently.

Clean, and worth saying

Server-side validation is solid — content is properly validated (100 KB cap, doc root, array children) rather than trusted, targetLoreEntryId is verified against the world, bodies are .strict(), and submit re-validates. I looked specifically for client data being trusted where the server should check, and found none.

Merge sequencing

Unchanged: contribute/page.tsx still conflicts whole-file with #18, and #18 is still CONFLICTING against its own base, so the rebase stays gated on fixing #18.


The single highest-value change is keeping the form mounted once it's been allowed. That alone converts three of these blockers from data loss into a visible banner.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

@mcfarhat Pushed eec8785, then merged the latest base in c6642e2 to clear the PR conflict.

Addressed the latest auth-recovery blockers:

  • The contribution form now stays mounted once access has been allowed, so failed refresh/reauth states show a banner above the editor instead of destroying in-progress writing.
  • Refresh failures from transient/network/server errors no longer clear the session.
  • Refresh calls are single-flight and cross-tab coordinated with a browser lock + session broadcast to avoid refresh-token reuse when multiple tabs recover at once.
  • Preflight no longer loops on token changes; it is keyed by world/retry and reads the current token from a ref.
  • Save/submit now has a synchronous in-flight guard to prevent rapid duplicate drafts/proposals.
  • Added a beforeunload guard for unsaved contribution work.

Validation:

  • GitHub Quality ✅
  • Vercel Preview ✅
  • PR is clean/mergeable ✅
  • Web typecheck ✅
  • Web lint ✅
  • Web build ✅
  • API typecheck/build passed after merging latest base ✅

@mcfarhat
mcfarhat changed the base branch from m2-worlds-frontend-integration-kareem to develop August 11, 2026 10:27

@mcfarhat mcfarhat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at c6642e2 (the merge commit; eec8785 is its second parent). Two independent passes, both driving the real Next 15 app in headless Chrome against a fault-injecting API — real typing into TipTap, real cookie jar, real BroadcastChannel, real Web Locks. Then every guard was deleted to confirm the failure reappears.

All five fixes hold. And the mutation testing turned up something worth telling you, because it means you're crediting the wrong mechanism in two places:

  • Draft survival is defence in depth, and the guard you lead with isn't what does it. Removing only the sticky render guard at :408 → draft still survived. Reverting only the effect (putting accessToken back in the deps at :217, dropping the hasAllowedAccessRef short-circuit at :127-129) → draft still survived. Reverting both → draft lost, original bug reproduced exactly. The load-bearing fix is the effect no longer re-running on token change.
  • Cross-tab: the Web Lock is what saves you, not the broadcast. Removing the lock (auth-session-provider.tsx:82-86) → reuse detected, family revoked, a session cleared. Removing the broadcast (:91-94) → no behavioural change at all. The broadcast is a UX nicety; the lock plus the shared cookie jar is the actual protection.
  • The double-submit guard specifically requires a synchronous ref. Deleting operationInFlightRef → 2 contributions and 2 submits, reproducing the duplicate proposals. Replacing it with an isSaving/isSubmitting state check → also 2 contributions. Good instinct; worth a comment in the code saying why state won't do.

Refresh storm: measured over 15s under persistent failure it's now 1–3 requests where it was 417 in 3s. Reverting the effect deps reproduces the storm at 41 refreshes in 8s and trips the 40/min cap, so that fix is doing real work. Double-submit: 2 clicks, 10 clicks, 30ms and 120ms spacing, Save+Submit racing — every case produced exactly 1 contribution.

Three things to fix, and they're all at the edges of the new mechanisms rather than in the old bug.


1. BLOCKER — one stalled tab freezes every other tab, permanently

navigator.locks.request(AUTH_REFRESH_LOCK, cb) is called with no signal, no timeout, no ifAvailable (auth-session-provider.tsx:82-86), and lib/api/client.ts:82 does a bare fetch with no timeout anywhere in the stack. A refresh that never settles holds the lock forever.

Reproduced: tab A loaded with /auth/refresh hanging → navigator.locks.query() shows held: ["hivelore-auth-refresh"]. Made the API healthy, opened tab B → tab B sat on "Checking contribution access…" for the entire observation window, and the API logged only 1 refresh call total, proving tab B never reached the network. It's blocked purely on the lock. Because isSessionLoading gates on that promise, the whole app is dead in tab B — and if that's the tab with the editor, the form never mounts, so you can't even start typing.

A crashed tab is fine (Web Locks release on context death). The failure mode is a live-but-stalled tab: server hang, load balancer holding the connection, mobile dead-air. This is new in this PR — the pre-fix code had no lock at all.

Fix: AbortSignal.timeout() on the lock request plus a fetch timeout, and fall through to an unlocked refresh on abort.

2. BLOCKER — a terminal refresh failure is classified as transient, so the user retries forever

Your classifier clears the session only on ApiError && status === 401 (:97-107). Reuse and invalid-token both return 401, so that part is right. But apps/api/src/routes/auth.ts:286-290 returns 403 "Request origin is not allowed." whenever the refresh cookie is used and Origin doesn't exactly equal env.CORS_ORIGIN — a single exact match, no list. That's terminal: retrying can never succeed.

Reproduced with contributions 401 + refresh 403 — three consecutive Save clicks, each POST /contributions → 401 → POST /auth/refresh → 403, each showing "Could not refresh your session. Your draft is still on this page; try again before leaving." Save stayed enabled, no sign-in link ever appeared, session never cleared.

This repo deploys to Vercel, so the realistic triggers are everyday: preview deployments on generated URLs, www vs apex, a domain alias, a proxy rewriting Origin, or a stale CORS_ORIGIN. Every one becomes a permanent "try again" loop instead of "sign in again". Treat 403 from /auth/refresh as terminal.

3. BLOCKER-ish — the draft is no longer wiped, but it's now stranded with no way out

Both passes hit this independently. After a real session death mid-writing: form stays mounted, title and body intact — the fix works. But canSave requires accessToken (:244-256), which the provider just nulled, so Save Draft and Submit Proposal are both permanently disabled. And permissionStatus is still 'allowed', so the reauth card carrying the ?next= sign-in link (:438-487) never renders. The only in-form action left is "Back to World", which navigates away and destroys the work.

So the UI instructs an action it doesn't offer. Your cross-tab work does provide a real rescue path — signing in in a new tab broadcasts session-refreshed, Save re-enables, and the draft saves — but nothing tells the user that's the move. Rendering the sign-in affordance in this state, with target="_blank", closes it almost for free.


Not blocking

  • There's no actual refresh ceiling — no attempt counter, no backoff. The storm is gone because there's one refresh per trigger, but the remaining trigger is the user: 60 Retry clicks over 2.4s produced 81 refresh requests, 41 rate-limited, enough to trip the server cap and log them out. Roughly 20 clicks/minute does it, and mashing Retry is the natural response to an error banner.
  • beforeunload nags after a successful save. :219-236 computes hasUnsavedWork from content presence, not dirtiness against the last saved snapshot, and nothing resets it after save. Verified: typed → saved successfully → reload → warning fires. Training users to click through the prompt makes the prompt worthless when it matters.
  • Client-side navigation still destroys the draft silently. Typed a draft, clicked the form's own "Back to World" link: no dialog (App Router Link doesn't fire beforeunload), went back, form empty. Same for the header "Sign in" link — the exact link the error in #3 tells people to use.
  • Save+Submit in the same tick silently swallows the Submit. Verified: 1 contribution created, 0 submit calls, status stays DRAFT, no error shown — the guard return nulls with no feedback, so the user believes they submitted.
  • A saved draft isn't resumable. After reload the saved draft isn't loaded back and the next save issues another create, so each page load orphans a duplicate. listContributions at :153 is used purely as a permission probe and its results discarded. Combined with the beforeunload nag, this is a trap.
  • The broadcast applies an inbound session without checking it's the same user. If another account signs in elsewhere in the browser, this tab silently re-arms with the new identity and the next Save creates the draft under their account (the server sets authorId from the token). The cookie is per-browser so the replacement is inherent — but the form staying armed isn't. Compare user ids on inbound broadcast and freeze the form if they differ.

Two process items

On rebase, drop the 12 apps/api files — I compared the copies rather than assuming: yours add no exported symbols Perla's lack, the difference is an inlined WorldWithRelations type and a publicWorldInclude const. But two things there are uniquely yours and must survive: the two publishedAt: new Date() lines in prisma/seed.ts, and the migration 20260810133000_publish_initial_world_bibles.

Your commit fe6e038 deletes apps/web/src/lib/api/session.ts, which #21 still imports in four components — and git applies that delete with no conflict, so merging the two produces a guaranteed build break that shows nothing in the merge output. I've asked Kareem to migrate those importers on #21; flagging it here so whichever lands second doesn't get blamed for it.

Measured merge order across every combination is #22 → #21 → #20, so this one lands last — which is fine, since the three blockers above are all small and localised.

One ask for the next round: apps/web has no test script at all. This PR adds 928 lines of async auth-failure logic and every behaviour above had to be verified by hand in a browser. Even a couple of tests around the in-flight guard and the transient/terminal classifier would pay for themselves immediately.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Pushed a0930bc for the auth/session review items.

Covered:

  • Added bounded auth refresh behavior: Web Lock wait timeout, refresh fetch timeout, and unlocked fallback if lock acquisition aborts.
  • Treat refresh 403 as terminal alongside 401, clearing the local session instead of retrying forever.
  • Added a sign-in-in-new-tab recovery affordance when the editor had already been allowed but the session disappears, so in-page draft content is not stranded.
  • Existing reauth sign-in link now also opens in a new tab.

Verified locally:

  • npm run typecheck --workspace=@hivelore/web
  • npm run lint --workspace=@hivelore/web
  • npm run build --workspace=@hivelore/web

@mcfarhat mcfarhat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at a0930bc6. Real production bundle (next build + next start) driven in headless Chrome against a fault-injecting API, with c6642e2 built and served alongside as a baseline so I could prove the harness reproduces the original three before trusting it on the fixes.

Blockers 2 and 3 — properly closed

403 is now terminal, and it didn't over-correct. Save with write 401 → refresh 403, three clicks:

baseline c6642e2 : 3 refreshes, 3 create attempts, "try again" each time,
                   Save/Submit still enabled, no sign-in affordance   <- the unwinnable loop
head a0930bc6    : click 1 = 1 refresh + 1 create; clicks 2 and 3 = ZERO requests
                   Author -> "Sign in required", recovery card shown, draft intact

And the important half — it didn't swing too far. refresh → 429, 500 and 503 each keep the session alive, Save and Submit stay enabled, one refresh per click, no recovery card, no sign-out. That's exactly right: 403 from /auth/refresh can only be the origin check (apps/api/src/routes/auth.ts:286-289), so treating it as terminal is correct, and everything else stays transient.

The stranded draft is fixed and the rescue works end to end. Baseline: the "sign in again" element is absent, Save disabled, draft on screen — the state that instructed an action it didn't offer. Head: element present and laid out (718×24, visible), linking to /login?next=%2Fworlds%2FW1%2Fcontribute with target=_blank. Signing in in another tab applied the broadcast in 106 ms, the card disappeared, Save re-enabled, and a subsequent save created exactly 1 contribution with the draft text byte-identical.

All five original fixes still hold — I re-verified rather than assumed. Draft survives four separate failure legs with title, summary and body intact; transient failures preserve the session; no storm (2 refreshes in a 15s window under a 401ing preflight); the Web Lock still prevents family revocation; and 10 rapid Submit clicks still produce exactly 1 contribution — with a rebuilt mutant swapping operationInFlightRef for isSaving || isSubmitting state producing 10 creates and 10 submits, so the synchronous ref is confirmed load-bearing.

Blocker 1 — half closed

The permanent freeze is genuinely gone, and that's real progress:

baseline : tab B stuck 40s, API logged 1 refresh call  (never reached the network)
head     : tab B clears in  6,857 ms -> usable editor        (API healthy)
           tab B clears in 17,641 ms -> "Sign in again" card (API dead)

But the fallback that's supposed to make lock contention harmless is dead code.

createTimeoutSignal prefers AbortSignal.timeout(ms) when available — which is every modern browser. That aborts with TimeoutError. isAbortError (auth-session-provider.tsx:48-50) tests error.name === 'AbortError'. It never matches, so return performRefresh() at :107 never executes and the catch rethrows.

I checked this rather than assumed, because the AbortController branch further down would produce AbortError — but that branch only runs on browsers without AbortSignal.timeout, i.e. effectively never:

AbortSignal.timeout(10) -> reason.name = "TimeoutError"   isAbortError -> false
controller.abort()      -> reason.name = "AbortError"     isAbortError -> true   (legacy path only)

Measured consequence, 2 of 2 runs, 10,178 ms each: with a squatter holding hivelore-auth-refresh, a healthy API and a valid session, the tab waits 10.2s, makes zero network refresh calls, and lands on "Sign in again." So the fix bounds the freeze by falsely signing out a user whose session was fine.

Please don't just widen the check, though — there's a sharp edge behind it. apps/api/src/lib/auth-sessions.ts:184-185 revokes the whole session family on refresh-token reuse, and right now the lock is the only thing preventing concurrent redemptions. If TimeoutError simply falls through to performRefresh(), a timed-out tab refreshes unlocked and concurrently with the lock holder — which is precisely the family-revocation bug fix #4 was written to prevent. The fallback needs to re-check latestSessionRef (or decline to redeem) rather than just stop throwing.

Related measurement worth having: the lock serializes, it doesn't dedupe. Two simultaneous tabs → 2 refresh calls (t=529ms, t=937ms, strictly non-overlapping so the second carries the rotated cookie); six simultaneous tabs → only 2, because later waiters receive the broadcast in time. The latestSessionRef short-circuit loses the race because the lock releases before postMessage fires (:114-120). Harmless today, but it means safety rests entirely on serialization — which is the same thing the fallback would undermine.

Second half of blocker 1: the timeout is only on /auth/refresh. lib/api/client.ts still has no default — it only forwards a caller-supplied signal (:74-76). Executed:

  • preflight GET /worlds/:id/contributions hangs → "Checking contribution access…" still spinning at 45,000 ms
  • POST create hangs after Save → button stuck aria-busy="true", disabled, no error banner, still stuck at 45,000 ms

My original wording was "no timeout anywhere in the stack", and that's still true for every call except refresh. A default timeout in request() covers all of it at once.

Non-blocking items — none were addressed

Not asking for them now, but so they're not lost:

  • No attempt ceiling. 60 Retry clicks under preflight 401 → refresh 429 produced 120 requests, all rate-limited, Retry still enabled, no cooldown and no message. (Was 81/41 — same class, higher count.)
  • beforeunload nags after a successful save. Dispatched a cancelable event: defaultPrevented is true while dirty and still true after a successful save.
  • Client-side navigation destroys the draft — the in-app "Back to World" <Link> (:660-665) navigates with no dialog and the draft is gone.
  • Save+Submit in one tick silently swallows the Submit — 1 create, 0 submits, badge stays DRAFT, and no message of any kind.
  • The broadcast applies any inbound session. Posted a foreign session (user-999 / someoneelse / platformRole: ADMIN) from another same-origin tab and the editor's Author flipped @kareem → @someoneelse with zero network calls.

Gates

typecheck 0, lint 0, next build 0, API tests 107/107 pass (up from develop's 89). All green.

apps/web still has no test script — its package.json scripts block is byte-identical to develop's and there are zero test files under apps/web/src. Every behaviour in this review had to be established by hand in a browser, twice now. That's the thing I'd most like to see before the internship ends: even a handful of tests around the in-flight guard, the terminal/transient classifier and the lock fallback would have caught the TimeoutError mismatch immediately.

Two fixes and this is done — the TimeoutError handling (with the revocation interaction thought through) and a default timeout in request().

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Pushed 0efbf64 for the latest #20 review gates.

Covered:

  • Lock acquisition timeouts now handle modern TimeoutError, wait briefly for a cross-tab refreshed-session broadcast, and fall back only to the existing in-memory session. They no longer redeem the refresh token outside the Web Lock, so the refresh-token family revocation invariant stays intact.
  • Non-terminal refresh failures during provider boot no longer clear the in-memory session.
  • Added a default 30s timeout to the shared web API client while preserving caller-provided abort signals, so hung preflight/save/submit calls are bounded.

Validation:

  • npm run typecheck --workspace=@hivelore/web
  • npm run lint --workspace=@hivelore/web
  • npm run build --workspace=@hivelore/web

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Merged latest develop after #21 landed and pushed e332416 on top of the auth/request timeout fix.

Conflict choices:

Validation after the merge:

  • npm run typecheck --workspace=@hivelore/web
  • npm run lint --workspace=@hivelore/web
  • npm run build --workspace=@hivelore/web
  • npm run lint --workspace=@hivelore/api
  • npm run typecheck --workspace=@hivelore/api with auth/env test values
  • npm test --workspace=@hivelore/api with auth/env test values: 123/123 passing
  • npm run build --workspace=@hivelore/api with auth/env test values

@KareemN1936
KareemN1936 force-pushed the feature/m2-contribution-editor-frontend-kareem branch from e332416 to 7125b9a Compare August 11, 2026 16:11
@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Final pushed head is 7125b9a.

Remote checks are green now:

  • Quality ✅
  • Vercel Preview ✅

The PR is clean/mergeable against develop after #21 was merged. Ready for re-review.

@KareemN1936
KareemN1936 requested a review from mcfarhat August 11, 2026 16:14
@KareemN1936
KareemN1936 force-pushed the feature/m2-contribution-editor-frontend-kareem branch from 7125b9a to 0efbf64 Compare August 11, 2026 16:18
@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Process correction: I reset this branch back to 0efbf64 so #20 no longer includes the post-#21 merge commit or any #21 code.

Reason: Kareem clarified that approved work should be merged by the reviewer/DR, not by me. I opened revert PR #24 to undo my #21 merge through the normal PR process.

This PR is back to the contribution-editor/auth request timeout work only.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Pushed 798ce9b to apply the Prettier formatting fix directly on #20 after resetting it away from the #21 merge.

#20 remains scoped to contribution editor/auth request timeout work only.

@mcfarhat mcfarhat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 798ce9b9. Real production build in headless Chrome against a fault-injecting API, with develop built alongside as a baseline.

Part B — the default request timeout: CLOSED

DEFAULT_REQUEST_TIMEOUT_MS = 30_000 in client.ts:13, applied inside request() so it covers every verb:

hung preflight GET : 45,000 unbounded -> 30,056 ms, then "Could not check contribution access" + Retry
hung POST save     : 45,000 unbounded -> 30,041 ms, error banner, Save re-enabled, draft preserved

That's properly fixed. Two small things: the raw DOMException text "signal timed out" is rendered verbatim to users in both banners, and 30s is a long time to stare at a spinner in an editor — 10–15s would be kinder.

And the five original fixes all still hold, re-verified: draft survives every failure leg, transient failures keep the session with Save and Submit enabled, one refresh call in a 15s window, two tabs on the healthy path make one server refresh, and 10 rapid Submits still produce exactly one contribution — with the mutation (sync ref → isSaving state) giving 10/10/10, so the ref is confirmed load-bearing again.


🔴 The request timeout introduces whole-family session revocation. develop does not have this.

This is the one to fix, and it's more severe than anything this PR set out to solve. One tab. No lock contention.

1. refresh takes 12s; client aborts at 10s   -> "Sign in again" at 10,910 ms
2. server completes the rotation anyway      -> "ok"; browser never receives the rotated cookie
3. next page load replays the old token      -> rotatedAt set -> RefreshTokenReuseError
                                             -> revokeSessionFamily()

measured: load 2 settled 111 ms, server log ["ok","REUSE_FAMILY_REVOKED"],
          reuseDetected=1, familyRevoked=true

develop, identical scenario: ["ok","ok"], reuseDetected=0, familyRevoked=false

I verified the mechanism in the source rather than taking the measurement on trust:

PR      auth-session-provider.tsx:99   signal: createTimeoutSignal(AUTH_REFRESH_REQUEST_TIMEOUT_MS)  // 10s
develop                                 0 occurrences of that constant
server  auth-sessions.ts:184            if (session.rotatedAt || session.revokedAt) {
                                          await revokeSessionFamily(...); throw RefreshTokenReuseError }

develop has no client-side abort, so it waits and receives the rotated cookie. Your 10s abort creates a window where the server has rotated but the client hasn't heard — and the server treats the next use of that token as theft.

The previous freeze was one tab stuck for 10.2s with a false sign-out. This is permanent server-side revocation of the whole family, across every tab and device, triggered by one slow refresh. Worth being clear that this constant predates your two newest commits — it just had never been measured.

Whatever you do about the timeout, the redemption itself needs to be atomic from the client's point of view: either don't abort the refresh request at all (accept the wait, as develop does), or make an aborted refresh not leave a rotated-but-unreceived token — e.g. have the server only mark rotatedAt once the response is acknowledged, or give the client a way to re-fetch the current token for its family rather than replaying the old one.

🟠 Part A — the fallback fires now, but your scenario is unchanged

isAbortError matches TimeoutError (:49-53), and I can see the fallback running from the timing — every lock-timeout path now settles ~750ms later, which is exactly AUTH_REFRESH_BROADCAST_WAIT_MS. But:

squatter on the lock + healthy API + valid session, 2/2 runs
                previous round      this round
settle time     10,178 ms           10,965 / 10,869 ms
refresh calls   0                   0
outcome         "Sign in again"     "Sign in again"
usable editor   no                  no

The fallback was changed from performRefresh() to waitForBroadcastedSession(), and on a cold mount latestSessionRef.current is null — only setSession populates it — so both branches at :106-118 fail and :120 throws. Net effect: 750ms more freeze, same false sign-out.

On the warm path it's arguably worse: it returns latestSessionRef.current where the access token equals the one that just 401'd, so the caller retries with the same stale token (measured createCalls=2, 0 refresh calls, save failed with the original error). It "recovers" to a token that cannot work.

To answer my own earlier question: it neither re-checks-then-redeems nor falls through — it declines to redeem entirely. That does keep the lock invariant safe (0 refresh calls, no reuse, no revocation under contention), so the caution was heeded. It just doesn't recover the user.

Also, :106-112 and :113-118 are logically identical — !== and === both return latestSessionRef.current. The whole thing reduces to if (ref) return ref.

A correction I owe you from last round

I told you the effect-dependency change was what saves the draft and the render guard wasn't. That was my sloppy compression of "either alone suffices". This round's mutation is cleaner: re-adding accessToken to the deps at :217 did not destroy the draft — the render guard at :408 covers it. So the honest split is the dep change is load-bearing for the refresh count, the render guard for draft survival. Both are doing work, on different problems. Sorry for the misleading steer.

Merge state

CONFLICTING with develop, 4 files — because develop has moved (#21 landed, was reverted, then restored):

  • auth-session-provider.tsx — add/add, 5 hunks. develop independently implemented the Web Lock, without timeouts and with a module-level refreshAuthSessionOnce. Genuine semantic overlap.
  • contribute/page.tsx — whole-file, and it's a product question rather than a merge. develop renders LoreContributeClient; this PR renders ContributionEditorForm. Mohammad's ruling was to follow the spec: §8.4 is one editor with an entry-type selector and a Submit Proposal button, and Journey B has a character going through the vote. So the per-type fields and relationship picker should feed the contribution/proposal flow rather than creating a lore entry directly. That's the shape to resolve toward.
  • worlds/[worldId]/page.tsx and worlds.test.ts — one hunk each.

Still unaddressed, still non-blocking

No attempt ceiling (60 Retry clicks → 60 requests), beforeunload firing after a successful save, in-app <Link> navigation destroying the draft with no dialog, Save+Submit in one tick silently swallowing the Submit, and the broadcast applying a session for a different user (posting one for "mallory" flipped the author from @kareem).

The ask I keep making

apps/web still has no test script — fourth round where every one of these behaviours had to be established by hand in a browser. All five original fixes and both new items are cheaply testable. Leen landed a CI build gate on hive-freelance today that goes red for all seven workspaces; worth a look at what she did, because this file is the strongest argument in the org for having one.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Changes requested on latest head 798ce9b9.

The green gates hold locally:

  • npm run lint passes.
  • npm run typecheck --workspace=@hivelore/web passes.
  • npm test --workspace=@hivelore/api passes with test env (107/107).
  • npm run build passes with test env.

Still blocking:

  1. apps/web/src/providers/auth-session-provider.tsx still aborts /auth/refresh after 10s via AUTH_REFRESH_REQUEST_TIMEOUT_MS. That is dangerous for rotating refresh tokens: if the server completes the rotation after the browser aborts, the client never receives the new cookie and the next refresh can replay the old token. The server treats replay as reuse and revokes the whole session family. Please remove the client abort from refresh-token redemption, or change the refresh protocol so an aborted response cannot leave the client with a rotated-but-unreceived token.

  2. The Web Lock timeout fallback now catches TimeoutError, but it still does not recover the cold-mount case. waitForBroadcastedSession() waits 750ms, then throws when latestSessionRef.current is null; on the warm path it can return the same access token that just failed. Please either wait for a genuinely newer broadcasted session or surface a non-destructive retry/sign-in state without pretending refresh succeeded.

  3. This branch is conflicted with current origin/develop. git merge-tree $(git merge-base origin/develop HEAD) origin/develop HEAD shows real conflicts in apps/web/src/providers/auth-session-provider.tsx and apps/web/src/app/worlds/[worldId]/contribute/page.tsx (plus test-file overlap). The contribute route needs a product-level resolution toward the contribution/proposal editor, not just a mechanical conflict pick.

Non-blocking but worth tightening while you are in here: timeout errors currently surface raw text like signal timed out, and the default 30s API timeout works but is a long spinner for an editor interaction.

I tried to submit this as a formal Request Changes review, but GitHub rejected it because the authenticated CLI user is the PR author.

@mcfarhat

Copy link
Copy Markdown
Contributor

@KareemN1936 your refresh-abort finding is correct, and Mohammad has ruled on the fix: a grace window. Thank you for raising it — and for being straight about why it came through as a comment rather than a review.

I verified the mechanism end to end rather than take it on trust:

apps/web/src/providers/auth-session-provider.tsx:25   AUTH_REFRESH_REQUEST_TIMEOUT_MS = 10_000
apps/web/src/providers/auth-session-provider.tsx:99   signal: createTimeoutSignal(...)
apps/web/src/providers/auth-session-provider.tsx:45   window.setTimeout(() => controller.abort(), ms)

apps/api/src/lib/auth-sessions.ts:184   if (session.rotatedAt || session.revokedAt) {
apps/api/src/lib/auth-sessions.ts:185     await revokeSessionFamily(...)     // every device
apps/api/src/lib/auth-sessions.ts:186     throw new RefreshTokenReuseError()

So the chain is exactly as you described: abort at 10s → the server may already have rotated → the client keeps the old token → the next refresh presents a rotated token → indistinguishable from replay → the whole session family is revoked. A slow mobile connection becomes a sign-out on every device. There's a second path at :217 as well — if the atomic rotation claim doesn't affect exactly one row, same outcome.

The reason this is a good catch rather than an obvious one: aborting a request is normally the defensive choice. It's unsafe here only because refresh-token redemption is single-use and stateful server-side, so abandoning it isn't free — and the server has no way to tell an abandoned redemption from a stolen token.

The decision: grace window on rotation

The fix goes server-side, in auth-sessions.ts, so it protects every client rather than just the web app:

  • When a rotated token is presented, don't immediately treat it as reuse. If the rotation happened within a short grace window and its successor has not yet been used, return the existing successor instead of revoking.
  • Revoke the family only when the rotated token arrives after the window, or when the successor has already been redeemed by someone else — that second case is the genuine theft signal and it stays fully intact.

That keeps reuse detection meaningful while making an aborted or lost response survivable. Recording the successor id on the rotated row is the usual way to implement it.

This is backend work and it isn't yours — I'm carding it separately rather than parking your PR behind it.

What I'd like in #20

Since the grace window won't exist yet when this merges, don't ship the hazard in the meantime: remove the abort from the refresh-token redemption path specifically (:99). Keep AUTH_REFRESH_LOCK_TIMEOUT_MS at :131 — bounding the Web Lock wait is fine and unrelated, since acquiring a lock isn't a stateful server operation.

If you'd rather keep a ceiling on that request, make it long enough that it can't fire before the server has realistically finished (and add a comment saying why the number exists), but removing it is simpler and strictly safer until the grace window lands.

Your other two items from 11:00 stand: the cold-mount case in waitForBroadcastedSession(), and the conflict with develop — which I agree needs a product resolution on the contribute route rather than a mechanical conflict pick.

@mcfarhat

Copy link
Copy Markdown
Contributor

@KareemN1936 a heads-up on the conflict state, because it has moved since you reported it and you'd have no way of knowing that from your side.

You flagged this yourself at 11:01 yesterday, naming auth-session-provider.tsx and contribute/page.tsx — so you were right and ahead of me on it. But develop has advanced since (the #21 reapply via #25, plus the docs change in #23), and the picture now is:

branch vs develop:  21 behind, 15 ahead
conflicting paths:
  apps/api/src/routes/contributions.test.ts
  apps/api/src/routes/worlds.test.ts
  apps/web/src/app/worlds/...

GitHub reports mergeable: CONFLICTING.

Two things worth knowing from that:

The conflict has shifted from source files to test files. contributions.test.ts and worlds.test.ts now conflict, which they didn't when you looked. That's usually a sign two people fixed the same test fixture independently — and it's the easier kind of conflict to resolve, because you can run the suite afterwards to check you picked correctly.

Twenty-one commits behind is the more useful number. Rather than resolving conflicts against a snapshot, merge develop in first and then resolve once. Doing it the other way round tends to mean resolving the same file twice.

And the contribute-page conflict is still not yours to guess at. You said it needs a product-level resolution toward the contribution/proposal editor rather than a mechanical pick, and I agree — don't resolve that one by choosing a side. If it's still conflicting after you merge develop, tell me what the two versions want to do and I'll make the call.

Nothing else changes on this PR. Your three items from yesterday stand, and the refresh-abort one has an answer now: Mohammad's decision is a grace window on rotation, implemented server-side, carded separately at https://trello.com/c/TUQMVcP2 so it isn't yours. The interim ask on this PR is unchanged — drop the abort from the refresh-redemption path specifically (:99), keep AUTH_REFRESH_LOCK_TIMEOUT_MS at :131 since bounding a lock wait is fine.

@KareemN1936
KareemN1936 force-pushed the feature/m2-contribution-editor-frontend-kareem branch from 798ce9b to 82ee56d Compare August 14, 2026 23:44
@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Pushed the rebase/review follow-up in 82ee56d.

Covered:

  • Rebasing the contribution editor branch onto current develop.
  • Resolved the /contribute conflict toward the proposal-backed ContributionEditorForm flow, keeping targetLoreEntryId instead of overloading entryId.
  • Removed the client-side abort around /auth/refresh so a slow refresh cannot rotate the server token without the browser receiving the cookie.
  • Kept the lock-timeout path from accepting the same stale access token as a recovery.
  • Preserved the initial-world-bible publishedAt seed/migration pieces called out in review while dropping the unrelated copied API/world deltas.

Verified locally:

  • npm run typecheck --workspace=@hivelore/web
  • npm run lint --workspace=@hivelore/web
  • npm run build --workspace=@hivelore/web

@mcfarhat mcfarhat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested at 82ee56d7 — two changes close this

First, the thing I warned you about: you heeded it. Zero files deleted. Verified four independent ways, all empty — --diff-filter=D, a scoped diff over components/lore/, a net-line-loss check, and a tree comparison. All five files under apps/web/src/components/lore/ are byte-identical to develop. I mutation-tested those checks (deleted a lore file, gutted another) and all four went red, so they're sensitive rather than vacuously passing.

The branch is also genuinely rebased — 0 behind, 12 ahead, git merge origin/develop says "Already up to date". Every gate is green on both sides: lint, typecheck, build, format, and 122/122 API tests matching develop exactly.

But the lore authoring UI is dead anyway, and no file-level check catches it

You didn't move the lore editor to /lore/edit — you overwrote contribute/page.tsx with yours. So LoreContributeClient now has zero importers, and LoreEntryForm is imported only by it. That's 686 lines of the lore feature preserved on disk and unreachable from any route. next build and eslint are both perfectly happy, which is exactly why it slipped past.

Same URL, both real production builds:

develop     /worlds/W1/contribute?type=characters
  -> "Create lore entity ... Entity title / Entity type /
      Character, City/Kingdom, Faction, Quest, Historical Event, Story"

this PR     same URL
  -> "Contribution type not supported yet"

A warning if you spot-check this: curl /worlds/W1/lore/edit returns 200, not 404 — the dynamic [entryId] segment swallows the literal "edit" and renders the entry-detail page for a nonexistent id. The route genuinely does not exist (it's absent from the build route table), but a status-code check would tell you otherwise.

The four links are all still pointing at /contribute

I drove each one:

  • lore-entity-tabs.tsx:210 "Add {category}" → ?type=characters → "Contribution type not supported yet"
  • lore-entry-detail-client.tsx:187 "Extend this lore" → ?type=CHARACTER → same wall
  • lore-entity-tabs.tsx:188 "Edit" and lore-entry-detail-client.tsx:181 "Suggest edit" → ?entryId=X → silently dropped

That last pair is the nastiest. Your page reads targetLoreEntryId, not entryId. Proven from the RSC payload: with ?targetLoreEntryId=abc123 the prop is bound; with ?entryId=abc123 it is absent. So "Edit this entry" opens a blank, unbound new contribution — no error, no warning, and the user's edit target is silently lost.

Also: ?type=stories-contributions, the real category id for stories, also hits the unsupported wall. Only the literal ?type=stories/story works, so the single story link that functions is the one on the world hub.

auth-session-provider.tsx — this is the strongest work in the PR

You genuinely reconciled it rather than picking a side. Develop's Web Lock, its module-level refreshAuthSessionOnce singleton and the BroadcastChannel all survive, with your lock timeout and non-terminal-error handling layered on top. Every removed line is accounted for and intentional. You also fixed the identical-branches bug I flagged at :106-118 — waitForBroadcastedSession now really compares against startingAccessToken instead of both legs returning the same ref. And isTerminalRefreshError correctly narrows session clearing to 401/403, so a transient blip no longer signs people out.

But the commit titled "avoid aborting auth refresh redemption" doesn't

You removed AUTH_REFRESH_REQUEST_TIMEOUT_MS and its dedicated 10-second abort. But DEFAULT_REQUEST_TIMEOUT_MS = 30_000 (apps/web/src/lib/api/client.ts:13), which you added earlier in this same PR, is applied unconditionally to every request at client.ts:113-116 — including POST /auth/refresh. The redemption is still aborted, just at 30s instead of 10s.

Measured against a stub that holds /auth/refresh for 32s, real production builds in headless Chrome:

this PR : refresh received t=12.974s
          >>> CLIENT ABORTED at t=42.972s <<<   (exactly 29.998s)
          rotation COMMITTED
          could NOT deliver rotated cookie (BrokenPipeError)

develop : refresh received t=7.325s
          client still connected -> NO abort
          rotation COMMITTED, cookie delivered

Isolating mutation: I rebuilt your branch with that one constant raised to 120_000 and changed nothing else — the abort disappeared and the cookie was delivered. So the constant is the sole cause, not Chrome and not my harness.

That's the same rotated-but-unreceived window from the 11 Aug review: the server sets rotatedAt, the client never receives the new cookie, the next use replays the old token, and auth-sessions.ts:184-186 throws RefreshTokenReuseError and calls revokeSessionFamily(). A real user gets signed out on every device. The window is narrower than before, but it's still a regression against develop.

The agreed grace window was never implemented — apps/api/src/lib/auth-sessions.ts is untouched; the only apps/api changes in this PR are a data migration and two lines in seed.ts. There's no "recent rotation + unused successor" check anywhere.

Genuine token reuse still revokes the family, which is correct and unchanged — that test passes.

Two smaller things

refreshAuthSession(options) gained a signal parameter that nothing passes — dead surface. And the lock timeout (10s) is now shorter than the request timeout (30s), so a waiting tab can fail spuriously while the holder is still legitimately refreshing; minor, since it no longer signs the user out.

Not yours, but worth a ticket: prisma migrate deploy can't bootstrap a fresh database on develop — 20260718154842_init fails with type "LoreType" already exists, created by 20260718131500_init_hivelore_schema. Two init migrations collide. I also checked whether your migration's timestamp ordering caused a problem by baselining a real Postgres at develop's state; it applies cleanly. Not a finding.

983 lines added, zero new tests — fifth round with that ask outstanding.


Two changes close this:

  1. Create apps/web/src/app/worlds/[worldId]/lore/edit/page.tsx with develop's 13-line page, and repoint the four links. That's the option-C item that didn't land.
  2. Exempt /auth/refresh from DEFAULT_REQUEST_TIMEOUT_MS, or implement the server-side grace window.

The second is the one that can revoke a real user's session family across every device, so if you only do one, do that.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Pushed the two requested fixes in f90c81f.

Done:

  • Restored the direct lore authoring route at /worlds/[worldId]/lore/edit using LoreContributeClient.
  • Repointed the lore edit/add/extend links away from /contribute and into /lore/edit, so the lore CRUD UI is reachable again while the proposal-backed contribution editor keeps /contribute.
  • Exempted POST /auth/refresh from the default 30s API timeout by adding an explicit timeoutMs: null request option for refresh only. Other API calls keep the default timeout.

Verified locally:

  • npm run typecheck --workspace=@hivelore/web
  • npm run lint --workspace=@hivelore/web
  • npm run build --workspace=@hivelore/web

The build route table now includes /worlds/[worldId]/lore/edit, so this is not just the dynamic [entryId] route swallowing "edit". Ready for re-review.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Follow-up: pushed eefa356 to format apps/web/src/lib/api/client.ts after the first CI run caught the Prettier wrap. The refreshed GitHub Quality and Vercel Preview checks are both green now.

@mcfarhat mcfarhat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Reviewed at eefa356 against the code itself, not the diff summary.

What's good

  • Refresh lock timeout + broadcast fallback. navigator.locks.request with a 10s abort signal, falling back to waitForBroadcastedSession(), is the right shape. A tab that can't get the lock now waits for the winner to broadcast instead of hanging or duplicating the refresh.
  • combineSignals(). Correctly handles the already-aborted case up front, unwraps the single-signal case instead of allocating, prefers AbortSignal.any where available, and degrades cleanly. Preserving the abort reason through the fallback is a nice touch that's easy to miss.
  • Narrowing the initial-load catch to 401/403. This is a real improvement: a transient network error no longer wipes the user's session. I specifically checked whether it could strand the app on a spinner — it can't, .finally() clears isSessionLoading unconditionally.
  • error.body?.error ?? '<fallback>' throughout. Optional chaining plus a fallback on every path means a malformed error body can't surface as undefined in the UI.
  • 100KB content limit is enforced on UTF-8 byte length rather than string length, which is the correct check.

One earlier concern, withdrawn

A review pass flagged that ApiError might not carry a usable body.error. That's wrong and I'm recording it so it doesn't resurface: ApiError is defined in apps/web/src/lib/api/errors.ts with status, statusText and body: ApiErrorBody | null, and ApiErrorBody.error?: string. Every call site here uses it correctly. No change needed.

Non-blocking nits

  1. createTimeoutSignal() fallback — setTimeout(() => controller.abort(), ms) is never cleared, so on browsers without AbortSignal.timeout a timer survives for up to 30s after the request settles. Harmless, but returning a cleanup or using AbortSignal.timeout exclusively would tidy it.
  2. combineSignals() fallback — the abort listeners are { once: true } but never removed when the request completes normally, so the controller is retained until the caller's signal is collected. Same legacy-only path.

Neither is worth another round.

On tests

contribution-editor-form.tsx has no tests, and for a 673-line component doing session recovery, draft state and permission checks I'd normally push back. I'm not blocking on it here, because apps/web has no test infrastructure at all — no runner, no testing dependencies, zero test files — so this is a gap the project never set up, not something you skipped. Holding your PR to a bar no previous web PR has met wouldn't be fair.

I've raised it as its own card. When the harness lands, the three cases worth covering first are: draft save/load, a token refresh landing mid-save, and the 403 permission-denied path.

Nice work on the concurrency handling in particular — that's the part most people get wrong.

@mcfarhat
mcfarhat merged commit 8ac7e97 into develop Aug 24, 2026
2 checks passed
@mcfarhat
mcfarhat deleted the feature/m2-contribution-editor-frontend-kareem branch August 24, 2026 13:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants