openvibers/openvibe.food Commits
Food: checked library recipes are in OpenVibe.Search (owner food, type recipe) (#58)
Changes
@@@ -26,7 +26,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and| Pantry by barcode | [/pantry/barcode](server/http/pages.js), [server/food/barcode.js](server/food/barcode.js), [public/js/barcode-scan.js](public/js/barcode-scan.js) | Type a product's barcode, or scan it with the camera where the browser can read barcodes (the picture stays on the device). Open Food Facts names the product, and Food matches it to the item on its list it is (peanut butter is not butter, apple juice is not apples). The person confirms or picks another item before anything is added. Only the barcode is sent, with Food's User-Agent; answers are cached a day and capped at `FOOD_BARCODE_PER_MINUTE` (60) || Pantry by photo or receipt | [/pantry/photo](server/http/pages.js), [public/js/pantry-photo.js](public/js/pantry-photo.js) | A photo of a cupboard, fridge or shelf, or a grocery receipt. The page makes it at most 1280 px on the device. OpenVibe.AI (`food.read_pantry_photo`) names the items from Food's list it clearly shows, and the person ticks what to add (unsure ones start unticked). AI keeps only the photo's hash; a receipt's prices, card and store details are never read out. Ten photos a person a day; nginx allows 4 MB on this one route || 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 |--| Recipe library | [/recipes/library](server/http/library-routes.js), [server/food/library.js](server/food/library.js) | Every six hours the next of 35 themes goes to `food.suggest_recipes` with an empty cupboard, so every ingredient must be a food from the list (a recipe with anything else is dropped); Food prices what to buy from its typical prices. Labelled as written by AI, noindex until a member of staff marks one checked (then indexed with Recipe JSON-LD and in the sitemap); staff can hide one. `/recipes/library.json?since=` lists the published ones added since a time (oldest first, up to 100): OpenVibe.Blog's budget-recipes desk reads it |++| Recipe library | [/recipes/library](server/http/library-routes.js), [server/food/library.js](server/food/library.js) | Every six hours the next of 35 themes goes to `food.suggest_recipes` with an empty cupboard, so every ingredient must be a food from the list (a recipe with anything else is dropped); Food prices what to buy from its typical prices. Labelled as written by AI, noindex until a member of staff marks one checked (then indexed with Recipe JSON-LD and in the sitemap); staff can hide one. `/recipes/library.json?since=` lists the published ones added since a time (oldest first, up to 100): OpenVibe.Blog's budget-recipes desk reads it. Checked recipes are OpenVibe.Search documents (owner `food`, type `recipe`) through Search's owner API ([server/food/search-index.js](server/food/search-index.js), `FOOD_SEARCH_URL`, `search.document.write`) || Delivery, compared | [/delivery](server/http/pages.js), [server/food/delivery.js](server/food/delivery.js) | One query opens DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart's own searches in new tabs (URL forms checked in a real browser on 2026-10-15; Postmates is Uber Eats now, Gopuff, Slice and ezCater have no stable search link). Meal kits and prepared meals (EveryPlate, Dinnerly, HelloFresh, Home Chef, Marley Spoon, Blue Apron, Hungryroot, Green Chef, Purple Carrot, Factor) by tier, each with its plans page and an OpenVibe.Watch link for price changes. Food fetches none of them; published per-serving prices conflict between sources, so none are shown || The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | Paced, cached, identified, bounded. With the Open Food Facts barcode lookup (one product read per barcode, cached a day; data under the Open Database License, credited on the page), these are the only outbound calls besides OpenVibe's own services || Store | [server/food/store.js](server/food/store.js), [migrations/](migrations/) | `food_geo_cache`, `food_plans`, `food_pantry`, `food_recipe_suggestions` |@@@ -88,6 +88,8 @@ async function createApp(opts = {}) {ctx.ai = opts.ai !== undefined ? opts.ai : createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokenClient, fetch: fetchImpl });// The recipe library (server/food/library.js): a theme every six hours where the process serves (server/index.js starts it).ctx.library = require('./food/library').createLibrary({ s, ai: ctx.ai, log });++ // Checked recipes in OpenVibe.Search (server/food/search-index.js; off without FOOD_SEARCH_URL).++ ctx.recipeSearch = require('./food/search-index').createRecipeSearchIndex({ config, library: ctx.library, log });const app = express();app.disable('x-powered-by');@@@ -63,6 +63,8 @@ function load(env = process.env) {// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.networkUrl,networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ // OpenVibe.Search's owner API (server/food/search-index.js): the checked library recipes as documents. Unset: off.++ search: { url: trim(env.FOOD_SEARCH_URL || '') },networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),// The audience this service's app, agent and service tokens carry.audience: env.FOOD_AUDIENCE || 'openvibe.food',nnew file mode 100644@@@ -0,0 +1,132 @@++'use strict';++/**++ * The recipe library in OpenVibe.Search: every recipe a member of staff checked (published and reviewed, the ones whose++ * page is indexed) is one document (search.index-document@1, owner food, type recipe, id = the slug,++ * /recipes/library/<slug>), written through Search's owner API with Food's service token (search.document.write on++ * openvibe.search). A recipe that is hidden or whose check is taken back is tombstoned.++ *++ * Runs a minute after boot, every six hours, and 30 seconds after a review or a hide (kick()). There are few checked++ * recipes, so the stored copy is read back from Search for each one and a document is re-sent, one revision up, only++ * when its content differs.++ *++ * Off unless FOOD_SEARCH_URL and the OAuth client secret are set.++ */++const { serviceAuth } = require('openvibe-contracts');++++const OWNER = 'food';++const TYPE = 'recipe';++const EVERY_MS = 6 * 60 * 60 * 1000;++const KICK_MS = 30 * 1000;++const COMPARED = ['canonical_url', 'title', 'summary', 'body'];++++const money = (n) => (n == null ? null : `$${Number(n).toFixed(2)}`);++++/** A recipe (the library's shape) → the document body; identity and revision are added by the caller. */++function documentFor(x, { baseUrl }) {++ const facts = [x.serves ? `Serves ${x.serves}` : null, x.minutes ? `about ${x.minutes} minutes` : null,++ x.cost != null ? `${money(x.cost)} to buy${x.cost_partial ? ' (some prices unknown)' : ''}` : null].filter(Boolean).join(', ');++ const ingredients = (x.ingredients || []).map((i) => [i.amount, i.name].filter(Boolean).join(' ')).filter(Boolean);++ return {++ visibility: 'public',++ canonical_url: `${baseUrl}/recipes/library/${x.slug}`,++ title: String(x.title).slice(0, 500),++ summary: `A budget recipe from OpenVibe.Food${facts ? `: ${facts}` : ''}.`.slice(0, 4000),++ body: [x.title, facts, ingredients.length ? `Ingredients: ${ingredients.join('; ')}` : '', ...(x.steps || [])].filter(Boolean).join('\n').slice(0, 48000),++ facets: { kind: 'recipe', ...(x.cost != null ? { cost: Number(x.cost) } : {}), ...(x.serves ? { serves: x.serves } : {}) },++ authorship: 'ai_generated',++ publication_state: 'published',++ published_at: x.created_at || null,++ updated_at: x.reviewed_at || x.created_at || null,++ indexability: { decision: 'index', reasons: [] },++ };++}++++function createRecipeSearchIndex({ config, library, fetchImpl = globalThis.fetch, log = console, tokens = null }) {++ const url = String((config.search && config.search.url) || '').replace(/\/+$/, '');++ const enabled = Boolean(url && (tokens || config.oauth.clientSecret));++ const auth = tokens || (enabled ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.search', scope: 'search.document.write', fetchImpl,++ }) : null);++ const baseUrl = String(config.baseUrl).replace(/\/+$/, '');++ const stats = { runs: 0, upserted: 0, deleted: 0, unchanged: 0, failed: 0, last_error: null };++++ async function call(method, p, body) {++ const res = await fetchImpl(`${url}${p}`, {++ method,++ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await auth.authHeaders()) },++ body: body ? JSON.stringify(body) : undefined,++ signal: AbortSignal.timeout(15000),++ });++ const json = await res.json().catch(() => ({}));++ if (res.status === 401 && auth.invalidate) auth.invalidate();++ return { status: res.status, ok: res.ok, body: json };++ }++++ async function remoteDocs() {++ const out = new Map();++ let after = 0;++ for (let i = 0; i < 50; i++) {++ const r = await call('GET', `/api/v1/owners/${OWNER}/documents?type=${TYPE}&limit=1000&after=${after}`);++ if (!r.ok) throw new Error(`listing: ${r.status} ${r.body.code || ''}`);++ for (const d of r.body.documents || []) out.set(d.id, d);++ if (!r.body.next_after) break;++ after = r.body.next_after;++ }++ return out;++ }++++ async function run() {++ if (!enabled) return null;++ stats.runs++;++ const checked = await library.reviewed();++ const remote = await remoteDocs();++ const keep = new Set();++ for (const x of checked) {++ keep.add(x.slug);++ const doc = documentFor(x, { baseUrl });++ const r = remote.get(x.slug);++ if (r && !r.deleted) {++ const stored = await call('GET', `/api/v1/owners/${OWNER}/documents/${TYPE}/${encodeURIComponent(x.slug)}`);++ const s = stored.ok ? (stored.body.document || stored.body) : null;++ if (s && COMPARED.every((k) => s[k] === doc[k])) { stats.unchanged++; continue; }++ }++ const revision = r ? Number(r.revision) + 1 : 1;++ const res = await call('PUT', `/api/v1/documents/${OWNER}/${TYPE}/${encodeURIComponent(x.slug)}`, { ...doc, revision });++ if (res.ok) stats.upserted++;++ else { stats.failed++; stats.last_error = `${x.slug}: ${res.status} ${res.body.code || ''}`; }++ }++ for (const [id, r] of remote) {++ if (keep.has(id) || r.deleted) continue;++ const res = await call('DELETE', `/api/v1/documents/${OWNER}/${TYPE}/${encodeURIComponent(id)}?revision=${Number(r.revision) + 1}`);++ if (res.ok) stats.deleted++;++ else { stats.failed++; stats.last_error = `${id}: ${res.status} ${res.body.code || ''}`; }++ }++ return { ...stats };++ }++++ const quietly = () => run().catch((err) => { stats.failed++; stats.last_error = err.message; log.warn('[OpenVibe.Food] search index run failed:', err.message); });++ let timer = null;++ let first = null;++ let kicked = null;++ return {++ enabled,++ run,++ stats: () => ({ ...stats }),++ kick() {++ if (!enabled || kicked) return;++ kicked = setTimeout(() => { kicked = null; quietly(); }, KICK_MS);++ if (kicked.unref) kicked.unref();++ },++ start({ firstDelayMs = 60_000 } = {}) {++ if (!enabled || timer) return;++ first = setTimeout(quietly, firstDelayMs);++ if (first.unref) first.unref();++ timer = setInterval(quietly, EVERY_MS);++ if (timer.unref) timer.unref();++ },++ stop() { clearTimeout(first); clearInterval(timer); clearTimeout(kicked); timer = null; first = null; kicked = null; },++ };++}++++module.exports = { createRecipeSearchIndex, documentFor };@@@ -109,6 +109,8 @@ ${staff ? html`<form method="post" action="/recipes/library/${x.slug}/review" clconst x = await lib.bySlug(req.params.slug);if (!x) return res.status(404).type('text/plain').send('Not found.');await fn(x);++ // A check, a take-back or a hide changes what OpenVibe.Search should hold (server/food/search-index.js).++ if (ctx.recipeSearch) ctx.recipeSearch.kick();res.redirect(303, `/recipes/library/${x.slug}`);}r.post('/recipes/library/:slug/review', form, (req, res) => staffAction(req, res, (x) => lib.review(x, req.viewer.subject, req.body.yes !== '0')));@@@ -43,7 +43,8 @@ async function start() {const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });// The recipe library writes a theme every six hours (server/food/library.js); off without OpenVibe.AI.ctx.library.start();-- const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.library.stop()];++ ctx.recipeSearch.start();++ const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.library.stop(), () => ctx.recipeSearch.stop()];createLifecycle({ server, ctx, extra });return { server, ctx };}nnew file mode 100644@@@ -0,0 +1,98 @@++'use strict';++/**++ * Checked library recipes in OpenVibe.Search (server/food/search-index.js), against a stand-in Search owner API:++ * - each checked recipe is a valid search.index-document@1 (owner food, type recipe, /recipes/library/<slug>);++ * - unchanged is not sent again; a change goes one revision up; a recipe no longer checked is tombstoned;++ * - off without FOOD_SEARCH_URL.++ */++const assert = require('assert');++const http = require('http');++const { validate } = require('openvibe-contracts');++const { createRecipeSearchIndex } = require('../server/food/search-index');++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.log(` ✗ ${name}\n ${err && err.stack}`); }++}++++function startSearch() {++ const docs = new Map();++ const puts = [];++ const server = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ const u = new URL(req.url, 'http://search.test');++ const send = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };++ if (req.headers.authorization !== 'Bearer svc-food') return send(401, { code: 'token.invalid' });++ if (req.method === 'GET' && u.pathname === '/api/v1/owners/food/documents') {++ return send(200, { documents: [...docs.values()].map((d) => ({ type: 'recipe', id: d.id, revision: d.revision, deleted: Boolean(d.deleted) })), next_after: null });++ }++ let m = /^\/api\/v1\/owners\/food\/documents\/recipe\/([^/]+)$/.exec(u.pathname);++ if (m && req.method === 'GET') {++ const d = docs.get(decodeURIComponent(m[1]));++ return d && !d.deleted ? send(200, { document: d }) : send(404, { code: 'search.not_found' });++ }++ m = /^\/api\/v1\/documents\/food\/recipe\/([^/]+)$/.exec(u.pathname);++ if (m && req.method === 'PUT') {++ const doc = { ...JSON.parse(raw), owner: 'food', type: 'recipe', id: decodeURIComponent(m[1]) };++ const v = validate('search.index-document@1', doc);++ if (!v.valid) return send(422, { code: 'search.bad_document', errors: v.errors });++ docs.set(doc.id, doc);++ puts.push(doc.id);++ return send(200, { outcome: 'applied' });++ }++ if (m && req.method === 'DELETE') {++ const id = decodeURIComponent(m[1]);++ docs.set(id, { id, revision: Number(u.searchParams.get('revision')), deleted: true });++ return send(200, { outcome: 'applied' });++ }++ return send(404, { code: 'route.not_found' });++ });++ });++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ docs, puts, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));++}++++(async () => {++ const search = await startSearch();++ let checked = [{++ slug: 'beans-and-rice', title: 'Beans and rice', serves: 2, minutes: 25, cost: 4.5, cost_partial: false,++ ingredients: [{ name: 'Black beans', amount: '1 can' }, { name: 'Rice', amount: '1 cup' }], steps: ['Cook the rice.', 'Warm the beans.'],++ created_at: '2026-10-15T10:00:00.000Z', reviewed_at: '2026-10-15T12:00:00.000Z',++ }];++ const library = { reviewed: async () => checked };++ const config = { search: { url: search.url }, oauth: { clientSecret: 'x', clientId: 'food' }, networkInternalUrl: 'http://network.test', baseUrl: 'https://openvibe.food' };++ const index = createRecipeSearchIndex({ config, library, tokens: { authHeaders: async () => ({ authorization: 'Bearer svc-food' }) }, log: { warn() {} } });++++ await check('a checked recipe is a valid document with its own address', async () => {++ const r = await index.run();++ assert.strictEqual(r.upserted, 1, JSON.stringify(r));++ const doc = search.docs.get('beans-and-rice');++ assert.strictEqual(doc.canonical_url, 'https://openvibe.food/recipes/library/beans-and-rice');++ assert.ok(doc.body.includes('Ingredients: 1 can Black beans; 1 cup Rice') && doc.summary.includes('$4.50 to buy'));++ assert.strictEqual(doc.authorship, 'ai_generated');++ assert.strictEqual(doc.revision, 1);++ });++++ await check('unchanged is not sent again; a change goes one revision up; no longer checked, it is tombstoned', async () => {++ await index.run();++ assert.strictEqual(search.puts.length, 1, 'nothing changed');++ checked = [{ ...checked[0], title: 'Beans and rice for two' }];++ await index.run();++ assert.strictEqual(search.docs.get('beans-and-rice').revision, 2);++ checked = [];++ const r = await index.run();++ assert.strictEqual(r.deleted, 1);++ assert.deepStrictEqual(search.docs.get('beans-and-rice'), { id: 'beans-and-rice', revision: 3, deleted: true });++ });++++ await check('off without FOOD_SEARCH_URL', async () => {++ const off = createRecipeSearchIndex({ config: { ...config, search: { url: '' } }, library });++ assert.strictEqual(off.enabled, false);++ assert.strictEqual(await off.run(), null);++ });++++ await search.close();++ console.log(failures ? `\n${failures} failed` : '\nall passed');++ process.exit(failures ? 1 : 0);++})();
@@ -26,7 +26,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
@@ -26,7 +26,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
| Pantry by barcode | [/pantry/barcode](server/http/pages.js), [server/food/barcode.js](server/food/barcode.js), [public/js/barcode-scan.js](public/js/barcode-scan.js) | Type a product's barcode, or scan it with the camera where the browser can read barcodes (the picture stays on the device). Open Food Facts names the product, and Food matches it to the item on its list it is (peanut butter is not butter, apple juice is not apples). The person confirms or picks another item before anything is added. Only the barcode is sent, with Food's User-Agent; answers are cached a day and capped at `FOOD_BARCODE_PER_MINUTE` (60) |
| Pantry by barcode | [/pantry/barcode](server/http/pages.js), [server/food/barcode.js](server/food/barcode.js), [public/js/barcode-scan.js](public/js/barcode-scan.js) | Type a product's barcode, or scan it with the camera where the browser can read barcodes (the picture stays on the device). Open Food Facts names the product, and Food matches it to the item on its list it is (peanut butter is not butter, apple juice is not apples). The person confirms or picks another item before anything is added. Only the barcode is sent, with Food's User-Agent; answers are cached a day and capped at `FOOD_BARCODE_PER_MINUTE` (60) |
| Pantry by photo or receipt | [/pantry/photo](server/http/pages.js), [public/js/pantry-photo.js](public/js/pantry-photo.js) | A photo of a cupboard, fridge or shelf, or a grocery receipt. The page makes it at most 1280 px on the device. OpenVibe.AI (`food.read_pantry_photo`) names the items from Food's list it clearly shows, and the person ticks what to add (unsure ones start unticked). AI keeps only the photo's hash; a receipt's prices, card and store details are never read out. Ten photos a person a day; nginx allows 4 MB on this one route |
| Pantry by photo or receipt | [/pantry/photo](server/http/pages.js), [public/js/pantry-photo.js](public/js/pantry-photo.js) | A photo of a cupboard, fridge or shelf, or a grocery receipt. The page makes it at most 1280 px on the device. OpenVibe.AI (`food.read_pantry_photo`) names the items from Food's list it clearly shows, and the person ticks what to add (unsure ones start unticked). AI keeps only the photo's hash; a receipt's prices, card and store details are never read out. Ten photos a person a day; nginx allows 4 MB on this one route |
| 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 |
| 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 |
-| Recipe library | [/recipes/library](server/http/library-routes.js), [server/food/library.js](server/food/library.js) | Every six hours the next of 35 themes goes to `food.suggest_recipes` with an empty cupboard, so every ingredient must be a food from the list (a recipe with anything else is dropped); Food prices what to buy from its typical prices. Labelled as written by AI, noindex until a member of staff marks one checked (then indexed with Recipe JSON-LD and in the sitemap); staff can hide one. `/recipes/library.json?since=` lists the published ones added since a time (oldest first, up to 100): OpenVibe.Blog's budget-recipes desk reads it |
+| Recipe library | [/recipes/library](server/http/library-routes.js), [server/food/library.js](server/food/library.js) | Every six hours the next of 35 themes goes to `food.suggest_recipes` with an empty cupboard, so every ingredient must be a food from the list (a recipe with anything else is dropped); Food prices what to buy from its typical prices. Labelled as written by AI, noindex until a member of staff marks one checked (then indexed with Recipe JSON-LD and in the sitemap); staff can hide one. `/recipes/library.json?since=` lists the published ones added since a time (oldest first, up to 100): OpenVibe.Blog's budget-recipes desk reads it. Checked recipes are OpenVibe.Search documents (owner `food`, type `recipe`) through Search's owner API ([server/food/search-index.js](server/food/search-index.js), `FOOD_SEARCH_URL`, `search.document.write`) |
| Delivery, compared | [/delivery](server/http/pages.js), [server/food/delivery.js](server/food/delivery.js) | One query opens DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart's own searches in new tabs (URL forms checked in a real browser on 2026-10-15; Postmates is Uber Eats now, Gopuff, Slice and ezCater have no stable search link). Meal kits and prepared meals (EveryPlate, Dinnerly, HelloFresh, Home Chef, Marley Spoon, Blue Apron, Hungryroot, Green Chef, Purple Carrot, Factor) by tier, each with its plans page and an OpenVibe.Watch link for price changes. Food fetches none of them; published per-serving prices conflict between sources, so none are shown |
| Delivery, compared | [/delivery](server/http/pages.js), [server/food/delivery.js](server/food/delivery.js) | One query opens DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart's own searches in new tabs (URL forms checked in a real browser on 2026-10-15; Postmates is Uber Eats now, Gopuff, Slice and ezCater have no stable search link). Meal kits and prepared meals (EveryPlate, Dinnerly, HelloFresh, Home Chef, Marley Spoon, Blue Apron, Hungryroot, Green Chef, Purple Carrot, Factor) by tier, each with its plans page and an OpenVibe.Watch link for price changes. Food fetches none of them; published per-serving prices conflict between sources, so none are shown |
| The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | Paced, cached, identified, bounded. With the Open Food Facts barcode lookup (one product read per barcode, cached a day; data under the Open Database License, credited on the page), these are the only outbound calls besides OpenVibe's own services |
| The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | Paced, cached, identified, bounded. With the Open Food Facts barcode lookup (one product read per barcode, cached a day; data under the Open Database License, credited on the page), these are the only outbound calls besides OpenVibe's own services |
| Store | [server/food/store.js](server/food/store.js), [migrations/](migrations/) | `food_geo_cache`, `food_plans`, `food_pantry`, `food_recipe_suggestions` |
| Store | [server/food/store.js](server/food/store.js), [migrations/](migrations/) | `food_geo_cache`, `food_plans`, `food_pantry`, `food_recipe_suggestions` |
@@ -88,6 +88,8 @@ async function createApp(opts = {}) {
@@ -88,6 +88,8 @@ async function createApp(opts = {}) {
ctx.ai = opts.ai !== undefined ? opts.ai : createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokenClient, fetch: fetchImpl });
ctx.ai = opts.ai !== undefined ? opts.ai : createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokenClient, fetch: fetchImpl });
// The recipe library (server/food/library.js): a theme every six hours where the process serves (server/index.js starts it).
// The recipe library (server/food/library.js): a theme every six hours where the process serves (server/index.js starts it).
ctx.library = require('./food/library').createLibrary({ s, ai: ctx.ai, log });
ctx.library = require('./food/library').createLibrary({ s, ai: ctx.ai, log });
+ // Checked recipes in OpenVibe.Search (server/food/search-index.js; off without FOOD_SEARCH_URL).
+ ctx.recipeSearch = require('./food/search-index').createRecipeSearchIndex({ config, library: ctx.library, log });
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -63,6 +63,8 @@ function load(env = process.env) {
@@ -63,6 +63,8 @@ function load(env = process.env) {
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
networkUrl,
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ // OpenVibe.Search's owner API (server/food/search-index.js): the checked library recipes as documents. Unset: off.
+ search: { url: trim(env.FOOD_SEARCH_URL || '') },
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
// The audience this service's app, agent and service tokens carry.
// The audience this service's app, agent and service tokens carry.
audience: env.FOOD_AUDIENCE || 'openvibe.food',
audience: env.FOOD_AUDIENCE || 'openvibe.food',
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+/**
+ * The recipe library in OpenVibe.Search: every recipe a member of staff checked (published and reviewed, the ones whose
+ * page is indexed) is one document (search.index-document@1, owner food, type recipe, id = the slug,
+ * /recipes/library/<slug>), written through Search's owner API with Food's service token (search.document.write on
+ * openvibe.search). A recipe that is hidden or whose check is taken back is tombstoned.
+ *
+ * Runs a minute after boot, every six hours, and 30 seconds after a review or a hide (kick()). There are few checked
+ * recipes, so the stored copy is read back from Search for each one and a document is re-sent, one revision up, only
+ * when its content differs.
+ *
+ * Off unless FOOD_SEARCH_URL and the OAuth client secret are set.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+const OWNER = 'food';
+const TYPE = 'recipe';
+const EVERY_MS = 6 * 60 * 60 * 1000;
+const KICK_MS = 30 * 1000;
+const COMPARED = ['canonical_url', 'title', 'summary', 'body'];
+
+const money = (n) => (n == null ? null : `$${Number(n).toFixed(2)}`);
+
+/** A recipe (the library's shape) → the document body; identity and revision are added by the caller. */
+function documentFor(x, { baseUrl }) {
+ const facts = [x.serves ? `Serves ${x.serves}` : null, x.minutes ? `about ${x.minutes} minutes` : null,
+ x.cost != null ? `${money(x.cost)} to buy${x.cost_partial ? ' (some prices unknown)' : ''}` : null].filter(Boolean).join(', ');
+ const ingredients = (x.ingredients || []).map((i) => [i.amount, i.name].filter(Boolean).join(' ')).filter(Boolean);
+ return {
+ visibility: 'public',
+ canonical_url: `${baseUrl}/recipes/library/${x.slug}`,
+ title: String(x.title).slice(0, 500),
+ summary: `A budget recipe from OpenVibe.Food${facts ? `: ${facts}` : ''}.`.slice(0, 4000),
+ body: [x.title, facts, ingredients.length ? `Ingredients: ${ingredients.join('; ')}` : '', ...(x.steps || [])].filter(Boolean).join('\n').slice(0, 48000),
+ facets: { kind: 'recipe', ...(x.cost != null ? { cost: Number(x.cost) } : {}), ...(x.serves ? { serves: x.serves } : {}) },
+ authorship: 'ai_generated',
+ publication_state: 'published',
+ published_at: x.created_at || null,
+ updated_at: x.reviewed_at || x.created_at || null,
+ indexability: { decision: 'index', reasons: [] },
+ };
+}
+
+function createRecipeSearchIndex({ config, library, fetchImpl = globalThis.fetch, log = console, tokens = null }) {
+ const url = String((config.search && config.search.url) || '').replace(/\/+$/, '');
+ const enabled = Boolean(url && (tokens || config.oauth.clientSecret));
+ const auth = tokens || (enabled ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.search', scope: 'search.document.write', fetchImpl,
+ }) : null);
+ const baseUrl = String(config.baseUrl).replace(/\/+$/, '');
+ const stats = { runs: 0, upserted: 0, deleted: 0, unchanged: 0, failed: 0, last_error: null };
+
+ async function call(method, p, body) {
+ const res = await fetchImpl(`${url}${p}`, {
+ method,
+ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await auth.authHeaders()) },
+ body: body ? JSON.stringify(body) : undefined,
+ signal: AbortSignal.timeout(15000),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (res.status === 401 && auth.invalidate) auth.invalidate();
+ return { status: res.status, ok: res.ok, body: json };
+ }
+
+ async function remoteDocs() {
+ const out = new Map();
+ let after = 0;
+ for (let i = 0; i < 50; i++) {
+ const r = await call('GET', `/api/v1/owners/${OWNER}/documents?type=${TYPE}&limit=1000&after=${after}`);
+ if (!r.ok) throw new Error(`listing: ${r.status} ${r.body.code || ''}`);
+ for (const d of r.body.documents || []) out.set(d.id, d);
+ if (!r.body.next_after) break;
+ after = r.body.next_after;
+ }
+ return out;
+ }
+
+ async function run() {
+ if (!enabled) return null;
+ stats.runs++;
+ const checked = await library.reviewed();
+ const remote = await remoteDocs();
+ const keep = new Set();
+ for (const x of checked) {
+ keep.add(x.slug);
+ const doc = documentFor(x, { baseUrl });
+ const r = remote.get(x.slug);
+ if (r && !r.deleted) {
+ const stored = await call('GET', `/api/v1/owners/${OWNER}/documents/${TYPE}/${encodeURIComponent(x.slug)}`);
+ const s = stored.ok ? (stored.body.document || stored.body) : null;
+ if (s && COMPARED.every((k) => s[k] === doc[k])) { stats.unchanged++; continue; }
+ }
+ const revision = r ? Number(r.revision) + 1 : 1;
+ const res = await call('PUT', `/api/v1/documents/${OWNER}/${TYPE}/${encodeURIComponent(x.slug)}`, { ...doc, revision });
+ if (res.ok) stats.upserted++;
+ else { stats.failed++; stats.last_error = `${x.slug}: ${res.status} ${res.body.code || ''}`; }
+ }
+ for (const [id, r] of remote) {
+ if (keep.has(id) || r.deleted) continue;
+ const res = await call('DELETE', `/api/v1/documents/${OWNER}/${TYPE}/${encodeURIComponent(id)}?revision=${Number(r.revision) + 1}`);
+ if (res.ok) stats.deleted++;
+ else { stats.failed++; stats.last_error = `${id}: ${res.status} ${res.body.code || ''}`; }
+ }
+ return { ...stats };
+ }
+
+ const quietly = () => run().catch((err) => { stats.failed++; stats.last_error = err.message; log.warn('[OpenVibe.Food] search index run failed:', err.message); });
+ let timer = null;
+ let first = null;
+ let kicked = null;
+ return {
+ enabled,
+ run,
+ stats: () => ({ ...stats }),
+ kick() {
+ if (!enabled || kicked) return;
+ kicked = setTimeout(() => { kicked = null; quietly(); }, KICK_MS);
+ if (kicked.unref) kicked.unref();
+ },
+ start({ firstDelayMs = 60_000 } = {}) {
+ if (!enabled || timer) return;
+ first = setTimeout(quietly, firstDelayMs);
+ if (first.unref) first.unref();
+ timer = setInterval(quietly, EVERY_MS);
+ if (timer.unref) timer.unref();
+ },
+ stop() { clearTimeout(first); clearInterval(timer); clearTimeout(kicked); timer = null; first = null; kicked = null; },
+ };
+}
+
+module.exports = { createRecipeSearchIndex, documentFor };
@@ -109,6 +109,8 @@ ${staff ? html`<form method="post" action="/recipes/library/${x.slug}/review" cl
@@ -109,6 +109,8 @@ ${staff ? html`<form method="post" action="/recipes/library/${x.slug}/review" cl
const x = await lib.bySlug(req.params.slug);
const x = await lib.bySlug(req.params.slug);
if (!x) return res.status(404).type('text/plain').send('Not found.');
if (!x) return res.status(404).type('text/plain').send('Not found.');
await fn(x);
await fn(x);
+ // A check, a take-back or a hide changes what OpenVibe.Search should hold (server/food/search-index.js).
+ if (ctx.recipeSearch) ctx.recipeSearch.kick();
res.redirect(303, `/recipes/library/${x.slug}`);
res.redirect(303, `/recipes/library/${x.slug}`);
}
}
r.post('/recipes/library/:slug/review', form, (req, res) => staffAction(req, res, (x) => lib.review(x, req.viewer.subject, req.body.yes !== '0')));
r.post('/recipes/library/:slug/review', form, (req, res) => staffAction(req, res, (x) => lib.review(x, req.viewer.subject, req.body.yes !== '0')));
@@ -43,7 +43,8 @@ async function start() {
@@ -43,7 +43,8 @@ async function start() {
const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });
const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });
// The recipe library writes a theme every six hours (server/food/library.js); off without OpenVibe.AI.
// The recipe library writes a theme every six hours (server/food/library.js); off without OpenVibe.AI.
ctx.library.start();
ctx.library.start();
- const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.library.stop()];
+ ctx.recipeSearch.start();
+ const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.library.stop(), () => ctx.recipeSearch.stop()];
createLifecycle({ server, ctx, extra });
createLifecycle({ server, ctx, extra });
return { server, ctx };
return { server, ctx };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+'use strict';
+/**
+ * Checked library recipes in OpenVibe.Search (server/food/search-index.js), against a stand-in Search owner API:
+ * - each checked recipe is a valid search.index-document@1 (owner food, type recipe, /recipes/library/<slug>);
+ * - unchanged is not sent again; a change goes one revision up; a recipe no longer checked is tombstoned;
+ * - off without FOOD_SEARCH_URL.
+ */
+const assert = require('assert');
+const http = require('http');
+const { validate } = require('openvibe-contracts');
+const { createRecipeSearchIndex } = require('../server/food/search-index');
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.log(` ✗ ${name}\n ${err && err.stack}`); }
+}
+
+function startSearch() {
+ const docs = new Map();
+ const puts = [];
+ const server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ const u = new URL(req.url, 'http://search.test');
+ const send = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
+ if (req.headers.authorization !== 'Bearer svc-food') return send(401, { code: 'token.invalid' });
+ if (req.method === 'GET' && u.pathname === '/api/v1/owners/food/documents') {
+ return send(200, { documents: [...docs.values()].map((d) => ({ type: 'recipe', id: d.id, revision: d.revision, deleted: Boolean(d.deleted) })), next_after: null });
+ }
+ let m = /^\/api\/v1\/owners\/food\/documents\/recipe\/([^/]+)$/.exec(u.pathname);
+ if (m && req.method === 'GET') {
+ const d = docs.get(decodeURIComponent(m[1]));
+ return d && !d.deleted ? send(200, { document: d }) : send(404, { code: 'search.not_found' });
+ }
+ m = /^\/api\/v1\/documents\/food\/recipe\/([^/]+)$/.exec(u.pathname);
+ if (m && req.method === 'PUT') {
+ const doc = { ...JSON.parse(raw), owner: 'food', type: 'recipe', id: decodeURIComponent(m[1]) };
+ const v = validate('search.index-document@1', doc);
+ if (!v.valid) return send(422, { code: 'search.bad_document', errors: v.errors });
+ docs.set(doc.id, doc);
+ puts.push(doc.id);
+ return send(200, { outcome: 'applied' });
+ }
+ if (m && req.method === 'DELETE') {
+ const id = decodeURIComponent(m[1]);
+ docs.set(id, { id, revision: Number(u.searchParams.get('revision')), deleted: true });
+ return send(200, { outcome: 'applied' });
+ }
+ return send(404, { code: 'route.not_found' });
+ });
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ docs, puts, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));
+}
+
+(async () => {
+ const search = await startSearch();
+ let checked = [{
+ slug: 'beans-and-rice', title: 'Beans and rice', serves: 2, minutes: 25, cost: 4.5, cost_partial: false,
+ ingredients: [{ name: 'Black beans', amount: '1 can' }, { name: 'Rice', amount: '1 cup' }], steps: ['Cook the rice.', 'Warm the beans.'],
+ created_at: '2026-10-15T10:00:00.000Z', reviewed_at: '2026-10-15T12:00:00.000Z',
+ }];
+ const library = { reviewed: async () => checked };
+ const config = { search: { url: search.url }, oauth: { clientSecret: 'x', clientId: 'food' }, networkInternalUrl: 'http://network.test', baseUrl: 'https://openvibe.food' };
+ const index = createRecipeSearchIndex({ config, library, tokens: { authHeaders: async () => ({ authorization: 'Bearer svc-food' }) }, log: { warn() {} } });
+
+ await check('a checked recipe is a valid document with its own address', async () => {
+ const r = await index.run();
+ assert.strictEqual(r.upserted, 1, JSON.stringify(r));
+ const doc = search.docs.get('beans-and-rice');
+ assert.strictEqual(doc.canonical_url, 'https://openvibe.food/recipes/library/beans-and-rice');
+ assert.ok(doc.body.includes('Ingredients: 1 can Black beans; 1 cup Rice') && doc.summary.includes('$4.50 to buy'));
+ assert.strictEqual(doc.authorship, 'ai_generated');
+ assert.strictEqual(doc.revision, 1);
+ });
+
+ await check('unchanged is not sent again; a change goes one revision up; no longer checked, it is tombstoned', async () => {
+ await index.run();
+ assert.strictEqual(search.puts.length, 1, 'nothing changed');
+ checked = [{ ...checked[0], title: 'Beans and rice for two' }];
+ await index.run();
+ assert.strictEqual(search.docs.get('beans-and-rice').revision, 2);
+ checked = [];
+ const r = await index.run();
+ assert.strictEqual(r.deleted, 1);
+ assert.deepStrictEqual(search.docs.get('beans-and-rice'), { id: 'beans-and-rice', revision: 3, deleted: true });
+ });
+
+ await check('off without FOOD_SEARCH_URL', async () => {
+ const off = createRecipeSearchIndex({ config: { ...config, search: { url: '' } }, library });
+ assert.strictEqual(off.enabled, false);
+ assert.strictEqual(await off.run(), null);
+ });
+
+ await search.close();
+ console.log(failures ? `\n${failures} failed` : '\nall passed');
+ process.exit(failures ? 1 : 0);
+})();