check($ip)) { Http::fail(429, 'rate_limited', 'Zu viele Anfragen.'); } $service = new LicenseService($db); $action = resolveLicenseAction(); switch ($action) { // WICHTIG: Die Lizenz-Endpunkte antworten bewusst OHNE den // status/error-Umschlag der uebrigen API. Ihr Format ist ein bereits // ausgerollter Vertrag: das Feld "status" auf oberster Ebene traegt den // Lizenzzustand (valid, revoked, expired, not_found, activation_limit). // Ein Umschlag mit status=success wuerde von jedem bestehenden Client als // "nicht valid" interpretiert - saemtliche Lizenzen gaelten als ungueltig. case 'validate': if (Http::method() !== 'POST') { Http::fail(405, 'method_not_allowed', 'Diese Aktion erwartet POST.'); } // Bewusst oeffentlich: Client-Anwendungen pruefen hier ihre Lizenz. // Die Antwort verraet nichts ueber fremde Lizenzen. Http::raw($service->validate(Http::body(), $ip)); case 'deactivate': if (Http::method() !== 'POST') { Http::fail(405, 'method_not_allowed', 'Diese Aktion erwartet POST.'); } ApiAuth::requireScope($db, 'license:deactivate'); Http::raw($service->deactivate(Http::body(), $ip)); // ------------------------------------------------------------------ // Entwicklungsschluessel zur Selbstbedienung // // Damit ein Agent auf der Testmaschine ueberhaupt etwas pruefen kann, // braucht er einen gueltigen Schluessel - fuer die Lizenzpruefung selbst // und seit 2.4 auch fuer den Zugang zur Release-Ablage. Ihn von Hand // auszustellen und weiterzureichen war der einzige Weg; das Ergebnis waren // unbefristete Schluessel in Chatverlaeufen. Herleitung in DevKeys. // // Antwortet im Hausformat, nicht im Sonderformat der Lizenzpruefung. // ------------------------------------------------------------------ case 'dev-key': if (Http::method() !== 'POST') { Http::fail(405, 'method_not_allowed', 'Diese Aktion erwartet POST.'); } // Enger gedrosselt als der Rest des Moduls: Ein Schluessel wird einmal // je Testlauf geholt, nicht im Sekundentakt. Wer haeufiger anfragt, hat // eine Schleife gebaut - und legt sonst Zeilen an, bis die Grenze aus // DevKeys::MAX_PER_PROJECT greift. $devLimiter = new RateLimiter($db, 10, 60, 'license_dev'); if (!$devLimiter->check($ip)) { Http::fail(429, 'rate_limited', 'Zu viele Anfragen nach Entwicklungsschluesseln.'); } // allowSession bewusst aus: Dieser Weg ist fuer Werkbank-Token auf // Testmaschinen, nicht fuer einen angemeldeten Browser. Wer im WebUI // sitzt, legt Lizenzen dort an. $context = ApiAuth::requireScope($db, 'license:dev', null, false); $slug = Http::str('project_slug') ?? Http::str('product'); if ($slug === null) { Http::fail(400, 'missing_project', 'Es fehlt project_slug.'); } // allowWorkbench bleibt aus. Ein Werkbank-Token darf projektuebergreifend // melden - aber ein Lizenzschluessel ist etwas anderes als eine Meldung: // Er oeffnet die Release-Ablage eines Produkts. Wer fuer ein fremdes // Projekt testet, braucht dafuer dessen Token. ApiAuth::enforceProject($context, $slug); $identity = Http::str('instance_id') ?? Http::str('hostname') ?? ($context['token']['owner_identity'] ?? null); if ($identity === null || $identity === '') { Http::fail( 400, 'missing_identity', 'Es fehlt instance_id - die Kennung der Maschine, die den Schluessel bekommt. ' . 'Ohne sie liesse sich ein wiederholter Aufruf nicht demselben Schluessel zuordnen.' ); } $devAction = strtolower(Http::str('action_mode') ?? 'issue'); try { if ($devAction === 'revoke') { $removed = DevKeys::revoke($db, $slug, (string)$identity, $context['actor']); Http::ok([ 'project_slug' => $slug, 'revoked' => $removed, 'message' => $removed ? 'Entwicklungsschluessel zurueckgegeben.' : 'Fuer diese Maschine war kein Entwicklungsschluessel vergeben.', ]); } $result = DevKeys::issue( $db, $slug, (string)$identity, Http::input('ttl_days') !== null ? Http::int('ttl_days') : null, $context['actor'] ); } catch (RuntimeException $e) { Http::fail(409, 'dev_key_denied', $e->getMessage()); } Http::ok([ 'license_key' => $result['license_key'], 'project_slug' => $result['project_slug'], 'instance_id' => $result['identity'], 'expires_at' => $result['expires_at'], 'max_activations' => $result['max_activations'], 'activations_used' => $result['activations_used'], 'created' => $result['created'], 'renewed' => $result['renewed'], 'message' => $result['created'] ? 'Entwicklungsschluessel ausgestellt. Er ist befristet und gilt nur fuer diese Maschine.' : 'Bestehender Entwicklungsschluessel verlaengert.', ], $result['created'] ? 201 : 200); case 'status': Http::raw(['status' => 'ok', 'module' => 'license', 'version' => '2.0']); default: Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [ 'available' => ['validate', 'deactivate', 'dev-key', 'status'], ]); } function resolveLicenseAction(): string { $explicit = Http::str('action'); if ($explicit !== null) { return strtolower($explicit); } $segments = array_values(array_filter( explode('/', trim(Http::path(), '/')), static fn(string $s): bool => $s !== '' )); $last = strtolower((string)end($segments)); return match ($last) { 'validate', 'deactivate', 'status', 'dev-key' => $last, // Ein Tippfehler bei der Selbstbedienung soll nicht stillschweigend zur // Verfuegbarkeitspruefung werden - sonst sucht jemand lange nach dem // Grund, warum "kein Schluessel kommt, aber alles ok meldet". 'devkey', 'dev_key' => 'dev-key', default => 'status', }; }