fix(offline,server,docs): apply PR #116 review findings to offline-sync
Carries the still-applicable findings from the PR #116 review into PR #120, since #120 includes the mobile-bootstrap commit. CORS hardening (removing the unconditional localhost/capacitor origins) is intentionally left out of scope. Service worker routing (latent bug fix + testability): - vite.config.ts: anchor Workbox path matching to a segment boundary (^/<seg>(/|$)) instead of startsWith, so siblings like /apidocs, /collaborators, /socket.iox are no longer mis-routed as API/realtime and forced NetworkOnly; align navigateFallbackDenylist with the same anchors. - new apps/client/src/pwa/sw-strategy.ts holds the canonical predicates (isApiPath, isCollabOrSocketPath) + unit tests; the vite.config regexes mirror it inline (Workbox generateSW serializes urlPattern fns standalone, so they cannot import the module). Server CORS (R1 extraction + coverage): - extract buildCorsAllowlist / isOriginAllowed into cors.util.ts with unit tests (evil-origin rejected, WebView/no-Origin allowed); main.ts rewired to use them with byte-for-byte identical behavior. Privacy — clear offline cache on logout: - new clear-offline-cache.ts purges the persisted query cache (idb-keyval gitmost-rq-cache), the Yjs page.* IndexedDB databases, and the service-worker api-get-cache; wired into handleLogout (best-effort, before the redirect) so a previous user's private data does not linger locally. Conventions & docs: - prettier fixes on main.ts and login.dto.ts. - CHANGELOG: document offline reading, returnToken opt-in, optional Swagger, new env vars, logout cache-clear, and the CORS open->allowlist breaking change. - docs/mobile-app-plan.md: correct the now-false §2.4 claims and update the §12 checklist (native cap add ios left unchecked — generated locally, gitignored). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
committed by
claude code agent 227
parent
8bfe572ebe
commit
c317f5502c
18
CHANGELOG.md
18
CHANGELOG.md
@@ -227,6 +227,18 @@ 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
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
|
||||
107
apps/client/src/features/offline/clear-offline-cache.test.ts
Normal file
107
apps/client/src/features/offline/clear-offline-cache.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
90
apps/client/src/features/offline/clear-offline-cache.ts
Normal file
90
apps/client/src/features/offline/clear-offline-cache.ts
Normal file
@@ -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.<id>` 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.<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 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
|
||||
}
|
||||
}
|
||||
@@ -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<string>(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,
|
||||
});
|
||||
|
||||
|
||||
32
apps/client/src/pwa/sw-strategy.test.ts
Normal file
32
apps/client/src/pwa/sw-strategy.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
32
apps/client/src/pwa/sw-strategy.ts
Normal file
32
apps/client/src/pwa/sw-strategy.ts
Normal file
@@ -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 (`^/<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);
|
||||
}
|
||||
@@ -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 (`^/<seg>(/|$)`) 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 },
|
||||
|
||||
@@ -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()
|
||||
|
||||
87
apps/server/src/integrations/environment/cors.util.spec.ts
Normal file
87
apps/server/src/integrations/environment/cors.util.spec.ts
Normal file
@@ -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<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 + 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
39
apps/server/src/integrations/environment/cors.util.ts
Normal file
39
apps/server/src/integrations/environment/cors.util.ts
Normal file
@@ -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<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);
|
||||
}
|
||||
@@ -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<string>([
|
||||
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'],
|
||||
|
||||
367
docs/mobile-app-plan.md
Normal file
367
docs/mobile-app-plan.md
Normal file
@@ -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`.
|
||||
Reference in New Issue
Block a user