diff --git a/.env.example b/.env.example index bf495fd..7488d03 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,8 @@ DATABASE_URL="postgresql://kooltap:kooltap@localhost:5432/kooltap" JWT_SECRET="dev-jwt-secret-change-in-production" PORT=3001 CORS_ORIGIN="http://localhost:3000" +# Oldest terminal app version that may sync (defaults to the one in apps/api/src/devices/app-version.ts) +# MIN_APP_VERSION="1.4.0" # Dashboard NEXT_PUBLIC_API_URL="http://localhost:3001/api/v1" \ No newline at end of file diff --git a/apps/api/src/common/filters/http-exception.filter.ts b/apps/api/src/common/filters/http-exception.filter.ts index c85d5e1..7fc0d21 100644 --- a/apps/api/src/common/filters/http-exception.filter.ts +++ b/apps/api/src/common/filters/http-exception.filter.ts @@ -54,6 +54,7 @@ export class HttpExceptionFilter implements ExceptionFilter { case 403: return 'forbidden'; case 404: return 'not_found'; case 409: return 'conflict'; + case 426: return 'upgrade_required'; case 429: return 'too_many_requests'; default: return 'internal_error'; } diff --git a/apps/api/src/devices/app-version.guard.ts b/apps/api/src/devices/app-version.guard.ts new file mode 100644 index 0000000..5a591bc --- /dev/null +++ b/apps/api/src/devices/app-version.guard.ts @@ -0,0 +1,22 @@ +import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common'; +import { APP_VERSION_HEADER, isAppVersionSupported, minAppVersion } from './app-version'; + +/** + * Refuses terminals running an app older than the minimum version, so they ask their user to update instead of + * syncing with a backoffice they no longer understand. Runs before device auth: an outdated app gets this answer + * even when its token is fine. + */ +@Injectable() +export class AppVersionGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const minVersion = minAppVersion(); + if (!isAppVersionSupported(request.headers[APP_VERSION_HEADER], minVersion)) { + throw new HttpException( + { code: 'app_update_required', message: `Update the app to version ${minVersion} or newer` }, + 426, // Upgrade Required; HttpStatus has no name for it + ); + } + return true; + } +} diff --git a/apps/api/src/devices/app-version.spec.ts b/apps/api/src/devices/app-version.spec.ts new file mode 100644 index 0000000..88c1bbf --- /dev/null +++ b/apps/api/src/devices/app-version.spec.ts @@ -0,0 +1,33 @@ +import { isAppVersionSupported, parseAppVersion } from './app-version'; + +describe('parseAppVersion', () => { + it('reads the version and ignores the build number', () => { + expect(parseAppVersion('1.4.2+37')).toEqual([1, 4, 2]); + expect(parseAppVersion('2.0')).toEqual([2, 0]); + }); + + it('rejects anything that is not a version', () => { + expect(parseAppVersion(undefined)).toBeNull(); + expect(parseAppVersion('')).toBeNull(); + expect(parseAppVersion('latest')).toBeNull(); + expect(parseAppVersion(['1.0.0'])).toBeNull(); + }); +}); + +describe('isAppVersionSupported', () => { + it('refuses apps that send no version', () => { + expect(isAppVersionSupported(undefined, '1.0.0')).toBe(false); + }); + + it('compares each part as a number', () => { + expect(isAppVersionSupported('1.10.0', '1.9.0')).toBe(true); + expect(isAppVersionSupported('1.2.0', '1.10.0')).toBe(false); + expect(isAppVersionSupported('1.2.0+99', '1.2.1')).toBe(false); + }); + + it('accepts the minimum itself and newer', () => { + expect(isAppVersionSupported('1.2.0', '1.2.0')).toBe(true); + expect(isAppVersionSupported('1.2', '1.2.0')).toBe(true); + expect(isAppVersionSupported('2.0.0', '1.9.9')).toBe(true); + }); +}); diff --git a/apps/api/src/devices/app-version.ts b/apps/api/src/devices/app-version.ts new file mode 100644 index 0000000..177579d --- /dev/null +++ b/apps/api/src/devices/app-version.ts @@ -0,0 +1,31 @@ +/** + * The oldest terminal app that may use the backoffice. Raise it with every app release that older versions must not + * keep syncing past; MIN_APP_VERSION overrides it without a deploy. + */ +export const MIN_APP_VERSION = '1.4.0'; + +export const APP_VERSION_HEADER = 'x-app-version'; + +export function minAppVersion(): string { + return process.env.MIN_APP_VERSION?.trim() || MIN_APP_VERSION; +} + +/** `1.4.2+37` → [1, 4, 2]; the build number after `+` is ignored. Null for anything that isn't a version */ +export function parseAppVersion(value: unknown): number[] | null { + if (typeof value !== 'string') return null; + const match = /^\s*v?(\d+(?:\.\d+)*)(?:[-+].*)?\s*$/.exec(value); + return match ? match[1].split('.').map(Number) : null; +} + +/** Whether [version] is at least [minimum]. Apps from before the version header send none and are outdated */ +export function isAppVersionSupported(version: unknown, minimum = minAppVersion()): boolean { + const current = parseAppVersion(version); + const required = parseAppVersion(minimum); + if (!required) return true; + if (!current) return false; + for (let i = 0; i < Math.max(current.length, required.length); i++) { + const diff = (current[i] ?? 0) - (required[i] ?? 0); + if (diff !== 0) return diff > 0; + } + return true; +} diff --git a/apps/api/src/devices/devices.controller.ts b/apps/api/src/devices/devices.controller.ts index e8f0ad6..e4beb8d 100644 --- a/apps/api/src/devices/devices.controller.ts +++ b/apps/api/src/devices/devices.controller.ts @@ -21,6 +21,7 @@ import { DevicesService } from './devices.service'; import { AuthService } from '../auth/auth.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { DeviceAuthGuard } from '../auth/guards/device-auth.guard'; +import { AppVersionGuard } from './app-version.guard'; import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; import { RegisterDeviceDto } from './dto/register-device.dto'; @@ -38,6 +39,7 @@ export class DevicesController { @Post('register') @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(AppVersionGuard) @ApiOperation({ summary: 'Register a new device' }) @ApiResponse({ status: 201, description: 'Device registered successfully' }) async register(@Body() dto: RegisterDeviceDto) { @@ -48,7 +50,7 @@ export class DevicesController { // traffic from the venue's shared IP @Post(':deviceId/sync') @SkipThrottle() - @UseGuards(DeviceAuthGuard) + @UseGuards(AppVersionGuard, DeviceAuthGuard) @ApiOperation({ summary: 'Sync device state with server' }) @ApiResponse({ status: 200, description: 'Sync successful, returns pending commands' }) async sync(@Param('deviceId') deviceId: string, @Body() dto: SyncDto, @Req() req: any) { @@ -59,7 +61,7 @@ export class DevicesController { // so an offline edit and its picture follow the same rules as every other change @Post(':deviceId/images') @SkipThrottle() - @UseGuards(DeviceAuthGuard) + @UseGuards(AppVersionGuard, DeviceAuthGuard) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_BYTES } })) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Upload a product picture from a terminal' }) @@ -69,7 +71,7 @@ export class DevicesController { @Post(':deviceId/commands/:commandId/ack') @SkipThrottle() - @UseGuards(DeviceAuthGuard) + @UseGuards(AppVersionGuard, DeviceAuthGuard) @ApiOperation({ summary: 'Acknowledge a command result' }) @ApiResponse({ status: 200, description: 'Command acknowledged' }) async ackCommand( diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 01c420d..ecc1684 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -18,6 +18,7 @@ services: RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-KoolTap } # Coolify's proxy is one hop; use 2 if Cloudflare proxying is also enabled TRUST_PROXY: ${TRUST_PROXY:-1} + MIN_APP_VERSION: ${MIN_APP_VERSION:-} healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3001/api/v1/health"] interval: 15s diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c49b4db..d6cf319 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -33,6 +33,7 @@ services: RESEND_FROM_EMAIL: "${RESEND_FROM_EMAIL:-KoolTap }" # The API sits behind a reverse proxy: trust one hop of X-Forwarded-For for per-client rate limiting TRUST_PROXY: "${TRUST_PROXY:-1}" + MIN_APP_VERSION: "${MIN_APP_VERSION:-}" depends_on: postgres: condition: service_healthy diff --git a/docs/api.md b/docs/api.md index 33d319a..4cfbf8d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -14,6 +14,9 @@ Swagger UI: `http://localhost:3001/api/docs` - Register to receive device bearer token (90 days) - Include device token in `Authorization: Bearer ` header - Device ID must match URL parameter +- Send the app version in an `X-App-Version` header (e.g. `1.4.2+37`, the build number is ignored) on every + `/devices/...` call. Without it, or below the minimum version (`MIN_APP_VERSION`), the call fails with + `426 app_update_required` before the token is checked: the app must be updated to use the backoffice again ## Endpoints @@ -162,4 +165,5 @@ All `/venues/:venueId/...` routes return `403 venue_forbidden` unless the user b - 403 Forbidden - 404 Not Found - 409 Conflict +- 426 Upgrade Required (`app_update_required`: the terminal app is too old) - 429 Too Many Requests \ No newline at end of file diff --git a/docs/flutter-app-version-prompt.md b/docs/flutter-app-version-prompt.md new file mode 100644 index 0000000..0d1c476 --- /dev/null +++ b/docs/flutter-app-version-prompt.md @@ -0,0 +1,34 @@ +## KoolTap Flutter App — Send the App Version, Ask to Update When It's Too Old + +The backoffice now refuses terminals running an app that is too old. Every call to `/devices/...` (register, sync, +image upload, command ack) must carry the app version in a header: + +``` +X-App-Version: 1.4.2+37 +``` + +Use the version from `pubspec.yaml` (read it at runtime with `package_info_plus`: `version` + `+` + `buildNumber`, or +just `version`). The build number after `+` is ignored by the backoffice. + +When the app is older than the backoffice's minimum version, or sends no header (every app from before this change), +the call fails **before** the token is checked with: + +``` +HTTP 426 +{ "error": { "code": "app_update_required", "message": "Update the app to version 1.4.0 or newer" } } +``` + +### What to Change + +1. **Send the header** on every request to the backoffice: add it where the HTTP client sets the `Authorization` + header (e.g. a shared client or interceptor in `lib/services/remote_sync_service.dart`), so register, sync, image + uploads and acks all carry it. +2. **Handle 426 / `app_update_required`** in the sync loop and on the registration screen: + - Stop syncing (don't retry in a loop); keep the till working offline exactly as when the backoffice is unreachable, + and keep every outbox (tab events, stock outcomes, acks) so nothing is lost once the app is updated. + - Show a clear, persistent banner/dialog: "This app is out of date. Update it to keep syncing with the backoffice", + with the server's `message`. + - **Don't** treat it as an auth error: never unpair or throw away the device token. + - Retry after the app restarts (an updated app restarts anyway). +3. Bump the app version in `pubspec.yaml` for the release that adds this, and tell the backoffice that version: it is + set as `MIN_APP_VERSION` (env var, or the constant in `apps/api/src/devices/app-version.ts`). diff --git a/scripts/device-sync.ts b/scripts/device-sync.ts index 3e73924..a59542c 100644 --- a/scripts/device-sync.ts +++ b/scripts/device-sync.ts @@ -54,7 +54,12 @@ async function api(endpoint: string, options: RequestInit = {}): Promis const { headers: customHeaders, ...rest } = options; const res = await fetch(`${API_URL}${endpoint}`, { ...rest, - headers: { 'Content-Type': 'application/json', ...(customHeaders as Record) }, + headers: { + 'Content-Type': 'application/json', + // Plays an up-to-date terminal app, so the minimum app version never refuses it + 'X-App-Version': process.env.APP_VERSION || '999.0.0', + ...(customHeaders as Record), + }, }); if (!res.ok) { const body = await res.json().catch(() => null);