#98 — Externe Schnittstelle für Rezept-/Essensplan-App (Vorratsschrank + Einkaufsliste) #97
Labels
No labels
priority/could
priority/must
priority/should
priority/wont
status/blocked
status/claimed
status/done-migrated
type/bug
type/feature
type/infra
type/tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/todo#97
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 alsSchreibziel, inkl. "Quelle"-Datenmodell für automatische Einträge).
Einordnung: Folgt demselben Prinzip, das
#94bereits für die Phase-3-Wandgerät-Anbindung festlegt: keineImplementierung der externen App selbst, aber eine stabile, dokumentierte Schnittstelle, die ein externes
Projekt ohne Rückfragen aufgreifen kann.
Acceptance criteria:
vorhanden) pro Vorratsschrank abrufbar.
nutzt dieselbe "Quelle"-Kennzeichnung wie
#90/#94(z. B. Quelle: "rezept-app"), damit im UI erkennbarbleibt, woher ein Eintrag stammt — kein separater, nicht nachvollziehbarer Schreibpfad.
Nutzer), kein neues Zusatzsystem nur für diese Schnittstelle.
#51), plusein kurzes eigenständiges Dokument (z. B.
docs/api/recipe-integration.md) mit Beispiel-Requests, das dieexterne App direkt übernehmen kann, ohne diesen Code lesen zu müssen.
Out of scope for this story:
Schnittstelle.
nicht angefragt).
design (
98_recipe_app_integration_interface_design.md)Design:
#98— Externe Schnittstelle für Rezept-/Essensplan-AppArchitect 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
GetCurrentUserIdQueryHandleraccept an API key as a session-cookieequivalent — 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
ApiKeyAuthenticationMiddlewarethat runs beforeMapRequests' routes and is explicitly allowlisted to exactly the two recipe-integration routes(
/api/GetPantryStockForRecipeIntegrationQuery,/api/WriteMissingIngredientsToShoppingListCommand). Forany other path, an
X-Api-Keyheader is ignored entirely — session cookie remains the only auth foreverything else, unchanged.
GetCurrentUserIdQueryHandlergets a small fallback: ifSession["UserId"]is absent, it checks
HttpContext.Items["ApiKeyUserId"](set only by the middleware, only on allowlistedroutes) before returning null. This means the two new handlers reuse the existing
AuthorizePantryAccessForCurrentUserQuery/AuthorizeShoppingListAccessForCurrentUserQuerydecoratorsunchanged — 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:
ApiKeyEntityStructural sibling of
PushSubscriptionEntity(personal, not shared,UserIdFK withOnDelete(DeleteBehavior.Cascade)— deleting the account deletes its keys automatically, no explicitlookup-and-remove needed in
DeleteCurrentUserAccountCommandHandler, same as push subscriptions today).Raw key format:
cqs_+ 32 random bytes as lowercase hex (sameRandomNumberGenerator.GetBytes+SHA256.HashDatapattern already used byRequestPasswordResetCommandHandler/CreateListInvitationCommandHandler).KeyHashgets a unique index for the middleware's lookup.New session-authenticated commands/queries (Settings UI)
CreateApiKeyCommand(Label?)→ApiKeyCreatedDto(Id, Label, KeyPrefix, CreatedAtUtc, Key)—Keyisthe raw plaintext, present only in this one response, never retrievable again.
RevokeApiKeyCommand(ApiKeyId)→ deletes the row; owner-only (must belong to the current user, checkedvia a new internal
AuthorizeApiKeyOwnerAccessForCurrentUserQuery, same shape as every otherAuthorizeXOwnerAccessQuery).GetApiKeysForCurrentUserQuery()→ApiKeyDto(Id, Label, KeyPrefix, CreatedAtUtc, LastUsedAtUtc)[]— nosecret material.
All three:
WithAuthorization(_ => new AuthorizeIsCurrentUserAuthenticatedQuery()), same asSubscribeToPushCommand/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; worksidentically 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 bycase-insensitive name (same compose pattern as
PantryLowStockShoppingWriter/AddOrActivateShoppingProductCommandHandler), activate withActivatedVia: ShoppingProductInCartSource.RecipeApp(new enum member,= 3) so the UI can show provenance later ifa 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, soMapRequestsauto-maps them toPOST /api/{Name}like every other command/query — no special-cased routing beyond the middleware'sallowlist 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 pollingregularly 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 ofinteractive 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)(
PasswordResetTokenHash,EmailVerificationTokenHash) — no new pattern introduced.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
GetCurrentUserIdQueryHandlerwith no gate.on every request, same cost model as session cookie's per-request DB-backed lookup since
#118).key-hash lookup and runaway polling from a misbehaving integration.
ApiKeyId/UserId, never the raw key or its hash.PushSubscriptionEntityprecedentalready exercised by
DeleteCurrentUserAccountCommandHandlertoday.GetPantryStockForRecipeIntegrationQuery/WriteMissingIngredientsToShoppingListCommandthrow the same
UnauthorizedAccessException→ 401/403 shape as every other authorization failure inthis 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 standalonedocs/api/recipe-integration.mdfor the external app team to workfrom without reading this repo's code.