feat(mobile): bootstrap mobile app (PWA + Capacitor + backend auth/CORS)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,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;
|
||||
}
|
||||
}
|
||||
@@ -102,6 +108,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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -320,4 +320,19 @@ export class EnvironmentService {
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
getCorsAllowedOrigins(): string[] {
|
||||
const raw = this.configService.get<string>('CORS_ALLOWED_ORIGINS', '');
|
||||
return raw
|
||||
.split(',')
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
isSwaggerEnabled(): boolean {
|
||||
const enabled = this.configService
|
||||
.get<string>('SWAGGER_ENABLED', 'false')
|
||||
.toLowerCase();
|
||||
return enabled === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NestFastifyApplication>(
|
||||
@@ -142,8 +143,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<string>([
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user