This commit is contained in:
@@ -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"
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -18,6 +18,7 @@ services:
|
||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-KoolTap <onboarding@resend.dev>}
|
||||
# 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
|
||||
|
||||
@@ -33,6 +33,7 @@ services:
|
||||
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
|
||||
TRUST_PROXY: "${TRUST_PROXY:-1}"
|
||||
MIN_APP_VERSION: "${MIN_APP_VERSION:-}"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -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 <token>` 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
|
||||
@@ -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`).
|
||||
@@ -54,7 +54,12 @@ async function api<T = any>(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<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) {
|
||||
const body = await res.json().catch(() => null);
|
||||
|
||||
Reference in New Issue
Block a user