diff --git a/.env.example b/.env.example
index 7407e629..e899f127 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 24081b38..a912c078 100644
--- a/apps/server/src/integrations/environment/environment.service.ts
+++ b/apps/server/src/integrations/environment/environment.service.ts
@@ -332,4 +332,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",