Skip to content

client quality/docs (#66) - #95

Closed
Alimedhat000 wants to merge 34 commits into
mainfrom
feat/51-session-hardening
Closed

client quality/docs (#66)#95
Alimedhat000 wants to merge 34 commits into
mainfrom
feat/51-session-hardening

Conversation

@Alimedhat000

Copy link
Copy Markdown
Owner
  • docs: client quality initiative design spec

  • docs: client quality initiative implementation plan

Alimedhat000 and others added 30 commits August 24, 2026 20:48
* docs: client quality initiative design spec

* docs: client quality initiative implementation plan
* ci: trigger workflows for stacked client-quality branches

* chore(client): wire vitest browser test runner

* chore(client): remove empty orphaned test file

* chore(client): scaffold components with autodocs and jsdoc stubs

* ci: install playwright chromium before running client tests

* ci: serialize root tests and disable client story file parallelism
* refactor(client): folder-per-component for layouts

* refactor(client): kebab-case auth context files

* refactor(client): normalize ui directory casing and dissolve Form/Input

* refactor(client): fix DashboardMain casing, relocate shared NewDocumentFormBody

* refactor(client): kebab-case hook filenames

* refactor(client): kebab-case DocumentMain subtree and util filenames
* chore(client): enforce kebab-case filenames, drop stale shared ignores

* chore(client): add jsdoc lint rule (warn)
* refactor(client): remove dead interceptor, split join-request api from hook

* feat(client): expose error state from data hooks

* docs(client): backfill JSDoc and add missing autodocs tags

* chore(client): enforce jsdoc rule (error) with corrected export contexts

* fix(server): clear collaborator/request rows before test document cleanup

* fix(client): correct stale e2e API port fallback to 5001

* refactor(client): injectable provider factory in useCollab

* docs(client): enforce JSDoc description/param quality via lint

require-description on components/hooks/features kills empty stubs;
require-param (destructured members exempt) covers hooks and feature
helper files where params have no Props interface to document. Member-
level prop docs stay a review convention. Policy recorded in AGENTS.md.

* docs(client): backfill prop-level JSDoc across components/hooks/features

Every custom component prop gets a one-line member doc (defaults noted
when implementation-established); inherited React/Radix props left
undocumented; hooks and feature helpers get full @PARAM tags.
* test(client): global router/auth/modal story decorators; drop redundant per-story routers

* test(client): stories for spinner, seo head, layouts and auth/create forms with play coverage

* test(client): stories for new-document modal and card dropdown with portal-aware plays

* test(client): status bar and toolbar stories with real codemirror views

* test(client): editor cluster stories; prewarm optimizeDeps for story tests

* test(client): deflake modal form validation assertion (existence over visibility)

* ci: prebundle react-dom/client for storybook tests

* fix(client): editor view reached toolbar/status as null; harden story assertions

markdown-editor held the CodeMirror view in a ref but passed it as a prop
during render — refs don't re-render, so MarkdownToolbar/MarkdownStatusBar
received null on mount and stayed inert until an unrelated toggle. Hold the
view in state instead.

Story fixes so green means verified:
- DocumentMain stories dial a real (never-connecting) websocket; inject a
  seeded offline provider via the new createProvider prop and assert the
  seeded content actually renders; add missing DocumentData fields
- editor Editable asserts live status bar (3 lines, non-zero length)
- Head stories assert document.title/meta instead of mount-only
- per-story fn() mocks: shared module-scope mocks made
  .not.toHaveBeenCalled() order-dependent under filtered reruns
- register form: accessible-name button query, DOM error assertion
- toolbar: destroy stale unattached views; null-view story asserts no-op
* ci: drop temporary client-quality branch triggers

* docs: adr 0001 defer auth token consolidation
…ain) (#74)

* Security: exact-ID doc resolution + WS authentication (#45, #24) (#59)

* fix: resolve documents by exact ID instead of unordered prefix match

* feat: authenticate collaboration websocket and enforce permissions

* fix(server): restrict collaborators listing to owners and stop leaking credentials (#58)

* fix(server): restrict collaborators listing to owners and stop leaking credentials

Closes #43

* fix(server): allow editors to list collaborators on shared documents

The owner-only restriction from #43 broke the editor UX: legitimate
edit collaborators could no longer see who they share a document with.
Relax listing to owners and edit-permission collaborators while keeping
the sanitized select (no credentials) and denying viewers and unrelated
users with 403.

Part of #61 (RBAC follow-up).

* feat(client): show collaborator roster to editors and view-only badge

- Collaborators dropdown now renders for owners and edit collaborators;
  remove buttons stay owner-only (server enforces this too)
- View-only collaborators get a 'View only' badge in the toolbar slot,
  explaining why editing is disabled, instead of silently missing
  controls
- Drop the redundant isCollaborator skip-guard in useCollaborators —
  fetch whenever mounted with a docId; mount gating lives in the toolbar

Part of #61 (RBAC follow-up).

* feat(client): inject collaborators into dropdown stories via optional prop

Stories previously passed a 'collaborators' arg the component ignored
(it fetches via useCollaborators(docId), needing a live API). Add an
optional collaborators override prop so Storybook can demo the roster
rendering added for owners/editors, including owner-only remove buttons.
* chore: ignore local git worktrees directory

* fix(server): remove leftover debug console.log calls

Closes #36
* fix(server): remove leftover debug console.log calls

Drops two debug console.log statements in document.controller.ts
(share-token decoding and collaborator removal) plus the now-unused
chalk import and a commented-out log in getClientInfo.ts. Both spots
already emit structured winston logs.

Closes #36

* feat(server): rate-limit auth endpoints

Adds express-rate-limit (10 requests / 15 min / IP) on register, login
and refresh to protect against brute force. 

closes #37

* fix(server): force-enable limiter in rate-limit tests

The shared authLimiter skips outside production and vitest runs with
NODE_ENV=test, so the limiter was disabled inside its own tests and CI
saw 200s instead of 429s. Override skip via the factory in the tests.
* chore: ignore local git worktrees directory

* fix(server): cascade-delete collaborators and join requests

Documents with Collaborator or CollaborationRequest rows could not be
deleted: both FKs were RESTRICT, so DELETE /api/document/:id returned
500 for any shared doc. Cascade from Document like YjsDocumentState
already does. Regression test covers the previously untested path.

closes #44
* chore: ignore local git worktrees directory

* fix(client): copy the selected permission's share link, not a stale one

The effect is now the single fetch trigger on permission change, fetches are last-request-wins, and Copy is disabled while a link loads. Covered by an e2e test
that copies during a slowed share-link request.

Fixes #48

* fix(client): invalidate previous share link whenever a new fetch starts

A failed refetch left the old permission's link in state: Copy
re-enabled with the select showing the new mode, reintroducing the
#48 symptom via the error path. shareLink is now cleared at fetch
start, so after a failure (or mid-load) nothing stale is displayed
or copyable.
…85)

approveRequest/rejectRequest updated requests by bare requestId,
allowing cross-document decisions, re-deciding settled requests,
and 500s on missing ids. Now validate ownership + pending status
(404/409) and write via scoped updateMany.

Closes #46
* fix(collab): accept email when adding collaborators

Client posted {email} while the server expected {userId}, so the
add-collaborator flow 500'd on Prisma errors; the UI was commented
out hiding the breakage.

- POST /:id/collaborators now takes {email, permission?} (zod
  validated), resolves the user server-side
- 404 unknown email, 409 duplicate, 400 malformed body
- restore add-by-email form (owner only), controlled dropdown state
  fixes chevron stuck rotated via global window.open
- addCollaborator hook returns success so input clears only on success
- drop debug console.log leftovers

Closes #50

* fix(server): map P2002 race on duplicate collaborator insert to 409

Two concurrent adds can both pass the findUnique pre-check; the
loser hit the raw unique-constraint error and surfaced as 500.
Wrap the create and translate PrismaClientKnownRequestError P2002
into ConflictError.

* fix(server): normalize email case at validation boundaries

Closes #82
* fix(server): make Yjs sole writer of Document.content

REST update/create could overwrite the Yjs-derived content mirror,
silently discarding collaborative edits (last-writer-wins). Strip
content from both endpoints; also write snapshot + mirror atomically
in dbPersistence.store so they cannot drift on partial failure.

Closes #47

* fix(client): stop sending content in document save payload
Document routes accepted unvalidated bodies and params: malformed ids
surfaced Prisma errors as 500s, non-boolean flags reached the DB layer,
and title/permission values were never length- or enum-checked. Auth
schemas also lagged client-side bounds (server allowed 6-char passwords
while the client requires 8).

Add body/param/query schemas wired through the existing validate()
middleware for every /document route, replace the manual share-link
permission check with a query enum schema, and harden register/login/
addCollaborator bounds (email <=254, username <=50 + charset,
password 8..128, fullName <=100). Mirror those bounds in the client
(zod schemas + maxLength attributes) so users never hit a 400.

Closes #52. Related: #37 (rate limiting), #83 (enumeration hardening).
Registration now returns a single generic conflict message regardless of
which field collided, and login returns an identical 401 body with a
non-empty generic message for both unknown-user and bad-password cases
(previously the body serialized to {}). A dummy bcrypt compare equalizes
response timing when the user does not exist.
Logout previously required a valid access token, so an expired session
could not log out (the exact moment logout matters). Reuse the
validateRefreshToken middleware, which already populates req.user for
the controller to revoke the stored token.
The refresh endpoint set an httpOnly accessToken cookie that no client
ever reads; the access token already travels in the response body.
SameSite=None + Secure is only valid for cross-site HTTPS deployments;
in dev the client and API are same-site over plain http, where Secure
cookies get dropped and None requires TLS. Use Lax/insecure outside
production, None/Secure in production.
Add a response interceptor that refreshes the access token once and
replays the failed request. Concurrent 401s share one in-flight refresh;
a request whose token was already rotated by a concurrent request
replays directly instead of triggering a second round-trip. When the
refresh itself fails, the stored token is cleared and listeners are
notified so the auth provider can reset state.

Also add a node-env vitest unit project so shared client lib code has a
test runner (the existing vitest setup only ran Storybook tests).
Bootstrap now restores the session via the shared single-flight refresh
and simply marks the user signed out when it fails — it no longer calls
the authenticated logout endpoint (which always 401'd and threw an
unhandled rejection). Remove the wasLoggedOut localStorage flag that
kept logging users out after a logout->login cycle, swallow logout
errors when the session is already dead server-side, and reset auth
state when the interceptor reports an unrecoverable session expiry.

Add e2e coverage for logout->re-login->reload session restore and for
not calling logout during an unauthenticated bootstrap; serialize auth
specs since real logins overwrite the user's single stored refresh
token.
clearCookie must mirror the cookie's Secure/SameSite/Path/HttpOnly
attributes, otherwise browsers retain the prod SameSite=None; Secure
cookie after logout and subsequent refresh still succeeds.
Verifies that logout clears both refreshToken and legacy accessToken
cookies with the same Path/HttpOnly/SameSite attributes used on set
(otherwise prod SameSite=None; Secure cookies survive logout and refresh
still succeeds), and that replaying the old cookie after logout is
rejected. Would have failed before 4f8cdf5.
Concurrent 401s sharing one refreshPromise notified listeners per waiter
(3×) and could replay with Bearer null after clearAccessToken. Move
clear+notify into refreshPromise rejection (once) and guard the
current !== _tokenUsed branch to throw when current is null instead of
asserting non-null.
The synced-scroll toggle suppressed mirrored-pane echoes with a flag reset
in a fresh requestAnimationFrame. Chromium dispatches the mirrored pane's
scroll event before that frame, but Firefox delivers it after the reset:
the echo then passes the guard and the next genuine update is swallowed,
so the preview trails and snaps back continuously while scrolling.

Replace the timing flag with position-based echo suppression (an event at
the last-written offset is an echo) and batch mirror writes to one per
animation frame. Handlers are now stable callbacks, so listeners stop
resubscribing on every render.

Covered by interaction stories: baseline mirroring both directions plus a
deterministic late-echo regression test.
Chromium coalesces same-frame scroll events, so no existing story failed
on the old guard when two updates landed in one frame. Deliver the first
update, then add a second within the same frame (Firefox's per-write
delivery): the old code swallows the second and the mirror settles stale;
the new batched mirror converges on the latest position.
useCollaborators only reset its error inside the initial fetch effect, so
a failed add or remove left 'Failed to add/remove collaborator' showing
indefinitely — even after a successful retry or closing/reopening the
dropdown. Reset the error at the start of each action, mirroring the
fetch effect.

Adds a browser-mode vitest project for hook-level tests (stories can't
exercise API-dependent logic) with coverage for error clearing on
successful retries.
Replace bare await act(async () => {}) flushes with a polling waitFor
that waits for loading to settle. Empty act flushes can miss the
initial fetch's microtask in CI timing.
unit (node) matched src/**/*.test.{ts,tsx} and browser-unit matched
src/**/__tests__/*.test.{ts,tsx}, so files under __tests__ ran in both
projects: use-collaborators.test needs document (fails in node) and
api.test imports node:http (fails in browser). Scope browser-unit to
src/hooks/__tests__/** and exclude that path from unit so each file runs
once in its intended environment.

Fixes failing Lint, Type Check and Test run 33129323962 on develop
(4× document is not defined + 1× node:http externalized).
- Add Session model (id, userId, jti, refreshToken, expiresAt) with
  migration 20260828010125_add_session; allows multiple concurrent
  refresh tokens per user instead of single User.refreshToken column
  that logged out other devices on each login.

- Refresh rotation (TDD): login creates Session with jti + 24h expiry;
  POST /auth/refresh verifies jti, rotates to new jti/token, sets
  env-aware cookie (lax/secure), invalidates old token and keeps
  legacy User.refreshToken in sync. Old token replay → 401. New token
  works. Covers #51.1 stolen-token lifetime + reuse surface (reuse now
  just 401; full revoke-all on reuse can be added once migrated).

- Multi-device (TDD): second login creates second Session without
  overwriting first; logout deletes only presented Session, leaving
  other device intact. Fixes single-column overwrite.

- isActive enforcement (TDD): login rejects deactivated (401 generic),
  refresh rejects deactivated before rotation, authenticate middleware
  now async and checks isActive via prisma so deactivated JWTs are
  rejected within 15m. Closes #51.2.

- Cookie flags: refresh rotation sets sameSite/secure per NODE_ENV
  (already done for login/logout in #92); logout clears only presented
  Session and both cookies with matching Path/SameSite/HttpOnly.

Tests: 3 new suites (rotation, isActive, multi-device) — 6 tests —
all green with existing 77 (83 total). Test setup now truncates
sessions table.
@Alimedhat000
Alimedhat000 deleted the feat/51-session-hardening branch August 28, 2026 12:29
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.

1 participant