openvibers/openvibe.food Commits

Food: recipe ideas from your cupboard (OpenVibe.AI food.suggest_recipes, costs from Food's own prices) (#27)

OP OpenVibers committed 12 Oct 2026 · 9aba1d9d1e · 20 files, +651 −44

* Food: recipe ideas from your cupboard (OpenVibe.AI food.suggest_recipes, costs from Food's own prices)

Food's recipes pillar: a signed-in person asks for recipe ideas from their cupboard. OpenVibe.AI's `food.suggest_recipes` (AI#57) answers, and Food works out the cost from its own food list.

- **Request:** the cupboard is sent by food name, together with up to 60 rows of Food's food list as numbered sources (staples first).
- **Validation:** an ingredient must be in the cupboard or cite a valid source, and a recipe left with no ingredients or steps is dropped.
- **Cost:** "still to buy" and its typical total come only from the food list's prices, never from the model. A food without a price is listed without one and left out of the total, which says so.
- **API:** `POST /api/v1/recipes/suggest` and `GET /api/v1/recipes`, for the person or for apps with `food.recipes.suggest` (Contracts 0.147.0). Limits and errors:
  - 10 requests a day;
  - `food.ai_off` when `OV_AI_INTERNAL_URL` is unset;
  - `food.pantry_empty` when the cupboard is empty;
  - AI errors are passed through, never a 500.
- **Storage:** migration 0004 keeps a person's last 20 suggestions, covered by account export and deletion.
- **Page `/recipes`:** one card per recipe, with ingredients beside the steps on wide screens. Cupboard items are marked, the "still to buy" box carries its typical total, and an AI safety note sits above the list. The page is linked from the cupboard and the nav.
  - Its styles are in `public/css/recipes.css`, loaded only there (layout `localStyles`), so the site-wide stylesheet stays within budget.
- **Dependency:** adds `openvibe-publishing` v1.4.0 for the AI client.

Tests: `test/recipes.test.js` (11 checks) and the account-data test; the full suite passes (19/19).

* openvibe-contracts v0.147.0 (was v0.143.0): the new capability is defined

Changes

ddiff --git a/.env.example b/.env.example
iindex 7ca1574..8d82d64 100644
---- a/.env.example
++++ b/.env.example
@@@ -21,6 +21,8 @@ OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_OAUTH_CLIENT_ID=food
OV_OAUTH_CLIENT_SECRET=
++# OpenVibe.AI on loopback, for recipe ideas from the cupboard (workflow food.suggest_recipes). Unset = recipe ideas are off.
++OV_AI_INTERNAL_URL=
# OV_OAUTH_REDIRECT_URI=https://openvibe.food/auth/callback
# FOOD_AUDIENCE=openvibe.food
ddiff --git a/README.md b/README.md
iindex a4329ac..1798dde 100644
---- a/README.md
++++ b/README.md
@@@ -2,8 +2,8 @@
> What should we eat?
--**Status:** the product works — food near you, a meal planner, the food database and the pantry are live and tested.
--Delivery comparison and recipes from the network's AI (the manifests' other pillars) are not built yet.
++**Status:** the product works — food near you, a meal planner, the food database, the pantry and recipe ideas from the network's AI are live and tested.
++Delivery comparison (the manifests' remaining pillar) is not built yet.
**Domain:** `openvibe.food` · **Port:** 4970 · **Service id:** `food` · **Env prefix:** `FOOD`
**License:** AGPL-3.0 (same as every OpenVibe service).
@@@ -23,8 +23,9 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
| Meal planner | [/plan](server/http/pages.js), [server/food/planner.js](server/food/planner.js) | One person, a couple or a group, 1–14 days: one item from each of the six USDA MyPlate food groups, cheapest per gram first, then the best calories a dollar, with a shopping list, the cheapest store per line and an estimated cost. Deterministic, and a budget is a hard cap |
| Food database | [/foods, /foods/:slug](server/http/pages.js), [server/food/foods.js](server/food/foods.js) | 42 items with nutrition per serving and per 100 g, the typical price band and the chain-by-chain comparison; server-rendered and in the sitemap |
| Pantry | [/pantry](server/http/pages.js), [server/food/pantry-match.js](server/food/pantry-match.js) | Signed in, keep what is in your cupboard and see which basic recipes you can make now, and what one more item would unlock. Deterministic; no model call |
++| Recipe ideas | [/recipes](server/http/pages.js), [server/food/recipes.js](server/food/recipes.js) | Signed in, ask OpenVibe.AI's `food.suggest_recipes` workflow for ideas from your pantry; Food validates the answer against its own food list and prices the to-buy items from its typical prices. Clearly labelled AI-suggested |
| The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | The only outbound calls this service makes: paced, cached, identified, bounded |
--| Store | [server/food/store.js](server/food/store.js), [migrations/](migrations/) | `food_geo_cache`, `food_plans`, `food_pantry` |
++| Store | [server/food/store.js](server/food/store.js), [migrations/](migrations/) | `food_geo_cache`, `food_plans`, `food_pantry`, `food_recipe_suggestions` |
| Platform | the skeleton | Sign-in with OpenVibe.Network, the OpenVibe Frame, limits, readiness, discovery, deploy files |
Everything is server-rendered and works without JavaScript: the search is a GET form, saving a plan and a cupboard is
@@@ -40,6 +41,8 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
- `food_pantry` (0002) — what one person says is in their cupboard, one row per food in the list.
- `account_data_events` (0003) — the receipts of the ADR-033 account export and deletion deliveries this service
applied, so a redelivered event changes nothing.
++- `food_recipe_suggestions` (0004) — a person's recipe ideas from OpenVibe.AI (the validated output plus Food's own
++ to-buy costs), keeping the last 20 per person.
0001 creates no tables. The service is the authority for these rows and for nothing else.
@@@ -60,24 +63,28 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
to (`OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`, `FOOD_AUDIENCE`).
- **OpenVibe.Events** — the two account subscriptions created at boot (`FOOD_EVENTS_URL` or `EVENTS_URL`,
`FOOD_EVENTS_SECRET`, `FOOD_EVENTS_ENDPOINT`).
++- **OpenVibe.AI** — the `food.suggest_recipes` workflow for recipe ideas from a pantry (`OV_AI_INTERNAL_URL`, plus
++ Food's OAuth client id and secret; the feature is off when `OV_AI_INTERNAL_URL` is unset).
- **Nominatim and Overpass** (OpenStreetMap, the only two hosts it fetches) — `FOOD_NOMINATIM_URL`,
`FOOD_OVERPASS_URL`, `FOOD_OSM_USER_AGENT`, `FOOD_OSM_MIN_INTERVAL_MS`, `FOOD_OSM_TIMEOUT_MS`,
`FOOD_GEO_CACHE_TTL_MS`.
- **PostgreSQL** (`DATABASE_URL`, `DATABASE_DIRECT_URL`) and **Valkey** for shared limit counters (`VALKEY_URL`,
`VALKEY_PREFIX`).
- **Packages**: `openvibe-contracts` v0.122.1, `openvibe-sdk` v0.44.0 (`db`, `auth`, `account-data`, `limits`,
-- `valkey`, `service`) and `openvibe-shared` v4.0.2 (`frame`, `legal`, `serve`, `release`, `metrics`, `ready`,
-- `seo`, `shell`, `cache-policy`, `showcase`, `app-icon`).
++ `valkey`, `service`), `openvibe-publishing` (`ai`) and `openvibe-shared` v4.0.2 (`frame`, `legal`, `serve`,
++ `release`, `metrics`, `ready`, `seo`, `shell`, `cache-policy`, `showcase`, `app-icon`).
## Capabilities
The service manifest (`food`, openvibe-contracts) lists four, and [server/http/principal.js](server/http/principal.js)
--declares the same four:
++declares the same four plus one proposed:
- `food.plan.read` — read the plans a caller saved
- `food.plan.write` — save a plan
- `food.pantry.read` — read a caller's pantry
- `food.pantry.write` — replace a caller's pantry
++- `food.recipes.suggest` — ask the network's AI for recipe ideas from a caller's pantry (proposed; not yet registered
++ in openvibe-contracts, so an app token cannot be granted it until the manifest ships)
An app, agent or service token needs the one a route names; a person acting for themself needs no capability. The
public routes (`/places`, `/foods`) name none. Food calls no capability on another service.
@@@ -94,15 +101,19 @@ public routes (`/places`, `/foods`) name none. Food calls no capability on anoth
| `GET /api/v1/plans`, `GET /api/v1/plans/:id` | signed in | your plans, newest first |
| `GET /api/v1/pantry` | signed in | your cupboard, with the "what can I make" suggestions |
| `PUT /api/v1/pantry` | signed in | replace it: `{ items: ["eggs-dozen", …] }` |
++| `POST /api/v1/recipes/suggest` | signed in | ask the AI for recipes from your pantry: `{ ask?, people? }` → 201 with the saved suggestion |
++| `GET /api/v1/recipes` | signed in | your recent recipe suggestions, newest first |
--Public reads need no token. A person's own data (a plan, a pantry) needs a signed-in caller; an app, agent or service
--token needs the route's capability (`food.plan.read|write`,
--`food.pantry.read|write` — [server/http/principal.js](server/http/principal.js)). A write made with the session cookie
--must come from `openvibe.food` itself. Errors are RFC 9457 `application/problem+json` with a stable `code`.
++Public reads need no token. A person's own data (a plan, a pantry, recipe ideas) needs a signed-in caller; an app,
++agent or service token needs the route's capability (`food.plan.read|write`,
++`food.pantry.read|write`, `food.recipes.suggest` — [server/http/principal.js](server/http/principal.js)). A write made
++with the session cookie must come from `openvibe.food` itself. Errors are RFC 9457 `application/problem+json` with a
++stable `code`. With `OV_AI_INTERNAL_URL` unset, asking for recipes answers 503 `food.ai_off`.
**Per-caller limits** ([server/http/caller-limits.js](server/http/caller-limits.js)): reads take `FOOD_LIMITS_MINUTE` /
`FOOD_LIMITS_HOUR`; the place search, which spends OpenStreetMap's shared request budget, has its own tighter numbers,
--as do saving a plan and replacing a pantry.
++as do saving a plan and replacing a pantry. Recipe suggestions are capped at 10 a person a day (429
++`food.recipes_daily_limit`).
## Data sources and their terms
@@@ -133,11 +144,17 @@ The nutrition figures are the source's, not analysed here, and are not dietary a
**Recipes** — ten simple combinations written for this service in plain words, using only items from the food list.
No copied recipe text. They are labelled "basic suggestions" wherever they appear.
++**Recipe ideas** — a signed-in person can ask OpenVibe.AI's `food.suggest_recipes` workflow for ideas from their
++pantry. Food sends the pantry and the rows of its own food list as sources, validates what comes back (an ingredient
++must be from the pantry or cite one of those sources), and prices only the to-buy items from its own typical prices —
++never from anything the AI said. The page says plainly the recipes were suggested by AI and should be checked.
++
## Configuration
See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET` (the `food` OAuth client on the
--Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. `FOOD_NOMINATIM_URL` and `FOOD_OVERPASS_URL` default
--to the public OSM hosts; tests point them at a stand-in and never reach the internet.
++Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. `OV_AI_INTERNAL_URL` (the internal OpenVibe.AI URL)
++turns the recipe ideas feature on; unset, the recipe routes answer 503 `food.ai_off`. `FOOD_NOMINATIM_URL` and
++`FOOD_OVERPASS_URL` default to the public OSM hosts; tests point them at a stand-in and never reach the internet.
## Development
@@@ -163,6 +180,9 @@ point at a stand-in ([test/helpers/osm.js](test/helpers/osm.js)). The main files
- [test/foods.test.js](test/foods.test.js) — the food list, the per-100 g arithmetic and its pages and sitemap entries.
- [test/pantry.test.js](test/pantry.test.js) — the pantry kept per account, the deterministic "what can I make", and
anonymous callers refused.
++- [test/recipes.test.js](test/recipes.test.js) — the AI recipe request and output validation, Food's own to-buy cost,
++ the last-20 cap, the daily limit, the AI-off and empty-pantry problems, AiRunError pass-through, the capability
++ check, and the page signed in and out.
- [test/account-data.test.js](test/account-data.test.js) — ADR-033 export and deletion through `/internal/events`,
applied once and carrying only that person's rows, with a bad signature refused.
- [test/caller-limits.test.js](test/caller-limits.test.js), [test/auth-jwks.test.js](test/auth-jwks.test.js),
@@@ -187,13 +207,13 @@ point at a stand-in ([test/helpers/osm.js](test/helpers/osm.js)). The main files
A person's account at OpenVibe.Network can be exported and deleted, and every service holding their rows answers its
part (ADR-033). Food receives `network.account.export_requested` and `network.account.deleted` at `POST /internal/events`
--(loopback only) — the two tables are mapped in [server/identity/account-data.js](server/identity/account-data.js), and
++(loopback only) — the three tables are mapped in [server/identity/account-data.js](server/identity/account-data.js), and
the boot-time subscriptions are created by [server/events-consumer.js](server/events-consumer.js):
--- **Exported:** the plans a person saved (`plans.json`) and their pantry (`pantry.json`), pushed to
-- `POST /internal/account-exports/:id/parts` with this service's own token. Nothing here is a secret — Food stores no
-- token, key or credential.
--- **Erased:** both tables hold the person's own rows and nothing anyone else's page hangs under them, so they are
++- **Exported:** the plans a person saved (`plans.json`), their pantry (`pantry.json`) and their recipe suggestions
++ (`recipe-suggestions.json`), pushed to `POST /internal/account-exports/:id/parts` with this service's own token.
++ Nothing here is a secret — Food stores no token, key or credential.
++- **Erased:** all three tables hold the person's own rows and nothing anyone else's page hangs under them, so they are
deleted whole and nothing is kept. Food then confirms with `POST /internal/account-deletions/:id/confirmations` and
the counts.
- **Anonymized:** nothing. There is no row Food keeps that was written by this person for another person to read.
@@@ -212,16 +232,17 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
- Session tokens are httpOnly cookies; a FedCM assertion or an app or service token is never a session.
- Secrets live only in the env file; only environment variable names appear in code and docs, and no secret is logged.
- Request bodies are never logged.
--- The only two hosts this service fetches are the OSM ones named in the configuration: a URL built from that base, and
-- nothing a caller sends. A place name is data in a query string, never a URL.
--- Nothing a person keeps here (a plan, a pantry) is public, cached or crawled.
++- The only hosts this service fetches are the OSM ones and OpenVibe.AI named in the configuration: a URL built from
++ those bases, and nothing a caller sends. A place name is data in a query string, never a URL.
++- Nothing a person keeps here (a plan, a pantry, recipe ideas) is public, cached or crawled.
---
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.143.0
++- openvibe-contracts: v0.147.0
- openvibe-sdk: v0.44.0
- openvibe-shared: v4.0.2
++- openvibe-publishing: v1.4.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex da6758e..ac44086 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,11 +7,11 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "4970",
"domain": "openvibe.food",
-- "contracts": "openvibe-contracts v0.143.0",
++ "contracts": "openvibe-contracts v0.147.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.0.2",
"packages": [
-- "openvibe-contracts v0.143.0",
++ "openvibe-contracts v0.147.0",
"openvibe-sdk v0.44.0",
"openvibe-shared v4.0.2"
],
@@@ -53,5 +53,5 @@
"deploy files"
],
"deployment": "openvibe-ovh: unit openvibe-food.service on 127.0.0.1:4970 (git checkout /opt/openvibe.food, env /etc/openvibe/food.env, database ov_food), deployed with `sudo ovhost deploy food`; public at https://openvibe.food since 2026-10-08.",
-- "updated": "2026-10-11"
++ "updated": "2026-10-12"
}
ddiff --git a/migrations/0004_recipe_suggestions.sql b/migrations/0004_recipe_suggestions.sql
nnew file mode 100644
iindex 0000000..43581e6
---- /dev/null
++++ b/migrations/0004_recipe_suggestions.sql
@@@ -0,0 +1,15 @@
++-- phase: expand
++-- OpenVibe.Food: recipe ideas a person asked OpenVibe.AI for from their pantry (workflow food.suggest_recipes,
++-- server/food/recipes.js). The row holds the validated output plus Food's own to-buy costs, and Food keeps the last
++-- 20 per person. Nothing here is ever a credential, a key or a token.
++
++CREATE TABLE food_recipe_suggestions (
++ id text COLLATE "C" PRIMARY KEY, -- rcp_<ULID>
++ subject_id text COLLATE "C" NOT NULL, -- user:usr_…
++ created_at text COLLATE "C" NOT NULL, -- ISO 8601, UTC
++ ask text, -- what the person felt like (at most 500 chars)
++ people integer, -- how many to feed (1–12), when given
++ ai_run_id text, -- the OpenVibe.AI run id, for the authorship record
++ recipes jsonb NOT NULL -- the validated recipes, with to_buy and to_buy_cost
++);
++CREATE INDEX food_recipe_suggestions_by_subject ON food_recipe_suggestions (subject_id, id DESC);
ddiff --git a/package-lock.json b/package-lock.json
iindex 0155930..06df724 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,8 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.147.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.2",
"pg": "^8.23.0"
@@@ -871,9 +872,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.143.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
-- "integrity": "sha512-Cdbf+9RoPAUBibpM2zXQvtsiKMugb55dKubTt8pFZvAmxsLtGvX+zaC++yL1vOSub8FX3+GgqcGtoJS6tklrUg==",
++ "version": "0.147.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.147.0",
++ "integrity": "sha512-7JC5c7WWNJWUDGQamrQZy7bsZhlhUfXXKhI2lCEG3PW99B73vks42tKYnq56j+YYRItzotYVDFt4hjzqUS3tPA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -886,6 +887,24 @@
"node": ">=20"
}
},
++ "node_modules/openvibe-publishing": {
++ "version": "1.4.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
++ "integrity": "sha512-9vByyVqpvy2zVQI4PrJWk5QqCym/YQ/fXZDgrcloKGBqvukKrUn7fg4ZERiJHcgEq+rgDbxbU4rNflZhw62K/Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "openvibe-sdk": ">=0.15.0",
++ "openvibe-shared": ">=1.5.0"
++ },
++ "peerDependenciesMeta": {
++ "openvibe-shared": {
++ "optional": true
++ }
++ }
++ },
"node_modules/openvibe-sdk": {
"version": "0.44.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
ddiff --git a/package.json b/package.json
iindex 256d53c..2b8eaad 100644
---- a/package.json
++++ b/package.json
@@@ -22,7 +22,8 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.147.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.2",
"pg": "^8.23.0"
ddiff --git a/public/css/app.css b/public/css/app.css
iindex 9018c76..3784bab 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -69,3 +69,4 @@ button.sc-btn.sc-primary { border: 0; }
.food-search-title { margin: 0; font-size: 1.05rem; font-weight: 750; color: var(--text-primary, #e6edf7); }
.food-search-panel > .sc-btn { width: 100%; justify-content: center; }
.food-search-note { margin: -4px 0 0; font-size: .8rem; color: var(--text-muted, #7386a3); text-align: center; }
++
ddiff --git a/public/css/recipes.css b/public/css/recipes.css
nnew file mode 100644
iindex 0000000..0cbc999
---- /dev/null
++++ b/public/css/recipes.css
@@@ -0,0 +1,26 @@
++/* OpenVibe.Food — recipe ideas (/recipes) only, so the site-wide stylesheet stays inside its budget. */
++.recipe-form { align-items: end; }
++.recipe-field { display: flex; flex-direction: column; gap: 4px; }
++.recipe-field-ask { flex: 1 1 320px; }
++.recipe-field-ask input { width: 100%; }
++.recipe-suggestion { margin: 18px 0 26px; }
++.recipe-asked { margin: 0 0 8px; }
++.recipe-grid { display: grid; gap: 14px; }
++.recipe { border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); padding: 16px 18px; }
++.recipe-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px; border-bottom: 1px solid var(--border, #1f2d47); padding-bottom: 8px; }
++.recipe-head h3 { margin: 0; font-size: 1.15rem; }
++.recipe-meta { margin: 0; font-size: .85rem; color: var(--text-secondary, #96a7c2); }
++.recipe h4 { margin: 12px 0 6px; font-size: .8rem; letter-spacing: .06em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }
++.recipe-body { display: grid; gap: 4px 24px; }
++@media (min-width: 760px) { .recipe-body { grid-template-columns: minmax(220px, 2fr) 3fr; } }
++.recipe-ingredients ul { list-style: none; padding: 0; margin: 0; }
++.recipe-ingredients li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 8px; padding: 5px 0; border-bottom: 1px dashed var(--border, #1f2d47); }
++.recipe-steps ol { margin: 0; padding-left: 1.3rem; }
++.recipe-steps li { margin: 0 0 6px; }
++.recipe-buy { margin-top: 14px; border-radius: 10px; background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); padding: 10px 12px; }
++.recipe-buy h4 { margin-top: 0; }
++.recipe-buy ul { list-style: none; padding: 0; margin: 0; }
++.recipe-buy li, .recipe-total { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; }
++.recipe-total { margin: 6px 0 2px; border-top: 1px solid var(--border-light, #2c3d5c); padding-top: 6px; }
++.recipe-buy-none { color: var(--success, #22c55e); }
++.recipe-notes { margin-top: 10px; color: var(--text-secondary, #96a7c2); }
ddiff --git a/server/app.js b/server/app.js
iindex d260776..22f0613 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -35,6 +35,7 @@ const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const accountDataLib = require('./identity/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
++const { createAiClient } = require('openvibe-publishing/ai');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
@@@ -75,6 +76,14 @@ async function createApp(opts = {}) {
ctx.accountData = accountData;
ctx.accountSend = accountSend;
++ // OpenVibe.AI (recipes from the pantry, server/food/recipes.js). Off when OV_AI_INTERNAL_URL is unset: the
++ // recipes API answers 503 food.ai_off. A test injects a stand-in through opts.ai so it never reaches the network.
++ const aiTokenClient = config.aiUrl && config.oauth.clientSecret ? contracts.serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.ai', scope: 'ai.run.create ai.run.read', fetchImpl,
++ }) : null;
++ ctx.ai = opts.ai !== undefined ? opts.ai : createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokenClient, fetch: fetchImpl });
++
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
ddiff --git a/server/config.js b/server/config.js
iindex f2444c7..6701dc9 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -69,6 +69,11 @@ function load(env = process.env) {
},
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
++ // OpenVibe.AI → Food (recipes from the pantry). Unset means the feature is off: POST /api/v1/recipes/suggest
++ // answers 503 food.ai_off. When set, Food calls the food.suggest_recipes workflow with its own service token
++ // (server/food/recipes.js).
++ aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
++
// OpenVibe.Events → this service (server/events-consumer.js). The secret signs a delivery (comma-separated
// for rotation, 32+ characters each); unset turns POST /internal/events off (503). The url is where the
// network.account.export_requested and network.account.deleted subscriptions are created at boot, off when
ddiff --git a/server/food/recipes.js b/server/food/recipes.js
iindex bbb1669..90843c2 100644
---- a/server/food/recipes.js
++++ b/server/food/recipes.js
@@@ -8,6 +8,8 @@
* These are deliberately simple combinations and the pantry page labels them as such. They are not dietary advice.
*/
++const { FOODS, getFood, round } = require('./foods');
++
const RECIPES = [
{
slug: 'beans-and-rice', name: 'Beans and rice', serves: 2, minutes: 25,
@@@ -73,4 +75,107 @@ const RECIPES = [
const bySlug = new Map(RECIPES.map((r) => [r.slug, r]));
--module.exports = { RECIPES, RECIPE_BY_SLUG: bySlug };
++// ── Recipes from the network's AI (OpenVibe.AI workflow food.suggest_recipes) ──
++// The pantry match above is deterministic and offline. This half builds the request to OpenVibe.AI from a person's
++// saved pantry and Food's own food list, then validates what comes back strictly: an ingredient must be in the pantry
++// or cite one of the sources Food sent, and a recipe with nothing left to make is dropped. Costs are computed here
++// from the food list's typical prices, never from anything the model said.
++const COST_NOTE = 'Cost is an estimate from typical 2025 US (Washington) prices carried over from OpenVibe.Tools, not a live price.';
++const MAX_SOURCES = 60;
++const MAX_PANTRY = 60;
++const MAX_ASK = 500;
++
++/** The rows of Food's own food list the AI may cite, staples first, at most 60. */
++function buildSources() {
++ return [...FOODS]
++ .sort((a, b) => (Number(b.shelf_stable) - Number(a.shelf_stable)) || (a.name < b.name ? -1 : 1))
++ .slice(0, MAX_SOURCES)
++ .map((f) => ({
++ source_type: 'food.food',
++ source_id: f.slug,
++ title: f.name,
++ content: JSON.stringify({ name: f.name, unit: f.serving_size, price: f.price, price_max: f.price_max, price_band_label: f.band_label }),
++ }));
++}
++
++function buildRequest({ pantry = [], ask = null, people = null } = {}) {
++ return {
++ pantry: pantry.slice(0, MAX_PANTRY).map((x) => String(x).slice(0, 120)),
++ ...(ask ? { ask: String(ask).slice(0, MAX_ASK) } : {}),
++ ...(people != null ? { people } : {}),
++ sources: buildSources(),
++ };
++}
++
++/**
++ * Validate the workflow output against the sources Food sent and the person's pantry. Citations are indexes into the
++ * `sources` array; an ingredient that neither is from the pantry nor cites a valid source is dropped, and a recipe
++ * left with no ingredients or no steps is dropped. Each kept recipe gains Food's own `to_buy` and `to_buy_cost`.
++ */
++function validateSuggestions(output, { sources = [], pantry = [] } = {}) {
++ const pantrySet = new Set(pantry);
++ const src = Array.isArray(sources) ? sources : [];
++ const out = output && typeof output === 'object' ? output : {};
++ const recipes = [];
++ for (const r of (Array.isArray(out.recipes) ? out.recipes : []).slice(0, 3)) {
++ if (!r || typeof r !== 'object') continue;
++ const ingredients = [];
++ for (const ing of (Array.isArray(r.ingredients) ? r.ingredients : [])) {
++ if (!ing || typeof ing !== 'object') continue;
++ const fromPantry = ing.from_pantry === true;
++ const citations = Array.isArray(ing.citations)
++ ? ing.citations.filter((c) => Number.isInteger(c) && c >= 0 && c < src.length)
++ : [];
++ if (!fromPantry && !citations.length) continue;
++ ingredients.push({ name: typeof ing.name === 'string' ? ing.name : '', amount: ing.amount == null ? null : ing.amount, from_pantry: fromPantry, citations });
++ }
++ const steps = Array.isArray(r.steps) ? r.steps.map((s) => (typeof s === 'string' ? s.trim() : '')).filter(Boolean) : [];
++ if (!ingredients.length || !steps.length) continue;
++
++ const toBuy = new Map();
++ for (const ing of ingredients) {
++ if (ing.from_pantry) continue;
++ for (const c of ing.citations) {
++ const slug = src[c] && src[c].source_id;
++ if (!slug || pantrySet.has(slug)) continue;
++ const food = getFood(slug);
++ if (!food) continue;
++ toBuy.set(slug, { slug, name: food.name, price: food.price == null ? null : round(food.price, 2) });
++ }
++ }
++ const toBuyList = [...toBuy.values()].sort((a, b) => (a.name < b.name ? -1 : 1));
++
++ recipes.push({
++ title: typeof r.title === 'string' && r.title.trim() ? r.title.trim() : 'Recipe',
++ serves: r.serves,
++ minutes: r.minutes,
++ ingredients,
++ steps,
++ notes: r.notes == null ? null : (typeof r.notes === 'string' ? r.notes : String(r.notes)),
++ to_buy: toBuyList,
++ // A food with no typical price is listed without one and left out of the total, which then says so.
++ to_buy_cost: round(toBuyList.reduce((n, x) => n + (x.price || 0), 0), 2),
++ to_buy_cost_partial: toBuyList.some((x) => x.price == null),
++ cost_note: COST_NOTE,
++ });
++ }
++ return {
++ recipes,
++ citations: Array.isArray(out.citations) ? out.citations.filter((c) => Number.isInteger(c) && c >= 0 && c < src.length) : [],
++ gaps: Array.isArray(out.gaps) ? out.gaps : [],
++ };
++}
++
++/** Call the workflow and return its run id plus Food's validated, costed output. */
++async function suggestRecipes(ai, { pantry = [], ask = null, people = null, subject } = {}) {
++ // The model reads the pantry by name; the slugs stay for matching what is already in the cupboard.
++ const input = buildRequest({ pantry: pantry.map((slug) => (getFood(slug) || {}).name || slug), ask, people });
++ const run = await ai.run('food.suggest_recipes', input, {
++ onBehalfOf: { type: 'user', id: subject },
++ target: { service: 'food', type: 'pantry', id: subject },
++ });
++ const out = validateSuggestions(run && run.output, { sources: input.sources, pantry });
++ return { runId: run && run.runId, recipes: out.recipes, citations: out.citations, gaps: out.gaps };
++}
++
++module.exports = { RECIPES, RECIPE_BY_SLUG: bySlug, COST_NOTE, MAX_SOURCES, buildSources, buildRequest, validateSuggestions, suggestRecipes };
ddiff --git a/server/food/store.js b/server/food/store.js
iindex 93d223b..f7a97ba 100644
---- a/server/food/store.js
++++ b/server/food/store.js
@@@ -7,6 +7,7 @@
*/
const PLAN_LIST_LIMIT = 50;
++const RECIPE_SUGGESTION_LIST_LIMIT = 20;
const parse = (v) => (v == null ? null : typeof v === 'string' ? JSON.parse(v) : v);
@@@ -74,8 +75,33 @@ async function setPantry(s, owner, items) {
return items.slice().sort();
}
++// ── food_recipe_suggestions ─────────────────────────────────
++async function insertRecipeSuggestion(s, r) {
++ await s.db.query(
++ 'INSERT INTO food_recipe_suggestions (id, subject_id, created_at, ask, people, ai_run_id, recipes) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)',
++ [r.id, r.subject_id, r.created_at, r.ask || null, r.people == null ? null : r.people, r.ai_run_id || null, JSON.stringify(r.recipes)]);
++ // Food keeps the last 20 suggestions per person; the oldest drop out.
++ await s.db.exec(
++ 'DELETE FROM food_recipe_suggestions WHERE subject_id = $1 AND id NOT IN (SELECT id FROM food_recipe_suggestions WHERE subject_id = $1 ORDER BY id DESC LIMIT $2)',
++ [r.subject_id, RECIPE_SUGGESTION_LIST_LIMIT]);
++ return recipeSuggestionRow(s, r.subject_id, r.id);
++}
++const recipeSuggestionRow = (s, subjectId, id) => s.db.maybe('SELECT * FROM food_recipe_suggestions WHERE subject_id = $1 AND id = $2', [subjectId, id]);
++async function listRecipeSuggestions(s, subjectId, { limit = 20 } = {}) {
++ return s.db.many('SELECT * FROM food_recipe_suggestions WHERE subject_id = $1 ORDER BY id DESC LIMIT $2', [subjectId, Math.min(limit, RECIPE_SUGGESTION_LIST_LIMIT)]);
++}
++/** The number of suggestions a person made on or after `since` (an ISO date, for the daily cap). */
++const countRecipeSuggestionsSince = (s, subjectId, since) => s.db.value('SELECT count(*)::int FROM food_recipe_suggestions WHERE subject_id = $1 AND created_at >= $2', [subjectId, since]);
++
++const recipeSuggestionToWire = (row) => ({
++ id: row.id, created_at: row.created_at, ask: row.ask || null,
++ people: row.people == null ? null : Number(row.people), ai_run_id: row.ai_run_id || null,
++ recipes: parse(row.recipes),
++});
++
module.exports = {
cacheGet, cachePut, cachePrune, startCachePrune,
insertPlan, planRow, listPlans, deletePlan, planToWire,
getPantry, pantryRows, setPantry,
++ insertRecipeSuggestion, recipeSuggestionRow, listRecipeSuggestions, countRecipeSuggestionsSince, recipeSuggestionToWire,
};
ddiff --git a/server/http/api.js b/server/http/api.js
iindex ed4e009..4ccff7b 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -24,6 +24,7 @@ const foods = require('../food/foods');
const { planMeals } = require('../food/planner');
const { searchPlaces } = require('../food/places');
const { matchPantry } = require('../food/pantry-match');
++const { suggestRecipes } = require('../food/recipes');
const store = require('../food/store');
const PLAN_ID = /^pln_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -147,6 +148,48 @@ function createApi(ctx) {
res.json(pantryWire(await store.setPantry(s, req.principal.requester, items)));
});
++ // ── Recipe ideas from the network's AI (a person's own) ──
++ const recipesDailyLimit = async (req, res, next) => {
++ const today = new Date(s.now()).toISOString().slice(0, 10);
++ const made = await store.countRecipeSuggestionsSince(s, req.principal.requester, today);
++ if (made >= 10) {
++ res.set('Retry-After', String(86400 - (Math.floor(s.now() / 1000) % 86400)));
++ return fail(req, res, 429, 'food.recipes_daily_limit', 'At most 10 recipe suggestions a day.');
++ }
++ return next();
++ };
++
++ r.post('/recipes/suggest', principal.requireCapability('food.recipes.suggest'), recipesDailyLimit, async (req, res) => {
++ if (!config.aiUrl) return fail(req, res, 503, 'food.ai_off', 'Recipe ideas are not available: OpenVibe.AI is not configured for this service.');
++ const b = req.body || {};
++ const ask = typeof b.ask === 'string' && b.ask.trim() ? b.ask.trim().slice(0, 500) : null;
++ let people = null;
++ if (b.people != null && b.people !== '') {
++ people = Number(b.people);
++ if (!Number.isInteger(people) || people < 1 || people > 12) return fail(req, res, 422, 'food.recipes.invalid', 'people: a whole number from 1 to 12');
++ }
++ const owner = req.principal.requester;
++ const pantry = await store.getPantry(s, owner);
++ if (!pantry.length) return fail(req, res, 422, 'food.pantry_empty', 'Your pantry is empty; add what you have before asking for recipe ideas.');
++ let suggestion;
++ try {
++ suggestion = await suggestRecipes(ctx.ai, { pantry, ask, people, subject: owner.replace(/^user:/, '') });
++ } catch (err) {
++ if (err && err.name === 'AiRunError') return fail(req, res, err.status || 502, err.code || 'ai.run_failed', err.message);
++ throw err;
++ }
++ const row = await store.insertRecipeSuggestion(s, {
++ id: s.newId('rcp'), subject_id: owner, created_at: s.iso(),
++ ask, people, ai_run_id: suggestion.runId, recipes: suggestion.recipes,
++ });
++ return res.status(201).json(store.recipeSuggestionToWire(row));
++ });
++
++ r.get('/recipes', principal.requireCapability('food.recipes.suggest'), limits.reads('food.recipes.read'), async (req, res) => {
++ const rows = await store.listRecipeSuggestions(s, req.principal.requester, { limit: 20 });
++ res.json({ recipes: rows.map(store.recipeSuggestionToWire) });
++ });
++
return r;
}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 78a3862..daf25c2 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -23,11 +23,13 @@ const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
const { html, raw, table, notice, time, badge } = require('../render/html');
++const { markdown } = require('../render/markdown');
const { send } = require('../render/layout');
const foods = require('../food/foods');
const { planMeals } = require('../food/planner');
const { searchPlaces, safeUrl, ATTRIBUTION } = require('../food/places');
const { matchPantry } = require('../food/pantry-match');
++const { suggestRecipes } = require('../food/recipes');
const store = require('../food/store');
const SITE_NAME = 'OpenVibe.Food';
@@@ -358,6 +360,7 @@ ${foods.FOODS.filter((f) => f.group === id).map((f) => html`<label class="pantry
body: html`<h1>Your cupboard</h1>
<p class="lede">Tick what you have. Nothing leaves your account; the matching is done here, not by a model.</p>
${saved ? notice('Cupboard saved.', 'ok') : ''}
++<p><a href="/recipes">Get recipe ideas</a></p>
<form method="post" action="/pantry" class="pantry-form">
${groupsBlock}
<div class="pantry-actions"><button class="sc-btn sc-primary" type="submit">Save cupboard</button><span class="muted small">${items.length} of ${foods.FOODS.length} items</span></div>
@@@ -384,6 +387,98 @@ ${match.rest.length ? html`<h3>Further off</h3><ul>${match.rest.slice(0, 6).map(
return res.redirect(303, '/pantry?saved=1');
});
++ // ── Recipe ideas from the network's AI ───────────────────
++ function recipeBlock(r) {
++ const meta = [r.serves != null ? `Serves ${r.serves}` : null, r.minutes != null ? `About ${r.minutes} min` : null].filter(Boolean);
++ return html`<article class="recipe">
++<header class="recipe-head"><h3>${r.title}</h3>${meta.length ? html`<p class="recipe-meta">${meta.join(' · ')}</p>` : ''}</header>
++<div class="recipe-body">
++<section class="recipe-ingredients" aria-label="Ingredients"><h4>Ingredients</h4>
++<ul>${r.ingredients.map((i) => html`<li><span>${i.name}${i.amount ? html` <span class="muted">${i.amount}</span>` : ''}</span>${i.from_pantry ? html` <span class="badge ok">in your cupboard</span>` : ''}</li>`)}</ul></section>
++<section class="recipe-steps" aria-label="Steps"><h4>Steps</h4>
++<ol>${r.steps.map((st) => html`<li>${st}</li>`)}</ol></section>
++</div>
++${r.to_buy && r.to_buy.length ? html`<div class="recipe-buy"><h4>Still to buy</h4>
++<ul>${r.to_buy.map((x) => html`<li><span>${x.name}</span><span>${x.price == null ? 'no typical price' : money(x.price)}</span></li>`)}</ul>
++<p class="recipe-total"><span>Typical total${r.to_buy_cost_partial ? ' (items with a price)' : ''}</span><strong>${money(r.to_buy_cost)}</strong></p>
++<p class="muted small">${r.cost_note}</p></div>` : html`<p class="recipe-buy recipe-buy-none">Everything this recipe needs is already in your cupboard.</p>`}
++${r.notes ? html`<div class="recipe-notes small">${raw(markdown(r.notes))}</div>` : ''}
++</article>`;
++ }
++
++ async function recipesPage(req, res, { saved = false, error = null } = {}) {
++ if (!signedIn(req)) {
++ return page(req, res, {
++ title: 'Recipe ideas', crumbs: [{ label: 'Recipe ideas' }], styles: [showcase.STYLESHEET], localStyles: ['css/recipes.css'],
++ body: html`<h1>Recipe ideas</h1>
++<p class="lede">Tell OpenVibe.Food what you feel like and how many people you are feeding. It suggests up to three recipes from the food list, marks what is already in your cupboard and lists what you would still need to buy, with a typical cost.</p>
++<p><a class="sc-btn sc-primary" href="/auth/login?next=%2Frecipes">Sign in to get recipe ideas</a></p>`,
++ });
++ }
++ const rows = (await store.listRecipeSuggestions(s, ownerOf(req), { limit: 20 })).map(store.recipeSuggestionToWire);
++ const problems = {
++ 'food.pantry_empty': 'Add what you have to your cupboard first — there is nothing to suggest recipes from.',
++ 'food.ai_off': 'Recipe ideas are not available right now.',
++ 'food.recipes.invalid': 'That request did not make sense. Try again.',
++ 'food.recipes_daily_limit': 'You have reached today\'s recipe idea limit. Come back tomorrow.',
++ };
++ page(req, res, {
++ title: 'Recipe ideas', crumbs: [{ label: 'Recipe ideas' }], styles: [showcase.STYLESHEET], localStyles: ['css/recipes.css'],
++ body: html`<h1>Recipe ideas</h1>
++<p class="lede">What do you feel like, and for how many people? OpenVibe.AI suggests up to three recipes from the food list, and OpenVibe.Food works out what you would still need to buy from its own typical prices.</p>
++${saved ? notice('Recipe ideas saved below.', 'ok') : ''}
++${error ? notice(problems[error] || 'That did not work. Try again.', 'warn') : ''}
++<form class="ov-form recipe-form" method="post" action="/recipes">
++<div class="recipe-field recipe-field-ask"><label for="ask">What do you feel like? <span class="muted">(optional)</span></label>
++<input id="ask" name="ask" type="text" maxlength="500" placeholder="Something quick and filling"></div>
++<div class="recipe-field"><label for="people">People <span class="muted">(optional)</span></label>
++<input id="people" name="people" type="number" min="1" max="12" step="1" inputmode="numeric"></div>
++<button class="sc-btn sc-primary" type="submit">Suggest recipes</button>
++</form>
++<p class="muted small">Ideas come from what is in <a href="/pantry">your cupboard</a>. Up to 10 requests a day.</p>
++${rows.length ? html`<h2>Recent ideas</h2>
++<p class="notice">Suggested by OpenVibe.AI from your cupboard and the food list. Check a recipe before you cook it: cook meat, poultry and eggs through, and mind allergies.</p>
++${rows.map((row) => html`<section class="recipe-suggestion">
++<p class="recipe-asked muted small">${row.ask ? html`“${row.ask}” · ` : ''}${row.people ? html`${row.people} ${row.people === 1 ? 'person' : 'people'} · ` : ''}${time(row.created_at)}</p>
++${row.recipes.length ? html`<div class="recipe-grid">${row.recipes.map(recipeBlock)}</div>` : html`<p class="muted">No recipe could be made from the food list this time. Try a different request.</p>`}
++</section>`)}` : html`<p class="muted">No ideas yet. Say what you feel like above, or just press Suggest recipes.</p>`}`,
++ });
++ }
++
++ r.get('/recipes', (req, res) => recipesPage(req, res, { saved: !!req.query.saved, error: typeof req.query.error === 'string' ? req.query.error : null }));
++
++ r.post('/recipes', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Frecipes');
++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('A recipe request must be made from openvibe.food itself.');
++ const b = req.body || {};
++ const ask = str(b.ask, 500);
++ const peopleRaw = str(b.people, 4);
++ let people = null;
++ if (peopleRaw) {
++ people = Number(peopleRaw);
++ if (!Number.isInteger(people) || people < 1 || people > 12) return res.redirect(303, '/recipes?error=food.recipes.invalid');
++ }
++ if (!config.aiUrl) return res.redirect(303, '/recipes?error=food.ai_off');
++ const owner = ownerOf(req);
++ const pantry = await store.getPantry(s, owner);
++ if (!pantry.length) return res.redirect(303, '/recipes?error=food.pantry_empty');
++ const today = new Date(s.now()).toISOString().slice(0, 10);
++ if (await store.countRecipeSuggestionsSince(s, owner, today) >= 10) return res.redirect(303, '/recipes?error=food.recipes_daily_limit');
++ let suggestion;
++ try {
++ suggestion = await suggestRecipes(ctx.ai, { pantry, ask, people, subject: owner.replace(/^user:/, '') });
++ } catch (err) {
++ if (err && err.name === 'AiRunError') return res.redirect(303, `/recipes?error=${encodeURIComponent(err.code || 'ai.run_failed')}`);
++ throw err;
++ }
++ await store.insertRecipeSuggestion(s, {
++ id: s.newId('rcp'), subject_id: owner, created_at: s.iso(),
++ ask, people, ai_run_id: suggestion.runId, recipes: suggestion.recipes,
++ });
++ return res.redirect(303, '/recipes?saved=1');
++ });
++
// ── The update log ───────────────────────────────────────
r.get('/updates', (req, res) => page(req, res, {
index: true, cache: PUBLIC_CACHE,
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex 7ab335f..24a96a5 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -28,6 +28,7 @@ const CAPABILITIES = [
'food.plan.write', // save a plan
'food.pantry.read', // read a caller's pantry
'food.pantry.write', // replace a caller's pantry
++ 'food.recipes.suggest', // ask the network's AI for recipes from a caller's pantry (proposed; see README)
];
function decodePayload(token) {
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 42e3305..0be71ad 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -1,14 +1,16 @@
'use strict';
/**
-- * Account export and deletion → Food (ADR-033; openvibe-sdk/account-data). Food holds two things a person made:
-- * a saved meal plan and their pantry list, both keyed `owner = user:usr_…` (server/http/principal.js builds the
-- * requester that way, server/food/store.js writes it). Both are the person's own — nothing anyone else hangs under
-- * them — so both are deleted whole and nothing is kept.
++ * Account export and deletion → Food (ADR-033; openvibe-sdk/account-data). Food holds three things a person made:
++ * a saved meal plan, their pantry list and their recipe suggestions, each keyed by the person (plans and pantry under
++ * `owner = user:usr_…`, recipe suggestions under `subject_id = user:usr_…`). All three are the person's own — nothing
++ * anyone else hangs under them — so all three are deleted whole and nothing is kept.
*
-- * network.account.export_requested the person's plans (plans.json) and pantry (pantry.json), newest first, pushed to
-- * Network (POST /internal/account-exports/:id/parts) with this service's token.
-- * network.account.deleted the person's plans and pantry go, and Food confirms with counts.
++ * network.account.export_requested the person's plans (plans.json), pantry (pantry.json) and recipe suggestions
++ * (recipe-suggestions.json), newest first, pushed to Network
++ * (POST /internal/account-exports/:id/parts) with this service's token.
++ * network.account.deleted the person's plans, pantry and recipe suggestions go, and Food confirms with
++ * counts.
*
* The Nominatim/Overpass cache (food_geo_cache) holds no person's rows: it is keyed by the query, not by a subject, so
* it is neither exported nor erased (see migrations/0002_food.sql). Nothing here is a secret: no token, key or
@@@ -17,12 +19,13 @@
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
/**
-- * The tables that hold a person's rows, with the value the subject column really stores. Meals plans and the pantry
-- * both store `user:usr_…` (server/food/store.js takes the owner straight from the principal's requester).
++ * The tables that hold a person's rows, with the value the subject column really stores. All three store
++ * `user:usr_…` (server/food/store.js takes the owner straight from the principal's requester).
*/
const TABLES = [
{ table: 'food_plans', subject: 'owner', value: (usr) => `user:${usr}`, file: 'plans.json' },
{ table: 'food_pantry', subject: 'owner', value: (usr) => `user:${usr}`, file: 'pantry.json' },
++ { table: 'food_recipe_suggestions', subject: 'subject_id', value: (usr) => `user:${usr}`, file: 'recipe-suggestions.json' },
];
/** The account-data handle for Food's store (server/db.js createStore). */
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 1853b33..0580797 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -28,6 +28,7 @@ const NAV = [
{ label: 'Meal plan', href: '/plan' },
{ label: 'Food list', href: '/foods' },
{ label: 'Cupboard', href: '/pantry' },
++ { label: 'Recipes', href: '/recipes' },
{ label: 'What shipped', href: '/updates' },
];
@@@ -48,7 +49,7 @@ function setRelease(id) { if (id) RELEASE = String(id); }
/**
* o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
-- * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names]
++ * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names], localStyles [this service's own extra stylesheets under public/, e.g. css/recipes.css]
*/
function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
@@@ -93,6 +94,7 @@ function renderPage(o) {
${appIcon.headTags({ site: 'food' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
++${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}
${render(scripts)}
<meta name="ov-boost" content="food@${esc(RELEASE)}">
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex db79b06..c28b5d5 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -49,6 +49,7 @@ async function makeRows(t, subject, title) {
const s = t.ctx.s;
await foodStore.insertPlan(s, { id: s.newId('pln'), owner: `user:${subject}`, title, people: 2, days: 3, budget: null, plan: { days: [{ items: [title] }] }, created_at: s.iso() });
await foodStore.setPantry(s, `user:${subject}`, [title]);
++ await foodStore.insertRecipeSuggestion(s, { id: s.newId('rcp'), subject_id: `user:${subject}`, created_at: s.iso(), ask: `ask-${title}`, people: 2, ai_run_id: 'run_1', recipes: [{ title }] });
}
(async () => {
@@@ -68,10 +69,13 @@ async function makeRows(t, subject, title) {
assert.ok(part, 'the part was pushed to Network');
assert.strictEqual(part.auth, 'Bearer tok_food', 'with this service\'s own token');
assert.strictEqual(part.body.subject, A);
-- assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['pantry.json', 'plans.json']);
++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['pantry.json', 'plans.json', 'recipe-suggestions.json']);
const plans = part.body.files.find((f) => f.name === 'plans.json').content;
assert.strictEqual(plans.length, 1);
assert.strictEqual(plans[0].title, 'person-a');
++ const suggestions = part.body.files.find((f) => f.name === 'recipe-suggestions.json').content;
++ assert.strictEqual(suggestions.length, 1);
++ assert.strictEqual(suggestions[0].ask, 'ask-person-a');
assert.ok(!JSON.stringify(part.body).includes('person-b'), 'nobody else\'s rows');
assert.ok(!/token|secret|password/i.test(JSON.stringify(part.body)), 'no secret is exported');
} finally { await t.close(); await stub.close(); }
@@@ -91,11 +95,12 @@ async function makeRows(t, subject, title) {
assert.strictEqual(res.json().outcome, 'erased');
assert.strictEqual(await gone(A), 0, 'the person\'s plans are gone');
assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_pantry WHERE owner = $1', [`user:${A}`]), 0, 'the pantry is gone');
++ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_recipe_suggestions WHERE subject_id = $1', [`user:${A}`]), 0, 'the recipe suggestions are gone');
assert.strictEqual(await gone(B), 1, 'someone else\'s plan stays');
const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
assert.ok(confirmation, 'the confirmation was sent');
-- assert.deepStrictEqual(confirmation.body.erased, { food_plans: 1, food_pantry: 1 });
++ assert.deepStrictEqual(confirmation.body.erased, { food_plans: 1, food_pantry: 1, food_recipe_suggestions: 1 });
assert.deepStrictEqual(confirmation.body.retained, {});
assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex a82cf58..3848d0a 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -48,7 +48,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend });
++ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, ai: opts.ai });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/recipes.test.js b/test/recipes.test.js
nnew file mode 100644
iindex 0000000..177efa1
---- /dev/null
++++ b/test/recipes.test.js
@@@ -0,0 +1,228 @@
++'use strict';
++/**
++ * Recipe ideas: the request Food builds for OpenVibe.AI, the strict validation of what comes back, the cost Food
++ * computes from its own food list (never from the AI), the last-20 store, the daily cap, the AI-off and empty-pantry
++ * problems, AiRunError pass-through, app tokens needing the capability, and the page signed in and out.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, check, done } = require('./helpers/boot');
++const { buildSources, buildRequest, validateSuggestions, COST_NOTE } = require('../server/food/recipes');
++const { getFood } = require('../server/food/foods');
++const { AiRunError } = require('openvibe-publishing/ai');
++const { serviceAuth, ids } = require('openvibe-contracts');
++const foodStore = require('../server/food/store');
++
++const SAME = { 'sec-fetch-site': 'same-origin' };
++
++/** A stand-in OpenVibe.AI client: records what it was asked and answers with a fixed output (or a failure). */
++function aiStub(output, { fail = null } = {}) {
++ const calls = [];
++ return {
++ calls,
++ async run(workflow, input, opts) {
++ calls.push({ workflow, input, opts });
++ if (fail) throw fail;
++ return { runId: 'run_01JTEST0000000000000000RUN', output };
++ },
++ };
++}
++
++const SAMPLE_OUTPUT = {
++ recipes: [
++ {
++ title: 'Beans and rice bowl', serves: 2, minutes: 20,
++ ingredients: [
++ { name: 'Eggs', amount: '2', from_pantry: true, citations: [] },
++ { name: 'Black beans', amount: '1 can', from_pantry: false, citations: [0] },
++ ],
++ steps: ['Cook the rice.', 'Add the beans.'],
++ notes: 'Season to taste.',
++ },
++ ],
++ citations: [0],
++ gaps: ['an onion would help'],
++};
++
++/** An app token for openvibe.food, signed by the mock Network's key. */
++function appToken(t, cap) {
++ const now = Math.floor(Date.now() / 1000);
++ const projectId = ids.newId('project');
++ return serviceAuth.signServiceToken({
++ iss: t.network.url, sub: `app:${ids.newId('app')}`, actor_type: 'app', aud: ['openvibe.food'], cap,
++ ns: [projectId], project_id: projectId, env: 'production', iat: now, exp: now + 300, jti: `tok_${crypto.randomUUID()}`,
++ }, t.network.privatePem);
++}
++
++(async () => {
++ await check('the request is built from the pantry and Food\'s own food list, capped at 60 sources', () => {
++ const sources = buildSources();
++ assert.ok(sources.length >= 1 && sources.length <= 60, `sources ${sources.length}`);
++ for (const src of sources) {
++ assert.strictEqual(src.source_type, 'food.food');
++ assert.ok(getFood(src.source_id), `${src.source_id} is in the food list`);
++ assert.strictEqual(src.title, getFood(src.source_id).name);
++ const content = JSON.parse(src.content);
++ assert.strictEqual(content.name, getFood(src.source_id).name);
++ assert.ok('price' in content && 'unit' in content, 'source carries the unit and typical price');
++ }
++
++ const req = buildRequest({ pantry: Array.from({ length: 70 }, (_, i) => `food-${i}`), ask: 'x'.repeat(600), people: 3 });
++ assert.strictEqual(req.pantry.length, 60);
++ assert.strictEqual(req.ask.length, 500);
++ assert.strictEqual(req.people, 3);
++ assert.deepStrictEqual(req.sources, sources);
++ });
++
++ await check('the AI output is validated: bad citations and empty recipes are dropped, cost is Food\'s own', () => {
++ const sources = [
++ { source_type: 'food.food', source_id: 'canned-beans', title: 'Beans', content: JSON.stringify({ price: 999 }) },
++ { source_type: 'food.food', source_id: 'white-rice', title: 'Rice', content: '{}' },
++ ];
++ const out = validateSuggestions({
++ recipes: [
++ {
++ title: 'A', serves: 1, minutes: 5,
++ ingredients: [
++ { name: 'Rice', amount: '1 cup', from_pantry: true, citations: [] },
++ { name: 'Beans', amount: '1 can', from_pantry: false, citations: [0] },
++ { name: 'Bad', from_pantry: false, citations: [9] },
++ ],
++ steps: ['Cook it.'],
++ },
++ { title: 'Empty', serves: 1, minutes: 5, ingredients: [{ name: 'Bad', from_pantry: false, citations: [9] }], steps: ['Cook it.'] },
++ { title: 'No steps', serves: 1, minutes: 5, ingredients: [{ name: 'Rice', from_pantry: true, citations: [] }], steps: [] },
++ ],
++ citations: [0, 9],
++ gaps: ['salt'],
++ }, { sources, pantry: [] });
++
++ assert.strictEqual(out.recipes.length, 1, 'the empty and stepless recipes are dropped');
++ assert.strictEqual(out.recipes[0].ingredients.length, 2, 'the uncited ingredient is dropped');
++ assert.deepStrictEqual(out.recipes[0].ingredients[1].citations, [0]);
++ assert.deepStrictEqual(out.recipes[0].to_buy, [{ slug: 'canned-beans', name: getFood('canned-beans').name, price: getFood('canned-beans').price }]);
++ assert.strictEqual(out.recipes[0].to_buy_cost, getFood('canned-beans').price, 'cost comes from the food list, not the source content the AI could have seen');
++ assert.strictEqual(out.recipes[0].cost_note, COST_NOTE);
++ assert.deepStrictEqual(out.citations, [0]);
++ assert.deepStrictEqual(out.gaps, ['salt']);
++ });
++
++ const ai = aiStub(SAMPLE_OUTPUT);
++ const t = await boot({ env: { OV_AI_INTERNAL_URL: 'http://ai.test' }, ai });
++ const kim = t.network.addUser('kim');
++ const lee = t.network.addUser('lee');
++ try {
++ await check('a signed-in person asks for recipes, Food stores its validated, costed answer', async () => {
++ await t.get('/api/v1/pantry', { json: { items: ['eggs-dozen'] }, method: 'PUT', as: kim, headers: SAME });
++ const created = await t.get('/api/v1/recipes/suggest', { as: kim, method: 'POST', json: { ask: 'quick dinner', people: 2 }, headers: SAME });
++ assert.strictEqual(created.status, 201, created.text);
++ const body = created.json();
++ assert.match(body.id, /^rcp_/);
++ assert.strictEqual(body.ask, 'quick dinner');
++ assert.strictEqual(body.people, 2);
++ assert.strictEqual(body.ai_run_id, 'run_01JTEST0000000000000000RUN');
++ assert.strictEqual(body.recipes.length, 1);
++ assert.strictEqual(body.recipes[0].title, 'Beans and rice bowl');
++
++ const call = ai.calls[0];
++ assert.strictEqual(call.workflow, 'food.suggest_recipes');
++ assert.deepStrictEqual(call.input.pantry, ['Large Eggs (1 dozen)']);
++ assert.ok(call.input.sources.length <= 60);
++ assert.strictEqual(call.opts.onBehalfOf.id, kim.subject);
++ assert.strictEqual(call.opts.target.id, kim.subject);
++ assert.strictEqual(call.opts.target.service, 'food');
++
++ const cited = call.input.sources[0].source_id;
++ assert.strictEqual(body.recipes[0].to_buy[0].slug, cited);
++ assert.strictEqual(body.recipes[0].to_buy_cost, getFood(cited).price);
++ assert.strictEqual(body.recipes[0].ingredients[0].from_pantry, true);
++
++ const list = await t.get('/api/v1/recipes', { as: kim });
++ assert.strictEqual(list.status, 200);
++ assert.deepStrictEqual(list.json().recipes.map((r) => r.id), [body.id]);
++ });
++
++ await check('an app token needs the recipes capability', async () => {
++ const without = await t.get('/api/v1/recipes/suggest', { bearer: appToken(t, []), method: 'POST', json: { ask: 'x' } });
++ assert.strictEqual(without.status, 403);
++ assert.match(without.json().code, /capability/);
++ // openvibe-contracts 0.147.0 defines it: a granted token passes the gate and reaches the route (its subject has
++ // no pantry yet, so the answer is the route's own 422, not a capability refusal).
++ const withCap = await t.get('/api/v1/recipes/suggest', { bearer: appToken(t, ['food.recipes.suggest']), method: 'POST', json: { ask: 'x' } });
++ assert.strictEqual(withCap.status, 422, withCap.text);
++ assert.strictEqual(withCap.json().code, 'food.pantry_empty');
++ });
++
++ await check('an empty pantry is a 422 food.pantry_empty', async () => {
++ const res = await t.get('/api/v1/recipes/suggest', { as: lee, method: 'POST', json: { ask: 'anything' }, headers: SAME });
++ assert.strictEqual(res.status, 422);
++ assert.strictEqual(res.json().code, 'food.pantry_empty');
++ });
++
++ await check('the page asks a signed-out person to sign in, and a signed-in one shows the idea and its cost', async () => {
++ const out = await t.get('/recipes');
++ assert.strictEqual(out.status, 200);
++ assert.match(out.text, /Sign in to get recipe ideas/);
++
++ const page = await t.get('/recipes', { as: kim });
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /Beans and rice bowl/);
++ assert.match(page.text, /in your cupboard/);
++ assert.match(page.text, /Still to buy/);
++ assert.match(page.text, /Typical total/);
++ assert.match(page.text, /Check a recipe before you cook it/);
++ });
++
++ await check('the daily limit is 10 suggestions a person', async () => {
++ const s = t.ctx.s;
++ const subject = `user:${kim.subject}`;
++ for (let i = 0; i < 9; i++) await foodStore.insertRecipeSuggestion(s, { id: `rcp_d${i}`, subject_id: subject, created_at: s.iso(), ask: null, people: null, ai_run_id: 'run', recipes: [{ title: 'filler' }] });
++ const res = await t.get('/api/v1/recipes/suggest', { as: kim, method: 'POST', json: { ask: 'one more' }, headers: SAME });
++ assert.strictEqual(res.status, 429);
++ assert.strictEqual(res.json().code, 'food.recipes_daily_limit');
++ });
++
++ await check('only the last 20 suggestions are kept per person', async () => {
++ const s = t.ctx.s;
++ const subject = `user:${lee.subject}`;
++ for (let i = 1; i <= 21; i++) await foodStore.insertRecipeSuggestion(s, { id: `rcp_c${String(i).padStart(4, '0')}`, subject_id: subject, created_at: s.iso(), ask: null, people: null, ai_run_id: 'run', recipes: [{ title: `r${i}` }] });
++ const rows = await foodStore.listRecipeSuggestions(s, subject, { limit: 50 });
++ assert.strictEqual(rows.length, 20);
++ assert.ok(rows.every((r) => r.id !== 'rcp_c0001'), 'the oldest is dropped');
++ assert.ok(rows.some((r) => r.id === 'rcp_c0021'), 'the newest is kept');
++ });
++
++ await check('recipe suggestions are exported and erased with the account', async () => {
++ const subject = `user:${kim.subject}`;
++ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_recipe_suggestions WHERE subject_id = $1', [subject]) > 0, true);
++ const part = await t.ctx.accountData.exportPart(kim.subject);
++ assert.ok(part.files.some((f) => f.name === 'recipe-suggestions.json'), 'the export carries recipe suggestions');
++ await t.ctx.accountData.erase([kim.subject]);
++ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_recipe_suggestions WHERE subject_id = $1', [subject]), 0, 'erased with the account');
++ });
++ } finally { await t.close(); }
++
++ await check('with OV_AI_INTERNAL_URL unset the route answers 503 food.ai_off', async () => {
++ const off = await boot();
++ const u = off.network.addUser('kim');
++ try {
++ const res = await off.get('/api/v1/recipes/suggest', { as: u, method: 'POST', json: { ask: 'x' }, headers: SAME });
++ assert.strictEqual(res.status, 503);
++ assert.strictEqual(res.json().code, 'food.ai_off');
++ } finally { await off.close(); }
++ });
++
++ await check('an AiRunError passes through its status and code', async () => {
++ const failing = aiStub(null, { fail: new AiRunError('ai.run_failed', 'the model failed', { status: 502 }) });
++ const t2 = await boot({ env: { OV_AI_INTERNAL_URL: 'http://ai.test' }, ai: failing });
++ const u = t2.network.addUser('kim');
++ try {
++ await t2.get('/api/v1/pantry', { json: { items: ['eggs-dozen'] }, method: 'PUT', as: u, headers: SAME });
++ const res = await t2.get('/api/v1/recipes/suggest', { as: u, method: 'POST', json: { ask: 'x' }, headers: SAME });
++ assert.strictEqual(res.status, 502);
++ assert.strictEqual(res.json().code, 'ai.run_failed');
++ } finally { await t2.close(); }
++ });
++
++ done();
++})();