dorfteich/apps/web/src/editor/use-collab-provider.ts
Claude Opus 4.8 fa7ae033b5
All checks were successful
CD / Build and push images (push) Successful in 2m58s
CI / Lint, typecheck, test (push) Successful in 1m55s
CI / Auth e2e pack (push) Successful in 2m25s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 11s
Add permission-revocation handling for live and offline sessions (#39)
Revoking write access must terminate live sessions and let a user with
pending offline edits export them rather than lose them silently.

Backend (generic, reused by M5 grants #53):
- packages/shared: POND_ACCESS_CHANGED_CHANNEL, the LISTEN/NOTIFY channel
  shared by api and collab.
- api: PondAccessNotifier emits pg_notify(pond_access_changed, pondId) on
  a permission-relevant change; the single generic seam for revocation.
  Wired into pond soft-delete as the interim trigger (see==modify until
  #53).
- collab: a dedicated-connection LISTEN listener (LISTEN is connection-
  bound, not pooled) that, on a notification, closes every open connection
  to the pond's open pages. Clients then reconnect and the api re-issues a
  token reflecting current access (downgrade to ro, or 403/404). Reconnects
  and re-LISTENs if its connection drops.

Frontend:
- use-collab-provider: a refused token (403/404) on (re)connect sets
  accessRevoked and stops the reconnect loop; exposes discardLocal.
- AccessRevokedDialog: keeps local content visible and offers Markdown
  copy/download (derived from the live editor doc, so offline edits are
  included) and an explicit discard that clears IndexedDB. de+en strings.

Tests: collab DB-backed integration test proves a direct NOTIFY closes a
live session within seconds (AC1) and leaves unrelated ponds untouched;
listener unit tests; api test asserts soft-delete fires the notifier;
web test for the export Markdown derivation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 07:01:46 +02:00

189 lines
7.2 KiB
TypeScript

import type { CollabTokenResponse } from '@dorfteich/shared';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useCallback, useEffect, useRef, useState } from 'react';
import { IndexeddbPersistence } from 'y-indexeddb';
import * as Y from 'yjs';
import { ApiError, apiGet } from '../lib/api';
/** What the editor shows about the live connection (issue #36). */
export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline';
export interface CollabState {
provider: HocuspocusProvider | null;
status: ConnectionStatus;
/** Access level of the current token; `ro` clients cannot edit. */
mode: 'rw' | 'ro' | null;
/** Set once the server rejects an update for exceeding the size ceiling (#35). */
tooLarge: boolean;
/** True when there are edits held only on this device (offline, #38). */
localOnly: boolean;
/**
* Set when the api refuses a collaboration token (403/404) on (re)connect,
* i.e. edit access was revoked (issue #39). The local content stays visible
* so the user can export it; live sync stops.
*/
accessRevoked: boolean;
/** Discard the device-local copy of this page (clears IndexedDB, #39). */
discardLocal: () => Promise<void>;
}
/** IndexedDB database name for a page's local Yjs persistence (issue #38). */
function localDbName(pageId: string): string {
return `dorfteich-page-${pageId}`;
}
/** WebSocket endpoint of the collab server, behind the same origin as the app
* (the reverse proxy forwards `/collab`; deployment.md requires WS upgrade). */
function collabWsUrl(): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/collab`;
}
/**
* Binds a page's `Y.Doc` to the Hocuspocus collaboration server (ADR 0003,
* realtime-collaboration.md). The document loads and persists through the
* collab server now — there is no REST autosave. The collaboration token is
* fetched lazily on every (re)connect, so an expired token is replaced
* transparently and a permission change takes effect on the next reconnect.
*/
export function useCollabProvider(ydoc: Y.Doc | null, pageId: string): CollabState {
const [provider, setProvider] = useState<HocuspocusProvider | null>(null);
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>(
'connecting',
);
const [synced, setSynced] = useState(false);
const [everConnected, setEverConnected] = useState(false);
const [online, setOnline] = useState(() => navigator.onLine);
const [mode, setMode] = useState<'rw' | 'ro' | null>(null);
const [tooLarge, setTooLarge] = useState(false);
const [hasUnsynced, setHasUnsynced] = useState(false);
const [accessRevoked, setAccessRevoked] = useState(false);
// Held so `discardLocal` can clear the current page's IndexedDB store even
// after `accessRevoked` has stopped live sync (issue #39).
const localPersistenceRef = useRef<IndexeddbPersistence | null>(null);
useEffect(() => {
if (!ydoc) return;
let disposed = false;
// Whether the server has ever confirmed our state this session; decides
// whether the local copy is discarded on leave (below).
let serverSynced = false;
// Local-first persistence: every opened page is mirrored to IndexedDB so
// edits survive a reload and offline work (ADR 0003, #38). The provider
// and IndexedDB share the same Y.Doc and merge conflict-free.
const localPersistence = new IndexeddbPersistence(localDbName(pageId), ydoc);
localPersistenceRef.current = localPersistence;
setAccessRevoked(false);
const instance = new HocuspocusProvider({
url: collabWsUrl(),
name: pageId,
document: ydoc,
token: async () => {
try {
const response = await apiGet<CollabTokenResponse>(`/pages/${pageId}/collab-token`);
if (!disposed) {
setMode(response.mode);
setAccessRevoked(false);
}
return response.token;
} catch (error) {
// A refused token (403/404) means edit access was revoked while we
// were connected or offline (#39). Surface it so the editor can offer
// an export; the effect below then stops the reconnect loop. Rethrow
// so this connection attempt aborts rather than using a stale token.
if (error instanceof ApiError && (error.status === 403 || error.status === 404)) {
if (!disposed) setAccessRevoked(true);
}
throw error;
}
},
onStatus: ({ status }) => {
if (disposed) return;
setWsStatus(status);
if (status === 'connected') setEverConnected(true);
},
onSynced: () => {
serverSynced = true;
if (!disposed) setSynced(true);
},
onDisconnect: () => {
if (!disposed) setSynced(false);
},
onUnsyncedChanges: ({ number }) => {
if (!disposed) setHasUnsynced(number > 0);
},
onStateless: ({ payload }) => {
if (disposed) return;
try {
const message = JSON.parse(payload) as { type?: string; code?: string };
if (message.type === 'error' && message.code === 'page_document_too_large') {
setTooLarge(true);
}
} catch {
// Ignore malformed stateless payloads.
}
},
});
setProvider(instance);
return () => {
disposed = true;
instance.destroy();
// Discard the local copy once the server has our changes (bounds
// IndexedDB growth); otherwise keep it so offline edits survive to the
// next visit (realtime-collaboration.md §Offline).
if (serverSynced) {
void localPersistence.clearData();
} else {
void localPersistence.destroy();
}
if (localPersistenceRef.current === localPersistence) localPersistenceRef.current = null;
setProvider(null);
setSynced(false);
setHasUnsynced(false);
};
}, [ydoc, pageId]);
// Once access is revoked, stop the reconnect loop: every reconnect would only
// fetch another refused token and hammer the api (issue #39). The local copy
// stays intact for export until the user leaves or explicitly discards it.
useEffect(() => {
if (accessRevoked && provider) provider.disconnect();
}, [accessRevoked, provider]);
useEffect(() => {
const goOnline = (): void => setOnline(true);
const goOffline = (): void => setOnline(false);
window.addEventListener('online', goOnline);
window.addEventListener('offline', goOffline);
return () => {
window.removeEventListener('online', goOnline);
window.removeEventListener('offline', goOffline);
};
}, []);
let status: ConnectionStatus;
if (!online) {
status = 'offline';
} else if (wsStatus === 'connected' && synced) {
status = 'connected';
} else if (everConnected) {
status = 'reconnecting';
} else {
status = 'connecting';
}
// Local-only = we hold edits the server has not acknowledged and we are not
// currently in sync (offline or reconnecting).
const localOnly = hasUnsynced && status !== 'connected';
const discardLocal = useCallback(async (): Promise<void> => {
await localPersistenceRef.current?.clearData();
}, []);
return { provider, status, mode, tooLarge, localOnly, accessRevoked, discardLocal };
}