feat(apicore-sync): authenticate with an ApiCore API key

APICORE_API_KEY replaces the service-account login: the key is sent as
the bearer token directly, needs no password and no MFA exemption, may
only read the shop feed and can be revoked in ApiCore at any time. A
malformed key is rejected with a clear message before any request.
Email/password stays as fallback for ApiCore versions without keys.

The system status treats the key or the legacy login as configured.
README, .env.example and the k8s secret template describe the key.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 01:24:26 +02:00
1 parent 5642f0c4af
commit 55be1fe881
7 files changed
+101 -16

No files matched your search

+6
View File
@@ -106,6 +106,12 @@ SMTP_FROM="Wabenhain <[email protected]>"
# harvests:read, products:read, extractions:read, bottling:read, apiaries:read # harvests:read, products:read, extractions:read, bottling:read, apiaries:read
# Details siehe src/lib/apicore-client.ts. # Details siehe src/lib/apicore-client.ts.
APICORE_API_URL= APICORE_API_URL=
# Empfohlen: API-Schluessel aus ApiCore → Verwaltung → Automatisierung →
# API-Schluessel (Berechtigung "Shop-Daten lesen"). Kein Passwort, keine MFA,
# jederzeit widerrufbar. Ist er gesetzt, werden die beiden Werte darunter
# nicht gebraucht.
APICORE_API_KEY=
# Nur fuer aeltere ApiCore-Staende ohne API-Schluessel:
APICORE_SYNC_EMAIL= APICORE_SYNC_EMAIL=
APICORE_SYNC_PASSWORD= APICORE_SYNC_PASSWORD=
+1 -1
View File
@@ -337,7 +337,7 @@ Die Reihenfolge ist zwingend, weil jede Stufe auf der vorherigen aufbaut: eine C
Wiederholte Läufe sind gefahrlos: Ernten werden über den stabilen Schlüssel `apicore-<ApiCore-ID>` als `Harvest.slug` wiedererkannt, Chargen über die ApiCore-Losnummer als `Batch.code`, Produkte über `Product.apicoreBottlingId`. Wiederholte Läufe sind gefahrlos: Ernten werden über den stabilen Schlüssel `apicore-<ApiCore-ID>` als `Harvest.slug` wiedererkannt, Chargen über die ApiCore-Losnummer als `Batch.code`, Produkte über `Product.apicoreBottlingId`.
**Einrichtung:** `APICORE_API_URL`, `APICORE_SYNC_EMAIL` und `APICORE_SYNC_PASSWORD` in `.env` setzen (siehe `.env.example`). ApiCore kennt keinen API-Key-Mechanismus, nur E-Mail/Passwort-Login — deshalb dort ein **eigenes, MFA-freies Service-Konto** anlegen statt einen echten Admin-Zugang einzutragen. Es braucht ausschließlich Leserechte: `harvests:read`, `products:read`, `extractions:read`, `bottling:read` und optional `apiaries:read` (nur für die Standortnamen an den Ernten). Fehlt eine Berechtigung, meldet der Sync das konkret als HTTP 403 statt still nichts zu tun. Details und die Zuordnung Feld für Feld: `src/lib/apicore-client.ts`, `src/lib/apicore-mapping.ts` und `src/app/admin/actions/apicore-sync.ts`. **Einrichtung:** `APICORE_API_URL` und `APICORE_API_KEY` in `.env` setzen (siehe `.env.example`). Den Schlüssel in ApiCore unter Verwaltung → Automatisierung → API-Schlüssel anlegen; er darf nur die freigegebenen Shop-Daten lesen (`GET /shop-sync`, Berechtigung `shop-sync:read`), hat kein Passwort und lässt sich dort jederzeit widerrufen. Für ältere ApiCore-Stände ohne API-Schlüssel funktioniert weiter `APICORE_SYNC_EMAIL`/`APICORE_SYNC_PASSWORD` eines eigenen, MFA-freien Service-Kontos. Fehlt eine Berechtigung, meldet der Sync das konkret als HTTP 403 statt still nichts zu tun. Zuständigkeit: Imkerei-Fakten (Datum, Losnummer, MHD, Abfüllmenge) kommen aus ApiCore, alles Verkaufsbezogene (Preise, Texte, Bilder, Sichtbarkeit, Shop-Bestand) wird nur hier gepflegt. Details und die Zuordnung Feld für Feld: `src/lib/apicore-client.ts`, `src/lib/apicore-mapping.ts` und `src/app/admin/actions/apicore-sync.ts`.
## Vollständiger Docker-Betrieb ## Vollständiger Docker-Betrieb
+7 -5
View File
@@ -106,11 +106,13 @@ stringData:
# "ApiCore-Synchronisierung"). Leer = der Sync-Knopf meldet einen # "ApiCore-Synchronisierung"). Leer = der Sync-Knopf meldet einen
# Konfigurationsfehler, statt still nichts zu tun. # Konfigurationsfehler, statt still nichts zu tun.
# #
# ApiCore kennt keinen API-Key-Mechanismus, nur E-Mail/Passwort-Login — # Empfohlen: API-Schluessel aus ApiCore (Verwaltung -> Automatisierung ->
# deshalb hier ein eigenes, MFA-FREIES Service-Konto eintragen, nicht einen # API-Schluessel, Berechtigung "Shop-Daten lesen"). Kein Passwort, keine
# echten Admin-Zugang. Noetige Leserechte: harvests:read, products:read, # MFA, jederzeit widerrufbar. Die Adresse APICORE_API_URL ist nicht geheim
# extractions:read, bottling:read und optional apiaries:read. Die Adresse # und steht in 01-configmap.yaml.
# APICORE_API_URL ist nicht geheim und steht in 01-configmap.yaml. APICORE_API_KEY: ""
# Nur fuer aeltere ApiCore-Staende ohne API-Schluessel: eigenes,
# MFA-freies Service-Konto mit Leserechten statt eines Admin-Zugangs.
APICORE_SYNC_EMAIL: "" APICORE_SYNC_EMAIL: ""
APICORE_SYNC_PASSWORD: "" APICORE_SYNC_PASSWORD: ""
+23
View File
@@ -40,6 +40,29 @@ describe("apicore-client", () => {
await expect(openApiCoreSession()).rejects.toThrow(/keine Antwort innerhalb von 20 Sekunden/); await expect(openApiCoreSession()).rejects.toThrow(/keine Antwort innerhalb von 20 Sekunden/);
}); });
test("nutzt einen API-Schluessel direkt als Bearer-Token, ohne Passwort-Login", async () => {
// Arrange
const key = `ack_abcd1234_${"A".repeat(43)}`;
vi.stubEnv("APICORE_API_KEY", key);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
// Act
const opened = await openApiCoreSession();
// Assert
expect(opened).toEqual({ baseUrl: "http://apicore.test/api/v1", token: key });
expect(fetchMock).not.toHaveBeenCalled();
});
test("weist einen falsch kopierten API-Schluessel mit klarer Meldung ab", async () => {
// Arrange
vi.stubEnv("APICORE_API_KEY", "ack_zu-kurz");
// Act & Assert
await expect(openApiCoreSession()).rejects.toThrow(/Format eines ApiCore-API-Schlüssels/);
});
test("setzt an jeder Anfrage ein Zeitlimit", async () => { test("setzt an jeder Anfrage ein Zeitlimit", async () => {
// Arrange // Arrange
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([])); const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
+35 -8
View File
@@ -77,20 +77,47 @@ export interface ApiCoreSession {
readonly token: string; readonly token: string;
} }
function getConfig(): { baseUrl: string; email: string; password: string } { type ApiCoreCredentials =
const baseUrl = process.env.APICORE_API_URL; | { baseUrl: string; apiKey: string }
| { baseUrl: string; email: string; password: string };
// ack_<8 Zeichen>_<43 Zeichen> — Format der ApiCore-API-Schluessel.
const API_KEY_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
/**
* Bevorzugt einen ApiCore-API-Schluessel (APICORE_API_KEY): kein Passwort,
* keine MFA-Frage, nur die Berechtigung "shop-sync:read", in ApiCore
* jederzeit widerrufbar. E-Mail/Passwort eines Service-Kontos bleibt als
* Rueckfall fuer aeltere ApiCore-Staende ohne API-Schluessel.
*/
function getConfig(): ApiCoreCredentials {
const baseUrl = process.env.APICORE_API_URL?.trim().replace(/\/$/, "");
const apiKey = process.env.APICORE_API_KEY?.trim();
const email = process.env.APICORE_SYNC_EMAIL; const email = process.env.APICORE_SYNC_EMAIL;
const password = process.env.APICORE_SYNC_PASSWORD; const password = process.env.APICORE_SYNC_PASSWORD;
if (!baseUrl || !email || !password) { if (baseUrl && apiKey) {
throw new ApiCoreConfigError( if (!API_KEY_PATTERN.test(apiKey)) {
"ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL, APICORE_SYNC_EMAIL und APICORE_SYNC_PASSWORD setzen (siehe .env.example)." throw new ApiCoreConfigError(
); "APICORE_API_KEY hat nicht das Format eines ApiCore-API-Schlüssels (ack_…). Bitte den Schlüssel vollständig aus ApiCore → Verwaltung → Automatisierung → API-Schlüssel übernehmen."
);
}
return { baseUrl, apiKey };
} }
return { baseUrl: baseUrl.replace(/\/$/, ""), email, password }; if (baseUrl && email && password) {
return { baseUrl, email, password };
}
throw new ApiCoreConfigError(
"ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL und APICORE_API_KEY setzen (siehe .env.example)."
);
} }
export async function openApiCoreSession(): Promise<ApiCoreSession> { export async function openApiCoreSession(): Promise<ApiCoreSession> {
const { baseUrl, email, password } = getConfig(); const config = getConfig();
// Ein API-Schluessel wird direkt als Bearer-Token gesendet — kein Login.
if ("apiKey" in config) {
return { baseUrl: config.baseUrl, token: config.apiKey };
}
const { baseUrl, email, password } = config;
let res: Response; let res: Response;
try { try {
+17
View File
@@ -50,6 +50,23 @@ describe("readIntegrationStatus", () => {
expect(JSON.stringify(readIntegrationStatus(FULL))).not.toMatch(/sk_live_abc|whsec_abc|noreply@|"pw"/); expect(JSON.stringify(readIntegrationStatus(FULL))).not.toMatch(/sk_live_abc|whsec_abc|noreply@|"pw"/);
}); });
it("accepts an ApiCore API key instead of the legacy email/password", () => {
const { APICORE_SYNC_EMAIL, APICORE_SYNC_PASSWORD, ...withoutPassword } = FULL;
void APICORE_SYNC_EMAIL;
void APICORE_SYNC_PASSWORD;
const apicore = readIntegrationStatus({ ...withoutPassword, APICORE_API_KEY: "ack_x" }).find(
(s) => s.key === "apicore"
);
expect(apicore).toMatchObject({ configured: true, missing: [] });
});
it("asks for the API key when neither key nor legacy login is set", () => {
const apicore = readIntegrationStatus({ APICORE_API_URL: "https://api.example.org/api" }).find(
(s) => s.key === "apicore"
);
expect(apicore).toMatchObject({ configured: false, missing: ["APICORE_API_KEY"] });
});
it("marks shop-critical integrations as required", () => { it("marks shop-critical integrations as required", () => {
const required = readIntegrationStatus({}).filter((s) => s.required).map((s) => s.key); const required = readIntegrationStatus({}).filter((s) => s.required).map((s) => s.key);
expect(required).toEqual(["stripe", "stripeWebhook", "mail"]); expect(required).toEqual(["stripe", "stripeWebhook", "mail"]);
+12 -2
View File
@@ -28,7 +28,8 @@ const INTEGRATIONS: { key: IntegrationKey; required: boolean; variables: string[
{ key: "turnstile", required: false, variables: ["TURNSTILE_SECRET_KEY", "NEXT_PUBLIC_TURNSTILE_SITE_KEY"] }, { key: "turnstile", required: false, variables: ["TURNSTILE_SECRET_KEY", "NEXT_PUBLIC_TURNSTILE_SITE_KEY"] },
{ key: "googleReviews", required: false, variables: ["GOOGLE_PLACES_API_KEY", "GOOGLE_PLACE_ID"] }, { key: "googleReviews", required: false, variables: ["GOOGLE_PLACES_API_KEY", "GOOGLE_PLACE_ID"] },
{ key: "dhl", required: false, variables: ["DHL_API_KEY", "DHL_API_SECRET", "DHL_GKP_USERNAME", "DHL_GKP_PASSWORD", "DHL_ACCOUNT_NUMBER"] }, { key: "dhl", required: false, variables: ["DHL_API_KEY", "DHL_API_SECRET", "DHL_GKP_USERNAME", "DHL_GKP_PASSWORD", "DHL_ACCOUNT_NUMBER"] },
{ key: "apicore", required: false, variables: ["APICORE_API_URL", "APICORE_SYNC_EMAIL", "APICORE_SYNC_PASSWORD"] }, // Zugang per APICORE_API_KEY; E-Mail/Passwort zaehlt als Rueckfall, siehe apicoreMissing().
{ key: "apicore", required: false, variables: ["APICORE_API_URL", "APICORE_API_KEY"] },
]; ];
function stripeMode(key: string): "live" | "test" | null { function stripeMode(key: string): "live" | "test" | null {
@@ -45,9 +46,18 @@ function stripeWarning(env: Env): IntegrationWarning | undefined {
return undefined; return undefined;
} }
/** Ein API-Schluessel ODER (Rueckfall) E-Mail + Passwort des Service-Kontos. */
function apicoreMissing(env: Env): string[] {
const missing = env.APICORE_API_URL?.trim() ? [] : ["APICORE_API_URL"];
const hasKey = Boolean(env.APICORE_API_KEY?.trim());
const hasPassword = Boolean(env.APICORE_SYNC_EMAIL?.trim() && env.APICORE_SYNC_PASSWORD?.trim());
return hasKey || hasPassword ? missing : [...missing, "APICORE_API_KEY"];
}
export function readIntegrationStatus(env: Env = process.env): IntegrationStatus[] { export function readIntegrationStatus(env: Env = process.env): IntegrationStatus[] {
return INTEGRATIONS.map(({ key, required, variables }) => { return INTEGRATIONS.map(({ key, required, variables }) => {
const missing = variables.filter((name) => !env[name]?.trim()); const missing =
key === "apicore" ? apicoreMissing(env) : variables.filter((name) => !env[name]?.trim());
const status: IntegrationStatus = { key, required, configured: missing.length === 0, missing }; const status: IntegrationStatus = { key, required, configured: missing.length === 0, missing };
const warning = key === "stripe" && status.configured ? stripeWarning(env) : undefined; const warning = key === "stripe" && status.configured ? stripeWarning(env) : undefined;
return warning ? { ...status, warning } : status; return warning ? { ...status, warning } : status;