diff --git a/.env.example b/.env.example index fe65236..466f1aa 100644 --- a/.env.example +++ b/.env.example @@ -106,6 +106,12 @@ SMTP_FROM="Wabenhain " # harvests:read, products:read, extractions:read, bottling:read, apiaries:read # Details siehe src/lib/apicore-client.ts. 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_PASSWORD= diff --git a/README.md b/README.md index 9bbb26b..e1ef5e3 100644 --- a/README.md +++ b/README.md @@ -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-` 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 diff --git a/k8s/02-secret.example.yaml b/k8s/02-secret.example.yaml index cd579da..3b51b26 100644 --- a/k8s/02-secret.example.yaml +++ b/k8s/02-secret.example.yaml @@ -106,11 +106,13 @@ stringData: # "ApiCore-Synchronisierung"). Leer = der Sync-Knopf meldet einen # Konfigurationsfehler, statt still nichts zu tun. # - # ApiCore kennt keinen API-Key-Mechanismus, nur E-Mail/Passwort-Login — - # deshalb hier ein eigenes, MFA-FREIES Service-Konto eintragen, nicht einen - # echten Admin-Zugang. Noetige Leserechte: harvests:read, products:read, - # extractions:read, bottling:read und optional apiaries:read. Die Adresse - # APICORE_API_URL ist nicht geheim und steht in 01-configmap.yaml. + # Empfohlen: API-Schluessel aus ApiCore (Verwaltung -> Automatisierung -> + # API-Schluessel, Berechtigung "Shop-Daten lesen"). Kein Passwort, keine + # MFA, jederzeit widerrufbar. Die Adresse 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_PASSWORD: "" diff --git a/src/lib/apicore-client.test.ts b/src/lib/apicore-client.test.ts index 174fa5e..1b1ff99 100644 --- a/src/lib/apicore-client.test.ts +++ b/src/lib/apicore-client.test.ts @@ -40,6 +40,29 @@ describe("apicore-client", () => { 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 () => { // Arrange const fetchMock = vi.fn().mockResolvedValue(jsonResponse([])); diff --git a/src/lib/apicore-client.ts b/src/lib/apicore-client.ts index 78fb385..7559397 100644 --- a/src/lib/apicore-client.ts +++ b/src/lib/apicore-client.ts @@ -77,20 +77,47 @@ export interface ApiCoreSession { readonly token: string; } -function getConfig(): { baseUrl: string; email: string; password: string } { - const baseUrl = process.env.APICORE_API_URL; +type ApiCoreCredentials = + | { 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 password = process.env.APICORE_SYNC_PASSWORD; - if (!baseUrl || !email || !password) { - throw new ApiCoreConfigError( - "ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL, APICORE_SYNC_EMAIL und APICORE_SYNC_PASSWORD setzen (siehe .env.example)." - ); + if (baseUrl && apiKey) { + if (!API_KEY_PATTERN.test(apiKey)) { + 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 { - 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; try { diff --git a/src/lib/integration-status.test.ts b/src/lib/integration-status.test.ts index a9c0772..2c9e958 100644 --- a/src/lib/integration-status.test.ts +++ b/src/lib/integration-status.test.ts @@ -50,6 +50,23 @@ describe("readIntegrationStatus", () => { 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", () => { const required = readIntegrationStatus({}).filter((s) => s.required).map((s) => s.key); expect(required).toEqual(["stripe", "stripeWebhook", "mail"]); diff --git a/src/lib/integration-status.ts b/src/lib/integration-status.ts index c52b4af..f685354 100644 --- a/src/lib/integration-status.ts +++ b/src/lib/integration-status.ts @@ -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: "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: "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 { @@ -45,9 +46,18 @@ function stripeWarning(env: Env): IntegrationWarning | 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[] { 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 warning = key === "stripe" && status.configured ? stripeWarning(env) : undefined; return warning ? { ...status, warning } : status;