openvibers/openvibe.food Commits

Meal plans: the one Share sheet, Only me by default (#223)

OP OpenVibers committed 6 days ago · d744981527 · 18 files, +750 −36

* Meal plans: the one Share sheet, Only me by default

A saved plan and its shopping list are one thing: Everyone, Anyone with the
link, Specific people or Only me, through openvibe-sdk/share. Specific people
is the household case, with Can view or Can edit, so the person you shop with
ticks the list with you. The plan page and GET /api/v1/plans/:id ask canView;
ticking asks canEdit. Anyone with the link reads signed out; the household's
names stay for the household.

The old Plan-together join link becomes the invite to a Specific-people plan:
a plan with a link on is Specific people, joining adds a household member, and
any audience change (or apply-to-all) ends the old link. The recipe library is
OpenVibe's own and Everyone by nature; it keeps the sheet's send part, and a
plan is never in the sitemap or llms.txt. Account deletion takes the share
rows, memberships and defaults with it.

* Meal plans: the gate from markup (openvibe-shared 4.123.0)

The specific-people gate is markup now: [data-ov-share-gate] with kind, id,
signed-in-as and sign-in-url, drawn by openvibe-shared 4.123.0's ov-share.js.
No inline script, so a strict CSP needs nothing and every value is escaped as
an attribute. openvibe-shared v4.123.0.

A signed-out visitor on a Specific-people plan gets the gate itself (Sign in)
instead of being sent to the login page.

A headless-Chrome test draws the gate both ways: Sign in signed out, Ask for
access for a signed-in stranger, with no console error or CSP refusal.

Changes

ddiff --git a/README.md b/README.md
iindex c217d54..bebae92 100644
---- a/README.md
++++ b/README.md
@@@ -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,
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
--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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex c5d0d25..d82181f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/migrations/0013_shares.sql b/migrations/0013_shares.sql
nnew file mode 100644
iindex 0000000..91018f7
---- /dev/null
++++ b/migrations/0013_shares.sql
@@@ -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;
ddiff --git a/migrations/0014_share_defaults.sql b/migrations/0014_share_defaults.sql
nnew file mode 100644
iindex 0000000..d6dfee4
---- /dev/null
++++ b/migrations/0014_share_defaults.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 52a6a01..d90ebf6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex f2eb816..43882fc 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/server/app.js b/server/app.js
iindex 942fe6d..53783f6 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/food/sharing.js b/server/food/sharing.js
nnew file mode 100644
iindex 0000000..1dda33d
---- /dev/null
++++ b/server/food/sharing.js
@@@ -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 };
ddiff --git a/server/food/store.js b/server/food/store.js
iindex ba73ed9..b6fb9ec 100644
---- a/server/food/store.js
++++ b/server/food/store.js
@@@ -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 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]);
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 01fadc8..ef02c3b 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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) ──────────────────────────────
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 3af7931..0d2f174 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -36,6 +36,7 @@ const { searchPlaces, safeUrl, ATTRIBUTION, NEAR_CITIES } = require('../food/pla
const { 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?next
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(),
});
++ 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.jo
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 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.jo
async 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`);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 3b590dc..5ceb43b 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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 };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 7905691..4461219 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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>`,
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 3897fea..0d15aac 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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(); }
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex e42bf1b..4dab5e6 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -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);
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;
ddiff --git a/test/plan-together.test.js b/test/plan-together.test.js
iindex f0b7c47..bc248cf 100644
---- a/test/plan-together.test.js
++++ b/test/plan-together.test.js
@@@ -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');
ddiff --git a/test/share-gate-chrome.test.js b/test/share-gate-chrome.test.js
nnew file mode 100644
iindex 0000000..3caa556
---- /dev/null
++++ b/test/share-gate-chrome.test.js
@@@ -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();
++ }
++})();
ddiff --git a/test/sharing.test.js b/test/sharing.test.js
nnew file mode 100644
iindex 0000000..1e9bec1
---- /dev/null
++++ b/test/sharing.test.js
@@@ -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();
++ }
++})();