From 25304fe7d9a22a111f225693c2afcccf17af4f9d Mon Sep 17 00:00:00 2001 From: claude_code Date: Sun, 21 Jun 2026 14:08:29 +0300 Subject: [PATCH] feat(mobile): bootstrap mobile app (PWA + Capacitor + backend auth/CORS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the §12 bootstrap from docs/mobile-app-plan.md. Backend (§6): - auth: optional returnToken flag on login returns the JWT in the body (data.authToken) for native Keychain/Keystore + Bearer; web cookie flow unchanged. - main.ts: explicit CORS allowlist (APP_URL + CORS_ALLOWED_ORIGINS env + Capacitor WebView origins), credentials enabled, replaces open enableCors(). - optional OpenAPI/Swagger at /api/docs behind SWAGGER_ENABLED. - env: CORS_ALLOWED_ORIGINS, SWAGGER_ENABLED, CAP_SERVER_URL. PWA: - manifest metadata, hand-rolled service worker (network-first nav, SWR assets, never intercepts /api,/socket.io,/collab), prod-only registration, apple-touch-icon. Capacitor: - capacitor.config.ts (webDir apps/client/dist; iOS via CAP_SERVER_URL to avoid bundling the AGPL client in the .ipa, see plan §9), cap:* scripts, deps, .gitignore for native dirs. - docs/mobile-bootstrap.md documenting what is done and the remaining manual steps (cap add ios/android, APNs/FCM, stores). Co-Authored-By: Claude Opus 4.8 --- .env.example | 13 +++ .gitignore | 5 ++ apps/client/index.html | 1 + apps/client/public/manifest.json | 29 ++----- apps/client/public/sw.js | 82 +++++++++++++++++++ apps/client/src/main.tsx | 10 +++ apps/server/package.json | 1 + apps/server/src/core/auth/auth.controller.ts | 12 +++ apps/server/src/core/auth/dto/login.dto.ts | 11 ++- .../environment/environment.service.ts | 15 ++++ apps/server/src/main.ts | 44 +++++++++- capacitor.config.ts | 29 +++++++ docs/mobile-bootstrap.md | 48 +++++++++++ package.json | 11 ++- 14 files changed, 288 insertions(+), 23 deletions(-) create mode 100644 apps/client/public/sw.js create mode 100644 capacitor.config.ts create mode 100644 docs/mobile-bootstrap.md diff --git a/.env.example b/.env.example index 73e57348..2c4ebf5d 100644 --- a/.env.example +++ b/.env.example @@ -92,6 +92,19 @@ IFRAME_EMBED_ALLOWED=false # Example: https://intranet.example.com,https://portal.example.com IFRAME_ALLOWED_ORIGINS= +# Comma-separated list of additional origins allowed to call the API via CORS. +# The APP_URL origin and native mobile (Capacitor) origins are always allowed. +# Leave empty for a same-origin (web-only) deployment. +CORS_ALLOWED_ORIGINS= + +# Expose OpenAPI/Swagger docs at /api/docs (development/debugging aid only). +SWAGGER_ENABLED=false + +# Capacitor (mobile shell): hosted client URL loaded by the iOS shell so the +# AGPL web client is NOT bundled into the .ipa (see docs/mobile-app-plan.md §9). +# Leave empty for Android bundled mode / local development. +CAP_SERVER_URL= + # Enable debug logging in production (default: false) DEBUG_MODE=false diff --git a/.gitignore b/.gitignore index cf440100..268b3857 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,8 @@ lerna-debug.log* # Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time) apps/client/public/vad/ + +# Capacitor native platform projects (generated locally via 'npx cap add ios|android') +/ios +/android +.capacitor diff --git a/apps/client/index.html b/apps/client/index.html index dcfd942a..fd81d3e3 100644 --- a/apps/client/index.html +++ b/apps/client/index.html @@ -10,6 +10,7 @@ + diff --git a/apps/client/public/manifest.json b/apps/client/public/manifest.json index c6da2b21..ee46ddef 100644 --- a/apps/client/public/manifest.json +++ b/apps/client/public/manifest.json @@ -1,30 +1,19 @@ { + "id": "/", "name": "Gitmost", "short_name": "Gitmost", + "description": "Gitmost - open-source collaborative documentation and knowledge base.", + "lang": "en", "start_url": "/", + "scope": "/", "display": "standalone", + "orientation": "any", "background_color": "#0E1117", "theme_color": "#0E1117", "icons": [ - { - "src": "icons/favicon-16x16.png", - "type": "image/png", - "sizes": "16x16" - }, - { - "src": "icons/favicon-32x32.png", - "type": "image/png", - "sizes": "32x32" - }, - { - "src": "icons/app-icon-192x192.png", - "type": "image/png", - "sizes": "180x180 192x192" - }, - { - "src": "icons/app-icon-512x512.png", - "type": "image/png", - "sizes": "512x512" - } + { "src": "icons/favicon-16x16.png", "type": "image/png", "sizes": "16x16" }, + { "src": "icons/favicon-32x32.png", "type": "image/png", "sizes": "32x32" }, + { "src": "icons/app-icon-192x192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" }, + { "src": "icons/app-icon-512x512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" } ] } diff --git a/apps/client/public/sw.js b/apps/client/public/sw.js new file mode 100644 index 00000000..f94a4ccd --- /dev/null +++ b/apps/client/public/sw.js @@ -0,0 +1,82 @@ +// Gitmost PWA service worker. +// Conservative strategy: +// - Never intercept API, websocket or collaboration traffic (always network). +// - Navigations: network-first, fall back to the cached app shell offline. +// - Other same-origin GET assets: stale-while-revalidate. +// Bump CACHE_VERSION to invalidate stale assets on deploy. +const CACHE_VERSION = "gitmost-v1"; +const APP_SHELL_URL = "/"; + +// Path prefixes that must always hit the network (auth/state/realtime). +const NETWORK_ONLY_PREFIXES = ["/api", "/socket.io", "/collab"]; + +self.addEventListener("install", (event) => { + // Activate this worker immediately without waiting for old tabs to close. + self.skipWaiting(); + event.waitUntil( + caches + .open(CACHE_VERSION) + .then((cache) => cache.add(APP_SHELL_URL)) + .catch(() => {}), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + const keys = await caches.keys(); + await Promise.all( + keys + .filter((key) => key !== CACHE_VERSION) + .map((key) => caches.delete(key)), + ); + await self.clients.claim(); + })(), + ); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + + // Only handle same-origin GET requests; everything else goes to the network. + if (request.method !== "GET") return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + if (NETWORK_ONLY_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) + return; + + // Navigations: network-first with an offline fallback to the cached shell. + if (request.mode === "navigate") { + event.respondWith( + (async () => { + try { + return await fetch(request); + } catch { + const cache = await caches.open(CACHE_VERSION); + const cached = await cache.match(APP_SHELL_URL); + return cached || Response.error(); + } + })(), + ); + return; + } + + // Static assets: stale-while-revalidate. + event.respondWith( + (async () => { + const cache = await caches.open(CACHE_VERSION); + const cached = await cache.match(request); + const network = fetch(request) + .then((response) => { + // Only cache successful, same-origin (basic) responses. + if (response && response.status === 200 && response.type === "basic") { + cache.put(request, response.clone()); + } + return response; + }) + .catch(() => undefined); + return cached || (await network) || Response.error(); + })(), + ); +}); diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx index 0f55ee60..889fae70 100644 --- a/apps/client/src/main.tsx +++ b/apps/client/src/main.tsx @@ -84,3 +84,13 @@ root.render( , ); + +// Register the service worker for PWA installability and an offline app shell. +// Production only: in dev the Vite server and HMR must not be intercepted. +if (import.meta.env.PROD && "serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("/sw.js").catch((err) => { + console.error("Service worker registration failed:", err); + }); + }); +} diff --git a/apps/server/package.json b/apps/server/package.json index ff693b75..6a906395 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -64,6 +64,7 @@ "@nestjs/platform-fastify": "^11.1.19", "@nestjs/platform-socket.io": "^11.1.19", "@nestjs/schedule": "^6.1.3", + "@nestjs/swagger": "^11.2.0", "@nestjs/terminus": "^11.1.1", "@nestjs/throttler": "^6.5.0", "@nestjs/websockets": "^11.1.19", diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts index 84cdea96..afa4ac07 100644 --- a/apps/server/src/core/auth/auth.controller.ts +++ b/apps/server/src/core/auth/auth.controller.ts @@ -97,6 +97,12 @@ export class AuthController { } else if (mfaResult.authToken) { // User doesn't have MFA and workspace doesn't require it this.setAuthCookie(res, mfaResult.authToken); + // Opt-in body token for native clients (Bearer auth). The response is + // wrapped by TransformHttpResponseInterceptor, so clients read it at + // `data.authToken`. Web clients omit returnToken and keep the cookie. + if (loginInput.returnToken) { + return { authToken: mfaResult.authToken }; + } return; } } @@ -104,6 +110,12 @@ export class AuthController { const authToken = await this.authService.login(loginInput, workspace.id); this.setAuthCookie(res, authToken); + // Opt-in body token for native clients (Bearer auth). The response is wrapped + // by TransformHttpResponseInterceptor, so clients read it at `data.authToken`. + // Web clients omit returnToken and keep using the httpOnly cookie only. + if (loginInput.returnToken) { + return { authToken }; + } } @UseGuards(SetupGuard) diff --git a/apps/server/src/core/auth/dto/login.dto.ts b/apps/server/src/core/auth/dto/login.dto.ts index 5b23230f..6855fc7e 100644 --- a/apps/server/src/core/auth/dto/login.dto.ts +++ b/apps/server/src/core/auth/dto/login.dto.ts @@ -1,4 +1,4 @@ -import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; +import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class LoginDto { @IsNotEmpty() @@ -8,4 +8,13 @@ export class LoginDto { @IsNotEmpty() @IsString() password: string; + + // When true, the access token is returned in the response body (in addition + // to the httpOnly cookie) so native/mobile clients can store it in + // Keychain/Keystore and send it as 'Authorization: Bearer'. Web clients omit + // this flag and keep using the cookie. Opt-in only: the token is never put in + // the body otherwise. + @IsOptional() + @IsBoolean() + returnToken?: boolean; } diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index 6bbc6dba..bcac8a91 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -320,4 +320,19 @@ export class EnvironmentService { .map((o) => o.trim()) .filter(Boolean); } + + getCorsAllowedOrigins(): string[] { + const raw = this.configService.get('CORS_ALLOWED_ORIGINS', ''); + return raw + .split(',') + .map((o) => o.trim()) + .filter(Boolean); + } + + isSwaggerEnabled(): boolean { + const enabled = this.configService + .get('SWAGGER_ENABLED', 'false') + .toLowerCase(); + return enabled === 'true'; + } } diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 1fb140c1..74cf1f8b 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -15,6 +15,7 @@ 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 { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; async function bootstrap() { const app = await NestFactory.create( @@ -149,8 +150,49 @@ async function bootstrap() { }), ); - app.enableCors(); + // Configure CORS explicitly (replaces the previous unconfigured enableCors()). + // The web client is same-origin in production; an explicit allowlist lets + // native/mobile WebView origins (Capacitor) and any configured cross-origin + // clients call the API, while everything else is rejected. + const corsAllowedOrigins = new Set([ + environmentService.getAppUrl(), + ...environmentService.getCorsAllowedOrigins(), + // Capacitor / Ionic WebView origins used by the native shell. + 'capacitor://localhost', + 'ionic://localhost', + 'http://localhost', + 'https://localhost', + ]); + + 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); + }, + credentials: true, + methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + }); + app.useGlobalInterceptors(new TransformHttpResponseInterceptor(reflector)); + + if (environmentService.isSwaggerEnabled()) { + // Optional OpenAPI docs to speed up typed mobile-client generation. + const swaggerConfig = new DocumentBuilder() + .setTitle('Gitmost API') + .setDescription('Gitmost REST API (RPC-style POST endpoints).') + .setVersion(process.env.APP_VERSION || '0.0.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('api/docs', app, document); + } + app.enableShutdownHooks(); const logger = new Logger('NestApplication'); diff --git a/capacitor.config.ts b/capacitor.config.ts new file mode 100644 index 00000000..ae517d7c --- /dev/null +++ b/capacitor.config.ts @@ -0,0 +1,29 @@ +import type { CapacitorConfig } from "@capacitor/cli"; + +// Capacitor configuration for the Gitmost mobile shell. +// +// AGPL / App Store note (see docs/mobile-app-plan.md section 9): the AGPL web +// client must NOT be bundled into the iOS .ipa. On iOS, point the shell at a +// hosted client via CAP_SERVER_URL (server.url) so the AGPL bytes are served +// from our own server rather than redistributed under Apple's DRM/usage-rules. +// Android may bundle the local web build (webDir) directly. +const serverUrl = process.env.CAP_SERVER_URL?.trim(); + +const config: CapacitorConfig = { + appId: "xyz.vvzvlad.gitmost", + appName: "Gitmost", + // Web build output of apps/client (Android bundled mode / local assets). + // Build it with `pnpm run client:build` before `cap sync`. + webDir: "apps/client/dist", + ...(serverUrl + ? { + // iOS / hosted mode: load the client from our server (AGPL-clean). + server: { + url: serverUrl, + cleartext: false, + }, + } + : {}), +}; + +export default config; diff --git a/docs/mobile-bootstrap.md b/docs/mobile-bootstrap.md new file mode 100644 index 00000000..55a7cfa6 --- /dev/null +++ b/docs/mobile-bootstrap.md @@ -0,0 +1,48 @@ +# Mobile app bootstrap + +Purpose: this document records what has been bootstrapped in the repo to enable a +mobile app for Gitmost, per the first-step checklist in +[docs/mobile-app-plan.md](./mobile-app-plan.md) section 12. + +## What is in the repo now + +- **PWA**: web app manifest, a hand-rolled service worker, and production-only + service worker registration in the client. This lets the existing responsive + web UI be installed and run as a Progressive Web App. +- **Backend mobile auth**: opt-in token return from the login flow. The login + request accepts a `returnToken` flag (must be sent as a JSON boolean) that makes + the server include the auth token in the response body, and the server already + accepts a `Bearer` token in the `Authorization` header. Note the global response + interceptor wraps every payload, so the native client reads the token at + `response.data.authToken` (not at the top level). A native client can store this + token (Keychain / Keystore) and send it as `Authorization: Bearer` on each request. +- **Explicit CORS allowlist**: the server reads a `CORS_ALLOWED_ORIGINS` env + variable for the allowed origins, and always allows the native WebView origins + (`capacitor://localhost`, `ionic://localhost`, `http://localhost`, + `https://localhost`) so the mobile shell can call the API. +- **Optional OpenAPI / Swagger**: an opt-in OpenAPI/Swagger surface gated behind + the `SWAGGER_ENABLED` env flag, useful for developing the native client. +- **Capacitor config**: [capacitor.config.ts](../capacitor.config.ts) at the + repo root. It targets the `apps/client` web build output (`apps/client/dist`) + for the Android bundled mode, and on iOS loads the client from a hosted server + via `CAP_SERVER_URL` (`server.url`) so the AGPL web client is not bundled into + the `.ipa` (see mobile-app-plan section 9). + +## Remaining MANUAL / local steps (require Xcode / external accounts, out of scope here) + +- Run `pnpm install` to fetch the Capacitor packages and `@nestjs/swagger`. +- Run `pnpm run client:build` to produce the web build in `apps/client/dist`. +- Run `npx cap add ios` and/or `npx cap add android` to generate the native + platform projects (these live outside version control; see `.gitignore`). +- Set `CAP_SERVER_URL` for the iOS build so the shell loads the hosted client + (AGPL-clean), then run `pnpm run mobile:build` / `cap sync`. +- Set up push notifications: APNs for iOS and FCM for Android. +- Obtain an Apple Developer account and the App Store / Play Console listings. +- Confirm the AGPL iOS distribution decision (mobile-app-plan section 9) before + shipping anything to the App Store. + +## See also + +For the full background, rationale, and the licensing analysis, see +[docs/mobile-app-plan.md](./mobile-app-plan.md) (section 12 for the bootstrap +checklist, section 9 for the AGPL / App Store licensing path). diff --git a/package.json b/package.json index fec021cc..8565fa9c 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,18 @@ "server:start": "nx run server:start:prod", "email:dev": "nx run server:email:dev", "dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\"", - "clean": "rm -rf apps/*/dist packages/*/dist apps/client/node_modules/.vite" + "clean": "rm -rf apps/*/dist packages/*/dist apps/client/node_modules/.vite", + "cap:copy": "cap copy", + "cap:sync": "cap sync", + "cap:ios": "cap open ios", + "cap:android": "cap open android", + "mobile:build": "pnpm run client:build && cap sync" }, "dependencies": { "@braintree/sanitize-url": "^7.1.2", + "@capacitor/android": "^7.0.0", + "@capacitor/core": "^7.0.0", + "@capacitor/ios": "^7.0.0", "@casl/ability": "6.8.0", "@docmost/editor-ext": "workspace:*", "@floating-ui/dom": "^1.7.3", @@ -78,6 +86,7 @@ "yjs": "^13.6.30" }, "devDependencies": { + "@capacitor/cli": "^7.0.0", "@nx/js": "22.6.1", "@types/bytes": "^3.1.5", "@types/qrcode": "^1.5.6",