Skip to content

Commit bdce7bd

Browse files
HMarzbanclaude
andcommitted
fix(webapp): keep the token out of page props and clear drafts on sign-out
The document page put the signed-in access token in its page props. The service worker caches the page and its /_next/data response in the others cache. Its header rule cannot see a token in a body. The props now carry isAuthed in its place. The WS token callback reads getSession() only, and it has no fallback to a prop. Chat drafts are keyed by workspace and room, with no user id. So the next person on a shared browser could see unsent text. After signOut() leaves no session, useSignOut now awaits clearAllComposerDrafts(). That function cancels queued draft writes first, so the pagehide flush cannot write one back. A failed sign-out keeps the drafts. Sign-out also removes the docId, padName, slug and title keys from localStorage. Nothing reads them, so useDocumentMetadata is deleted. Refs #429 Refs #444 Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent cda1150 commit bdce7bd

12 files changed

Lines changed: 56 additions & 52 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,13 @@ This file is the product changelog. The webapp package keeps [`apps/webapp/CHANG
308308
analytics tables or call the admin, cron and notification helper functions.
309309
Monthly view tables now have row security on
310310
([#314](https://github.com/docs-plus/docs.plus/issues/314)).
311+
- Keep the access token out of the document page data. The offline cache
312+
keeps that data, so the next person on a shared device could read the token
313+
([#429](https://github.com/docs-plus/docs.plus/issues/429)).
314+
- Clear chat drafts on sign-out. A draft belongs to a chat, not to a person,
315+
so the next person on a shared browser could see unsent text. Sign-out also
316+
removes four old document keys from browser storage
317+
([#444](https://github.com/docs-plus/docs.plus/issues/444)).
311318

312319
### Removed
313320

‎apps/webapp/CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,17 @@ plus the house order in [`RELEASE_POLICY.md`](../../RELEASE_POLICY.md).
545545
- **A notification click opens same-origin URLs only.** The service worker
546546
opens `/` for any other `action_url`
547547
([#417](https://github.com/docs-plus/docs.plus/issues/417)).
548+
- **Document page props carry no access token.** `documentServerSideProps`
549+
sends `isAuthed` in its place, and the WS `token` callback reads
550+
`getSession()` only. The service worker caches page props, and its header
551+
rule cannot see a token in a body
552+
([#429](https://github.com/docs-plus/docs.plus/issues/429)).
553+
- **Sign-out clears chat drafts and the old document keys.** Once
554+
`signOut()` leaves no session, `useSignOut` awaits `clearAllComposerDrafts()`.
555+
It cancels queued draft writes first, so the pagehide flush cannot write one
556+
back. Sign-out also removes the unused `docId`, `padName`, `slug` and
557+
`title` keys, and `useDocumentMetadata` is deleted
558+
([#444](https://github.com/docs-plus/docs.plus/issues/444)).
548559
- **A live chat message takes its author's avatar from the profile, not from
549560
presence.** Any client can send a presence payload. A forged one could change
550561
the avatar on another user's live messages, and the name and avatar in the

‎apps/webapp/CLAUDE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ Follow industry overlay UX (Material, Apple HIG, Radix/shadcn) and dim-not-lift
206206
- Workbox `urlPattern` matchers must be self-contained (next-pwa serializes via `Function.toString()` — no module-scope refs). Verify a matcher change under **Node** (the actual Next build runtime), never `bun -e`. Bun's `Function.prototype.toString()` re-serializes with `const` bindings inlined. A matcher that reads `const isSameOrigin = …; return !isSameOrigin` therefore reads back mutated, and throws a false serialization/assertion failure that Node (source-accurate) passes.
207207
- Deploy-boundary chunk/SW GlitchTip noise: `chunkLoadRecovery.ts` one-shot reload (10s `sessionStorage` cooldown) + `instrumentation-client.ts` `beforeSend` drops SW-lifecycle and auto-recovered chunk errors. Keep the chunk pattern lists in sync across both. Rebuild the webapp for `sw.js` changes.
208208
- **PWA path guards (epic #249).** Keep #257, #259, #260 and #261 open as standing "do not build" cards. Do not add `capture` or a camera UI to the profile photo. Do not add folders; Settings → Documents is the one library, and Home and Command jump are doors into it. Do not add visit-based Recents; Last opened stays an owner-only stamp. The REST list sorts by it on the Owner live list and the Merged list. There, a row the caller does not own sorts as never opened. Joined lists memberships, not visits. Open pads with `router.push`, never `_blank`, so `launch_handler: navigate-existing` holds.
209-
- **Workbox rule 0 keeps authenticated requests out of Cache Storage.** Any request with a `token` or `Authorization` header is `NetworkOnly`. supabase-js sends `Authorization` even when signed out, so every Supabase REST GET skips the cache. Keep the rule first in `workbox-runtime-caching.js`, ahead of the adapted defaults. The MCP status probe (`/.well-known/oauth-protected-resource`) is `NetworkOnly` right after it, so a cached answer never shows Online while the server is down.
209+
- **Workbox rule 0 keeps authenticated requests out of Cache Storage.** Any request with a `token` or `Authorization` header is `NetworkOnly`. supabase-js sends `Authorization` even when signed out, so every Supabase REST GET skips the cache. Keep the rule first in `workbox-runtime-caching.js`, ahead of the adapted defaults. The MCP status probe (`/.well-known/oauth-protected-resource`) is `NetworkOnly` right after it, so a cached answer never shows Online while the server is down. The rule reads request headers only. Page props sit in the `others` cache, so they must never carry a token or another secret (#429).
210210
- **Inbound share is `/receive`.** The manifest `share_target` posts there. `public/service-worker.js` handles only `POST /receive`: it stashes the file and redirects. `/receive` is a utility route and a reserved slug, so it never becomes a pad. Do not add `file_handlers` or change manifest `id` or `launch_handler`.
211211

212212
### Next Product APIs
@@ -369,7 +369,7 @@ Follow industry overlay UX (Material, Apple HIG, Radix/shadcn) and dim-not-lift
369369
- **The patch-versus-invalidate rule.** A remove or an in-place patch keeps the loaded rows equal to a server prefix, so it patches. An insert whose server position the client cannot know must refetch instead. **Duplicate is that case and it must NOT patch.** `duplicateDocument` never writes `lastOpenedAt`, and `documents.service.ts` sorts that column `nulls: 'last'`, so under `lastOpenedAt_desc` a copy belongs at the end, past the loaded window. A patch put it near the top and the refetch then removed it, so the row flashed and vanished. `addDuplicate()` takes no argument for that reason.
370370
- **A debounce guarding a NETWORK write must `flush()` on unmount. A debounce guarding LOCAL state must `cancel()`.** `NotificationsSection.tsx` cancelled a preference write, and `ModalContent` unmounts its children on close, so closing the panel inside 500 ms discarded the patch silently. Its line is the only caller of `updateNotificationPreferences` in the webapp, so nothing rescued it. `DocumentsSection.tsx` and `ProfileSection.tsx` both cancel correctly, because both guard local state. A sweep that unifies the three would reintroduce the data loss.
371371
- The cleanup body must stay braced. The flushed function is `async`, so an expression-bodied arrow hands React a Promise as a cleanup value.
372-
- **Sign-out flushes a pending patch first, and `keepalive` is NOT the fix.** After `signOut()` the session is gone, and `update_notification_preferences` raises `42501 unauthenticated`, so no transport option can save it. The fix is ordering. The open `NotificationsSection` registers its debounced save in `components/settings/utils/pendingPreferenceWrites.ts`, and `useSignOut` awaits `flushPendingPreferenceWrites()` before `signOut()`. Keep that order. Sign-out also unregisters this device's push subscription (`unregisterPushSubscription`) before `signOut()`, best effort, because that RPC needs the session too (#417).
372+
- **Sign-out flushes a pending patch first, and `keepalive` is NOT the fix.** After `signOut()` the session is gone, and `update_notification_preferences` raises `42501 unauthenticated`, so no transport option can save it. The fix is ordering. The open `NotificationsSection` registers its debounced save in `components/settings/utils/pendingPreferenceWrites.ts`, and `useSignOut` awaits `flushPendingPreferenceWrites()` before `signOut()`. Keep that order. Sign-out also unregisters this device's push subscription (`unregisterPushSubscription`) before `signOut()`, best effort, because that RPC needs the session too (#417). Once `signOut()` leaves no session, `useSignOut` awaits `clearAllComposerDrafts()` and removes the four old localStorage keys (`docId`, `padName`, `slug`, `title`). Only then does it reload (#444).
373373
- **A setting saved anywhere reaches every open tab and device of that person** (2026-09-30). `hooks/useProfileSync.ts` listens on the private `profile:<uid>` topic and also refreshes on `visibilitychange`, because Realtime keeps no backlog. It copies only `SYNCED_FIELDS` into `authStore.profile`, never `status`, and only from the newest read. It invalidates `notificationPreferencesKey(userId)`, keyed by user so one person never sees another's cache. It mounts as the null `ProfileSync` inside `AppQueryClientRoot`'s provider, above the lazy `DocumentShellInner`, so sync starts with the page. Private topics subscribe through `utils/supabase/subscribePrivateTopic.ts`, because realtime-js `channel()` returns a same-topic channel that is still leaving.
374374
- **Notifications reads the `useNotificationPreferences` query, never `profile_data`.** Unconfirmed edits sit in `localEdits` and win over the query, so a refetch or the writer's own echo never flips a toggle back. The RPC's merged return goes into the cache with `setQueryData`, after `cancelQueries` stops any read that began before the commit; the writer's own echo refetches. The controls stay live while a save is in flight. `DEFAULT_PREFERENCES` mirrors the SQL fallbacks and leaves out `timezone`, so a missing timezone still reads as missing and gets written.
375375
- **`ProfileSection` takes a remote change only into a field the person has not edited.** The theme is per browser: `themeStore` rehydrates on the `storage` event and repaints through `applyThemeToDom`. Push subscription and notification permission are per browser too, and never sync.

‎apps/webapp/config/pwa/workbox-runtime-caching.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ if (adapted !== EXPECTED_ADAPTED) {
8989
// A request with a token or Authorization header never enters Cache Storage.
9090
// supabase-js always sends Authorization. Sign-out does not clear the cache.
9191
// It sits first so rule 13 never sees these.
92+
// This rule reads request headers only, so page props must never carry a token.
9293
const credentialedRequestRule = {
9394
urlPattern: ({ request }) =>
9495
Boolean(request?.headers?.has('token') || request?.headers?.has('authorization')),

‎apps/webapp/src/components/chatroom/CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Moved verbatim out of the repo-root [AGENTS.md](../../../../../AGENTS.md) so it
7373
- After submit, `cleanupAfterSubmit` in `useComposerSubmit` decides the refocus. On a phone or tablet, `keepKeyboardAfterSubmit` (`isMobile`) is true, so the editor always refocuses. On desktop it refocuses only if the editor was the active element. Neither path refocuses while the emoji panel or the link dialog is open. Enter-key submits keep focus naturally.
7474
- `useEditor(...)` in `components/chatroom/components/MessageComposer/hooks/useTiptapEditor.ts` is built once per mount. Its `onUpdate` reads `workspaceId` and `channelId` from `draftCtxRef.current`, and an effect keeps that ref current. Do not capture either id in the closure, or drafts cross channels.
7575
- `prepareOutboundContent` must return stable `htmlChunks: string[]` / `textChunks: string[]` fields on both the happy and empty paths, so callers never need an `as` cast. It must not return a nested `chunks` wrapper.
76-
- **Draft vs mode memory.** Text/HTML drafts live in IndexedDB (`messageComposerDB` via `syncComposerDraft` / `discardComposerDraft`). When IndexedDB fails to open, `messageComposerDB` keeps them in a module-level `Map` instead. Zustand `*MessageMemory` holds reply/edit/comment UI modes only — do not reintroduce `messageDraftMemory`. Edit and comment modes never write the saved draft. When either mode ends, the composer loads the saved draft again. An edit has its own attachment list under the `::edit` key, so an edit never touches the draft's attachments or their uploads. A comment shows the saved draft's tiles, but the files it adds never enter the saved draft.
76+
- **Draft vs mode memory.** Text/HTML drafts live in IndexedDB (`messageComposerDB` via `syncComposerDraft` / `discardComposerDraft`). When IndexedDB fails to open, `messageComposerDB` keeps them in a module-level `Map` instead. Zustand `*MessageMemory` holds reply/edit/comment UI modes only — do not reintroduce `messageDraftMemory`. Edit and comment modes never write the saved draft. When either mode ends, the composer loads the saved draft again. An edit has its own attachment list under the `::edit` key, so an edit never touches the draft's attachments or their uploads. A comment shows the saved draft's tiles, but the files it adds never enter the saved draft. The draft key has no user id on purpose. Sign-out clears every draft through `clearAllComposerDrafts`, which cancels queued writes first, because the pagehide flush would write them back (#444).
7777
- **Mode edges come from one table.** `useComposerModeEdge` turns the mode kind (`none`, `reply`, `edit`, `comment`) into one edge per change. The first edge of a mount starts from `mount`. The pure `composerModeEdgeAction` decides the draft load (`keep`, `fill`, `replace`) and the upload discard. `useComposerAttachmentLifecycle` reads that edge. `useComposerDraft` reads a second edge in which reply counts as none, so a reply toggle never re-runs the draft load. The draft load and the reply and comment upload discard come from the table; do not infer them from a previous-mode ref again. Leaving edit still cancels the `::edit` list through its own effect on `editMessageMemory`.
7878
- An edge that leaves reply or comment deletes the uploads that the mode added. A switch between reply and comment counts as leaving.
7979
- Entering a mode never discards, because the failed-comment restore records its files again after the `await`.

‎apps/webapp/src/components/pages/document/DocumentPage.tsx‎

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import HeadSeo from '@components/HeadSeo'
22
import DocumentLayouts from '@components/pages/document/layouts/DocumentLayouts'
33
import { GlobalDialog } from '@components/ui/GlobalDialog'
4-
import useDocumentMetadata from '@hooks/useDocumentMetadata'
54
import useInitiateDocumentAndWorkspace from '@hooks/useInitiateDocumentAndWorkspace'
65
import useJoinWorkspace from '@hooks/useJoinWorkspace'
76
import useMapDocumentAndWorkspace from '@hooks/useMapDocumentAndWorkspace'
@@ -11,37 +10,26 @@ import { GoogleOneTapLayout } from '@layouts'
1110
import { closeOpenChatRoom } from '@services/openHeadingChatroom'
1211
import { useStore } from '@stores'
1312
import { ensureEmojiData } from '@utils/ensureEmojiData'
14-
import { useRouter } from 'next/router'
1513
import { useEffect, useLayoutEffect } from 'react'
1614

1715
type DocumentPageProps = {
1816
docMetadata: any
1917
isMobile: boolean
2018
deviceType?: 'desktop' | 'mobile' | 'tablet'
21-
accessToken?: string | null
2219
}
2320

24-
const DocumentPage = ({
25-
docMetadata,
26-
isMobile,
27-
deviceType = 'desktop',
28-
accessToken
29-
}: DocumentPageProps) => {
30-
const router = useRouter()
31-
const slugs = (router.query.slugs as string[]) || []
21+
const DocumentPage = ({ docMetadata, isMobile, deviceType = 'desktop' }: DocumentPageProps) => {
3222
const { loading: channelsLoading } = useMapDocumentAndWorkspace(docMetadata)
3323

3424
// The page's single gate: set synchronously at provider creation (no network wait),
3525
// nulled on destroy (doc switch). Channel, join, and sync state must never re-gate
3626
// this tree — once the layout mounts, the editor is never unmounted for the same doc.
3727
const provider = useStore((state) => state.settings.hocuspocusProvider)
3828

39-
useDocumentMetadata(slugs, docMetadata)
4029
useInitiateDocumentAndWorkspace(docMetadata)
4130
useYdocAndProvider({
4231
documentId: docMetadata.documentId,
4332
slug: docMetadata.slug,
44-
accessToken: accessToken ?? '',
4533
deviceType
4634
})
4735
useJoinWorkspace({ documentId: docMetadata.documentId, channelsLoading })

‎apps/webapp/src/components/settings/hooks/useSignOut.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { signOut } from '@api'
22
import * as toast from '@components/toast'
3+
import { clearAllComposerDrafts } from '@db/messageComposerDB'
34
import { isPushSupported, unregisterPushSubscription } from '@utils/push-notifications'
45
import { supabaseClient } from '@utils/supabase'
56
import { useState } from 'react'
@@ -29,6 +30,14 @@ export const useSignOut = () => {
2930
return
3031
}
3132
}
33+
// After signOut, so a failed sign-out keeps the drafts. The four keys are no longer
34+
// written; this removes them from browsers that still hold them.
35+
await clearAllComposerDrafts()
36+
try {
37+
for (const key of ['docId', 'padName', 'slug', 'title']) localStorage.removeItem(key)
38+
} catch {
39+
// Blocked storage holds no keys to remove.
40+
}
3241
// The returning-person row survives sign-out, or it would almost never appear.
3342
// "Not you?" on that row drops it.
3443
// Consume the takeover's history entry first, or back after reload re-lands on it.

‎apps/webapp/src/db/messageComposerDB.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,19 @@ export function cancelPendingWrites(): void {
277277
debouncedWriters.forEach((debouncedFn) => debouncedFn.cancel())
278278
debouncedWriters.clear()
279279
}
280+
281+
/**
282+
* The draft key has no user id, so the next person on this browser would see these drafts.
283+
* Cancel first, or the pagehide flush writes a queued draft back after the clear.
284+
*/
285+
export async function clearAllComposerDrafts(): Promise<void> {
286+
cancelPendingWrites()
287+
memory.clear()
288+
try {
289+
const db = await getDB()
290+
await db.composer.clear()
291+
} catch (err) {
292+
// Drafts may remain on disk. With no IndexedDB, the memory map was the only store.
293+
console.warn('[composerDB] clear failed', err)
294+
}
295+
}

‎apps/webapp/src/hooks/useDocumentMetadata.ts‎

Lines changed: 0 additions & 24 deletions
This file was deleted.

‎apps/webapp/src/hooks/useYdocAndProvider.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,12 @@ const requestPersistentStorage = () => {
4646
interface UseYdocAndProviderProps {
4747
documentId: string
4848
slug: string
49-
accessToken: string
5049
deviceType?: DeviceType
5150
}
5251

5352
const useYdocAndProvider = ({
5453
documentId,
5554
slug,
56-
accessToken,
5755
deviceType = 'desktop'
5856
}: UseYdocAndProviderProps): void => {
5957
const ydocRef = useRef(new Y.Doc())
@@ -122,7 +120,7 @@ const useYdocAndProvider = ({
122120
)
123121
connectedWithSessionRef.current = Boolean(data.session?.user)
124122
return JSON.stringify({
125-
accessToken: data.session?.access_token ?? (error ? '' : (accessToken ?? '')),
123+
accessToken: data.session?.access_token ?? '',
126124
slug,
127125
deviceType
128126
})

0 commit comments

Comments
 (0)