feat: add version check app
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-27 00:05:00 +01:00
parent bdb465782d
commit 97572fe459
11 changed files with 140 additions and 4 deletions
+2
View File
@@ -4,6 +4,8 @@ DATABASE_URL="postgresql://kooltap:kooltap@localhost:5432/kooltap"
JWT_SECRET="dev-jwt-secret-change-in-production" JWT_SECRET="dev-jwt-secret-change-in-production"
PORT=3001 PORT=3001
CORS_ORIGIN="http://localhost:3000" 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 # Dashboard
NEXT_PUBLIC_API_URL="http://localhost:3001/api/v1" NEXT_PUBLIC_API_URL="http://localhost:3001/api/v1"
@@ -54,6 +54,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
case 403: return 'forbidden'; case 403: return 'forbidden';
case 404: return 'not_found'; case 404: return 'not_found';
case 409: return 'conflict'; case 409: return 'conflict';
case 426: return 'upgrade_required';
case 429: return 'too_many_requests'; case 429: return 'too_many_requests';
default: return 'internal_error'; default: return 'internal_error';
} }
+22
View File
@@ -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;
}
}
+33
View File
@@ -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);
});
});
+31
View File
@@ -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;
}
+5 -3
View File
@@ -21,6 +21,7 @@ import { DevicesService } from './devices.service';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { DeviceAuthGuard } from '../auth/guards/device-auth.guard'; import { DeviceAuthGuard } from '../auth/guards/device-auth.guard';
import { AppVersionGuard } from './app-version.guard';
import { RolesGuard } from '../common/guards/roles.guard'; import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator'; import { Roles } from '../common/decorators/roles.decorator';
import { RegisterDeviceDto } from './dto/register-device.dto'; import { RegisterDeviceDto } from './dto/register-device.dto';
@@ -38,6 +39,7 @@ export class DevicesController {
@Post('register') @Post('register')
@Throttle({ default: { limit: 10, ttl: 60000 } }) @Throttle({ default: { limit: 10, ttl: 60000 } })
@UseGuards(AppVersionGuard)
@ApiOperation({ summary: 'Register a new device' }) @ApiOperation({ summary: 'Register a new device' })
@ApiResponse({ status: 201, description: 'Device registered successfully' }) @ApiResponse({ status: 201, description: 'Device registered successfully' })
async register(@Body() dto: RegisterDeviceDto) { async register(@Body() dto: RegisterDeviceDto) {
@@ -48,7 +50,7 @@ export class DevicesController {
// traffic from the venue's shared IP // traffic from the venue's shared IP
@Post(':deviceId/sync') @Post(':deviceId/sync')
@SkipThrottle() @SkipThrottle()
@UseGuards(DeviceAuthGuard) @UseGuards(AppVersionGuard, DeviceAuthGuard)
@ApiOperation({ summary: 'Sync device state with server' }) @ApiOperation({ summary: 'Sync device state with server' })
@ApiResponse({ status: 200, description: 'Sync successful, returns pending commands' }) @ApiResponse({ status: 200, description: 'Sync successful, returns pending commands' })
async sync(@Param('deviceId') deviceId: string, @Body() dto: SyncDto, @Req() req: any) { 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 // so an offline edit and its picture follow the same rules as every other change
@Post(':deviceId/images') @Post(':deviceId/images')
@SkipThrottle() @SkipThrottle()
@UseGuards(DeviceAuthGuard) @UseGuards(AppVersionGuard, DeviceAuthGuard)
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_BYTES } })) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_BYTES } }))
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload a product picture from a terminal' }) @ApiOperation({ summary: 'Upload a product picture from a terminal' })
@@ -69,7 +71,7 @@ export class DevicesController {
@Post(':deviceId/commands/:commandId/ack') @Post(':deviceId/commands/:commandId/ack')
@SkipThrottle() @SkipThrottle()
@UseGuards(DeviceAuthGuard) @UseGuards(AppVersionGuard, DeviceAuthGuard)
@ApiOperation({ summary: 'Acknowledge a command result' }) @ApiOperation({ summary: 'Acknowledge a command result' })
@ApiResponse({ status: 200, description: 'Command acknowledged' }) @ApiResponse({ status: 200, description: 'Command acknowledged' })
async ackCommand( async ackCommand(
+1
View File
@@ -18,6 +18,7 @@ services:
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-KoolTap <onboarding@resend.dev>} RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-KoolTap <onboarding@resend.dev>}
# Coolify's proxy is one hop; use 2 if Cloudflare proxying is also enabled # Coolify's proxy is one hop; use 2 if Cloudflare proxying is also enabled
TRUST_PROXY: ${TRUST_PROXY:-1} TRUST_PROXY: ${TRUST_PROXY:-1}
MIN_APP_VERSION: ${MIN_APP_VERSION:-}
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3001/api/v1/health"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3001/api/v1/health"]
interval: 15s interval: 15s
+1
View File
@@ -33,6 +33,7 @@ services:
RESEND_FROM_EMAIL: "${RESEND_FROM_EMAIL:-KoolTap <onboarding@resend.dev>}" RESEND_FROM_EMAIL: "${RESEND_FROM_EMAIL:-KoolTap <onboarding@resend.dev>}"
# The API sits behind a reverse proxy: trust one hop of X-Forwarded-For for per-client rate limiting # The API sits behind a reverse proxy: trust one hop of X-Forwarded-For for per-client rate limiting
TRUST_PROXY: "${TRUST_PROXY:-1}" TRUST_PROXY: "${TRUST_PROXY:-1}"
MIN_APP_VERSION: "${MIN_APP_VERSION:-}"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
+4
View File
@@ -14,6 +14,9 @@ Swagger UI: `http://localhost:3001/api/docs`
- Register to receive device bearer token (90 days) - Register to receive device bearer token (90 days)
- Include device token in `Authorization: Bearer <token>` header - Include device token in `Authorization: Bearer <token>` header
- Device ID must match URL parameter - 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 ## Endpoints
@@ -162,4 +165,5 @@ All `/venues/:venueId/...` routes return `403 venue_forbidden` unless the user b
- 403 Forbidden - 403 Forbidden
- 404 Not Found - 404 Not Found
- 409 Conflict - 409 Conflict
- 426 Upgrade Required (`app_update_required`: the terminal app is too old)
- 429 Too Many Requests - 429 Too Many Requests
+34
View File
@@ -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`).
+6 -1
View File
@@ -54,7 +54,12 @@ async function api<T = any>(endpoint: string, options: RequestInit = {}): Promis
const { headers: customHeaders, ...rest } = options; const { headers: customHeaders, ...rest } = options;
const res = await fetch(`${API_URL}${endpoint}`, { const res = await fetch(`${API_URL}${endpoint}`, {
...rest, ...rest,
headers: { 'Content-Type': 'application/json', ...(customHeaders as Record<string, string>) }, 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<string, string>),
},
}); });
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => null); const body = await res.json().catch(() => null);