Compare commits

..

1 Commits

Author SHA1 Message Date
vvzvlad
fcf1fdec89 Merge pull request #1 from vvzvlad/develop
Release 0.94.0
2026-06-26 18:23:28 +03:00
94 changed files with 502 additions and 7759 deletions

View File

@@ -92,19 +92,6 @@ IFRAME_EMBED_ALLOWED=false
# Example: https://intranet.example.com,https://portal.example.com
IFRAME_ALLOWED_ORIGINS=
# Comma-separated list of additional origins allowed to call the API via CORS.
# The APP_URL origin and native mobile (Capacitor) origins are always allowed.
# Leave empty for a same-origin (web-only) deployment.
CORS_ALLOWED_ORIGINS=
# Expose OpenAPI/Swagger docs at /api/docs (development/debugging aid only).
SWAGGER_ENABLED=false
# Capacitor (mobile shell): hosted client URL loaded by the iOS shell so the
# AGPL web client is NOT bundled into the .ipa (see docs/mobile-app-plan.md §9).
# Leave empty for Android bundled mode / local development.
CAP_SERVER_URL=
# Enable debug logging in production (default: false)
DEBUG_MODE=false

6
.gitignore vendored
View File

@@ -42,15 +42,9 @@ lerna-debug.log*
.nx/installation
.nx/cache
.claude/worktrees/
.claude/tmp/
# TypeScript incremental build artifacts
*.tsbuildinfo
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
apps/client/public/vad/
# Capacitor native platform projects (generated locally via 'npx cap add ios|android')
/ios
/android
.capacitor

View File

@@ -283,46 +283,37 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
### Cutting a release
The git tag is the source of truth for the displayed version (the client UI reads `git describe --tags` via `vite.config.ts`); the `package.json` bump is metadata that backs the server `/version` endpoint (`version.service.ts`).
The git tag is the source of truth for the displayed version (UI reads `git describe --tags`); the `package.json` bump is metadata only. Steps:
**Golden rule — tag on `develop` first, merge to `main` afterwards.** Cut the version-bump commit on `develop`, put the tag on *that* commit, and push it. Merge `develop` into `main` later (it does not block the tag or the release). Because the tag is in `develop`'s ancestry from the moment it is created, `git describe` on `develop` — and the `ghcr.io/vvzvlad/gitmost:develop` image — reports the new version immediately, with **no back-merge dance**. Do **not** tag `main`'s merge commit; that is the mistake described in the pitfall below (we hit it twice).
Steps:
1. Make sure `develop` is up to date, clean, and pushed to **both** remotes (`git status`; `git push gitea develop && git push github develop`).
1. Make sure `main` is clean and pushed (`git status`, `git push`).
2. Pick `vX.Y.Z` (SemVer): **minor** bump for a batch of features, **patch** for fixes only. Review what landed with `git log <last-tag>..HEAD --no-merges`.
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit **on `develop`** with the bare version as the subject, e.g. `0.94.1` (matches past bump commits).
4. For a real release (skip for a bare hotfix tag), update `CHANGELOG.md` (Keep a Changelog format): add a `## [X.Y.Z] - YYYY-MM-DD` section summarising `git log vPREV..HEAD --no-merges` grouped by type (Breaking / Added / Changed / Fixed / Removed), and the `compare/vPREV...vX.Y.Z` link at the bottom. Fold it into the bump commit.
5. Tag that develop commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`.
6. Push the branch **and** the tag to **both** writable remotes — `git push <branch>` does **not** push tags, and tags are per-remote:
```bash
git push gitea develop && git push gitea vX.Y.Z
git push github develop && git push github vX.Y.Z
```
Pushing the `v*` tag to `github` triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release). The tag *must* exist on `github`, because the `:develop` and release images are built there by GitHub Actions and `git describe` on the runner only sees the tags present on `github` (not your local clone or `gitea`).
7. Merge `develop` into `main` when ready (commonly later — this does not gate the release):
```bash
git checkout main
git merge --ff-only develop # or a merge commit if fast-forward is not possible
git push gitea main && git push github main
```
The tag is already reachable from `main` (it lives in the `develop` history that `main` now contains), so `main` reports `vX.Y.Z` too — no extra tagging needed.
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit with the bare version as the subject, e.g. `0.91.0` (matches past bump commits).
4. Update `CHANGELOG.md` (Keep a Changelog format): add a `## [X.Y.Z] - YYYY-MM-DD` section summarising `git log vPREV..HEAD --no-merges` grouped by type (Breaking / Added / Changed / Fixed / Removed), and add the `compare/vPREV...vX.Y.Z` link at the bottom. Fold the bump + changelog into the release commit.
5. Tag the release commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`.
6. Push commit and tag: `git push origin main && git push origin vX.Y.Z`. Pushing the `v*` tag triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release).
7. **Back-merge the release into `develop`** so develop builds report the new version: `git checkout develop && git merge --no-ff main && git push origin develop` (push to Gitea as well if that is the canonical remote).
#### Pitfall: tagging `main` instead of `develop` (the mistake to avoid)
#### Why develop keeps showing the *previous* version (and why step 7 matters)
`git describe --tags --always` (see `vite.config.ts`) walks **backwards from the current commit** and picks the **nearest tag reachable in that commit's ancestry**, then appends `-<commits-since-tag>-g<short-hash>`.
The UI version is `git describe --tags --always` (see `vite.config.ts`), which walks **backwards from the current commit** and picks the **nearest tag reachable in that commit's ancestry**, then appends `-<commits-since-tag>-g<short-hash>`.
The wrong flow we fell into twice: merge `develop` into `main` *first*, then tag `main`'s **release merge commit**. That merge commit is **not** in `develop`'s history, so `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable one. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.93.0-NNN-g<hash>` even though a release was "cut". Tagging on `develop` (the golden rule above) avoids this entirely: the tag is in `develop`'s ancestry from the start, and `main` still gets it once `develop` is merged in.
The release tag (`vX.Y.Z`) is created on **`main`'s release merge commit**, and that commit is **not** in `develop`'s history. So until the release is back-merged, `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable tag. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.91.0-NNN-g<hash>` even though `main` is already tagged `v0.93.0`. This is the classic git-flow pitfall: the version on `develop` does **not** advance just because a release was tagged on `main`.
Second gotcha — the tag must exist on the remote CI builds from. `git describe` names a tag **ref**, not just a commit. The `:develop` and release images are built by GitHub Actions (`develop.yml` / `release.yml`, `actions/checkout` with `fetch-depth: 0`), so the version they print depends on which tags exist **on the `github` remote** — not on your local clone or on `gitea`. `git push <branch>` does **not** push tags; push them explicitly to **each** remote (`gitea` and `github`). A tag that only lives on `gitea` is invisible to the GitHub build.
Back-merging `main → develop` (step 7) pulls the tagged release commit into `develop`'s ancestry, after which develop builds correctly show `vX.Y.Z-NNN-g<hash>`. If `develop` already drifted (release tagged but never back-merged), just run step 7 now — no new tag is needed.
If you already tagged `main` (or `develop` still shows the old version), recover without re-tagging:
##### The tag must also exist on the remote that CI builds from (multi-remote gotcha)
1. Make the tagged commit reachable from `develop` — either back-merge `main → develop` (`git checkout develop && git merge --no-ff main`), or confirm the tagged commit is already an ancestor of `develop`.
2. Make sure the tag exists on `github`: compare `git ls-remote --tags github` with `gitea`, and push the missing one (`git push github vX.Y.Z` / `git push gitea vX.Y.Z`). Pushing a `v*` tag to `github` also fires `release.yml` — expected, just be aware.
3. Re-run the develop build (`gh workflow run Develop`, or push any commit to `develop`) so `git describe` re-resolves with the tag now in scope.
`git describe` names a tag **ref**, not just a commit — so the back-merge is *necessary but not sufficient*. The develop image is built by GitHub Actions (`develop.yml`, `actions/checkout` with `fetch-depth: 0`, then `git describe --tags --always`), so the version it prints depends on which tags exist **on the `github` remote**, not on your local clone or on `gitea`.
(There is no `origin` remote here — push to `gitea` **and** `github` explicitly, and always push release tags to both.)
This repo has two writable remotes — `gitea` (canonical, where commits land) and `github` (where the `:develop` and release images are built) — plus `upstream` (docmost, never push). **`git push <branch>` does NOT push tags**; tags must be pushed explicitly and *to each remote separately*. A release tag that only lives on `gitea` is invisible to the GitHub Actions build: even with the tagged commit fully in `develop`'s history (step 7 done), `git describe` on the GitHub runner falls back to the previous tag it *does* have, so the develop image keeps showing e.g. `v0.91.0-NNN` while `git describe` locally already says `v0.93.0-NN`.
Fix / checklist when develop still shows the old version after a back-merge:
1. Confirm the tag is missing on github: `git ls-remote --tags github` (compare with `gitea`).
2. Push it there: `git push github vX.Y.Z` (and `git push gitea vX.Y.Z` if it is missing on gitea too). Note: pushing a `v*` tag to `github` also triggers `release.yml` (multi-arch GHCR images + draft Release) — expected, but be aware.
3. Re-run the develop build (`gh workflow run Develop`, or push any commit to `develop`) so `git describe` re-resolves with the tag now present.
(The `git push origin ...` in steps 6–7 above is shorthand — there is no `origin` remote here; substitute `gitea` **and** `github` as appropriate, and always push release tags to both.)
## Planning docs

View File

@@ -22,16 +22,6 @@ per-workspace rolling-day token budget.
### Added
- **Custom pretty-links for shared pages (`/l/:alias`).** A page editor can give
any publicly shared page a short, memorable, workspace-scoped vanity address
backed by a new `share_aliases` table. Hitting `/l/<alias>` issues a `302`
(never `301`, since the target is retargetable) to the canonical
`/share/<key>/p/<slug>` page; an unknown, dangling, or no-longer-readable alias
serves the plain SPA index so that the existence of a name never leaks. An
alias can be moved to another page (with a confirm-reassign guard) and the
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
alias any workspace member can reclaim. (#205)
- **Persistent AI-chat history as the source of truth + server-side export.**
An assistant turn is now persisted to the database step by step: the row is
inserted upfront as `streaming` and updated as each agent step finishes, then
@@ -193,18 +183,6 @@ embeds — plus a large batch of security hardening and test coverage.
injected into the `<head>` of public share pages only (for analytics such as
Google Analytics or Yandex.Metrika), kept separate from the member-facing
HTML-embed feature.
- **Offline reading support**: opened pages, their sidebar tree, breadcrumb
children, and comments are cached in IndexedDB (TanStack Query persister plus
`y-indexeddb` for the page's Yjs document), and a PWA service worker
(vite-plugin-pwa) serves an app shell so previously opened pages stay readable
offline. The offline cache (persisted query cache, Yjs page documents, and the
service-worker API cache) is cleared on logout so a previous user's private
data does not remain in the browser.
- **Mobile bootstrap**: a `returnToken` opt-in on login so native/mobile clients
can request the access JWT in the response body (`data.authToken`) in addition
to the httpOnly cookie (the web client stays cookie-only); an optional
OpenAPI/Swagger UI at `/api/docs` gated by `SWAGGER_ENABLED` (off by default);
and new env vars `CORS_ALLOWED_ORIGINS`, `SWAGGER_ENABLED`, `CAP_SERVER_URL`.
- **MCP**: a hierarchical tree mode for `list_pages`, and per-user auth for the
embedded `/mcp` endpoint.
- **Page tree**: Expand all / Collapse all for the space tree, and
@@ -220,12 +198,6 @@ embeds — plus a large batch of security hardening and test coverage.
### Changed
- **CORS is now an explicit allowlist** (replaces the previous unconfigured
`app.enableCors()`). The same-origin web client is unaffected, but any
separately-hosted cross-domain client must now be listed in
`CORS_ALLOWED_ORIGINS` (native Capacitor/Ionic/localhost WebView origins are
allowed automatically). Requests with no `Origin` header (server-to-server)
are still allowed.
- HTML embed blocks now render inside a sandboxed iframe (separate origin) and,
when the workspace HTML-embed toggle is on, can be inserted by any member
(previously admin-only). Turning the toggle off hides existing embeds and

View File

@@ -10,7 +10,6 @@
<meta name="theme-color" content="#0E1117" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/app-icon-192x192.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-touch-fullscreen" content="yes" />
<meta name="apple-mobile-web-app-title" content="Gitmost" />

View File

@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.94.1",
"version": "0.94.0",
"scripts": {
"dev": "node scripts/copy-vad-assets.mjs && vite",
"build": "node scripts/copy-vad-assets.mjs && tsc && vite build",
@@ -33,9 +33,7 @@
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
"@tabler/icons-react": "3.40.0",
"@tanstack/query-async-storage-persister": "5.90.17",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-query-persist-client": "5.90.17",
"@tanstack/react-virtual": "3.13.24",
"ai": "6.0.207",
"alfaaz": "1.1.0",
@@ -47,7 +45,6 @@
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"idb-keyval": "6.2.5",
"jotai": "2.18.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.7",
@@ -98,7 +95,6 @@
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vite-plugin-pwa": "1.3.0",
"vitest": "4.1.6"
}
}

View File

@@ -464,15 +464,6 @@
"Move page": "Move page",
"Move page to a different space.": "Move page to a different space.",
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
"Offline — changes are saved locally and will sync when you reconnect": "Offline — changes are saved locally and will sync when you reconnect",
"Syncing changes…": "Syncing changes…",
"All changes synced": "All changes synced",
"Update available": "Update available",
"Reload": "Reload",
"Make available offline": "Make available offline",
"Saving page for offline use...": "Saving page for offline use...",
"Page is now available offline": "Page is now available offline",
"Failed to make page available offline": "Failed to make page available offline",
"Table of contents": "Table of contents",
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
"Share": "Share",
@@ -1327,15 +1318,5 @@
"Protocol": "Protocol",
"How chat requests are sent and how reasoning is surfaced": "How chat requests are sent and how reasoning is surfaced",
"OpenAI-compatible (surfaces reasoning)": "OpenAI-compatible (surfaces reasoning)",
"OpenAI (official)": "OpenAI (official)",
"Custom address": "Custom address",
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
"This address is already in use": "This address is already in use",
"Move custom address?": "Move custom address?",
"Move here": "Move here",
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
"The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?",
"Failed to set custom address": "Failed to set custom address",
"Failed to remove custom address": "Failed to remove custom address"
"OpenAI (official)": "OpenAI (official)"
}

View File

@@ -474,15 +474,6 @@
"Move page": "Переместить страницу",
"Move page to a different space.": "Переместите страницу в другое пространство.",
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
"Offline — changes are saved locally and will sync when you reconnect": "Нет сети — изменения сохраняются локально и синхронизируются при восстановлении соединения",
"Syncing changes…": "Синхронизация изменений…",
"All changes synced": "Все изменения синхронизированы",
"Update available": "Доступно обновление",
"Reload": "Перезагрузить",
"Make available offline": "Сделать доступным офлайн",
"Saving page for offline use...": "Сохраняем страницу для офлайн-доступа…",
"Page is now available offline": "Страница доступна офлайн",
"Failed to make page available offline": "Не удалось сделать страницу доступной офлайн",
"Table of contents": "Оглавление",
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
"Share": "Поделиться",
@@ -1184,15 +1175,5 @@
"Protocol": "Протокол",
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
"OpenAI (official)": "OpenAI (официальный)",
"Custom address": "Пользовательский адрес",
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
"This address is already in use": "Этот адрес уже занят",
"Move custom address?": "Переместить пользовательский адрес?",
"Move here": "Переместить сюда",
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
"The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?",
"Failed to set custom address": "Не удалось задать пользовательский адрес",
"Failed to remove custom address": "Не удалось удалить пользовательский адрес"
"OpenAI (official)": "OpenAI (официальный)"
}

View File

@@ -1,19 +1,30 @@
{
"id": "/",
"name": "Gitmost",
"short_name": "Gitmost",
"description": "Gitmost - open-source collaborative documentation and knowledge base.",
"lang": "en",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0E1117",
"theme_color": "#0E1117",
"icons": [
{ "src": "icons/favicon-16x16.png", "type": "image/png", "sizes": "16x16" },
{ "src": "icons/favicon-32x32.png", "type": "image/png", "sizes": "32x32" },
{ "src": "icons/app-icon-192x192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
{ "src": "icons/app-icon-512x512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" }
{
"src": "icons/favicon-16x16.png",
"type": "image/png",
"sizes": "16x16"
},
{
"src": "icons/favicon-32x32.png",
"type": "image/png",
"sizes": "32x32"
},
{
"src": "icons/app-icon-192x192.png",
"type": "image/png",
"sizes": "180x180 192x192"
},
{
"src": "icons/app-icon-512x512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}

View File

@@ -23,7 +23,6 @@ import { acceptInvitation } from "@/features/workspace/services/workspace-servic
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { clearOfflineCache } from "@/features/offline/clear-offline-cache";
export default function useAuth() {
const { t } = useTranslation();
@@ -124,13 +123,6 @@ export default function useAuth() {
const handleLogout = async () => {
setCurrentUser(RESET);
await logout();
// Purge the previous user's offline data while the page is still alive —
// window.location.replace below would otherwise interrupt async cleanup.
try {
await clearOfflineCache();
} catch {
// best-effort: never block logout on cache cleanup
}
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
};

View File

@@ -10,12 +10,6 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>("");
// Local (IndexedDB) persistence sync state for the current page's Y.Doc.
export const isLocalSyncedAtom = atom<boolean>(false);
// Remote (Hocuspocus) sync state for the current page's Y.Doc.
export const isRemoteSyncedAtom = atom<boolean>(false);
export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);

View File

@@ -104,19 +104,6 @@
min-width: 0;
}
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`,
which pushes the first text line ~0.5em below the "N." marker (aligned to
flex-start), making the number float above the text. Drop the outer margins
so the marker and the first line share the same top edge — same approach
used for callouts in core.css. */
.definitionContent > :first-child {
margin-top: 0;
}
.definitionContent > :last-child {
margin-bottom: 0;
}
.backLink {
flex: 0 0 auto;
cursor: pointer;

View File

@@ -1,21 +0,0 @@
import { createContext, useContext } from "react";
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type * as Y from "yjs";
// Shared collaboration providers lifted above the title/body editors so that
// both siblings bind to the SAME Y.Doc and HocuspocusProvider. The title lives
// in a dedicated 'title' fragment of the same doc as the body.
export interface EditorProvidersContextValue {
ydoc: Y.Doc;
remote: HocuspocusProvider;
providersReady: boolean;
}
export const EditorProvidersContext =
createContext<EditorProvidersContextValue | null>(null);
// Returns the shared providers, or null when rendered outside of a provider.
// Consumers must be null-safe (the body editor falls back to a non-collab mode).
export function useEditorProviders(): EditorProvidersContextValue | null {
return useContext(EditorProvidersContext);
}

View File

@@ -32,8 +32,6 @@ import {
pageEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
import { usePageCollabProviders } from "@/features/editor/hooks/use-page-collab-providers";
import { EditorProvidersContext } from "@/features/editor/contexts/editor-providers-context";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
@@ -86,10 +84,6 @@ export function FullEditor({
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const isEditMode = currentPageEditMode === PageEditMode.Edit;
// Single shared Y.Doc + HocuspocusProvider for both the title and body
// editors (title lives in the 'title' fragment of the same doc).
const { ydoc, remote, providersReady } = usePageCollabProviders(pageId);
// Apply the user's saved preference only once on initial load, not on every
// page navigation — so the mode sticks across navigations within a session.
useEffect(() => {
@@ -109,30 +103,26 @@ export function FullEditor({
<MemoizedFixedToolbar />
)}
<MemoizedDeletedPageBanner slugId={slugId} />
<EditorProvidersContext.Provider
value={ydoc && remote ? { ydoc, remote, providersReady } : null}
>
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
title={title}
spaceSlug={spaceSlug}
editable={editable}
/>
<PageByline
creator={creator}
contributors={contributors}
editable={editable}
isEditMode={isEditMode}
isDictationEnabled={isDictationEnabled}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</EditorProvidersContext.Provider>
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
title={title}
spaceSlug={spaceSlug}
editable={editable}
/>
<PageByline
creator={creator}
contributors={contributors}
editable={editable}
isEditMode={isEditMode}
isDictationEnabled={isDictationEnabled}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</Container>
);
}

View File

@@ -1,205 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
HocuspocusProvider,
onStatusParameters,
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
onStatelessParameters,
} from "@hocuspocus/provider";
import { useAtom, useSetAtom } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import {
isLocalSyncedAtom,
isRemoteSyncedAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { jwtDecode } from "jwt-decode";
export interface PageCollabProviders {
ydoc: Y.Doc | null;
remote: HocuspocusProvider | null;
socket: HocuspocusProviderWebsocket | null;
providersReady: boolean;
}
/**
* Owns the full collaboration provider lifecycle for a page so that the title
* and body editors can share a single Y.Doc + HocuspocusProvider. The behavior
* is relocated verbatim from page-editor.tsx: it creates the providers once per
* pageId, connects/disconnects on idle/visibility, attaches each render,
* destroys on unmount, refreshes the collab token on auth failure, and applies
* the onStateless 'page.updated' cache update.
*/
export function usePageCollabProviders(pageId: string): PageCollabProviders {
const collaborationURL = useCollaborationUrl();
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
const setIsLocalSyncedAtom = useSetAtom(isLocalSyncedAtom);
const setIsRemoteSyncedAtom = useSetAtom(isRemoteSyncedAtom);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
// The provider-creating effect runs only once per pageId, so any token read
// inside its handlers would be captured STALE (the old token at first render).
// Mirror the latest token into a ref the auth-failure handler can read live.
const collabTokenRef = useRef<string | undefined>(undefined);
useEffect(() => {
collabTokenRef.current = collabQuery?.token;
}, [collabQuery?.token]);
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
// Providers only created once per pageId
const providersRef = useRef<{
ydoc: Y.Doc;
local: IndexeddbPersistence;
remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
// Mirror the local/remote sync flags into shared atoms so the header
// indicator can read them. These atoms are the single source of truth; the
// wrappers keep the existing call sites valid while driving only the atoms.
const setLocalSynced = (value: boolean) => {
setIsLocalSyncedAtom(value);
};
const setRemoteSynced = (value: boolean) => {
setIsRemoteSyncedAtom(value);
};
useEffect(() => {
if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setRemoteSynced(event.state);
};
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
const onAuthenticationFailedHandler = () => {
// Read the token from the ref, not the closed-over `collabQuery`: this
// handler is created once and would otherwise decode a stale token after
// a refetch. A missing/malformed token must NOT crash the handler —
// jwtDecode(undefined) throws — so treat any decode failure as "needs
// refresh" and proceed to refetch + reconnect instead of getting stuck.
const token = collabTokenRef.current;
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
if (token) {
try {
const payload = jwtDecode<{ exp: number }>(token);
needsRefresh = Date.now() / 1000 >= payload.exp;
} catch {
needsRefresh = true; // malformed token -> refresh
}
}
if (!needsRefresh) return;
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
};
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
onStateless: onStatelessHandler,
});
local.on("synced", onLocalSyncedHandler);
providersRef.current = { ydoc, socket, local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
// Reset shared sync state on page change/unmount.
setLocalSynced(false);
setRemoteSynced(false);
};
}, [pageId]);
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket;
if (
isIdle &&
documentState === "hidden" &&
yjsConnectionStatus === WebSocketStatus.Connected
) {
socket.disconnect();
return;
}
if (
documentState === "visible" &&
yjsConnectionStatus === WebSocketStatus.Disconnected
) {
resetIdle();
socket.connect();
}
}, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
return {
ydoc: providersRef.current?.ydoc ?? null,
remote: providersRef.current?.remote ?? null,
socket: providersRef.current?.socket ?? null,
providersReady,
};
}

View File

@@ -6,7 +6,16 @@ import React, {
useRef,
useState,
} from "react";
import { WebSocketStatus } from "@hocuspocus/provider";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
HocuspocusProvider,
onStatusParameters,
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
onStatelessParameters,
} from "@hocuspocus/provider";
import {
Editor,
EditorContent,
@@ -19,15 +28,13 @@ import {
mainExtensions,
} from "@/features/editor/extensions/extensions";
import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
isLocalSyncedAtom,
isRemoteSyncedAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import {
activeCommentIdAtom,
@@ -51,8 +58,10 @@ import {
} from "@/features/editor/components/common/editor-paste-handler.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import { useDebouncedCallback } from "@mantine/hooks";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
@@ -63,7 +72,9 @@ import {
GitmostInsertRecordingResult,
gitmostInsertRecordingIntoEditor,
} from "@/features/editor/gitmost/gitmost-recording.ts";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
@@ -88,6 +99,7 @@ export default function PageEditor({
canComment,
}: PageEditorProps) {
const { t } = useTranslation();
const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null);
@@ -101,10 +113,22 @@ export default function PageEditor({
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
// Always holds the latest collab token. The provider effect below runs once
// per pageId, so a handler created inside it would otherwise close over a
// stale `collabQuery`. Reading the ref gives the current token instead.
const collabTokenRef = useRef<string | undefined>(undefined);
useEffect(() => {
collabTokenRef.current = collabQuery?.token;
}, [collabQuery?.token]);
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
@@ -113,27 +137,141 @@ export default function PageEditor({
[isComponentMounted],
);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
// Shared providers + Y.Doc lifted into full-editor via context. The provider
// lifecycle (creation, idle/visibility connect, attach, destroy, token
// refresh) lives in usePageCollabProviders. Null-safe when rendered without
// the context (defensive) — in practice full-editor always provides it.
const editorProviders = useEditorProviders();
const remote = editorProviders?.remote ?? null;
const providersReady = editorProviders?.providersReady ?? false;
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
useEffect(() => {
if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setIsLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state);
};
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
const onAuthenticationFailedHandler = () => {
// Read the latest token via the ref (the closure-captured `collabQuery`
// may be stale). Guard the decode: a missing or unparseable token must
// not throw "Invalid token specified" and should trigger a refresh so
// the editor reconnects even when the initial token fetch failed.
const token = collabTokenRef.current;
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
if (token) {
try {
// A token that decodes but lacks a numeric `exp` must be treated as
// expired (`Date.now()/1000 >= undefined` is `false`, which would
// otherwise skip the reconnect), so refresh on any missing/non-number exp.
const exp = jwtDecode<{ exp?: number }>(token).exp;
needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp;
} catch {
needsRefresh = true;
}
}
if (!needsRefresh) return;
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
};
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
onStateless: onStatelessHandler,
});
local.on("synced", onLocalSyncedHandler);
providersRef.current = { socket, local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
};
}, [pageId]);
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket;
if (
isIdle &&
documentState === "hidden" &&
yjsConnectionStatus === WebSocketStatus.Connected
) {
socket.disconnect();
return;
}
if (
documentState === "visible" &&
yjsConnectionStatus === WebSocketStatus.Disconnected
) {
resetIdle();
socket.connect();
}
}, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
const extensions = useMemo(() => {
if (!providersReady || !remote || !currentUser?.user) {
if (!providersReady || !providersRef.current || !currentUser?.user) {
return mainExtensions;
}
const remoteProvider = providersRef.current.remote;
return [
...mainExtensions,
...collabExtensions(remote, currentUser?.user),
...collabExtensions(remoteProvider, currentUser?.user),
];
}, [providersReady, remote, currentUser?.user]);
}, [providersReady, currentUser?.user]);
const editor = useEditor(
{
@@ -375,7 +513,7 @@ export default function PageEditor({
{editor &&
!editorIsEditable &&
(editable || canComment) &&
remote && <ReadonlyBubbleMenu editor={editor} />}
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)}

View File

@@ -10,15 +10,9 @@ ul[data-type="taskList"] {
display: flex;
> label {
/* Box exactly one text-line tall and center the checkbox in it, so the
checkbox lines up with the first line of the item's text. This tracks
the editor line-height (--mantine-line-height-xl) instead of a magic
padding-top that drifts from the real line box. */
padding-top: 0.2rem;
flex: 0 0 auto;
margin-right: 0.5rem;
height: calc(var(--mantine-line-height-xl, 1.65) * 1em);
display: inline-flex;
align-items: center;
user-select: none;
}

View File

@@ -1,5 +1,5 @@
import "@/features/editor/styles/index.css";
import { useEffect } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading";
@@ -11,14 +11,14 @@ import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
import { updatePageData } from "@/features/page/queries/page-query";
import {
updatePageData,
useUpdateTitlePageMutation,
} from "@/features/page/queries/page-query";
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
import { useAtom } from "jotai";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import {
Collaboration,
isChangeOrigin,
} from "@tiptap/extension-collaboration";
import { History } from "@tiptap/extension-history";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
@@ -28,9 +28,6 @@ import localEmitter from "@/lib/local-emitter.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts";
import { platformModifierKey } from "@/lib";
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
export interface TitleEditorProps {
pageId: string;
@@ -48,83 +45,65 @@ export function TitleEditor({
editable,
}: TitleEditorProps) {
const { t } = useTranslation();
const { mutateAsync: updateTitlePageMutationAsync } =
useUpdateTitlePageMutation();
const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom);
const emit = useQueryEmit();
const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
// Shared Y.Doc (title lives in its own 'title' fragment of the same doc as
// the body). Yjs is the source of truth for the title content.
const editorProviders = useEditorProviders();
const ydoc = editorProviders?.ydoc ?? null;
const providersReady = editorProviders?.providersReady ?? false;
// Until the shared doc is ready, the collaborative editor binds nothing and
// would render an empty heading until the Yjs 'title' fragment hydrates. Show
// a non-editable static <h1> with the `title` prop in the meantime. The prop
// is NEVER fed into the collaborative editor (Yjs stays the single source of
// truth — seeding it would duplicate the title).
const titleReady = providersReady && !!ydoc;
const titleEditor = useEditor(
{
extensions: [
Document.extend({
content: "heading",
}),
Heading.configure({
levels: [1],
}),
Text,
Placeholder.configure({
placeholder: t("Untitled"),
showOnlyWhenEditable: false,
}),
// Bind the title to the dedicated 'title' fragment of the shared doc.
// Collaboration also manages undo/redo, so the History extension is
// intentionally omitted (it would conflict with Yjs). When the doc is
// not ready yet the editor renders empty until the doc arrives.
...(ydoc
? [Collaboration.configure({ document: ydoc, field: "title" })]
: []),
EmojiCommand,
],
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setTitleEditor(editor);
}
const titleEditor = useEditor({
extensions: [
Document.extend({
content: "heading",
}),
Heading.configure({
levels: [1],
}),
Text,
Placeholder.configure({
placeholder: t("Untitled"),
showOnlyWhenEditable: false,
}),
History.configure({
depth: 20,
}),
EmojiCommand,
],
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setTitleEditor(editor);
setActivePageId(pageId);
}
},
onUpdate({ editor }) {
debounceUpdate();
},
editable: editable,
content: title,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
"aria-label": t("Page title"),
},
onUpdate({ editor, transaction }) {
// Drive URL + tree propagation only on genuine local edits; skip
// remote/collab-origin Yjs updates to avoid feedback loops.
if (transaction && isChangeOrigin(transaction)) return;
debouncedPropagateTitle(editor.getText());
},
editable: editable,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
"aria-label": t("Page title"),
},
handleDOMEvents: {
keydown: (_view, event) => {
if (platformModifierKey(event) && event.code === "KeyS") {
event.preventDefault();
return true;
}
if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
},
handleDOMEvents: {
keydown: (_view, event) => {
if (platformModifierKey(event) && event.code === "KeyS") {
event.preventDefault();
return true;
}
if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
},
},
},
[pageId, ydoc],
);
});
useEffect(() => {
const anchorId = window.location.hash
@@ -134,42 +113,59 @@ export function TitleEditor({
navigate(pageSlug, { replace: true });
}, [title]);
// On a local title change: update the URL slug and propagate the change to
// the live tree/breadcrumbs for online users. No REST round-trip — the title
// itself is persisted through Yjs. Offline this simply no-ops the socket
// emit and the title syncs on reconnect.
const debouncedPropagateTitle = useDebouncedCallback((titleText: string) => {
const anchorId = window.location.hash
? window.location.hash.substring(1)
: undefined;
navigate(buildPageUrl(spaceSlug, slugId, titleText, anchorId), {
replace: true,
const saveTitle = useCallback(() => {
if (!titleEditor || activePageId !== pageId) return;
if (
titleEditor.getText() === title ||
(titleEditor.getText() === "" && title === null)
) {
return;
}
updateTitlePageMutationAsync({
pageId: pageId,
title: titleEditor.getText(),
}).then((page) => {
const event: UpdateEvent = {
operation: "updateOne",
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
payload: {
title: page.title,
slugId: page.slugId,
parentPageId: page.parentPageId,
icon: page.icon,
},
};
if (page.title !== titleEditor.getText()) return;
updatePageData(page);
localEmitter.emit("message", event);
emit(event);
});
}, [pageId, title, titleEditor]);
const page =
queryClient.getQueryData<IPage>(["pages", slugId]) ??
queryClient.getQueryData<IPage>(["pages", pageId]);
if (!page) return;
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
const updatedPage: IPage = { ...page, title: titleText };
const event: UpdateEvent = {
operation: "updateOne",
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
payload: {
title: titleText,
slugId: page.slugId,
parentPageId: page.parentPageId,
icon: page.icon,
},
};
updatePageData(updatedPage);
localEmitter.emit("message", event);
emit(event);
}, 500);
useEffect(() => {
// Do not overwrite the title while the user is actively editing it. The
// server rebroadcasts PAGE_UPDATED to the author too, and that echo can
// carry a title that lags behind what the user has just typed; resetting
// content from it here would drop in-progress characters and jump the
// cursor. Apply external title changes only when the field is not focused.
if (
titleEditor &&
!titleEditor.isDestroyed &&
!titleEditor.isFocused &&
title !== titleEditor.getText()
) {
titleEditor.commands.setContent(title);
}
}, [pageId, title, titleEditor]);
useEffect(() => {
setTimeout(() => {
@@ -179,6 +175,13 @@ export function TitleEditor({
}, 300);
}, [titleEditor]);
useEffect(() => {
return () => {
// force-save title on navigation
saveTitle();
};
}, [pageId]);
useEffect(() => {
if (!titleEditor) return;
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
@@ -245,22 +248,16 @@ export function TitleEditor({
return (
<div className="page-title">
{titleReady ? (
<EditorContent
editor={titleEditor}
onKeyDown={(event) => {
// First handle the search hotkey
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
<EditorContent
editor={titleEditor}
onKeyDown={(event) => {
// First handle the search hotkey
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
// Then handle other key events
handleTitleKeyDown(event);
}}
/>
) : (
// Static, non-editable fallback so the title is visible before Yjs
// hydrates the 'title' fragment. Not wired into the collaborative editor.
<h1>{title}</h1>
)}
// Then handle other key events
handleTitleKeyDown(event);
}}
/>
</div>
);
}

View File

@@ -1,107 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// vi.mock factories are hoisted above imports, so the spies they reference must
// be declared via vi.hoisted (also hoisted). These are inspected by assertions.
const h = vi.hoisted(() => ({
clear: vi.fn(),
del: vi.fn(),
}));
// The module under test imports the app entry at load time — it must be mocked.
vi.mock("@/main.tsx", () => ({
queryClient: { clear: h.clear },
}));
vi.mock("idb-keyval", () => ({
del: h.del,
}));
import { clearOfflineCache } from "./clear-offline-cache";
import { OFFLINE_CACHE_KEY } from "./query-persister";
// jsdom does not provide indexedDB.databases() or Cache Storage, so the browser
// globals are stubbed per-test. We restore them afterwards.
const originalIndexedDB = (globalThis as any).indexedDB;
const originalCaches = (globalThis as any).caches;
beforeEach(() => {
h.clear.mockClear();
h.del.mockClear();
});
afterEach(() => {
(globalThis as any).indexedDB = originalIndexedDB;
(globalThis as any).caches = originalCaches;
vi.restoreAllMocks();
});
describe("clearOfflineCache", () => {
it("resolves without throwing when the browser globals are absent", async () => {
(globalThis as any).indexedDB = undefined;
delete (globalThis as any).caches;
await expect(clearOfflineCache()).resolves.toBeUndefined();
// The two store-agnostic steps still run.
expect(h.clear).toHaveBeenCalledTimes(1);
expect(h.del).toHaveBeenCalledWith(OFFLINE_CACHE_KEY);
});
it("deletes only `page.*` IndexedDB databases and only `api-get-cache` caches", async () => {
const deleteDatabase = vi.fn((_name: string) => {
const request: any = {};
// Resolve the deletion on the next microtask, like a real IDBRequest.
queueMicrotask(() => request.onsuccess && request.onsuccess());
return request;
});
(globalThis as any).indexedDB = {
databases: vi
.fn()
.mockResolvedValue([
{ name: "page.aaa" },
{ name: "page.bbb" },
{ name: "keyval-store" },
{ name: undefined },
]),
deleteDatabase,
};
const cacheDelete = vi.fn().mockResolvedValue(true);
(globalThis as any).caches = {
keys: vi
.fn()
.mockResolvedValue([
"workbox-runtime-https://app/api-get-cache",
"other-cache",
]),
delete: cacheDelete,
};
await expect(clearOfflineCache()).resolves.toBeUndefined();
// Only the two page.* databases are deleted.
expect(deleteDatabase).toHaveBeenCalledTimes(2);
expect(deleteDatabase).toHaveBeenCalledWith("page.aaa");
expect(deleteDatabase).toHaveBeenCalledWith("page.bbb");
// Only the api-get-cache entry is deleted.
expect(cacheDelete).toHaveBeenCalledTimes(1);
expect(cacheDelete).toHaveBeenCalledWith(
"workbox-runtime-https://app/api-get-cache",
);
});
it("never throws even if a step rejects (best-effort)", async () => {
h.del.mockRejectedValueOnce(new Error("idb boom"));
(globalThis as any).indexedDB = {
databases: vi.fn().mockRejectedValue(new Error("databases boom")),
deleteDatabase: vi.fn(),
};
(globalThis as any).caches = {
keys: vi.fn().mockRejectedValue(new Error("caches boom")),
delete: vi.fn(),
};
await expect(clearOfflineCache()).resolves.toBeUndefined();
expect(h.clear).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,92 +0,0 @@
import { del } from "idb-keyval";
import { queryClient } from "@/main.tsx";
import { OFFLINE_CACHE_KEY } from "./query-persister";
/**
* Best-effort purge of all of the current user's offline data from the browser.
*
* On logout the previous user's private data would otherwise linger locally and
* be readable by the next person on the device. This clears the three offline
* stores the app writes:
* 1. the in-memory + IndexedDB-persisted TanStack Query cache (idb-keyval key
* `OFFLINE_CACHE_KEY`),
* 2. the Yjs page documents (IndexedDB databases named `page.<id>` created by
* y-indexeddb in make-offline.ts), and
* 3. any legacy service worker `api-get-cache` Cache Storage entry. The
* Workbox runtime no longer creates this cache (the GET /api NetworkFirst
* rule was removed — offline reads come from the persisted RQ cache), so
* this is now a defensive cleanup for caches left by older app versions.
*
* Fully best-effort: every step is isolated so a single failure neither blocks
* the remaining steps nor throws to the caller (logout must never be blocked on
* cache cleanup). Callers may ignore the resolved value.
*
* Limitations:
* - Deleting the Yjs page databases relies on `indexedDB.databases()`, which
* is unavailable in some browsers (notably Firefox). There we skip silently;
* those `page.<id>` databases are then left in place.
* - Cache Storage clearing only runs where `caches` exists (secure contexts /
* service-worker-capable browsers).
*/
export async function clearOfflineCache(): Promise<void> {
// 1a. Drop the in-memory query cache immediately.
try {
queryClient.clear();
} catch {
// best-effort: ignore in-memory cache reset failures
}
// 1b. Delete the persisted RQ cache from IndexedDB.
try {
await del(OFFLINE_CACHE_KEY);
} catch {
// best-effort: ignore persisted-cache deletion failures
}
// 2. Delete the Yjs page IndexedDB databases (`page.<id>`).
// `indexedDB.databases()` is not implemented everywhere (e.g. Firefox); when
// it is missing we cannot enumerate the page databases, so we skip silently.
try {
if (
typeof indexedDB !== "undefined" &&
typeof indexedDB.databases === "function"
) {
const dbs = await indexedDB.databases();
for (const db of dbs) {
const name = db?.name;
if (typeof name !== "string" || !name.startsWith("page.")) continue;
try {
// Fire-and-forget delete; await a thin wrapper so a slow delete does
// not race the page teardown, but never reject on it.
await new Promise<void>((resolve) => {
const request = indexedDB.deleteDatabase(name);
request.onsuccess = () => resolve();
request.onerror = () => resolve();
request.onblocked = () => resolve();
});
} catch {
// best-effort per database
}
}
}
} catch {
// best-effort: ignore enumeration/deletion failures
}
// 3. Clear any legacy service worker API cache. Current builds no longer
// create it, but an older client may have left an "api-get-cache" entry
// (Workbox may prefix the name), so match by substring rather than exact name.
try {
if ("caches" in window) {
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key.includes("api-get-cache"))
.map((key) => caches.delete(key)),
);
}
} catch {
// best-effort: ignore Cache Storage failures
}
}

View File

@@ -1,258 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// vi.mock factories are hoisted above imports, so any spy they reference must be
// declared with vi.hoisted (which is hoisted as well). These shared spies are
// inspected by the assertions below.
const h = vi.hoisted(() => ({
ydocDestroy: vi.fn(),
idbDestroy: vi.fn(),
providerOn: vi.fn(),
providerOff: vi.fn(),
providerDestroy: vi.fn(),
}));
// The module under test imports the app entry at load time — it must be mocked.
vi.mock("@/main.tsx", () => ({
queryClient: { setQueryData: vi.fn(), prefetchQuery: vi.fn() },
}));
vi.mock("@/features/page/services/page-service", () => ({
getPageById: vi.fn(),
getPageBreadcrumbs: vi.fn(),
getSidebarPages: vi.fn(),
getAllSidebarPages: vi.fn(),
}));
vi.mock("@/features/space/services/space-service.ts", () => ({
getSpaceById: vi.fn(),
}));
vi.mock("@/features/comment/services/comment-service", () => ({
getPageComments: vi.fn(),
}));
// Use the `function` form (not an arrow) so Vitest binds the constructor return
// value when the module under test calls `new Y.Doc()` etc.
vi.mock("yjs", () => ({
Doc: vi.fn(function () {
return { destroy: h.ydocDestroy };
}),
}));
vi.mock("y-indexeddb", () => ({
IndexeddbPersistence: vi.fn(function () {
return { destroy: h.idbDestroy };
}),
}));
vi.mock("@hocuspocus/provider", () => ({
HocuspocusProvider: vi.fn(function () {
return { on: h.providerOn, off: h.providerOff, destroy: h.providerDestroy };
}),
}));
import {
warmInfiniteAll,
warmPageYdoc,
makePageAvailableOffline,
} from "./make-offline";
import { queryClient } from "@/main.tsx";
import {
getPageById,
getPageBreadcrumbs,
getSidebarPages,
} from "@/features/page/services/page-service";
import { getPageComments } from "@/features/comment/services/comment-service";
const setQueryData = (queryClient as any).setQueryData as ReturnType<
typeof vi.fn
>;
const prefetchQuery = (queryClient as any).prefetchQuery as ReturnType<
typeof vi.fn
>;
beforeEach(() => {
// Clear call history WITHOUT wiping the mock implementations the vi.mock
// factories installed (vi.clearAllMocks would drop the constructor return
// objects and break the provider/idb/yjs spies).
setQueryData.mockClear();
prefetchQuery.mockReset();
prefetchQuery.mockResolvedValue(undefined);
(getPageById as ReturnType<typeof vi.fn>).mockReset();
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockReset();
(getSidebarPages as ReturnType<typeof vi.fn>).mockReset();
(getPageComments as ReturnType<typeof vi.fn>).mockReset();
h.ydocDestroy.mockClear();
h.idbDestroy.mockClear();
h.providerOn.mockClear();
h.providerOff.mockClear();
h.providerDestroy.mockClear();
});
describe("warmInfiniteAll", () => {
it("warms a single page and writes the InfiniteData cache shape", async () => {
const res = { items: [{ id: 1 }], meta: { nextCursor: null } };
const fetchPage = vi.fn().mockResolvedValue(res);
await warmInfiniteAll(["comments", "p1"], fetchPage);
expect(fetchPage).toHaveBeenCalledTimes(1);
expect(fetchPage).toHaveBeenCalledWith(undefined);
expect(setQueryData).toHaveBeenCalledTimes(1);
expect(setQueryData).toHaveBeenCalledWith(["comments", "p1"], {
pages: [res],
pageParams: [undefined],
});
});
it("walks the cursor chain across multiple pages", async () => {
const r0 = { items: [], meta: { nextCursor: "c1" } };
const r1 = { items: [], meta: { nextCursor: "c2" } };
const r2 = { items: [], meta: { nextCursor: null } };
const fetchPage = vi
.fn()
.mockResolvedValueOnce(r0)
.mockResolvedValueOnce(r1)
.mockResolvedValueOnce(r2);
await warmInfiniteAll(["comments", "p1"], fetchPage);
expect(fetchPage).toHaveBeenCalledTimes(3);
expect(fetchPage.mock.calls.map((c) => c[0])).toEqual([
undefined,
"c1",
"c2",
]);
const payload = setQueryData.mock.calls[0][1];
expect(payload.pages).toEqual([r0, r1, r2]);
expect(payload.pageParams).toEqual([undefined, "c1", "c2"]);
});
it("caps pagination at maxPages", async () => {
// Always returns a non-null cursor — the cap is the only thing that stops it.
const fetchPage = vi
.fn()
.mockResolvedValue({ items: [], meta: { nextCursor: "more" } });
await warmInfiniteAll(["comments", "p1"], fetchPage, 2);
expect(fetchPage).toHaveBeenCalledTimes(2);
const payload = setQueryData.mock.calls[0][1];
expect(payload.pages).toHaveLength(2);
});
it("returns true on success", async () => {
const fetchPage = vi
.fn()
.mockResolvedValue({ items: [], meta: { nextCursor: null } });
await expect(
warmInfiniteAll(["comments", "p1"], fetchPage),
).resolves.toBe(true);
});
it("reports errors (returns false) and never writes the cache on failure", async () => {
const fetchPage = vi.fn().mockRejectedValue(new Error("network"));
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(
warmInfiniteAll(["comments", "p1"], fetchPage),
).resolves.toBe(false);
expect(setQueryData).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
});
describe("makePageAvailableOffline", () => {
const okPage = {
id: "uuid-1",
slugId: "slug-1",
space: { slug: "space-slug" },
};
it("returns ok:true with no failures when every step succeeds", async () => {
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
const result = await makePageAvailableOffline({
pageId: "uuid-1",
spaceId: "space-uuid",
});
expect(result).toEqual({ ok: true, failed: [] });
});
it("returns ok:false with the failed step label when a warm step fails", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
// Comments warm fails -> labeled "comments".
(getPageComments as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("network"),
);
const result = await makePageAvailableOffline({
pageId: "uuid-1",
spaceId: "space-uuid",
});
expect(result.ok).toBe(false);
expect(result.failed).toContain("comments");
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
});
describe("warmPageYdoc", () => {
afterEach(() => {
vi.useRealTimers();
});
it("resolves on synced, detaches the listener once, and tears everything down (settle-once)", async () => {
const promise = warmPageYdoc("p1", "ws://x");
// Grab the synced handler the provider registered.
expect(h.providerOn).toHaveBeenCalledWith("synced", expect.any(Function));
const handler = h.providerOn.mock.calls.find(
(c) => c[0] === "synced",
)![1] as () => void;
handler();
await expect(promise).resolves.toBeUndefined();
// Listener detached and everything cleaned up.
expect(h.providerOff).toHaveBeenCalledWith("synced", expect.any(Function));
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
// Firing the handler again must NOT re-run cleanup (settled guard).
handler();
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
});
it("resolves and cleans up after the timeout when synced never fires", async () => {
vi.useFakeTimers();
const promise = warmPageYdoc("p1", "ws://x");
// Do not fire "synced"; let the 8s safety timeout settle it.
await vi.advanceTimersByTimeAsync(8000);
await expect(promise).resolves.toBeUndefined();
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,272 +0,0 @@
import * as Y from "yjs";
import { IndexeddbPersistence } from "y-indexeddb";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { queryClient } from "@/main.tsx";
import {
getPageById,
getPageBreadcrumbs,
getSidebarPages,
} from "@/features/page/services/page-service";
import {
pageKeys,
sidebarPagesQueryOptions,
} from "@/features/page/queries/page-query";
import { spaceByIdQueryOptions } from "@/features/space/queries/space-query";
import { RQ_KEY } from "@/features/comment/queries/comment-query";
import { getPageComments } from "@/features/comment/services/comment-service";
import { IPage } from "@/features/page/types/page.types";
import { IPagination } from "@/lib/types.ts";
/**
* Fully paginate an infinite query and write the @tanstack InfiniteData cache
* shape ({ pages, pageParams }) that the matching useInfiniteQuery hook reads.
*
* The default prefetchInfiniteQuery only warms the FIRST page, which leaves
* hooks that treat hasNextPage as still-loading (e.g. the comments panel)
* spinning forever offline, and silently truncates large lists. This walks the
* cursor chain until it runs out (or hits maxPages) so the whole list is cached.
*
* Best-effort: a failure does not throw (a partial/failed warm is still useful),
* but it is reported — the error is logged with context and `false` is returned
* so the caller can record the failed step instead of silently succeeding.
*
* Returns true if the whole list was paginated and written, false on any error.
*
* Exported for unit testing of the cursor-walk / cache-write behavior.
*/
export async function warmInfiniteAll<T>(
queryKey: readonly unknown[],
fetchPage: (cursor: string | undefined) => Promise<IPagination<T>>,
maxPages = 50,
): Promise<boolean> {
try {
const pages: IPagination<T>[] = [];
const pageParams: (string | undefined)[] = [];
let cursor: string | undefined = undefined;
for (let i = 0; i < maxPages; i++) {
const res = await fetchPage(cursor);
pages.push(res);
pageParams.push(cursor);
cursor = res?.meta?.nextCursor ?? undefined;
if (!cursor) break;
}
queryClient.setQueryData(queryKey, { pages, pageParams });
return true;
} catch (error) {
console.error("warmInfiniteAll failed", { queryKey, error });
return false;
}
}
export interface MakePageAvailableOfflineParams {
pageId: string;
spaceId?: string;
}
/**
* Outcome of {@link makePageAvailableOffline}. `ok` is true only when every warm
* step succeeded; `failed` lists the labels of the steps that failed (a subset
* of: "page", "space", "tree", "breadcrumbs", "comments").
*/
export interface MakePageAvailableOfflineResult {
ok: boolean;
failed: string[];
}
/**
* Best-effort prefetch of a page's read queries so they get persisted to
* IndexedDB and become readable offline.
*
* Each step is isolated and this function does NOT throw — a partial warm is
* still useful. Instead of silently succeeding, every failed step is logged
* with a label and recorded in the returned result: `{ ok, failed }` where
* `ok` is true only if no step failed and `failed` lists the failed step
* labels. Only meaningful while online (the underlying requests must succeed).
*/
export async function makePageAvailableOffline({
pageId,
spaceId,
}: MakePageAvailableOfflineParams): Promise<MakePageAvailableOfflineResult> {
const failed: string[] = [];
// Fetch the page document ONCE and write it under BOTH cache keys, exactly
// like usePageQuery's onData effect. Every page consumer reads
// pageKeys.detail(slugId) (usePageQuery keys on the slugId for routed reads),
// so warming only the uuid key would leave the offline page blank.
let page: IPage | undefined;
try {
page = await getPageById({ pageId });
queryClient.setQueryData(pageKeys.detail(page.slugId), page);
queryClient.setQueryData(pageKeys.detail(page.id), page);
} catch (error) {
console.error("makePageAvailableOffline: page step failed", {
pageId,
error,
});
failed.push("page");
}
// Warm the space — page.tsx renders nothing until the space query resolves
// (useGetSpaceBySlugQuery). Awaited (not the fire-and-forget prefetchSpace) so
// the space is actually persisted before the caller fires its toast. Shares
// spaceByIdQueryOptions so the key/fn cannot drift from the hook.
try {
const spaceSlug = page?.space?.slug;
if (spaceSlug) {
await queryClient.prefetchQuery(spaceByIdQueryOptions(spaceSlug));
}
} catch (error) {
console.error("makePageAvailableOffline: space step failed", {
pageId,
error,
});
failed.push("space");
}
// Warm the sidebar tree root so the WHOLE root level renders offline (matches
// useGetRootSidebarPagesQuery's pageKeys.rootSidebar(spaceId) infinite cache).
// Fully paginated so large root levels are not truncated at 100.
if (spaceId) {
const ok = await warmInfiniteAll(pageKeys.rootSidebar(spaceId), (cursor) =>
getSidebarPages({ spaceId, cursor, limit: 100 }),
);
if (!ok) failed.push("tree");
}
// Warm the children of the page and of every ancestor so the path to this
// page is expandable offline. We MIRROR fetchAllAncestorChildren exactly via
// sidebarPagesQueryOptions — same pageKeys.sidebar({ pageId, spaceId }) key,
// same getAllSidebarPages fn (which aggregates ALL children pages, so nothing
// is truncated at 100), same 30min staleTime — otherwise the warmed cache
// would never be read by the offline tree.
const warmSidebarChildren = async (id: string): Promise<boolean> => {
try {
// Keep EXACTLY { pageId, spaceId } so the key hashes identically to
// fetchAllAncestorChildren's (no parentPageId, no extra fields).
const params = { pageId: id, spaceId };
await queryClient.prefetchQuery(sidebarPagesQueryOptions(params));
return true;
} catch (error) {
console.error("makePageAvailableOffline: tree node step failed", {
pageId: id,
error,
});
return false;
}
};
// The page's own children.
if (!(await warmSidebarChildren(pageId))) failed.push("tree");
// Each ancestor's children. Use the breadcrumbs endpoint ONLY to discover the
// ancestor ids — we intentionally do NOT cache the breadcrumbs themselves
// (the UI derives the path from the tree).
try {
const ancestors = (await getPageBreadcrumbs(pageId)) as
| Array<{ id?: string }>
| undefined;
for (const ancestor of ancestors ?? []) {
const ancestorId = ancestor?.id;
if (!ancestorId || ancestorId === pageId) continue;
if (!(await warmSidebarChildren(ancestorId))) failed.push("tree");
}
} catch (error) {
console.error("makePageAvailableOffline: breadcrumbs step failed", {
pageId,
error,
});
failed.push("breadcrumbs");
}
// Comments (matches useCommentsQuery's RQ_KEY(pageId) infinite cache).
// useCommentsQuery reports isLoading while hasNextPage is true, so warming
// only the first page leaves the offline comments panel spinning forever on
// pages with >100 comments. Fully paginate so the last cached page has no
// nextCursor and the panel settles offline.
const commentsOk = await warmInfiniteAll(RQ_KEY(pageId), (cursor) =>
getPageComments({ pageId, cursor, limit: 100 }),
);
if (!commentsOk) failed.push("comments");
// Dedupe — the tree label can be recorded once per failed node/ancestor.
const uniqueFailed = [...new Set(failed)];
return { ok: uniqueFailed.length === 0, failed: uniqueFailed };
}
/**
* Best-effort warm-up of the page's Yjs document into IndexedDB so the editor
* can open offline.
*
* Opens a local IndexeddbPersistence plus a transient HocuspocusProvider to
* pull the server state into IndexedDB, then tears both down once synced (or
* after a timeout). Entirely wrapped in try/catch — NEVER throws.
*
* Only meaningful when online at warm time; offline it is a no-op that resolves.
*/
export async function warmPageYdoc(
pageId: string,
collabUrl: string,
token?: string,
): Promise<void> {
let ydoc: Y.Doc | null = null;
let local: IndexeddbPersistence | null = null;
let remote: HocuspocusProvider | null = null;
try {
const documentName = `page.${pageId}`;
ydoc = new Y.Doc();
local = new IndexeddbPersistence(documentName, ydoc);
remote = new HocuspocusProvider({
url: collabUrl,
name: documentName,
document: ydoc,
token,
});
const provider = remote;
await new Promise<void>((resolve) => {
let settled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const finish = () => {
if (settled) return;
settled = true;
// Clear the pending timeout and detach the listener so neither leaks
// after we resolve.
if (timeoutId !== undefined) clearTimeout(timeoutId);
try {
provider.off("synced", finish);
} catch {
// best-effort
}
resolve();
};
// Resolve once the server state has synced into the local doc...
provider.on("synced", finish);
// ...or give up after a short timeout so we never hang.
timeoutId = setTimeout(finish, 8000);
});
} catch {
// best-effort
} finally {
try {
remote?.destroy();
} catch {
// best-effort
}
try {
local?.destroy();
} catch {
// best-effort
}
try {
ydoc?.destroy();
} catch {
// best-effort
}
}
}

View File

@@ -1,84 +0,0 @@
import { describe, it, expect } from "vitest";
import {
shouldDehydrateOfflineQuery,
OFFLINE_PERSIST_ROOTS,
} from "./query-persister";
// Small helper to build the structural query shape the predicate reads.
const makeQuery = (status: string, queryKey: readonly unknown[]) =>
({ state: { status }, queryKey }) as any;
describe("shouldDehydrateOfflineQuery", () => {
it("returns true for a successful query whose root is in the allowlist", () => {
expect(shouldDehydrateOfflineQuery(makeQuery("success", ["pages", "abc"]))).toBe(
true,
);
expect(
shouldDehydrateOfflineQuery(
makeQuery("success", ["sidebar-pages", { pageId: "p", spaceId: "s" }]),
),
).toBe(true);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["comments", "p1"])),
).toBe(true);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["space", "s"])),
).toBe(true);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["recent-changes"])),
).toBe(true);
});
it("returns false when the status is not success (status gate)", () => {
expect(
shouldDehydrateOfflineQuery(makeQuery("pending", ["pages", "abc"])),
).toBe(false);
expect(
shouldDehydrateOfflineQuery(makeQuery("error", ["pages", "abc"])),
).toBe(false);
});
it("returns false for a successful query whose root is NOT in the allowlist (privacy gate)", () => {
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["collab-token", "ws"])),
).toBe(false);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["trash", "s"])),
).toBe(false);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["unknown"])),
).toBe(false);
});
it("returns false for an empty/undefined queryKey", () => {
// String(undefined) is not a member of the allowlist.
expect(shouldDehydrateOfflineQuery(makeQuery("success", []))).toBe(false);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", undefined as any)),
).toBe(false);
});
});
describe("OFFLINE_PERSIST_ROOTS", () => {
it("contains exactly the expected 8 navigation/read roots", () => {
const expected = [
"pages",
"sidebar-pages",
"root-sidebar-pages",
"breadcrumbs",
"comments",
"space",
"spaces",
"recent-changes",
];
expect(OFFLINE_PERSIST_ROOTS.size).toBe(8);
for (const root of expected) {
expect(OFFLINE_PERSIST_ROOTS.has(root)).toBe(true);
}
});
it("does NOT contain volatile/auth keys", () => {
expect(OFFLINE_PERSIST_ROOTS.has("collab-token")).toBe(false);
expect(OFFLINE_PERSIST_ROOTS.has("trash")).toBe(false);
});
});

