#98 — Externe Schnittstelle für Rezept-/Essensplan-App (Vorratsschrank + Einkaufsliste) #97

Closed
opened 2026-08-18 13:13:59 +02:00 by lena · 1 comment
lena commented 2026-08-18 13:13:59 +02:00 (Migrated from git.butzei.de)

Story: Externe Schnittstelle für Rezept-/Essensplan-App (Vorratsschrank + Einkaufsliste)

As a Nutzer, der eine separate Rezept-/Essensplan-App verwendet (eigenes, separates Projekt außerhalb dieses
Repos),
I want to dass diese App eine dokumentierte Schnittstelle bereitstellt, über die die andere App den
aktuellen Vorratsschrank-Bestand lesen und fehlende Zutaten automatisch auf die Einkaufsliste schreiben kann,
so that ich kein eigenes Rezept-Modul in dieser App brauche, sondern die dafür vorgesehene, separate App
diesen Teil übernehmen kann, ohne dass beide Systeme Daten doppelt pflegen.

Depends on: #94 (Vorratsschrank muss existieren, um Bestand abzufragen), #90 (geteilte Einkaufsliste als
Schreibziel, inkl. "Quelle"-Datenmodell für automatische Einträge).

Einordnung: Folgt demselben Prinzip, das #94 bereits für die Phase-3-Wandgerät-Anbindung festlegt: keine
Implementierung der externen App selbst, aber eine stabile, dokumentierte Schnittstelle, die ein externes
Projekt ohne Rückfragen aufgreifen kann.

