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
@@ -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';
}
+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 { 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(