View File

@@ -1,50 +0,0 @@
import { get, set, del } from "idb-keyval";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
// Structural subset of a TanStack Query we read when deciding what to persist.
// We avoid importing the branded `Query` class because the persist-client and
// react-query may resolve to different `@tanstack/query-core` copies, whose
// `Query` types are nominally incompatible (private brand). This structural
// shape stays assignable to whichever copy the persister expects.
type DehydratableQuery = {
state: { status: string };
queryKey: readonly unknown[];
};
// idb-keyval key under which TanStack Query persists its dehydrated cache.
// Exported so the logout cache-clear logic deletes the exact same key (no
// magic-string drift between persist and purge).
export const OFFLINE_CACHE_KEY = "gitmost-rq-cache";
// IndexedDB-backed storage adapter for TanStack Query's async persister.
const idbStorage = {
getItem: (key: string) => get<string>(key).then((v) => v ?? null),
setItem: (key: string, value: string) => set(key, value),
removeItem: (key: string) => del(key),
};
export const queryPersister = createAsyncStoragePersister({
storage: idbStorage,
key: OFFLINE_CACHE_KEY,
throttleTime: 1000,
});
// Only navigation/read query roots are persisted for offline reading.
// Volatile/auth queries (collab tokens, trash lists) are intentionally excluded.
export const OFFLINE_PERSIST_ROOTS = new Set<string>([
"pages",
"sidebar-pages",
"root-sidebar-pages",
"breadcrumbs",
"comments",
"space",
"spaces",
"recent-changes",
]);
export function shouldDehydrateOfflineQuery(query: DehydratableQuery): boolean {
return (
query.state.status === "success" &&
OFFLINE_PERSIST_ROOTS.has(String(query.queryKey?.[0]))
);
}

View File

