diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f154a7..8d755862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -227,6 +227,18 @@ embeds — plus a large batch of security hardening and test coverage. injected into the `` 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 @@ -242,6 +254,12 @@ 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 diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts index 94ab3595..5b1a4453 100644 --- a/apps/client/src/features/auth/hooks/use-auth.ts +++ b/apps/client/src/features/auth/hooks/use-auth.ts @@ -23,6 +23,7 @@ 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(); @@ -123,6 +124,13 @@ 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`); }; diff --git a/apps/client/src/features/offline/clear-offline-cache.test.ts b/apps/client/src/features/offline/clear-offline-cache.test.ts new file mode 100644 index 00000000..f1f1ac5a --- /dev/null +++ b/apps/client/src/features/offline/clear-offline-cache.test.ts @@ -0,0 +1,107 @@ +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); + }); +}); diff --git a/apps/client/src/features/offline/clear-offline-cache.ts b/apps/client/src/features/offline/clear-offline-cache.ts new file mode 100644 index 00000000..990632c8 --- /dev/null +++ b/apps/client/src/features/offline/clear-offline-cache.ts @@ -0,0 +1,90 @@ +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.` created by + * y-indexeddb in make-offline.ts), and + * 3. the service worker `api-get-cache` Cache Storage entry (private GET /api + * responses cached by the Workbox runtime). + * + * 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.` 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 { + // 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.`). + // `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((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 the service worker API cache (private GET /api responses). The + // Workbox runtime cache name contains "api-get-cache" (Workbox may prefix it), + // 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 + } +} diff --git a/apps/client/src/features/offline/query-persister.ts b/apps/client/src/features/offline/query-persister.ts index f8e84a3c..043a0322 100644 --- a/apps/client/src/features/offline/query-persister.ts +++ b/apps/client/src/features/offline/query-persister.ts @@ -11,6 +11,11 @@ type DehydratableQuery = { 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(key).then((v) => v ?? null), @@ -20,7 +25,7 @@ const idbStorage = { export const queryPersister = createAsyncStoragePersister({ storage: idbStorage, - key: "gitmost-rq-cache", + key: OFFLINE_CACHE_KEY, throttleTime: 1000, }); diff --git a/apps/client/src/pwa/sw-strategy.test.ts b/apps/client/src/pwa/sw-strategy.test.ts new file mode 100644 index 00000000..b3057f0d --- /dev/null +++ b/apps/client/src/pwa/sw-strategy.test.ts @@ -0,0 +1,32 @@ +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); + }); +}); diff --git a/apps/client/src/pwa/sw-strategy.ts b/apps/client/src/pwa/sw-strategy.ts new file mode 100644 index 00000000..1a262464 --- /dev/null +++ b/apps/client/src/pwa/sw-strategy.ts @@ -0,0 +1,32 @@ +/** + * 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 (`^/(/|$)`) 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); +} diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index 4e28dae2..9cb92264 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -64,17 +64,24 @@ export default defineConfig(({ mode }) => { workbox: { globPatterns: ["**/*.{js,css,html,svg,png,ico,woff2,json}"], navigateFallback: "index.html", - navigateFallbackDenylist: [/^\/api\//, /^\/collab\//, /^\/socket\.io\//], + // Segment-anchored (`^/(/|$)`) so navigation requests to these + // segments are consistently excluded from the SPA fallback, mirroring + // the runtimeCaching urlPattern regexes below. + navigateFallbackDenylist: [/^\/api(\/|$)/, /^\/collab(\/|$)/, /^\/socket\.io(\/|$)/], 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 }) => url.pathname.startsWith("/collab"), handler: "NetworkOnly" }, - { urlPattern: ({ url }) => url.pathname.startsWith("/socket.io"), handler: "NetworkOnly" }, + { urlPattern: ({ url }) => /^\/(collab|socket\.io)(\/|$)/.test(url.pathname), handler: "NetworkOnly" }, // M2 read-path: GET navigation API responses fall back to cache when offline. // Only GET is cached; mutations always hit the network (Workbox caching handlers // only match GET by default, but scope explicitly for clarity/safety). { - urlPattern: ({ url, request }) => url.pathname.startsWith("/api") && request.method === "GET", + urlPattern: ({ url, request }) => /^\/api(\/|$)/.test(url.pathname) && request.method === "GET", handler: "NetworkFirst", options: { cacheName: "api-get-cache", @@ -83,7 +90,7 @@ export default defineConfig(({ mode }) => { }, }, // Any non-GET /api stays network-only (never served stale). - { urlPattern: ({ url }) => url.pathname.startsWith("/api"), handler: "NetworkOnly" }, + { urlPattern: ({ url }) => /^\/api(\/|$)/.test(url.pathname), handler: "NetworkOnly" }, ], }, devOptions: { enabled: false }, diff --git a/apps/server/src/core/auth/dto/login.dto.ts b/apps/server/src/core/auth/dto/login.dto.ts index 6855fc7e..5351b42c 100644 --- a/apps/server/src/core/auth/dto/login.dto.ts +++ b/apps/server/src/core/auth/dto/login.dto.ts @@ -1,4 +1,10 @@ -import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { + IsBoolean, + IsEmail, + IsNotEmpty, + IsOptional, + IsString, +} from 'class-validator'; export class LoginDto { @IsNotEmpty() diff --git a/apps/server/src/integrations/environment/cors.util.spec.ts b/apps/server/src/integrations/environment/cors.util.spec.ts new file mode 100644 index 00000000..65d59e92 --- /dev/null +++ b/apps/server/src/integrations/environment/cors.util.spec.ts @@ -0,0 +1,87 @@ +import { buildCorsAllowlist, isOriginAllowed } from './cors.util'; + +const WEBVIEW_ORIGINS = [ + 'capacitor://localhost', + 'ionic://localhost', + 'http://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 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 = new Set(); + 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 + 4 WebView origins, the duplicate configured origin collapses. + expect(allowlist.size).toBe(1 + WEBVIEW_ORIGINS.length); + }); + + it('always includes the four WebView origins 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); + } + }); +}); diff --git a/apps/server/src/integrations/environment/cors.util.ts b/apps/server/src/integrations/environment/cors.util.ts new file mode 100644 index 00000000..c3ce00ef --- /dev/null +++ b/apps/server/src/integrations/environment/cors.util.ts @@ -0,0 +1,39 @@ +// 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. CORS hardening of these is +// intentionally out of scope. +const NATIVE_WEBVIEW_ORIGINS = [ + 'capacitor://localhost', + 'ionic://localhost', + 'http://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 { + return new Set([ + 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, +): boolean { + if (!origin) return true; + return allowlist.has(origin); +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 74cf1f8b..48f90f07 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -15,6 +15,10 @@ 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() { @@ -154,25 +158,19 @@ async function bootstrap() { // 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 = new Set([ - environmentService.getAppUrl(), - ...environmentService.getCorsAllowedOrigins(), - // Capacitor / Ionic WebView origins used by the native shell. - 'capacitor://localhost', - 'ionic://localhost', - 'http://localhost', - 'https://localhost', - ]); + 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) => { - if (!origin || corsAllowedOrigins.has(origin)) { - callback(null, true); - return; - } - callback(null, false); + 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'], diff --git a/docs/mobile-app-plan.md b/docs/mobile-app-plan.md new file mode 100644 index 00000000..8bdc6170 --- /dev/null +++ b/docs/mobile-app-plan.md @@ -0,0 +1,367 @@ +# Мобильное приложение 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`.