feat(api): GET /desktop/update — signierte Desktop-Pakete im Format des Tauri-Updaters ausliefern

- Neuer oeffentlicher Endpunkt (vor download/:platform): base-Origin wird
  per safeOrigin validiert (nur http/https, kein Pfad/Query/Fragment/
  Userinfo, sonst 400) und nur zum Bau der absoluten Download-URL genutzt,
  nie serverseitig abgerufen
- 204 ohne Body bei fremder Plattform/Architektur, fehlendem Manifest,
  fehlender Signatur oder fehlendem/ungueltigem updateVersion; sonst
  { version, pub_date (nur RFC 3339), url, signature, notes }
- Manifest-Felder signature (je Plattform) und updateVersion optional in
  @tessera/shared, Eintragspruefung akzeptiert nur String-Signaturen
- 10 neue Spec-Tests, Test 11 erweitert (23 gesamt); latest/download
  unveraendert

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 15:32:52 +02:00
parent 678ba51725
commit de81c74e53
4 changed files with 351 additions and 7 deletions
+43 -3
View File
@@ -1,7 +1,17 @@
import { Controller, Get, Inject, Param, StreamableFile } from '@nestjs/common';
import type { DesktopLatestResponse } from '@tessera/shared';
import {
BadRequestException,
Controller,
Get,
Inject,
Param,
Query,
Res,
StreamableFile,
} from '@nestjs/common';
import type { DesktopLatestResponse, DesktopUpdateResponse } from '@tessera/shared';
import type { Response } from 'express';
import { Public } from '../auth/decorators/public.decorator';
import { DesktopService } from './desktop.service';
import { DesktopService, safeOrigin } from './desktop.service';
@Controller('desktop')
export class DesktopController {
@@ -22,6 +32,36 @@ export class DesktopController {
return this.desktopService.getLatest();
}
// Bewusst oeffentlich (D-10, wie latest/download): der Desktop-Client prueft
// beim Start vor jeder Anmeldung. Statische Route VOR `download/:platform`
// (Konvention Route-Order, auch wenn sich die beiden hier nicht
// ueberschatten). 204 ist der vom Updater-Plugin definierte Status fuer
// "kein Update"; `passthrough` + `res.status(204)` funktioniert, weil Nest
// den Standardstatus VOR dem Handler setzt und die Antwort danach ohne
// eigenen Statuscode schreibt -- der Handler-Status gewinnt. `current`
// wird nicht deklariert, weil der Service es nicht auswertet.
@Public()
@Get('update')
update(
@Query('target') target: unknown,
@Query('arch') arch: unknown,
@Query('base') base: unknown,
@Res({ passthrough: true }) res: Response,
): DesktopUpdateResponse | undefined {
const origin = safeOrigin(base);
if (!origin) {
throw new BadRequestException(
'base must be an http(s) origin without path, query or credentials',
);
}
const result = this.desktopService.getUpdate({ target, arch, origin });
if (!result) {
res.status(204);
return undefined;
}
return result;
}
// Bewusst oeffentlich (D-10): der Download selbst braucht keine Anmeldung,
// gleicher Grund wie getLatest oben.
@Public()