openvibers/openvibe.food Commits

Food: the household chat, with Pantry, an AI member that knows the cupboards (#51)

OP OpenVibers committed 28 days ago · dbbe7a9628 · 9 files, +255 −2

Owner Q15: a household with a shared pantry and a group chat with an AI member that knows it. The owner of a shared plan starts a private OpenVibe.Chat room for the household (Chat's room API as the owner): every member by account subject, later joiners on the owner's page view, and Pantry, an AI member linked to the plan. Chat reads the household through GET /internal/households/:id/chat-context (food.household.read, a service token only): the plan, what is still to buy and every member's cupboard. Migration 0007 keeps the room's slug. Needs Contracts 0.189.0 (the pin round) and Chat#102 (members by subject, AI members) deployed first.

Changes

ddiff --git a/README.md b/README.md
iindex 885bd2d..539c1b2 100644
---- a/README.md
++++ b/README.md
@@@ -29,6 +29,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
| 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 |
++| Household chat | [/plan/p/:id](server/http/pages.js), [server/food/household-chat.js](server/food/household-chat.js) | The owner of a shared plan starts a private room on OpenVibe.Chat for the household, made through Chat's room API as the owner, with every member added by account subject (and anyone who joins later, when the owner opens the plan) and **Pantry**, an AI member linked to the plan. Writing `@pantry` there asks it: Chat reads the household from `GET /internal/households/:id/chat-context` (`food.household.read`, Chat's service token) — the plan, what is still to buy and every member's cupboard — and OpenVibe.AI answers. The plan's page links the room for everyone |
| 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 |
@@@ -94,6 +95,8 @@ declares the same four plus one proposed:
- `food.plan.write` — save a plan
- `food.pantry.read` — read a caller's pantry
- `food.pantry.write` — replace a caller's pantry
++- `food.household.read` — a household's pantry and plan as text for its OpenVibe.Chat room's AI member (internal,
++ loopback only, a service token; granted to Chat)
- `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)
@@@ -166,6 +169,8 @@ See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRE
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.
++`FOOD_CHAT_INTERNAL_URL` (default `http://127.0.0.1:4400`) is where the household chat calls OpenVibe.Chat's room API,
++and `FOOD_CHAT_PUBLIC_URL` (`https://openvibe.chat`) what the plan's page links.
## Development
ddiff --git a/migrations/0008_plan_chat.sql b/migrations/0008_plan_chat.sql
nnew file mode 100644
iindex 0000000..dc18e17
---- /dev/null
++++ b/migrations/0008_plan_chat.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- OpenVibe.Food: a household's chat (server/food/household-chat.js). The owner of a shared plan can open a private
++-- room for it on OpenVibe.Chat with an AI member, Pantry, that knows the plan and the household's cupboards; this is
++-- the room's address there (openvibe.chat/r/<chat_room>). Nothing here is a credential.
++ALTER TABLE food_plans ADD COLUMN IF NOT EXISTS chat_room text COLLATE "C";
ddiff --git a/server/app.js b/server/app.js
iindex b039b9b..8fba961 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -153,6 +153,16 @@ async function createApp(opts = {}) {
// 404 for /internal/ so this is reachable only on 127.0.0.1.
app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));
++ // ── A household's pantry and plan for its OpenVibe.Chat room's AI member (food.household.read, granted to Chat) ──
++ // Loopback only like every /internal/ route; a service token with the capability, never a person.
++ app.get('/internal/households/:id/chat-context', principal.middleware, (req, res, next) => (req.principal.kind === 'app' ? next() : contracts.http.sendProblem(res, 403, 'food.service_only', { detail: 'A service token with food.household.read reads this.', ctx: req.ov })), principal.requireCapability('food.household.read'), async (req, res) => {
++ const id = String(req.params.id || '');
++ const out = /^pln_[0-9A-Z]{26}$/.test(id) ? await require('./food/household-chat').contextOf(s, id) : null;
++ if (!out) return contracts.http.sendProblem(res, 404, 'food.household_not_found', { detail: 'No such household.', ctx: req.ov });
++ res.set('Cache-Control', 'no-store');
++ return res.json(out);
++ });
++
// ── Static assets (content-hashed ?v= → immutable) ──────
app.use('/shared', require('openvibe-shared/serve').handler());
app.use(express.static(PUBLIC_DIR, {
ddiff --git a/server/config.js b/server/config.js
iindex ee127f4..54becce 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -65,6 +65,9 @@ function load(env = process.env) {
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 || '') },
++ // OpenVibe.Chat (server/food/household-chat.js): a household's room, made as its owner through Chat's room API.
++ // `internalUrl` is where this service calls it (the loopback address in production), `publicUrl` what people open.
++ chat: { internalUrl: trim(env.FOOD_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400'), publicUrl: trim(env.FOOD_CHAT_PUBLIC_URL || 'https://openvibe.chat') },
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
// The audience this service's app, agent and service tokens carry.
audience: env.FOOD_AUDIENCE || 'openvibe.food',
ddiff --git a/server/food/household-chat.js b/server/food/household-chat.js
nnew file mode 100644
iindex 0000000..915ed85
---- /dev/null
++++ b/server/food/household-chat.js
@@@ -0,0 +1,110 @@
++'use strict';
++
++/**
++ * A household's chat (the owner's Q15: a household with a shared pantry and a group chat with an AI member that knows
++ * it). A household is a saved plan shared with its members (migrations/0005). Its owner can open a private room on
++ * OpenVibe.Chat for it: Food calls Chat's room API as the owner (their own Network token from this site's session),
++ * makes the room, adds every member by account subject, and adds an AI member, Pantry, linked to this plan. When
++ * someone writes @pantry there, Chat reads contextOf() through GET /internal/households/:planId/chat-context with its
++ * service token (food.household.read) and the AI answers knowing the plan, what is still to buy and every member's
++ * cupboard.
++ *
++ * contextOf(s, planId) → { text, source, updated_at } | null
++ * openRoom(deps, s, row, members) → { ok, slug } | { ok: false, detail }
++ * syncMembers(deps, row, members) adds members who joined since (the owner's page view, at most every
++ * ten minutes a plan)
++ *
++ * deps: { config, token, fetchImpl, log }. Food stores only the room's slug (food_plans.chat_room).
++ */
++const foods = require('./foods');
++const store = require('./store');
++
++const AI_NAME = 'Pantry';
++const PERSONA = 'A friendly, practical cook for this household. Suggest cheap, simple meals from what is already in our cupboards and on our plan, say what we would still need to buy, and keep answers short.';
++const SOURCE = 'OpenVibe.Food household';
++const synced = new Map(); // plan id → last member sync (ms)
++
++const nameOfFood = (slug) => { const f = foods.getFood(slug); return f ? f.name : slug; };
++const list = (items, empty = 'nothing') => (items.length ? items.join(', ') : empty);
++
++/** The household as text for the AI member: who, the plan, what is left to buy, and every cupboard. */
++async function contextOf(s, planId) {
++ const row = await s.db.maybe('SELECT * FROM food_plans WHERE id = $1', [planId]);
++ if (!row) return null;
++ const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;
++ const [members, ticks] = await Promise.all([store.listMembers(s, row.id), store.listTicks(s, row.id)]);
++ const ticked = new Set(ticks.map((t) => t.item));
++ const people = [{ who: row.owner, name: 'the plan\'s owner' }, ...members.map((m) => ({ who: m.member, name: m.name || 'a member' }))];
++ const lines = [];
++ lines.push(`Household: ${people.map((p) => p.name).join(', ')} (${people.length} ${people.length === 1 ? 'person' : 'people'}).`);
++ lines.push(`Plan "${row.title || 'Meal plan'}": ${plan.spec.people} ${plan.spec.people === 1 ? 'person' : 'people'}, ${plan.spec.days} ${plan.spec.days === 1 ? 'day' : 'days'}${plan.spec.budget != null ? `, budget ${plan.spec.budget} USD` : ''}, estimated ${plan.totals.cost} USD.`);
++ const toBuy = plan.shopping_list.filter((i) => !ticked.has(i.slug)).map((i) => `${i.name} ×${i.packs}`);
++ const got = plan.shopping_list.filter((i) => ticked.has(i.slug)).map((i) => i.name);
++ lines.push(`Still to buy: ${list(toBuy)}.`);
++ if (got.length) lines.push(`Already bought: ${list(got)}.`);
++ const menu = (plan.menu || []).slice(0, 7).map((d) => `Day ${d.day}: ${['breakfast', 'lunch', 'dinner', 'snack'].map((k) => `${k} ${list(d[k] || [], '-')}`).join('; ')}`);
++ if (menu.length) lines.push(`The plan's meals:\n${menu.join('\n')}`);
++ for (const p of people) {
++ const items = await store.getPantry(s, p.who);
++ lines.push(`${p.name === 'the plan\'s owner' ? 'The owner\'s' : `${p.name}'s`} cupboard: ${list(items.map(nameOfFood), 'empty (nothing listed on OpenVibe.Food)')}.`);
++ }
++ return { text: lines.join('\n').slice(0, 4000), source: SOURCE, updated_at: new Date(s.now()).toISOString() };
++}
++
++async function chatCall(deps, method, path, body) {
++ const res = await (deps.fetchImpl || globalThis.fetch)(`${deps.config.chat.internalUrl}/api/chat/rooms${path}`, {
++ method,
++ headers: { Authorization: `Bearer ${deps.token}`, 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: body ? JSON.stringify(body) : undefined,
++ signal: AbortSignal.timeout(10_000),
++ });
++ const json = await res.json().catch(() => null);
++ return { ok: res.ok, status: res.status, body: json };
++}
++
++const subjectOf = (requester) => String(requester || '').replace(/^user:/, '');
++
++/** Make the household's room on Chat (private), add its members and Pantry. */
++async function openRoom(deps, s, row, members) {
++ if (!deps.token) return { ok: false, detail: 'Sign in again, then start the chat.' };
++ const slug = `household-${row.id.slice(-10).toLowerCase()}`;
++ const name = `${String(row.title || 'Our household').slice(0, 30)} household`.replace(/\s+/g, ' ').trim();
++ let made = await chatCall(deps, 'POST', '/', { name, slug, topic: 'Our meal plan, our shopping and what to cook. Write @pantry to ask the AI.', visibility: 'private' });
++ if (!made.ok && made.status !== 409) {
++ (deps.log || console).warn(`[Food] household chat for ${row.id}: Chat answered ${made.status}`);
++ return { ok: false, detail: made.status === 401 ? 'Sign in again, then start the chat.' : 'OpenVibe.Chat did not make the room just now. Try again in a minute.' };
++ }
++ if (!made.ok) {
++ // 409: the address exists. It is this household's room only if this person owns it.
++ const mine = await chatCall(deps, 'GET', `/${slug}`);
++ if (!mine.ok || !mine.body || !mine.body.room || mine.body.room.role !== 'owner') return { ok: false, detail: 'A room with this household\'s address already exists on OpenVibe.Chat.' };
++ made = mine;
++ }
++ await addMembers(deps, slug, members);
++ const ai = await chatCall(deps, 'POST', `/${slug}/ai-members`, { name: AI_NAME, persona: PERSONA, link_service: 'food', link_resource: row.id });
++ if (!ai.ok && ai.status !== 409) (deps.log || console).warn(`[Food] household chat ${slug}: adding ${AI_NAME} answered ${ai.status}`);
++ await store.setChatRoom(s, row.owner, row.id, slug);
++ synced.set(row.id, Date.now());
++ return { ok: true, slug };
++}
++
++async function addMembers(deps, slug, members) {
++ for (const m of members) {
++ const r = await chatCall(deps, 'POST', `/${slug}/members`, { subject: subjectOf(m.member), role: 'member' });
++ if (!r.ok) (deps.log || console).warn(`[Food] household chat ${slug}: a member could not be added (${r.status})`);
++ }
++}
++
++/** On the owner's page view: add anyone who joined the household since the last sync (every ten minutes at most). */
++async function syncMembers(deps, row, members, { now = Date.now() } = {}) {
++ if (!row.chat_room || !deps.token) return;
++ const last = synced.get(row.id) || 0;
++ if (now - last < 600_000) return;
++ synced.set(row.id, now);
++ if (synced.size > 2000) synced.delete(synced.keys().next().value);
++ await addMembers(deps, row.chat_room, members);
++}
++
++function _reset() { synced.clear(); }
++
++module.exports = { AI_NAME, contextOf, openRoom, syncMembers, _reset };
ddiff --git a/server/food/store.js b/server/food/store.js
iindex aec6631..c6b2bff 100644
---- a/server/food/store.js
++++ b/server/food/store.js
@@@ -69,6 +69,7 @@ async function planForViewer(s, viewer, id) {
const m = await s.db.maybe('SELECT 1 AS ok FROM food_plan_members WHERE plan_id = $1 AND member = $2', [id, viewer]);
return m ? { row, role: 'member' } : null;
}
++const setChatRoom = (s, owner, id, slug) => s.db.exec('UPDATE food_plans SET chat_room = $3 WHERE owner = $1 AND id = $2', [owner, id, slug]);
const setShareHash = (s, owner, id, hash) => s.db.exec('UPDATE food_plans SET share_hash = $3 WHERE owner = $1 AND id = $2', [owner, id, hash]);
const planByShareHash = (s, hash) => s.db.maybe('SELECT * FROM food_plans WHERE share_hash = $1', [hash]);
const listMembers = (s, planId) => s.db.many('SELECT member, name, joined_at FROM food_plan_members WHERE plan_id = $1 ORDER BY joined_at', [planId]);
@@@ -144,7 +145,7 @@ const recipeSuggestionToWire = (row) => ({
module.exports = {
cacheGet, cachePut, cachePrune, startCachePrune,
insertPlan, planRow, listPlans, deletePlan, planToWire,
-- planForViewer, setShareHash, planByShareHash, listMembers, listSharedPlans, addMember, removeMember, listTicks, tick, untick,
++ planForViewer, setChatRoom, setShareHash, planByShareHash, listMembers, listSharedPlans, addMember, removeMember, listTicks, tick, untick,
MEMBERS_MAX, SHARED_PLANS_MAX,
getPantry, pantryRows, setPantry, addPantryItem,
insertRecipeSuggestion, recipeSuggestionRow, listRecipeSuggestions, countRecipeSuggestionsSince, recipeSuggestionToWire,
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 5307b81..b8f2fcc 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -33,6 +33,7 @@ const { matchPantry } = require('../food/pantry-match');
const { suggestRecipes } = require('../food/recipes');
const store = require('../food/store');
const delivery = require('../food/delivery');
++const householdChat = require('../food/household-chat');
const SITE_NAME = 'OpenVibe.Food';
const TAGLINE = 'What should we eat?';
@@@ -311,10 +312,26 @@ ${table(['Name', 'For', 'Cost', 'Joined'], shared.map((row) => cells(row, row.jo
return null;
}
++ /** The household's room on OpenVibe.Chat, with Pantry, an AI member that knows the plan and every cupboard. */
++ const chatDeps = (req) => ({ config, token: req.viewer && req.viewer.token, log: ctx.log || console });
++ function chatSection(row, role, members) {
++ const intro = html`<p class="muted small">Pantry, the chat's AI member, knows this plan, what is still to buy and what everyone keeps in their pantry here: write <code>@pantry</code> to ask it what to cook.</p>`;
++ if (row.chat_room) {
++ return html`<h2 id="chat">The household chat</h2>${intro}
++<p><a class="sc-btn sc-primary" href="${config.chat.publicUrl}/r/${encodeURIComponent(row.chat_room)}">Open the household chat</a></p>
++<p class="muted small">A private room on OpenVibe.Chat for ${members.length + 1} ${members.length ? 'people' : 'person'}. ${role === 'owner' ? 'People who join the household are added when you open this page.' : ''}</p>`;
++ }
++ if (role !== 'owner') return html`<h2 id="chat">The household chat</h2><p class="muted small">The plan's owner can start a private chat for the household, with an AI member that knows your pantries.</p>`;
++ return html`<h2 id="chat">The household chat</h2>${intro}
++<form method="post" action="/plan/p/${row.id}/chat"><button class="sc-btn sc-primary" type="submit">Start the household chat</button></form>
++<p class="muted small">It makes a private room on OpenVibe.Chat for you${members.length ? ' and everyone in this household' : ''}. You can add more people and change Pantry in the room's settings.</p>`;
++ }
++
async function planPage(req, res, got, { link = null, problem = null, status = 200 } = {}) {
const { row, role } = got;
const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;
const [members, ticks] = await Promise.all([store.listMembers(s, row.id), store.listTicks(s, row.id)]);
++ if (role === 'owner' && row.chat_room) householdChat.syncMembers(chatDeps(req), row, members).catch(() => {});
const ticked = new Map(ticks.map((t) => [t.item, t]));
const got_ = plan.shopping_list.filter((i) => ticked.has(i.slug)).length;
const me = ownerOf(req);
@@@ -338,6 +355,7 @@ ${planBody(plan, { shopping: false })}
<li>${role === 'owner' ? 'You' : 'The plan’s owner'} <span class="badge">owner</span></li>
${members.map((m) => html`<li>${m.member === me ? 'You' : (m.name || 'Someone')} <span class="muted small">joined ${time(m.joined_at)}</span>${role === 'owner' ? btn('remove', 'Remove', { fields: { member: m.member } }) : ''}</li>`)}
</ul>
++${chatSection(row, role, members)}
${role === 'owner' ? html`${link ? html`<div class="notice ok" role="status"><p><b>Your share link.</b> Anyone signed in with OpenVibe who opens it can join this household. It is shown once: copy it now. A new link replaces it.</p>
<p class="share-link"><input type="text" readonly value="${link}" aria-label="Share link"></p></div>` : ''}
<p class="muted small">${row.share_hash ? 'A share link is on.' : 'No share link is on.'} Members see this plan and tick the shopping list; only you change the household.</p>
@@@ -372,7 +390,7 @@ ${role === 'owner' ? html`${link ? html`<div class="notice ok" role="status"><p>
case 'leave':
if (role === 'member') await store.removeMember(s, row.id, ownerOf(req));
return res.redirect(303, '/plan/saved');
-- case 'share': case 'unshare': case 'remove': case 'delete':
++ case 'share': case 'unshare': case 'remove': case 'delete': case 'chat':
if (role !== 'owner') return res.status(403).type('text/plain').send('Only the plan’s owner changes the household.');
break;
default:
@@@ -383,6 +401,12 @@ ${role === 'owner' ? html`${link ? html`<div class="notice ok" role="status"><p>
await store.setShareHash(s, ownerOf(req), row.id, hashOf(secret));
return planPage(req, res, { row: { ...row, share_hash: 'on' }, role }, { link: `${config.baseUrl}/plan/join/${secret}` });
}
++ if (req.params.action === 'chat') {
++ if (row.chat_room) return res.redirect(303, `/plan/p/${row.id}#chat`);
++ const out = await householdChat.openRoom(chatDeps(req), s, row, await store.listMembers(s, row.id));
++ if (!out.ok) return planPage(req, res, got, { problem: out.detail, status: 502 });
++ return res.redirect(303, `/plan/p/${row.id}#chat`);
++ }
if (req.params.action === 'unshare') await store.setShareHash(s, ownerOf(req), row.id, null);
if (req.params.action === 'remove') { const m = str(body.member, 80); if (m) await store.removeMember(s, row.id, m); }
if (req.params.action === 'delete') { await store.deletePlan(s, ownerOf(req), row.id); return res.redirect(303, '/plan/saved'); }
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex 24a96a5..d46ee41 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -29,6 +29,7 @@ const CAPABILITIES = [
'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)
++ 'food.household.read', // a household's pantry and plan as text, for its OpenVibe.Chat room's AI member (granted to Chat)
];
function decodePayload(token) {
ddiff --git a/test/household-chat.test.js b/test/household-chat.test.js
nnew file mode 100644
iindex 0000000..02e6316
---- /dev/null
++++ b/test/household-chat.test.js
@@@ -0,0 +1,94 @@
++'use strict';
++/**
++ * A household's chat (server/food/household-chat.js): the owner of a shared plan opens a private room on OpenVibe.Chat
++ * (a stand-in here) as themselves, with every member added by account subject and Pantry, an AI member linked to the
++ * plan; only the owner can; the page links the room for everyone. Chat's AI member reads the household through
++ * GET /internal/households/:id/chat-context with a service token carrying food.household.read: the plan, what is
++ * still to buy and every member's cupboard. A person, or a service without the capability, is refused.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const { serviceAuth } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++const SAME = { 'sec-fetch-site': 'same-origin' };
++
++/** A stand-in for OpenVibe.Chat's room API: records every call, answers like Chat. */
++function startChat() {
++ const calls = [];
++ const server = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ const json = body ? JSON.parse(body) : null;
++ calls.push({ method: req.method, url: req.url, auth: req.headers.authorization || null, json });
++ const send = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (req.method === 'POST' && req.url === '/api/chat/rooms/') return send(201, { room: { slug: json.slug, name: json.name, role: 'owner', visibility: json.visibility } });
++ if (req.method === 'POST' && /\/members$/.test(req.url)) return send(200, { ok: true, role: 'member' });
++ if (req.method === 'POST' && /\/ai-members$/.test(req.url)) return send(201, { id: 1, name: json.name, handle: 'pantry' });
++ return send(404, { error: 'No such room' });
++ });
++ });
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) })));
++}
++
++function serviceToken(t, cap) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: t.network.url, sub: 'svc:chat', actor_type: 'service', aud: ['openvibe.food'], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomUUID()}` }, t.network.privatePem);
++}
++
++(async () => {
++ const chat = await startChat();
++ const t = await boot({ env: { FOOD_CHAT_INTERNAL_URL: chat.url, FOOD_CHAT_PUBLIC_URL: 'https://openvibe.chat' } });
++ const kim = t.network.addUser('kim');
++ const lee = t.network.addUser('lee');
++ let id = null;
++ try {
++ await check('the owner starts the household chat: a private room as themselves, the members, and Pantry', async () => {
++ const posted = await t.get('/plan', { as: kim, method: 'POST', form: { people: '2', days: '3', title: 'Our week' }, headers: SAME });
++ id = new URL(posted.headers.get('location'), t.base).searchParams.get('saved');
++ const shared = await t.get(`/plan/p/${id}/share`, { as: kim, method: 'POST', form: {}, headers: SAME });
++ const secret = (/\/plan\/join\/([A-Za-z0-9_-]{43})/.exec(shared.text) || [])[1];
++ assert.strictEqual((await t.get(`/plan/join/${secret}`, { as: lee, method: 'POST', form: {}, headers: SAME })).status, 303);
++ await t.get('/api/v1/pantry', { json: { items: ['white-rice', 'canned-beans'] }, method: 'PUT', as: kim, headers: SAME });
++ await t.get('/api/v1/pantry', { json: { items: ['onion-3lb'] }, method: 'PUT', as: lee, headers: SAME });
++
++ const before = await t.get(`/plan/p/${id}`, { as: kim });
++ assert.ok(before.text.includes('Start the household chat'));
++ assert.strictEqual((await t.get(`/plan/p/${id}/chat`, { as: lee, method: 'POST', form: {}, headers: SAME })).status, 403, 'only the owner');
++ const started = await t.get(`/plan/p/${id}/chat`, { as: kim, method: 'POST', form: {}, headers: SAME });
++ assert.strictEqual(started.status, 303, started.text.slice(0, 200));
++ const slug = `household-${id.slice(-10).toLowerCase()}`;
++ const room = chat.calls.find((c) => c.method === 'POST' && c.url === '/api/chat/rooms/');
++ assert.deepStrictEqual([room.json.slug, room.json.visibility, room.json.name], [slug, 'private', 'Our week household']);
++ assert.match(room.auth, /^Bearer ey/, 'as the owner, with their own Network token');
++ const member = chat.calls.find((c) => c.url === `/api/chat/rooms/${slug}/members`);
++ assert.deepStrictEqual(member.json, { subject: lee.subject, role: 'member' }, 'members by account subject');
++ const ai = chat.calls.find((c) => c.url === `/api/chat/rooms/${slug}/ai-members`);
++ assert.deepStrictEqual([ai.json.name, ai.json.link_service, ai.json.link_resource], ['Pantry', 'food', id]);
++
++ const page = await t.get(`/plan/p/${id}`, { as: lee });
++ assert.ok(page.text.includes(`href="https://openvibe.chat/r/${slug}"`), 'every member gets the link');
++ assert.ok(page.text.includes('@pantry'));
++ const again = await t.get(`/plan/p/${id}/chat`, { as: kim, method: 'POST', form: {}, headers: SAME });
++ assert.strictEqual(again.status, 303);
++ assert.strictEqual(chat.calls.filter((c) => c.url === '/api/chat/rooms/').length, 1, 'one room per household');
++ });
++
++ await check('Chat reads the household with food.household.read: the plan, what is left to buy, every cupboard', async () => {
++ const r = await t.get(`/internal/households/${id}/chat-context`, { bearer: serviceToken(t, ['food.household.read']) });
++ assert.strictEqual(r.status, 200, r.text);
++ const b = r.json();
++ assert.strictEqual(b.source, 'OpenVibe.Food household');
++ assert.ok(b.text.includes('Plan "Our week": 2 people, 3 days'), b.text);
++ assert.ok(b.text.includes('Still to buy:'));
++ assert.match(b.text, /The owner's cupboard: .*rice/i);
++ assert.match(b.text, /lee's cupboard: .*onion/i);
++ assert.strictEqual((await t.get(`/internal/households/${id}/chat-context`, { bearer: serviceToken(t, ['food.pantry.read']) })).status, 403, 'without the capability');
++ assert.strictEqual((await t.get(`/internal/households/${id}/chat-context`, { as: kim })).status, 403, 'never a person');
++ assert.strictEqual((await t.get('/internal/households/pln_00000000000000000000000000/chat-context', { bearer: serviceToken(t, ['food.household.read']) })).status, 404);
++ });
++ } finally { await t.close(); await chat.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });