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
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
189 lines
7.2 KiB
TypeScript
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 };
|
|
}
|