@@ -11,8 +11,6 @@ import {
IconList,
IconMarkdown,
IconPrinter,
IconCloud,
IconCloudCheck,
IconStar,
IconStarFilled,
IconTrash,
@@ -36,8 +34,6 @@ import { Trans, useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
import { htmlToMarkdown } from "@docmost/editor-ext";
import {
isLocalSyncedAtom,
isRemoteSyncedAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
@@ -381,16 +377,14 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
function ConnectionWarning() {
const { t } = useTranslation();
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
const [showWarning, setShowWarning] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDisconnected = ["disconnected", "connecting"].includes(
yjsConnectionStatus,
);
useEffect(() => {
const isDisconnected = ["disconnected", "connecting"].includes(
yjsConnectionStatus,
);
if (isDisconnected) {
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
@@ -402,7 +396,7 @@ function ConnectionWarning() {
}
setShowWarning(false);
}
}, [isDisconnected]);
}, [yjsConnectionStatus]);
// Cleanup only on unmount
useEffect(() => {
@@ -413,59 +407,22 @@ function ConnectionWarning() {
};
}, []);
// State (1): offline/disconnected — changes are kept locally. Preserve the
// existing >5s debounce before surfacing this state.
if (isDisconnected) {
if (!showWarning) return null;
if (!showWarning) return null;
const offlineLabel = t(
"Offline — changes are saved locally and will sync when you reconnect",
);
return (
<Tooltip label={offlineLabel} openDelay={250} withArrow>
<ThemeIcon
variant="default"
c="red"
role="status"
aria-label={offlineLabel}
style={{ border: "none" }}
>
<IconWifiOff size={20} stroke={2} />
</ThemeIcon>
</Tooltip>
);
}
// State (2): connected but the remote replica is not fully caught up yet.
if (!isRemoteSynced || !isLocalSynced) {
const syncingLabel = t("Syncing changes…");
return (
<Tooltip label={syncingLabel} openDelay={250} withArrow>
<ThemeIcon
variant="default"
c="dimmed"
role="status"
aria-label={syncingLabel}
style={{ border: "none" }}
>
<IconCloud size={20} stroke={2} />
</ThemeIcon>
</Tooltip>
);
}
// State (3): fully synced — subtle confirmation indicator.
const syncedLabel = t("All changes synced");
return (
<Tooltip label={syncedLabel} openDelay={250} withArrow>
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<ThemeIcon
variant="default"
c="dimmed"
c="red"
role="status"
aria-label={syncedLabel}
aria-label={t("Real-time editor connection lost. Retrying...")}
style={{ border: "none" }}
>
<IconCloudCheck size={20} stroke={2} />
<IconWifiOff size={20} stroke={2} />
</ThemeIcon>
</Tooltip>
);

View File

@@ -1,7 +1,6 @@
import {
InfiniteData,
QueryKey,
queryOptions,
useInfiniteQuery,
UseInfiniteQueryResult,
useMutation,
@@ -44,36 +43,11 @@ import { SpaceTreeNode } from "@/features/page/tree/types";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
/**
* Centralized React Query key factories for page queries. The hooks below and
* the offline warm path (features/offline/make-offline.ts) share these so the
* runtime keys can never silently drift apart.
*/
export const pageKeys = {
detail: (idOrSlug: string) => ["pages", idOrSlug] as const,
sidebar: (data: unknown) => ["sidebar-pages", data] as const,
rootSidebar: (spaceId: string) => ["root-sidebar-pages", spaceId] as const,
breadcrumbs: (pageId: string) => ["breadcrumbs", pageId] as const,
recentChanges: (spaceId?: string) => ["recent-changes", spaceId] as const,
};
/**
* Shared queryOptions for the sidebar-pages (ancestor children) query. Both
* fetchAllAncestorChildren and the offline warm path consume this so the key,
* queryFn and staleTime stay identical.
*/
export const sidebarPagesQueryOptions = (params: SidebarPagesParams) =>
queryOptions({
queryKey: pageKeys.sidebar(params),
queryFn: () => getAllSidebarPages(params),
staleTime: 30 * 60 * 1000,
});
export function usePageQuery(
pageInput: Partial<IPageInput>,
): UseQueryResult<IPage, Error> {
const query = useQuery({
queryKey: pageKeys.detail(pageInput.pageId),
queryKey: ["pages", pageInput.pageId],
queryFn: () => getPageById(pageInput),
enabled: !!pageInput.pageId,
staleTime: 5 * 60 * 1000,
@@ -82,9 +56,9 @@ export function usePageQuery(
useEffect(() => {
if (query.data) {
if (isValidUuid(pageInput.pageId)) {
queryClient.setQueryData(pageKeys.detail(query.data.slugId), query.data);
queryClient.setQueryData(["pages", query.data.slugId], query.data);
} else {
queryClient.setQueryData(pageKeys.detail(query.data.id), query.data);
queryClient.setQueryData(["pages", query.data.id], query.data);
}
}
}, [query.data]);
@@ -106,20 +80,18 @@ export function useCreatePageMutation() {
}
export function updatePageData(data: IPage) {
const pageBySlug = queryClient.getQueryData<IPage>(
pageKeys.detail(data.slugId),
);
const pageById = queryClient.getQueryData<IPage>(pageKeys.detail(data.id));
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
if (pageBySlug) {
queryClient.setQueryData(pageKeys.detail(data.slugId), {
queryClient.setQueryData(["pages", data.slugId], {
...pageBySlug,
...data,
});
}
if (pageById) {
queryClient.setQueryData(pageKeys.detail(data.id), { ...pageById, ...data });
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
}
invalidateOnUpdatePage(
@@ -131,6 +103,12 @@ export function updatePageData(data: IPage) {
);
}
export function useUpdateTitlePageMutation() {
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => updatePage(data),
});
}
export function useUpdatePageMutation() {
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => updatePage(data),
@@ -167,11 +145,11 @@ export function useRemovePageMutation() {
});
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
const cached = queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
const cached = queryClient.getQueryData<IPage>(["pages", pageId]);
if (cached) {
const stamped = { ...cached, deletedAt: new Date() };
queryClient.setQueryData(pageKeys.detail(cached.id), stamped);
queryClient.setQueryData(pageKeys.detail(cached.slugId), stamped);
queryClient.setQueryData(["pages", cached.id], stamped);
queryClient.setQueryData(["pages", cached.slugId], stamped);
}
invalidateOnDeletePage(pageId);
@@ -292,11 +270,8 @@ export function useRestorePageMutation() {
// Replace would strip space/permissions/content and break the editor.
const merge = (cached: IPage | undefined) =>
cached ? { ...cached, ...restoredPage } : cached;
queryClient.setQueryData<IPage>(pageKeys.detail(restoredPage.id), merge);
queryClient.setQueryData<IPage>(
pageKeys.detail(restoredPage.slugId),
merge,
);
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge);
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
},
onError: (error) => {
notifications.show({
@@ -311,7 +286,7 @@ export function useGetSidebarPagesQuery(
data: SidebarPagesParams | null,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
return useInfiniteQuery({
queryKey: pageKeys.sidebar(data),
queryKey: ["sidebar-pages", data],
enabled: !!data?.pageId || !!data?.spaceId,
queryFn: ({ pageParam }) =>
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
@@ -322,7 +297,7 @@ export function useGetSidebarPagesQuery(
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
return useInfiniteQuery({
queryKey: pageKeys.rootSidebar(data.spaceId),
queryKey: ["root-sidebar-pages", data.spaceId],
queryFn: async ({ pageParam }) => {
return getSidebarPages({
spaceId: data.spaceId,
@@ -348,7 +323,7 @@ export function usePageBreadcrumbsQuery(
pageId: string,
): UseQueryResult<Partial<IPage[]>, Error> {
return useQuery({
queryKey: pageKeys.breadcrumbs(pageId),
queryKey: ["breadcrumbs", pageId],
queryFn: () => getPageBreadcrumbs(pageId),
enabled: !!pageId,
});
@@ -360,12 +335,10 @@ export async function fetchAllAncestorChildren(
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
opts?: { fresh?: boolean },
) {
// not using a hook here, so we can call it inside a useEffect hook. Reuse the
// shared sidebarPagesQueryOptions (key + queryFn) so the offline warm path and
// this fetch never drift, but override staleTime for the `fresh` reconnect
// refresh (#159 #8), which must force a server refetch (staleTime 0).
// not using a hook here, so we can call it inside a useEffect hook
const response = await queryClient.fetchQuery({
...sidebarPagesQueryOptions(params),
queryKey: ["sidebar-pages", params],
queryFn: () => getAllSidebarPages(params),
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
});
@@ -375,7 +348,7 @@ export async function fetchAllAncestorChildren(
export function useRecentChangesQuery(spaceId?: string) {
return useInfiniteQuery({
queryKey: pageKeys.recentChanges(spaceId),
queryKey: ["recent-changes", spaceId],
queryFn: ({ pageParam }) =>
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined,
@@ -441,12 +414,12 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
let queryKey: QueryKey = null;
if (data.parentPageId === null) {
queryKey = pageKeys.rootSidebar(data.spaceId);
queryKey = ["root-sidebar-pages", data.spaceId];
} else {
queryKey = pageKeys.sidebar({
pageId: data.parentPageId,
spaceId: data.spaceId,
});
queryKey = [
"sidebar-pages",
{ pageId: data.parentPageId, spaceId: data.spaceId },
];
}
//update all sidebar pages
@@ -506,7 +479,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
//update root sidebar pages haschildern
const rootSideBarMatches = queryClient.getQueriesData({
queryKey: pageKeys.rootSidebar(data.spaceId),
queryKey: ["root-sidebar-pages", data.spaceId],
exact: false,
});
@@ -530,7 +503,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
//update recent changes
queryClient.invalidateQueries({
queryKey: pageKeys.recentChanges(data.spaceId),
queryKey: ["recent-changes", data.spaceId],
});
}
@@ -544,9 +517,9 @@ export function invalidateOnUpdatePage(
invalidatePageTree();
let queryKey: QueryKey = null;
if (parentPageId === null) {
queryKey = pageKeys.rootSidebar(spaceId);
queryKey = ["root-sidebar-pages", spaceId];
} else {
queryKey = pageKeys.sidebar({ pageId: parentPageId, spaceId: spaceId });
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
}
//update all sidebar pages
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
@@ -569,7 +542,7 @@ export function invalidateOnUpdatePage(
//update recent changes
queryClient.invalidateQueries({
queryKey: pageKeys.recentChanges(spaceId),
queryKey: ["recent-changes", spaceId],
});
}
@@ -584,8 +557,8 @@ export function updateCacheOnMovePage(
// Remove page from old parent's cache
const oldQueryKey =
oldParentId === null
? pageKeys.rootSidebar(spaceId)
: pageKeys.sidebar({ pageId: oldParentId, spaceId });
? ["root-sidebar-pages", spaceId]
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
oldQueryKey,
@@ -605,7 +578,7 @@ export function updateCacheOnMovePage(
if (oldParentId !== null) {
const oldParentCache = queryClient.getQueryData<
InfiniteData<IPagination<IPage>>
>(pageKeys.sidebar({ pageId: oldParentId, spaceId }));
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
const remainingChildren =
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
@@ -643,8 +616,8 @@ export function updateCacheOnMovePage(
// Add page to new parent's cache
const newQueryKey =
newParentId === null
? pageKeys.rootSidebar(spaceId)
: pageKeys.sidebar({ pageId: newParentId, spaceId });
? ["root-sidebar-pages", spaceId]
: ["sidebar-pages", { pageId: newParentId, spaceId }];
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
newQueryKey,

View File

@@ -6,7 +6,6 @@ import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconArrowRight,
IconCloudDownload,
IconCopy,
IconDotsVertical,
IconFileExport,
@@ -32,12 +31,6 @@ import {
} from "@/features/favorite/queries/favorite-query";
import { useToggleTemplateMutation } from "@/features/page-embed/queries/page-embed-query";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { getCollaborationUrl } from "@/lib/config.ts";
import {
makePageAvailableOffline,
warmPageYdoc,
} from "@/features/offline/make-offline";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
@@ -72,40 +65,6 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const isFavorited = favoriteIds.has(node.id);
const toggleTemplate = useToggleTemplateMutation();
const isTemplate = !!node.isTemplate;
const { data: collabQuery } = useCollabToken();
const handleMakeAvailableOffline = async () => {
notifications.show({ message: t("Saving page for offline use...") });
try {
// Prefetch read queries so they get persisted to IndexedDB. The result
// reports whether every warm step succeeded.
const result = await makePageAvailableOffline({
pageId: node.id,
spaceId: node.spaceId,
});
// Best-effort: warm the page's Yjs document into IndexedDB.
await warmPageYdoc(node.id, getCollaborationUrl(), collabQuery?.token);
if (result.ok) {
notifications.show({ message: t("Page is now available offline") });
} else {
// Partial warm — the page may still be partly usable offline, but some
// queries failed to cache, so surface it as an error rather than a
// silent success.
notifications.show({
message: t("Failed to make page available offline"),
color: "red",
});
}
} catch {
// makePageAvailableOffline no longer throws, but warmPageYdoc and other
// unexpected failures stay guarded here.
notifications.show({
message: t("Failed to make page available offline"),
color: "red",
});
}
};
const handleToggleTemplate = async () => {
const next = !isTemplate;
@@ -243,17 +202,6 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
{t("Export")}
</Menu.Item>
<Menu.Item
leftSection={<IconCloudDownload size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleMakeAvailableOffline();
}}
>
{t("Make available offline")}
</Menu.Item>
{canEdit && (
<>
<Menu.Item

View File

@@ -14,6 +14,7 @@ import {
useCreatePageMutation,
useRemovePageMutation,
useMovePageMutation,
useUpdatePageMutation,
updateCacheOnMovePage,
} from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -22,6 +23,7 @@ import { getSpaceUrl } from "@/lib/config.ts";
export type UseTreeMutation = {
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
handleCreate: (parentId: string | null) => Promise<void>;
handleRename: (id: string, name: string) => Promise<void>;
handleDelete: (id: string) => Promise<void>;
};
@@ -33,6 +35,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
// children) and then immediately invokes a handler.
const store = useStore();
const createPageMutation = useCreatePageMutation();
const updatePageMutation = useUpdatePageMutation();
const removePageMutation = useRemovePageMutation();
const movePageMutation = useMovePageMutation();
const navigate = useNavigate();
@@ -178,6 +181,20 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
);
const handleRename = useCallback(
async (id: string, name: string) => {
setData((prev) =>
treeModel.update(prev, id, { name } as Partial<SpaceTreeNode>),
);
try {
await updatePageMutation.mutateAsync({ pageId: id, title: name });
} catch (error) {
console.error("Error updating page title:", error);
}
},
[updatePageMutation, setData],
);
const handleDelete = useCallback(
async (id: string) => {
const node = treeModel.find(
@@ -223,7 +240,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
);
return { handleMove, handleCreate, handleDelete };
return { handleMove, handleCreate, handleRename, handleDelete };
}
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {

View File

@@ -1,237 +0,0 @@
import {
ActionIcon,
Button,
Group,
Modal,
Text,
TextInput,
} from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl } from "@/lib/config.ts";
import {
useRemoveShareAliasMutation,
useSetShareAliasMutation,
useShareAliasForPageQuery,
} from "@/features/share/queries/share-query.ts";
import { checkShareAliasAvailability } from "@/features/share/services/share-service.ts";
import {
isValidShareAlias,
normalizeShareAlias,
} from "@/features/share/share-alias.util.ts";
interface ShareAliasSectionProps {
pageId: string;
readOnly: boolean;
}
// The prefix label shown next to the slug input, e.g. "docs.example.com/l/".
function aliasPrefixLabel(): string {
const url = getAppUrl();
const host = url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
return `${host}/l/`;
}
export default function ShareAliasSection({
pageId,
readOnly,
}: ShareAliasSectionProps) {
const { t } = useTranslation();
const { data: currentAlias } = useShareAliasForPageQuery(pageId);
const setAliasMutation = useSetShareAliasMutation();
const removeAliasMutation = useRemoveShareAliasMutation();
const [value, setValue] = useState("");
const [availability, setAvailability] = useState<{
valid: boolean;
available: boolean;
currentPageId: string | null;
} | null>(null);
const [reassign, setReassign] = useState<{
alias: string;
currentPageTitle: string | null;
} | null>(null);
// Seed the input from the page's current alias (if any).
useEffect(() => {
setValue(currentAlias?.alias ?? "");
}, [currentAlias?.alias, pageId]);
const normalized = useMemo(() => normalizeShareAlias(value), [value]);
const isValid = isValidShareAlias(normalized);
const unchanged = currentAlias?.alias === normalized;
// Debounced availability probe (skips when invalid or unchanged).
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
setAvailability(null);
if (!isValid || unchanged) return;
debounceRef.current && clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
try {
const res = await checkShareAliasAvailability(normalized);
setAvailability({
valid: res.valid,
available: res.available,
currentPageId: res.currentPageId,
});
} catch {
setAvailability(null);
}
}, 400);
return () => {
debounceRef.current && clearTimeout(debounceRef.current);
};
}, [normalized, isValid, unchanged]);
const prettyLink = currentAlias?.alias
? `${getAppUrl()}/l/${currentAlias.alias}`
: null;
const handleSave = async (confirmReassign = false) => {
try {
await setAliasMutation.mutateAsync({
pageId,
alias: normalized,
confirmReassign,
});
setReassign(null);
} catch (error: any) {
// The address already points at another page: prompt to move it here.
if (error?.status === 409 || error?.response?.status === 409) {
const data = error?.response?.data;
if (data?.code === "ALIAS_REASSIGN_REQUIRED") {
setReassign({
alias: normalized,
currentPageTitle: data?.currentPageTitle ?? null,
});
}
}
}
};
const handleRemove = async () => {
if (!currentAlias?.id) return;
await removeAliasMutation.mutateAsync(currentAlias.id);
setValue("");
};
const showInvalid = normalized.length > 0 && !isValid;
const showTaken =
isValid && !unchanged && availability && !availability.available;
return (
<>
<Text size="sm" fw={500} mt="md">
{t("Custom address")}
</Text>
<Text size="xs" c="dimmed" mb={4}>
{t("A short, memorable link you can point at any shared page.")}
</Text>
{prettyLink && (
<Group my="xs" gap={4} wrap="nowrap">
<TextInput
variant="filled"
value={prettyLink}
readOnly
rightSection={<CopyTextButton text={prettyLink} />}
style={{ width: "100%" }}
/>
<ActionIcon
component="a"
variant="default"
target="_blank"
href={prettyLink}
size="sm"
>
<IconExternalLink size={16} />
</ActionIcon>
</Group>
)}
<TextInput
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
// Show the canonical form once the user pauses so what they type maps
// visibly to what gets stored.
onBlur={() => setValue(normalized)}
leftSection={
<Text size="xs" c="dimmed" pl={4} style={{ whiteSpace: "nowrap" }}>
{aliasPrefixLabel()}
</Text>
}
leftSectionWidth={Math.min(aliasPrefixLabel().length * 7 + 12, 180)}
placeholder={t("my-page")}
disabled={readOnly}
error={
showInvalid
? t("Use 2-60 lowercase letters, digits and hyphens")
: showTaken
? t("This address is already in use")
: undefined
}
/>
<Group mt="xs" gap="xs">
<Button
size="compact-sm"
onClick={() => handleSave(false)}
loading={setAliasMutation.isPending}
disabled={readOnly || !isValid || unchanged}
>
{t("Save")}
</Button>
{currentAlias?.id && (
<Button
size="compact-sm"
variant="default"
color="red"
onClick={handleRemove}
loading={removeAliasMutation.isPending}
disabled={readOnly}
>
{t("Remove")}
</Button>
)}
</Group>
<Modal
opened={!!reassign}
onClose={() => setReassign(null)}
title={t("Move custom address?")}
centered
size="sm"
>
<Text size="sm">
{reassign?.currentPageTitle
? t(
'The address "{{alias}}" currently points to "{{title}}". Move it to this page?',
{
alias: reassign?.alias,
title: reassign?.currentPageTitle,
},
)
: t(
'The address "{{alias}}" is already in use. Move it to this page?',
{ alias: reassign?.alias },
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={() => setReassign(null)}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={() => handleSave(true)}
loading={setAliasMutation.isPending}
>
{t("Move here")}
</Button>
</Group>
</Modal>
</>
);
}

View File

@@ -25,7 +25,6 @@ import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css";
import ShareAliasSection from "@/features/share/components/share-alias-section.tsx";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
@@ -254,9 +253,6 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
disabled={readOnly}
/>
</Group>
{pageId && (
<ShareAliasSection pageId={pageId} readOnly={readOnly} />
)}
</>
)}
</>

View File

@@ -10,8 +10,6 @@ import { useTranslation } from "react-i18next";
import {
ICreateShare,
IShare,
IShareAlias,
ISetShareAlias,
ISharedItem,
ISharedPage,
ISharedPageTree,
@@ -22,14 +20,11 @@ import {
import {
createShare,
deleteShare,
getShareAliasForPage,
getSharedPageTree,
getShareForPage,
getShareInfo,
getSharePageInfo,
getShares,
removeShareAlias,
setShareAlias,
updateShare,
} from "@/features/share/services/share-service.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
@@ -175,72 +170,6 @@ export function useDeleteShareMutation() {
});
}
export function useShareAliasForPageQuery(
pageId: string,
): UseQueryResult<IShareAlias | null, Error> {
return useQuery({
// The endpoint resolves to null when the page has no alias; normalize the
// absence so React Query never sees `undefined`.
queryKey: ["share-alias-for-page", pageId],
queryFn: async () => (await getShareAliasForPage(pageId)) ?? null,
enabled: !!pageId,
staleTime: 60 * 1000,
retry: false,
});
}
export function useSetShareAliasMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<IShareAlias, Error, ISetShareAlias>({
mutationFn: (data) => setShareAlias(data),
onSuccess: () => {
queryClient.invalidateQueries({
predicate: (item) =>
["share-alias-for-page", "share-list"].includes(
item.queryKey[0] as string,
),
});
},
onError: (error) => {
// A 409 reassign-required is handled inline by the modal (it shows the
// "move address here?" confirmation), so don't surface a generic toast.
if (error?.["status"] === 409) return;
notifications.show({
message:
error?.["response"]?.data?.message || t("Failed to set custom address"),
color: "red",
});
},
});
}
export function useRemoveShareAliasMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (aliasId) => removeShareAlias(aliasId),
onSuccess: () => {
queryClient.invalidateQueries({
predicate: (item) =>
["share-alias-for-page", "share-list"].includes(
item.queryKey[0] as string,
),
});
},
onError: (error) => {
notifications.show({
message:
error?.["response"]?.data?.message ||
t("Failed to remove custom address"),
color: "red",
});
},
});
}
export function useGetSharedPageTreeQuery(
shareId: string,
): UseQueryResult<ISharedPageTree, Error> {

View File

@@ -4,9 +4,6 @@ import { IPage } from "@/features/page/types/page.types";
import {
ICreateShare,
IShare,
IShareAlias,
IShareAliasAvailability,
ISetShareAlias,
ISharedItem,
ISharedPage,
ISharedPageTree,
@@ -60,33 +57,3 @@ export async function getSharedPageTree(
const req = await api.post<ISharedPageTree>("/shares/tree", { shareId });
return req.data;
}
export async function getShareAliasForPage(
pageId: string,
): Promise<IShareAlias | null> {
const req = await api.post<IShareAlias | null>("/share-aliases/for-page", {
pageId,
});
return req.data;
}
export async function setShareAlias(
data: ISetShareAlias,
): Promise<IShareAlias> {
const req = await api.post<IShareAlias>("/share-aliases/set", data);
return req.data;
}
export async function removeShareAlias(aliasId: string): Promise<void> {
await api.post("/share-aliases/remove", { aliasId });
}
export async function checkShareAliasAvailability(
alias: string,
): Promise<IShareAliasAvailability> {
const req = await api.post<IShareAliasAvailability>(
"/share-aliases/availability",
{ alias },
);
return req.data;
}

View File

@@ -1,32 +0,0 @@
import { describe, it, expect } from "vitest";
import {
isValidShareAlias,
normalizeShareAlias,
} from "@/features/share/share-alias.util.ts";
// Mirrors the server-side util so the modal's live feedback matches what the
// server will accept/store.
describe("normalizeShareAlias", () => {
it("lowercases, trims and maps separators to single hyphens", () => {
expect(normalizeShareAlias(" My Cool_Page ")).toBe("my-cool-page");
});
it("collapses repeated hyphens and trims edges", () => {
expect(normalizeShareAlias("--a---b--")).toBe("a-b");
});
});
describe("isValidShareAlias", () => {
it("accepts ascii hyphen-separated slugs of length 2..60", () => {
expect(isValidShareAlias("hello-world")).toBe(true);
expect(isValidShareAlias("a".repeat(60))).toBe(true);
});
it("rejects too short, edge/double hyphens, uppercase and non-ascii", () => {
expect(isValidShareAlias("a")).toBe(false);
expect(isValidShareAlias("-a")).toBe(false);
expect(isValidShareAlias("a--b")).toBe(false);
expect(isValidShareAlias("Hello")).toBe(false);
expect(isValidShareAlias("привет")).toBe(false);
});
});

View File

@@ -1,26 +0,0 @@
/**
* Client copy of the vanity share-alias helpers. Kept in sync with the server
* (`apps/server/src/core/share/share-alias.util.ts`) so live input feedback
* matches what the server will store/accept. ASCII-only, lowercase, hyphen
* separated, length 2..60.
*/
// Normalize a user-provided vanity alias into canonical ASCII storage form.
export function normalizeShareAlias(raw: string): string {
return (raw ?? "")
.trim()
.toLowerCase()
.replace(/[\s_]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "");
}
const ALIAS_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function isValidShareAlias(alias: string): boolean {
return (
typeof alias === "string" &&
alias.length >= 2 &&
alias.length <= 60 &&
ALIAS_RE.test(alias)
);
}

View File

@@ -75,30 +75,6 @@ export interface IShareInfoInput {
pageId: string;
}
// Vanity /l/:alias pointer.
export interface IShareAlias {
id: string;
workspaceId: string;
alias: string;
pageId: string | null;
creatorId: string | null;
createdAt: string;
updatedAt: string;
}
export interface ISetShareAlias {
pageId: string;
alias: string;
confirmReassign?: boolean;
}
export interface IShareAliasAvailability {
alias: string;
valid: boolean;
available: boolean;
currentPageId: string | null;
}
export interface ISharedPageTree {
share: IShare;
pageTree: Partial<IPage[]>;

View File

@@ -1,6 +1,5 @@
import {
keepPreviousData,
queryOptions,
useInfiniteQuery,
useMutation,
useQuery,
@@ -32,37 +31,11 @@ import { getRecentChanges } from "@/features/page/services/page-service.ts";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
/**
* Centralized React Query key factories for space queries. The hooks below and
* the offline warm path (features/offline/make-offline.ts) share these so the
* runtime keys can never silently drift apart.
*/
export const spaceKeys = {
detail: (idOrSlug: string) => ["space", idOrSlug] as const,
list: (params?: QueryParams) => ["spaces", params] as const,
members: (spaceId: string, query?: string) =>
["spaceMembers", spaceId, query] as const,
};
/**
* Shared queryOptions for fetching a space by id/slug. Both
* useGetSpaceBySlugQuery and the offline warm path consume this so the key,
* queryFn and staleTime stay identical. (`enabled` is intentionally omitted —
* prefetchQuery ignores it anyway and the warm path always passes a real id;
* the hook reapplies `enabled` itself.)
*/
export const spaceByIdQueryOptions = (spaceId: string) =>
queryOptions({
queryKey: spaceKeys.detail(spaceId),
queryFn: () => getSpaceById(spaceId),
staleTime: 5 * 60 * 1000,
});
export function useGetSpacesQuery(
params?: QueryParams,
): UseQueryResult<IPagination<ISpace>, Error> {
return useQuery({
queryKey: spaceKeys.list(params),
queryKey: ["spaces", params],
queryFn: () => getSpaces(params),
placeholderData: keepPreviousData,
refetchOnMount: true,
@@ -71,16 +44,16 @@ export function useGetSpacesQuery(
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
const query = useQuery({
queryKey: spaceKeys.detail(spaceId),
queryKey: ["space", spaceId],
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId,
});
useEffect(() => {
if (query.data) {
if (isValidUuid(spaceId)) {
queryClient.setQueryData(spaceKeys.detail(query.data.slug), query.data);
queryClient.setQueryData(["space", query.data.slug], query.data);
} else {
queryClient.setQueryData(spaceKeys.detail(query.data.id), query.data);
queryClient.setQueryData(["space", query.data.id], query.data);
}
}
}, [query.data]);
@@ -89,11 +62,8 @@ export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
}
export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
// Note: intentionally NOT using spaceByIdQueryOptions here — that factory sets
// a 5min staleTime which would let this prefetch skip fetching fresh data;
// prefetchSpace must always refetch (default staleTime: 0).
queryClient.prefetchQuery({
queryKey: spaceKeys.detail(spaceSlug),
queryKey: ["space", spaceSlug],
queryFn: () => getSpaceById(spaceSlug),
});
@@ -130,8 +100,10 @@ export function useGetSpaceBySlugQuery(
spaceId: string,
): UseQueryResult<ISpace, Error> {
return useQuery({
...spaceByIdQueryOptions(spaceId),
queryKey: ["space", spaceId],
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId,
staleTime: 5 * 60 * 1000,
});
}
@@ -144,16 +116,14 @@ export function useUpdateSpaceMutation() {
onSuccess: (data, variables) => {
notifications.show({ message: t("Space updated successfully") });
const space = queryClient.getQueryData(
spaceKeys.detail(variables.spaceId),
) as ISpace;
const space = queryClient.getQueryData([
"space",
variables.spaceId,
]) as ISpace;
if (space) {
const updatedSpace = { ...space, ...data };
queryClient.setQueryData(
spaceKeys.detail(variables.spaceId),
updatedSpace,
);
queryClient.setQueryData(spaceKeys.detail(data.slug), updatedSpace);
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
queryClient.setQueryData(["space", data.slug], updatedSpace);
}
queryClient.invalidateQueries({
@@ -178,7 +148,7 @@ export function useDeleteSpaceMutation() {
if (variables.slug) {
queryClient.removeQueries({
queryKey: spaceKeys.detail(variables.slug),
queryKey: ["space", variables.slug],
exact: true,
});
}
@@ -186,7 +156,7 @@ export function useDeleteSpaceMutation() {
// Remove space-specific queries
if (variables.id) {
queryClient.removeQueries({
queryKey: spaceKeys.detail(variables.id),
queryKey: ["space", variables.id],
exact: true,
});
@@ -226,7 +196,7 @@ export function useSpaceMembersInfiniteQuery(
query?: string,
) {
return useInfiniteQuery({
queryKey: spaceKeys.members(spaceId, query),
queryKey: ["spaceMembers", spaceId, query],
queryFn: ({ pageParam }) =>
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
enabled: !!spaceId,

View File

@@ -11,8 +11,7 @@ import { MantineProvider } from "@mantine/core";
import { BrowserRouter } from "react-router-dom";
import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HelmetProvider } from "react-helmet-async";
import "./i18n";
import { PostHogProvider } from "posthog-js/react";
@@ -22,12 +21,6 @@ import {
isCloud,
isPostHogEnabled,
} from "@/lib/config.ts";
import {
queryPersister,
shouldDehydrateOfflineQuery,
} from "@/features/offline/query-persister";
import { PwaUpdatePrompt } from "@/pwa/pwa-update-prompt";
import { isCapacitorNativePlatform } from "@/pwa/is-capacitor";
import posthog from "posthog-js";
export const queryClient = new QueryClient({
@@ -37,8 +30,6 @@ export const queryClient = new QueryClient({
refetchOnWindowFocus: false,
retry: false,
staleTime: 5 * 60 * 1000,
// Keep cached read data around long enough to be persisted/restored for offline use.
gcTime: 1000 * 60 * 60 * 24,
},
},
});
@@ -59,34 +50,15 @@ root.render(
<BrowserRouter>
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
<ModalsProvider>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: queryPersister,
maxAge: 1000 * 60 * 60 * 24,
buster: APP_VERSION,
dehydrateOptions: {
shouldDehydrateQuery: shouldDehydrateOfflineQuery,
},
}}
>
<QueryClientProvider client={queryClient}>
<Notifications position="bottom-center" limit={3} zIndex={10000} />
{/* Skip SW registration inside the Capacitor native WebView — the
native shell serves assets itself; a browser SW would conflict. */}
{!isCapacitorNativePlatform() && <PwaUpdatePrompt />}
<HelmetProvider>
<PostHogProvider client={posthog}>
<App />
</PostHogProvider>
</HelmetProvider>
</PersistQueryClientProvider>
</QueryClientProvider>
</ModalsProvider>
</MantineProvider>
</BrowserRouter>,
);
// Service worker registration is owned by <PwaUpdatePrompt /> above (via
// vite-plugin-pwa's useRegisterSW: Workbox precache + prompt-based updates,
// and skipped inside the Capacitor native WebView). The earlier hand-written
// /sw.js registration from the mobile bootstrap was removed here to avoid a
// double registration / competing service worker.

View File

@@ -1,39 +0,0 @@
import { describe, it, expect, afterEach } from "vitest";
import { isCapacitorNativePlatform } from "./is-capacitor";
describe("isCapacitorNativePlatform", () => {
afterEach(() => {
// Keep tests isolated from each other and from the rest of the suite.
delete (globalThis as any).Capacitor;
});
it("returns false when Capacitor is undefined", () => {
expect(isCapacitorNativePlatform()).toBe(false);
});
it("uses isNativePlatform() when it is a function", () => {
(globalThis as any).Capacitor = { isNativePlatform: () => true };
expect(isCapacitorNativePlatform()).toBe(true);
(globalThis as any).Capacitor = { isNativePlatform: () => false };
expect(isCapacitorNativePlatform()).toBe(false);
});
it("falls back to the boolean property when isNativePlatform is not a function", () => {
(globalThis as any).Capacitor = { isNativePlatform: true };
expect(isCapacitorNativePlatform()).toBe(true);
(globalThis as any).Capacitor = { isNativePlatform: false };
expect(isCapacitorNativePlatform()).toBe(false);
});
it("returns false when reading Capacitor throws (try/catch)", () => {
Object.defineProperty(globalThis, "Capacitor", {
configurable: true,
get() {
throw new Error("boom");
},
});
expect(isCapacitorNativePlatform()).toBe(false);
});
});

View File

@@ -1,23 +0,0 @@
/**
* Detects whether the client is running inside a Capacitor native WebView
* (native iOS/Android shell from the feature/mobile-app-bootstrap branch).
*
* This is a pure runtime check against the global `Capacitor` object that the
* native bridge injects — no `@capacitor/*` dependency is added. On the plain
* browser / installed-PWA path `window.Capacitor` is undefined, so this returns
* false and the Workbox service worker registers normally.
*
* Inside the native WebView the SW must NOT register: it would layer a redundant
* (and conflicting) cache over Capacitor's own asset serving and interfere with
* the native auth/CORS flow.
*/
export function isCapacitorNativePlatform(): boolean {
try {
const cap = (globalThis as any)?.Capacitor;
return !!(cap && typeof cap.isNativePlatform === "function"
? cap.isNativePlatform()
: cap?.isNativePlatform);
} catch {
return false;
}
}

View File

@@ -1,59 +0,0 @@
import { useEffect } from "react";
import { Button } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { useRegisterSW } from "virtual:pwa-register/react";
// Stable notification id so we can show/hide a single update prompt.
const UPDATE_NOTIFICATION_ID = "pwa-update-available";
/**
* Listens for a waiting service worker and surfaces a Mantine notification
* prompting the user to reload into the new version.
*
* Must be mounted inside the Mantine provider subtree (Notifications must be
* available). Renders nothing itself.
*/
export function PwaUpdatePrompt() {
const { t } = useTranslation();
const {
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegisterError(error) {
// Best-effort: a failed registration must not break the app.
console.error("Service worker registration error:", error);
},
});
useEffect(() => {
if (!needRefresh) return;
notifications.show({
id: UPDATE_NOTIFICATION_ID,
title: t("Update available"),
message: (
<Button
size="xs"
variant="light"
mt="xs"
onClick={() => updateServiceWorker(true)}
>
{t("Reload")}
</Button>
),
autoClose: false,
withCloseButton: true,
});
// Hide the notification when the prompt is no longer needed / on cleanup.
return () => {
notifications.hide(UPDATE_NOTIFICATION_ID);
};
}, [needRefresh, t, updateServiceWorker]);
return null;
}
export default PwaUpdatePrompt;

View File

@@ -1,32 +0,0 @@
import { describe, it, expect } from "vitest";
import { isApiPath, isCollabOrSocketPath } from "./sw-strategy";
describe("isApiPath", () => {
it("matches the /api segment and its subtree", () => {
expect(isApiPath("/api")).toBe(true);
expect(isApiPath("/api/")).toBe(true);
expect(isApiPath("/api/pages")).toBe(true);
});
it("does not over-match sibling paths", () => {
expect(isApiPath("/apidocs")).toBe(false);
expect(isApiPath("/apixyz")).toBe(false);
expect(isApiPath("/")).toBe(false);
expect(isApiPath("/pages")).toBe(false);
});
});
describe("isCollabOrSocketPath", () => {
it("matches the /collab and /socket.io segments and their subtrees", () => {
expect(isCollabOrSocketPath("/collab")).toBe(true);
expect(isCollabOrSocketPath("/collab/x")).toBe(true);
expect(isCollabOrSocketPath("/socket.io")).toBe(true);
expect(isCollabOrSocketPath("/socket.io/abc")).toBe(true);
});
it("does not over-match sibling paths", () => {
expect(isCollabOrSocketPath("/collaborators")).toBe(false);
expect(isCollabOrSocketPath("/collabx")).toBe(false);
expect(isCollabOrSocketPath("/socket.iox")).toBe(false);
});
});

View File

@@ -1,32 +0,0 @@
/**
* Canonical service-worker routing predicates.
*
* IMPORTANT: With vite-plugin-pwa using Workbox `generateSW`, the
* `runtimeCaching[].urlPattern` functions are serialized standalone into the
* generated service worker and CANNOT reference imported symbols. The matching
* logic is therefore duplicated as inline regex literals in
* apps/client/vite.config.ts. This module is the testable source of truth, and
* the two MUST be kept in sync. This duplication is intentional and is the
* documented Workbox limitation.
*
* Matching is anchored to a path SEGMENT boundary (`^/<seg>(/|$)`) so that
* sibling paths like `/apidocs`, `/collaborators`, `/socket.iox` are NOT
* wrongly treated as API/realtime traffic.
*/
/**
* True when `pathname` is the `/api` segment or anything beneath it.
* `/api` and `/api/...` -> true; `/apidocs`, `/apixyz` -> false.
*/
export function isApiPath(pathname: string): boolean {
return /^\/api(\/|$)/.test(pathname);
}
/**
* True when `pathname` is the `/collab` or `/socket.io` segment (or beneath it).
* `/collab`, `/collab/x`, `/socket.io`, `/socket.io/abc` -> true;
* `/collaborators`, `/collabx`, `/socket.iox` -> false.
*/
export function isCollabOrSocketPath(pathname: string): boolean {
return /^\/(collab|socket\.io)(\/|$)/.test(pathname);
}

View File

@@ -1,4 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" />
/// <reference types="vite-plugin-pwa/info" />
declare const APP_VERSION: string

View File

@@ -1,6 +1,5 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
import * as path from "path";
import { execSync } from "node:child_process";
@@ -54,51 +53,7 @@ export default defineConfig(({ mode }) => {
},
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
},
plugins: [
react(),
VitePWA({
registerType: "prompt",
injectRegister: null,
strategies: "generateSW",
manifest: false,
workbox: {
globPatterns: ["**/*.{js,css,html,svg,png,ico,woff2,json}"],
navigateFallback: "index.html",
// Segment-anchored (`^/<seg>(/|$)`) so navigation requests to these
// segments are consistently excluded from the SPA fallback, mirroring
// the runtimeCaching urlPattern regexes below.
//
// `/share`, `/mcp`, and `/robots.txt` mirror the server static-serve
// exclude list (apps/server/src/main.ts setGlobalPrefix `exclude`):
// robots.txt, the SEO/OG/analytics-injected public share HTML, and the
// embedded MCP endpoint are served by server controllers, so the SW must
// never shadow them with the precached index.html app shell (doing so
// would break SEO and MCP).
navigateFallbackDenylist: [
/^\/api(\/|$)/,
/^\/collab(\/|$)/,
/^\/socket\.io(\/|$)/,
/^\/share(\/|$)/,
/^\/mcp(\/|$)/,
/^\/robots\.txt$/,
],
cleanupOutdatedCaches: true,
clientsClaim: true,
// The urlPattern regexes below mirror apps/client/src/pwa/sw-strategy.ts
// and MUST be kept in sync with it. Workbox `generateSW` serializes these
// functions standalone into the generated service worker, so they cannot
// import the module — the matching logic is intentionally duplicated as
// self-contained inline regex literals anchored to a path segment boundary.
runtimeCaching: [
{ urlPattern: ({ url }) => /^\/(collab|socket\.io)(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
// All /api stays network-only; offline reads come from the persisted
// React Query cache (IndexedDB) + y-indexeddb, not the SW HTTP cache.
{ urlPattern: ({ url }) => /^\/api(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
],
},
devOptions: { enabled: false },
}),
],
plugins: [react()],
build: {
rolldownOptions: {
output: {

View File

@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.94.1",
"version": "0.94.0",
"description": "",
"author": "",
"private": true,
@@ -64,7 +64,6 @@
"@nestjs/platform-fastify": "^11.1.19",
"@nestjs/platform-socket.io": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.2.0",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.19",

View File

@@ -24,9 +24,7 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import {
CollaborationHandler,
CollabEventHandlers,
writeTitleFragment,
} from './collaboration.handler';
import { User } from '@docmost/db/types/entity.types';
@Injectable()
export class CollaborationGateway {
@@ -151,45 +149,6 @@ export class CollaborationGateway {
return this.hocuspocus.openDirectConnection(documentName, context);
}
/**
* Write a new page title INTO the page's Yjs 'title' fragment, Redis-INDEPENDENT.
*
* Unlike the Redis-routed `handleYjsEvent` path — which routes through
* `redisSync?.handleEvent` and SILENTLY no-ops when Redis is disabled
* (COLLAB_DISABLE_REDIS=true → redisSync === null) — this goes straight
* through the local Hocuspocus `openDirectConnection`. The title sync
* therefore works in BOTH single-process (no Redis) and Redis-clustered
* deployments.
*
* openDirectConnection loads the doc from persistence when no editor is
* connected, so this works whether or not an editor is currently open: the
* clear+reseed lands on the loaded doc and is persisted by onStoreDocument.
*
* Provenance: when the caller is the agent, the actor/aiChatId are threaded
* into the connection `context` so onStoreDocument sees `context.actor ===
* 'agent'` for the resulting title store (mirrors the body/REST path). The
* resulting title store is usually a no-op anyway — PageService already wrote
* the same title to the page.title column, so onStoreDocument's
* `titleText !== page.title` guard skips the column write — but we wire the
* context for correctness regardless.
*/
async writePageTitle(
pageId: string,
title: string,
context?: { user?: User; actor?: string; aiChatId?: string },
): Promise<void> {
const documentName = `page.${pageId}`;
const connection = await this.hocuspocus.openDirectConnection(
documentName,
context ?? {},
);
try {
await connection.transact((doc) => writeTitleFragment(doc, title));
} finally {
await connection.disconnect();
}
}
/*
*Can be used before calling openDirectConnection directly
*/

View File

@@ -1,139 +0,0 @@
import * as Y from 'yjs';
import { TiptapTransformer } from '@hocuspocus/transformer';
import { writeTitleFragment } from './collaboration.handler';
import { CollaborationGateway } from './collaboration.gateway';
import {
buildTitleSeedYdoc,
jsonToText,
tiptapExtensions,
} from './collaboration.util';
// Read the plain text held in the doc's 'title' XmlFragment, the same way
// PersistenceExtension.onStoreDocument extracts it before writing page.title.
const readTitleText = (doc: Y.Doc): string => {
const titleJson = TiptapTransformer.fromYdoc(doc, 'title');
return titleJson ? jsonToText(titleJson).trim() : '';
};
describe('writeTitleFragment — the clear+seed title write (Bug 1)', () => {
it('replaces an OLD title fragment with EXACTLY the new title (no duplication)', () => {
// Seed the doc's 'title' fragment with an OLD title, like a real page.
const doc = new Y.Doc();
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
expect(readTitleText(doc)).toBe('Old Title');
writeTitleFragment(doc, 'New Title');
// The fragment must contain EXACTLY the new title — not "Old TitleNew Title"
// (append) or "New TitleNew Title" (duplication). A single heading node.
expect(readTitleText(doc)).toBe('New Title');
const titleJson = TiptapTransformer.fromYdoc(doc, 'title') as any;
expect(titleJson.content).toHaveLength(1);
expect(titleJson.content[0].type).toBe('heading');
});
it('seeds the title fragment when it started empty', () => {
const doc = new Y.Doc();
// Force the 'title' fragment to exist but be empty.
doc.getXmlFragment('title');
expect(readTitleText(doc)).toBe('');
writeTitleFragment(doc, 'First Title');
expect(readTitleText(doc)).toBe('First Title');
});
it('does not corrupt the body when rewriting the title', () => {
// A doc with both a body and an old title; the body must survive untouched.
const doc = new Y.Doc();
const bodyDoc = TiptapTransformer.toYdoc(
{
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'body text' }] },
],
},
'default',
tiptapExtensions,
);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(bodyDoc));
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old')));
writeTitleFragment(doc, 'New');
expect(readTitleText(doc)).toBe('New');
const bodyJson = TiptapTransformer.fromYdoc(doc, 'default');
expect(jsonToText(bodyJson)).toContain('body text');
});
});
describe('CollaborationGateway.writePageTitle — Redis-independent path', () => {
// Build a gateway with only its hocuspocus.openDirectConnection stubbed; the
// method must drive the clear+seed through that direct connection (NOT through
// redisSync), so the title write survives COLLAB_DISABLE_REDIS.
const makeGateway = (doc: Y.Doc) => {
const disconnect = jest.fn().mockResolvedValue(undefined);
const transact = jest.fn(async (fn: (d: Y.Doc) => void) => {
fn(doc);
});
const openDirectConnection = jest
.fn()
.mockResolvedValue({ transact, disconnect });
const gateway = Object.create(CollaborationGateway.prototype);
// redisSync is intentionally null — this is the no-Redis scenario.
gateway.redisSync = null;
gateway.hocuspocus = { openDirectConnection } as any;
return { gateway, openDirectConnection, transact, disconnect };
};
it('writes the new title via openDirectConnection and disconnects', async () => {
const doc = new Y.Doc();
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
const { gateway, openDirectConnection, disconnect } = makeGateway(doc);
await gateway.writePageTitle('page-1', 'New Title', { user: { id: 'u1' } });
expect(openDirectConnection).toHaveBeenCalledWith(
'page.page-1',
expect.objectContaining({ user: { id: 'u1' } }),
);
expect(readTitleText(doc)).toBe('New Title');
expect(disconnect).toHaveBeenCalledTimes(1);
});
it('threads agent provenance into the connection context', async () => {
const doc = new Y.Doc();
const { gateway, openDirectConnection } = makeGateway(doc);
await gateway.writePageTitle('page-1', 'Agent Title', {
user: { id: 'u1' },
actor: 'agent',
aiChatId: 'chat-1',
});
expect(openDirectConnection).toHaveBeenCalledWith(
'page.page-1',
expect.objectContaining({ actor: 'agent', aiChatId: 'chat-1' }),
);
});
it('disconnects even when the transaction throws', async () => {
const disconnect = jest.fn().mockResolvedValue(undefined);
const openDirectConnection = jest.fn().mockResolvedValue({
transact: jest.fn().mockRejectedValue(new Error('boom')),
disconnect,
});
const gateway = Object.create(CollaborationGateway.prototype);
gateway.redisSync = null;
gateway.hocuspocus = { openDirectConnection } as any;
await expect(
gateway.writePageTitle('page-1', 'X', {}),
).rejects.toThrow('boom');
expect(disconnect).toHaveBeenCalledTimes(1);
});
});

View File

@@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common';
import { Hocuspocus, Document } from '@hocuspocus/server';
import { TiptapTransformer } from '@hocuspocus/transformer';
import {
buildTitleSeedYdoc,
prosemirrorNodeToYElement,
tiptapExtensions,
} from './collaboration.util';
@@ -14,35 +13,6 @@ export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers']
>;
/**
* Clear+reseed the 'title' XmlFragment of `doc` so it holds EXACTLY `title`.
*
* Used by the gateway's direct `writePageTitle` method to write a new page
* title INTO the page's Yjs 'title' fragment. The title lives in the same
* Y.Doc as the body; onStoreDocument extracts it on every save, so a REST/MCP
* rename that only updated the page.title DB column would be reverted on the
* next collaborative save unless the Yjs 'title' fragment is kept in sync.
* The whole fragment is replaced (no merge/append),
* mirroring the 'replace' body path: the new title fully supersedes the old.
*
* DELIBERATE TRADE-OFF: because this does a FULL clear+replace of the 'title'
* fragment, a REST/MCP rename arriving while a user is actively editing the
* title in an open editor WILL overwrite that in-progress edit. This is
* acceptable — the title is a short, rarely-concurrently-edited field — and is
* preferable to leaving a stale Yjs title that onStoreDocument would revert the
* DB column to on the next save.
*/
export function writeTitleFragment(doc: Y.Doc, title: string): void {
const titleFragment = doc.getXmlFragment('title');
if (titleFragment.length > 0) {
titleFragment.delete(0, titleFragment.length);
}
const newTitleDoc = buildTitleSeedYdoc(title);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newTitleDoc));
}
@Injectable()
export class CollaborationHandler {
private readonly logger = new Logger(CollaborationHandler.name);

View File

@@ -1,13 +1,9 @@
import * as Y from 'yjs';
import { TiptapTransformer } from '@hocuspocus/transformer';
import {
getPageId,
isEmptyParagraphDoc,
jsonToNode,
prosemirrorNodeToYElement,
buildTitleSeedYdoc,
jsonToText,
tiptapExtensions,
} from './collaboration.util';
import { Node } from '@tiptap/pm/model';
@@ -245,49 +241,3 @@ describe('prosemirrorNodeToYElement', () => {
expect(element.get(1).get(0).toString()).toBe('two');
});
});
describe('buildTitleSeedYdoc', () => {
it('builds a level-1 heading carrying the title text', () => {
const doc = buildTitleSeedYdoc('Hello World');
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
const first = json.content?.[0];
expect(first.type).toBe('heading');
expect(first.attrs.level).toBe(1);
expect(jsonToText(json).trim()).toBe('Hello World');
});
it('produces a non-empty title fragment for a non-empty title', () => {
const doc = buildTitleSeedYdoc('Some Title');
expect(doc.get('title', Y.XmlFragment).length).toBeGreaterThan(0);
});
it('produces a heading with no text child for an empty title', () => {
const doc = buildTitleSeedYdoc('');
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
const first = json.content?.[0];
expect(first.type).toBe('heading');
// No text content for an empty title.
expect(first.content ?? []).toHaveLength(0);
expect(jsonToText(json).trim()).toBe('');
});
it('round-trips a title through build -> extract -> build -> extract', () => {
const title = 'Round Trip Title';
const doc1 = buildTitleSeedYdoc(title);
const text1 = jsonToText(TiptapTransformer.fromYdoc(doc1, 'title')).trim();
const doc2 = buildTitleSeedYdoc(text1);
const text2 = jsonToText(TiptapTransformer.fromYdoc(doc2, 'title')).trim();
expect(text1).toBe(title);
expect(text2).toBe(text1);
});
// Touch tiptapExtensions so the import is exercised (mirrors the brief's import
// list and guards against accidental tree-shaking of the schema dependency).
it('uses the shared tiptap extensions schema', () => {
expect(Array.isArray(tiptapExtensions)).toBe(true);
});
});

View File

@@ -59,7 +59,6 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
import { Node, Schema } from '@tiptap/pm/model';
import * as Y from 'yjs';
import { Logger } from '@nestjs/common';
import { TiptapTransformer } from '@hocuspocus/transformer';
export const tiptapExtensions = [
StarterKit.configure({
@@ -144,34 +143,6 @@ export function jsonToText(tiptapJson: JSONContent) {
return generateText(tiptapJson, tiptapExtensions);
}
/**
* Build a standalone Y.Doc that holds ONLY the page title, in a dedicated Yjs
* fragment named exactly 'title' (the collaborative title-editor contract with
* the client). The ProseMirror shape is a doc with a single level-1 heading
* whose text is the title (empty title => heading with no text child).
*
* The encoded state of the returned doc can be merged into a body doc via
* `Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed))` to seed the title
* fragment for legacy pages. Seeding MUST be guarded by an emptiness check on
* the existing 'title' fragment to avoid the Yjs duplication trap.
*/
export function buildTitleSeedYdoc(title: string): Y.Doc {
return TiptapTransformer.toYdoc(
{
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: title ? [{ type: 'text', text: title }] : [],
},
],
},
'title',
tiptapExtensions,
);
}
export function jsonToNode(tiptapJson: JSONContent) {
const schema = getSchema(tiptapExtensions);
try {

View File

@@ -1,9 +1,3 @@
export const HISTORY_INTERVAL = 5 * 60 * 1000;
export const HISTORY_FAST_INTERVAL = 60 * 1000;
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
// Redis pub/sub channel that bridges a PAGE_UPDATED tree snapshot (a title/icon
// rename) from the standalone collab process to the API process, which is the
// single broadcast authority. Imported by both halves of the bridge:
// PageTreeBridgePublisher (collab process) and PageTreeBridgeSubscriber (API process).
export const COLLAB_TREE_UPDATE_CHANNEL = 'collab:tree-update';

View File

@@ -1,483 +0,0 @@
import * as Y from 'yjs';
import { TiptapTransformer } from '@hocuspocus/transformer';
import { PersistenceExtension } from './persistence.extension';
import { buildTitleSeedYdoc, tiptapExtensions } from '../collaboration.util';
// Direct instantiation with stub deps, mirroring the auth/env unit specs.
const bodyJson = {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
};
// Build a body Y.Doc with a known JSON, plus a monkey-patched broadcastStateless
// (the real Hocuspocus Document supplies it; a bare Y.Doc does not).
const buildDoc = () => {
const d: any = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
d.broadcastStateless = jest.fn();
return d;
};
const cloneOut = (doc: any) =>
JSON.parse(JSON.stringify(TiptapTransformer.fromYdoc(doc, 'default')));
const addTitleFragment = (doc: any, title: string) =>
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc(title)));
describe('PersistenceExtension', () => {
let pageRepo: any;
let pageHistoryRepo: any;
let trx: any;
let db: any;
let aiQueue: any;
let historyQueue: any;
let notificationQueue: any;
let collabHistory: any;
let transclusionService: any;
let ext: PersistenceExtension;
beforeEach(() => {
pageRepo = {
findById: jest.fn(),
updatePage: jest.fn().mockResolvedValue(undefined),
};
pageHistoryRepo = {
findPageLastHistory: jest.fn(),
saveHistory: jest.fn(),
};
trx = {};
db = { transaction: () => ({ execute: (fn: any) => fn(trx) }) };
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
transclusionService = {
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
syncPageReferences: jest.fn().mockResolvedValue(undefined),
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
};
ext = new PersistenceExtension(
pageRepo as any,
pageHistoryRepo as any,
db as any,
aiQueue as any,
historyQueue as any,
notificationQueue as any,
collabHistory as any,
transclusionService as any,
);
});
describe('seedTitleFragment', () => {
it('returns false for empty/whitespace/null titles', () => {
const doc = new Y.Doc();
expect((ext as any).seedTitleFragment(doc, '')).toBe(false);
expect((ext as any).seedTitleFragment(doc, ' ')).toBe(false);
expect((ext as any).seedTitleFragment(doc, null)).toBe(false);
});
it('does NOT re-seed an existing non-empty title fragment', () => {
const doc = new Y.Doc();
addTitleFragment(doc, 'Existing');
expect((ext as any).seedTitleFragment(doc, 'Other')).toBe(false);
const text = TiptapTransformer.fromYdoc(doc, 'title');
expect(JSON.stringify(text)).toContain('Existing');
expect(JSON.stringify(text)).not.toContain('Other');
});
it('seeds an empty fragment from a non-empty title and returns true', () => {
const doc = new Y.Doc();
expect((ext as any).seedTitleFragment(doc, 'Hello')).toBe(true);
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
expect(JSON.stringify(json)).toContain('Hello');
});
it('returns false (defensive) when reading the fragment throws', () => {
const fakeDoc = {
get: () => {
throw new Error('boom');
},
};
expect((ext as any).seedTitleFragment(fakeDoc as any, 'X')).toBe(false);
});
});
describe('onStoreDocument', () => {
const basePage = (overrides: any) => ({
id: 'PAGE_ID',
slugId: 'slug',
spaceId: 'space',
parentPageId: null,
creatorId: 'creator',
contributorIds: ['creator'],
workspaceId: 'ws',
title: 'whatever',
content: null,
lastUpdatedSource: 'user',
createdAt: new Date().toISOString(),
...overrides,
});
const context = { user: { id: 'u1', name: 'U', avatarUrl: null } };
it('no-op when neither body nor title changed', async () => {
const document = buildDoc();
const page = basePage({
content: cloneOut(document),
title: 'hello title',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
expect(pageRepo.updatePage).not.toHaveBeenCalled();
expect(document.broadcastStateless).not.toHaveBeenCalled();
expect(collabHistory.addContributors).not.toHaveBeenCalled();
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
expect(aiQueue.add).not.toHaveBeenCalled();
expect(historyQueue.add).not.toHaveBeenCalled();
});
it('title-only change persists the title without body side-effects', async () => {
const document = buildDoc();
addTitleFragment(document, 'New Title');
const page = basePage({
content: cloneOut(document),
title: 'Old Title',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
expect(call[0].title).toBe('New Title');
expect(call[0].ydoc).toBeDefined();
expect(call[0].contributorIds).toBeDefined();
expect('content' in call[0]).toBe(false);
// Title-only must not touch the body-authorship provenance.
expect('lastUpdatedSource' in call[0]).toBe(false);
expect(call[1]).toBe('PAGE_ID');
expect(call[3].treeUpdate.title).toBe('New Title');
expect(collabHistory.addContributors).toHaveBeenCalledTimes(1);
expect(collabHistory.addContributors).toHaveBeenCalledWith(
'PAGE_ID',
expect.any(Array),
);
expect(document.broadcastStateless).toHaveBeenCalledTimes(1);
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
expect(aiQueue.add).not.toHaveBeenCalled();
expect(historyQueue.add).not.toHaveBeenCalled();
});
it('an EMPTY title fragment does NOT overwrite a non-empty page.title (anti-corruption guard, Bug 2)', async () => {
// The client can momentarily seed the 'title' fragment as an EMPTY heading
// (hasTitleFragment true, extracted text '') before the real title syncs.
// Body is unchanged here, so the only candidate write is the title -> the
// guard must turn this into a full no-op (no updatePage, no broadcast).
const document = buildDoc();
addTitleFragment(document, ''); // empty heading: length > 0 but text ''
const page = basePage({
content: cloneOut(document),
title: 'Real Title',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
// No write at all: the empty title is not authoritative and the body is
// unchanged, so onStoreDocument must take the no-op fast path.
expect(pageRepo.updatePage).not.toHaveBeenCalled();
expect(document.broadcastStateless).not.toHaveBeenCalled();
});
it('an EMPTY title fragment alongside a body change persists the body but NOT an empty title (anti-corruption guard, Bug 2)', async () => {
const document = buildDoc();
addTitleFragment(document, ''); // empty title fragment
const page = basePage({
content: { type: 'doc', content: [] }, // different body -> bodyChanged
title: 'Real Title',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
// Body is persisted, but the title is NOT included (empty == not
// authoritative) and no tree update is broadcast for the title.
expect(call[0].content).toBeTruthy();
expect('title' in call[0]).toBe(false);
expect(call[3]).toBeUndefined();
});
it('body + title change persists both with full body side-effects', async () => {
const document = buildDoc();
addTitleFragment(document, 'New Title');
const page = basePage({
content: { type: 'doc', content: [] },
title: 'Old Title',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
expect(call[0].content).toBeTruthy();
expect(call[0].title).toBe('New Title');
expect(call[0].ydoc).toBeDefined();
expect(call[0].lastUpdatedSource).toBe('user');
expect(call[3].treeUpdate).toBeDefined();
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
expect(aiQueue.add).toHaveBeenCalled();
expect(historyQueue.add).toHaveBeenCalled();
expect(collabHistory.addContributors).toHaveBeenCalled();
expect(document.broadcastStateless).toHaveBeenCalled();
});
it('body-only change persists the body without a tree update', async () => {
const document = buildDoc();
const page = basePage({
content: { type: 'doc', content: [] },
title: 'whatever',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
expect(call[0].content).toBeTruthy();
expect('title' in call[0]).toBe(false);
// No treeUpdate for a body-only save.
expect(call[3]).toBeUndefined();
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
expect(aiQueue.add).toHaveBeenCalled();
expect(historyQueue.add).toHaveBeenCalled();
expect(document.broadcastStateless).toHaveBeenCalled();
});
});
describe('onLoadDocument', () => {
it('returns early (no DB read) when the document is not empty', async () => {
const document = { isEmpty: () => false };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
expect(result).toBeUndefined();
expect(pageRepo.findById).not.toHaveBeenCalled();
});
it('returns undefined and does not persist when the page is null', async () => {
const document = { isEmpty: () => true };
pageRepo.findById.mockResolvedValue(null);
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
expect(result).toBeUndefined();
expect(pageRepo.updatePage).not.toHaveBeenCalled();
});
it('seeds + persists under a lock when the persisted ydoc lacks a title fragment', async () => {
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
const page = {
id: 'PAGE_ID',
title: 'Legacy Title',
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
content: null,
};
// Both the cheap pre-check and the locked re-read return the same row.
pageRepo.findById.mockResolvedValue(page);
const document = { isEmpty: () => true };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
// The locked re-read must take the row lock inside the tx.
const lockedReadCall = pageRepo.findById.mock.calls.find(
(c: any[]) => c[1]?.withLock,
);
expect(lockedReadCall).toBeDefined();
expect(lockedReadCall[1].trx).toBe(trx);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
expect(Buffer.isBuffer(call[0].ydoc)).toBe(true);
expect(call[1]).toBe('PAGE_ID');
// Persist must run inside the transaction.
expect(call[2]).toBe(trx);
expect(result).toBeTruthy();
});
it('does NOT lock or persist when the ydoc already has a title fragment', async () => {
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
Y.applyUpdate(src, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Has Title')));
const page = {
id: 'PAGE_ID',
title: 'Has Title',
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
content: null,
};
pageRepo.findById.mockResolvedValue(page);
const document = { isEmpty: () => true };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
// Hot path: only the cheap lock-free read, no locked re-read, no write.
expect(pageRepo.findById).toHaveBeenCalledTimes(1);
expect(pageRepo.findById.mock.calls[0][1]?.withLock).toBeFalsy();
expect(pageRepo.updatePage).not.toHaveBeenCalled();
expect(result).toBeTruthy();
});
it('converts legacy content -> ydoc inside a tx and persists a {ydoc} Buffer', async () => {
const page = {
id: 'PAGE_ID',
title: 'T',
ydoc: null,
content: bodyJson,
};
pageRepo.findById.mockResolvedValue(page);
const document = { isEmpty: () => true };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
const lockedReadCall = pageRepo.findById.mock.calls.find(
(c: any[]) => c[1]?.withLock,
);
expect(lockedReadCall).toBeDefined();
expect(lockedReadCall[1].trx).toBe(trx);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
expect(Buffer.isBuffer(call[0].ydoc)).toBe(true);
expect(call[2]).toBe(trx);
// The rebuilt doc carries the body.
expect(JSON.stringify(cloneOut(result))).toContain('hello');
});
it('SKIPS rebuild when the locked re-read shows the ydoc was already healed', async () => {
// Simulate a concurrent process: the cheap pre-check sees ydoc=null (legacy
// rebuild path), but by the time we hold the lock another process has
// already persisted a healthy ydoc. We must adopt it, not rebuild/clobber.
const healed = TiptapTransformer.toYdoc(
{ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'healed' }] }] },
'default',
tiptapExtensions,
);
Y.applyUpdate(healed, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Healed Title')));
const healedYdoc = Buffer.from(Y.encodeStateAsUpdate(healed));
const preCheck = { id: 'PAGE_ID', title: 'T', ydoc: null, content: bodyJson };
const lockedRow = {
id: 'PAGE_ID',
title: 'Healed Title',
ydoc: healedYdoc,
content: bodyJson,
};
pageRepo.findById
.mockResolvedValueOnce(preCheck) // cheap pre-check
.mockResolvedValueOnce(lockedRow); // locked re-read
const document = { isEmpty: () => true };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
// The healthy ydoc had a title fragment already, so nothing was rebuilt or
// seeded -> no clobbering write.
expect(pageRepo.updatePage).not.toHaveBeenCalled();
// The returned doc is the healed body, NOT a fresh rebuild of bodyJson.
expect(JSON.stringify(cloneOut(result))).toContain('healed');
});
it('REJECTS the load when the rebuild persist fails (does not return an unpersisted doc)', async () => {
const page = { id: 'PAGE_ID', title: 'T', ydoc: null, content: bodyJson };
pageRepo.findById.mockResolvedValue(page);
pageRepo.updatePage.mockRejectedValue(new Error('db down'));
const errSpy = jest
.spyOn((ext as any).logger, 'error')
.mockImplementation(() => undefined);
const document = { isEmpty: () => true };
await expect(
ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any),
).rejects.toThrow('db down');
expect(errSpy).toHaveBeenCalled();
});
it('seed-only persist FAILURE returns the doc from the existing ydoc (no throw)', async () => {
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
const page = {
id: 'PAGE_ID',
title: 'Legacy Title',
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
content: null,
};
pageRepo.findById.mockResolvedValue(page);
pageRepo.updatePage.mockRejectedValue(new Error('db down'));
const errSpy = jest
.spyOn((ext as any).logger, 'error')
.mockImplementation(() => undefined);
const document = { isEmpty: () => true };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
// Non-fatal: we fall back to the doc loaded from the existing page.ydoc.
expect(result).toBeTruthy();
expect(JSON.stringify(cloneOut(result))).toContain('hello');
expect(errSpy).toHaveBeenCalled();
});
});
});

View File

@@ -9,7 +9,6 @@ import * as Y from 'yjs';
import { Injectable, Logger } from '@nestjs/common';
import { TiptapTransformer } from '@hocuspocus/transformer';
import {
buildTitleSeedYdoc,
getPageId,
isEmptyParagraphDoc,
jsonToText,
@@ -117,10 +116,6 @@ export class PersistenceExtension implements Extension {
return;
}
// Cheap, lock-free pre-check (hot path stays lock-free). It tells us whether
// any heal (legacy rebuild and/or title seed) is needed; the heal itself
// re-reads the row FOR UPDATE and re-validates inside a transaction so it
// runs exactly once (see healUnderLock).
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
includeYdoc: true,
@@ -132,164 +127,33 @@ export class PersistenceExtension implements Extension {
}
if (page.ydoc) {
this.logger.debug(`ydoc loaded from db: ${pageId}`);
const doc = new Y.Doc();
Y.applyUpdate(doc, new Uint8Array(page.ydoc));
const dbState = new Uint8Array(page.ydoc);
// Legacy pages persisted their title only in the `page.title` column; the
// ydoc has no 'title' fragment. Decide cheaply (no lock) whether a seed is
// needed by inspecting the loaded doc's 'title' fragment. A seed is needed
// only when that fragment is empty AND there is a non-empty column title.
let titleSeedNeeded = false;
try {
const titleFrag = doc.get('title', Y.XmlFragment);
titleSeedNeeded = titleFrag.length === 0 && !!page.title?.trim();
} catch (err) {
// A malformed title fragment must not break loading; skip the seed.
this.logger.warn(`failed to inspect title fragment: ${err?.['message']}`);
titleSeedNeeded = false;
}
if (!titleSeedNeeded) {
// Fully healthy: a ydoc with a title fragment (or nothing to seed).
this.logger.debug(`ydoc loaded from db: ${pageId}`);
return doc;
}
// SEED-ONLY heal: a valid page.ydoc already exists; we only need to add the
// title fragment. If the persist fails we must NOT hand out an unpersisted
// fresh-client-id seed (it could later duplicate the title), so we fall
// back to the healthy doc loaded from the EXISTING page.ydoc, without the
// seed. The title just won't render until a later successful heal —
// non-fatal, non-corrupting.
try {
return await this.healUnderLock(pageId);
} catch (err) {
this.logger.error(
`Failed to persist seeded ydoc for page ${pageId}; serving existing ydoc without title seed`,
err,
);
return doc;
}
Y.applyUpdate(doc, dbState);
return doc;
}
// NOTE (offline-sync M1, Goal 2): this per-load self-heal converts +
// title-seeds + persists every legacy page (content set, ydoc null) on its
// first open, which neutralizes the duplication trap incrementally. A
// proactive one-shot BATCH migration over all such pages could be added
// later, but it requires the tiptap schema + TiptapTransformer (Node/Yjs),
// which a Kysely SQL migration cannot run; no runnable-task/CLI convention
// exists in this repo yet, so we deliberately avoid a fragile migration.
//
// If no ydoc state in db, REBUILD a Y.Doc from the JSON in page.content under
// a row lock (see healUnderLock).
// if no ydoc state in db convert json in page.content to Ydoc.
if (page.content) {
// REBUILD heal: surface failures. If the persist fails we REFUSE the load
// (re-throw) rather than hand out an unpersisted fresh-client-id rebuild —
// returning it would re-arm the duplication trap. A transient DB failure
// means the client reconnects and retries: correctness over availability.
try {
return await this.healUnderLock(pageId);
} catch (err) {
this.logger.error(
`Failed to persist rebuilt ydoc for page ${pageId}; refusing load`,
err,
);
throw err;
}
this.logger.debug(`converting json to ydoc: ${pageId}`);
const ydoc = TiptapTransformer.toYdoc(
page.content,
'default',
tiptapExtensions,
);
Y.encodeStateAsUpdate(ydoc);
return ydoc;
}
this.logger.debug(`creating fresh ydoc: ${pageId}`);
return new Y.Doc();
}
/**
* Serialize the legacy self-heal (rebuild from page.content and/or seed the
* title fragment, then persist) so it runs exactly ONCE per page, closing the
* Yjs duplication trap. Both TiptapTransformer.toYdoc and buildTitleSeedYdoc
* mint FRESH Yjs client-ids every call, so two concurrent rebuilds (the API
* process via openDirectConnection AND the standalone collab process both
* seeing `ydoc IS NULL`) could each persist a different-client-id state and let
* a long-offline client merge-and-duplicate. We prevent that by re-reading the
* row FOR UPDATE inside a transaction and re-validating state under the lock:
* whoever wins the lock heals; the loser observes the healthy `ydoc` and adopts
* it instead of rebuilding. The persist happens IN THE SAME TX, so a failed
* write rolls back and propagates out (the caller then decides refuse vs.
* fall-back).
*/
private async healUnderLock(pageId: string): Promise<Y.Doc> {
return executeTx(this.db, async (trx) => {
const locked = await this.pageRepo.findById(pageId, {
withLock: true,
includeContent: true,
includeYdoc: true,
trx,
});
const doc = new Y.Doc();
let rebuilt = false;
if (locked?.ydoc) {
// Another process already healed (or the page always had a ydoc): adopt
// the healthy persisted state, do NOT rebuild.
Y.applyUpdate(doc, new Uint8Array(locked.ydoc));
} else if (locked?.content) {
this.logger.debug(`converting json to ydoc: ${pageId}`);
const built = TiptapTransformer.toYdoc(
locked.content,
'default',
tiptapExtensions,
);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(built));
rebuilt = true;
}
// else: no ydoc and no content -> a fresh empty doc.
// Idempotent, emptiness-guarded title seed (safe to call always).
const seeded = this.seedTitleFragment(doc, locked?.title ?? null);
if (rebuilt || seeded) {
// Persist IN THE SAME TX. If this throws, the tx rolls back and the
// error propagates out of executeTx to the caller.
await this.pageRepo.updatePage(
{ ydoc: Buffer.from(Y.encodeStateAsUpdate(doc)) },
pageId,
trx,
);
this.logger.debug(`persisted rebuilt/seeded ydoc: ${pageId}`);
}
return doc;
});
}
/**
* Seed the 'title' fragment of `doc` from the `page.title` column for legacy
* pages whose persisted ydoc has no title fragment yet.
*
* Guarded STRICTLY by emptiness: we only seed when the existing 'title'
* fragment is empty AND there is a non-empty column title. Seeding a non-empty
* fragment would re-introduce the Yjs duplication trap, so we never do it.
* Returns true when a seed was applied (so the caller can persist).
* Defensive: a malformed title must not break document loading.
*/
private seedTitleFragment(doc: Y.Doc, title: string | null): boolean {
const trimmed = (title ?? '').trim();
if (!trimmed) return false;
try {
const titleFrag = doc.get('title', Y.XmlFragment);
if (titleFrag.length !== 0) return false;
const titleSeed = buildTitleSeedYdoc(title);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed));
this.logger.debug('seeded title fragment from page.title column');
return true;
} catch (err) {
this.logger.warn(`failed to seed title fragment: ${err?.['message']}`);
return false;
}
}
async onStoreDocument(data: onStoreDocumentPayload) {
const { documentName, document, context } = data;
@@ -307,34 +171,7 @@ export class PersistenceExtension implements Extension {
this.logger.warn('jsonToText' + err?.['message']);
}
// Title lives in the SAME Y.Doc as the body, in a dedicated 'title' fragment
// (the collaborative title-editor contract with the client). Extract it
// defensively: a malformed title fragment must NOT crash the document store.
// `hasTitleFragment` distinguishes "the doc actually carries a title
// fragment" from "legacy doc with no title fragment" — only the former may
// write page.title, so a legacy doc never clobbers the column with ''.
let titleText = '';
let hasTitleFragment = false;
try {
const titleFrag = document.get('title', Y.XmlFragment);
hasTitleFragment = !!titleFrag && titleFrag.length > 0;
if (hasTitleFragment) {
const titleJson = TiptapTransformer.fromYdoc(document, 'title');
titleText = titleJson ? jsonToText(titleJson).trim() : '';
}
} catch (err) {
this.logger.warn('title extraction: ' + err?.['message']);
hasTitleFragment = false;
}
let page: Page = null;
// Tracks whether the BODY ('default') changed in this store. The heavy
// body-only side-effects (transclusion sync, mentions, RAG, history) stay
// gated on this so a title-only change does not trigger them.
let bodyChanged = false;
// Tracks a successful title-only persist so the post-tx contributor folding
// (collabHistory.addContributors) runs for the title-only case too.
let titleOnlyPersisted = false;
const editingUserIds = this.consumeContributors(documentName);
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
// if the current writer is the agent (covers a store with no prior onChange
@@ -368,80 +205,11 @@ export class PersistenceExtension implements Extension {
return;
}
bodyChanged = !isDeepStrictEqual(tiptapJson, page.content);
// Only a populated 'title' fragment may update page.title; compare
// against the current column value (treat null as '').
//
// ANTI-CORRUPTION GUARD (Bug 2): the client's collaborative title-editor
// can momentarily initialize the 'title' fragment as an EMPTY heading
// (so `hasTitleFragment` is true, but the extracted `titleText` is '')
// BEFORE the server's real-title seed has synced. Writing that '' would
// silently wipe a non-empty page.title to "untitled". A wiki page is
// never legitimately retitled to empty via this path, so we treat an
// empty extracted title as "not authoritative" and never persist it.
// The `titleText.length > 0` clause makes this guard apply to BOTH the
// title-only branch and the body+title branch below.
//
// DELIBERATE: this intentionally makes it impossible to retitle a page
// to EMPTY via the collab path — a wiki page is never legitimately
// empty-titled. If a non-empty-title rule ever needs relaxing or
// enforcing differently, the REST UpdatePageDto is the place to validate
// the title, not this collab guard.
const titleChanged =
hasTitleFragment &&
titleText.length > 0 &&
titleText !== (page.title ?? '');
// No-op fast path: neither body nor title changed.
if (!bodyChanged && !titleChanged) {
if (isDeepStrictEqual(tiptapJson, page.content)) {
page = null;
return;
}
// Title-only change: the body is unchanged, so skip the heavy body
// history/contributor logic and persist just the new title and the
// ydoc (the title fragment edit lives in the same ydoc). The early-skip
// used to drop this case entirely, losing the title change.
if (!bodyChanged) {
// Fold the window's editing users into contributors the same way the
// body branch does, so a user who edited ONLY the title is not dropped
// from page.contributorIds.
const contributorIds = Array.from(
new Set([
...(page.contributorIds || []),
...editingUserIds,
page.creatorId,
]),
);
await this.pageRepo.updatePage(
{
title: titleText,
ydoc: ydocState,
lastUpdatedById: context.user.id,
contributorIds,
// A title-only change is not a body-authorship transition; leave
// lastUpdatedSource/aiChatId untouched so the user->agent history
// boundary in the body branch is not bypassed.
},
pageId,
trx,
// Mirror PageService.update's tree snapshot so a collaborative rename
// propagates to other users' sidebar/breadcrumbs like the REST rename.
{
treeUpdate: {
id: pageId,
slugId: page.slugId,
spaceId: page.spaceId,
parentPageId: page.parentPageId ?? null,
title: titleText,
},
},
);
this.logger.debug(`Page title updated: ${pageId} - SlugId: ${page.slugId}`);
titleOnlyPersisted = true;
return;
}
let contributorIds = undefined;
try {
const existingContributors = page.contributorIds || [];
@@ -459,22 +227,29 @@ export class PersistenceExtension implements Extension {
// Approach A — boundary snapshot before the agent's first edit.
// When this store is the agent's and the page's currently persisted
// state was authored by a human, pin that human state as its own
// history version BEFORE the agent overwrites it. `page` still holds the
// OLD content/provenance here, so saveHistory(page) captures the
// pre-agent state tagged 'user'. The agent's new content is snapshotted
// later by the debounced PAGE_HISTORY job ('agent'). Skip if the prior
// state is already agent-authored (boundary already pinned on the
// user->agent transition), if the page is effectively empty, or if the
// latest existing snapshot already equals this human state (avoid
// duplicates).
if (lastUpdatedSource === 'agent' && page.lastUpdatedSource !== 'agent') {
// history version BEFORE the agent overwrites it. `page` still holds
// the OLD content/provenance here, so saveHistory(page) captures the
// pre-agent state tagged 'user'. The agent's new content is
// snapshotted later by the debounced PAGE_HISTORY job ('agent'). Skip
// if the prior state is already agent-authored (boundary already
// pinned on the user->agent transition), if the page is effectively
// empty, or if the latest existing snapshot already equals this human
// state (avoid duplicates).
if (
lastUpdatedSource === 'agent' &&
page.lastUpdatedSource !== 'agent'
) {
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true, trx },
);
const humanBaselineMissing =
!lastHistory || !isDeepStrictEqual(lastHistory.content, page.content);
if (!isEmptyParagraphDoc(page.content as any) && humanBaselineMissing) {
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content);
if (
!isEmptyParagraphDoc(page.content as any) &&
humanBaselineMissing
) {
await this.pageHistoryRepo.saveHistory(page, {
contributorIds: page.contributorIds ?? undefined,
trx,
@@ -492,27 +267,9 @@ export class PersistenceExtension implements Extension {
lastUpdatedSource,
lastUpdatedAiChatId: context?.aiChatId ?? null,
contributorIds: contributorIds,
// Persist the title in the SAME transaction when the title fragment
// changed alongside the body.
...(titleChanged ? { title: titleText } : {}),
},
pageId,
trx,
// Mirror PageService.update's tree snapshot so a collaborative rename
// propagates to other users' sidebar/breadcrumbs like the REST rename.
// Only attach when the title actually changed; a body-only save must
// not trigger a tree broadcast.
titleChanged
? {
treeUpdate: {
id: pageId,
slugId: page.slugId,
spaceId: page.spaceId,
parentPageId: page.parentPageId ?? null,
title: titleText,
},
}
: undefined,
);
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
@@ -533,8 +290,6 @@ export class PersistenceExtension implements Extension {
}
}
// `page` is truthy whenever anything was persisted (body OR title-only), so
// the page.updated broadcast fires for a title-only change too.
if (page) {
document.broadcastStateless(
JSON.stringify({
@@ -552,20 +307,11 @@ export class PersistenceExtension implements Extension {
: undefined,
}),
);
}
// Record the window's editing users in collab history for a title-only
// change too (the body branch does this below, gated on bodyChanged).
if (page && titleOnlyPersisted) {
await this.collabHistory.addContributors(pageId, editingUserIds);
}
// Body-only side-effects: skip them for a title-only change (body unchanged).
if (page && bodyChanged) {
await this.syncTransclusion(pageId, page.workspaceId, tiptapJson);
}
if (page && bodyChanged) {
if (page) {
await this.collabHistory.addContributors(pageId, editingUserIds);
const mentions = extractMentions(tiptapJson);

View File

@@ -1,81 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import { PageTreeBridgePublisher } from './page-tree-bridge.publisher';
import { COLLAB_TREE_UPDATE_CHANNEL } from '../constants';
import {
PageEvent,
TreeUpdateSnapshot,
} from '../../database/listeners/page.listener';
const treeUpdate: TreeUpdateSnapshot = {
id: 'page-1',
slugId: 'slug-1',
spaceId: 'space-1',
parentPageId: null,
title: 'Renamed',
icon: '🚀',
};
describe('PageTreeBridgePublisher', () => {
let publisher: PageTreeBridgePublisher;
let redis: { publish: jest.Mock };
beforeEach(async () => {
redis = { publish: jest.fn().mockResolvedValue(1) };
const redisService = { getOrThrow: () => redis } as unknown as RedisService;
const module: TestingModule = await Test.createTestingModule({
providers: [
PageTreeBridgePublisher,
{ provide: RedisService, useValue: redisService },
],
}).compile();
publisher = module.get<PageTreeBridgePublisher>(PageTreeBridgePublisher);
});
it('WITH a `treeUpdate`: publishes the JSON snapshot on the channel', async () => {
const event: PageEvent = {
pageIds: ['page-1'],
workspaceId: 'ws-1',
treeUpdate,
};
await publisher.onPageUpdated(event);
expect(redis.publish).toHaveBeenCalledTimes(1);
expect(redis.publish).toHaveBeenCalledWith(
COLLAB_TREE_UPDATE_CHANNEL,
JSON.stringify(treeUpdate),
);
});
it('content-only save (NO `treeUpdate`): does NOT publish', async () => {
const event: PageEvent = {
pageIds: ['page-1'],
workspaceId: 'ws-1',
};
await publisher.onPageUpdated(event);
expect(redis.publish).not.toHaveBeenCalled();
});
it('a publish rejection is caught (no throw)', async () => {
redis.publish.mockRejectedValueOnce(new Error('redis down'));
const errorSpy = jest
.spyOn(publisher['logger'], 'error')
.mockImplementation(() => undefined);
const event: PageEvent = {
pageIds: ['page-1'],
workspaceId: 'ws-1',
treeUpdate,
};
await expect(publisher.onPageUpdated(event)).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledTimes(1);
errorSpy.mockRestore();
});
});

View File

@@ -1,55 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
import { EventName } from '../../common/events/event.contants';
import { PageEvent } from '../../database/listeners/page.listener';
import { COLLAB_TREE_UPDATE_CHANNEL } from '../constants';
/**
* Collab-process half of the cross-process tree-update bridge.
*
* The standalone collab process bootstraps `CollabAppModule`, which does NOT
* import `WsModule`/`PageWsListener`. So when a collaborative title/icon rename
* persists and emits `EventName.PAGE_UPDATED` with a `treeUpdate` snapshot, there
* is no listener in this process to broadcast it — the live tree update would be
* lost for 2-process (COLLAB_URL set) deployments.
*
* This publisher fills that gap: it forwards the `treeUpdate` snapshot over a
* Redis pub/sub channel to the API process, which re-broadcasts it via
* `WsTreeService` (the single broadcast authority).
*
* It is registered ONLY in `CollabAppModule.providers`, so it never runs in the
* API process (where `PageWsListener` already broadcasts the same event locally).
* That module placement is what prevents a double broadcast. In single-process
* mode `CollabAppModule` is not loaded at all, so this publisher never runs.
*/
@Injectable()
export class PageTreeBridgePublisher {
private readonly logger = new Logger(PageTreeBridgePublisher.name);
private readonly redis: Redis;
constructor(private readonly redisService: RedisService) {
this.redis = this.redisService.getOrThrow();
}
@OnEvent(EventName.PAGE_UPDATED)
async onPageUpdated(event: PageEvent): Promise<void> {
// Mirror PageWsListener's gating: only title/icon changes carry a snapshot.
// Content-only saves leave `treeUpdate` undefined and are ignored.
if (!event.treeUpdate) return;
try {
await this.redis.publish(
COLLAB_TREE_UPDATE_CHANNEL,
JSON.stringify(event.treeUpdate),
);
} catch (err) {
// A Redis publish failure must not break the store path.
this.logger.error(
`Failed to publish tree update to ${COLLAB_TREE_UPDATE_CHANNEL}`,
err instanceof Error ? err.stack : String(err),
);
}
}
}

View File

@@ -20,7 +20,6 @@ import { CaslModule } from '../../core/casl/casl.module';
import { ThrottleModule } from '../../integrations/throttle/throttle.module';
import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { PageTreeBridgePublisher } from '../listeners/page-tree-bridge.publisher';
@Module({
imports: [
@@ -55,6 +54,6 @@ import { PageTreeBridgePublisher } from '../listeners/page-tree-bridge.publisher
? [CollaborationController]
: []),
],
providers: [AppService, PageTreeBridgePublisher],
providers: [AppService],
})
export class CollabAppModule {}

View File

@@ -19,87 +19,4 @@ describe('AuthController', () => {
it('should be defined', () => {
expect(controller).toBeDefined();
});
// The EE MFA module is absent in this repo, so require() throws and is caught;
// login falls through to authService.login -> setAuthCookie -> returnToken.
describe('login returnToken branch', () => {
const workspace = { id: 'ws1', enforceSso: false };
const makeController = () => {
const authService = {
login: jest.fn().mockResolvedValue('jwt-token-123'),
};
const environmentService = {
getCookieExpiresIn: jest.fn().mockReturnValue(new Date()),
isHttps: jest.fn().mockReturnValue(false),
};
const ctrl = new AuthController(
authService as any,
{} as any,
environmentService as any,
{} as any,
{} as any,
);
const res = { setCookie: jest.fn() };
return { ctrl, authService, res };
};
it('returns the body token and sets the cookie when returnToken is true', async () => {
const { ctrl, authService, res } = makeController();
const loginInput = {
email: 'a@b.com',
password: 'pw',
returnToken: true,
};
const result = await ctrl.login(
workspace as any,
res as any,
loginInput as any,
);
expect(result).toEqual({ authToken: 'jwt-token-123' });
expect(res.setCookie).toHaveBeenCalledTimes(1);
expect(res.setCookie).toHaveBeenCalledWith(
'authToken',
'jwt-token-123',
expect.objectContaining({ httpOnly: true }),
);
expect(authService.login).toHaveBeenCalled();
});
it('returns no body token but still sets the cookie when returnToken is omitted', async () => {
const { ctrl, res } = makeController();
const loginInput = { email: 'a@b.com', password: 'pw' };
const result = await ctrl.login(
workspace as any,
res as any,
loginInput as any,
);
expect(result).toBeUndefined();
expect(res.setCookie).toHaveBeenCalledTimes(1);
});
// Guards against an `!== undefined`-style bug: an explicit `false` must
// behave exactly like the omitted case (cookie set, no token in the body).
it('returns no body token but still sets the cookie when returnToken is false', async () => {
const { ctrl, res } = makeController();
const loginInput = {
email: 'a@b.com',
password: 'pw',
returnToken: false,
};
const result = await ctrl.login(
workspace as any,
res as any,
loginInput as any,
);
expect(result).toBeUndefined();
expect(res.setCookie).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -97,12 +97,6 @@ export class AuthController {
} else if (mfaResult.authToken) {
// User doesn't have MFA and workspace doesn't require it
this.setAuthCookie(res, mfaResult.authToken);
// Opt-in body token for native clients (Bearer auth). The response is
// wrapped by TransformHttpResponseInterceptor, so clients read it at
// `data.authToken`. Web clients omit returnToken and keep the cookie.
if (loginInput.returnToken) {
return { authToken: mfaResult.authToken };
}
return;
}
}
@@ -110,12 +104,6 @@ export class AuthController {
const authToken = await this.authService.login(loginInput, workspace.id);
this.setAuthCookie(res, authToken);
// Opt-in body token for native clients (Bearer auth). The response is wrapped
// by TransformHttpResponseInterceptor, so clients read it at `data.authToken`.
// Web clients omit returnToken and keep using the httpOnly cookie only.
if (loginInput.returnToken) {
return { authToken };
}
}
@UseGuards(SetupGuard)

View File

@@ -1,10 +1,4 @@
import {
IsBoolean,
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@IsNotEmpty()
@@ -14,13 +8,4 @@ export class LoginDto {
@IsNotEmpty()
@IsString()
password: string;
// When true, the access token is returned in the response body (in addition
// to the httpOnly cookie) so native/mobile clients can store it in
// Keychain/Keystore and send it as 'Authorization: Bearer'. Web clients omit
// this flag and keep using the cookie. Opt-in only: the token is never put in
// the body otherwise.
@IsOptional()
@IsBoolean()
returnToken?: boolean;
}

View File

@@ -30,102 +30,6 @@ describe('PageService', () => {
expect(service).toBeDefined();
});
describe('update — title sync into collab doc (Bug 1)', () => {
const makeUpdateService = () => {
const pageRepo = {
updatePage: jest.fn().mockResolvedValue(undefined),
findById: jest.fn().mockResolvedValue({ id: 'page-1' }),
};
const generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
const collaborationGateway = {
writePageTitle: jest.fn().mockResolvedValue(undefined),
};
const svc = new PageService(
pageRepo as any, // pageRepo
{} as any, // pagePermissionRepo
{} as any, // attachmentRepo
{} as any, // db
{} as any, // storageService
{} as any, // attachmentQueue
{} as any, // aiQueue
generalQueue as any, // generalQueue
{} as any, // eventEmitter
collaborationGateway as any, // collaborationGateway
{} as any, // watcherService
{} as any, // transclusionService
);
return { svc, pageRepo, collaborationGateway };
};
const basePage = (): Page =>
({
id: 'page-1',
slugId: 'slug-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
parentPageId: null,
title: 'Old Title',
icon: null,
contributorIds: [],
}) as any;
const user = { id: 'u1' } as any;
it('writes the new title into the collab doc when the title actually changed', async () => {
const { svc, collaborationGateway } = makeUpdateService();
await svc.update(basePage(), { title: 'New Title' } as any, user);
// Must use the Redis-independent writePageTitle (direct
// openDirectConnection), NOT handleYjsEvent which no-ops without Redis.
expect(collaborationGateway.writePageTitle).toHaveBeenCalledTimes(1);
expect(collaborationGateway.writePageTitle).toHaveBeenCalledWith(
'page-1',
'New Title',
expect.objectContaining({ user }),
);
});
it('threads agent provenance into the collab title write', async () => {
const { svc, collaborationGateway } = makeUpdateService();
await svc.update(basePage(), { title: 'New Title' } as any, user, {
actor: 'agent',
aiChatId: 'chat-1',
} as any);
expect(collaborationGateway.writePageTitle).toHaveBeenCalledWith(
'page-1',
'New Title',
expect.objectContaining({ actor: 'agent', aiChatId: 'chat-1' }),
);
});
it('does NOT write into the collab doc when the title is unchanged', async () => {
const { svc, collaborationGateway } = makeUpdateService();
// Same title -> titleChanged is false; an icon-only change must not fire
// the title sync.
await svc.update(
basePage(),
{ title: 'Old Title', icon: '📄' } as any,
user,
);
expect(collaborationGateway.writePageTitle).not.toHaveBeenCalled();
});
it('does NOT write into the collab doc when the DTO omits the title', async () => {
const { svc, collaborationGateway } = makeUpdateService();
await svc.update(basePage(), { icon: '📄' } as any, user);
expect(collaborationGateway.writePageTitle).not.toHaveBeenCalled();
});
});
describe('movePage cycle guard (#67)', () => {
// A valid fractional-indexing key — movePage validates `position` by feeding
// it to generateJitteredKeyBetween(position, null) before anything else.

View File

@@ -244,8 +244,6 @@ export class PageService {
contributors.add(user.id);
const contributorIds = Array.from(contributors);
const isAgent = provenance?.actor === 'agent';
// Detect a real title/icon change so the WS tree listener can broadcast an
// `updateOne` to the space (rename / icon swap) WITHOUT re-broadcasting on a
// content-only save. Only treat a field as changed when the DTO actually
@@ -288,43 +286,6 @@ export class PageService {
: undefined,
);
// Bug 1: a REST/MCP rename wrote the new title ONLY to the page.title DB
// column above. The title's source of truth is the Yjs 'title' fragment in
// the page's collab doc, which onStoreDocument re-extracts on every save —
// so leaving the fragment stale would REVERT this rename on the page's next
// collaborative save (and re-broadcast the old title). Push the new title
// into the Yjs 'title' fragment so Yjs stays in sync and never reverts.
//
// Use the gateway's writePageTitle (direct openDirectConnection) rather than
// a Redis-routed handleYjsEvent path: handleYjsEvent routes through
// redisSync and SILENTLY no-ops when Redis is disabled
// (COLLAB_DISABLE_REDIS=true), which would let the rename revert in a
// single-process deployment. writePageTitle is Redis-independent and
// openDirectConnection loads the doc from persistence when no editor is
// connected, so this also works for an offline page. Thread agent provenance
// into the context so onStoreDocument tags the title store 'agent' too.
if (titleChanged) {
try {
await this.collaborationGateway.writePageTitle(
page.id,
updatePageDto.title,
{
user,
...(isAgent
? { actor: 'agent', aiChatId: provenance.aiChatId }
: {}),
},
);
} catch (err) {
// The DB column write already succeeded (fast-read source stays
// correct); a failure to sync Yjs here must not fail the rename. Log so
// a persistent desync is visible.
this.logger.warn(
`Failed to sync renamed title into collab doc for page ${page.id}: ${err?.['message']}`,
);
}
}
this.generalQueue
.add(QueueJob.ADD_PAGE_WATCHERS, {
userIds: [user.id],

View File

@@ -1,44 +0,0 @@
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
/**
* Create/retarget a vanity alias for a page. `confirmReassign` is the
* two-step guard for the "address already points at another page" case: the
* first call without it gets a 409 carrying the current target, the client
* confirms, and retries with `confirmReassign: true`.
*/
export class SetShareAliasDto {
@IsString()
@IsNotEmpty()
pageId: string;
@IsString()
@IsNotEmpty()
alias: string;
@IsBoolean()
@IsOptional()
confirmReassign?: boolean;
}
export class RemoveShareAliasDto {
@IsString()
@IsNotEmpty()
aliasId: string;
}
export class ShareAliasAvailabilityDto {
@IsString()
@IsNotEmpty()
alias: string;
}
export class ShareAliasForPageDto {
@IsString()
@IsNotEmpty()
pageId: string;
}

View File

@@ -1,252 +0,0 @@
import * as fs from 'node:fs';
// `@sindresorhus/slugify` is ESM-only and not in jest's transformIgnorePatterns,
// so the real module fails to parse under ts-jest. Stub it with a minimal,
// deterministic slugifier — this spec asserts the controller's slug *assembly*
// (`<title-slug>-<slugId>`, 70-char clamp, `untitled` fallback), not the upstream
// slug algorithm. The factory keeps the real ESM module from ever being loaded.
jest.mock('@sindresorhus/slugify', () => ({
__esModule: true,
default: (input: string) =>
String(input)
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, ''),
}));
import { ShareAliasRedirectController } from './share-alias-redirect.controller';
/**
* Routing/leak guard for the PUBLIC `GET /l/:alias` resolver.
*
* This is the most security-sensitive surface of the alias feature: an
* unauthenticated route that MUST serve the plain SPA index (exactly like any
* unknown path) for an unknown / dangling / no-longer-readable alias so that the
* existence of a name never leaks. Only a resolvable, still-readable alias may
* 302 to the canonical `/share/<key>/p/<title-slug>-<slugId>` page (302 — never
* 301 — because the target is retargetable). These tests pin that routing and
* the defensive percent-decoding, mirroring `share-seo.controller.routing.spec`.
*/
const STREAM_SENTINEL = { __isStream: true } as unknown as fs.ReadStream;
// Stub fs at CALL time (jest.spyOn), NOT module load (jest.mock): the controller
// transitively pulls bcrypt, whose native module is located by node-gyp-build
// reading the filesystem at import time — a module-level fs mock breaks that.
beforeEach(() => {
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'createReadStream').mockReturnValue(STREAM_SENTINEL);
});
afterEach(() => jest.restoreAllMocks());
function makeRes() {
const res: any = {
sent: undefined as unknown,
statusCode: undefined as number | undefined,
redirectUrl: undefined as string | undefined,
type: jest.fn(() => res),
status: jest.fn((code: number) => {
res.statusCode = code;
return res;
}),
send: jest.fn((v: unknown) => {
res.sent = v;
return res;
}),
redirect: jest.fn((url: string, code: number) => {
res.redirectUrl = url;
res.statusCode = code;
return res;
}),
};
return res;
}
function makeController(opts: {
resolved?: { share: any; page: any } | null;
selfHosted?: boolean;
}) {
const shareAliasService = {
resolveReadableTarget: jest.fn(async () => opts.resolved ?? null),
};
const workspaceRepo = {
findFirst: jest.fn(async () => ({ id: 'ws-self' })),
findByHostname: jest.fn(async (sub: string) =>
sub === 'acme' ? { id: 'ws-acme' } : null,
),
};
const environmentService = {
isSelfHosted: jest.fn(() => opts.selfHosted ?? true),
};
const controller = new ShareAliasRedirectController(
shareAliasService as any,
workspaceRepo as any,
environmentService as any,
);
return { controller, shareAliasService, workspaceRepo, environmentService };
}
const selfReq: any = { raw: { headers: { host: 'self' } } };
describe('ShareAliasRedirectController.resolve', () => {
it('302-redirects a resolvable alias to the canonical share page', async () => {
const { controller, shareAliasService } = makeController({
resolved: {
share: { key: 'SHAREKEY' },
page: { slugId: 'abc123', title: 'Quarterly Report' },
},
});
const res = makeRes();
await controller.resolve('promo', selfReq, res);
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
'promo',
'ws-self',
);
expect(res.redirect).toHaveBeenCalledWith(
'/share/SHAREKEY/p/quarterly-report-abc123',
302,
);
// No index stream was served on a hit.
expect(res.sent).toBeUndefined();
});
it('falls back to "untitled" in the slug when the target has no title', async () => {
const { controller } = makeController({
resolved: { share: { key: 'K' }, page: { slugId: 'sid', title: '' } },
});
const res = makeRes();
await controller.resolve('promo', selfReq, res);
expect(res.redirect).toHaveBeenCalledWith('/share/K/p/untitled-sid', 302);
});
it('clamps the title-slug to the first 70 characters of the page title', async () => {
// 119-char title; only the first 70 chars must reach the slug. The 70-char
// boundary deliberately falls mid-word ("Entire" -> "entir") so the clamp is
// unambiguous: anything past char 70 ("...e Fiscal Year...") must be dropped.
const longTitle =
'The Comprehensive Quarterly Financial Performance Report For The Entire Fiscal Year Two Thousand Twenty Five And Beyond';
const { controller } = makeController({
resolved: {
share: { key: 'K' },
page: { slugId: 'sid', title: longTitle },
},
});
const res = makeRes();
await controller.resolve('promo', selfReq, res);
expect(res.redirect).toHaveBeenCalledWith(
'/share/K/p/the-comprehensive-quarterly-financial-performance-report-for-the-entir-sid',
302,
);
});
it('streams the SPA index WITHOUT a 302 for an unknown/dangling/unreadable alias (no leak)', async () => {
const { controller, shareAliasService } = makeController({ resolved: null });
const res = makeRes();
await controller.resolve('does-not-exist', selfReq, res);
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalled();
// The plain index stream was served and no redirect leaked alias existence.
expect(res.redirect).not.toHaveBeenCalled();
expect(res.sent).toBe(STREAM_SENTINEL);
expect(res.type).toHaveBeenCalledWith('text/html');
});
it('streams the SPA index without even resolving when the workspace is null', async () => {
// Subdomain host that maps to no workspace => workspace === null.
const { controller, shareAliasService, workspaceRepo } = makeController({
selfHosted: false,
});
const res = makeRes();
const req: any = { raw: { headers: { host: 'unknown.example.com' } } };
await controller.resolve('promo', req, res);
expect(workspaceRepo.findByHostname).toHaveBeenCalledWith('unknown');
// Never even attempts to resolve (alias existence cannot leak per-host).
expect(shareAliasService.resolveReadableTarget).not.toHaveBeenCalled();
expect(res.redirect).not.toHaveBeenCalled();
expect(res.sent).toBe(STREAM_SENTINEL);
});
it('defensively decodes broken percent-encoding and treats it as unknown', async () => {
const { controller, shareAliasService } = makeController({ resolved: null });
const res = makeRes();
// '%E0%A4%A' is invalid -> decodeURIComponent throws -> raw value is used,
// and the alias resolves to nothing (no crash, served as index).
await controller.resolve('%E0%A4%A', selfReq, res);
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
'%E0%A4%A',
'ws-self',
);
expect(res.redirect).not.toHaveBeenCalled();
expect(res.sent).toBe(STREAM_SENTINEL);
});
it('decodes a valid percent-encoded alias before resolving', async () => {
const { controller, shareAliasService } = makeController({ resolved: null });
const res = makeRes();
await controller.resolve('my%2Dlink', selfReq, res);
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
'my-link',
'ws-self',
);
});
it('resolves the workspace via findFirst on the self-hosted path', async () => {
const { controller, workspaceRepo, shareAliasService } = makeController({
selfHosted: true,
resolved: null,
});
const res = makeRes();
await controller.resolve('promo', selfReq, res);
expect(workspaceRepo.findFirst).toHaveBeenCalled();
expect(workspaceRepo.findByHostname).not.toHaveBeenCalled();
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
'promo',
'ws-self',
);
});
it('resolves the workspace via findByHostname (subdomain) on the cloud path', async () => {
const { controller, workspaceRepo, shareAliasService } = makeController({
selfHosted: false,
resolved: null,
});
const res = makeRes();
const req: any = { raw: { headers: { host: 'acme.example.com' } } };
await controller.resolve('promo', req, res);
expect(workspaceRepo.findByHostname).toHaveBeenCalledWith('acme');
expect(workspaceRepo.findFirst).not.toHaveBeenCalled();
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
'promo',
'ws-acme',
);
});
it('serves a 404 when no built client index exists', async () => {
jest.spyOn(fs, 'existsSync').mockReturnValue(false);
const { controller } = makeController({ resolved: null });
const res = makeRes();
await controller.resolve('promo', selfReq, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.redirect).not.toHaveBeenCalled();
});
});

View File

@@ -1,95 +0,0 @@
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { join } from 'path';
import * as fs from 'node:fs';
import slugify from '@sindresorhus/slugify';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { Workspace } from '@docmost/db/types/entity.types';
import { ShareAliasService } from './share-alias.service';
/**
* Public resolver for vanity links `GET /l/:alias`. Excluded from the global
* `/api` prefix (see main.ts) and parallel to ShareSeoController.
*
* On a hit it issues a 302 (NEVER 301) to the canonical
* `/share/:key/p/:slug` page, so:
* - the existing share render + SSR meta is reused verbatim (crawlers follow
* the 302 and get the correct preview);
* - because the alias target is mutable, a temporary redirect is always
* re-resolved — a cached 301 would pin clients to the pre-swap page.
*
* Any unknown / dangling / no-longer-readable alias serves the plain SPA index
* (same as any unknown path) so the existence of a name never leaks.
*/
@Controller('l')
export class ShareAliasRedirectController {
constructor(
private readonly shareAliasService: ShareAliasService,
private readonly workspaceRepo: WorkspaceRepo,
private readonly environmentService: EnvironmentService,
) {}
@Get(':alias')
async resolve(
@Param('alias') rawAlias: string,
@Req() req: FastifyRequest,
@Res({ passthrough: false }) res: FastifyReply,
) {
// NestJS does not apply middlewares to paths excluded from the global /api
// prefix, so the DomainMiddleware workspace resolution is duplicated here
// (same workaround as ShareSeoController).
let workspace: Workspace = null;
if (this.environmentService.isSelfHosted()) {
workspace = await this.workspaceRepo.findFirst();
} else {
const header = req.raw.headers.host;
const subdomain = header?.split('.')[0];
workspace = subdomain
? await this.workspaceRepo.findByHostname(subdomain)
: null;
}
const clientDistPath = join(__dirname, '..', '..', '..', '..', 'client/dist');
const indexFilePath = join(clientDistPath, 'index.html');
let decoded = rawAlias;
try {
decoded = decodeURIComponent(rawAlias);
} catch {
// Malformed percent-encoding -> treat as unknown alias.
}
const resolved = workspace
? await this.shareAliasService.resolveReadableTarget(
decoded,
workspace.id,
)
: null;
if (!resolved) {
return this.sendIndex(indexFilePath, res);
}
const slug = buildPageSlug(resolved.page.slugId, resolved.page.title);
// 302, NOT 301: the alias is retargetable, so the redirect must always be
// re-resolved by clients/crawlers.
return res.redirect(`/share/${resolved.share.key}/p/${slug}`, 302);
}
private sendIndex(indexFilePath: string, res: FastifyReply) {
if (!fs.existsSync(indexFilePath)) {
// No built client (e.g. API-only dev): nothing to serve.
res.status(404).send('Not found');
return;
}
const stream = fs.createReadStream(indexFilePath);
res.type('text/html').send(stream);
}
}
/** Canonical share page slug: `<title-slug>-<slugId>` (mirrors the client). */
function buildPageSlug(slugId: string, title?: string): string {
const titleSlug = slugify(title?.substring(0, 70) || 'untitled');
return `${titleSlug}-${slugId}`;
}

View File

@@ -1,260 +0,0 @@
import {
BadRequestException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { ShareAliasController } from './share-alias.controller';
/**
* Authz-gate tests for the authenticated alias management controller. The access
* decisions for creating/retargeting/removing an alias live in THIS controller
* (the service spec delegates authorization to the caller), so each gate is
* pinned here against mocked PageRepo / ShareService / ShareAliasService /
* PageAccessService. A regression that drops any gate must fail here.
*/
describe('ShareAliasController authz gates', () => {
function makeController() {
const shareAliasService = {
setAlias: jest.fn(async () => ({ id: 'alias-1' })),
removeAlias: jest.fn(async () => undefined),
getAliasById: jest.fn(),
getAliasForPage: jest.fn(),
checkAvailability: jest.fn(),
};
const shareService = {
resolveReadableSharePage: jest.fn(),
isSharingAllowed: jest.fn(),
};
const pageRepo = { findById: jest.fn() };
const pageAccessService = {
validateCanEdit: jest.fn(async () => undefined),
validateCanView: jest.fn(async () => undefined),
};
const controller = new ShareAliasController(
shareAliasService as any,
shareService as any,
pageRepo as any,
pageAccessService as any,
);
return {
controller,
shareAliasService,
shareService,
pageRepo,
pageAccessService,
};
}
const user: any = { id: 'u-1' };
const workspace: any = { id: 'ws-1' };
describe('set', () => {
it('throws NotFoundException for a nonexistent page', async () => {
const { controller, pageRepo, pageAccessService } = makeController();
pageRepo.findById.mockResolvedValue(null);
await expect(
controller.set({ pageId: 'p-x', alias: 'promo' } as any, user, workspace),
).rejects.toBeInstanceOf(NotFoundException);
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
});
it('throws NotFoundException for a page in another workspace', async () => {
const { controller, pageRepo } = makeController();
pageRepo.findById.mockResolvedValue({
id: 'p-1',
workspaceId: 'ws-OTHER',
});
await expect(
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
).rejects.toBeInstanceOf(NotFoundException);
});
it('enforces validateCanEdit before setting the alias', async () => {
const { controller, pageRepo, pageAccessService, shareService } =
makeController();
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
pageAccessService.validateCanEdit.mockRejectedValue(
new ForbiddenException('no edit'),
);
await expect(
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
// Gate short-circuits before any share resolution.
expect(shareService.resolveReadableSharePage).not.toHaveBeenCalled();
});
it('throws BadRequestException when the page is not publicly shared', async () => {
const { controller, pageRepo, shareService } = makeController();
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
shareService.resolveReadableSharePage.mockResolvedValue(null);
await expect(
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
).rejects.toThrow('Page is not publicly shared');
await expect(
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
).rejects.toBeInstanceOf(BadRequestException);
});
it('throws ForbiddenException when public sharing is disabled', async () => {
const { controller, pageRepo, shareService } = makeController();
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
shareService.resolveReadableSharePage.mockResolvedValue({
share: { spaceId: 'sp-1' },
});
shareService.isSharingAllowed.mockResolvedValue(false);
await expect(
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('delegates to setAlias on the happy path with all gates passed', async () => {
const { controller, pageRepo, shareService, shareAliasService } =
makeController();
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
shareService.resolveReadableSharePage.mockResolvedValue({
share: { spaceId: 'sp-1' },
});
shareService.isSharingAllowed.mockResolvedValue(true);
const result = await controller.set(
{ pageId: 'p-1', alias: 'promo', confirmReassign: true } as any,
user,
workspace,
);
expect(shareAliasService.setAlias).toHaveBeenCalledWith({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'promo',
confirmReassign: true,
});
expect(result).toEqual({ id: 'alias-1' });
});
});
describe('remove', () => {
it('throws NotFoundException for an unknown alias', async () => {
const { controller, shareAliasService } = makeController();
shareAliasService.getAliasById.mockResolvedValue(null);
await expect(
controller.remove({ aliasId: 'a-x' } as any, user, workspace),
).rejects.toBeInstanceOf(NotFoundException);
expect(shareAliasService.removeAlias).not.toHaveBeenCalled();
});
it('requires validateCanEdit on the current target before removing', async () => {
const { controller, shareAliasService, pageRepo, pageAccessService } =
makeController();
shareAliasService.getAliasById.mockResolvedValue({
id: 'a-1',
pageId: 'p-1',
});
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
pageAccessService.validateCanEdit.mockRejectedValue(
new ForbiddenException('no edit'),
);
await expect(
controller.remove({ aliasId: 'a-1' } as any, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
expect(shareAliasService.removeAlias).not.toHaveBeenCalled();
});
it('removes a dangling alias (pageId null) WITHOUT an edit check', async () => {
const { controller, shareAliasService, pageRepo, pageAccessService } =
makeController();
shareAliasService.getAliasById.mockResolvedValue({
id: 'a-1',
pageId: null,
});
await controller.remove({ aliasId: 'a-1' } as any, user, workspace);
expect(pageRepo.findById).not.toHaveBeenCalled();
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
expect(shareAliasService.removeAlias).toHaveBeenCalledWith('a-1', 'ws-1');
});
it('removes when the editor can edit the current target', async () => {
const { controller, shareAliasService, pageRepo, pageAccessService } =
makeController();
shareAliasService.getAliasById.mockResolvedValue({
id: 'a-1',
pageId: 'p-1',
});
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
await controller.remove({ aliasId: 'a-1' } as any, user, workspace);
expect(pageAccessService.validateCanEdit).toHaveBeenCalled();
expect(shareAliasService.removeAlias).toHaveBeenCalledWith('a-1', 'ws-1');
});
it('removes even if the recorded target page no longer exists', async () => {
const { controller, shareAliasService, pageRepo, pageAccessService } =
makeController();
shareAliasService.getAliasById.mockResolvedValue({
id: 'a-1',
pageId: 'p-gone',
});
pageRepo.findById.mockResolvedValue(null);
await controller.remove({ aliasId: 'a-1' } as any, user, workspace);
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
expect(shareAliasService.removeAlias).toHaveBeenCalledWith('a-1', 'ws-1');
});
});
describe('forPage', () => {
it('throws NotFoundException for a cross-workspace/nonexistent page', async () => {
const { controller, pageRepo, pageAccessService } = makeController();
pageRepo.findById.mockResolvedValue({
id: 'p-1',
workspaceId: 'ws-OTHER',
});
await expect(
controller.forPage({ pageId: 'p-1' } as any, user, workspace),
).rejects.toBeInstanceOf(NotFoundException);
expect(pageAccessService.validateCanView).not.toHaveBeenCalled();
});
it('requires validateCanView and returns the alias (or null)', async () => {
const { controller, pageRepo, pageAccessService, shareAliasService } =
makeController();
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
shareAliasService.getAliasForPage.mockResolvedValue({ id: 'a-1' });
const result = await controller.forPage(
{ pageId: 'p-1' } as any,
user,
workspace,
);
expect(pageAccessService.validateCanView).toHaveBeenCalled();
expect(result).toEqual({ id: 'a-1' });
});
it('returns null when the page has no alias', async () => {
const { controller, pageRepo, shareAliasService } = makeController();
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
shareAliasService.getAliasForPage.mockResolvedValue(undefined);
const result = await controller.forPage(
{ pageId: 'p-1' } as any,
user,
workspace,
);
expect(result).toBeNull();
});
});
});

View File

@@ -1,139 +0,0 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PageAccessService } from '../page/page-access/page-access.service';
import { ShareService } from './share.service';
import { ShareAliasService } from './share-alias.service';
import {
RemoveShareAliasDto,
SetShareAliasDto,
ShareAliasAvailabilityDto,
ShareAliasForPageDto,
} from './dto/share-alias.dto';
/**
* Authenticated management of vanity `/l/:alias` links. The PUBLIC resolve path
* lives in `ShareAliasRedirectController` (`/l/:alias`); this controller only
* creates/retargets/removes/looks-up aliases for editors.
*/
@UseGuards(JwtAuthGuard)
@Controller('share-aliases')
export class ShareAliasController {
constructor(
private readonly shareAliasService: ShareAliasService,
private readonly shareService: ShareService,
private readonly pageRepo: PageRepo,
private readonly pageAccessService: PageAccessService,
) {}
@HttpCode(HttpStatus.OK)
@Post('set')
async set(
@Body() dto: SetShareAliasDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page || page.workspaceId !== workspace.id) {
throw new NotFoundException('Page not found');
}
// Editing the page is required to point an address at it.
await this.pageAccessService.validateCanEdit(page, user);
// The page must currently be publicly readable through the share graph; an
// alias to a non-shared page would only ever 404.
const resolved = await this.shareService.resolveReadableSharePage(
undefined,
page.id,
workspace.id,
);
if (!resolved) {
throw new BadRequestException('Page is not publicly shared');
}
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
resolved.share.spaceId,
);
if (!sharingAllowed) {
throw new ForbiddenException('Public sharing is disabled');
}
return this.shareAliasService.setAlias({
workspaceId: workspace.id,
pageId: page.id,
creatorId: user.id,
alias: dto.alias,
confirmReassign: dto.confirmReassign,
});
}
@HttpCode(HttpStatus.OK)
@Post('remove')
async remove(
@Body() dto: RemoveShareAliasDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const alias = await this.shareAliasService.getAliasById(
dto.aliasId,
workspace.id,
);
if (!alias) {
throw new NotFoundException('Alias not found');
}
// Only someone who can edit the (current) target page may free the address.
// A dangling alias (page deleted) can be removed by any workspace member.
if (alias.pageId) {
const page = await this.pageRepo.findById(alias.pageId);
if (page) {
await this.pageAccessService.validateCanEdit(page, user);
}
}
await this.shareAliasService.removeAlias(alias.id, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('availability')
async availability(
@Body() dto: ShareAliasAvailabilityDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.shareAliasService.checkAvailability(dto.alias, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('for-page')
async forPage(
@Body() dto: ShareAliasForPageDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page || page.workspaceId !== workspace.id) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
return (
(await this.shareAliasService.getAliasForPage(page.id, workspace.id)) ??
null
);
}
}

View File

@@ -1,252 +0,0 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { ShareAliasService } from './share-alias.service';
/**
* Behaviour tests for the alias write/resolve semantics: create vs no-op vs the
* 409 reassign guard, uniqueness-race handling, availability probe, and the
* request-time readable-target resolution (which re-runs the share boundary).
*/
describe('ShareAliasService', () => {
function makeService() {
const shareAliasRepo = {
findByAliasAndWorkspace: jest.fn(),
findByPageId: jest.fn(),
findById: jest.fn(),
insert: jest.fn(),
updatePageId: jest.fn(),
delete: jest.fn(),
};
const pageRepo = { findById: jest.fn() };
const shareService = {
resolveReadableSharePage: jest.fn(),
isSharingAllowed: jest.fn(),
};
const service = new ShareAliasService(
shareAliasRepo as any,
pageRepo as any,
shareService as any,
);
return { service, shareAliasRepo, pageRepo, shareService };
}
describe('setAlias', () => {
it('rejects an invalid alias before touching the db', async () => {
const { service, shareAliasRepo } = makeService();
await expect(
service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'A', // too short + uppercase
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled();
});
it('normalizes then inserts a brand-new alias', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.insert.mockResolvedValue({ id: 'a-1', alias: 'my-page' });
const res = await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: ' My Page ',
});
expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith(
'my-page',
'ws-1',
);
expect(shareAliasRepo.insert).toHaveBeenCalledWith({
workspaceId: 'ws-1',
alias: 'my-page',
pageId: 'p-1',
creatorId: 'u-1',
});
expect(res).toMatchObject({ id: 'a-1' });
});
it('is a no-op when the alias already points at the same page', async () => {
const { service, shareAliasRepo } = makeService();
const existing = { id: 'a-1', alias: 'foo', pageId: 'p-1' };
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(existing);
const res = await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
});
expect(res).toBe(existing);
expect(shareAliasRepo.insert).not.toHaveBeenCalled();
expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled();
});
it('throws 409 with current target when name is taken and not confirmed', async () => {
const { service, shareAliasRepo, pageRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
id: 'a-1',
alias: 'foo',
pageId: 'p-other',
});
pageRepo.findById.mockResolvedValue({ id: 'p-other', title: 'Other' });
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
});
fail('expected ConflictException');
} catch (err) {
expect(err).toBeInstanceOf(ConflictException);
expect((err as ConflictException).getResponse()).toMatchObject({
code: 'ALIAS_REASSIGN_REQUIRED',
currentPageId: 'p-other',
currentPageTitle: 'Other',
});
}
expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled();
});
it('retargets (UPDATE page_id) when confirmReassign is set', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
id: 'a-1',
alias: 'foo',
pageId: 'p-other',
});
shareAliasRepo.updatePageId.mockResolvedValue({ id: 'a-1', pageId: 'p-1' });
const res = await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
confirmReassign: true,
});
expect(shareAliasRepo.updatePageId).toHaveBeenCalledWith(
'a-1',
'p-1',
'ws-1',
);
expect(res).toMatchObject({ pageId: 'p-1' });
});
it('maps a unique-violation race to 409', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.insert.mockRejectedValue({ code: '23505' });
await expect(
service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
}),
).rejects.toBeInstanceOf(ConflictException);
});
});
describe('checkAvailability', () => {
it('reports invalid for a bad slug without a db hit', async () => {
const { service, shareAliasRepo } = makeService();
const res = await service.checkAvailability('Bad Slug!', 'ws-1');
expect(res).toMatchObject({ valid: false, available: false });
expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled();
});
it('reports available when no row exists', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
const res = await service.checkAvailability('free-name', 'ws-1');
expect(res).toMatchObject({
alias: 'free-name',
valid: true,
available: true,
currentPageId: null,
});
});
it('reports taken with the current target page', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
id: 'a-1',
pageId: 'p-9',
});
const res = await service.checkAvailability('taken', 'ws-1');
expect(res).toMatchObject({ available: false, currentPageId: 'p-9' });
});
});
describe('resolveReadableTarget', () => {
it('returns null for an invalid alias', async () => {
const { service } = makeService();
expect(await service.resolveReadableTarget('!!', 'ws-1')).toBeNull();
});
it('returns null for an unknown or dangling alias', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValueOnce(undefined);
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValueOnce({
id: 'a-1',
pageId: null,
});
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
});
it('returns null when the page is no longer publicly readable', async () => {
const { service, shareAliasRepo, shareService } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
id: 'a-1',
pageId: 'p-1',
});
shareService.resolveReadableSharePage.mockResolvedValue(null);
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
});
it('returns null when sharing is disabled for the space', async () => {
const { service, shareAliasRepo, shareService } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
id: 'a-1',
pageId: 'p-1',
});
shareService.resolveReadableSharePage.mockResolvedValue({
share: { key: 'k', spaceId: 's-1' },
page: { slugId: 'sid', title: 'T' },
});
shareService.isSharingAllowed.mockResolvedValue(false);
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
});
it('returns the resolved share+page on success', async () => {
const { service, shareAliasRepo, shareService } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
id: 'a-1',
pageId: 'p-1',
});
const resolved = {
share: { key: 'k', spaceId: 's-1' },
page: { slugId: 'sid', title: 'T' },
};
shareService.resolveReadableSharePage.mockResolvedValue(resolved);
shareService.isSharingAllowed.mockResolvedValue(true);
const res = await service.resolveReadableTarget('FOO', 'ws-1');
expect(res).toBe(resolved);
// alias was normalized to lowercase before lookup
expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith(
'foo',
'ws-1',
);
});
});
});

View File

@@ -1,187 +0,0 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
} from '@nestjs/common';
import { ShareAliasRepo } from '@docmost/db/repos/share-alias/share-alias.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { ShareService } from './share.service';
import { Page, ShareAlias } from '@docmost/db/types/entity.types';
import { isValidShareAlias, normalizeShareAlias } from './share-alias.util';
/** Postgres unique_violation; the (workspace_id, alias) constraint races here. */
const PG_UNIQUE_VIOLATION = '23505';
export interface ResolvedAliasTarget {
share: NonNullable<
Awaited<ReturnType<ShareService['resolveReadableSharePage']>>
>['share'];
page: Page;
}
@Injectable()
export class ShareAliasService {
private readonly logger = new Logger(ShareAliasService.name);
constructor(
private readonly shareAliasRepo: ShareAliasRepo,
private readonly pageRepo: PageRepo,
private readonly shareService: ShareService,
) {}
/**
* Create or retarget a vanity alias. The alias is workspace-scoped:
* - no row for this name -> INSERT a new pointer
* - row already points at pageId -> no-op (idempotent)
* - row points elsewhere -> the "swap". Without confirmReassign we
* throw 409 carrying the current target so the client can confirm; with
* it we UPDATE the single row's page_id (every /l/<alias> link follows the
* 302 to the new page instantly — no stale 301 cache).
*
* Caller is responsible for authorizing the page (edit rights + public
* readability); this method owns only the alias-name semantics.
*/
async setAlias(opts: {
workspaceId: string;
pageId: string;
creatorId: string;
alias: string;
confirmReassign?: boolean;
}): Promise<ShareAlias> {
const { workspaceId, pageId, creatorId, confirmReassign } = opts;
const alias = normalizeShareAlias(opts.alias);
if (!isValidShareAlias(alias)) {
throw new BadRequestException(
'Invalid alias. Use 2-60 lowercase letters, digits and hyphens.',
);
}
const existing = await this.shareAliasRepo.findByAliasAndWorkspace(
alias,
workspaceId,
);
if (!existing) {
try {
return await this.shareAliasRepo.insert({
workspaceId,
alias,
pageId,
creatorId,
});
} catch (err: any) {
// Lost a uniqueness race: another request claimed the name first.
if (err?.code === PG_UNIQUE_VIOLATION) {
throw new ConflictException({ message: 'Alias already taken' });
}
this.logger.error(err);
throw new BadRequestException('Failed to set alias');
}
}
// Already points at this page -> nothing to do.
if (existing.pageId === pageId) {
return existing;
}
// Name occupied by a different (or dangling) target: require confirmation.
if (!confirmReassign) {
const currentPage = existing.pageId
? await this.pageRepo.findById(existing.pageId)
: null;
throw new ConflictException({
message: 'Alias already in use',
code: 'ALIAS_REASSIGN_REQUIRED',
currentPageId: existing.pageId,
currentPageTitle: currentPage?.title ?? null,
});
}
return this.shareAliasRepo.updatePageId(existing.id, pageId, workspaceId);
}
/** Free a vanity name (no history kept). */
async removeAlias(aliasId: string, workspaceId: string): Promise<void> {
await this.shareAliasRepo.delete(aliasId, workspaceId);
}
/** Debounced availability probe for the modal. */
async checkAvailability(
rawAlias: string,
workspaceId: string,
): Promise<{
alias: string;
valid: boolean;
available: boolean;
currentPageId: string | null;
}> {
const alias = normalizeShareAlias(rawAlias);
if (!isValidShareAlias(alias)) {
return { alias, valid: false, available: false, currentPageId: null };
}
const existing = await this.shareAliasRepo.findByAliasAndWorkspace(
alias,
workspaceId,
);
return {
alias,
valid: true,
available: !existing,
currentPageId: existing?.pageId ?? null,
};
}
/** A single alias row scoped to the workspace, or undefined. */
getAliasById(
aliasId: string,
workspaceId: string,
): Promise<ShareAlias | undefined> {
return this.shareAliasRepo.findById(aliasId, workspaceId);
}
/** The alias currently targeting a page (modal display), or undefined. */
getAliasForPage(
pageId: string,
workspaceId: string,
): Promise<ShareAlias | undefined> {
return this.shareAliasRepo.findByPageId(pageId, workspaceId);
}
/**
* Resolve a vanity alias to the canonical, publicly-READABLE share page, or
* null. This re-runs the authoritative share boundary at request time (so a
* later-unshared / restricted / sharing-disabled target collapses to null and
* the caller serves the generic SPA 404 — no existence leak). The alias row
* itself is just a pointer; this is where access is actually decided.
*/
async resolveReadableTarget(
rawAlias: string,
workspaceId: string,
): Promise<ResolvedAliasTarget | null> {
const alias = normalizeShareAlias(rawAlias);
if (!isValidShareAlias(alias)) return null;
const aliasRow = await this.shareAliasRepo.findByAliasAndWorkspace(
alias,
workspaceId,
);
// Unknown name or a dangling alias (target page deleted) -> not resolvable.
if (!aliasRow?.pageId) return null;
const resolved = await this.shareService.resolveReadableSharePage(
undefined,
aliasRow.pageId,
workspaceId,
);
if (!resolved) return null;
const sharingAllowed = await this.shareService.isSharingAllowed(
workspaceId,
resolved.share.spaceId,
);
if (!sharingAllowed) return null;
return resolved;
}
}

View File

@@ -1,60 +0,0 @@
import { isValidShareAlias, normalizeShareAlias } from './share-alias.util';
describe('normalizeShareAlias', () => {
it('lowercases and trims', () => {
expect(normalizeShareAlias(' HelloWorld ')).toBe('helloworld');
});
it('converts spaces and underscores to single hyphens', () => {
expect(normalizeShareAlias('my cool page')).toBe('my-cool-page');
expect(normalizeShareAlias('my_cool_page')).toBe('my-cool-page');
});
it('collapses repeated hyphens and trims edge hyphens', () => {
expect(normalizeShareAlias('--a---b--')).toBe('a-b');
});
it('handles null/undefined defensively', () => {
expect(normalizeShareAlias(undefined as unknown as string)).toBe('');
});
});
describe('isValidShareAlias', () => {
it('accepts ascii lowercase hyphen-separated slugs', () => {
expect(isValidShareAlias('hello')).toBe(true);
expect(isValidShareAlias('hello-world-2')).toBe(true);
expect(isValidShareAlias('a1')).toBe(true);
});
it('rejects too short / too long', () => {
expect(isValidShareAlias('a')).toBe(false);
expect(isValidShareAlias('a'.repeat(61))).toBe(false);
expect(isValidShareAlias('a'.repeat(60))).toBe(true);
});
it('rejects leading/trailing/double hyphens', () => {
expect(isValidShareAlias('-abc')).toBe(false);
expect(isValidShareAlias('abc-')).toBe(false);
expect(isValidShareAlias('a--b')).toBe(false);
});
it('rejects uppercase, cyrillic and other non-ascii', () => {
expect(isValidShareAlias('Hello')).toBe(false);
expect(isValidShareAlias('привет')).toBe(false);
expect(isValidShareAlias('a b')).toBe(false);
expect(isValidShareAlias('a_b')).toBe(false);
expect(isValidShareAlias('a.b')).toBe(false);
});
it('normalize + validate round-trips a messy input to a valid slug', () => {
const alias = normalizeShareAlias(' My Cool_Page!! ');
// "!!" is not stripped by normalize (only case/separators), so the result
// still fails validation — the charset gate is intentionally separate.
expect(alias).toBe('my-cool-page!!');
expect(isValidShareAlias(alias)).toBe(false);
const ok = normalizeShareAlias(' My Cool Page ');
expect(ok).toBe('my-cool-page');
expect(isValidShareAlias(ok)).toBe(true);
});
});

View File

@@ -1,30 +0,0 @@
/**
* Vanity share-alias helpers shared by the write path (set/availability) and the
* `/l/:alias` resolve path. Aliases are ASCII-only, lowercase, hyphen-separated
* slugs — deliberately no Cyrillic / transliteration: the user types the exact
* canonical form. Keep this in sync with the client copy in
* `apps/client/src/features/share/share-alias.util.ts`.
*/
// Normalize a user-provided vanity alias into canonical ASCII storage form.
// This only canonicalizes shape (case, separators); it does NOT enforce the
// charset — call isValidShareAlias afterwards to reject anything illegal.
export function normalizeShareAlias(raw: string): string {
return (raw ?? '')
.trim()
.toLowerCase()
.replace(/[\s_]+/g, '-') // spaces/underscores -> single hyphen
.replace(/-{2,}/g, '-') // collapse repeated hyphens
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
}
// ASCII only: lowercase letters/digits in hyphen-separated groups, length 2..60.
const ALIAS_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function isValidShareAlias(alias: string): boolean {
return (
typeof alias === 'string' &&
alias.length >= 2 &&
alias.length <= 60 &&
ALIAS_RE.test(alias)
);
}

View File

@@ -5,22 +5,13 @@ import { TokenModule } from '../auth/token.module';
import { ShareSeoController } from './share-seo.controller';
import { TransclusionModule } from '../page/transclusion/transclusion.module';
import { AiModule } from '../../integrations/ai/ai.module';
import { ShareAliasService } from './share-alias.service';
import { ShareAliasController } from './share-alias.controller';
import { ShareAliasRedirectController } from './share-alias-redirect.controller';
@Module({
// AiModule (AiSettingsService) is used by the page-info route to surface
// whether the anonymous public-share assistant is enabled for the workspace.
imports: [TokenModule, TransclusionModule, AiModule],
controllers: [
ShareController,
ShareSeoController,
// Vanity /l/:alias: authenticated management + public 302 resolver.
ShareAliasController,
ShareAliasRedirectController,
],
providers: [ShareService, ShareAliasService],
exports: [ShareService, ShareAliasService],
controllers: [ShareController, ShareSeoController],
providers: [ShareService],
exports: [ShareService],
})
export class ShareModule {}

View File

@@ -23,7 +23,6 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { ShareAliasRepo } from '@docmost/db/repos/share-alias/share-alias.repo';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
@@ -97,7 +96,6 @@ import { normalizePostgresUrl } from '../common/helpers';
UserSessionRepo,
BacklinkRepo,
ShareRepo,
ShareAliasRepo,
NotificationRepo,
WatcherRepo,
LabelRepo,
@@ -130,7 +128,6 @@ import { normalizePostgresUrl } from '../common/helpers';
UserSessionRepo,
BacklinkRepo,
ShareRepo,
ShareAliasRepo,
NotificationRepo,
WatcherRepo,
LabelRepo,

View File

@@ -1,54 +0,0 @@
import { type Kysely, sql } from 'kysely';
/**
* Vanity share aliases: a retargetable, human-readable pointer (`/l/<alias>`)
* that lives independently of any single `shares` row. The alias belongs to the
* WORKSPACE (stable address), and `page_id` is nullable with ON DELETE SET NULL
* so the address survives deletion of its current target (it 404s until
* retargeted) rather than disappearing with the page.
*/
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('share_aliases')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
// Normalized ASCII, lowercase. Uniqueness is enforced per-workspace below.
.addColumn('alias', 'varchar', (col) => col.notNull())
// Nullable + SET NULL: the address outlives its target page.
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('set null'),
)
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
// The vanity name is unique within a workspace (mirrors shares.key scoping).
await db.schema
.createIndex('share_aliases_workspace_id_alias_unique')
.on('share_aliases')
.columns(['workspace_id', 'alias'])
.unique()
.execute();
// "Which alias targets this page?" lookup for the share modal.
await db.schema
.createIndex('share_aliases_page_id_idx')
.on('share_aliases')
.column('page_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('share_aliases').execute();
}

View File

@@ -1,120 +0,0 @@
import { ShareAliasRepo } from './share-alias.repo';
import type { KyselyDB } from '../../types/kysely.types';
/**
* SQL-shape unit tests for ShareAliasRepo. A live Postgres is out of scope;
* instead we spy on the Kysely builder to assert each method pins the
* workspace scope (so a name in one workspace can never resolve another's
* page) and threads the right columns.
*/
describe('ShareAliasRepo', () => {
function makeSelectRepo(result: unknown) {
const where = jest.fn();
const builder: any = {
select: jest.fn(() => builder),
where: jest.fn((...args: unknown[]) => {
where(...args);
return builder;
}),
executeTakeFirst: jest.fn().mockResolvedValue(result),
};
const db = { selectFrom: jest.fn(() => builder) } as unknown as KyselyDB;
return { repo: new ShareAliasRepo(db), db, where, builder };
}
it('findByAliasAndWorkspace scopes by alias AND workspace', async () => {
const row = { id: 'a-1', alias: 'foo', workspaceId: 'ws-1' };
const { repo, db, where } = makeSelectRepo(row);
const res = await repo.findByAliasAndWorkspace('foo', 'ws-1');
expect(res).toBe(row);
expect(db.selectFrom).toHaveBeenCalledWith('shareAliases');
expect(where).toHaveBeenCalledWith('alias', '=', 'foo');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
});
it('findByPageId scopes by page AND workspace', async () => {
const { repo, where } = makeSelectRepo(undefined);
await repo.findByPageId('p-1', 'ws-1');
expect(where).toHaveBeenCalledWith('pageId', '=', 'p-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
});
it('insert writes the provided columns and returns the row', async () => {
const values = jest.fn();
const inserted = { id: 'a-1' };
const builder: any = {
values: jest.fn((v: unknown) => {
values(v);
return builder;
}),
returning: jest.fn(() => builder),
executeTakeFirst: jest.fn().mockResolvedValue(inserted),
};
const db = { insertInto: jest.fn(() => builder) } as unknown as KyselyDB;
const repo = new ShareAliasRepo(db);
const res = await repo.insert({
workspaceId: 'ws-1',
alias: 'foo',
pageId: 'p-1',
creatorId: 'u-1',
});
expect(db.insertInto).toHaveBeenCalledWith('shareAliases');
expect(values).toHaveBeenCalledWith({
workspaceId: 'ws-1',
alias: 'foo',
pageId: 'p-1',
creatorId: 'u-1',
});
expect(res).toBe(inserted);
});
it('updatePageId retargets a single row scoped by id + workspace', async () => {
const set = jest.fn();
const where = jest.fn();
const builder: any = {
set: jest.fn((s: unknown) => {
set(s);
return builder;
}),
where: jest.fn((...args: unknown[]) => {
where(...args);
return builder;
}),
returning: jest.fn(() => builder),
executeTakeFirst: jest.fn().mockResolvedValue({ id: 'a-1' }),
};
const db = { updateTable: jest.fn(() => builder) } as unknown as KyselyDB;
const repo = new ShareAliasRepo(db);
await repo.updatePageId('a-1', 'p-2', 'ws-1');
expect(db.updateTable).toHaveBeenCalledWith('shareAliases');
expect(set.mock.calls[0][0].pageId).toBe('p-2');
expect(set.mock.calls[0][0].updatedAt).toBeInstanceOf(Date);
expect(where).toHaveBeenCalledWith('id', '=', 'a-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
});
it('delete scopes by id + workspace', async () => {
const where = jest.fn();
const builder: any = {
where: jest.fn((...args: unknown[]) => {
where(...args);
return builder;
}),
execute: jest.fn().mockResolvedValue(undefined),
};
const db = { deleteFrom: jest.fn(() => builder) } as unknown as KyselyDB;
const repo = new ShareAliasRepo(db);
await repo.delete('a-1', 'ws-1');
expect(db.deleteFrom).toHaveBeenCalledWith('shareAliases');
expect(where).toHaveBeenCalledWith('id', '=', 'a-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
});
});

View File

@@ -1,109 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
InsertableShareAlias,
ShareAlias,
} from '@docmost/db/types/entity.types';
/**
* Repository for vanity share aliases (`/l/:alias`). An alias is a long-lived,
* workspace-scoped pointer to a page; retargeting is a single UPDATE of
* `page_id`. All lookups are workspace-scoped so a name in one workspace can
* never resolve a page in another.
*/
@Injectable()
export class ShareAliasRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof ShareAlias> = [
'id',
'workspaceId',
'alias',
'pageId',
'creatorId',
'createdAt',
'updatedAt',
];
/** Resolve a (normalized) alias within a workspace, or undefined. */
async findByAliasAndWorkspace(
alias: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<ShareAlias | undefined> {
return dbOrTx(this.db, trx)
.selectFrom('shareAliases')
.select(this.baseFields)
.where('alias', '=', alias)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
/** The alias currently pointing at a page (for the share modal). */
async findByPageId(
pageId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<ShareAlias | undefined> {
return dbOrTx(this.db, trx)
.selectFrom('shareAliases')
.select(this.baseFields)
.where('pageId', '=', pageId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async findById(
id: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<ShareAlias | undefined> {
return dbOrTx(this.db, trx)
.selectFrom('shareAliases')
.select(this.baseFields)
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async insert(
insertable: InsertableShareAlias,
trx?: KyselyTransaction,
): Promise<ShareAlias> {
return dbOrTx(this.db, trx)
.insertInto('shareAliases')
.values(insertable)
.returning(this.baseFields)
.executeTakeFirst();
}
/** Retarget an existing alias to a new page (the "swap" operation). */
async updatePageId(
id: string,
pageId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<ShareAlias> {
return dbOrTx(this.db, trx)
.updateTable('shareAliases')
.set({ pageId, updatedAt: new Date() })
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
async delete(
id: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await dbOrTx(this.db, trx)
.deleteFrom('shareAliases')
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.execute();
}
}

View File

@@ -1,94 +0,0 @@
import * as migration from './migrations/20260626T130000-share-aliases';
import type {
InsertableShareAlias,
ShareAlias,
UpdatableShareAlias,
} from './types/entity.types';
/**
* Sanity checks for the share_aliases migration + entity types. We don't run a
* live Postgres here (that's the integration suite); instead we assert the
* migration exposes the expected up/down contract and creates the table with
* the unique (workspace_id, alias) constraint and the page_id index, and that
* the generated entity types line up with the column set.
*/
describe('share-aliases migration', () => {
it('up creates the table, the unique index and the page_id index', async () => {
const calls: string[] = [];
const tableBuilder: any = new Proxy(
{},
{
get(_t, prop: string) {
if (prop === 'execute') return async () => undefined;
// addColumn/addConstraint/etc. are chainable no-ops.
return () => tableBuilder;
},
},
);
const indexBuilder: any = new Proxy(
{},
{
get(_t, prop: string) {
if (prop === 'execute') return async () => undefined;
return () => indexBuilder;
},
},
);
const schema = {
createTable: (name: string) => {
calls.push(`createTable:${name}`);
return tableBuilder;
},
createIndex: (name: string) => {
calls.push(`createIndex:${name}`);
return indexBuilder;
},
};
await migration.up({ schema } as any);
expect(calls).toContain('createTable:share_aliases');
expect(calls).toContain(
'createIndex:share_aliases_workspace_id_alias_unique',
);
expect(calls).toContain('createIndex:share_aliases_page_id_idx');
});
it('down drops the table', async () => {
const calls: string[] = [];
const dropBuilder: any = { execute: async () => undefined };
const schema = {
dropTable: (name: string) => {
calls.push(`dropTable:${name}`);
return dropBuilder;
},
};
await migration.down({ schema } as any);
expect(calls).toContain('dropTable:share_aliases');
});
it('entity types expose the alias columns', () => {
// Compile-time only: these typed declarations fail `tsc` if the entity types
// drift (missing/renamed columns, wrong nullability). The runtime assertions
// would be tautological, so the value is purely in the type-check.
const row: ShareAlias = {
id: 'a-1',
workspaceId: 'ws-1',
alias: 'foo',
pageId: 'p-1',
creatorId: 'u-1',
createdAt: new Date(),
updatedAt: new Date(),
};
const insert: InsertableShareAlias = {
workspaceId: 'ws-1',
alias: 'foo',
};
const update: UpdatableShareAlias = { pageId: null };
expect([row, insert, update]).toHaveLength(3);
});
});

View File

@@ -305,16 +305,6 @@ export interface Pages {
ydoc: Buffer | null;
}
export interface ShareAliases {
alias: string;
createdAt: Generated<Timestamp>;
creatorId: string | null;
id: Generated<string>;
pageId: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface Shares {
createdAt: Generated<Timestamp>;
creatorId: string | null;
@@ -684,7 +674,6 @@ export interface DB {
pageVerifiers: PageVerifiers;
pages: Pages;
scimTokens: ScimTokens;
shareAliases: ShareAliases;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;

View File

@@ -30,7 +30,6 @@ import {
AuthProviders,
AuthAccounts,
Shares,
ShareAliases,
Favorites,
FileTasks,
UserMfa as _UserMFA,
@@ -173,11 +172,6 @@ export type Share = Selectable<Shares>;
export type InsertableShare = Insertable<Shares>;
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
// Share alias (vanity /l/:alias pointer)
export type ShareAlias = Selectable<ShareAliases>;
export type InsertableShareAlias = Insertable<ShareAliases>;
export type UpdatableShareAlias = Updateable<Omit<ShareAliases, 'id'>>;
// Favorite
export type Favorite = Selectable<Favorites>;
export type InsertableFavorite = Insertable<Favorites>;

View File

@@ -1,93 +0,0 @@
import { buildCorsAllowlist, isOriginAllowed } from './cors.util';
const WEBVIEW_ORIGINS = [
'capacitor://localhost',
'ionic://localhost',
'https://localhost',
];
describe('isOriginAllowed', () => {
const allowlist = buildCorsAllowlist({
appUrl: 'https://app.example',
configuredOrigins: ['https://other.example'],
});
it('allows requests with no Origin header', () => {
expect(isOriginAllowed(undefined, allowlist)).toBe(true);
expect(isOriginAllowed('', allowlist)).toBe(true);
});
it('allows an exact allowlisted origin', () => {
expect(isOriginAllowed('https://app.example', allowlist)).toBe(true);
expect(isOriginAllowed('https://other.example', allowlist)).toBe(true);
});
it('allows each native WebView origin', () => {
for (const origin of WEBVIEW_ORIGINS) {
expect(isOriginAllowed(origin, allowlist)).toBe(true);
}
});
it('rejects a foreign credentialed origin', () => {
// With credentials:true a foreign credentialed origin must be rejected.
expect(isOriginAllowed('https://evil.example', allowlist)).toBe(false);
});
it('rejects the cleartext http://localhost origin', () => {
// The native shell uses the secure scheme (https://localhost) on Android and
// the capacitor:// custom scheme on iOS, so cleartext http://localhost must
// not be trusted.
expect(isOriginAllowed('http://localhost', allowlist)).toBe(false);
});
it('rejects a trailing-slash mismatch', () => {
expect(isOriginAllowed('https://app.example/', allowlist)).toBe(false);
});
it('rejects a host-case mismatch', () => {
expect(isOriginAllowed('https://APP.example', allowlist)).toBe(false);
});
it('allows no-Origin but rejects cross-origin with an empty allowlist', () => {
const empty: ReadonlySet<string> = new Set<string>();
expect(isOriginAllowed(undefined, empty)).toBe(true);
expect(isOriginAllowed('https://app.example', empty)).toBe(false);
});
});
describe('buildCorsAllowlist', () => {
it('contains the app URL, each configured origin, and all WebView origins', () => {
const allowlist = buildCorsAllowlist({
appUrl: 'https://app.example',
configuredOrigins: ['https://a.example', 'https://b.example'],
});
expect(allowlist.has('https://app.example')).toBe(true);
expect(allowlist.has('https://a.example')).toBe(true);
expect(allowlist.has('https://b.example')).toBe(true);
for (const origin of WEBVIEW_ORIGINS) {
expect(allowlist.has(origin)).toBe(true);
}
});
it('deduplicates when a configured origin coincides with the app URL', () => {
const allowlist = buildCorsAllowlist({
appUrl: 'https://app.example',
configuredOrigins: ['https://app.example'],
});
// app URL + WebView origins, the duplicate configured origin collapses.
expect(allowlist.size).toBe(1 + WEBVIEW_ORIGINS.length);
});
it('always includes every WebView origin even with no configured origins', () => {
const allowlist = buildCorsAllowlist({
appUrl: 'https://app.example',
configuredOrigins: [],
});
for (const origin of WEBVIEW_ORIGINS) {
expect(allowlist.has(origin)).toBe(true);
}
});
});

View File

@@ -1,50 +0,0 @@
// CORS trust boundary helpers. `buildCorsAllowlist` produces the exact set of
// origins the API trusts, and `isOriginAllowed` is the predicate the enableCors
// origin callback uses to accept/reject each request. With credentials:true a
// foreign credentialed origin must never be allowed, so anything not in the
// allowlist (apart from no-Origin requests) is rejected.
// Native WebView origins used by the Capacitor/Ionic mobile shell. Always
// trusted so the native client can call the API.
//
// - `capacitor://localhost` — iOS native custom scheme.
// - `ionic://localhost` — legacy native custom scheme.
// - `https://localhost` — Android default secure scheme.
//
// The cleartext `http://localhost` origin is intentionally NOT trusted: the
// Capacitor shell uses the secure scheme (capacitor.config.ts sets
// `cleartext: false` and does not override `androidScheme`, so Capacitor's
// default Android scheme is `https` => origin `https://localhost`), and iOS runs
// in hosted mode (`server.url` = CAP_SERVER_URL, whose origin is the app URL
// already in the allowlist). No native client legitimately uses
// `http://localhost`, so allowing it would only widen the credentialed-CORS
// surface to arbitrary local http content.
const NATIVE_WEBVIEW_ORIGINS = [
'capacitor://localhost',
'ionic://localhost',
'https://localhost',
] as const;
// Build the CORS allowlist: the app URL, all configured cross-origin clients,
// and the native WebView origins. Dedup is automatic via Set.
export function buildCorsAllowlist(input: {
appUrl: string;
configuredOrigins: readonly string[];
}): Set<string> {
return new Set<string>([
input.appUrl,
...input.configuredOrigins,
...NATIVE_WEBVIEW_ORIGINS,
]);
}
// Decide whether a request's Origin is allowed. A missing Origin header (curl,
// server-to-server, some native WebViews) is allowed; otherwise the origin must
// be present in the allowlist.
export function isOriginAllowed(
origin: string | undefined,
allowlist: ReadonlySet<string>,
): boolean {
if (!origin) return true;
return allowlist.has(origin);
}

View File

@@ -5,13 +5,6 @@ import { EnvironmentService } from './environment.service';
describe('EnvironmentService', () => {
let service: EnvironmentService;
// Build a service over a stub ConfigService whose get(key, def) returns
// values from the supplied env map (falling back to the provided default).
const makeService = (env: Record<string, string>) =>
new EnvironmentService({
get: (k: string, d?: string) => (k in env ? env[k] : d),
} as any);
beforeEach(() => {
service = new EnvironmentService(
{} as any, // configService
@@ -21,83 +14,4 @@ describe('EnvironmentService', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getCorsAllowedOrigins', () => {
it('splits, trims, and drops empty entries', () => {
const svc = makeService({
CORS_ALLOWED_ORIGINS: 'https://a.com, https://b.com ,, https://c.com',
});
expect(svc.getCorsAllowedOrigins()).toEqual([
'https://a.com',
'https://b.com',
'https://c.com',
]);
});
it('returns an empty array when the var is absent', () => {
const svc = makeService({});
expect(svc.getCorsAllowedOrigins()).toEqual([]);
});
it('returns an empty array for an empty string', () => {
const svc = makeService({ CORS_ALLOWED_ORIGINS: '' });
expect(svc.getCorsAllowedOrigins()).toEqual([]);
});
it('returns a single origin unchanged', () => {
const svc = makeService({
CORS_ALLOWED_ORIGINS: 'https://app.example',
});
expect(svc.getCorsAllowedOrigins()).toEqual(['https://app.example']);
});
// Adversarial case: leading/trailing/duplicate commas with surrounding
// spaces must be dropped, exercising both .map(trim) and .filter(Boolean).
it('drops leading/trailing commas with surrounding spaces', () => {
const svc = makeService({ CORS_ALLOWED_ORIGINS: ' , a , , b ' });
expect(svc.getCorsAllowedOrigins()).toEqual(['a', 'b']);
});
});
describe('isSwaggerEnabled', () => {
it('is true for "true"', () => {
expect(makeService({ SWAGGER_ENABLED: 'true' }).isSwaggerEnabled()).toBe(
true,
);
});
it('is true case-insensitively for "TRUE"', () => {
expect(makeService({ SWAGGER_ENABLED: 'TRUE' }).isSwaggerEnabled()).toBe(
true,
);
});
it('is true for mixed-case "True"', () => {
expect(makeService({ SWAGGER_ENABLED: 'True' }).isSwaggerEnabled()).toBe(
true,
);
});
it('defaults to false when absent', () => {
expect(makeService({}).isSwaggerEnabled()).toBe(false);
});
it('is false for non-"true" values', () => {
expect(makeService({ SWAGGER_ENABLED: '0' }).isSwaggerEnabled()).toBe(
false,
);
expect(makeService({ SWAGGER_ENABLED: 'yes' }).isSwaggerEnabled()).toBe(
false,
);
expect(makeService({ SWAGGER_ENABLED: 'false' }).isSwaggerEnabled()).toBe(
false,
);
expect(makeService({ SWAGGER_ENABLED: '' }).isSwaggerEnabled()).toBe(
false,
);
expect(makeService({ SWAGGER_ENABLED: '1' }).isSwaggerEnabled()).toBe(
false,
);
});
});
});

View File

@@ -320,19 +320,4 @@ export class EnvironmentService {
.map((o) => o.trim())
.filter(Boolean);
}
getCorsAllowedOrigins(): string[] {
const raw = this.configService.get<string>('CORS_ALLOWED_ORIGINS', '');
return raw
.split(',')
.map((o) => o.trim())
.filter(Boolean);
}
isSwaggerEnabled(): boolean {
const enabled = this.configService
.get<string>('SWAGGER_ENABLED', 'false')
.toLowerCase();
return enabled === 'true';
}
}

View File

@@ -15,11 +15,6 @@ import { InternalLogFilter } from './common/logger/internal-log-filter';
import { EnvironmentService } from './integrations/environment/environment.service';
import { resolveFrameHeader } from './common/helpers';
import { resolveTrustProxy } from './integrations/environment/trust-proxy.util';
import {
buildCorsAllowlist,
isOriginAllowed,
} from './integrations/environment/cors.util';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
@@ -45,14 +40,7 @@ async function bootstrap() {
app.useLogger(app.get(PinoLogger));
app.setGlobalPrefix('api', {
exclude: [
'robots.txt',
'share/:shareId/p/:pageSlug',
// Vanity link resolver lives outside /api so /l/<alias> is a clean
// public URL that 302s to the canonical share page.
'l/:alias',
'mcp',
],
exclude: ['robots.txt', 'share/:shareId/p/:pageSlug', 'mcp'],
});
const reflector = app.get(Reflector);
@@ -154,43 +142,8 @@ async function bootstrap() {
}),
);
// Configure CORS explicitly (replaces the previous unconfigured enableCors()).
// The web client is same-origin in production; an explicit allowlist lets
// native/mobile WebView origins (Capacitor) and any configured cross-origin
// clients call the API, while everything else is rejected.
const corsAllowedOrigins = buildCorsAllowlist({
appUrl: environmentService.getAppUrl(),
configuredOrigins: environmentService.getCorsAllowedOrigins(),
});
app.enableCors({
// Allow requests with no Origin header (curl, server-to-server, some native
// WebView requests) and any origin in the allowlist; reject the rest.
origin: (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void,
) => {
callback(null, isOriginAllowed(origin, corsAllowedOrigins));
},
credentials: true,
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
app.enableCors();
app.useGlobalInterceptors(new TransformHttpResponseInterceptor(reflector));
if (environmentService.isSwaggerEnabled()) {
// Optional OpenAPI docs to speed up typed mobile-client generation.
const swaggerConfig = new DocumentBuilder()
.setTitle('Gitmost API')
.setDescription('Gitmost REST API (RPC-style POST endpoints).')
.setVersion(process.env.APP_VERSION || '0.0.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document);
}
app.enableShutdownHooks();
const logger = new Logger('NestApplication');

View File

@@ -1,114 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import { PageTreeBridgeSubscriber } from './page-tree-bridge.subscriber';
import { WsTreeService } from '../ws-tree.service';
import { COLLAB_TREE_UPDATE_CHANNEL } from '../../collaboration/constants';
import { TreeUpdateSnapshot } from '../../database/listeners/page.listener';
const treeUpdate: TreeUpdateSnapshot = {
id: 'page-1',
slugId: 'slug-1',
spaceId: 'space-1',
parentPageId: null,
title: 'Renamed',
icon: '🚀',
};
describe('PageTreeBridgeSubscriber.onMessage', () => {
let subscriber: PageTreeBridgeSubscriber;
let wsTree: { broadcastPageUpdated: jest.Mock };
beforeEach(async () => {
wsTree = {
broadcastPageUpdated: jest.fn().mockResolvedValue(undefined),
};
// onMessage is driven directly; no real redis connection is needed.
const redisService = {
getOrThrow: () => ({ duplicate: () => ({}) }),
} as unknown as RedisService;
const module: TestingModule = await Test.createTestingModule({
providers: [
PageTreeBridgeSubscriber,
{ provide: RedisService, useValue: redisService },
{ provide: WsTreeService, useValue: wsTree },
],
}).compile();
subscriber = module.get<PageTreeBridgeSubscriber>(PageTreeBridgeSubscriber);
});
it('valid JSON on the channel: broadcasts the parsed snapshot', async () => {
await subscriber.onMessage(
COLLAB_TREE_UPDATE_CHANNEL,
JSON.stringify(treeUpdate),
);
expect(wsTree.broadcastPageUpdated).toHaveBeenCalledTimes(1);
expect(wsTree.broadcastPageUpdated).toHaveBeenCalledWith(treeUpdate);
});
it('malformed JSON: does NOT broadcast and does not throw', async () => {
const warnSpy = jest
.spyOn(subscriber['logger'], 'warn')
.mockImplementation(() => undefined);
await expect(
subscriber.onMessage(COLLAB_TREE_UPDATE_CHANNEL, '{not json'),
).resolves.toBeUndefined();
expect(wsTree.broadcastPageUpdated).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
it('message on a different channel: ignored', async () => {
await subscriber.onMessage('some:other:channel', JSON.stringify(treeUpdate));
expect(wsTree.broadcastPageUpdated).not.toHaveBeenCalled();
});
it('broadcast rejects: onMessage does not throw / produce unhandled rejection', async () => {
wsTree.broadcastPageUpdated.mockRejectedValueOnce(new Error('db down'));
const warnSpy = jest
.spyOn(subscriber['logger'], 'warn')
.mockImplementation(() => undefined);
await expect(
subscriber.onMessage(
COLLAB_TREE_UPDATE_CHANNEL,
JSON.stringify(treeUpdate),
),
).resolves.toBeUndefined();
expect(wsTree.broadcastPageUpdated).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
it('onModuleInit when subscribe() rejects: resolves without throwing', async () => {
const sub = {
on: jest.fn(),
subscribe: jest.fn().mockRejectedValue(new Error('redis down')),
};
const redisService = {
getOrThrow: () => ({ duplicate: () => sub }),
} as unknown as RedisService;
const local = new PageTreeBridgeSubscriber(
redisService,
wsTree as unknown as WsTreeService,
);
const errorSpy = jest
.spyOn(local['logger'], 'error')
.mockImplementation(() => undefined);
await expect(local.onModuleInit()).resolves.toBeUndefined();
expect(sub.subscribe).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledTimes(1);
errorSpy.mockRestore();
});
});

View File

@@ -1,115 +0,0 @@
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
import { COLLAB_TREE_UPDATE_CHANNEL } from '../../collaboration/constants';
import { TreeUpdateSnapshot } from '../../database/listeners/page.listener';
import { WsTreeService } from '../ws-tree.service';
/**
* API-process half of the cross-process tree-update bridge.
*
* It subscribes to the Redis pub/sub channel that the collab process's
* `PageTreeBridgePublisher` publishes to and re-broadcasts each collab-originated
* `treeUpdate` snapshot through `WsTreeService`. This is what makes a
* collaborative rename reach other users' sidebars in 2-process (COLLAB_URL set)
* deployments. The API process is the single broadcast authority:
* `broadcastPageUpdated` routes through the restriction-aware `emitTreeEvent`, so
* this path stays authorization-safe.
*
* In single-process mode this subscriber still subscribes, but nobody publishes
* (the publisher lives only in `CollabAppModule`), so it stays idle and harmless.
*
* NOTE: this assumes a SINGLE API broadcaster. With multiple horizontally-scaled
* API replicas, every replica would receive the pub/sub message and re-broadcast,
* duplicating the client update (the Socket.IO Redis adapter already fans a single
* emit out to all replicas' clients). Scaling the API horizontally would require a
* consumer-group / leader-election scheme instead of fan-out pub/sub. That is out
* of scope for the current single-API deployment.
*/
@Injectable()
export class PageTreeBridgeSubscriber
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(PageTreeBridgeSubscriber.name);
private sub?: Redis;
constructor(
private readonly redisService: RedisService,
private readonly wsTree: WsTreeService,
) {}
async onModuleInit(): Promise<void> {
// A connection in subscribe mode cannot run other commands, so use a
// dedicated duplicated client (mirrors RedisSyncExtension's `sub`).
this.sub = this.redisService.getOrThrow().duplicate();
// ioredis connections emit 'error' on disconnect/reconnect; an EventEmitter
// 'error' with no listener THROWS and can crash the process. The bridge is
// optional, so just log and stay alive (mirrors RedisSyncExtension).
this.sub.on('error', (err) =>
this.logger.warn(`tree-update subscriber redis error: ${err?.message}`),
);
this.sub.on('message', (channel, message) =>
this.onMessage(channel, message),
);
// The bridge is optional for core API operation: if Redis is down at boot,
// subscribe() rejects — log and continue rather than crash API bootstrap.
try {
await this.sub.subscribe(COLLAB_TREE_UPDATE_CHANNEL);
} catch (err) {
this.logger.error(
`Failed to subscribe to ${COLLAB_TREE_UPDATE_CHANNEL}; cross-process tree updates disabled: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
async onMessage(channel: string, message: string): Promise<void> {
if (channel !== COLLAB_TREE_UPDATE_CHANNEL) return;
let snapshot: TreeUpdateSnapshot;
try {
snapshot = JSON.parse(message) as TreeUpdateSnapshot;
} catch (err) {
// Malformed payload must never throw out of the message handler.
this.logger.warn(
`Dropping malformed tree update on ${COLLAB_TREE_UPDATE_CHANNEL}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return;
}
// broadcastPageUpdated -> emitTreeEvent does a DB permission read that can
// reject. ioredis does not await this handler, so a rejection would become
// an unhandled promise rejection — swallow it (warn, never rethrow).
try {
await this.wsTree.broadcastPageUpdated(snapshot);
} catch (err) {
this.logger.warn(
`Failed to broadcast tree update for page ${snapshot.id}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
async onModuleDestroy(): Promise<void> {
if (!this.sub) return;
try {
await this.sub.unsubscribe(COLLAB_TREE_UPDATE_CHANNEL);
await this.sub.quit();
} catch (err) {
this.logger.warn(
`Failed to tear down tree-update subscriber: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
}

View File

@@ -3,19 +3,12 @@ import { WsGateway } from './ws.gateway';
import { WsService } from './ws.service';
import { WsTreeService } from './ws-tree.service';
import { PageWsListener } from './listeners/page-ws.listener';
import { PageTreeBridgeSubscriber } from './listeners/page-tree-bridge.subscriber';
import { TokenModule } from '../core/auth/token.module';
@Global()
@Module({
imports: [TokenModule],
providers: [
WsGateway,
WsService,
WsTreeService,
PageWsListener,
PageTreeBridgeSubscriber,
],
providers: [WsGateway, WsService, WsTreeService, PageWsListener],
exports: [WsGateway, WsService, WsTreeService],
})
export class WsModule {}

View File

@@ -1,34 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('AppController (e2e)', () => {
let app: NestFastifyApplication;
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
// Docmost runs on Fastify (see src/main.ts). The default
// createNestApplication() would load @nestjs/platform-express, which is not
// a dependency of this project, so an explicit FastifyAdapter is required.
app = moduleFixture.createNestApplication<NestFastifyApplication>(
new FastifyAdapter(),
);
app = moduleFixture.createNestApplication();
await app.init();
// Fastify must finish booting before its HTTP server can serve requests.
await app.getHttpAdapter().getInstance().ready();
});
afterEach(async () => {
// Guard with optional chaining: if beforeEach throws before `app` is
// assigned, closing undefined would mask the original failure.
await app?.close();
});
it('/ (GET)', () => {

View File

@@ -1,18 +1,14 @@
{
"moduleFileExtensions": ["js", "json", "ts", "tsx"],
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)sx?$": ["ts-jest", { "tsconfig": { "allowJs": true } }]
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@sindresorhus[+/][a-z0-9-]+|escape-string-regexp|p-limit|yocto-queue)(@|/))"
],
"moduleNameMapper": {
"^@docmost/db/(.*)$": "<rootDir>/../src/database/$1",
"^@docmost/transactional/(.*)$": "<rootDir>/../src/integrations/transactional/$1",
"^@docmost/ee/(.*)$": "<rootDir>/../src/ee/$1",
"^src/(.*)$": "<rootDir>/../src/$1"
"^@docmost/ee/(.*)$": "<rootDir>/../src/ee/$1"
}
}

View File

@@ -1,29 +0,0 @@
import type { CapacitorConfig } from "@capacitor/cli";
// Capacitor configuration for the Gitmost mobile shell.
//
// AGPL / App Store note (see docs/mobile-app-plan.md section 9): the AGPL web
// client must NOT be bundled into the iOS .ipa. On iOS, point the shell at a
// hosted client via CAP_SERVER_URL (server.url) so the AGPL bytes are served
// from our own server rather than redistributed under Apple's DRM/usage-rules.
// Android may bundle the local web build (webDir) directly.
const serverUrl = process.env.CAP_SERVER_URL?.trim();
const config: CapacitorConfig = {
appId: "xyz.vvzvlad.gitmost",
appName: "Gitmost",
// Web build output of apps/client (Android bundled mode / local assets).
// Build it with `pnpm run client:build` before `cap sync`.
webDir: "apps/client/dist",
...(serverUrl
? {
// iOS / hosted mode: load the client from our server (AGPL-clean).
server: {
url: serverUrl,
cleartext: false,
},
}
: {}),
};
export default config;

View File

@@ -1,367 +0,0 @@
# Мобильное приложение gitmost — исследование и план
> Статус: исследовательский + проектный документ.
> Контекст: gitmost — форк Docmost, чистое веб-приложение. Отдельного
> мобильного (нативного/устанавливаемого) приложения **нет**.
> Цель: определить путь к мобильным приложениям — **iOS обязательно, Android
> как пойдёт** — с заделом на оффлайн в будущем (оффлайн сейчас не требуется).
Документ фиксирует, что уже есть в коде, почему путь к мобилке предопределён
устройством продукта, сравнивает варианты и описывает рекомендуемый план с
привязкой к файлам.
---
## 1. TL;DR
1. **Нативного приложения нет.** В проекте отсутствуют Capacitor, React Native,
Cordova и т.п. Мобильного клиента ещё не начинали.
2. **Адаптивная веб-версия — есть, и довольно проработанная.** Веб-клиент
открывается с телефона как mobile-friendly сайт: сворачиваемый сайдбар-drawer,
отдельные мобильные компоненты (история, поиск, хлебные крошки), responsive-
примитивы Mantine, mobile-tuned `viewport`. Это готовый фундамент UI.
3. **Ядро продукта — веб-редактор — нативно не воспроизвести.** TipTap 3
(ProseMirror) + совместное редактирование на Yjs/Hocuspocus плотно сшиты с
React. Production-порта Yjs под Swift/Kotlin нет. Любой реалистичный путь
оставляет редактор в **WebView**.
4. **API уже готов к нативному клиенту.** Сервер принимает JWT не только из
cookie, но и из заголовка `Authorization: Bearer`. Есть точка входа для
вебсокета совместного редактирования (`POST /auth/collab-token`).
5. **Рекомендуемый путь — Capacitor:** обернуть существующий React-SPA в
нативную оболочку (iOS + Android из одного кода), добавить нативные плагины
(push, биометрия, share, файлы). Эволюция в гибрид (нативная навигация +
WebView-редактор) делается потом инкрементально, без переписывания.
6. **Оффлайн-будущее уже заложено** (Yjs + `y-indexeddb`). Детальный план —
в [offline-sync-plan.md](offline-sync-plan.md); мобильное приложение этот
план переиспользует, а не дублирует.
7. **Главный блокер — не технический, а лицензионный.** AGPL форка несовместима
с условиями App Store, если зашивать веб-клиент в бинарник: DRM/usage-rules
Apple = «дополнительные ограничения», запрещённые AGPLv3 §10. Развязки —
грузить клиент с сервера (не из `.ipa`), PWA или sideload. Детали и матрица —
в §9; закрывать **до** кода обёртки.
---
## 2. Текущее состояние (как есть)
### 2.1. Стек
| Слой | Технологии |
|---|---|
| Бэкенд | NestJS 11 + Fastify, Kysely/Postgres, Redis/BullMQ. API в стиле RPC-POST (соглашение Docmost). Аутентификация — JWT. |
| Фронт | React 18 + Vite + Mantine + TanStack Query + i18next. Обычный SPA. |
| Ядро (редактор) | TipTap 3 (ProseMirror) + совместное редактирование на Yjs через Hocuspocus — см. [page-editor.tsx](../apps/client/src/features/editor/page-editor.tsx). |
| Оффлайн-фундамент | `yjs` + `y-indexeddb` уже в зависимостях клиента (локальная CRDT-копия тела документа). |
### 2.2. Мобильного приложения нет
В `package.json` и `apps/*/package.json` нет `capacitor`, `react-native`,
`cordova`, `expo`. Нативной оболочки в репозитории не заведено.
### 2.3. Адаптивная веб-версия — есть
| Что | Где |
|---|---|
| Адаптивная оболочка Mantine `AppShell` с `breakpoint: "sm"`, раздельные состояния `collapsed.mobile` / `collapsed.desktop` | [global-app-shell.tsx](../apps/client/src/components/layouts/global/global-app-shell.tsx) (L85–99) |
| Отдельный мобильный сайдбар-drawer (`mobileSidebarAtom` отделён от `desktopSidebarAtom`), авто-закрытие при навигации по дереву | [sidebar-atom.ts](../apps/client/src/components/layouts/global/hooks/atoms/sidebar-atom.ts), [space-tree-row.tsx](../apps/client/src/features/page/tree/components/space-tree-row.tsx) (L147–148) |
| Мобильная модалка истории + свой CSS | [history-modal.tsx](../apps/client/src/features/page-history/components/history-modal.tsx) (L17–19), `history-modal-mobile.tsx` |
| Мобильный контрол поиска | [search-control.tsx](../apps/client/src/features/search/components/search-control.tsx) (L38–42) |
| Мобильный рендер хлебных крошек через `useMediaQuery` | [breadcrumb.tsx](../apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx) (L41) |
| Responsive-примитивы `hiddenFrom`/`visibleFrom` (~16 мест), медиа-запросы в CSS-модулях | по всему `apps/client/src` |
| Mobile-tuned viewport (`width=device-width, user-scalable=no`) | [index.html](../apps/client/index.html) (L8) |
> Важно: адаптив проверялся в мобильном **браузере**, а не в WebView нативной
> оболочки. Перед сборкой приложения нужно прогнать UI как PWA/в WebView и
> отловить отличия (жесты, экранная клавиатура/IME в редакторе, safe-area).
### 2.4. Готовность API к нативному клиенту
- **Bearer-токен уже поддержан.** JWT извлекается из cookie **или** из заголовка
`Authorization`: см. [jwt.strategy.ts](../apps/server/src/core/auth/strategies/jwt.strategy.ts) (L27–29).
Серверная сторона нативной авторизации менять не нужно. (Подтверждено
мобильным бутстрапом.)
- **Токен можно вернуть в теле логина (opt-in).** [`login`](../apps/server/src/core/auth/auth.controller.ts)
по-прежнему кладёт JWT в `httpOnly`-cookie, а при флаге `returnToken` дополнительно
возвращает его в теле ответа (`data.authToken`) для нативных клиентов; веб-клиент
остаётся на cookie. Реализовано мобильным бутстрапом.
- **Точка входа вебсокета коллаборации:** [`POST /auth/collab-token`](../apps/server/src/core/auth/auth.controller.ts) (L187–193).
(Подтверждено мобильным бутстрапом.)
- **CORS — явный allowlist.** Вместо безусловного `app.enableCors()` теперь
настраиваемый whitelist через `CORS_ALLOWED_ORIGINS` плюс автоматически
разрешённые нативные WebView-origin'ы (Capacitor/Ionic/localhost). Реализовано
мобильным бутстрапом.
- **OpenAPI/Swagger — опционально.** Swagger UI доступен на `/api/docs` за флагом
`SWAGGER_ENABLED` (по умолчанию выключен), что даёт авто-генерацию типизированного
клиента. Реализовано мобильным бутстрапом.
---
## 3. Почему путь к мобилке предопределён
Три факта диктуют решение независимо от моды:
1. **Редактор практически невозможно переписать нативно.** ProseMirror + весь
набор TipTap-расширений + Yjs-CRDT — это не «поле ввода». Нативного
production-порта Yjs под Swift/Kotlin нет (есть Rust `yrs` с биндингами, но
это отдельный тяжёлый проект). Переписывание ядра нативно = годы и вечное
расхождение с веб-версией. **Вывод: редактор остаётся в WebView.**
2. **API уже умеет нативного клиента** (Bearer, collab-token).
3. **Оффлайн-фундамент уже заложен** на веб-уровне (Yjs + `y-indexeddb`),
и он работает внутри WebView.
---
## 4. Три возможных пути
| Путь | Суть | Плюсы | Минусы | Вердикт |
|---|---|---|---|---|
| **A. Полностью нативно** (Swift/Kotlin) | Переписать всё, включая редактор и CRDT-синк | Максимально нативный UX | Воспроизвести ProseMirror + расширения + Yjs; несоразмерные трудозатраты; вечное отставание от веба | ❌ Не наш случай |
| **B. WebView-обёртка SPA (Capacitor)** | Обернуть существующий React-клиент в нативную оболочку, native-возможности — плагинами | Реюз ~100% кода (редактор, коллаборация, оффлайн); один кодовый бэйз → iOS+Android; быстро | Менее «нативно»; риск отказа App Store за «просто сайт» (4.2) — лечится нативной ценностью | ✅ Рекомендуется |
| **C. Гибрид: нативная оболочка + WebView-редактор** | Навигация/списки/поиск/логин — нативно (React Native/Swift), экран редактирования — web в WebView | Лучший UX; путь Notion/Linear | Заметно больше работы; нужен мост JS↔native | ⚖️ Цель эволюции из B |
---
## 5. Рекомендуемый путь
**B (Capacitor) как первый релиз, с заложенной эволюцией в C.**
Почему:
- Capacitor создан под сценарий «есть веб-приложение → хочу его в App Store с
нативными возможностями». Переиспользуется весь React-клиент и, главное,
редактор — то, что нативно не сделать.
- Один кодовый бэйз закрывает «iOS обязательно» и «Android как пойдёт»
одновременно, без второй команды.
- Адаптивная вёрстка уже есть (см. §2.3) — переверстывать под телефон с нуля
не нужно; работа смещается в нативную обвязку.
- Оффлайн-будущее подготовлено (Yjs + `y-indexeddb`); см.
[offline-sync-plan.md](offline-sync-plan.md).
- Когда упрётесь в UX отдельных экранов — их по одному выносят в нативную
оболочку, оставив редактор в WebView. То есть B → C делается инкрементально.
Почему **не** чистый React Native сразу: редактор всё равно придётся держать в
WebView (ядро web-only), но при этом теряется прямой реюз остального React-кода
и появляется мост как обязательная сложность с первого дня — для iOS-first
старта это лишний оверхед.
> Альтернатива: если критичен максимально нативный UX с первого релиза и есть
> ресурс — сразу путь C на React Native (Expo) с WebView только под редактор.
> Это сознательный размен «больше работы сейчас» за «более нативное ощущение».
⚠️ **Лицензионная оговорка к iOS.** Обычный Capacitor зашивает веб-билд
`apps/client` в `.ipa` — для публикации в App Store это **нарушает AGPL**
(см. §9). Выбор Capacitor для **Android** остаётся в силе, но на **iOS**
веб-клиент нельзя бандлить в бинарник: либо грузить его с сервера
(`server.url`), либо PWA. То есть рекомендация «B (Capacitor)» применима к
Android как есть, а к iOS — только в конфигурации без зашитого AGPL.
---
## 6. Что доработать на бэкенде
Немного, но конкретно:
1. **Выдача токена в теле ответа для нативного хранения.** Сейчас логин кладёт
JWT только в `httpOnly`-cookie и не возвращает его в body. На мобиле
`httpOnly`-cookie между разными origin (`capacitor://localhost` ↔ API) — боль
с SameSite/CORS. Чище: мобильный логин-флоу, возвращающий JWT в ответе, чтобы
хранить его в Keychain/Keystore и слать как `Authorization: Bearer`. Сервер
уже принимает Bearer — менять надо только **выдачу**.
Файлы: [auth.controller.ts](../apps/server/src/core/auth/auth.controller.ts).
2. **CORS.** Сейчас [`app.enableCors()`](../apps/server/src/main.ts) (L144) без
конфигурации. Под мобильные origin'ы и для безопасности задать явный whitelist.
3. **Push-уведомления.** Модуль `notification` уже есть — добавить регистрацию
device-token и интеграцию **APNs** (iOS) / **FCM** (Android).
4. **Опционально — OpenAPI/Swagger.** Сейчас спецификации нет; добавить
`@nestjs/swagger` дёшево и сильно ускорит мобильную разработку
(типизированный клиент).
---
## 7. Android-специфика
На пути Capacitor Android едет почти бесплатно (`npx cap add android` из того же
веб-билда), но есть нюансы:
- **Движок в плюс.** Android System WebView (Chromium) обновляется через Play
Store независимо от ОС и обычно свежее iOS WKWebView. Более рискованный движок
по совместимости — это iOS, а не Android.
- **Фрагментация.** Дешёвые/старые устройства с малой памятью и устаревшим
WebView; стек тяжёлый (ProseMirror + Yjs + mermaid + katex + excalidraw) —
тестировать на бюджетных аппаратах.
- **Обвязка под Android:** аппаратная/жестовая кнопка «Назад» (навигация внутри
приложения, а не выход), **FCM** для push, Android App Links (вместо iOS
Universal Links), подписание и Play Console.
- **Главный риск именно для Android — ввод текста в ProseMirror на Gboard/IME.**
Историческая боль `contenteditable` на Android (прыжки курсора, дубли символов
при композиции). Стало лучше, но **проверять в первую очередь и рано**.
- **Магазин.** Google Play лояльнее к webview-обёрткам, чем App Store; риск
«отклонят как просто сайт» для Play практически неактуален.
---
## 8. iOS-специфика
- **WKWebView** на движке WebKit жёстко привязан к версии ОС — это более
рискованный по совместимости движок (тестировать прежде всего его).
- **App Store guideline 4.2 (minimum functionality).** Чистая webview-обёртка
рискует отклонением «это просто сайт». Лечится реальной нативной ценностью:
push, share-extension, биометрический разблок, оффлайн-кэш — всё это Capacitor
даёт плагинами.
- **safe-area** под «чёлку»/системные панели, поведение экранной клавиатуры в
редакторе.
---
## 9. Лицензионный блокер: AGPL ↔ App Store (iOS)
> Это не инженерная, а **лицензионная** задача — закрывать её надо **до** кода
> обёртки, иначе можно сделать приложение, которое некуда легально опубликовать.
> Ниже — инженерно-лицензионный разбор, **не** юридическая консультация; финально
> подтверждать у того, кто разбирается в лицензиях.
### 9.1. Суть конфликта
gitmost — форк Docmost под **AGPL-3.0** (константа форка: «100% open, AGPL-only»).
Две вещи несовместимы:
- **AGPLv3 §10** (последний абзац) запрещает накладывать на получателя кода
**любые дополнительные ограничения** сверх самой лицензии.
- **Стандартный EULA App Store** ровно их и накладывает: **FairPlay/DRM**,
привязка установки к Apple ID с лимитом устройств (**usage rules**), запрет
свободного перераспространения бинарника.
Приняв условия Apple, чтобы попасть в App Store, вы нарушаете AGPL кода, который
раздаёте.
### 9.2. Почему это бьёт именно по форку
Запрет «дополнительных ограничений» связывает **лицензиатов, но не самого
правообладателя**: владелец 100% копирайта может опубликовать свой код в App Store.
Но в gitmost бóльшая часть копирайта принадлежит **upstream-Docmost** и
контрибьюторам — вы выступаете дистрибьютором *чужого* AGPL-кода и не можете
единолично добавить App-Store-исключение.
Прецеденты: **VLC** (удалён из App Store в 2011 по жалобе на конфликт GPL с
условиями стора; вернулся только после перелицензирования и согласия
правообладателей), **GNU Go** — снят по той же причине. Это не теоретический риск.
### 9.3. Ключевой принцип развязки: лицензия смотрит на `.ipa`, а не на устройство
Определяющее — **что раздаёт сам Apple** (`.ipa` под FairPlay) и **кто раздаёт
AGPL-байты**, а не то, окажутся ли они в итоге на устройстве:
- AGPL **внутри `.ipa`** → получен под ограничениями Apple → **нарушение**.
- AGPL **скачан с вашего сервера** → получен от вас под AGPL (исходники открыты,
§13 выполнен) → ограничения Apple на него **не** накладываются, даже если бандл
кэшируется в песочнице приложения.
Следствие: **офлайн на iOS легально достижим** — если кэшированный бандл пришёл с
вашего сервера, а не из `.ipa`. Ограничение тут не лицензионное, а в **ревью
Apple** (см. §9.5).
### 9.4. Варианты «грузить веб-клиент с сервера»
**A. WebView навигируется на хостед-клиент (`server.url`).** Capacitor умеет
`server: { url: 'https://app.example.com' }` — оболочка грузит WebView с удалённого
URL, мост и нативные плагины по-прежнему инжектятся. В `.ipa` — ноль AGPL.
- Плюс: лицензионно самый чистый; **origin = ваш домен**, поэтому cookie/CORS
работают как в браузере (боль `capacitor://localhost` ↔ API из §6 исчезает —
токен в body/Keychain может и не понадобиться).
- Минус: холодный старт требует сети; сервер лёг → приложение кирпич; офлайна по
умолчанию нет.
**B. OTA: пустой шелл скачивает и кэширует бандл.** Шелл при первом запуске тянет
JS-бандл с вашего сервера и кэширует как веб-ассеты (механизм Cordova/CodePush).
Open-source self-host-вариант — `@capgo/capacitor-updater` (важно для AGPL-проекта:
без привязки к проприетарному Appflow).
- Плюс: **даёт офлайн** — кэш AGPL легален, т.к. распространён вами, а не Apple.
- Минус: упирается в политику Apple по hot-update (§9.5).
**Не-обходы (мифы):** «никто не засудит» — это нарушение, а не обход; «LGPL-нуть
обёртку» — не помогает (проблема в AGPL-веб-клиенте, а не в обёртке); «mere
aggregation» — не катит: зашитый бандл это комбинированное распространяемое
произведение, а не простая агрегация.
### 9.5. Гейты Apple
| # | Guideline | Суть | Влияние |
|---|---|---|---|
| 1 | **2.5.2** (исполняемый код) | Скачивать/исполнять **нативный** код нельзя, **но** есть исключение для скриптов, исполняемых встроенным WebKit/JavascriptCore, если они не меняют назначение приложения | Загрузка веб-клиента в `WKWebView` под исключение попадает: вариант A — чистый, B — терпимый, но с границами |
| 2 | **4.2** (minimum functionality) | Чистый WebView-«просто сайт» рискует отклонением | Лечится нативной ценностью в оболочке (push/APNs, биометрия, share, файлы — ваш нативный код, не AGPL) |
| 3 | конфликт двух гейтов | «Лицензионно чистый» вариант (пустой шелл качает всё) — самый рискованный для ревью; «безопасный для ревью» (зашить веб-билд в `.ipa`) — лицензионное нарушение | **Совместить (офлайн) + (чистая AGPL) + (низкий риск ревью) в одной конфигурации нельзя — выбираете любые два** |
Безопасность: раз исполняете удалённый код — только HTTPS, желательно cert-pinning
(подмена сервера = произвольный JS в WebView пользователя).
### 9.6. Итоговая матрица распространения iOS
| Конфигурация | AGPL-чистота | Офлайн | Риск ревью Apple |
|---|---|---|---|
| A. `server.url` на хостед-клиент | ✅ чистая | ❌ нет | средний (4.2, лечится плагинами) |
| B. OTA пустой шелл + кэш бандла | ✅ чистая | ✅ есть | выше (2.5.2 + 4.2) |
| Зашить веб-билд в `.ipa` (обычный Capacitor) | ❌ нарушение | ✅ | низкий |
| **PWA** | ✅ чистая | ✅ | App Store не нужен |
| Sideload / EU DMA-маркетплейсы (iOS 17.4+) | ✅ чистая | ✅ | вне App Store; **только ЕС** |
**Вывод:** для iOS **PWA** — самое дешёвое решение, закрывающее всё сразу. Если
присутствие именно в App Store критично — **вариант A** (`server.url` + нативные
плагины под 4.2) легальный и реалистичный ценой «онлайн для холодного старта».
Офлайн в App Store (вариант B) технически и лицензионно возможен, но это
максимальный риск на ревью — закладывать только если офлайн на iOS обязателен.
Совместить «App Store + зашитый офлайн AGPL» легально нельзя, пока копирайт не ваш.
---
## 10. Оффлайн в будущем
Оффлайн сейчас не требуется, но позиция хорошая:
- Тело документа уже редактируется через Yjs (CRDT) + `y-indexeddb` — локальная
копия и автослияние правок работают, в том числе в WebView.
- «Полностью онлайн» — это всё вокруг тела (навигация, заголовки, комментарии,
CRUD, вложения, авторизация). Их оффлайн-синхронизация описана отдельным
планом с этапами M0…M4 — см. [offline-sync-plan.md](offline-sync-plan.md).
- Мобильное приложение **переиспользует** этот план, а не строит оффлайн заново.
Нюанс Android: System WebView под нехваткой места может чистить хранилище →
для оффлайна, возможно, понадобится дублировать критичные данные в нативное
хранилище, чтобы локальные копии не вычищались.
---
## 11. Открытые вопросы (зафиксировать до старта)
- **Q1.** Путь: Capacitor (B) с эволюцией в гибрид, или сразу React Native (C)?
Рекомендация — B.
- **Q2.** Мобильная авторизация: отдельный логин-флоу с токеном в body + Keychain/
Keystore + Bearer (рекомендуется) или попытка работать через cookie в WebView?
- **Q3.** Push: APNs + FCM сразу или iOS-first?
- **Q4.** Подключать ли OpenAPI/Swagger для генерации мобильного клиента?
- **Q5.** Когда включать оффлайн (M0…M4 из offline-sync-plan.md) относительно
первого мобильного релиза?
- **Q6.** iOS-дистрибуция при AGPL (§9): App Store через `server.url`
(онлайн-клиент, без зашитого AGPL), PWA или sideload/EU-маркетплейсы? Этот
лицензионный путь нужно подтвердить **до** кода обёртки. Рекомендация — PWA для
iOS, Capacitor для Android.
---
## 12. Чеклист первого шага (бутстрап Capacitor, iOS-first)
- [ ] **Закрыть лицензионный путь iOS (§9) ДО кода обёртки:** выбрать
`server.url` / PWA / sideload и подтвердить у разбирающегося в лицензиях.
- [ ] **Не бандлить AGPL-веб-клиент в iOS `.ipa`** (DRM/usage-rules App Store ⟂
AGPLv3 §10) — на iOS грузить клиент с сервера или идти через PWA.
- [ ] Прогнать существующий адаптивный UI как PWA/в WebView, отловить отличия
(жесты, IME в редакторе, safe-area).
- [x] Добавить Capacitor в монорепо, нацелить на веб-билд `apps/client`
(Android — зашитый билд; iOS — `server.url`/PWA без зашитого AGPL, см. §9).
- [ ] `npx cap add ios` (Android — `npx cap add android`, когда будет готова обвязка) — нативные проекты генерируются локально и намеренно не хранятся в VCS (см. §9).
- [x] Бэкенд: мобильный логин-флоу с токеном в body; хранить токен в Keychain/
Keystore; слать `Authorization: Bearer`.
- [x] Бэкенд: явный CORS-whitelist под мобильные origin'ы.
- [ ] Native-плагины под App Store 4.2: push, биометрия, share, файлы.
- [ ] Push: APNs (iOS); FCM добавить вместе с Android.
- [ ] Проверить вебсокет коллаборации из WebView (`/auth/collab-token` + Hocuspocus).
- [x] (Опционально) Подключить `@nestjs/swagger`.

View File

@@ -1,61 +0,0 @@
# Mobile app bootstrap
Purpose: this document records what has been bootstrapped in the repo to enable a
mobile app for Gitmost, per the first-step checklist in
[docs/mobile-app-plan.md](./mobile-app-plan.md) section 12.
## What is in the repo now
- **PWA**: web app manifest plus a service worker generated by `vite-plugin-pwa`
using Workbox (`strategies: "generateSW"` — not hand-rolled). The SW is built
for production only (`devOptions: { enabled: false }`) and uses
`registerType: "prompt"`, so the user is asked to apply an update rather than it
auto-updating; registration goes through `virtual:pwa-register/react`
(`useRegisterSW`) in `apps/client/src/pwa/pwa-update-prompt.tsx`, mounted from
`main.tsx` and skipped inside the Capacitor native WebView. The SW precaches the
app shell (`globPatterns` js/css/html/...) and serves `navigateFallback:
"index.html"` for SPA routes, with `navigateFallbackDenylist` excluding the
server-owned routes `/api`, `/collab`, `/socket.io`, `/share/`, `/mcp`, and
`/robots.txt`. `runtimeCaching` keeps `/collab`, `/socket.io`, and all `/api`
as `NetworkOnly` — offline reads are served by the persisted TanStack Query
cache (IndexedDB) and `y-indexeddb` for the page Yjs doc, not by an SW HTTP
cache. This lets the existing responsive web UI be installed and run as a
Progressive Web App. See [docs/offline-sync-plan.md](./offline-sync-plan.md) for
the full offline/sync design.
- **Backend mobile auth**: opt-in token return from the login flow. The login
request accepts a `returnToken` flag (must be sent as a JSON boolean) that makes
the server include the auth token in the response body, and the server already
accepts a `Bearer` token in the `Authorization` header. Note the global response
interceptor wraps every payload, so the native client reads the token at
`response.data.authToken` (not at the top level). A native client can store this
token (Keychain / Keystore) and send it as `Authorization: Bearer` on each request.
- **Explicit CORS allowlist**: the server reads a `CORS_ALLOWED_ORIGINS` env
variable for the allowed origins, and always allows the native WebView origins
(`capacitor://localhost`, `ionic://localhost`, `https://localhost`) so the
mobile shell can call the API.
- **Optional OpenAPI / Swagger**: an opt-in OpenAPI/Swagger surface gated behind
the `SWAGGER_ENABLED` env flag, useful for developing the native client.
- **Capacitor config**: [capacitor.config.ts](../capacitor.config.ts) at the
repo root. It targets the `apps/client` web build output (`apps/client/dist`)
for the Android bundled mode, and on iOS loads the client from a hosted server
via `CAP_SERVER_URL` (`server.url`) so the AGPL web client is not bundled into
the `.ipa` (see mobile-app-plan section 9).
## Remaining MANUAL / local steps (require Xcode / external accounts, out of scope here)
- Run `pnpm install` to fetch the Capacitor packages and `@nestjs/swagger`.
- Run `pnpm run client:build` to produce the web build in `apps/client/dist`.
- Run `npx cap add ios` and/or `npx cap add android` to generate the native
platform projects (these live outside version control; see `.gitignore`).
- Set `CAP_SERVER_URL` for the iOS build so the shell loads the hosted client
(AGPL-clean), then run `pnpm run mobile:build` / `cap sync`.
- Set up push notifications: APNs for iOS and FCM for Android.
- Obtain an Apple Developer account and the App Store / Play Console listings.
- Confirm the AGPL iOS distribution decision (mobile-app-plan section 9) before
shipping anything to the App Store.
## See also
For the full background, rationale, and the licensing analysis, see
[docs/mobile-app-plan.md](./mobile-app-plan.md) (section 12 for the bootstrap
checklist, section 9 for the AGPL / App Store licensing path).

View File

@@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.94.1",
"version": "0.94.0",
"private": true,
"scripts": {
"build": "nx run-many -t build",
@@ -16,18 +16,10 @@
"server:start": "nx run server:start:prod",
"email:dev": "nx run server:email:dev",
"dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\"",
"clean": "rm -rf apps/*/dist packages/*/dist apps/client/node_modules/.vite",
"cap:copy": "cap copy",
"cap:sync": "cap sync",
"cap:ios": "cap open ios",
"cap:android": "cap open android",
"mobile:build": "pnpm run client:build && cap sync"
"clean": "rm -rf apps/*/dist packages/*/dist apps/client/node_modules/.vite"
},
"dependencies": {
"@braintree/sanitize-url": "^7.1.2",
"@capacitor/android": "^7.0.0",
"@capacitor/core": "^7.0.0",
"@capacitor/ios": "^7.0.0",
"@casl/ability": "6.8.0",
"@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "^1.7.3",
@@ -86,7 +78,6 @@
"yjs": "^13.6.30"
},
"devDependencies": {
"@capacitor/cli": "^7.0.0",
"@nx/js": "22.6.1",
"@types/bytes": "^3.1.5",
"@types/qrcode": "^1.5.6",

View File

@@ -7,7 +7,6 @@ import { writeFileSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { deflateSync } from "node:zlib";
import { createServer } from "node:http";
const API = process.env.DOCMOST_API_URL;
if (!API || !process.env.DOCMOST_EMAIL || !process.env.DOCMOST_PASSWORD) {
@@ -105,7 +104,7 @@ async function main() {
{ find: "БУКВОЕД", replace: "КНИГОЛЮБ" },
{ find: "[1]", replace: "[42]" },
]);
check("edit_page_text: both edits applied", editRes.applied.every((e) => e.replacements === 1));
check("edit_page_text: both edits applied", editRes.edits.every((e) => e.replacements === 1));
await new Promise((r) => setTimeout(r, 16000)); // wait for server persistence
const pj2 = await client.getPageJson(pageId);
const text2 = JSON.stringify(pj2.content);
@@ -150,24 +149,11 @@ async function main() {
check("update_page_json: paragraph appended", JSON.stringify(pj4.content).includes("добавленный через update_page_json"));
check("update_page_json: custom node id preserved", lastNode.attrs?.id === "testidjsonpush", lastNode.attrs?.id);
// 6b. images: upload / insert / replace (clean src, fresh attachment on replace).
// insert_image / replace_image take an http(s) URL that the SERVER fetches;
// local file paths are intentionally unsupported. The Docmost server runs on
// the same host as this test, so serve the PNG bytes over a throwaway
// localhost HTTP server it can reach.
const bytesA = makePng(255, 0, 0); // red
const bytesB = makePng(0, 0, 255); // blue (a DIFFERENT valid PNG)
const imgServer = createServer((req, res) => {
res.writeHead(200, { "Content-Type": "image/png" });
res.end(req.url === "/b.png" ? bytesB : bytesA);
});
await new Promise((resolve, reject) => {
imgServer.once("error", reject);
imgServer.listen(0, "127.0.0.1", resolve);
});
const imgPort = imgServer.address().port;
const urlA = `http://127.0.0.1:${imgPort}/a.png`;
const urlB = `http://127.0.0.1:${imgPort}/b.png`;
// 6b. images: upload / insert / replace (clean src, fresh attachment on replace)
const pngA = join(tmpdir(), `mcp-e2e-img-a-${Date.now()}.png`);
const pngB = join(tmpdir(), `mcp-e2e-img-b-${Date.now()}.png`);
writeFileSync(pngA, makePng(255, 0, 0)); // red
writeFileSync(pngB, makePng(0, 0, 255)); // blue (a DIFFERENT valid PNG)
try {
// Independent login to fetch file bytes with the same cookie the editor uses.
const login = await axios.post(
@@ -187,7 +173,7 @@ async function main() {
});
// insert_image: append the first PNG, src must be clean (no ?v=) and fetchable.
const ins = await client.insertImage(pageId, urlA);
const ins = await client.insertImage(pageId, pngA);
check("insert_image: src has no ?v= cache-buster", !ins.src.includes("?v="), ins.src);
const fileA = await fetchFile(ins.src);
check("insert_image: file fetch returns 200", fileA.status === 200, `status=${fileA.status}`);
@@ -213,7 +199,7 @@ async function main() {
// replace_image: must create a NEW attachment with a clean, fetchable URL.
// The 200 fetch is the assertion that catches the in-place-overwrite HTTP 500 regression.
const rep = await client.replaceImage(pageId, oldAttachmentId, urlB);
const rep = await client.replaceImage(pageId, oldAttachmentId, pngB);
check("replace_image: new attachment id differs from old", rep.newAttachmentId !== oldAttachmentId, `${oldAttachmentId} -> ${rep.newAttachmentId}`);
check("replace_image: src has no ?v= cache-buster", !rep.src.includes("?v="), rep.src);
const fileB = await fetchFile(rep.src);
@@ -229,7 +215,8 @@ async function main() {
check("replace_image: page has new attachment id", !!findImage(pjImg2.content.content, rep.newAttachmentId), rep.newAttachmentId);
check("replace_image: old attachment id repointed away", !findImage(pjImg2.content.content, oldAttachmentId), oldAttachmentId);
} finally {
imgServer.close();
try { unlinkSync(pngA); } catch {}
try { unlinkSync(pngB); } catch {}
}
// 6c. rich formatting: callout type, task list, inline marks, table alignment,
@@ -454,10 +441,7 @@ async function main() {
// 9. comments: create / list / reply / update / check_new / delete
const beforeComments = new Date(Date.now() - 1000).toISOString();
// A top-level comment requires an inline "selection": exact contiguous text
// that exists in the persisted page to anchor on. "Добавленный абзац." is a
// plain paragraph re-imported in section 5 and still present here.
const c1 = await client.createComment(pageId, "Первый **комментарий** с [ссылкой](https://example.com).", "inline", "Добавленный абзац.");
const c1 = await client.createComment(pageId, "Первый **комментарий** с [ссылкой](https://example.com).");
check("create_comment: created", !!c1.data.id, c1.data.id);
check("create_comment: markdown round-trip", c1.data.content.includes("**комментарий**"), c1.data.content);
const reply = await client.createComment(pageId, "Ответ на комментарий.", "page", undefined, c1.data.id);

1185
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff