openvibers/openvibe.food Commits
Food: add to your cupboard from a photo or a receipt (OpenVibe.AI) (#54)
Changes
@@@ -24,6 +24,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and| 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 || 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 || 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 |@@@ -103,6 +103,15 @@ server {# Events posts to http://127.0.0.1:4970/internal/events directly. Never proxied from outside (ADR-033).location ~* ^/internal(/|$) { return 404; }++ # ── A cupboard photo or receipt (POST /pantry/photo): the page shrinks it to at most 1280 px first, so a few++ # hundred KB; 4 MB matches the route's own body cap. Everywhere else keeps 128k.++ location = /pantry/photo {++ client_max_body_size 4m;++ limit_req zone=food_api burst=5 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:4970;++ }++# ── Pages, docs, robots.txt, sitemap, static assets ─────location / {proxy_pass http://127.0.0.1:4970;@@@ -7,3 +7,9 @@#bc-video[hidden] { display: none; }.pantry-confirm { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0; }.pantry-confirm select { font: inherit; padding: 8px 10px; border-radius: 10px; max-width: 100%; }++.pantry-photo fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 10px; margin: 0 0 16px; max-width: 560px; }++.pantry-photo legend { font-weight: 700; padding: 0 4px; }++.pantry-photo-kind { display: flex; gap: 14px; flex-wrap: wrap; margin: 4px 0 8px; }++.pantry-photo input[type=file] { display: block; margin: 6px 0 10px; max-width: 100%; }++.pantry-seen { list-style: none; padding: 0; display: grid; gap: 8px; }++.pantry-seen li { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border: 1px solid var(--border, #1f2d47); border-radius: 10px; }nnew file mode 100644@@@ -0,0 +1,34 @@++/* OpenVibe.Food — "Add from a photo or a receipt" on /pantry: shrink the chosen photo on the device (at most 1280 px,++ JPEG) and put it in the form as a data URL, so the server never handles a full-size camera file. */++(function () {++ 'use strict';++ var file = document.getElementById('photo-file');++ var data = document.getElementById('photo-data');++ var send = document.getElementById('photo-send');++ if (!file || !data || !send || !window.FileReader) return;++ var MAX = 1280;++ file.addEventListener('change', function () {++ send.disabled = true;++ data.value = '';++ var f = file.files && file.files[0];++ if (!f || !/^image\//.test(f.type)) return;++ send.textContent = 'Preparing the photo…';++ var reader = new FileReader();++ var img = new Image();++ img.onload = function () {++ var scale = Math.min(1, MAX / Math.max(img.naturalWidth, img.naturalHeight));++ var c = document.createElement('canvas');++ c.width = Math.max(1, Math.round(img.naturalWidth * scale));++ c.height = Math.max(1, Math.round(img.naturalHeight * scale));++ c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);++ data.value = c.toDataURL('image/jpeg', 0.82);++ send.disabled = false;++ send.textContent = 'Read the photo';++ };++ img.onerror = function () { send.textContent = 'That file is not a photo this browser can open'; };++ // A data: URL (Food's CSP allows data: images, not blob:).++ reader.onload = function () { img.src = reader.result; };++ reader.readAsDataURL(f);++ });++ document.getElementById('photo-form').addEventListener('submit', function () { send.disabled = true; send.textContent = 'Reading…'; });++})();@@@ -494,7 +494,7 @@ ${f.tags.length ? html`<p class="tags">${f.tags.map((t) => badge(t))}</p>` : ''}});// ── Your cupboard ────────────────────────────────────────-- async function pantryPage(req, res, { saved = false, added = null } = {}) {++ async function pantryPage(req, res, { saved = false, added = null, addedCount = 0 } = {}) {if (!signedIn(req)) {return page(req, res, {title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET],@@@ -511,11 +511,12 @@ ${foods.FOODS.filter((f) => f.group === id).map((f) => html`<label class="pantry</fieldset>`);const recipeLine = (x) => html`<li><b>${x.name}</b> — serves ${x.serves}, about ${x.minutes} minutes${x.missing && x.missing.length ? html` · missing ${x.missing.join(', ')}` : ''}</li>`;page(req, res, {-- title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET], localStyles: ['css/pantry.css'], scripts: ['js/barcode-scan.js'],++ title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET], localStyles: ['css/pantry.css'], scripts: ['js/barcode-scan.js', 'js/pantry-photo.js'],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') : ''}${added && foods.getFood(added) ? notice(html`Added <b>${foods.getFood(added).name}</b> to your cupboard.`, 'ok') : ''}++${addedCount > 1 && addedCount <= 80 ? notice(`Added ${addedCount} items to your cupboard.`, 'ok') : ''}<form method="get" action="/pantry/barcode" class="pantry-barcode" role="search"><label for="bc">Add by barcode</label><div class="pantry-barcode-row"><input id="bc" name="code" inputmode="numeric" autocomplete="off" pattern="[0-9 \-]{8,18}" maxlength="18" placeholder="The numbers under the bars" required>@@@ -524,6 +525,15 @@ ${added && foods.getFood(added) ? notice(html`Added <b>${foods.getFood(added).na<video id="bc-video" playsinline muted hidden></video><p class="muted small">Looks the product up on <a href="https://world.openfoodfacts.org/">Open Food Facts</a> and asks you before adding it. Only the barcode is sent.</p></form>++<form method="post" action="/pantry/photo" class="pantry-photo" id="photo-form">++<fieldset><legend>Add from a photo or a receipt</legend>++<div class="pantry-photo-kind"><label><input type="radio" name="kind" value="shelf" checked> A cupboard, fridge or shelf</label>++<label><input type="radio" name="kind" value="receipt"> A grocery receipt</label></div>++<input type="file" id="photo-file" accept="image/*" capture="environment" aria-describedby="photo-note">++<input type="hidden" name="image" id="photo-data">++<button class="sc-btn" type="submit" id="photo-send" disabled>Read the photo</button>++<p class="muted small" id="photo-note">OpenVibe.AI reads the photo and lists what it sees from Food's list; you choose what to add. The photo is made smaller on your device first, is not kept, and a receipt's prices, card and store details are never read out. Ten photos a day. Needs JavaScript.</p>++</fieldset></form><p><a href="/recipes">Get recipe ideas</a></p><form method="post" action="/pantry" class="pantry-form">${groupsBlock}@@@ -537,7 +547,7 @@ ${match.rest.length ? html`<h3>Further off</h3><ul>${match.rest.slice(0, 6).map(});}-- r.get('/pantry', (req, res) => pantryPage(req, res, { saved: !!req.query.saved, added: typeof req.query.added === 'string' ? req.query.added : null }));++ r.get('/pantry', (req, res) => pantryPage(req, res, { saved: !!req.query.saved, added: typeof req.query.added === 'string' ? req.query.added : null, addedCount: Number(req.query.added_count) || 0 }));// ── Pantry by barcode (server/food/barcode.js) ───────────// Look a barcode up and ask before adding: the match is preselected, any other item can be picked instead.@@@ -566,14 +576,72 @@ ${found ? html`<form method="post" action="/pantry/items" class="pantry-confirm"});});++ // ── Pantry by photo or receipt (OpenVibe.AI food.read_pantry_photo) ──++ // The page's script shrinks the photo on the device and sends it as a data URL; the model names list items by++ // number; the person ticks what to add. Ten photos a person a day (this process's count).++ const photoReads = new Map();++ const PHOTO_RE = /^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/=]+$/;++ r.post('/pantry/photo', express.urlencoded({ extended: false, limit: '4mb' }), async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fpantry');++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Send a photo from openvibe.food itself.');++ const b = req.body || {};++ const kind = b.kind === 'receipt' ? 'receipt' : 'shelf';++ const image = typeof b.image === 'string' ? b.image : '';++ const owner = ownerOf(req);++ const today = new Date(s.now()).toISOString().slice(0, 10);++ const used = photoReads.get(owner);++ const count = used && used.day === today ? used.n : 0;++ let problem = null;++ let out = null;++ if (!PHOTO_RE.test(image) || image.length > 3_500_000) problem = 'That photo did not arrive. Choose it again, and wait until "Read the photo" lights up.';++ else if (!config.aiUrl || !ctx.ai) problem = 'Reading photos needs OpenVibe.AI, which is not connected here.';++ else if (count >= 10) problem = 'That is ten photos today. Barcodes and the list below still work.';++ else {++ photoReads.set(owner, { day: today, n: count + 1 });++ try {++ const run = await ctx.ai.run('food.read_pantry_photo', { image: { data_url: image }, kind, foods: foods.FOODS.map((f) => f.name) }, {++ onBehalfOf: { type: 'user', id: owner.replace(/^user:/, '') }, target: { service: 'food', type: 'pantry', id: owner.replace(/^user:/, '') },++ });++ out = run && run.output;++ } catch (err) {++ if (err && err.name === 'AiRunError') problem = 'OpenVibe.AI could not read that photo just now. Try again in a moment, or use a barcode.';++ else throw err;++ }++ }++ const seen = new Map();++ for (const it of (out && Array.isArray(out.items) ? out.items : [])) {++ const f = Number.isInteger(it && it.food) ? foods.FOODS[it.food] : null;++ if (f && !seen.has(f.slug)) seen.set(f.slug, { food: f, seen: String(it.seen || '').slice(0, 160), confidence: ['high', 'medium', 'low'].includes(it.confidence) ? it.confidence : 'low' });++ }++ const have = new Set(await store.getPantry(s, owner));++ const unlisted = (out && Array.isArray(out.unlisted) ? out.unlisted : []).map((x) => String(x).slice(0, 120)).filter(Boolean).slice(0, 30);++ const rows = [...seen.values()];++ page(req, res, {++ title: kind === 'receipt' ? 'From your receipt' : 'From your photo', crumbs: [{ label: 'Your cupboard', href: '/pantry' }, { label: 'From a photo' }],++ styles: [showcase.STYLESHEET], localStyles: ['css/pantry.css'],++ body: html`<h1>${kind === 'receipt' ? 'From your receipt' : 'From your photo'}</h1>++${problem ? notice(problem, 'warn') : ''}++${!problem && rows.length ? html`<p class="lede">OpenVibe.AI saw these from Food's list. Untick anything that is wrong, then add the rest.</p>++<form method="post" action="/pantry/items" class="pantry-photo-confirm">++<ul class="pantry-seen">${rows.map((r) => html`<li><label><input type="checkbox" name="item" value="${r.food.slug}"${r.confidence !== 'low' ? raw(' checked') : ''}> <b>${r.food.name}</b>${have.has(r.food.slug) ? html` <span class="muted small">already in your cupboard</span>` : ''}</label>++<span class="muted small">${r.seen}${r.confidence === 'low' ? ' · not sure' : ''}</span></li>`)}</ul>++<button class="sc-btn sc-primary" type="submit">Add to my cupboard</button></form>` : ''}++${!problem && !rows.length ? html`<p class="lede">Nothing from Food's list was clear in that photo.</p>` : ''}++${unlisted.length ? html`<p class="muted small">Also seen, not on Food's list: ${unlisted.join(', ')}.</p>` : ''}++<p class="muted small">Read by OpenVibe.AI; the photo was not kept. Check before you cook or shop.</p>++<p><a href="/pantry">Back to your cupboard</a></p>`,++ });++ });++r.post('/pantry/items', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {res.set('Cache-Control', cache.htmlHeaders({ private: true }));if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fpantry');if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Add to a cupboard from openvibe.food itself.');-- const item = foods.getFood(String((req.body && req.body.item) || ''));-- if (!item) return res.redirect(303, '/pantry');-- await store.addPantryItem(s, ownerOf(req), item.slug);-- return res.redirect(303, `/pantry?added=${encodeURIComponent(item.slug)}`);++ const picked = [...new Set([].concat((req.body && req.body.item) || []).map((v) => foods.getFood(String(v))).filter(Boolean).map((f) => f.slug))].slice(0, 80);++ if (!picked.length) return res.redirect(303, '/pantry');++ for (const slug of picked) await store.addPantryItem(s, ownerOf(req), slug);++ return res.redirect(303, picked.length === 1 ? `/pantry?added=${encodeURIComponent(picked[0])}` : `/pantry?added_count=${picked.length}`);});r.post('/pantry', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++/**++ * Pantry by photo or receipt (POST /pantry/photo → OpenVibe.AI food.read_pantry_photo → the person ticks what to add):++ * - the model gets the photo (a data URL the page made smaller), the kind and Food's list by name, as the person;++ * - list numbers come back as Food's items, sure ones ticked and unsure ones not, plus what it saw off the list;++ * - a bad photo, a cross-site post, an AI failure and the eleventh photo of the day are refused politely;++ * - several ticked items are added in one post.++ */++const assert = require('assert');++const { AiRunError } = require('openvibe-publishing/ai');++const { boot, check, done } = require('./helpers/boot');++const foods = require('../server/food/foods');++++const SAME = { 'sec-fetch-site': 'same-origin' };++const PHOTO = 'data:image/jpeg;base64,' + Buffer.from('not really a jpeg, the stub does not look').toString('base64');++const idx = (slug) => foods.FOODS.findIndex((f) => f.slug === slug);++++(async () => {++ const calls = [];++ let fail = null;++ const ai = {++ async run(workflow, input, opts) {++ calls.push({ workflow, input, opts });++ if (fail) throw fail;++ return { runId: 'run_1', output: {++ items: [++ { food: idx('eggs-dozen'), seen: 'receipt line: LG EGGS 12CT', confidence: 'high' },++ { food: idx('whole-milk'), seen: 'receipt line: WHL MILK GAL', confidence: 'medium' },++ { food: idx('avocado'), seen: 'receipt line: AVO', confidence: 'low' },++ { food: 999, seen: 'out of range', confidence: 'high' },++ { food: idx('eggs-dozen'), seen: 'twice', confidence: 'high' },++ ],++ unlisted: ['sparkling water'], gaps: [],++ } };++ },++ };++ const t = await boot({ env: { OV_AI_INTERNAL_URL: 'http://ai.test' }, ai });++ const kim = t.network.addUser('kim');++ const post = (form, headers = SAME) => t.get('/pantry/photo', { form, method: 'POST', as: kim, headers });++ try {++ await check('the photo goes to OpenVibe.AI as the person, with the kind and the list by name', async () => {++ const r = await post({ kind: 'receipt', image: PHOTO });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.strictEqual(calls.length, 1);++ assert.strictEqual(calls[0].workflow, 'food.read_pantry_photo');++ assert.deepStrictEqual(Object.keys(calls[0].input).sort(), ['foods', 'image', 'kind']);++ assert.strictEqual(calls[0].input.kind, 'receipt');++ assert.strictEqual(calls[0].input.image.data_url, PHOTO);++ assert.strictEqual(calls[0].input.foods.length, foods.FOODS.length);++ assert.strictEqual(calls[0].opts.onBehalfOf.id, kim.subject);++ assert.ok(r.text.includes('From your receipt'));++ assert.ok(r.text.includes('<input type="checkbox" name="item" value="eggs-dozen" checked>'), 'sure: ticked');++ assert.ok(r.text.includes('<input type="checkbox" name="item" value="whole-milk" checked>'));++ assert.ok(r.text.includes('<input type="checkbox" name="item" value="avocado">'), 'unsure: not ticked');++ assert.strictEqual((r.text.match(/value="eggs-dozen"/g) || []).length, 1, 'an item is listed once');++ assert.ok(r.text.includes('sparkling water'), 'what it saw off the list');++ });++++ await check('several ticked items are added in one post', async () => {++ const r = await t.get('/pantry/items', { body: 'item=eggs-dozen&item=whole-milk&item=nope', method: 'POST', as: kim, headers: { ...SAME, 'content-type': 'application/x-www-form-urlencoded' } });++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.get('location'), '/pantry?added_count=2');++ assert.deepStrictEqual((await t.get('/api/v1/pantry', { as: kim })).json().items, ['eggs-dozen', 'whole-milk']);++ assert.ok((await t.get('/pantry?added_count=2', { as: kim })).text.includes('Added 2 items to your cupboard.'));++ });++++ await check('a bad photo, a cross-site post and an AI failure are refused politely; ten photos a day', async () => {++ assert.match((await post({ kind: 'shelf', image: 'data:text/html;base64,PHNjcmlwdD4=' })).text, /That photo did not arrive/);++ assert.strictEqual((await post({ kind: 'shelf', image: PHOTO }, { 'sec-fetch-site': 'cross-site' })).status, 403);++ fail = new AiRunError('ai.run_failed', 'the model failed', { status: 502 });++ assert.match((await post({ kind: 'shelf', image: PHOTO })).text, /could not read that photo just now/);++ fail = null;++ let last = null;++ for (let i = 0; i < 8; i++) last = await post({ kind: 'shelf', image: PHOTO });++ assert.ok(last.text.includes('From your photo'), 'the tenth still runs');++ const n = calls.length;++ assert.match((await post({ kind: 'shelf', image: PHOTO })).text, /That is ten photos today/);++ assert.strictEqual(calls.length, n, 'the eleventh never reaches OpenVibe.AI');++ });++++ await check('the cupboard page offers the photo form', async () => {++ const page = await t.get('/pantry', { as: kim });++ assert.ok(page.text.includes('action="/pantry/photo"') && page.text.includes('js/pantry-photo.js') && page.text.includes('capture="environment"'));++ });++ } finally { await t.close(); }++ done();++})();
@@ -24,6 +24,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
@@ -24,6 +24,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
@@ -103,6 +103,15 @@ server {
@@ -103,6 +103,15 @@ server {
# Events posts to http://127.0.0.1:4970/internal/events directly. Never proxied from outside (ADR-033).
# Events posts to http://127.0.0.1:4970/internal/events directly. Never proxied from outside (ADR-033).
location ~* ^/internal(/|$) { return 404; }
location ~* ^/internal(/|$) { return 404; }
+ # ── A cupboard photo or receipt (POST /pantry/photo): the page shrinks it to at most 1280 px first, so a few
+ # hundred KB; 4 MB matches the route's own body cap. Everywhere else keeps 128k.
+ location = /pantry/photo {
+ client_max_body_size 4m;
+ limit_req zone=food_api burst=5 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:4970;
+ }
+
# ── Pages, docs, robots.txt, sitemap, static assets ─────
# ── Pages, docs, robots.txt, sitemap, static assets ─────
location / {
location / {
proxy_pass http://127.0.0.1:4970;
proxy_pass http://127.0.0.1:4970;
@@ -7,3 +7,9 @@
@@ -7,3 +7,9 @@
#bc-video[hidden] { display: none; }
#bc-video[hidden] { display: none; }
.pantry-confirm { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0; }
.pantry-confirm { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0; }
.pantry-confirm select { font: inherit; padding: 8px 10px; border-radius: 10px; max-width: 100%; }
.pantry-confirm select { font: inherit; padding: 8px 10px; border-radius: 10px; max-width: 100%; }
+.pantry-photo fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 10px; margin: 0 0 16px; max-width: 560px; }
+.pantry-photo legend { font-weight: 700; padding: 0 4px; }
+.pantry-photo-kind { display: flex; gap: 14px; flex-wrap: wrap; margin: 4px 0 8px; }
+.pantry-photo input[type=file] { display: block; margin: 6px 0 10px; max-width: 100%; }
+.pantry-seen { list-style: none; padding: 0; display: grid; gap: 8px; }
+.pantry-seen li { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border: 1px solid var(--border, #1f2d47); border-radius: 10px; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+/* OpenVibe.Food — "Add from a photo or a receipt" on /pantry: shrink the chosen photo on the device (at most 1280 px,
+ JPEG) and put it in the form as a data URL, so the server never handles a full-size camera file. */
+(function () {
+ 'use strict';
+ var file = document.getElementById('photo-file');
+ var data = document.getElementById('photo-data');
+ var send = document.getElementById('photo-send');
+ if (!file || !data || !send || !window.FileReader) return;
+ var MAX = 1280;
+ file.addEventListener('change', function () {
+ send.disabled = true;
+ data.value = '';
+ var f = file.files && file.files[0];
+ if (!f || !/^image\//.test(f.type)) return;
+ send.textContent = 'Preparing the photo…';
+ var reader = new FileReader();
+ var img = new Image();
+ img.onload = function () {
+ var scale = Math.min(1, MAX / Math.max(img.naturalWidth, img.naturalHeight));
+ var c = document.createElement('canvas');
+ c.width = Math.max(1, Math.round(img.naturalWidth * scale));
+ c.height = Math.max(1, Math.round(img.naturalHeight * scale));
+ c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
+ data.value = c.toDataURL('image/jpeg', 0.82);
+ send.disabled = false;
+ send.textContent = 'Read the photo';
+ };
+ img.onerror = function () { send.textContent = 'That file is not a photo this browser can open'; };
+ // A data: URL (Food's CSP allows data: images, not blob:).
+ reader.onload = function () { img.src = reader.result; };
+ reader.readAsDataURL(f);
+ });
+ document.getElementById('photo-form').addEventListener('submit', function () { send.disabled = true; send.textContent = 'Reading…'; });
+})();
@@ -494,7 +494,7 @@ ${f.tags.length ? html`<p class="tags">${f.tags.map((t) => badge(t))}</p>` : ''}
@@ -494,7 +494,7 @@ ${f.tags.length ? html`<p class="tags">${f.tags.map((t) => badge(t))}</p>` : ''}
});
});
// ── Your cupboard ────────────────────────────────────────
// ── Your cupboard ────────────────────────────────────────
- async function pantryPage(req, res, { saved = false, added = null } = {}) {
+ async function pantryPage(req, res, { saved = false, added = null, addedCount = 0 } = {}) {
if (!signedIn(req)) {
if (!signedIn(req)) {
return page(req, res, {
return page(req, res, {
title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET],
title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET],
@@ -511,11 +511,12 @@ ${foods.FOODS.filter((f) => f.group === id).map((f) => html`<label class="pantry
@@ -511,11 +511,12 @@ ${foods.FOODS.filter((f) => f.group === id).map((f) => html`<label class="pantry
</fieldset>`);
</fieldset>`);
const recipeLine = (x) => html`<li><b>${x.name}</b> — serves ${x.serves}, about ${x.minutes} minutes${x.missing && x.missing.length ? html` · missing ${x.missing.join(', ')}` : ''}</li>`;
const recipeLine = (x) => html`<li><b>${x.name}</b> — serves ${x.serves}, about ${x.minutes} minutes${x.missing && x.missing.length ? html` · missing ${x.missing.join(', ')}` : ''}</li>`;
page(req, res, {
page(req, res, {
- title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET], localStyles: ['css/pantry.css'], scripts: ['js/barcode-scan.js'],
+ title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET], localStyles: ['css/pantry.css'], scripts: ['js/barcode-scan.js', 'js/pantry-photo.js'],
body: html`<h1>Your cupboard</h1>
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>
<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') : ''}
${saved ? notice('Cupboard saved.', 'ok') : ''}
${added && foods.getFood(added) ? notice(html`Added <b>${foods.getFood(added).name}</b> to your cupboard.`, 'ok') : ''}
${added && foods.getFood(added) ? notice(html`Added <b>${foods.getFood(added).name}</b> to your cupboard.`, 'ok') : ''}
+${addedCount > 1 && addedCount <= 80 ? notice(`Added ${addedCount} items to your cupboard.`, 'ok') : ''}
<form method="get" action="/pantry/barcode" class="pantry-barcode" role="search">
<form method="get" action="/pantry/barcode" class="pantry-barcode" role="search">
<label for="bc">Add by barcode</label>
<label for="bc">Add by barcode</label>
<div class="pantry-barcode-row"><input id="bc" name="code" inputmode="numeric" autocomplete="off" pattern="[0-9 \-]{8,18}" maxlength="18" placeholder="The numbers under the bars" required>
<div class="pantry-barcode-row"><input id="bc" name="code" inputmode="numeric" autocomplete="off" pattern="[0-9 \-]{8,18}" maxlength="18" placeholder="The numbers under the bars" required>
@@ -524,6 +525,15 @@ ${added && foods.getFood(added) ? notice(html`Added <b>${foods.getFood(added).na
@@ -524,6 +525,15 @@ ${added && foods.getFood(added) ? notice(html`Added <b>${foods.getFood(added).na
<video id="bc-video" playsinline muted hidden></video>
<video id="bc-video" playsinline muted hidden></video>
<p class="muted small">Looks the product up on <a href="https://world.openfoodfacts.org/">Open Food Facts</a> and asks you before adding it. Only the barcode is sent.</p>
<p class="muted small">Looks the product up on <a href="https://world.openfoodfacts.org/">Open Food Facts</a> and asks you before adding it. Only the barcode is sent.</p>
</form>
</form>
+<form method="post" action="/pantry/photo" class="pantry-photo" id="photo-form">
+<fieldset><legend>Add from a photo or a receipt</legend>
+<div class="pantry-photo-kind"><label><input type="radio" name="kind" value="shelf" checked> A cupboard, fridge or shelf</label>
+<label><input type="radio" name="kind" value="receipt"> A grocery receipt</label></div>
+<input type="file" id="photo-file" accept="image/*" capture="environment" aria-describedby="photo-note">
+<input type="hidden" name="image" id="photo-data">
+<button class="sc-btn" type="submit" id="photo-send" disabled>Read the photo</button>
+<p class="muted small" id="photo-note">OpenVibe.AI reads the photo and lists what it sees from Food's list; you choose what to add. The photo is made smaller on your device first, is not kept, and a receipt's prices, card and store details are never read out. Ten photos a day. Needs JavaScript.</p>
+</fieldset></form>
<p><a href="/recipes">Get recipe ideas</a></p>
<p><a href="/recipes">Get recipe ideas</a></p>
<form method="post" action="/pantry" class="pantry-form">
<form method="post" action="/pantry" class="pantry-form">
${groupsBlock}
${groupsBlock}
@@ -537,7 +547,7 @@ ${match.rest.length ? html`<h3>Further off</h3><ul>${match.rest.slice(0, 6).map(
@@ -537,7 +547,7 @@ ${match.rest.length ? html`<h3>Further off</h3><ul>${match.rest.slice(0, 6).map(
});
});
}
}
- r.get('/pantry', (req, res) => pantryPage(req, res, { saved: !!req.query.saved, added: typeof req.query.added === 'string' ? req.query.added : null }));
+ r.get('/pantry', (req, res) => pantryPage(req, res, { saved: !!req.query.saved, added: typeof req.query.added === 'string' ? req.query.added : null, addedCount: Number(req.query.added_count) || 0 }));
// ── Pantry by barcode (server/food/barcode.js) ───────────
// ── Pantry by barcode (server/food/barcode.js) ───────────
// Look a barcode up and ask before adding: the match is preselected, any other item can be picked instead.
// Look a barcode up and ask before adding: the match is preselected, any other item can be picked instead.
@@ -566,14 +576,72 @@ ${found ? html`<form method="post" action="/pantry/items" class="pantry-confirm"
@@ -566,14 +576,72 @@ ${found ? html`<form method="post" action="/pantry/items" class="pantry-confirm"
});
});
});
});
+ // ── Pantry by photo or receipt (OpenVibe.AI food.read_pantry_photo) ──
+ // The page's script shrinks the photo on the device and sends it as a data URL; the model names list items by
+ // number; the person ticks what to add. Ten photos a person a day (this process's count).
+ const photoReads = new Map();
+ const PHOTO_RE = /^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/=]+$/;
+ r.post('/pantry/photo', express.urlencoded({ extended: false, limit: '4mb' }), async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fpantry');
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Send a photo from openvibe.food itself.');
+ const b = req.body || {};
+ const kind = b.kind === 'receipt' ? 'receipt' : 'shelf';
+ const image = typeof b.image === 'string' ? b.image : '';
+ const owner = ownerOf(req);
+ const today = new Date(s.now()).toISOString().slice(0, 10);
+ const used = photoReads.get(owner);
+ const count = used && used.day === today ? used.n : 0;
+ let problem = null;
+ let out = null;
+ if (!PHOTO_RE.test(image) || image.length > 3_500_000) problem = 'That photo did not arrive. Choose it again, and wait until "Read the photo" lights up.';
+ else if (!config.aiUrl || !ctx.ai) problem = 'Reading photos needs OpenVibe.AI, which is not connected here.';
+ else if (count >= 10) problem = 'That is ten photos today. Barcodes and the list below still work.';
+ else {
+ photoReads.set(owner, { day: today, n: count + 1 });
+ try {
+ const run = await ctx.ai.run('food.read_pantry_photo', { image: { data_url: image }, kind, foods: foods.FOODS.map((f) => f.name) }, {
+ onBehalfOf: { type: 'user', id: owner.replace(/^user:/, '') }, target: { service: 'food', type: 'pantry', id: owner.replace(/^user:/, '') },
+ });
+ out = run && run.output;
+ } catch (err) {
+ if (err && err.name === 'AiRunError') problem = 'OpenVibe.AI could not read that photo just now. Try again in a moment, or use a barcode.';
+ else throw err;
+ }
+ }
+ const seen = new Map();
+ for (const it of (out && Array.isArray(out.items) ? out.items : [])) {
+ const f = Number.isInteger(it && it.food) ? foods.FOODS[it.food] : null;
+ if (f && !seen.has(f.slug)) seen.set(f.slug, { food: f, seen: String(it.seen || '').slice(0, 160), confidence: ['high', 'medium', 'low'].includes(it.confidence) ? it.confidence : 'low' });
+ }
+ const have = new Set(await store.getPantry(s, owner));
+ const unlisted = (out && Array.isArray(out.unlisted) ? out.unlisted : []).map((x) => String(x).slice(0, 120)).filter(Boolean).slice(0, 30);
+ const rows = [...seen.values()];
+ page(req, res, {
+ title: kind === 'receipt' ? 'From your receipt' : 'From your photo', crumbs: [{ label: 'Your cupboard', href: '/pantry' }, { label: 'From a photo' }],
+ styles: [showcase.STYLESHEET], localStyles: ['css/pantry.css'],
+ body: html`<h1>${kind === 'receipt' ? 'From your receipt' : 'From your photo'}</h1>
+${problem ? notice(problem, 'warn') : ''}
+${!problem && rows.length ? html`<p class="lede">OpenVibe.AI saw these from Food's list. Untick anything that is wrong, then add the rest.</p>
+<form method="post" action="/pantry/items" class="pantry-photo-confirm">
+<ul class="pantry-seen">${rows.map((r) => html`<li><label><input type="checkbox" name="item" value="${r.food.slug}"${r.confidence !== 'low' ? raw(' checked') : ''}> <b>${r.food.name}</b>${have.has(r.food.slug) ? html` <span class="muted small">already in your cupboard</span>` : ''}</label>
+<span class="muted small">${r.seen}${r.confidence === 'low' ? ' · not sure' : ''}</span></li>`)}</ul>
+<button class="sc-btn sc-primary" type="submit">Add to my cupboard</button></form>` : ''}
+${!problem && !rows.length ? html`<p class="lede">Nothing from Food's list was clear in that photo.</p>` : ''}
+${unlisted.length ? html`<p class="muted small">Also seen, not on Food's list: ${unlisted.join(', ')}.</p>` : ''}
+<p class="muted small">Read by OpenVibe.AI; the photo was not kept. Check before you cook or shop.</p>
+<p><a href="/pantry">Back to your cupboard</a></p>`,
+ });
+ });
+
r.post('/pantry/items', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
r.post('/pantry/items', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fpantry');
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fpantry');
if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Add to a cupboard from openvibe.food itself.');
if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Add to a cupboard from openvibe.food itself.');
- const item = foods.getFood(String((req.body && req.body.item) || ''));
- if (!item) return res.redirect(303, '/pantry');
- await store.addPantryItem(s, ownerOf(req), item.slug);
- return res.redirect(303, `/pantry?added=${encodeURIComponent(item.slug)}`);
+ const picked = [...new Set([].concat((req.body && req.body.item) || []).map((v) => foods.getFood(String(v))).filter(Boolean).map((f) => f.slug))].slice(0, 80);
+ if (!picked.length) return res.redirect(303, '/pantry');
+ for (const slug of picked) await store.addPantryItem(s, ownerOf(req), slug);
+ return res.redirect(303, picked.length === 1 ? `/pantry?added=${encodeURIComponent(picked[0])}` : `/pantry?added_count=${picked.length}`);
});
});
r.post('/pantry', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
r.post('/pantry', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+/**
+ * Pantry by photo or receipt (POST /pantry/photo → OpenVibe.AI food.read_pantry_photo → the person ticks what to add):
+ * - the model gets the photo (a data URL the page made smaller), the kind and Food's list by name, as the person;
+ * - list numbers come back as Food's items, sure ones ticked and unsure ones not, plus what it saw off the list;
+ * - a bad photo, a cross-site post, an AI failure and the eleventh photo of the day are refused politely;
+ * - several ticked items are added in one post.
+ */
+const assert = require('assert');
+const { AiRunError } = require('openvibe-publishing/ai');
+const { boot, check, done } = require('./helpers/boot');
+const foods = require('../server/food/foods');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+const PHOTO = 'data:image/jpeg;base64,' + Buffer.from('not really a jpeg, the stub does not look').toString('base64');
+const idx = (slug) => foods.FOODS.findIndex((f) => f.slug === slug);
+
+(async () => {
+ const calls = [];
+ let fail = null;
+ const ai = {
+ async run(workflow, input, opts) {
+ calls.push({ workflow, input, opts });
+ if (fail) throw fail;
+ return { runId: 'run_1', output: {
+ items: [
+ { food: idx('eggs-dozen'), seen: 'receipt line: LG EGGS 12CT', confidence: 'high' },
+ { food: idx('whole-milk'), seen: 'receipt line: WHL MILK GAL', confidence: 'medium' },
+ { food: idx('avocado'), seen: 'receipt line: AVO', confidence: 'low' },
+ { food: 999, seen: 'out of range', confidence: 'high' },
+ { food: idx('eggs-dozen'), seen: 'twice', confidence: 'high' },
+ ],
+ unlisted: ['sparkling water'], gaps: [],
+ } };
+ },
+ };
+ const t = await boot({ env: { OV_AI_INTERNAL_URL: 'http://ai.test' }, ai });
+ const kim = t.network.addUser('kim');
+ const post = (form, headers = SAME) => t.get('/pantry/photo', { form, method: 'POST', as: kim, headers });
+ try {
+ await check('the photo goes to OpenVibe.AI as the person, with the kind and the list by name', async () => {
+ const r = await post({ kind: 'receipt', image: PHOTO });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.strictEqual(calls.length, 1);
+ assert.strictEqual(calls[0].workflow, 'food.read_pantry_photo');
+ assert.deepStrictEqual(Object.keys(calls[0].input).sort(), ['foods', 'image', 'kind']);
+ assert.strictEqual(calls[0].input.kind, 'receipt');
+ assert.strictEqual(calls[0].input.image.data_url, PHOTO);
+ assert.strictEqual(calls[0].input.foods.length, foods.FOODS.length);
+ assert.strictEqual(calls[0].opts.onBehalfOf.id, kim.subject);
+ assert.ok(r.text.includes('From your receipt'));
+ assert.ok(r.text.includes('<input type="checkbox" name="item" value="eggs-dozen" checked>'), 'sure: ticked');
+ assert.ok(r.text.includes('<input type="checkbox" name="item" value="whole-milk" checked>'));
+ assert.ok(r.text.includes('<input type="checkbox" name="item" value="avocado">'), 'unsure: not ticked');
+ assert.strictEqual((r.text.match(/value="eggs-dozen"/g) || []).length, 1, 'an item is listed once');
+ assert.ok(r.text.includes('sparkling water'), 'what it saw off the list');
+ });
+
+ await check('several ticked items are added in one post', async () => {
+ const r = await t.get('/pantry/items', { body: 'item=eggs-dozen&item=whole-milk&item=nope', method: 'POST', as: kim, headers: { ...SAME, 'content-type': 'application/x-www-form-urlencoded' } });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.get('location'), '/pantry?added_count=2');
+ assert.deepStrictEqual((await t.get('/api/v1/pantry', { as: kim })).json().items, ['eggs-dozen', 'whole-milk']);
+ assert.ok((await t.get('/pantry?added_count=2', { as: kim })).text.includes('Added 2 items to your cupboard.'));
+ });
+
+ await check('a bad photo, a cross-site post and an AI failure are refused politely; ten photos a day', async () => {
+ assert.match((await post({ kind: 'shelf', image: 'data:text/html;base64,PHNjcmlwdD4=' })).text, /That photo did not arrive/);
+ assert.strictEqual((await post({ kind: 'shelf', image: PHOTO }, { 'sec-fetch-site': 'cross-site' })).status, 403);
+ fail = new AiRunError('ai.run_failed', 'the model failed', { status: 502 });
+ assert.match((await post({ kind: 'shelf', image: PHOTO })).text, /could not read that photo just now/);
+ fail = null;
+ let last = null;
+ for (let i = 0; i < 8; i++) last = await post({ kind: 'shelf', image: PHOTO });
+ assert.ok(last.text.includes('From your photo'), 'the tenth still runs');
+ const n = calls.length;
+ assert.match((await post({ kind: 'shelf', image: PHOTO })).text, /That is ten photos today/);
+ assert.strictEqual(calls.length, n, 'the eleventh never reaches OpenVibe.AI');
+ });
+
+ await check('the cupboard page offers the photo form', async () => {
+ const page = await t.get('/pantry', { as: kim });
+ assert.ok(page.text.includes('action="/pantry/photo"') && page.text.includes('js/pantry-photo.js') && page.text.includes('capture="environment"'));
+ });
+ } finally { await t.close(); }
+ done();
+})();