openvibers/openvibe.food Commits
Meal plans: the one Share sheet, Only me by default (#223)
Changes
@@@ -13,7 +13,7 @@ Delivery is compared the permitted way: one search opens every delivery app's owOpenVibe.Food answers "what should we eat?" for a household. It finds food banks, soup kitchens, community fridges,free pantry boxes and budget grocery stores near a place (OpenStreetMap), plans a shop within a budget and renders theshopping list with an estimated cost, carries a food database with nutrition and typical price bands, and keeps a--pantry with a deterministic "what can I make". Everything is server-rendered and works without JavaScript.++pantry with a deterministic "what can I make". A saved plan is Only me until its Share sheet says otherwise: Everyone, Anyone with the link, or Specific people (the household — Can view or Can edit, so you tick the list together). Everything is server-rendered and works without JavaScript.## What it does@@@ -293,6 +293,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [<!-- versions:start -->- openvibe-contracts: v0.289.1- openvibe-sdk: v0.91.0--- openvibe-shared: v4.122.0++- openvibe-shared: v4.123.0- openvibe-publishing: v1.4.0<!-- versions:end -->@@@ -9,11 +9,11 @@"domain": "openvibe.food","contracts": "openvibe-contracts v0.289.1","sdk": "openvibe-sdk v0.91.0",-- "shared": "openvibe-shared v4.122.0",++ "shared": "openvibe-shared v4.123.0","packages": ["openvibe-contracts v0.289.1","openvibe-sdk v0.91.0",-- "openvibe-shared v4.122.0"++ "openvibe-shared v4.123.0"],"summary": "OpenVibe.Food — What should we eat? Food near you (food banks, pantries and budget grocery stores from OpenStreetMap, paced and cached to stay inside the OSM usage policies), a deterministic meal planner with a shopping list and an estimated cost, a 42-item food database with nutrition per serving and per 100 g, and a pantry with a plain 'what can I make' against ten basic recipes. Server-rendered pages, an API at /api/v1, per-caller limits on the place search, and a test suite that never calls the internet.","works": [nnew file mode 100644@@@ -0,0 +1,72 @@++-- phase: expand++-- The one sharing system (openvibe-sdk/share) on a saved meal plan: its audience, the people it is shared with, the++-- log and the delivery queue the SDK writes. Kept verbatim from the SDK's own schema (node_modules/openvibe-sdk/src/share/++-- schema.js at the pinned version), so its store finds the tables it expects and the test database has them.++-- A plan's own row is food_plans; there is one share row per plan, kind 'plan'.++++++CREATE TABLE IF NOT EXISTS shares (++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),++ revision integer NOT NULL DEFAULT 1,++ lock text NOT NULL DEFAULT 'none',++ reseal_nonce_base text,++ PRIMARY KEY (service, kind, resource_id)++);++CREATE TABLE IF NOT EXISTS share_members (++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,++ key_slot jsonb,++ PRIMARY KEY (service, kind, resource_id, subject),++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE TABLE IF NOT EXISTS share_transfers (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),++ key_slot jsonb,++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';++CREATE TABLE IF NOT EXISTS share_access_requests (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';++CREATE TABLE IF NOT EXISTS share_log (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE++);++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;++DO $$ BEGIN++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();++ END IF;++END $$;++CREATE TABLE IF NOT EXISTS share_deliveries (++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE++);++CREATE TABLE IF NOT EXISTS share_name_limits (++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL++);++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the++-- slot a transfer was offered with. Repeating this migration is a no-op.++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';++DO $$ BEGIN++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));++EXCEPTION WHEN duplicate_object THEN NULL; END $$;++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;nnew file mode 100644@@@ -0,0 +1,21 @@++-- phase: expand++-- Visibility step 3 (design 2026-11-06): the person's default audience for new meal plans, and one share row for every++-- plan that already exists. The old household link maps to Anyone with the link (any signed-in person holding its++-- secret could already join and read the plan); a plan without one was Only me. Members of a household stay members++-- through food_plan_members, so they keep editing the shopping list.++-- New plans are registered as they are made (server/food/sharing.js); a plan without a row is still its owner's,++-- because every read asks canView and a missing row falls back to the old columns. Repeating this file is a no-op.++++CREATE TABLE IF NOT EXISTS food_share_defaults (++ subject text COLLATE "C" PRIMARY KEY,++ audience text COLLATE "C" NOT NULL CHECK (audience IN ('public', 'link', 'people', 'private')),++ updated_at timestamptz NOT NULL DEFAULT now()++);++++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)++SELECT++ 'food', 'plan', p.id,++ jsonb_build_object('type', 'user', 'id', regexp_replace(p.owner, '^user:', '')),++ CASE WHEN p.share_hash IS NOT NULL THEN 'people' ELSE 'private' END++FROM food_plans p++ON CONFLICT (service, kind, resource_id) DO NOTHING;@@@ -18,7 +18,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0","pg": "^8.23.0"},"devDependencies": {@@@ -1209,9 +1209,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.122.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",-- "integrity": "sha512-/BneZ1PYDWUM/DBEb8EodvTbsK9a+5EGY+bM8zJi1vgL+D8ohau7EixB4uNcqD0RLXJMu4IUYtSdwb/QPPyPMA==",++ "version": "4.123.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",++ "integrity": "sha512-9XLlRm24VEf/amaSHm+3OEKYKKrDj9ic4Fii9qsKwtUV9HilQ8cw6H1TNi27m/VIdJcvFkZASpkmxnIhQW+rkw==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -25,7 +25,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0","pg": "^8.23.0"},"engines": {@@@ -26,6 +26,7 @@ const configLib = require('./config');const { openStore } = require('./db');const { createKeyStore } = require('./auth/keys');const { createSso } = require('./auth/sso');++const { createSharing } = require('./food/sharing');const { createPrincipal } = require('./http/principal');const { createUpstream } = require('./food/upstream');const foodStore = require('./food/store');@@@ -59,7 +60,10 @@ async function createApp(opts = {}) {const principal = createPrincipal({ config, keys });// A food box's codes and score from OpenVibe.Coupons and OpenVibe.Reviews (server/food/box-links.js; off when unset).const boxLinks = (config.boxLinks.couponsUrl || config.boxLinks.reviewsUrl) ? require('./food/box-links').createBoxLinks(config.boxLinks) : null;-- const ctx = { config, s, keys, sso, principal, log, boxLinks };++ // The one sharing system (design 2026-11-06): a saved plan and its shopping list are its owner's until the++ // Share sheet says otherwise.++ const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });++ const ctx = { config, s, keys, sso, principal, log, boxLinks, sharing };// The only outbound client this service has (Nominatim + Overpass, paced and cached); the pages and the API get// it from here, so every test points it at a stand-in through FOOD_NOMINATIM_URL / FOOD_OVERPASS_URL.// Its own wall clock, not the store's: the pace between two calls must not depend on an injected test clock.@@@ -208,6 +212,26 @@ async function createApp(opts = {}) {// ── API ─────────────────────────────────────────────────app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.FOOD_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));++ // ── /api/v1/plan/:id/share — the one Share sheet's API (openvibe-sdk/share) ──++ // GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people++ // and /api/v1/shared-with-me. Access is still decided by canView on every read path; the router answers only its++ // own paths.++ {++ const shareApi = express.Router();++ shareApi.use(sharing.shares.router({++ service: 'food',++ authenticate: (req) => sharing.shareViewer(req.viewer),++ describe: sharing.describe,++ }));++ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|[^/]+\/[^/]+\/share(?:\/|$))/;++ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));++ }++ // Expire members and transfers and send queued share events/notifications. Unref'd: it never holds a test open.++ if (opts.startSharingWorker !== false) {++ const timer = setInterval(() => { sharing.work().catch(() => { /* logged inside */ }); }, 15_000);++ if (timer.unref) timer.unref();++ app.locals.sharingTimer = timer;++ }app.use('/api/v1', createApi(ctx));app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));nnew file mode 100644@@@ -0,0 +1,223 @@++'use strict';++++/**++ * Sharing on OpenVibe.Food (design 2026-11-06, visibility step 3), through openvibe-sdk/share.++ *++ * A saved plan is Only me until its owner says otherwise: the meal plan and its shopping list are one thing, so one++ * audience covers both. Specific people is the household case — add the person you shop with with Can edit, and the++ * shopping list ticks for both of you. The Share sheet changes one plan; the person's default for new plans lives on++ * /plan/saved ("New plans: Only me").++ *++ * Recipes are not in this: the library's recipes are OpenVibe's own, written by AI from the food list, and everyone++ * can read them already; a person's recipe ideas are a private answer from the AI, not an address somebody can be++ * given. Its page keeps the sheet's send part only.++ *++ * The old "Plan together" link stays: its secret still lets a signed-in person join a household and edit the list.++ * A plan with a link on is at least Anyone with the link; setting the audience to Specific people or Only me turns++ * the join link off, so an old link can never outlive the audience its owner chose.++ */++const { createShares } = require('openvibe-sdk/share');++const { serviceAuth } = require('openvibe-contracts');++++const SERVICE = 'food';++const KIND = 'plan';++const AUDIENCES = ['public', 'link', 'people', 'private'];++const DEFAULT_AUDIENCE = 'private';++++const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });++++/** Food names a person 'user:usr_…' in its rows; a page viewer is { subject }. Both become the SDK's subject. */++function subjectOf(viewer) {++ if (!viewer) return null;++ if (typeof viewer === 'string') {++ const id = viewer.startsWith('user:') ? viewer.slice(5) : viewer;++ return /^usr_/.test(id) ? { type: 'user', id } : null;++ }++ if (viewer.subject && /^usr_/.test(String(viewer.subject))) return { type: 'user', id: String(viewer.subject) };++ return null;++}++++function shareViewer(viewer) {++ const subject = subjectOf(viewer);++ if (!subject) return null;++ return { subject, staff: !!(viewer && viewer.staff) };++}++++function createSharing({ db, config, fetchImpl = globalThis.fetch, log = console } = {}) {++ const base = String(config.networkInternalUrl || '').replace(/\/+$/, '');++ const tokens = config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({++ tokenUrl: base + '/oauth/token',++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network',++ scope: 'identity.subject.resolve network.notifications.push',++ fetch: fetchImpl,++ }) : null;++++ async function networkJson({ path, method = 'GET', json, query } = {}) {++ if (!tokens) throw new Error('Network is not configured for this service');++ const url = new URL(base + path);++ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));++ const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };++ if (json !== undefined) headers['Content-Type'] = 'application/json';++ const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });++ const body = await res.json().catch(() => null);++ if (!res.ok) {++ const err = new Error('Network ' + method + ' ' + path + ' ' + res.status + ': ' + ((body && (body.detail || body.error)) || 'failed'));++ err.status = res.status;++ throw err;++ }++ return body;++ }++++ /** One notification. It never carries the plan's name, its meals or its shopping list. */++ async function notify(n) {++ const to = n && n.to;++ if (!to || to.type !== 'user') return;++ const words = {++ 'share.added': ['A meal plan was shared with you', 'Open it on OpenVibe.Food.'],++ 'share.access_requested': ['Someone asked for access to your meal plan', 'Review the request on OpenVibe.Food.'],++ 'share.transfer.requested': ['Someone wants to give you a meal plan', 'Accept or decline it on OpenVibe.Food.'],++ 'share.transfer.answered': ['A meal plan transfer was answered', 'See what happened on OpenVibe.Food.'],++ }[n.kind] || ['Sharing on OpenVibe.Food', 'There is something new for you.'];++ try {++ await networkJson({++ path: '/internal/notifications/push', method: 'POST',++ json: {++ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],++ service: 'food', url: n.ref && n.ref.id ? `${config.baseUrl}/plan/p/${encodeURIComponent(n.ref.id)}` : `${config.baseUrl}/plan`,++ category: 'social',++ },++ });++ } catch (err) {++ log.warn('[Sharing] notification not sent:', err.message);++ }++ }++++ async function publish(envelope) {++ if (!config.events || !config.events.url) { log.warn('[Sharing] no events URL configured; share envelope not sent'); return; }++ const res = await fetchImpl(String(config.events.url).replace(/\/+$/, ''), {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify(envelope),++ signal: AbortSignal.timeout(8000),++ });++ if (!res.ok) throw new Error('Events answered ' + res.status);++ }++++ /**++ * Any audience change ends the household join link: the invite was issued under the audience it had, and an old++ * /plan/join/<secret> must never outlive the choice its owner made. Making a new link is one press, after the++ * audience is set (server/http/pages.js does it in that order).++ */++ async function onAudienceChange(ref, audience, tx) {++ if (ref.service !== SERVICE || ref.kind !== KIND) return;++ await tx.exec('UPDATE food_plans SET share_hash = NULL WHERE id = $1', [String(ref.id)]);++ }++++ async function onOwnerChange(ref, from, to, tx) {++ if (ref.service !== SERVICE || ref.kind !== KIND) return;++ const owner = to && to.type === 'user' ? `user:${to.id}` : null;++ if (!owner) return;++ await tx.exec('UPDATE food_plans SET owner = $1 WHERE id = $2', [owner, String(ref.id)]);++ }++++ const shares = createShares({++ db,++ network: { json: networkJson },++ events: { publish },++ notify,++ onAudienceChange,++ onOwnerChange,++ });++++ return {++ shares,++ SERVICE,++ KIND,++ AUDIENCES,++ DEFAULT_AUDIENCE,++ refOf,++ shareViewer,++ networkJson,++ /** Register a plan, at the owner's default until the Share sheet says otherwise. */++ register: async (viewer, id, audience = DEFAULT_AUDIENCE) => {++ const subject = subjectOf(viewer);++ if (!subject) throw new Error('A plan has a signed-in owner');++ return await shares.register(refOf(id), {++ owner_subject: subject,++ audience: AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE,++ });++ },++ canView: async (viewer, id) => await shares.canView(shareViewer(viewer), refOf(id)),++ canEdit: async (viewer, id) => await shares.canEdit(shareViewer(viewer), refOf(id)),++ setAudience: async (viewer, id, audience) => await shares.setAudience(shareViewer(viewer), refOf(id), audience),++ shareOf: async (id) => await db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3',++ [SERVICE, KIND, String(id)]),++ audienceOf: async (id) => {++ const row = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3',++ [SERVICE, KIND, String(id)]);++ return row ? row.audience : null;++ },++ defaultOf: async (viewer) => {++ const subject = subjectOf(viewer);++ if (!subject) return DEFAULT_AUDIENCE;++ const row = await db.maybe('SELECT audience FROM food_share_defaults WHERE subject = $1', [subject.id]);++ return row && AUDIENCES.includes(row.audience) ? row.audience : DEFAULT_AUDIENCE;++ },++ setDefault: async (viewer, audience, { applyAll = false } = {}) => {++ const subject = subjectOf(viewer);++ if (!subject) return false;++ const want = AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE;++ await db.exec(`INSERT INTO food_share_defaults (subject, audience, updated_at) VALUES ($1, $2, now())++ ON CONFLICT (subject) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [subject.id, want]);++ if (applyAll) {++ await db.exec(`UPDATE shares SET audience = $1, revision = revision + 1++ WHERE service = $2 AND kind = $3 AND owner_subject->>'id' = $4 AND owner_subject->>'type' = 'user'`,++ [want, SERVICE, KIND, subject.id]);++ // Any audience change ends the household join link, here as on a single plan: an old /plan/join/<secret>++ // must never outlive the audience its owner chose.++ await db.exec('UPDATE food_plans SET share_hash = NULL WHERE owner = $1', [`user:${subject.id}`]);++ }++ return true;++ },++ /** The people a plan is shared with, as the page lists them (the SDK's projections, owner only). */++ membersOf: async (viewer, id) => {++ try {++ const state = await shares.get(shareViewer(viewer), refOf(id));++ return (state && state.members) || [];++ } catch { return []; }++ },++ describe: async (ref) => {++ if (ref.service !== SERVICE || ref.kind !== KIND) return {};++ const row = await db.maybe('SELECT id, title FROM food_plans WHERE id = $1', [String(ref.id)]);++ if (!row) return {};++ return {++ title: row.title || `Plan ${String(row.id).slice(-6)}`,++ link: `${config.baseUrl}/plan/p/${encodeURIComponent(row.id)}`,++ roles: ['viewer', 'editor'],++ transferable: false,++ };++ },++ work: async ({ flush = true } = {}) => {++ let expired = 0;++ try { expired = await shares.expireMembers(); } catch (err) { log.warn('[Sharing] expiry sweep:', err.message); }++ let sent = 0;++ if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[Sharing] deliveries:', err.message); } }++ return { expired, sent };++ },++ };++}++++/** Register a plan just made, at the person's default; a sharing system that is down never fails the save. */++async function registerPlan(sharing, viewer, id, log = console) {++ if (!sharing) return;++ try {++ const audience = await sharing.defaultOf(viewer).catch(() => DEFAULT_AUDIENCE);++ await sharing.register(viewer, id, audience);++ } catch (err) {++ log.warn(`[Food] plan ${id}: not registered with the sharing system: ${String((err && err.message) || err).slice(0, 200)}`);++ }++}++++module.exports = { createSharing, registerPlan, shareViewer, subjectOf, refOf, AUDIENCES, DEFAULT_AUDIENCE, SERVICE, KIND };@@@ -64,13 +64,29 @@ const deletePlan = (s, owner, id) => s.db.exec('DELETE FROM food_plans WHERE ownconst MEMBERS_MAX = 12; // people in one household besides its ownerconst SHARED_PLANS_MAX = 50; // households one person may belong to--/** The plan and the viewer's place in it ('owner' or 'member'), or null when it is neither theirs nor shared with them. */--async function planForViewer(s, viewer, id) {++/**++ * The plan and the viewer's place in it, or null when it is neither theirs nor shared with them. `role` is 'owner',++ * 'member' (a household member or a Specific-people editor: they tick the shopping list) or 'viewer' (shared with++ * them, read only). The share row decides when there is one; a plan that predates the backfill falls back to the++ * household link's own rule. Legacy food_plan_members members keep editing (their join link still means Can edit).++ */++async function planForViewer(s, viewer, id, { sharing = null } = {}) {const row = await s.db.maybe('SELECT * FROM food_plans WHERE id = $1', [id]);if (!row) return null;-- if (row.owner === viewer) return { row, role: 'owner' };-- 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;++ if (row.owner === viewer) return { row, role: 'owner', canEdit: true, audience: sharing ? await sharing.audienceOf(id).catch(() => null) : (row.share_hash ? 'link' : 'private') };++ const memberRow = await s.db.maybe('SELECT 1 AS ok FROM food_plan_members WHERE plan_id = $1 AND member = $2', [id, viewer]);++ if (sharing) {++ const audience = await sharing.audienceOf(id).catch(() => null);++ if (audience) {++ const [view, edit] = await Promise.all([++ sharing.canView(viewer, id).catch(() => false),++ sharing.canEdit(viewer, id).catch(() => false),++ ]);++ if (view || edit || memberRow) return { row, role: edit || memberRow ? 'member' : 'viewer', canEdit: Boolean(edit || memberRow), audience };++ return null;++ }++ }++ return memberRow ? { row, role: 'member', canEdit: true, audience: row.share_hash ? 'link' : 'private' } : 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]);@@@ -29,6 +29,7 @@ const { planMeals } = require('../food/planner');const { searchPlaces } = require('../food/places');const { matchPantry } = require('../food/pantry-match');const { suggestRecipes } = require('../food/recipes');++const { registerPlan } = require('../food/sharing');const store = require('../food/store');const PLAN_ID = /^pln_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -137,6 +138,7 @@ function createApi(ctx) {id: s.newId('pln'), owner: req.principal.requester, title,people: plan.spec.people, days: plan.spec.days, budget: plan.spec.budget, plan, created_at: s.iso(),});++ await registerPlan(ctx.sharing, req.principal.requester, row.id, ctx.log || console);res.set('Location', `/api/v1/plans/${row.id}`);return res.status(201).json(store.planToWire(row));});@@@ -148,9 +150,11 @@ function createApi(ctx) {});r.get('/plans/:id', principal.requireCapability('food.plan.read'), limits.reads('food.plan.read'), async (req, res) => {-- const row = PLAN_ID.test(req.params.id) ? await store.planRow(s, req.principal.requester, req.params.id) : null;-- if (!row) return fail(req, res, 404, 'food.plan.not_found', 'No such plan.');-- res.json(store.planToWire(row));++ // The owner, a household member, or somebody the plan is shared with (design 2026-11-06, canView).++ const got = PLAN_ID.test(req.params.id) ? await store.planForViewer(s, req.principal.requester, req.params.id, { sharing: ctx.sharing }) : null;++ if (!got) return fail(req, res, 404, 'food.plan.not_found', 'No such plan.');++ res.set('Cache-Control', 'private, no-store');++ res.json({ ...store.planToWire(got.row), canEdit: got.canEdit, audience: got.audience });});// ── Pantry (a person's own) ──────────────────────────────@@@ -36,6 +36,7 @@ const { searchPlaces, safeUrl, ATTRIBUTION, NEAR_CITIES } = require('../food/placonst { matchPantry } = require('../food/pantry-match');const { suggestRecipes } = require('../food/recipes');const store = require('../food/store');++const { registerPlan } = require('../food/sharing');const delivery = require('../food/delivery');const { mountBoxPages } = require('./box-pages');const householdChat = require('../food/household-chat');@@@ -372,20 +373,37 @@ ${plan && !signedIn(req) ? html`<p class="muted small"><a href="/auth/login?nextid: s.newId('pln'), owner: ownerOf(req), title: str(body.title, 120),people: plan.spec.people, days: plan.spec.days, budget: plan.spec.budget, plan, created_at: s.iso(),});++ await registerPlan(ctx.sharing, ownerOf(req), row.id, ctx.log || console);return res.redirect(303, `${back}&saved=${encodeURIComponent(row.id)}`);});// Saved plans sit under the same page: nothing personal is ever in a public cache.r.get('/plan/saved', async (req, res) => {if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fplan%2Fsaved');-- const [rows, shared] = await Promise.all([store.listPlans(s, ownerOf(req), { limit: 50 }), store.listSharedPlans(s, ownerOf(req))]);++ const [rows, joined] = await Promise.all([store.listPlans(s, ownerOf(req), { limit: 50 }), store.listSharedPlans(s, ownerOf(req))]);++ // A household member stays on the list only while the share row still lets them in (an old join link can be++ // off, or the audience can have moved to Specific people without them).++ const shared = [];++ for (const row of joined) {++ if (!ctx.sharing) { shared.push(row); continue; }++ if (await store.planForViewer(s, ownerOf(req), row.id, { sharing: ctx.sharing })) shared.push(row);++ }const cells = (row, when) => {const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;return [html`<a href="/plan/p/${row.id}">${planTitle(row)}</a>`, `${row.people} people, ${row.days} days`, money(plan.totals.cost), time(when)];};++ const audience = ctx.sharing ? await ctx.sharing.defaultOf(ownerOf(req)).catch(() => 'private') : 'private';++ const AUDIENCE_TEXT = { public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' };++ const options = ['private', 'people', 'link', 'public']++ .map((a) => html`<option value="${a}"${a === audience ? raw(' selected') : ''}>${AUDIENCE_TEXT[a]}</option>`);page(req, res, {title: 'Your plans', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Saved' }],body: html`<h1>Your saved plans</h1>++<div class="plan-actions plan-defaults">++<form method="post" action="/plan/saved/defaults"><label class="muted small" for="plan-default-audience">New plans</label> <select id="plan-default-audience" name="audience">${options}</select>++<label class="muted small"><input type="checkbox" name="apply_all" value="1"> Apply to all my existing plans</label>++<button class="sc-btn" type="submit">Save</button></form></div>++<p class="muted small">Only me unless you choose otherwise. A plan's Share button changes just that plan.</p>${table(['Name', 'For', 'Cost', 'Saved'], rows.map((row) => cells(row, row.created_at)), { empty: 'No saved plans yet. Make one on the plan page and save it.' })}${shared.length ? html`<h2>Shared with you</h2>${table(['Name', 'For', 'Cost', 'Joined'], shared.map((row) => cells(row, row.joined_at)))}` : ''}@@@ -404,14 +422,43 @@ ${table(['Name', 'For', 'Cost', 'Joined'], shared.map((row) => cells(row, row.joconst nameOf = (req) => String(req.viewer.displayName || req.viewer.username || 'someone').slice(0, 80);const planTitle = (row) => row.title || `Plan ${String(row.id).slice(-6)}`;const PLAN_STYLES = { styles: [showcase.STYLESHEET], localStyles: ['css/plan.css'] };++ // The one Share sheet (openvibe-shared 4.114+): loaded on the plan pages that carry a Share button or a gate.++ const SHARE_HEAD = `<link rel="stylesheet" href="${require('openvibe-shared/serve').url('ov-share.css')}"><script src="${require('openvibe-shared/serve').url('ov-share.js')}" defer></script>`;const form = express.urlencoded({ extended: false, limit: '16kb' });const toLogin = (req, res) => res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl.slice(0, 400))}`);const notOurs = (req, res) => res.status(403).type('text/plain').send('A plan is changed from openvibe.food itself.');++ r.post('/plan/saved/defaults', form, async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fplan%2Fsaved');++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('A plan is changed from openvibe.food itself.');++ if (ctx.sharing) {++ const audience = ['private', 'people', 'link', 'public'].includes(req.body.audience) ? req.body.audience : 'private';++ await ctx.sharing.setDefault(ownerOf(req), audience, { applyAll: String(req.body.apply_all || '') === '1' })++ .catch((err) => (ctx.log || console).warn('[Food] the plan default was not saved:', err.message));++ }++ return res.redirect(303, '/plan/saved');++ });++++async function loadPlan(req, res) {const id = str(req.params.id, 40);-- const got = id && PLAN_ID.test(id) ? await store.planForViewer(s, ownerOf(req), id) : null;++ const viewer = signedIn(req) ? ownerOf(req) : null;++ const got = id && PLAN_ID.test(id) ? await store.planForViewer(s, viewer, id, { sharing: ctx.sharing }) : null;if (got) return got;++ // Specific people, and this reader is not on the list: the audience, never the plan's name, meals or list.++ // openvibe-shared 4.123.0 draws the card from the [data-ov-share-gate] markup: no inline script, so a strict++ // CSP needs nothing, and every value is escaped like any other attribute.++ const audience = id && PLAN_ID.test(id) && ctx.sharing ? await ctx.sharing.audienceOf(id).catch(() => null) : null;++ if (audience === 'people') {++ const signInUrl = `/auth/login?next=${encodeURIComponent(`/plan/p/${id}`)}`;++ page(req, res, {++ title: 'A plan shared with specific people', ...PLAN_STYLES, head: SHARE_HEAD, index: false,++ body: html`<h1>A meal plan shared with specific people</h1>++<p class="lede">Sign in with the OpenVibe account it was shared with, or ask its owner for access.</p>++<div id="share-gate" data-ov-share-gate data-kind="plan" data-id="${id}" data-signed-in-as="${signedIn(req) ? (req.viewer.username || '') : ''}" data-sign-in-url="${signInUrl}">This is shared with specific people. Sign in with the OpenVibe account it was shared with, or ask its owner for access.</div>`,++ }, 404);++ return null;++ }page(req, res, { title: 'Plan not found', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Not found' }],body: html`<h1>Plan not found</h1><p>It is not yours or shared with you, or it was deleted.</p><p><a href="/plan/saved">Your plans</a></p>` }, 404);return null;@@@ -434,43 +481,66 @@ ${table(['Name', 'For', 'Cost', 'Joined'], shared.map((row) => cells(row, row.joasync function planPage(req, res, got, { link = null, problem = null, status = 200 } = {}) {const { row, role } = got;++ const canEdit = got.canEdit !== undefined ? !!got.canEdit : role !== 'viewer';++ const audience = got.audience || (row.share_hash ? 'link' : 'private');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)]);++ // The people the Share sheet added (Specific people). The household list is the same idea: everyone who can++ // see this plan. Their names come from Network's projections; if it cannot answer, the subject's tail does.++ const sharedPeople = ctx.sharing && role === 'owner' ? await ctx.sharing.membersOf(ownerOf(req), row.id).catch(() => []) : [];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);++ const me = signedIn(req) ? ownerOf(req) : null;++ const people = members.length + sharedPeople.length + 1;++ // A household's names are for the household; somebody reading an Everyone/link plan sees only how many.++ const showHousehold = role === 'owner' || role === 'member' || audience === 'people' || audience === 'private';++ const AUDIENCE_TEXT = { public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' };const btn = (action, label, { cls = 'sc-btn', fields = {} } = {}) => html`<form method="post" action="/plan/p/${row.id}/${action}">${Object.entries(fields).map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}<button class="${cls}" type="submit">${label}</button></form>`;res.set('Cache-Control', cache.htmlHeaders({ private: true }));page(req, res, {-- title: planTitle(row), crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Your plans', href: '/plan/saved' }, { label: planTitle(row) }], ...PLAN_STYLES,++ title: planTitle(row), crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Your plans', href: '/plan/saved' }, { label: planTitle(row) }], ...PLAN_STYLES, head: SHARE_HEAD,body: html`<h1>${planTitle(row)}</h1>--<p class="lede">${role === 'owner' ? 'Your plan' : 'Shared with you'} · ${members.length + 1} ${members.length ? 'people in this household' : 'person so far'}.</p>++<p class="lede">${role === 'owner' ? 'Your plan' : canEdit ? 'Shared with you' : 'Shared with you, read only'} · ${people} ${people === 1 ? 'person' : 'people'}.</p>++<p class="plan-share"><button type="button" class="sc-btn" data-ov-share-sheet data-kind="plan" data-id="${row.id}" data-title="${planTitle(row)}">Share</button> <span class="badge">${AUDIENCE_TEXT[audience] || 'Only me'}</span></p>${problem ? notice(problem, 'warn') : ''}<h2>The shopping list</h2>--<p class="muted small">${got_} of ${plan.shopping_list.length} got. A tick shows for everyone in the household.</p>++<p class="muted small">${got_} of ${plan.shopping_list.length} got. ${canEdit ? 'A tick shows for everyone it is shared with.' : 'Only its owner and the people with Can edit tick it.'}</p><ul class="shop-list">${plan.shopping_list.map((i) => {const t = ticked.get(i.slug);-- return html`<li class="${t ? 'got' : ''}"><form method="post" action="/plan/p/${row.id}/tick"><input type="hidden" name="item" value="${i.slug}"><input type="hidden" name="done" value="${t ? '0' : '1'}"><button class="tick" type="submit" aria-pressed="${t ? 'true' : 'false'}" aria-label="${t ? 'Not got yet' : 'Got it'}: ${i.name}">${t ? '✓' : ''}</button></form>++ const tick = canEdit++ ? html`<form method="post" action="/plan/p/${row.id}/tick"><input type="hidden" name="item" value="${i.slug}"><input type="hidden" name="done" value="${t ? '0' : '1'}"><button class="tick" type="submit" aria-pressed="${t ? 'true' : 'false'}" aria-label="${t ? 'Not got yet' : 'Got it'}: ${i.name}">${t ? '✓' : ''}</button></form>`++ : html`<span class="tick" aria-hidden="true">${t ? '✓' : ''}</span>`;++ return html`<li class="${t ? 'got' : ''}">${tick}<span class="shop-name"><a href="/foods/${i.slug}">${i.name}</a> × ${String(i.packs)}</span><span class="shop-meta muted small">${i.store_name || ''} ${money(i.cost)}${t ? html` · got by ${t.ticked_by === me ? 'you' : (t.name || 'someone')}` : ''}</span></li>`;})}</ul>${planBody(plan, { shopping: false })}<h2 id="household">The household</h2>--<ul class="household">++${showHousehold ? html`<ul class="household"><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>++${sharedPeople.map((m) => html`<li>${m.subject && m.subject.id === String(me).replace(/^user:/, '') ? 'You' : (m.display_name || m.username || 'Someone')} <span class="muted small">${m.role === 'editor' ? 'can edit' : 'can view'}${m.expires_at ? ` until ${String(m.expires_at).slice(0, 10)}` : ''}</span></li>`)}++</ul>` : html`<p class="muted small">${people} ${people === 1 ? 'person' : 'people'} can open this plan. Its list of people is for them.</p>`}${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>++${role === 'owner' ? html`<h2 id="plan-together">The household link</h2>++<p class="muted small">A join link is a second way in, for someone who is not an OpenVibe account you can name: anyone signed in who opens it joins the household and ticks the list with you. The Share button above is the sheet's one; this link is yours to turn on and off.</p>++${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>--<div class="plan-actions">${btn('share', row.share_hash ? 'Make a new share link' : 'Make a share link', { cls: 'sc-btn sc-primary' })}${row.share_hash ? btn('unshare', 'Turn the link off') : ''}${btn('delete', 'Delete this plan', { cls: 'sc-btn danger' })}</div>`-- : html`<div class="plan-actions">${btn('leave', 'Leave this household', { cls: 'sc-btn danger' })}</div>`}`,++<p class="muted small">${row.share_hash ? 'A join link is on.' : 'No join link is on.'}</p>++<div class="plan-actions">${btn('share', row.share_hash ? 'Make a new join link' : 'Make a join link')}${row.share_hash ? btn('unshare', 'Turn the link off') : ''}${btn('delete', 'Delete this plan', { cls: 'sc-btn danger' })}</div>`++ : role === 'viewer' ? '' : html`<div class="plan-actions">${btn('leave', 'Leave this household', { cls: 'sc-btn danger' })}</div>`}`,}, status);}r.get('/plan/p/:id', async (req, res) => {-- if (!signedIn(req)) return toLogin(req, res);++ // Anyone with the link (and Everyone) opens the page signed out; Only me and Specific people need a person.++ if (!signedIn(req)) {++ const id = str(req.params.id, 40);++ const audience = ctx.sharing && PLAN_ID.test(id) ? await ctx.sharing.audienceOf(id).catch(() => null) : null;++ // A private plan asks a visitor to sign in; a Specific-people plan shows the gate (loadPlan draws it), so a++ // stranger signed out is offered Sign in rather than sent away from the plan's address.++ if (audience !== 'link' && audience !== 'public' && audience !== 'people') return toLogin(req, res);++ }const got = await loadPlan(req, res);if (got) await planPage(req, res, got);});@@@ -485,6 +555,7 @@ ${role === 'owner' ? html`${link ? html`<div class="notice ok" role="status"><p>const back = () => res.redirect(303, `/plan/p/${row.id}`);switch (req.params.action) {case 'tick': {++ if (!got.canEdit) return res.status(403).type('text/plain').send('This plan was shared with you to read. Only its owner and the people with Can edit tick the list.');const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;const item = str(body.item, 80);if (!item || !plan.shopping_list.some((i) => i.slug === item)) return res.status(400).type('text/plain').send('That is not on this shopping list.');@@@ -502,9 +573,16 @@ ${role === 'owner' ? html`${link ? html`<div class="notice ok" role="status"><p>return res.status(404).type('text/plain').send('Not found.');}if (req.params.action === 'share') {++ // A join link is an invite to a household: the plan is Specific people, and this link is how somebody not++ // yet on the list joins it. The audience first, then the link (an audience change ends any old link).++ if (ctx.sharing) {++ const audience = await ctx.sharing.audienceOf(row.id).catch(() => null);++ if (!audience) await ctx.sharing.register(ownerOf(req), row.id, 'people').catch(() => {});++ else if (audience !== 'people') await ctx.sharing.setAudience(ownerOf(req), row.id, 'people').catch(() => {});++ }const secret = crypto.randomBytes(32).toString('base64url');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}` });++ return planPage(req, res, { row: { ...row, share_hash: 'on' }, role, canEdit: true }, { link: `${config.baseUrl}/plan/join/${secret}` });}if (req.params.action === 'chat') {if (row.chat_room) return res.redirect(303, `/plan/p/${row.id}#chat`);@@@ -35,7 +35,37 @@ const TABLES = [/** The account-data handle for Food's store (server/db.js createStore). */function create({ db, note = 'A pantry row names a food by its slug in server/food/foods.js.', log = console } = {}) {-- return createAccountData({ db, service: 'food', tables: TABLES, note, log });++ return createAccountData({++ db,++ service: 'food',++ tables: TABLES,++ /**++ * The share row names its owner, members and requests by canonical subject (jsonb), which the table map's++ * equality cannot reach: the person's own plans take their share rows with them, and their membership of++ * someone else's plan goes. Children first: share_members, transfers and requests point at shares.++ */++ extraErase: async (t, subjects) => {++ for (const subject of subjects) {++ const id = String(subject).replace(/^user:/, '');++ await t.exec("DELETE FROM share_members WHERE service='food' AND subject->>'id' = $1", [id]);++ await t.exec("DELETE FROM share_transfers WHERE service='food' AND (from_subject->>'id' = $1 OR to_subject->>'id' = $1)", [id]);++ await t.exec("DELETE FROM share_access_requests WHERE service='food' AND subject->>'id' = $1", [id]);++ await t.exec("DELETE FROM food_share_defaults WHERE subject = $1", [id]);++ }++ for (const subject of subjects) {++ const id = String(subject).replace(/^user:/, '');++ const owned = await t.many("SELECT kind, resource_id FROM shares WHERE service='food' AND owner_subject->>'id' = $1", [id]);++ for (const row of owned) {++ await t.exec("DELETE FROM share_members WHERE service='food' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);++ await t.exec("DELETE FROM share_transfers WHERE service='food' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);++ await t.exec("DELETE FROM share_access_requests WHERE service='food' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);++ await t.exec("DELETE FROM shares WHERE service='food' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);++ }++ }++ },++ note,++ log,++ });}module.exports = { create, TABLES, TOPICS };@@@ -106,6 +106,7 @@ ${appIcon.headTags({ site: 'food' })}${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}++${o.head || ''}${render(scripts)}<meta name="ov-boost" content="food@${esc(RELEASE)}"><script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,@@@ -69,7 +69,12 @@ async function joinB(t) {try {const missing = await uncovered(d.db, {tables: TABLES,-- exempt: { account_data_events: 'the ledger of requests itself: which export and deletion Food applied' },++ exempt: {++ account_data_events: 'the ledger of requests itself: which export and deletion Food applied',++ share_members: 'the people a plan is shared with: erased and exported through the plan it belongs to',++ share_access_requests: 'an ask for access to a plan: erased with the plan or with the person asking',++ food_share_defaults: 'the person\'s default audience for new plans: erased with the person',++ },});assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);} finally { await d.close(); }@@@ -306,6 +306,24 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mconst parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);return await projectsApi(req, raw, json, u, parts, url.searchParams);}++ // The sharing system's (openvibe-sdk/share) name lookups and notifications.++ if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {++ const name = String(url.searchParams.get('username') || '').toLowerCase();++ const u = st.byUsername.get(name);++ if (!u) return problem(json, 404, 'identity.name_not_found', 'no such person');++ return json(200, { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name });++ }++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {++ const subjectIds = JSON.parse(raw.toString('utf8') || '{}').subject_ids || [];++ const results = {};++ for (const subjectId of subjectIds) {++ const u = [...st.users.values()].find((x) => x.subject === subjectId);++ if (u) results[subjectId] = { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name };++ }++ return json(200, { results });++ }++ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });++return json(404, { error: 'not found' });});issuer = srv.url;@@@ -33,7 +33,7 @@ const CROSS = { 'sec-fetch-site': 'cross-site' };const mine = await t.get(`/plan/p/${id}`, { as: kim });assert.strictEqual(mine.status, 200);assert.match(mine.headers.get('cache-control'), /private/);-- assert.ok(mine.text.includes('Our week') && mine.text.includes('The household') && mine.text.includes('Make a share link'));++ assert.ok(mine.text.includes('Our week') && mine.text.includes('The household') && mine.text.includes('Make a join link'));item = (/name="item" value="([a-z0-9-]+)"/.exec(mine.text) || [])[1];assert.ok(item, 'the shopping list has tick buttons');assert.ok(mine.text.includes('css/plan.css') || mine.text.includes('plan.css'), 'its own stylesheet');nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++/**++ * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]): the plan page++ * carries markup only, ov-share.js draws the gate when it loads, signed out it offers Sign in, and a signed-in person++ * who is not on the list is offered Ask for access — with no console error and no CSP refusal.++ *++ * Skipped (exit 0) without Chrome, or with OV_SKIP_BROWSER=1. The page is served by this repo's own app on a++ * temporary database, so the CSP headers the browser sees are Food's.++ */++const assert = require('assert');++const fs = require('fs');++const { boot, check, done } = require('./helpers/boot');++++let playwright = null;++try { playwright = require('playwright'); } catch {++ try { playwright = require(process.env.PLAYWRIGHT_MODULE || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'); } catch { playwright = null; }++}++const CHROME = process.env.CHROME_BIN || '/usr/bin/google-chrome';++if (process.env.OV_SKIP_BROWSER === '1' || !playwright || !fs.existsSync(CHROME)) {++ console.log('share-gate-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const REFUSED = /Content Security Policy|Refused to (?:load|execute|apply)|Uncaught SyntaxError/i;++++(async () => {++ const t = await boot();++ const SAME = { 'sec-fetch-site': 'same-origin' };++ const ada = t.network.addUser('ada');++ const cy = t.network.addUser('cy');++ const cookieOf = (user) => {++ const [name, ...rest] = t.signIn(user).split('=');++ return { name, value: rest.join('='), url: t.base };++ };++++ let browser;++ const errors = [];++ const watch = (page) => {++ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });++ page.on('pageerror', (e) => errors.push(String(e)));++ };++ const settled = async (page) => {++ await page.waitForSelector('#share-gate .ov-share-gate', { timeout: 8000 });++ return await page.$eval('#share-gate', (el) => el.textContent.replace(/\s+/g, ' ').trim());++ };++++ try {++ let id;++ await check('a plan shared with Specific people, with nobody on the list yet', async () => {++ const posted = await t.get('/plan', { as: ada, method: 'POST', form: { people: '3', days: '2', title: 'Our week' }, headers: SAME });++ assert.strictEqual(posted.status, 303, posted.text);++ id = new URL(posted.headers.get('location'), t.base).searchParams.get('saved');++ assert.match(id, /^pln_[0-9A-Z]{26}$/);++ await t.ctx.sharing.setAudience(`user:${ada.subject}`, id, 'people');++ });++++ await check('headless Chrome: the gate draws itself, signed out, with Sign in', async () => {++ browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });++ const ctx = await browser.newContext();++ const page = await ctx.newPage();++ watch(page);++ const res = await page.goto(`${t.base}/plan/p/${id}`, { waitUntil: 'load', timeout: 15000 });++ assert.strictEqual(res.status(), 404, 'the gate answers 404, never the plan');++ const text = await settled(page);++ assert.ok(/Sign in/.test(text), `signed out the gate offers Sign in, got: ${text}`);++ const href = await page.$eval('#share-gate .ov-share-gate a', (a) => a.getAttribute('href'));++ assert.match(href, /^\/auth\/login\?next=/);++ await ctx.close();++ });++++ await check('headless Chrome: signed in as somebody not on the list, Ask for access', async () => {++ const ctx = await browser.newContext();++ await ctx.addCookies([cookieOf(cy)]);++ const page = await ctx.newPage();++ watch(page);++ await page.goto(`${t.base}/plan/p/${id}`, { waitUntil: 'load', timeout: 15000 });++ const text = await settled(page);++ assert.ok(/Ask for access/.test(text), `a stranger is offered Ask for access, got: ${text}`);++ assert.ok(/@cy/.test(text), 'the gate names the account it is signed in as');++ await ctx.close();++ });++++ await check('headless Chrome: no console error and no CSP refusal on the gate page', async () => {++ const refused = errors.filter((e) => REFUSED.test(e));++ assert.deepStrictEqual(refused, [], `the gate page was refused: ${refused.join(' | ')}`);++ });++ } finally {++ if (browser) await browser.close().catch(() => {});++ await t.close();++ done();++ }++})();nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++/**++ * Sharing a saved meal plan (design 2026-11-06, visibility step 3): the one Share sheet (openvibe-sdk/share) and the++ * one check, canView, on the plan page and the plans API. The meal plan and its shopping list are one thing.++ *++ * - a new plan is Only me; its page carries the Share button for its owner;++ * - Anyone with the link is readable signed out, and a reader cannot tick;++ * - Specific people shows the gate, never the plan's name or list, to somebody not on the list;++ * - Can edit really ticks the shopping list; Can view only reads;++ * - the person's default for new plans, and "apply to all", work;++ * - a plan is never on a gathered page: not the sitemap, not llms.txt. A recipe, which is Everyone by nature, is.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const SAME = { 'sec-fetch-site': 'same-origin' };++ try {++ const kim = t.network.addUser('kim');++ const bo = t.network.addUser('bo');++ const cy = t.network.addUser('cy');++ const di = t.network.addUser('di');++++ const call = (pathname, o = {}) => {++ const { headers, ...rest } = o;++ return t.get(pathname, { ...rest, headers: { 'x-requested-with': 'ov-share', ...(headers || {}) } });++ };++ const share = (id, o = {}) => call(`/api/v1/plan/${encodeURIComponent(id)}/share`, o);++ const setShare = async (id, body, as) => {++ const first = await share(id, { as });++ assert.strictEqual(first.status, 200, first.text);++ const etag = first.headers.get('etag');++ assert.ok(etag);++ return await share(id, { as, method: 'PUT', json: body, headers: { 'if-match': etag } });++ };++ const audienceRow = async (id) => await t.ctx.s.db.maybe(++ 'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['food', 'plan', id]);++ const newPlan = async (as, title) => {++ const posted = await t.get('/plan', { as, method: 'POST', form: { people: '3', days: '2', title }, headers: SAME });++ assert.strictEqual(posted.status, 303, posted.text);++ return new URL(posted.headers.get('location'), t.base).searchParams.get('saved');++ };++ const firstItem = (text) => (/name="item" value="([a-z0-9-]+)"/.exec(text) || [])[1];++++ let id;++ await check('a new plan is Only me, and its page carries the Share button for its owner', async () => {++ id = await newPlan(kim, 'Our week');++ assert.deepStrictEqual(await audienceRow(id), { audience: 'private' });++ const mine = await t.get(`/plan/p/${id}`, { as: kim });++ assert.strictEqual(mine.status, 200, mine.text);++ assert.ok(mine.text.includes('data-ov-share-sheet') && mine.text.includes('data-kind="plan"'));++ assert.ok(mine.text.includes('ov-share.js'));++ assert.ok(mine.text.includes('Only me'));++ assert.strictEqual((await t.get(`/plan/p/${id}`, { as: bo })).status, 404, 'not shared: not found');++ const anon = await t.get(`/plan/p/${id}`);++ assert.strictEqual(anon.status, 303, 'a private plan asks a visitor to sign in');++ assert.match(anon.headers.get('location'), /^\/auth\/login\?next=/);++ });++++ await check('Anyone with the link reads the plan signed out, and cannot tick', async () => {++ const saved = await setShare(id, { audience: 'link' }, kim);++ assert.strictEqual(saved.status, 200, saved.text);++ assert.strictEqual(saved.json().audience, 'link');++ const guest = await t.get(`/plan/p/${id}`);++ assert.strictEqual(guest.status, 200, guest.text);++ assert.ok(guest.text.includes('Our week'));++ assert.ok(guest.text.includes('Anyone with the link'));++ const item = firstItem(guest.text);++ assert.ok(!item, 'a reader gets no tick form');++ const api = await t.get(`/api/v1/plans/${id}`, { as: bo });++ assert.strictEqual(api.status, 200, api.text);++ assert.strictEqual(api.json().canEdit, false);++ assert.strictEqual((await t.get(`/plan/p/${id}/tick`, { as: bo, method: 'POST', form: { item: 'rice', done: '1' }, headers: SAME })).status, 403);++ });++++ await check('Specific people: an editor ticks, a viewer only reads, a stranger gets the gate', async () => {++ const saved = await setShare(id, { audience: 'people', members: [{ name: 'bo', role: 'editor' }, { name: 'cy', role: 'viewer' }] }, kim);++ assert.strictEqual(saved.status, 200, saved.text);++ assert.strictEqual(saved.json().audience, 'people');++ const editor = await t.get(`/plan/p/${id}`, { as: bo });++ assert.strictEqual(editor.status, 200, editor.text);++ const item = firstItem(editor.text);++ assert.ok(item, 'Can edit opens the tick buttons');++ assert.strictEqual((await t.get(`/plan/p/${id}/tick`, { as: bo, method: 'POST', form: { item, done: '1' }, headers: SAME })).status, 303);++ assert.ok(/<li class="got">/.test((await t.get(`/plan/p/${id}`, { as: kim })).text), 'the owner sees the tick');++ const viewer = await t.get(`/plan/p/${id}`, { as: cy });++ assert.strictEqual(viewer.status, 200);++ assert.ok(!firstItem(viewer.text), 'Can view cannot tick');++ assert.strictEqual((await t.get(`/plan/p/${id}/tick`, { as: cy, method: 'POST', form: { item, done: '0' }, headers: SAME })).status, 403);++ const stranger = await t.get(`/plan/p/${id}`, { as: di });++ assert.strictEqual(stranger.status, 404);++ assert.ok(stranger.text.includes('shared with specific people'));++ assert.ok(!stranger.text.includes('Our week'), 'the gate never shows the plan name');++ assert.strictEqual((await t.get(`/api/v1/plans/${id}`, { as: di })).status, 404);++ });++++ await check('the default applies to the next plan, and "apply to all" moves the existing one', async () => {++ const saved = await t.get('/plan/saved/defaults', { as: kim, method: 'POST', form: { audience: 'link', apply_all: '1' }, headers: SAME });++ assert.strictEqual(saved.status, 303, saved.text);++ assert.deepStrictEqual(await audienceRow(id), { audience: 'link' });++ const next = await newPlan(kim, 'Next week');++ assert.deepStrictEqual(await audienceRow(next), { audience: 'link' });++ const page = await t.get('/plan/saved', { as: kim });++ assert.ok(page.text.includes('New plans'), 'the default is on the plans page');++ });++++ await check('gathered pages: a plan is never in the sitemap or llms.txt; a recipe is Everyone', async () => {++ const next = await newPlan(kim, 'A public plan');++ await setShare(next, { audience: 'public' }, kim);++ const sitemap = await t.get('/sitemap.xml');++ assert.strictEqual(sitemap.status, 200);++ assert.ok(!sitemap.text.includes(next), 'a plan is not in the sitemap, even Everyone');++ const llms = await t.get('/llms.txt');++ assert.ok(!llms.text.includes(next), 'a plan is not in llms.txt');++ assert.ok(/Disallow: \/plan\/p\//.test((await t.get('/robots.txt')).text));++ // The recipe library is OpenVibe's own, Everyone by nature, and keeps the sheet's send part.++ const recipe = await t.ctx.s.db.maybe('SELECT slug FROM food_library_recipes LIMIT 1');++ if (recipe) {++ const r = await t.get(`/recipes/library/${recipe.slug}`);++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('data-ov-share-sheet'));++ assert.ok(!r.text.includes('data-kind="recipe"'), 'a recipe is public by nature: no audience part');++ }++ });++ } finally {++ await t.close();++ done();++ }++})();
@@ -13,7 +13,7 @@ Delivery is compared the permitted way: one search opens every delivery app's ow
@@ -13,7 +13,7 @@ Delivery is compared the permitted way: one search opens every delivery app's ow
OpenVibe.Food answers "what should we eat?" for a household. It finds food banks, soup kitchens, community fridges,
OpenVibe.Food answers "what should we eat?" for a household. It finds food banks, soup kitchens, community fridges,
free pantry boxes and budget grocery stores near a place (OpenStreetMap), plans a shop within a budget and renders the
free pantry boxes and budget grocery stores near a place (OpenStreetMap), plans a shop within a budget and renders the
shopping list with an estimated cost, carries a food database with nutrition and typical price bands, and keeps a
shopping list with an estimated cost, carries a food database with nutrition and typical price bands, and keeps a
-pantry with a deterministic "what can I make". Everything is server-rendered and works without JavaScript.
+pantry with a deterministic "what can I make". A saved plan is Only me until its Share sheet says otherwise: Everyone, Anyone with the link, or Specific people (the household — Can view or Can edit, so you tick the list together). Everything is server-rendered and works without JavaScript.
## What it does
## What it does
@@ -293,6 +293,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -293,6 +293,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.289.1
- openvibe-contracts: v0.289.1
- openvibe-sdk: v0.91.0
- openvibe-sdk: v0.91.0
-- openvibe-shared: v4.122.0
+- openvibe-shared: v4.123.0
- openvibe-publishing: v1.4.0
- openvibe-publishing: v1.4.0
<!-- versions:end -->
<!-- versions:end -->
@@ -9,11 +9,11 @@
@@ -9,11 +9,11 @@
"domain": "openvibe.food",
"domain": "openvibe.food",
"contracts": "openvibe-contracts v0.289.1",
"contracts": "openvibe-contracts v0.289.1",
"sdk": "openvibe-sdk v0.91.0",
"sdk": "openvibe-sdk v0.91.0",
- "shared": "openvibe-shared v4.122.0",
+ "shared": "openvibe-shared v4.123.0",
"packages": [
"packages": [
"openvibe-contracts v0.289.1",
"openvibe-contracts v0.289.1",
"openvibe-sdk v0.91.0",
"openvibe-sdk v0.91.0",
- "openvibe-shared v4.122.0"
+ "openvibe-shared v4.123.0"
],
],
"summary": "OpenVibe.Food — What should we eat? Food near you (food banks, pantries and budget grocery stores from OpenStreetMap, paced and cached to stay inside the OSM usage policies), a deterministic meal planner with a shopping list and an estimated cost, a 42-item food database with nutrition per serving and per 100 g, and a pantry with a plain 'what can I make' against ten basic recipes. Server-rendered pages, an API at /api/v1, per-caller limits on the place search, and a test suite that never calls the internet.",
"summary": "OpenVibe.Food — What should we eat? Food near you (food banks, pantries and budget grocery stores from OpenStreetMap, paced and cached to stay inside the OSM usage policies), a deterministic meal planner with a shopping list and an estimated cost, a 42-item food database with nutrition per serving and per 100 g, and a pantry with a plain 'what can I make' against ten basic recipes. Server-rendered pages, an API at /api/v1, per-caller limits on the place search, and a test suite that never calls the internet.",
"works": [
"works": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+-- phase: expand
+-- The one sharing system (openvibe-sdk/share) on a saved meal plan: its audience, the people it is shared with, the
+-- log and the delivery queue the SDK writes. Kept verbatim from the SDK's own schema (node_modules/openvibe-sdk/src/share/
+-- schema.js at the pinned version), so its store finds the tables it expects and the test database has them.
+-- A plan's own row is food_plans; there is one share row per plan, kind 'plan'.
+
+
+CREATE TABLE IF NOT EXISTS shares (
+ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
+ revision integer NOT NULL DEFAULT 1,
+ lock text NOT NULL DEFAULT 'none',
+ reseal_nonce_base text,
+ PRIMARY KEY (service, kind, resource_id)
+);
+CREATE TABLE IF NOT EXISTS share_members (
+ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
+ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
+ key_slot jsonb,
+ PRIMARY KEY (service, kind, resource_id, subject),
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE TABLE IF NOT EXISTS share_transfers (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
+ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
+ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
+ key_slot jsonb,
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
+CREATE TABLE IF NOT EXISTS share_access_requests (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
+ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
+CREATE TABLE IF NOT EXISTS share_log (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
+ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
+);
+CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
+ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
+ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
+ END IF;
+END $$;
+CREATE TABLE IF NOT EXISTS share_deliveries (
+ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
+ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
+ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
+);
+CREATE TABLE IF NOT EXISTS share_name_limits (
+ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
+);
+CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
+CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
+CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
+-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
+-- slot a transfer was offered with. Repeating this migration is a no-op.
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
+DO $$ BEGIN
+ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
+ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+-- phase: expand
+-- Visibility step 3 (design 2026-11-06): the person's default audience for new meal plans, and one share row for every
+-- plan that already exists. The old household link maps to Anyone with the link (any signed-in person holding its
+-- secret could already join and read the plan); a plan without one was Only me. Members of a household stay members
+-- through food_plan_members, so they keep editing the shopping list.
+-- New plans are registered as they are made (server/food/sharing.js); a plan without a row is still its owner's,
+-- because every read asks canView and a missing row falls back to the old columns. Repeating this file is a no-op.
+
+CREATE TABLE IF NOT EXISTS food_share_defaults (
+ subject text COLLATE "C" PRIMARY KEY,
+ audience text COLLATE "C" NOT NULL CHECK (audience IN ('public', 'link', 'people', 'private')),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
+SELECT
+ 'food', 'plan', p.id,
+ jsonb_build_object('type', 'user', 'id', regexp_replace(p.owner, '^user:', '')),
+ CASE WHEN p.share_hash IS NOT NULL THEN 'people' ELSE 'private' END
+FROM food_plans p
+ON CONFLICT (service, kind, resource_id) DO NOTHING;
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1209,9 +1209,9 @@
@@ -1209,9 +1209,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.122.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
- "integrity": "sha512-/BneZ1PYDWUM/DBEb8EodvTbsK9a+5EGY+bM8zJi1vgL+D8ohau7EixB4uNcqD0RLXJMu4IUYtSdwb/QPPyPMA==",
+ "version": "4.123.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
+ "integrity": "sha512-9XLlRm24VEf/amaSHm+3OEKYKKrDj9ic4Fii9qsKwtUV9HilQ8cw6H1TNi27m/VIdJcvFkZASpkmxnIhQW+rkw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -26,6 +26,7 @@ const configLib = require('./config');
@@ -26,6 +26,7 @@ const configLib = require('./config');
const { openStore } = require('./db');
const { openStore } = require('./db');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
+const { createSharing } = require('./food/sharing');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
const { createUpstream } = require('./food/upstream');
const { createUpstream } = require('./food/upstream');
const foodStore = require('./food/store');
const foodStore = require('./food/store');
@@ -59,7 +60,10 @@ async function createApp(opts = {}) {
@@ -59,7 +60,10 @@ async function createApp(opts = {}) {
const principal = createPrincipal({ config, keys });
const principal = createPrincipal({ config, keys });
// A food box's codes and score from OpenVibe.Coupons and OpenVibe.Reviews (server/food/box-links.js; off when unset).
// A food box's codes and score from OpenVibe.Coupons and OpenVibe.Reviews (server/food/box-links.js; off when unset).
const boxLinks = (config.boxLinks.couponsUrl || config.boxLinks.reviewsUrl) ? require('./food/box-links').createBoxLinks(config.boxLinks) : null;
const boxLinks = (config.boxLinks.couponsUrl || config.boxLinks.reviewsUrl) ? require('./food/box-links').createBoxLinks(config.boxLinks) : null;
- const ctx = { config, s, keys, sso, principal, log, boxLinks };
+ // The one sharing system (design 2026-11-06): a saved plan and its shopping list are its owner's until the
+ // Share sheet says otherwise.
+ const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });
+ const ctx = { config, s, keys, sso, principal, log, boxLinks, sharing };
// The only outbound client this service has (Nominatim + Overpass, paced and cached); the pages and the API get
// The only outbound client this service has (Nominatim + Overpass, paced and cached); the pages and the API get
// it from here, so every test points it at a stand-in through FOOD_NOMINATIM_URL / FOOD_OVERPASS_URL.
// it from here, so every test points it at a stand-in through FOOD_NOMINATIM_URL / FOOD_OVERPASS_URL.
// Its own wall clock, not the store's: the pace between two calls must not depend on an injected test clock.
// Its own wall clock, not the store's: the pace between two calls must not depend on an injected test clock.
@@ -208,6 +212,26 @@ async function createApp(opts = {}) {
@@ -208,6 +212,26 @@ async function createApp(opts = {}) {
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.FOOD_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.FOOD_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
+ // ── /api/v1/plan/:id/share — the one Share sheet's API (openvibe-sdk/share) ──
+ // GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people
+ // and /api/v1/shared-with-me. Access is still decided by canView on every read path; the router answers only its
+ // own paths.
+ {
+ const shareApi = express.Router();
+ shareApi.use(sharing.shares.router({
+ service: 'food',
+ authenticate: (req) => sharing.shareViewer(req.viewer),
+ describe: sharing.describe,
+ }));
+ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|[^/]+\/[^/]+\/share(?:\/|$))/;
+ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
+ }
+ // Expire members and transfers and send queued share events/notifications. Unref'd: it never holds a test open.
+ if (opts.startSharingWorker !== false) {
+ const timer = setInterval(() => { sharing.work().catch(() => { /* logged inside */ }); }, 15_000);
+ if (timer.unref) timer.unref();
+ app.locals.sharingTimer = timer;
+ }
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,223 @@
@@ -0,0 +1,223 @@
+'use strict';
+
+/**
+ * Sharing on OpenVibe.Food (design 2026-11-06, visibility step 3), through openvibe-sdk/share.
+ *
+ * A saved plan is Only me until its owner says otherwise: the meal plan and its shopping list are one thing, so one
+ * audience covers both. Specific people is the household case — add the person you shop with with Can edit, and the
+ * shopping list ticks for both of you. The Share sheet changes one plan; the person's default for new plans lives on
+ * /plan/saved ("New plans: Only me").
+ *
+ * Recipes are not in this: the library's recipes are OpenVibe's own, written by AI from the food list, and everyone
+ * can read them already; a person's recipe ideas are a private answer from the AI, not an address somebody can be
+ * given. Its page keeps the sheet's send part only.
+ *
+ * The old "Plan together" link stays: its secret still lets a signed-in person join a household and edit the list.
+ * A plan with a link on is at least Anyone with the link; setting the audience to Specific people or Only me turns
+ * the join link off, so an old link can never outlive the audience its owner chose.
+ */
+const { createShares } = require('openvibe-sdk/share');
+const { serviceAuth } = require('openvibe-contracts');
+
+const SERVICE = 'food';
+const KIND = 'plan';
+const AUDIENCES = ['public', 'link', 'people', 'private'];
+const DEFAULT_AUDIENCE = 'private';
+
+const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
+
+/** Food names a person 'user:usr_…' in its rows; a page viewer is { subject }. Both become the SDK's subject. */
+function subjectOf(viewer) {
+ if (!viewer) return null;
+ if (typeof viewer === 'string') {
+ const id = viewer.startsWith('user:') ? viewer.slice(5) : viewer;
+ return /^usr_/.test(id) ? { type: 'user', id } : null;
+ }
+ if (viewer.subject && /^usr_/.test(String(viewer.subject))) return { type: 'user', id: String(viewer.subject) };
+ return null;
+}
+
+function shareViewer(viewer) {
+ const subject = subjectOf(viewer);
+ if (!subject) return null;
+ return { subject, staff: !!(viewer && viewer.staff) };
+}
+
+function createSharing({ db, config, fetchImpl = globalThis.fetch, log = console } = {}) {
+ const base = String(config.networkInternalUrl || '').replace(/\/+$/, '');
+ const tokens = config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({
+ tokenUrl: base + '/oauth/token',
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network',
+ scope: 'identity.subject.resolve network.notifications.push',
+ fetch: fetchImpl,
+ }) : null;
+
+ async function networkJson({ path, method = 'GET', json, query } = {}) {
+ if (!tokens) throw new Error('Network is not configured for this service');
+ const url = new URL(base + path);
+ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));
+ const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };
+ if (json !== undefined) headers['Content-Type'] = 'application/json';
+ const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });
+ const body = await res.json().catch(() => null);
+ if (!res.ok) {
+ const err = new Error('Network ' + method + ' ' + path + ' ' + res.status + ': ' + ((body && (body.detail || body.error)) || 'failed'));
+ err.status = res.status;
+ throw err;
+ }
+ return body;
+ }
+
+ /** One notification. It never carries the plan's name, its meals or its shopping list. */
+ async function notify(n) {
+ const to = n && n.to;
+ if (!to || to.type !== 'user') return;
+ const words = {
+ 'share.added': ['A meal plan was shared with you', 'Open it on OpenVibe.Food.'],
+ 'share.access_requested': ['Someone asked for access to your meal plan', 'Review the request on OpenVibe.Food.'],
+ 'share.transfer.requested': ['Someone wants to give you a meal plan', 'Accept or decline it on OpenVibe.Food.'],
+ 'share.transfer.answered': ['A meal plan transfer was answered', 'See what happened on OpenVibe.Food.'],
+ }[n.kind] || ['Sharing on OpenVibe.Food', 'There is something new for you.'];
+ try {
+ await networkJson({
+ path: '/internal/notifications/push', method: 'POST',
+ json: {
+ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],
+ service: 'food', url: n.ref && n.ref.id ? `${config.baseUrl}/plan/p/${encodeURIComponent(n.ref.id)}` : `${config.baseUrl}/plan`,
+ category: 'social',
+ },
+ });
+ } catch (err) {
+ log.warn('[Sharing] notification not sent:', err.message);
+ }
+ }
+
+ async function publish(envelope) {
+ if (!config.events || !config.events.url) { log.warn('[Sharing] no events URL configured; share envelope not sent'); return; }
+ const res = await fetchImpl(String(config.events.url).replace(/\/+$/, ''), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(envelope),
+ signal: AbortSignal.timeout(8000),
+ });
+ if (!res.ok) throw new Error('Events answered ' + res.status);
+ }
+
+ /**
+ * Any audience change ends the household join link: the invite was issued under the audience it had, and an old
+ * /plan/join/<secret> must never outlive the choice its owner made. Making a new link is one press, after the
+ * audience is set (server/http/pages.js does it in that order).
+ */
+ async function onAudienceChange(ref, audience, tx) {
+ if (ref.service !== SERVICE || ref.kind !== KIND) return;
+ await tx.exec('UPDATE food_plans SET share_hash = NULL WHERE id = $1', [String(ref.id)]);
+ }
+
+ async function onOwnerChange(ref, from, to, tx) {
+ if (ref.service !== SERVICE || ref.kind !== KIND) return;
+ const owner = to && to.type === 'user' ? `user:${to.id}` : null;
+ if (!owner) return;
+ await tx.exec('UPDATE food_plans SET owner = $1 WHERE id = $2', [owner, String(ref.id)]);
+ }
+
+ const shares = createShares({
+ db,
+ network: { json: networkJson },
+ events: { publish },
+ notify,
+ onAudienceChange,
+ onOwnerChange,
+ });
+
+ return {
+ shares,
+ SERVICE,
+ KIND,
+ AUDIENCES,
+ DEFAULT_AUDIENCE,
+ refOf,
+ shareViewer,
+ networkJson,
+ /** Register a plan, at the owner's default until the Share sheet says otherwise. */
+ register: async (viewer, id, audience = DEFAULT_AUDIENCE) => {
+ const subject = subjectOf(viewer);
+ if (!subject) throw new Error('A plan has a signed-in owner');
+ return await shares.register(refOf(id), {
+ owner_subject: subject,
+ audience: AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE,
+ });
+ },
+ canView: async (viewer, id) => await shares.canView(shareViewer(viewer), refOf(id)),
+ canEdit: async (viewer, id) => await shares.canEdit(shareViewer(viewer), refOf(id)),
+ setAudience: async (viewer, id, audience) => await shares.setAudience(shareViewer(viewer), refOf(id), audience),
+ shareOf: async (id) => await db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3',
+ [SERVICE, KIND, String(id)]),
+ audienceOf: async (id) => {
+ const row = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3',
+ [SERVICE, KIND, String(id)]);
+ return row ? row.audience : null;
+ },
+ defaultOf: async (viewer) => {
+ const subject = subjectOf(viewer);
+ if (!subject) return DEFAULT_AUDIENCE;
+ const row = await db.maybe('SELECT audience FROM food_share_defaults WHERE subject = $1', [subject.id]);
+ return row && AUDIENCES.includes(row.audience) ? row.audience : DEFAULT_AUDIENCE;
+ },
+ setDefault: async (viewer, audience, { applyAll = false } = {}) => {
+ const subject = subjectOf(viewer);
+ if (!subject) return false;
+ const want = AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE;
+ await db.exec(`INSERT INTO food_share_defaults (subject, audience, updated_at) VALUES ($1, $2, now())
+ ON CONFLICT (subject) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [subject.id, want]);
+ if (applyAll) {
+ await db.exec(`UPDATE shares SET audience = $1, revision = revision + 1
+ WHERE service = $2 AND kind = $3 AND owner_subject->>'id' = $4 AND owner_subject->>'type' = 'user'`,
+ [want, SERVICE, KIND, subject.id]);
+ // Any audience change ends the household join link, here as on a single plan: an old /plan/join/<secret>
+ // must never outlive the audience its owner chose.
+ await db.exec('UPDATE food_plans SET share_hash = NULL WHERE owner = $1', [`user:${subject.id}`]);
+ }
+ return true;
+ },
+ /** The people a plan is shared with, as the page lists them (the SDK's projections, owner only). */
+ membersOf: async (viewer, id) => {
+ try {
+ const state = await shares.get(shareViewer(viewer), refOf(id));
+ return (state && state.members) || [];
+ } catch { return []; }
+ },
+ describe: async (ref) => {
+ if (ref.service !== SERVICE || ref.kind !== KIND) return {};
+ const row = await db.maybe('SELECT id, title FROM food_plans WHERE id = $1', [String(ref.id)]);
+ if (!row) return {};
+ return {
+ title: row.title || `Plan ${String(row.id).slice(-6)}`,
+ link: `${config.baseUrl}/plan/p/${encodeURIComponent(row.id)}`,
+ roles: ['viewer', 'editor'],
+ transferable: false,
+ };
+ },
+ work: async ({ flush = true } = {}) => {
+ let expired = 0;
+ try { expired = await shares.expireMembers(); } catch (err) { log.warn('[Sharing] expiry sweep:', err.message); }
+ let sent = 0;
+ if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[Sharing] deliveries:', err.message); } }
+ return { expired, sent };
+ },
+ };
+}
+
+/** Register a plan just made, at the person's default; a sharing system that is down never fails the save. */
+async function registerPlan(sharing, viewer, id, log = console) {
+ if (!sharing) return;
+ try {
+ const audience = await sharing.defaultOf(viewer).catch(() => DEFAULT_AUDIENCE);
+ await sharing.register(viewer, id, audience);
+ } catch (err) {
+ log.warn(`[Food] plan ${id}: not registered with the sharing system: ${String((err && err.message) || err).slice(0, 200)}`);
+ }
+}
+
+module.exports = { createSharing, registerPlan, shareViewer, subjectOf, refOf, AUDIENCES, DEFAULT_AUDIENCE, SERVICE, KIND };
@@ -64,13 +64,29 @@ const deletePlan = (s, owner, id) => s.db.exec('DELETE FROM food_plans WHERE own
@@ -64,13 +64,29 @@ const deletePlan = (s, owner, id) => s.db.exec('DELETE FROM food_plans WHERE own
const MEMBERS_MAX = 12; // people in one household besides its owner
const MEMBERS_MAX = 12; // people in one household besides its owner
const SHARED_PLANS_MAX = 50; // households one person may belong to
const SHARED_PLANS_MAX = 50; // households one person may belong to
-/** The plan and the viewer's place in it ('owner' or 'member'), or null when it is neither theirs nor shared with them. */
-async function planForViewer(s, viewer, id) {
+/**
+ * The plan and the viewer's place in it, or null when it is neither theirs nor shared with them. `role` is 'owner',
+ * 'member' (a household member or a Specific-people editor: they tick the shopping list) or 'viewer' (shared with
+ * them, read only). The share row decides when there is one; a plan that predates the backfill falls back to the
+ * household link's own rule. Legacy food_plan_members members keep editing (their join link still means Can edit).
+ */
+async function planForViewer(s, viewer, id, { sharing = null } = {}) {
const row = await s.db.maybe('SELECT * FROM food_plans WHERE id = $1', [id]);
const row = await s.db.maybe('SELECT * FROM food_plans WHERE id = $1', [id]);
if (!row) return null;
if (!row) return null;
- if (row.owner === viewer) return { row, role: 'owner' };
- 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;
+ if (row.owner === viewer) return { row, role: 'owner', canEdit: true, audience: sharing ? await sharing.audienceOf(id).catch(() => null) : (row.share_hash ? 'link' : 'private') };
+ const memberRow = await s.db.maybe('SELECT 1 AS ok FROM food_plan_members WHERE plan_id = $1 AND member = $2', [id, viewer]);
+ if (sharing) {
+ const audience = await sharing.audienceOf(id).catch(() => null);
+ if (audience) {
+ const [view, edit] = await Promise.all([
+ sharing.canView(viewer, id).catch(() => false),
+ sharing.canEdit(viewer, id).catch(() => false),
+ ]);
+ if (view || edit || memberRow) return { row, role: edit || memberRow ? 'member' : 'viewer', canEdit: Boolean(edit || memberRow), audience };
+ return null;
+ }
+ }
+ return memberRow ? { row, role: 'member', canEdit: true, audience: row.share_hash ? 'link' : 'private' } : 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 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 setShareHash = (s, owner, id, hash) => s.db.exec('UPDATE food_plans SET share_hash = $3 WHERE owner = $1 AND id = $2', [owner, id, hash]);
@@ -29,6 +29,7 @@ const { planMeals } = require('../food/planner');
@@ -29,6 +29,7 @@ const { planMeals } = require('../food/planner');
const { searchPlaces } = require('../food/places');
const { searchPlaces } = require('../food/places');
const { matchPantry } = require('../food/pantry-match');
const { matchPantry } = require('../food/pantry-match');
const { suggestRecipes } = require('../food/recipes');
const { suggestRecipes } = require('../food/recipes');
+const { registerPlan } = require('../food/sharing');
const store = require('../food/store');
const store = require('../food/store');
const PLAN_ID = /^pln_[0-9A-HJKMNP-TV-Z]{26}$/;
const PLAN_ID = /^pln_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -137,6 +138,7 @@ function createApi(ctx) {
@@ -137,6 +138,7 @@ function createApi(ctx) {
id: s.newId('pln'), owner: req.principal.requester, title,
id: s.newId('pln'), owner: req.principal.requester, title,
people: plan.spec.people, days: plan.spec.days, budget: plan.spec.budget, plan, created_at: s.iso(),
people: plan.spec.people, days: plan.spec.days, budget: plan.spec.budget, plan, created_at: s.iso(),
});
});
+ await registerPlan(ctx.sharing, req.principal.requester, row.id, ctx.log || console);
res.set('Location', `/api/v1/plans/${row.id}`);
res.set('Location', `/api/v1/plans/${row.id}`);
return res.status(201).json(store.planToWire(row));
return res.status(201).json(store.planToWire(row));
});
});
@@ -148,9 +150,11 @@ function createApi(ctx) {
@@ -148,9 +150,11 @@ function createApi(ctx) {
});
});
r.get('/plans/:id', principal.requireCapability('food.plan.read'), limits.reads('food.plan.read'), async (req, res) => {
r.get('/plans/:id', principal.requireCapability('food.plan.read'), limits.reads('food.plan.read'), async (req, res) => {
- const row = PLAN_ID.test(req.params.id) ? await store.planRow(s, req.principal.requester, req.params.id) : null;
- if (!row) return fail(req, res, 404, 'food.plan.not_found', 'No such plan.');
- res.json(store.planToWire(row));
+ // The owner, a household member, or somebody the plan is shared with (design 2026-11-06, canView).
+ const got = PLAN_ID.test(req.params.id) ? await store.planForViewer(s, req.principal.requester, req.params.id, { sharing: ctx.sharing }) : null;
+ if (!got) return fail(req, res, 404, 'food.plan.not_found', 'No such plan.');
+ res.set('Cache-Control', 'private, no-store');
+ res.json({ ...store.planToWire(got.row), canEdit: got.canEdit, audience: got.audience });
});
});
// ── Pantry (a person's own) ──────────────────────────────
// ── Pantry (a person's own) ──────────────────────────────
@@ -36,6 +36,7 @@ const { searchPlaces, safeUrl, ATTRIBUTION, NEAR_CITIES } = require('../food/pla
@@ -36,6 +36,7 @@ const { searchPlaces, safeUrl, ATTRIBUTION, NEAR_CITIES } = require('../food/pla
const { matchPantry } = require('../food/pantry-match');
const { matchPantry } = require('../food/pantry-match');
const { suggestRecipes } = require('../food/recipes');
const { suggestRecipes } = require('../food/recipes');
const store = require('../food/store');
const store = require('../food/store');
+const { registerPlan } = require('../food/sharing');
const delivery = require('../food/delivery');
const delivery = require('../food/delivery');
const { mountBoxPages } = require('./box-pages');
const { mountBoxPages } = require('./box-pages');
const householdChat = require('../food/household-chat');
const householdChat = require('../food/household-chat');
@@ -372,20 +373,37 @@ ${plan && !signedIn(req) ? html`<p class="muted small"><a href="/auth/login?next
@@ -372,20 +373,37 @@ ${plan && !signedIn(req) ? html`<p class="muted small"><a href="/auth/login?next
id: s.newId('pln'), owner: ownerOf(req), title: str(body.title, 120),
id: s.newId('pln'), owner: ownerOf(req), title: str(body.title, 120),
people: plan.spec.people, days: plan.spec.days, budget: plan.spec.budget, plan, created_at: s.iso(),
people: plan.spec.people, days: plan.spec.days, budget: plan.spec.budget, plan, created_at: s.iso(),
});
});
+ await registerPlan(ctx.sharing, ownerOf(req), row.id, ctx.log || console);
return res.redirect(303, `${back}&saved=${encodeURIComponent(row.id)}`);
return res.redirect(303, `${back}&saved=${encodeURIComponent(row.id)}`);
});
});
// Saved plans sit under the same page: nothing personal is ever in a public cache.
// Saved plans sit under the same page: nothing personal is ever in a public cache.
r.get('/plan/saved', async (req, res) => {
r.get('/plan/saved', async (req, res) => {
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fplan%2Fsaved');
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fplan%2Fsaved');
- const [rows, shared] = await Promise.all([store.listPlans(s, ownerOf(req), { limit: 50 }), store.listSharedPlans(s, ownerOf(req))]);
+ const [rows, joined] = await Promise.all([store.listPlans(s, ownerOf(req), { limit: 50 }), store.listSharedPlans(s, ownerOf(req))]);
+ // A household member stays on the list only while the share row still lets them in (an old join link can be
+ // off, or the audience can have moved to Specific people without them).
+ const shared = [];
+ for (const row of joined) {
+ if (!ctx.sharing) { shared.push(row); continue; }
+ if (await store.planForViewer(s, ownerOf(req), row.id, { sharing: ctx.sharing })) shared.push(row);
+ }
const cells = (row, when) => {
const cells = (row, when) => {
const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;
const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;
return [html`<a href="/plan/p/${row.id}">${planTitle(row)}</a>`, `${row.people} people, ${row.days} days`, money(plan.totals.cost), time(when)];
return [html`<a href="/plan/p/${row.id}">${planTitle(row)}</a>`, `${row.people} people, ${row.days} days`, money(plan.totals.cost), time(when)];
};
};
+ const audience = ctx.sharing ? await ctx.sharing.defaultOf(ownerOf(req)).catch(() => 'private') : 'private';
+ const AUDIENCE_TEXT = { public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' };
+ const options = ['private', 'people', 'link', 'public']
+ .map((a) => html`<option value="${a}"${a === audience ? raw(' selected') : ''}>${AUDIENCE_TEXT[a]}</option>`);
page(req, res, {
page(req, res, {
title: 'Your plans', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Saved' }],
title: 'Your plans', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Saved' }],
body: html`<h1>Your saved plans</h1>
body: html`<h1>Your saved plans</h1>
+<div class="plan-actions plan-defaults">
+<form method="post" action="/plan/saved/defaults"><label class="muted small" for="plan-default-audience">New plans</label> <select id="plan-default-audience" name="audience">${options}</select>
+<label class="muted small"><input type="checkbox" name="apply_all" value="1"> Apply to all my existing plans</label>
+<button class="sc-btn" type="submit">Save</button></form></div>
+<p class="muted small">Only me unless you choose otherwise. A plan's Share button changes just that plan.</p>
${table(['Name', 'For', 'Cost', 'Saved'], rows.map((row) => cells(row, row.created_at)), { empty: 'No saved plans yet. Make one on the plan page and save it.' })}
${table(['Name', 'For', 'Cost', 'Saved'], rows.map((row) => cells(row, row.created_at)), { empty: 'No saved plans yet. Make one on the plan page and save it.' })}
${shared.length ? html`<h2>Shared with you</h2>
${shared.length ? html`<h2>Shared with you</h2>
${table(['Name', 'For', 'Cost', 'Joined'], shared.map((row) => cells(row, row.joined_at)))}` : ''}
${table(['Name', 'For', 'Cost', 'Joined'], shared.map((row) => cells(row, row.joined_at)))}` : ''}
@@ -404,14 +422,43 @@ ${table(['Name', 'For', 'Cost', 'Joined'], shared.map((row) => cells(row, row.jo
@@ -404,14 +422,43 @@ ${table(['Name', 'For', 'Cost', 'Joined'], shared.map((row) => cells(row, row.jo
const nameOf = (req) => String(req.viewer.displayName || req.viewer.username || 'someone').slice(0, 80);
const nameOf = (req) => String(req.viewer.displayName || req.viewer.username || 'someone').slice(0, 80);
const planTitle = (row) => row.title || `Plan ${String(row.id).slice(-6)}`;
const planTitle = (row) => row.title || `Plan ${String(row.id).slice(-6)}`;
const PLAN_STYLES = { styles: [showcase.STYLESHEET], localStyles: ['css/plan.css'] };
const PLAN_STYLES = { styles: [showcase.STYLESHEET], localStyles: ['css/plan.css'] };
+ // The one Share sheet (openvibe-shared 4.114+): loaded on the plan pages that carry a Share button or a gate.
+ const SHARE_HEAD = `<link rel="stylesheet" href="${require('openvibe-shared/serve').url('ov-share.css')}"><script src="${require('openvibe-shared/serve').url('ov-share.js')}" defer></script>`;
const form = express.urlencoded({ extended: false, limit: '16kb' });
const form = express.urlencoded({ extended: false, limit: '16kb' });
const toLogin = (req, res) => res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl.slice(0, 400))}`);
const toLogin = (req, res) => res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl.slice(0, 400))}`);
const notOurs = (req, res) => res.status(403).type('text/plain').send('A plan is changed from openvibe.food itself.');
const notOurs = (req, res) => res.status(403).type('text/plain').send('A plan is changed from openvibe.food itself.');
+ r.post('/plan/saved/defaults', form, async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fplan%2Fsaved');
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('A plan is changed from openvibe.food itself.');
+ if (ctx.sharing) {
+ const audience = ['private', 'people', 'link', 'public'].includes(req.body.audience) ? req.body.audience : 'private';
+ await ctx.sharing.setDefault(ownerOf(req), audience, { applyAll: String(req.body.apply_all || '') === '1' })
+ .catch((err) => (ctx.log || console).warn('[Food] the plan default was not saved:', err.message));
+ }
+ return res.redirect(303, '/plan/saved');
+ });
+
+
async function loadPlan(req, res) {
async function loadPlan(req, res) {
const id = str(req.params.id, 40);
const id = str(req.params.id, 40);
- const got = id && PLAN_ID.test(id) ? await store.planForViewer(s, ownerOf(req), id) : null;
+ const viewer = signedIn(req) ? ownerOf(req) : null;
+ const got = id && PLAN_ID.test(id) ? await store.planForViewer(s, viewer, id, { sharing: ctx.sharing }) : null;
if (got) return got;
if (got) return got;
+ // Specific people, and this reader is not on the list: the audience, never the plan's name, meals or list.
+ // openvibe-shared 4.123.0 draws the card from the [data-ov-share-gate] markup: no inline script, so a strict
+ // CSP needs nothing, and every value is escaped like any other attribute.
+ const audience = id && PLAN_ID.test(id) && ctx.sharing ? await ctx.sharing.audienceOf(id).catch(() => null) : null;
+ if (audience === 'people') {
+ const signInUrl = `/auth/login?next=${encodeURIComponent(`/plan/p/${id}`)}`;
+ page(req, res, {
+ title: 'A plan shared with specific people', ...PLAN_STYLES, head: SHARE_HEAD, index: false,
+ body: html`<h1>A meal plan shared with specific people</h1>
+<p class="lede">Sign in with the OpenVibe account it was shared with, or ask its owner for access.</p>
+<div id="share-gate" data-ov-share-gate data-kind="plan" data-id="${id}" data-signed-in-as="${signedIn(req) ? (req.viewer.username || '') : ''}" data-sign-in-url="${signInUrl}">This is shared with specific people. Sign in with the OpenVibe account it was shared with, or ask its owner for access.</div>`,
+ }, 404);
+ return null;
+ }
page(req, res, { title: 'Plan not found', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Not found' }],
page(req, res, { title: 'Plan not found', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Not found' }],
body: html`<h1>Plan not found</h1><p>It is not yours or shared with you, or it was deleted.</p><p><a href="/plan/saved">Your plans</a></p>` }, 404);
body: html`<h1>Plan not found</h1><p>It is not yours or shared with you, or it was deleted.</p><p><a href="/plan/saved">Your plans</a></p>` }, 404);
return null;
return null;
@@ -434,43 +481,66 @@ ${table(['Name', 'For', 'Cost', 'Joined'], shared.map((row) => cells(row, row.jo
@@ -434,43 +481,66 @@ ${table(['Name', 'For', 'Cost', 'Joined'], shared.map((row) => cells(row, row.jo
async function planPage(req, res, got, { link = null, problem = null, status = 200 } = {}) {
async function planPage(req, res, got, { link = null, problem = null, status = 200 } = {}) {
const { row, role } = got;
const { row, role } = got;
+ const canEdit = got.canEdit !== undefined ? !!got.canEdit : role !== 'viewer';
+ const audience = got.audience || (row.share_hash ? 'link' : 'private');
const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;
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 [members, ticks] = await Promise.all([store.listMembers(s, row.id), store.listTicks(s, row.id)]);
+ // The people the Share sheet added (Specific people). The household list is the same idea: everyone who can
+ // see this plan. Their names come from Network's projections; if it cannot answer, the subject's tail does.
+ const sharedPeople = ctx.sharing && role === 'owner' ? await ctx.sharing.membersOf(ownerOf(req), row.id).catch(() => []) : [];
if (role === 'owner' && row.chat_room) householdChat.syncMembers(chatDeps(req), row, members).catch(() => {});
if (role === 'owner' && row.chat_room) householdChat.syncMembers(chatDeps(req), row, members).catch(() => {});
const ticked = new Map(ticks.map((t) => [t.item, t]));
const ticked = new Map(ticks.map((t) => [t.item, t]));
const got_ = plan.shopping_list.filter((i) => ticked.has(i.slug)).length;
const got_ = plan.shopping_list.filter((i) => ticked.has(i.slug)).length;
- const me = ownerOf(req);
+ const me = signedIn(req) ? ownerOf(req) : null;
+ const people = members.length + sharedPeople.length + 1;
+ // A household's names are for the household; somebody reading an Everyone/link plan sees only how many.
+ const showHousehold = role === 'owner' || role === 'member' || audience === 'people' || audience === 'private';
+ const AUDIENCE_TEXT = { public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' };
const btn = (action, label, { cls = 'sc-btn', fields = {} } = {}) => html`<form method="post" action="/plan/p/${row.id}/${action}">${Object.entries(fields).map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}<button class="${cls}" type="submit">${label}</button></form>`;
const btn = (action, label, { cls = 'sc-btn', fields = {} } = {}) => html`<form method="post" action="/plan/p/${row.id}/${action}">${Object.entries(fields).map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}<button class="${cls}" type="submit">${label}</button></form>`;
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
page(req, res, {
page(req, res, {
- title: planTitle(row), crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Your plans', href: '/plan/saved' }, { label: planTitle(row) }], ...PLAN_STYLES,
+ title: planTitle(row), crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Your plans', href: '/plan/saved' }, { label: planTitle(row) }], ...PLAN_STYLES, head: SHARE_HEAD,
body: html`<h1>${planTitle(row)}</h1>
body: html`<h1>${planTitle(row)}</h1>
-<p class="lede">${role === 'owner' ? 'Your plan' : 'Shared with you'} · ${members.length + 1} ${members.length ? 'people in this household' : 'person so far'}.</p>
+<p class="lede">${role === 'owner' ? 'Your plan' : canEdit ? 'Shared with you' : 'Shared with you, read only'} · ${people} ${people === 1 ? 'person' : 'people'}.</p>
+<p class="plan-share"><button type="button" class="sc-btn" data-ov-share-sheet data-kind="plan" data-id="${row.id}" data-title="${planTitle(row)}">Share</button> <span class="badge">${AUDIENCE_TEXT[audience] || 'Only me'}</span></p>
${problem ? notice(problem, 'warn') : ''}
${problem ? notice(problem, 'warn') : ''}
<h2>The shopping list</h2>
<h2>The shopping list</h2>
-<p class="muted small">${got_} of ${plan.shopping_list.length} got. A tick shows for everyone in the household.</p>
+<p class="muted small">${got_} of ${plan.shopping_list.length} got. ${canEdit ? 'A tick shows for everyone it is shared with.' : 'Only its owner and the people with Can edit tick it.'}</p>
<ul class="shop-list">${plan.shopping_list.map((i) => {
<ul class="shop-list">${plan.shopping_list.map((i) => {
const t = ticked.get(i.slug);
const t = ticked.get(i.slug);
- return html`<li class="${t ? 'got' : ''}"><form method="post" action="/plan/p/${row.id}/tick"><input type="hidden" name="item" value="${i.slug}"><input type="hidden" name="done" value="${t ? '0' : '1'}"><button class="tick" type="submit" aria-pressed="${t ? 'true' : 'false'}" aria-label="${t ? 'Not got yet' : 'Got it'}: ${i.name}">${t ? '✓' : ''}</button></form>
+ const tick = canEdit
+ ? html`<form method="post" action="/plan/p/${row.id}/tick"><input type="hidden" name="item" value="${i.slug}"><input type="hidden" name="done" value="${t ? '0' : '1'}"><button class="tick" type="submit" aria-pressed="${t ? 'true' : 'false'}" aria-label="${t ? 'Not got yet' : 'Got it'}: ${i.name}">${t ? '✓' : ''}</button></form>`
+ : html`<span class="tick" aria-hidden="true">${t ? '✓' : ''}</span>`;
+ return html`<li class="${t ? 'got' : ''}">${tick}
<span class="shop-name"><a href="/foods/${i.slug}">${i.name}</a> × ${String(i.packs)}</span><span class="shop-meta muted small">${i.store_name || ''} ${money(i.cost)}${t ? html` · got by ${t.ticked_by === me ? 'you' : (t.name || 'someone')}` : ''}</span></li>`;
<span class="shop-name"><a href="/foods/${i.slug}">${i.name}</a> × ${String(i.packs)}</span><span class="shop-meta muted small">${i.store_name || ''} ${money(i.cost)}${t ? html` · got by ${t.ticked_by === me ? 'you' : (t.name || 'someone')}` : ''}</span></li>`;
})}</ul>
})}</ul>
${planBody(plan, { shopping: false })}
${planBody(plan, { shopping: false })}
<h2 id="household">The household</h2>
<h2 id="household">The household</h2>
-<ul class="household">
+${showHousehold ? html`<ul class="household">
<li>${role === 'owner' ? 'You' : 'The plan’s owner'} <span class="badge">owner</span></li>
<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>`)}
${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>
+${sharedPeople.map((m) => html`<li>${m.subject && m.subject.id === String(me).replace(/^user:/, '') ? 'You' : (m.display_name || m.username || 'Someone')} <span class="muted small">${m.role === 'editor' ? 'can edit' : 'can view'}${m.expires_at ? ` until ${String(m.expires_at).slice(0, 10)}` : ''}</span></li>`)}
+</ul>` : html`<p class="muted small">${people} ${people === 1 ? 'person' : 'people'} can open this plan. Its list of people is for them.</p>`}
${chatSection(row, role, members)}
${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>
+${role === 'owner' ? html`<h2 id="plan-together">The household link</h2>
+<p class="muted small">A join link is a second way in, for someone who is not an OpenVibe account you can name: anyone signed in who opens it joins the household and ticks the list with you. The Share button above is the sheet's one; this link is yours to turn on and off.</p>
+${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="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>
-<div class="plan-actions">${btn('share', row.share_hash ? 'Make a new share link' : 'Make a share link', { cls: 'sc-btn sc-primary' })}${row.share_hash ? btn('unshare', 'Turn the link off') : ''}${btn('delete', 'Delete this plan', { cls: 'sc-btn danger' })}</div>`
- : html`<div class="plan-actions">${btn('leave', 'Leave this household', { cls: 'sc-btn danger' })}</div>`}`,
+<p class="muted small">${row.share_hash ? 'A join link is on.' : 'No join link is on.'}</p>
+<div class="plan-actions">${btn('share', row.share_hash ? 'Make a new join link' : 'Make a join link')}${row.share_hash ? btn('unshare', 'Turn the link off') : ''}${btn('delete', 'Delete this plan', { cls: 'sc-btn danger' })}</div>`
+ : role === 'viewer' ? '' : html`<div class="plan-actions">${btn('leave', 'Leave this household', { cls: 'sc-btn danger' })}</div>`}`,
}, status);
}, status);
}
}
r.get('/plan/p/:id', async (req, res) => {
r.get('/plan/p/:id', async (req, res) => {
- if (!signedIn(req)) return toLogin(req, res);
+ // Anyone with the link (and Everyone) opens the page signed out; Only me and Specific people need a person.
+ if (!signedIn(req)) {
+ const id = str(req.params.id, 40);
+ const audience = ctx.sharing && PLAN_ID.test(id) ? await ctx.sharing.audienceOf(id).catch(() => null) : null;
+ // A private plan asks a visitor to sign in; a Specific-people plan shows the gate (loadPlan draws it), so a
+ // stranger signed out is offered Sign in rather than sent away from the plan's address.
+ if (audience !== 'link' && audience !== 'public' && audience !== 'people') return toLogin(req, res);
+ }
const got = await loadPlan(req, res);
const got = await loadPlan(req, res);
if (got) await planPage(req, res, got);
if (got) await planPage(req, res, got);
});
});
@@ -485,6 +555,7 @@ ${role === 'owner' ? html`${link ? html`<div class="notice ok" role="status"><p>
@@ -485,6 +555,7 @@ ${role === 'owner' ? html`${link ? html`<div class="notice ok" role="status"><p>
const back = () => res.redirect(303, `/plan/p/${row.id}`);
const back = () => res.redirect(303, `/plan/p/${row.id}`);
switch (req.params.action) {
switch (req.params.action) {
case 'tick': {
case 'tick': {
+ if (!got.canEdit) return res.status(403).type('text/plain').send('This plan was shared with you to read. Only its owner and the people with Can edit tick the list.');
const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;
const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;
const item = str(body.item, 80);
const item = str(body.item, 80);
if (!item || !plan.shopping_list.some((i) => i.slug === item)) return res.status(400).type('text/plain').send('That is not on this shopping list.');
if (!item || !plan.shopping_list.some((i) => i.slug === item)) return res.status(400).type('text/plain').send('That is not on this shopping list.');
@@ -502,9 +573,16 @@ ${role === 'owner' ? html`${link ? html`<div class="notice ok" role="status"><p>
@@ -502,9 +573,16 @@ ${role === 'owner' ? html`${link ? html`<div class="notice ok" role="status"><p>
return res.status(404).type('text/plain').send('Not found.');
return res.status(404).type('text/plain').send('Not found.');
}
}
if (req.params.action === 'share') {
if (req.params.action === 'share') {
+ // A join link is an invite to a household: the plan is Specific people, and this link is how somebody not
+ // yet on the list joins it. The audience first, then the link (an audience change ends any old link).
+ if (ctx.sharing) {
+ const audience = await ctx.sharing.audienceOf(row.id).catch(() => null);
+ if (!audience) await ctx.sharing.register(ownerOf(req), row.id, 'people').catch(() => {});
+ else if (audience !== 'people') await ctx.sharing.setAudience(ownerOf(req), row.id, 'people').catch(() => {});
+ }
const secret = crypto.randomBytes(32).toString('base64url');
const secret = crypto.randomBytes(32).toString('base64url');
await store.setShareHash(s, ownerOf(req), row.id, hashOf(secret));
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}` });
+ return planPage(req, res, { row: { ...row, share_hash: 'on' }, role, canEdit: true }, { link: `${config.baseUrl}/plan/join/${secret}` });
}
}
if (req.params.action === 'chat') {
if (req.params.action === 'chat') {
if (row.chat_room) return res.redirect(303, `/plan/p/${row.id}#chat`);
if (row.chat_room) return res.redirect(303, `/plan/p/${row.id}#chat`);
@@ -35,7 +35,37 @@ const TABLES = [
@@ -35,7 +35,37 @@ const TABLES = [
/** The account-data handle for Food's store (server/db.js createStore). */
/** The account-data handle for Food's store (server/db.js createStore). */
function create({ db, note = 'A pantry row names a food by its slug in server/food/foods.js.', log = console } = {}) {
function create({ db, note = 'A pantry row names a food by its slug in server/food/foods.js.', log = console } = {}) {
- return createAccountData({ db, service: 'food', tables: TABLES, note, log });
+ return createAccountData({
+ db,
+ service: 'food',
+ tables: TABLES,
+ /**
+ * The share row names its owner, members and requests by canonical subject (jsonb), which the table map's
+ * equality cannot reach: the person's own plans take their share rows with them, and their membership of
+ * someone else's plan goes. Children first: share_members, transfers and requests point at shares.
+ */
+ extraErase: async (t, subjects) => {
+ for (const subject of subjects) {
+ const id = String(subject).replace(/^user:/, '');
+ await t.exec("DELETE FROM share_members WHERE service='food' AND subject->>'id' = $1", [id]);
+ await t.exec("DELETE FROM share_transfers WHERE service='food' AND (from_subject->>'id' = $1 OR to_subject->>'id' = $1)", [id]);
+ await t.exec("DELETE FROM share_access_requests WHERE service='food' AND subject->>'id' = $1", [id]);
+ await t.exec("DELETE FROM food_share_defaults WHERE subject = $1", [id]);
+ }
+ for (const subject of subjects) {
+ const id = String(subject).replace(/^user:/, '');
+ const owned = await t.many("SELECT kind, resource_id FROM shares WHERE service='food' AND owner_subject->>'id' = $1", [id]);
+ for (const row of owned) {
+ await t.exec("DELETE FROM share_members WHERE service='food' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
+ await t.exec("DELETE FROM share_transfers WHERE service='food' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
+ await t.exec("DELETE FROM share_access_requests WHERE service='food' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
+ await t.exec("DELETE FROM shares WHERE service='food' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
+ }
+ }
+ },
+ note,
+ log,
+ });
}
}
module.exports = { create, TABLES, TOPICS };
module.exports = { create, TABLES, TOPICS };
@@ -106,6 +106,7 @@ ${appIcon.headTags({ site: 'food' })}
@@ -106,6 +106,7 @@ ${appIcon.headTags({ site: 'food' })}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}
${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
+${o.head || ''}
${render(scripts)}
${render(scripts)}
<meta name="ov-boost" content="food@${esc(RELEASE)}">
<meta name="ov-boost" content="food@${esc(RELEASE)}">
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
@@ -69,7 +69,12 @@ async function joinB(t) {
@@ -69,7 +69,12 @@ async function joinB(t) {
try {
try {
const missing = await uncovered(d.db, {
const missing = await uncovered(d.db, {
tables: TABLES,
tables: TABLES,
- exempt: { account_data_events: 'the ledger of requests itself: which export and deletion Food applied' },
+ exempt: {
+ account_data_events: 'the ledger of requests itself: which export and deletion Food applied',
+ share_members: 'the people a plan is shared with: erased and exported through the plan it belongs to',
+ share_access_requests: 'an ask for access to a plan: erased with the plan or with the person asking',
+ food_share_defaults: 'the person\'s default audience for new plans: erased with the person',
+ },
});
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
} finally { await d.close(); }
} finally { await d.close(); }
@@ -306,6 +306,24 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -306,6 +306,24 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
return await projectsApi(req, raw, json, u, parts, url.searchParams);
return await projectsApi(req, raw, json, u, parts, url.searchParams);
}
}
+ // The sharing system's (openvibe-sdk/share) name lookups and notifications.
+ if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {
+ const name = String(url.searchParams.get('username') || '').toLowerCase();
+ const u = st.byUsername.get(name);
+ if (!u) return problem(json, 404, 'identity.name_not_found', 'no such person');
+ return json(200, { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name });
+ }
+ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {
+ const subjectIds = JSON.parse(raw.toString('utf8') || '{}').subject_ids || [];
+ const results = {};
+ for (const subjectId of subjectIds) {
+ const u = [...st.users.values()].find((x) => x.subject === subjectId);
+ if (u) results[subjectId] = { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name };
+ }
+ return json(200, { results });
+ }
+ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
+
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
});
});
issuer = srv.url;
issuer = srv.url;
@@ -33,7 +33,7 @@ const CROSS = { 'sec-fetch-site': 'cross-site' };
@@ -33,7 +33,7 @@ const CROSS = { 'sec-fetch-site': 'cross-site' };
const mine = await t.get(`/plan/p/${id}`, { as: kim });
const mine = await t.get(`/plan/p/${id}`, { as: kim });
assert.strictEqual(mine.status, 200);
assert.strictEqual(mine.status, 200);
assert.match(mine.headers.get('cache-control'), /private/);
assert.match(mine.headers.get('cache-control'), /private/);
- assert.ok(mine.text.includes('Our week') && mine.text.includes('The household') && mine.text.includes('Make a share link'));
+ assert.ok(mine.text.includes('Our week') && mine.text.includes('The household') && mine.text.includes('Make a join link'));
item = (/name="item" value="([a-z0-9-]+)"/.exec(mine.text) || [])[1];
item = (/name="item" value="([a-z0-9-]+)"/.exec(mine.text) || [])[1];
assert.ok(item, 'the shopping list has tick buttons');
assert.ok(item, 'the shopping list has tick buttons');
assert.ok(mine.text.includes('css/plan.css') || mine.text.includes('plan.css'), 'its own stylesheet');
assert.ok(mine.text.includes('css/plan.css') || mine.text.includes('plan.css'), 'its own stylesheet');
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+/**
+ * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]): the plan page
+ * carries markup only, ov-share.js draws the gate when it loads, signed out it offers Sign in, and a signed-in person
+ * who is not on the list is offered Ask for access — with no console error and no CSP refusal.
+ *
+ * Skipped (exit 0) without Chrome, or with OV_SKIP_BROWSER=1. The page is served by this repo's own app on a
+ * temporary database, so the CSP headers the browser sees are Food's.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const { boot, check, done } = require('./helpers/boot');
+
+let playwright = null;
+try { playwright = require('playwright'); } catch {
+ try { playwright = require(process.env.PLAYWRIGHT_MODULE || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'); } catch { playwright = null; }
+}
+const CHROME = process.env.CHROME_BIN || '/usr/bin/google-chrome';
+if (process.env.OV_SKIP_BROWSER === '1' || !playwright || !fs.existsSync(CHROME)) {
+ console.log('share-gate-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const REFUSED = /Content Security Policy|Refused to (?:load|execute|apply)|Uncaught SyntaxError/i;
+
+(async () => {
+ const t = await boot();
+ const SAME = { 'sec-fetch-site': 'same-origin' };
+ const ada = t.network.addUser('ada');
+ const cy = t.network.addUser('cy');
+ const cookieOf = (user) => {
+ const [name, ...rest] = t.signIn(user).split('=');
+ return { name, value: rest.join('='), url: t.base };
+ };
+
+ let browser;
+ const errors = [];
+ const watch = (page) => {
+ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
+ page.on('pageerror', (e) => errors.push(String(e)));
+ };
+ const settled = async (page) => {
+ await page.waitForSelector('#share-gate .ov-share-gate', { timeout: 8000 });
+ return await page.$eval('#share-gate', (el) => el.textContent.replace(/\s+/g, ' ').trim());
+ };
+
+ try {
+ let id;
+ await check('a plan shared with Specific people, with nobody on the list yet', async () => {
+ const posted = await t.get('/plan', { as: ada, method: 'POST', form: { people: '3', days: '2', title: 'Our week' }, headers: SAME });
+ assert.strictEqual(posted.status, 303, posted.text);
+ id = new URL(posted.headers.get('location'), t.base).searchParams.get('saved');
+ assert.match(id, /^pln_[0-9A-Z]{26}$/);
+ await t.ctx.sharing.setAudience(`user:${ada.subject}`, id, 'people');
+ });
+
+ await check('headless Chrome: the gate draws itself, signed out, with Sign in', async () => {
+ browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
+ const ctx = await browser.newContext();
+ const page = await ctx.newPage();
+ watch(page);
+ const res = await page.goto(`${t.base}/plan/p/${id}`, { waitUntil: 'load', timeout: 15000 });
+ assert.strictEqual(res.status(), 404, 'the gate answers 404, never the plan');
+ const text = await settled(page);
+ assert.ok(/Sign in/.test(text), `signed out the gate offers Sign in, got: ${text}`);
+ const href = await page.$eval('#share-gate .ov-share-gate a', (a) => a.getAttribute('href'));
+ assert.match(href, /^\/auth\/login\?next=/);
+ await ctx.close();
+ });
+
+ await check('headless Chrome: signed in as somebody not on the list, Ask for access', async () => {
+ const ctx = await browser.newContext();
+ await ctx.addCookies([cookieOf(cy)]);
+ const page = await ctx.newPage();
+ watch(page);
+ await page.goto(`${t.base}/plan/p/${id}`, { waitUntil: 'load', timeout: 15000 });
+ const text = await settled(page);
+ assert.ok(/Ask for access/.test(text), `a stranger is offered Ask for access, got: ${text}`);
+ assert.ok(/@cy/.test(text), 'the gate names the account it is signed in as');
+ await ctx.close();
+ });
+
+ await check('headless Chrome: no console error and no CSP refusal on the gate page', async () => {
+ const refused = errors.filter((e) => REFUSED.test(e));
+ assert.deepStrictEqual(refused, [], `the gate page was refused: ${refused.join(' | ')}`);
+ });
+ } finally {
+ if (browser) await browser.close().catch(() => {});
+ await t.close();
+ done();
+ }
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+/**
+ * Sharing a saved meal plan (design 2026-11-06, visibility step 3): the one Share sheet (openvibe-sdk/share) and the
+ * one check, canView, on the plan page and the plans API. The meal plan and its shopping list are one thing.
+ *
+ * - a new plan is Only me; its page carries the Share button for its owner;
+ * - Anyone with the link is readable signed out, and a reader cannot tick;
+ * - Specific people shows the gate, never the plan's name or list, to somebody not on the list;
+ * - Can edit really ticks the shopping list; Can view only reads;
+ * - the person's default for new plans, and "apply to all", work;
+ * - a plan is never on a gathered page: not the sitemap, not llms.txt. A recipe, which is Everyone by nature, is.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const SAME = { 'sec-fetch-site': 'same-origin' };
+ try {
+ const kim = t.network.addUser('kim');
+ const bo = t.network.addUser('bo');
+ const cy = t.network.addUser('cy');
+ const di = t.network.addUser('di');
+
+ const call = (pathname, o = {}) => {
+ const { headers, ...rest } = o;
+ return t.get(pathname, { ...rest, headers: { 'x-requested-with': 'ov-share', ...(headers || {}) } });
+ };
+ const share = (id, o = {}) => call(`/api/v1/plan/${encodeURIComponent(id)}/share`, o);
+ const setShare = async (id, body, as) => {
+ const first = await share(id, { as });
+ assert.strictEqual(first.status, 200, first.text);
+ const etag = first.headers.get('etag');
+ assert.ok(etag);
+ return await share(id, { as, method: 'PUT', json: body, headers: { 'if-match': etag } });
+ };
+ const audienceRow = async (id) => await t.ctx.s.db.maybe(
+ 'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['food', 'plan', id]);
+ const newPlan = async (as, title) => {
+ const posted = await t.get('/plan', { as, method: 'POST', form: { people: '3', days: '2', title }, headers: SAME });
+ assert.strictEqual(posted.status, 303, posted.text);
+ return new URL(posted.headers.get('location'), t.base).searchParams.get('saved');
+ };
+ const firstItem = (text) => (/name="item" value="([a-z0-9-]+)"/.exec(text) || [])[1];
+
+ let id;
+ await check('a new plan is Only me, and its page carries the Share button for its owner', async () => {
+ id = await newPlan(kim, 'Our week');
+ assert.deepStrictEqual(await audienceRow(id), { audience: 'private' });
+ const mine = await t.get(`/plan/p/${id}`, { as: kim });
+ assert.strictEqual(mine.status, 200, mine.text);
+ assert.ok(mine.text.includes('data-ov-share-sheet') && mine.text.includes('data-kind="plan"'));
+ assert.ok(mine.text.includes('ov-share.js'));
+ assert.ok(mine.text.includes('Only me'));
+ assert.strictEqual((await t.get(`/plan/p/${id}`, { as: bo })).status, 404, 'not shared: not found');
+ const anon = await t.get(`/plan/p/${id}`);
+ assert.strictEqual(anon.status, 303, 'a private plan asks a visitor to sign in');
+ assert.match(anon.headers.get('location'), /^\/auth\/login\?next=/);
+ });
+
+ await check('Anyone with the link reads the plan signed out, and cannot tick', async () => {
+ const saved = await setShare(id, { audience: 'link' }, kim);
+ assert.strictEqual(saved.status, 200, saved.text);
+ assert.strictEqual(saved.json().audience, 'link');
+ const guest = await t.get(`/plan/p/${id}`);
+ assert.strictEqual(guest.status, 200, guest.text);
+ assert.ok(guest.text.includes('Our week'));
+ assert.ok(guest.text.includes('Anyone with the link'));
+ const item = firstItem(guest.text);
+ assert.ok(!item, 'a reader gets no tick form');
+ const api = await t.get(`/api/v1/plans/${id}`, { as: bo });
+ assert.strictEqual(api.status, 200, api.text);
+ assert.strictEqual(api.json().canEdit, false);
+ assert.strictEqual((await t.get(`/plan/p/${id}/tick`, { as: bo, method: 'POST', form: { item: 'rice', done: '1' }, headers: SAME })).status, 403);
+ });
+
+ await check('Specific people: an editor ticks, a viewer only reads, a stranger gets the gate', async () => {
+ const saved = await setShare(id, { audience: 'people', members: [{ name: 'bo', role: 'editor' }, { name: 'cy', role: 'viewer' }] }, kim);
+ assert.strictEqual(saved.status, 200, saved.text);
+ assert.strictEqual(saved.json().audience, 'people');
+ const editor = await t.get(`/plan/p/${id}`, { as: bo });
+ assert.strictEqual(editor.status, 200, editor.text);
+ const item = firstItem(editor.text);
+ assert.ok(item, 'Can edit opens the tick buttons');
+ assert.strictEqual((await t.get(`/plan/p/${id}/tick`, { as: bo, method: 'POST', form: { item, done: '1' }, headers: SAME })).status, 303);
+ assert.ok(/<li class="got">/.test((await t.get(`/plan/p/${id}`, { as: kim })).text), 'the owner sees the tick');
+ const viewer = await t.get(`/plan/p/${id}`, { as: cy });
+ assert.strictEqual(viewer.status, 200);
+ assert.ok(!firstItem(viewer.text), 'Can view cannot tick');
+ assert.strictEqual((await t.get(`/plan/p/${id}/tick`, { as: cy, method: 'POST', form: { item, done: '0' }, headers: SAME })).status, 403);
+ const stranger = await t.get(`/plan/p/${id}`, { as: di });
+ assert.strictEqual(stranger.status, 404);
+ assert.ok(stranger.text.includes('shared with specific people'));
+ assert.ok(!stranger.text.includes('Our week'), 'the gate never shows the plan name');
+ assert.strictEqual((await t.get(`/api/v1/plans/${id}`, { as: di })).status, 404);
+ });
+
+ await check('the default applies to the next plan, and "apply to all" moves the existing one', async () => {
+ const saved = await t.get('/plan/saved/defaults', { as: kim, method: 'POST', form: { audience: 'link', apply_all: '1' }, headers: SAME });
+ assert.strictEqual(saved.status, 303, saved.text);
+ assert.deepStrictEqual(await audienceRow(id), { audience: 'link' });
+ const next = await newPlan(kim, 'Next week');
+ assert.deepStrictEqual(await audienceRow(next), { audience: 'link' });
+ const page = await t.get('/plan/saved', { as: kim });
+ assert.ok(page.text.includes('New plans'), 'the default is on the plans page');
+ });
+
+ await check('gathered pages: a plan is never in the sitemap or llms.txt; a recipe is Everyone', async () => {
+ const next = await newPlan(kim, 'A public plan');
+ await setShare(next, { audience: 'public' }, kim);
+ const sitemap = await t.get('/sitemap.xml');
+ assert.strictEqual(sitemap.status, 200);
+ assert.ok(!sitemap.text.includes(next), 'a plan is not in the sitemap, even Everyone');
+ const llms = await t.get('/llms.txt');
+ assert.ok(!llms.text.includes(next), 'a plan is not in llms.txt');
+ assert.ok(/Disallow: \/plan\/p\//.test((await t.get('/robots.txt')).text));
+ // The recipe library is OpenVibe's own, Everyone by nature, and keeps the sheet's send part.
+ const recipe = await t.ctx.s.db.maybe('SELECT slug FROM food_library_recipes LIMIT 1');
+ if (recipe) {
+ const r = await t.get(`/recipes/library/${recipe.slug}`);
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('data-ov-share-sheet'));
+ assert.ok(!r.text.includes('data-kind="recipe"'), 'a recipe is public by nature: no audience part');
+ }
+ });
+ } finally {
+ await t.close();
+ done();
+ }
+})();