Acceptance criteria:

  • Lese-Endpunkt: aktueller Vorratsschrank-Bestand (Produktname, Menge, Soll-Menge, Barcode falls
    vorhanden) pro Vorratsschrank abrufbar.
  • Schreib-Endpunkt: fehlende Zutaten (Name + Menge) auf eine vorher verknüpfte Einkaufsliste schreiben;
    nutzt dieselbe "Quelle"-Kennzeichnung wie #90/#94 (z. B. Quelle: "rezept-app"), damit im UI erkennbar
    bleibt, woher ein Eintrag stammt — kein separater, nicht nachvollziehbarer Schreibpfad.
  • Authentifizierung: nutzt das bestehende Auth-Modell (z. B. persönlicher API-Key oder Token pro
    Nutzer), kein neues Zusatzsystem nur für diese Schnittstelle.
  • Dokumentation: OpenAPI-Spezifikation (nutzt die bestehende Swagger/OpenAPI-Generierung aus #51), plus
    ein kurzes eigenständiges Dokument (z. B. docs/api/recipe-integration.md) mit Beispiel-Requests, das die
    externe App direkt übernehmen kann, ohne diesen Code lesen zu müssen.
  • Kein UI für Rezepte/Essensplanung in dieser App — reine Schnittstelle für die externe App.

Out of scope for this story:

  • Die Rezept-/Essensplan-App selbst (eigenes, separates Projekt, nicht Teil dieses Repos).
  • Automatische Rezeptvorschläge basierend auf Bestand — das ist Logik der externen App, nicht dieser
    Schnittstelle.
  • Zwei-Wege-Sync (die externe App liest/schreibt hier; ein Rückkanal, der Rezeptdaten in diese App bringt, ist
    nicht angefragt).
# Story: Externe Schnittstelle für Rezept-/Essensplan-App (Vorratsschrank + Einkaufsliste) **As a** Nutzer, der eine separate Rezept-/Essensplan-App verwendet (eigenes, separates Projekt außerhalb dieses Repos), **I want to** dass diese App eine dokumentierte Schnittstelle bereitstellt, über die die andere App den aktuellen Vorratsschrank-Bestand lesen und fehlende Zutaten automatisch auf die Einkaufsliste schreiben kann, **so that** ich kein eigenes Rezept-Modul in dieser App brauche, sondern die dafür vorgesehene, separate App diesen Teil übernehmen kann, ohne dass beide Systeme Daten doppelt pflegen. **Depends on:** `#94` (Vorratsschrank muss existieren, um Bestand abzufragen), `#90` (geteilte Einkaufsliste als Schreibziel, inkl. "Quelle"-Datenmodell für automatische Einträge). **Einordnung:** Folgt demselben Prinzip, das `#94` bereits für die Phase-3-Wandgerät-Anbindung festlegt: keine Implementierung der externen App selbst, aber eine stabile, dokumentierte Schnittstelle, die ein externes Projekt ohne Rückfragen aufgreifen kann. **Acceptance criteria:** - [ ] **Lese-Endpunkt:** aktueller Vorratsschrank-Bestand (Produktname, Menge, Soll-Menge, Barcode falls vorhanden) pro Vorratsschrank abrufbar. - [ ] **Schreib-Endpunkt:** fehlende Zutaten (Name + Menge) auf eine vorher verknüpfte Einkaufsliste schreiben; nutzt dieselbe "Quelle"-Kennzeichnung wie `#90`/`#94` (z. B. Quelle: "rezept-app"), damit im UI erkennbar bleibt, woher ein Eintrag stammt — kein separater, nicht nachvollziehbarer Schreibpfad. - [ ] **Authentifizierung:** nutzt das bestehende Auth-Modell (z. B. persönlicher API-Key oder Token pro Nutzer), kein neues Zusatzsystem nur für diese Schnittstelle. - [ ] **Dokumentation:** OpenAPI-Spezifikation (nutzt die bestehende Swagger/OpenAPI-Generierung aus `#51`), plus ein kurzes eigenständiges Dokument (z. B. `docs/api/recipe-integration.md`) mit Beispiel-Requests, das die externe App direkt übernehmen kann, ohne diesen Code lesen zu müssen. - [ ] Kein UI für Rezepte/Essensplanung in dieser App — reine Schnittstelle für die externe App. **Out of scope for this story:** - Die Rezept-/Essensplan-App selbst (eigenes, separates Projekt, nicht Teil dieses Repos). - Automatische Rezeptvorschläge basierend auf Bestand — das ist Logik der externen App, nicht dieser Schnittstelle. - Zwei-Wege-Sync (die externe App liest/schreibt hier; ein Rückkanal, der Rezeptdaten in diese App bringt, ist nicht angefragt).
lena commented 2026-08-18 13:13:59 +02:00 (Migrated from git.butzei.de)

design (98_recipe_app_integration_interface_design.md)

Design: #98 — Externe Schnittstelle für Rezept-/Essensplan-App

Architect note (2026-08-10). Scope: a read endpoint (Vorratsschrank-Bestand) and a write endpoint
(fehlende Zutaten → Einkaufsliste), both authenticated via a personal API key instead of the existing
session-cookie model, plus the Settings UI to create/revoke that key. No recipe/meal-plan UI in this app,
per the story's own AC.

Auth mechanism — deliberately narrow, not a second global auth scheme

The naive approach — make GetCurrentUserIdQueryHandler accept an API key as a session-cookie
equivalent — would silently grant an API key holder access to every existing command/query in the app
(change password, delete account, every list mutation), since every one of them resolves "current user"
through that single handler. That's a much bigger blast radius than the story asks for, and turns any
leaked key into full account takeover instead of "read pantry stock / write a shopping list".

Instead: API-key auth is resolved by a new ApiKeyAuthenticationMiddleware that runs before
MapRequests' routes and is explicitly allowlisted to exactly the two recipe-integration routes
(/api/GetPantryStockForRecipeIntegrationQuery, /api/WriteMissingIngredientsToShoppingListCommand). For
any other path, an X-Api-Key header is ignored entirely — session cookie remains the only auth for
everything else, unchanged. GetCurrentUserIdQueryHandler gets a small fallback: if Session["UserId"]
is absent, it checks HttpContext.Items["ApiKeyUserId"] (set only by the middleware, only on allowlisted
routes) before returning null. This means the two new handlers reuse the existing
AuthorizePantryAccessForCurrentUserQuery / AuthorizeShoppingListAccessForCurrentUserQuery decorators
unchanged — an API key still only ever proves "this is user X", membership is still checked exactly like
today.

A leaked key therefore grants at most: read one pantry's stock, write items onto one shopping list — not
account takeover, not access to lists the key's owner isn't already a member of. CSRF does not apply
(the key must be explicitly supplied in a header — an ambient cookie can't be forced into a cross-site
request). CORS does not apply either — the recipe app calls this server-to-server, not from a browser
page on another origin; CORS is a browser enforcement mechanism, irrelevant to a server-to-server HTTP
call.

New entity: ApiKeyEntity

Structural sibling of PushSubscriptionEntity (personal, not shared, UserId FK with
OnDelete(DeleteBehavior.Cascade) — deleting the account deletes its keys automatically, no explicit
lookup-and-remove needed in DeleteCurrentUserAccountCommandHandler, same as push subscriptions today).

ApiKeyEntity
  Id            ApiKeyId (Vogen int, identity)
  UserId        UserId (FK, cascade)
  KeyHash       string (SHA-256 hex of the raw key — same pattern as PasswordResetTokenEntity/
                 EmailVerificationTokenEntity; the raw key is never stored, only returned once at
                 creation time)
  KeyPrefix     string (first 8 chars of the raw key, stored in the clear so the UI can show
                 "cqs_a1b2c3d4…" without ever re-displaying the secret)
  Label         string? (user-supplied, e.g. "Rezept-App")
  CreatedAtUtc  DateTimeOffset
  LastUsedAtUtc DateTimeOffset?

Raw key format: cqs_ + 32 random bytes as lowercase hex (same RandomNumberGenerator.GetBytes +
SHA256.HashData pattern already used by RequestPasswordResetCommandHandler/
CreateListInvitationCommandHandler). KeyHash gets a unique index for the middleware's lookup.

New session-authenticated commands/queries (Settings UI)

  • CreateApiKeyCommand(Label?)ApiKeyCreatedDto(Id, Label, KeyPrefix, CreatedAtUtc, Key)Key is
    the raw plaintext, present only in this one response, never retrievable again.
  • RevokeApiKeyCommand(ApiKeyId) → deletes the row; owner-only (must belong to the current user, checked
    via a new internal AuthorizeApiKeyOwnerAccessForCurrentUserQuery, same shape as every other
    AuthorizeXOwnerAccessQuery).
  • GetApiKeysForCurrentUserQuery()ApiKeyDto(Id, Label, KeyPrefix, CreatedAtUtc, LastUsedAtUtc)[] — no
    secret material.

All three: WithAuthorization(_ => new AuthorizeIsCurrentUserAuthenticatedQuery()), same as
SubscribeToPushCommand/GetPushSubscriptionStatusForCurrentUserQuery.

New API-key-authenticated commands/queries (recipe app)

  • GetPantryStockForRecipeIntegrationQuery(PantryId)RecipeIntegrationPantryStockItemDto(Name, Quantity, TargetQuantity, Barcode)[]. WithAuthorization(x => new AuthorizePantryAccessForCurrentUserQuery(x.PantryId)) — unchanged existing decorator; works
    identically whether "current user" came from session or from the API-key middleware.
  • WriteMissingIngredientsToShoppingListCommand(ShoppingListId, IReadOnlyCollection< RecipeIntegrationIngredientDto(Name, Quantity)>)ShoppingProductDto[]. WithAuthorization(x => new AuthorizeShoppingListAccessForCurrentUserQuery(x.ShoppingListId)). Per-item: find-or-create by
    case-insensitive name (same compose pattern as PantryLowStockShoppingWriter/
    AddOrActivateShoppingProductCommandHandler), activate with ActivatedVia: ShoppingProductInCartSource.RecipeApp (new enum member, = 3) so the UI can show provenance later if
    a future story wants that — out of scope here, per the AC's "Quelle"-Kennzeichnung requirement, the
    field alone satisfies "recognizable in the data model", no UI change required by this story.

Both are ordinary public record ... : IRequest<...> types, so MapRequests auto-maps them to
POST /api/{Name} like every other command/query — no special-cased routing beyond the middleware's
allowlist gate that decides which auth is accepted there.

Middleware placement

app.UseSession(...)app.UseSessionMaintenance()app.UseApiKeyAuthentication() (new,
allowlist-gated) → app.UseRateLimiter() → ... → app.MapRequests(...). A new rate-limiter policy
("recipe-integration", partitioned by the hashed key, not IP — a legitimate integration polling
regularly shouldn't share a budget with everyone behind the same NAT) is applied to both allowlisted
routes, mirroring the existing "login" policy's shape.

Security pre-review (self-review — /code-review's self-invocation gate and this session's lack of

interactive human sign-off both mean this is written up explicitly rather than run as a separate agent
pass, same practice this codebase has followed since #97/#99/#100)

  1. Key storage: only SHA-256 hash persisted, matching the existing token-hash precedent
    (PasswordResetTokenHash, EmailVerificationTokenHash) — no new pattern introduced.
  2. Blast radius on leak: bounded to exactly the two allowlisted routes and to lists/pantries the
    key's owner already has access to — see "Auth mechanism" above. This is the main design decision this
    review exists to validate, and it's the reason a route-allowlisted middleware was chosen over a
    session-fallback inside GetCurrentUserIdQueryHandler with no gate.
  3. Revocation: hard-delete, immediate effect (no cache to invalidate — the middleware looks up the DB
    on every request, same cost model as session cookie's per-request DB-backed lookup since #118).
  4. Rate limiting: new dedicated policy, see above — prevents both credential-stuffing against the
    key-hash lookup and runaway polling from a misbehaving integration.
  5. No plaintext ever logged: handlers log ApiKeyId/UserId, never the raw key or its hash.
  6. CSRF/CORS: not applicable, reasoned through above.
  7. Account deletion: cascade FK, verified against the existing PushSubscriptionEntity precedent
    already exercised by DeleteCurrentUserAccountCommandHandler today.
  8. Enumeration: GetPantryStockForRecipeIntegrationQuery/WriteMissingIngredientsToShoppingListCommand
    throw the same UnauthorizedAccessException → 401/403 shape as every other authorization failure in
    this codebase (no distinct "key valid but wrong pantry" vs. "key invalid" signal that would help an
    attacker enumerate valid pantry IDs).

Out of scope (per story)

No recipe/meal-plan UI. No two-way sync. OpenAPI documentation covered by the existing Swashbuckle
generation (#51) plus a new standalone docs/api/recipe-integration.md for the external app team to work
from without reading this repo's code.

**design** (`98_recipe_app_integration_interface_design.md`) # Design: `#98` — Externe Schnittstelle für Rezept-/Essensplan-App **Architect note (2026-08-10).** Scope: a read endpoint (Vorratsschrank-Bestand) and a write endpoint (fehlende Zutaten → Einkaufsliste), both authenticated via a personal API key instead of the existing session-cookie model, plus the Settings UI to create/revoke that key. No recipe/meal-plan UI in this app, per the story's own AC. ## Auth mechanism — deliberately narrow, not a second global auth scheme The naive approach — make `GetCurrentUserIdQueryHandler` accept an API key as a session-cookie equivalent — would silently grant an API key holder access to *every* existing command/query in the app (change password, delete account, every list mutation), since every one of them resolves "current user" through that single handler. That's a much bigger blast radius than the story asks for, and turns any leaked key into full account takeover instead of "read pantry stock / write a shopping list". Instead: API-key auth is resolved by a new `ApiKeyAuthenticationMiddleware` that runs before `MapRequests`' routes and is **explicitly allowlisted to exactly the two recipe-integration routes** (`/api/GetPantryStockForRecipeIntegrationQuery`, `/api/WriteMissingIngredientsToShoppingListCommand`). For any other path, an `X-Api-Key` header is ignored entirely — session cookie remains the only auth for everything else, unchanged. `GetCurrentUserIdQueryHandler` gets a small fallback: if `Session["UserId"]` is absent, it checks `HttpContext.Items["ApiKeyUserId"]` (set only by the middleware, only on allowlisted routes) before returning null. This means the two new handlers reuse the *existing* `AuthorizePantryAccessForCurrentUserQuery` / `AuthorizeShoppingListAccessForCurrentUserQuery` decorators unchanged — an API key still only ever proves "this is user X", membership is still checked exactly like today. A leaked key therefore grants at most: read one pantry's stock, write items onto one shopping list — not account takeover, not access to lists the key's owner isn't already a member of. CSRF does not apply (the key must be explicitly supplied in a header — an ambient cookie can't be forced into a cross-site request). CORS does not apply either — the recipe app calls this server-to-server, not from a browser page on another origin; CORS is a browser enforcement mechanism, irrelevant to a server-to-server HTTP call. ## New entity: `ApiKeyEntity` Structural sibling of `PushSubscriptionEntity` (personal, not shared, `UserId` FK with `OnDelete(DeleteBehavior.Cascade)` — deleting the account deletes its keys automatically, no explicit lookup-and-remove needed in `DeleteCurrentUserAccountCommandHandler`, same as push subscriptions today). ``` ApiKeyEntity Id ApiKeyId (Vogen int, identity) UserId UserId (FK, cascade) KeyHash string (SHA-256 hex of the raw key — same pattern as PasswordResetTokenEntity/ EmailVerificationTokenEntity; the raw key is never stored, only returned once at creation time) KeyPrefix string (first 8 chars of the raw key, stored in the clear so the UI can show "cqs_a1b2c3d4…" without ever re-displaying the secret) Label string? (user-supplied, e.g. "Rezept-App") CreatedAtUtc DateTimeOffset LastUsedAtUtc DateTimeOffset? ``` Raw key format: `cqs_` + 32 random bytes as lowercase hex (same `RandomNumberGenerator.GetBytes` + `SHA256.HashData` pattern already used by `RequestPasswordResetCommandHandler`/ `CreateListInvitationCommandHandler`). `KeyHash` gets a unique index for the middleware's lookup. ## New session-authenticated commands/queries (Settings UI) - `CreateApiKeyCommand(Label?)` → `ApiKeyCreatedDto(Id, Label, KeyPrefix, CreatedAtUtc, Key)` — `Key` is the raw plaintext, present **only** in this one response, never retrievable again. - `RevokeApiKeyCommand(ApiKeyId)` → deletes the row; owner-only (must belong to the current user, checked via a new internal `AuthorizeApiKeyOwnerAccessForCurrentUserQuery`, same shape as every other `AuthorizeXOwnerAccessQuery`). - `GetApiKeysForCurrentUserQuery()` → `ApiKeyDto(Id, Label, KeyPrefix, CreatedAtUtc, LastUsedAtUtc)[]` — no secret material. All three: `WithAuthorization(_ => new AuthorizeIsCurrentUserAuthenticatedQuery())`, same as `SubscribeToPushCommand`/`GetPushSubscriptionStatusForCurrentUserQuery`. ## New API-key-authenticated commands/queries (recipe app) - `GetPantryStockForRecipeIntegrationQuery(PantryId)` → `RecipeIntegrationPantryStockItemDto(Name, Quantity, TargetQuantity, Barcode)[]`. `WithAuthorization(x => new AuthorizePantryAccessForCurrentUserQuery(x.PantryId))` — unchanged existing decorator; works identically whether "current user" came from session or from the API-key middleware. - `WriteMissingIngredientsToShoppingListCommand(ShoppingListId, IReadOnlyCollection< RecipeIntegrationIngredientDto(Name, Quantity)>)` → `ShoppingProductDto[]`. `WithAuthorization(x => new AuthorizeShoppingListAccessForCurrentUserQuery(x.ShoppingListId))`. Per-item: find-or-create by case-insensitive name (same compose pattern as `PantryLowStockShoppingWriter`/ `AddOrActivateShoppingProductCommandHandler`), activate with `ActivatedVia: ShoppingProductInCartSource.RecipeApp` (new enum member, `= 3`) so the UI can show provenance later if a future story wants that — out of scope here, per the AC's "Quelle"-Kennzeichnung requirement, the field alone satisfies "recognizable in the data model", no UI change required by this story. Both are ordinary `public record ... : IRequest<...>` types, so `MapRequests` auto-maps them to `POST /api/{Name}` like every other command/query — no special-cased routing beyond the middleware's allowlist gate that decides *which auth* is accepted there. ## Middleware placement `app.UseSession(...)` → `app.UseSessionMaintenance()` → **`app.UseApiKeyAuthentication()`** (new, allowlist-gated) → `app.UseRateLimiter()` → ... → `app.MapRequests(...)`. A new rate-limiter policy (`"recipe-integration"`, partitioned by the hashed key, not IP — a legitimate integration polling regularly shouldn't share a budget with everyone behind the same NAT) is applied to both allowlisted routes, mirroring the existing `"login"` policy's shape. ## Security pre-review (self-review — `/code-review`'s self-invocation gate and this session's lack of interactive human sign-off both mean this is written up explicitly rather than run as a separate agent pass, same practice this codebase has followed since `#97`/`#99`/`#100`) 1. **Key storage**: only SHA-256 hash persisted, matching the existing token-hash precedent (`PasswordResetTokenHash`, `EmailVerificationTokenHash`) — no new pattern introduced. 2. **Blast radius on leak**: bounded to exactly the two allowlisted routes and to lists/pantries the key's owner already has access to — see "Auth mechanism" above. This is the main design decision this review exists to validate, and it's the reason a route-allowlisted middleware was chosen over a session-fallback inside `GetCurrentUserIdQueryHandler` with no gate. 3. **Revocation**: hard-delete, immediate effect (no cache to invalidate — the middleware looks up the DB on every request, same cost model as session cookie's per-request DB-backed lookup since `#118`). 4. **Rate limiting**: new dedicated policy, see above — prevents both credential-stuffing against the key-hash lookup and runaway polling from a misbehaving integration. 5. **No plaintext ever logged**: handlers log `ApiKeyId`/`UserId`, never the raw key or its hash. 6. **CSRF/CORS**: not applicable, reasoned through above. 7. **Account deletion**: cascade FK, verified against the existing `PushSubscriptionEntity` precedent already exercised by `DeleteCurrentUserAccountCommandHandler` today. 8. **Enumeration**: `GetPantryStockForRecipeIntegrationQuery`/`WriteMissingIngredientsToShoppingListCommand` throw the same `UnauthorizedAccessException` → 401/403 shape as every other authorization failure in this codebase (no distinct "key valid but wrong pantry" vs. "key invalid" signal that would help an attacker enumerate valid pantry IDs). ## Out of scope (per story) No recipe/meal-plan UI. No two-way sync. OpenAPI documentation covered by the existing Swashbuckle generation (`#51`) plus a new standalone `docs/api/recipe-integration.md` for the external app team to work from without reading this repo's code.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
robert/todo#97
No description provided.