openvibers/openvibe.blog Commits

Blog: a Budget recipes desk from OpenVibe.Food's recipe library (Food's blog on openvibe.blog) (#61)

OP OpenVibers committed 28 days ago · a3de707d13 · 6 files, +162 −9

The owner's "interconnect food with blog where it creates its own blogs" (audit, Food item 6). A weekly
desk (Mondays 08:00 UTC, 3 recipes at least) reads Food's /recipes/library.json (OV_FOOD_URL; off
without it), keeps each recipe as a desk item food:<slug> dated when Food added it, and asks
blog.write_digest for one cited post, each recipe a food.recipe source linked under the post. The next
pull asks only for what is newer than the newest recipe Blog holds.

Changes

ddiff --git a/README.md b/README.md
iindex a027d98..59f5f5d 100644
---- a/README.md
++++ b/README.md
@@@ -172,11 +172,13 @@ indexed (`noindex`, not in the sitemaps).
| Hosting digest | `linode-types`, `aws-lightsail-bundles`, `ovhcloud-vps`, `vultr-plans`, `lowendbox-posts`, `lowendtalk-offers` | Mondays, for the week before |
| Crowdfunding digest | `opencollective-accounts` | Mondays, for the week before (3 items at least) |
| Developer digest | `nodejs-blog` | Mondays, for the week before |
++| Budget recipes | OpenVibe.Food's recipe library (`/recipes/library.json`, `OV_FOOD_URL`; each recipe a `food.recipe` source) | Mondays, 08:00 UTC, for the week before (3 recipes at least) |
| Is This Real? (satire) | none | daily, 14:00 UTC |
Every 15 minutes (`BLOG_DESKS_INTERVAL_MS`) where the worker runs: the change feed of each Sources category the desks
read is pulled (openvibe-publishing/ingest cursors in `blog_ingest_cursor`) into `blog_desk_items` (21 days; a removed
--item is dropped). An item new to Blog is dated by when Sources first saw it, a new revision by when Blog saw it, so the
++item is dropped); the budget-recipes desk asks OpenVibe.Food for the recipes added since the newest one Blog holds (the
++owner's "interconnect food with blog where it creates its own blogs"). An item new to Blog is dated by when Sources first saw it, a new revision by when Blog saw it, so the
first pull never presents old items as new. When a desk's period is over, its newest items (40 at most) go to
`blog.write_digest`; a section the AI did not cite is left out and said so, a run that cites nothing makes no post, and
every cited item is linked under the post. The satire desk sends the next question of `server/desks/catalog.js` to
ddiff --git a/server/config.js b/server/config.js
iindex af1d7fd..b2d4f28 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -65,6 +65,8 @@ function load(env = process.env) {
aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
// OpenVibe.Sources (sources.item.read): the items the AI desks write their digests from (server/desks/).
sources: { internalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''), timeoutMs: int(env.BLOG_SOURCES_TIMEOUT_MS, 10000) },
++ // OpenVibe.Food's recipe library feed (/recipes/library.json, public): the budget-recipes desk. Empty = off.
++ food: { url: trim(env.OV_FOOD_URL || ''), timeoutMs: int(env.BLOG_FOOD_TIMEOUT_MS, 10000) },
// The AI desks (server/desks/desks.js): pull, write and prune every intervalMs where the worker runs. Off with
// BLOG_DESKS=off; the digests also need OV_SOURCES_INTERNAL_URL, every desk needs OV_AI_INTERNAL_URL.
desks: { enabled: env.BLOG_DESKS !== 'off', intervalMs: Math.max(60_000, int(env.BLOG_DESKS_INTERVAL_MS, 15 * 60_000)) },
ddiff --git a/server/desks/catalog.js b/server/desks/catalog.js
iindex eb80695..da3bf85 100644
---- a/server/desks/catalog.js
++++ b/server/desks/catalog.js
@@@ -10,6 +10,7 @@
* categories the Sources categories its sources are in (one change cursor each)
* sources the Sources source keys it reads (a source that is disabled or empty just contributes nothing)
* min, max fewer items than min: no post for that period; at most max items go to the AI (the newest)
++ * feed instead of Sources: 'food-library', OpenVibe.Food's recipe library (/recipes/library.json)
*/
const DESKS = [
@@@ -38,6 +39,11 @@ const DESKS = [
about: 'New and growing open-source and community campaigns on Open Collective, week by week: what they raise money for and what they have received, as Open Collective states it.',
categories: ['crowdfunding'], sources: ['opencollective-accounts'],
},
++ {
++ key: 'recipes', title: 'Budget recipes', every: 'week', hour: 8, min: 3, max: 20, feed: 'food-library',
++ about: 'The week\'s new recipes in OpenVibe.Food\'s library: cheap, simple dishes made only from its food list, each with what to buy and what that typically costs. Written by AI from the recipes themselves; check a recipe before you cook it.',
++ categories: [], sources: ['food-library'],
++ },
{
key: 'dev', title: 'Developer digest', every: 'week', hour: 7, min: 1, max: 30,
about: 'Releases and news from the Node.js project blog, week by week.',
ddiff --git a/server/desks/desks.js b/server/desks/desks.js
iindex d40fdb8..e4238e9 100644
---- a/server/desks/desks.js
++++ b/server/desks/desks.js
@@@ -92,7 +92,8 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
const ai = createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokens, fetch: fetchImpl });
const sourcesOn = Boolean(config.sources && config.sources.internalUrl && config.oauth.clientSecret);
const cursor = ingest.createChangeCursor(db, { prefix: 'blog', now });
-- const wanted = new Set(catalog.DESKS.flatMap((d) => d.sources));
++ const wanted = new Set(catalog.DESKS.filter((d) => !d.feed).flatMap((d) => d.sources));
++ const foodOn = Boolean(config.food && config.food.url);
const categories = [...new Set(catalog.DESKS.flatMap((d) => d.categories))];
const clients = new Map(categories.map((cat) => [cat, ingest.createSourcesClient({ config: { ...config, sources: { ...config.sources, category: cat } }, fetchImpl, now })]));
const stats = { lastPullAt: null, lastComposeAt: null, lastError: null, written: 0 };
@@@ -137,6 +138,42 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
return { pulled };
}
++ /**
++ * OpenVibe.Food's recipe library (the budget-recipes desk): the recipes added since the newest one Blog holds (or
++ * two weeks back), each as a desk item `food:<slug>` dated when Food added it.
++ */
++ async function pullFood() {
++ if (!foodOn) return 0;
++ const newest = await db.value("SELECT max(published_at) FROM blog_desk_items WHERE source_key = 'food-library'");
++ const since = newest || new Date(now() - 14 * 86_400_000).toISOString();
++ let res;
++ try {
++ res = await fetchImpl(`${config.food.url}/recipes/library.json?since=${encodeURIComponent(since)}&limit=100`, {
++ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(config.food.timeoutMs || 10000),
++ });
++ } catch (err) {
++ stats.lastError = `pull food-library: ${err.message}`;
++ return 0;
++ }
++ if (!res.ok) { stats.lastError = `pull food-library: HTTP ${res.status}`; return 0; }
++ const body = await res.json().catch(() => ({}));
++ let n = 0;
++ for (const x of Array.isArray(body.recipes) ? body.recipes : []) {
++ if (!x || !x.slug || !x.title || !x.url || Number.isNaN(Date.parse(x.created_at))) continue;
++ const facts = [x.serves ? `Serves ${x.serves}` : null, x.minutes ? `about ${x.minutes} min` : null,
++ x.cost != null ? `$${Number(x.cost).toFixed(2)} to buy${x.cost_partial ? ' (some prices unknown)' : ''}` : null].filter(Boolean).join(', ');
++ const summary = [facts, Array.isArray(x.ingredients) && x.ingredients.length ? `Ingredients: ${x.ingredients.slice(0, 15).join(', ')}.` : null, x.reviewed ? 'Checked by a person.' : null].filter(Boolean).join(' ');
++ const at = Date.parse(x.created_at);
++ await db.query(`INSERT INTO blog_desk_items (item_id, source_key, title, url, summary, published_at, fields, revision, changed_at, updated_at)
++ VALUES ($1, 'food-library', $2, $3, $4, $5, $6::jsonb, 1, $7, $8)
++ ON CONFLICT (item_id) DO UPDATE SET title = excluded.title, summary = excluded.summary, fields = excluded.fields, updated_at = excluded.updated_at`,
++ [`food:${String(x.slug).slice(0, 100)}`, clean(x.title, 500), String(x.url).slice(0, 500), summary.slice(0, 2000), new Date(at).toISOString(),
++ JSON.stringify({ theme: clean(x.theme, 200), cost: x.cost, serves: x.serves, minutes: x.minutes, reviewed: Boolean(x.reviewed) }), at, now()]);
++ n++;
++ }
++ return n;
++ }
++
// ── Posts ───────────────────────────────────────────────
const shape = (r) => (r ? {
@@@ -178,7 +215,7 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
const r = await ai.run('blog.write_digest', {
desk: desk.title, about: desk.about, period: label,
sources: items.map((it) => ({
-- source_type: 'sources.item', source_id: it.item_id,
++ source_type: desk.feed === 'food-library' ? 'food.recipe' : 'sources.item', source_id: it.item_id,
...(it.url ? { url: it.url } : {}), ...(it.title ? { title: clean(it.title, 500) } : {}),
...(it.published_at ? { published_at: String(it.published_at).slice(0, 40) } : {}),
snippet: snippetOf(it),
@@@ -240,11 +277,10 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
if (!ai.enabled) return [];
const out = [];
const at = now();
-- if (sourcesOn) {
-- for (const desk of catalog.DESKS) {
-- const p = await writeDigest(desk, duePeriod(desk, at));
-- if (p) out.push(p);
-- }
++ for (const desk of catalog.DESKS) {
++ if (desk.feed === 'food-library' ? !foodOn : !sourcesOn) continue;
++ const p = await writeDigest(desk, duePeriod(desk, at));
++ if (p) out.push(p);
}
const p = await writeSatire(catalog.SATIRE, duePeriod(catalog.SATIRE, at));
if (p) out.push(p);
@@@ -262,6 +298,7 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
running = true;
try {
const p = await pull();
++ p.pulled += await pullFood();
const written = await compose();
await prune();
return { ...p, written: written.length };
@@@ -278,7 +315,7 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
return {
enabled: Boolean(c.enabled && ai.enabled),
stats,
-- pull, compose, tick, prune,
++ pull, pullFood, compose, tick, prune,
/** Published posts of a desk, newest first (staff also see hidden ones). */
async list(deskKey, { limit = 20, offset = 0, staff = false } = {}) {
const states = staff ? ['published', 'hidden'] : ['published'];
ddiff --git a/test/recipes-desk.test.js b/test/recipes-desk.test.js
nnew file mode 100644
iindex 0000000..7a218bd
---- /dev/null
++++ b/test/recipes-desk.test.js
@@@ -0,0 +1,105 @@
++'use strict';
++/**
++ * The budget-recipes desk (server/desks/, feed 'food-library'): the week's new recipes from OpenVibe.Food's library
++ * (/recipes/library.json), written up by blog.write_digest with each recipe as a `food.recipe` source.
++ * - off without OV_FOOD_URL; the pull asks Food for what is new since the newest recipe Blog holds;
++ * - a week with fewer recipes than the desk's minimum gets no post; the post cites Food's recipe pages.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++const at = (s) => Date.parse(s);
++
++function stub(handler) {
++ return new Promise((resolve) => {
++ const s = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ const out = handler(req, raw ? JSON.parse(raw) : null) || { status: 404, body: {} };
++ res.writeHead(out.status || 200, { 'content-type': 'application/json' });
++ res.end(JSON.stringify(out.body));
++ });
++ }).listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${s.address().port}`, close: () => new Promise((r) => s.close(r)) }));
++ });
++}
++
++(async () => {
++ const recipe = (slug, created_at, extra = {}) => ({
++ slug, url: `https://openvibe.food/recipes/library/${slug}`, title: slug.replace(/-/g, ' '), theme: 'cheap dinners',
++ serves: 2, minutes: 20, cost: 4.5, cost_partial: false, ingredients: ['rice', 'beans', 'onion'], reviewed: false, created_at, ...extra,
++ });
++ const library = [
++ recipe('beans-and-rice', '2026-10-06T10:00:00.000Z', { reviewed: true }),
++ recipe('lentil-soup', '2026-10-08T10:00:00.000Z'),
++ recipe('egg-fried-rice', '2026-10-11T10:00:00.000Z'),
++ recipe('next-week-pasta', '2026-10-13T10:00:00.000Z'),
++ ];
++ const asked = [];
++ const food = await stub((req) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname !== '/recipes/library.json') return null;
++ const since = u.searchParams.get('since');
++ asked.push(since);
++ return { body: { recipes: library.filter((x) => x.created_at >= since) } };
++ });
++ const runs = [];
++ const ai = await stub((req, body) => {
++ if (req.method === 'POST' && req.url.startsWith('/api/v1/runs')) {
++ runs.push(body);
++ const output = body.workflow === 'blog.write_satire'
++ ? { title: 'Satire', dek: 'x', body_markdown: 'Made up.', tags: [] }
++ : { title: 'Lentil soup and two rice dishes under five dollars', dek: 'Three cheap dinners.', sections: [
++ { heading: 'Dinners', paragraphs: [{ text: 'Beans and rice feeds two for about $4.50.', citations: [0] }, { text: 'Lentil soup and egg fried rice use the same pantry.', citations: [1, 2] }] },
++ ], citations: [0], gaps: [] };
++ return { status: 201, body: { run: { id: `run_${runs.length}`, status: 'succeeded', workflow: { key: body.workflow, version: 1 }, provenance: { model: 'gpt-test' }, output } } };
++ }
++ if (/^\/api\/v1\/runs\/[^/]+\/citations$/.test(req.url)) return { body: { citations: [] } };
++ return null;
++ });
++
++ const off = await boot({ env: { OV_AI_INTERNAL_URL: ai.url } });
++ await check('off without OV_FOOD_URL: nothing is asked of Food', async () => {
++ off.clock.set(at('2026-10-15T09:00:00Z'));
++ await off.ctx.desks.tick();
++ assert.deepStrictEqual(asked, []);
++ assert.strictEqual((await off.ctx.desks.list('recipes')).total, 0);
++ });
++ await off.close();
++ runs.length = 0;
++
++ const t = await boot({ env: { OV_AI_INTERNAL_URL: ai.url, OV_FOOD_URL: food.url } });
++ t.clock.set(at('2026-10-15T09:00:00Z'));
++ const desks = t.ctx.desks;
++
++ await check('the week\'s recipes become one cited post; the next pull asks only for what is newer', async () => {
++ const r = await desks.tick();
++ assert.ok(r, 'the tick ran');
++ assert.match(asked[0], /^2026-10-01T09:00:00/, 'the first pull reaches two weeks back');
++ const digest = runs.find((x) => x.workflow === 'blog.write_digest' && x.input.desk === 'Budget recipes');
++ assert.ok(digest, JSON.stringify(runs.map((x) => x.input && x.input.desk)));
++ assert.deepStrictEqual(digest.input.sources.map((s) => s.source_id), ['food:egg-fried-rice', 'food:lentil-soup', 'food:beans-and-rice'], 'the week of 5 to 11 October, newest first');
++ assert.ok(digest.input.sources.every((s) => s.source_type === 'food.recipe' && s.url.startsWith('https://openvibe.food/recipes/library/')));
++ assert.ok(digest.input.sources[2].snippet.includes('$4.50 to buy') && digest.input.sources[2].snippet.includes('Checked by a person.'));
++ const posts = (await desks.list('recipes')).posts;
++ assert.strictEqual(posts.length, 1);
++ assert.strictEqual(posts[0].title, 'Lentil soup and two rice dishes under five dollars');
++ const page = await t.get(`/desks/recipes/${posts[0].slug}`);
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('https://openvibe.food/recipes/library/lentil-soup'), 'it links the recipes');
++ await desks.tick();
++ assert.match(asked[asked.length - 1], /^2026-10-13T10:00:00/, 'then only what is newer than the newest recipe Blog holds');
++ });
++
++ await check('a week below the minimum gets no post', async () => {
++ t.clock.set(at('2026-10-22T09:00:00Z'));
++ await desks.tick();
++ assert.strictEqual((await desks.list('recipes')).total, 1, 'one recipe in the week of 12 October is not enough');
++ });
++
++ await t.close();
++ await food.close();
++ await ai.close();
++ done();
++})();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 4157359..5d18ac5 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -63,6 +63,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
'server/auth/sso.js': 'Network JWKS, OAuth token and revoke (configured); dead after the SDK move',
'server/changelog.js': 'Network registry (configured) and api.github.com (fixed host) for the repositories the registry names',
'server/clients/community.js': 'OpenVibe.Community (configured)',
++ 'server/desks/desks.js': 'OpenVibe.Food\'s recipe library feed (OV_FOOD_URL, configured; the budget-recipes desk)',
'server/clients/media.js': 'OpenVibe.Media (configured)',
'server/clients/network.js': 'OpenVibe.Network (configured)',
};