openvibers/openvibe.fun Commits

Quests and plans: their visibility moves onto share.v1 rows (#85)

OP OpenVibers committed 6 days ago · 4c2963bcb1 · 11 files, +523 −18

Visibility step 6 (design 2026-11-06): public/unlisted/hidden map 1:1 onto
Everyone/Anyone with the link/Only me in the one sharing model. The old column
is kept and still written whenever the audience changes, so an old reader fails
closed; the page reads ask the share row first and fall back to the column.
Accepting a quest makes it Everyone, hiding it makes it Only me, and staff
hiding writes the row too. The owner gets the Share button on the quest and
plan pages; a Specific-people item answers the gate.

Changes

ddiff --git a/migrations/0002_shares.sql b/migrations/0002_shares.sql
nnew file mode 100644
iindex 0000000..f693576
---- /dev/null
++++ b/migrations/0002_shares.sql
@@@ -0,0 +1,92 @@
++-- phase: expand
++-- The one sharing system (openvibe-sdk/share) on a watchlist: 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 watchlist's own row is trade_watchlists; one share row per watchlist, kind 'watchlist', id = wl_….
++
++
++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;
++
++-- Visibility step 6 (design 2026-11-06): Fun's own visibility columns become share rows, 1:1 — public -> Everyone,
++-- unlisted -> Anyone with the link, hidden -> Only me. The column is kept and still written (onAudienceChange), so an
++-- old reader fails closed: Everyone/link stay public/unlisted, Specific people and Only me are hidden. The read paths
++-- ask the share row first and fall back to the column for a row that predates this file. Repeating it is a no-op.
++
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT 'fun', 'quest', q.slug,
++ jsonb_build_object('type', 'user', 'id', COALESCE(q.created_by, b.streamer_subject)),
++ CASE q.visibility WHEN 'public' THEN 'public' WHEN 'unlisted' THEN 'link' ELSE 'private' END
++FROM fun_quests q JOIN fun_quest_boards b ON b.id = q.board_id
++WHERE COALESCE(q.created_by, b.streamer_subject) LIKE 'usr_%'
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
++
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT 'fun', 'plan', p.slug, jsonb_build_object('type', 'user', 'id', p.owner_subject),
++ CASE p.visibility WHEN 'public' THEN 'public' WHEN 'unlisted' THEN 'link' ELSE 'private' END
++FROM fun_plans p
++WHERE p.owner_subject LIKE 'usr_%'
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
ddiff --git a/server/app.js b/server/app.js
iindex 583f991..b0e2bef 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -27,6 +27,7 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
++const { createSharing } = require('./sharing');
const { createPageRoutes } = require('./http/pages');
const { createServiceReadiness } = require('./observability');
const { assetVersion, send } = require('./render/layout');
@@@ -72,7 +73,10 @@ async function createApp(opts = {}) {
}) : null,
});
const search = createSearchIndex({ config, s, onQueued: () => searchRelay.kick() });
-- const store = createStore(s, { search, config });
++ // The one sharing system (design 2026-11-06, step 6): Fun's visibility columns move onto share rows; the column
++ // is still written, so an old reader fails closed.
++ const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });
++ const store = createStore(s, { search, config, sharing });
const upstreams = createUpstreams({ config, fetchImpl, now: s.now, log });
const guideUpstream = createGuideUpstreams({ config, s, fetchImpl, now: s.now, log });
const guide = createGuide({ config, s, upstream: guideUpstream, now: s.now, log });
@@@ -88,7 +92,7 @@ async function createApp(opts = {}) {
const ctx = {
config, s, keys, sso, principal, search, searchRelay, store, upstreams, guide, guideUpstream, coins, chatSync,
-- accountData, accountSend, log,
++ accountData, accountSend, sharing, log,
};
const app = express();
@@@ -155,6 +159,26 @@ async function createApp(opts = {}) {
// ── Static assets (content-hashed ?v= → immutable) ──────
app.use('/shared', require('openvibe-shared/serve').handler());
++ // ── /api/v1/{quest,plan}/:slug/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. The sheet is a browser, so it presents the session cookie (req.viewer).
++ {
++ const shareApi = express.Router();
++ shareApi.use(sharing.shares.router({
++ service: 'fun',
++ 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(express.static(PUBLIC_DIR, {
index: false, redirect: false,
setHeaders(res, filePath) {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 2aa0c9e..b4796d7 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -31,6 +31,17 @@ const views = require('../views');
const PUBLIC_CACHE = 'public, max-age=60';
++// The one Share sheet (openvibe-shared ov-share.js): its stylesheet and script, and the gate that draws itself from
++// markup. A page with a Share button needs the shared navbar, which loads the sheet on the first press.
++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>`;
++
++/** The account a person not on a Specific-people item's list sees: the audience, never its title. */
++function shareGateHtml(kind, slug, { signedInAs = '', signInUrl = '/auth/login' } = {}) {
++ const what = kind === 'plan' ? 'plan' : 'quest';
++ return `<div class="fu-wrap fu-page-pad fu-narrow"><div class="fu-empty"><h1>This ${what} is shared with specific people</h1><p class="fu-muted">Sign in with the OpenVibe account it was shared with, or ask its owner for access.</p>
++<div data-ov-share-gate data-kind="${kind}" data-id="${slug}" data-signed-in-as="${signedInAs}" 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></div></div>`;
++}
++
function createPageRoutes(ctx) {
const { config, s, store, upstreams, guide, coins } = ctx;
const r = asyncRouter();
@@@ -182,11 +193,21 @@ function createPageRoutes(ctx) {
});
async function renderQuest(req, res, { problem = null, status = 200, flash = null } = {}) {
-- const out = await store.questForPage(String(req.params.slug || ''), { subject: signedIn(req) ? req.viewer.subject : null, staff: staffViewer(req) });
-- if (!out) return notFound(req, res);
++ const slug = String(req.params.slug || '');
++ const out = await store.questForPage(slug, { subject: signedIn(req) ? req.viewer.subject : null, staff: staffViewer(req) });
++ if (!out) {
++ const audience = ctx.sharing ? await ctx.sharing.audienceOf('quest', slug).catch(() => null) : null;
++ if (audience === 'people') {
++ return page(req, res, {
++ title: 'A quest shared with specific people', head: SHARE_HEAD,
++ body: raw(shareGateHtml('quest', slug, { signedInAs: signedIn(req) ? (req.viewer.user && req.viewer.user.username) || '' : '', signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` })),
++ }, 404);
++ }
++ return notFound(req, res);
++ }
const live = await liveOf(out.quest.streamer_username);
return page(req, res, {
-- index: out.quest.visibility === 'public', agentLayer: out.quest.visibility === 'public', cache: out.quest.visibility === 'public' ? PUBLIC_CACHE : null,
++ index: out.quest.visibility === 'public', agentLayer: out.quest.visibility === 'public', cache: out.quest.visibility === 'public' ? PUBLIC_CACHE : null, head: SHARE_HEAD,
title: out.quest.title,
description: out.quest.brief.slice(0, 160),
jsonLd: out.quest.visibility === 'public' ? [seo.jsonLd.webPage({ name: out.quest.title, url: `${base()}/q/${out.quest.slug}`, description: out.quest.brief.slice(0, 160), siteUrl: base() })] : [],
@@@ -282,10 +303,20 @@ function createPageRoutes(ctx) {
});
async function renderPlan(req, res, { problem = null, status = 200, flash = null } = {}) {
-- const out = await store.planForPage(String(req.params.slug || ''), { subject: signedIn(req) ? req.viewer.subject : null, staff: staffViewer(req) });
-- if (!out) return notFound(req, res);
++ const slug = String(req.params.slug || '');
++ const out = await store.planForPage(slug, { subject: signedIn(req) ? req.viewer.subject : null, staff: staffViewer(req) });
++ if (!out) {
++ const audience = ctx.sharing ? await ctx.sharing.audienceOf('plan', slug).catch(() => null) : null;
++ if (audience === 'people') {
++ return page(req, res, {
++ title: 'A plan shared with specific people', head: SHARE_HEAD,
++ body: raw(shareGateHtml('plan', slug, { signedInAs: signedIn(req) ? (req.viewer.user && req.viewer.user.username) || '' : '', signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` })),
++ }, 404);
++ }
++ return notFound(req, res);
++ }
return page(req, res, {
-- index: out.plan.visibility === 'public', agentLayer: out.plan.visibility === 'public', cache: out.plan.visibility === 'public' ? PUBLIC_CACHE : null,
++ index: out.plan.visibility === 'public', agentLayer: out.plan.visibility === 'public', cache: out.plan.visibility === 'public' ? PUBLIC_CACHE : null, head: SHARE_HEAD,
title: out.plan.title,
description: (out.plan.summary || 'A group plan on OpenVibe.Fun').slice(0, 160),
jsonLd: out.plan.visibility === 'public' ? [seo.jsonLd.webPage({ name: out.plan.title, url: `${base()}/plans/${out.plan.slug}`, description: (out.plan.summary || 'A group plan on OpenVibe.Fun').slice(0, 160), siteUrl: base() })] : [],
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 95dcb4a..8910ac7 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -32,6 +32,29 @@ function create({ db, log = console } = {}) {
db,
service: 'fun',
tables: TABLES,
++ /**
++ * The sharing rows (design 2026-11-06, step 6) name their owner and members by canonical subject. The item
++ * stays as the community's record with its name removed; its share row and the person's memberships go, and
++ * the read paths fall back to the old column (which stays public/unlisted). Children first.
++ */
++ extraErase: async (t, subjects) => {
++ for (const subject of subjects) {
++ const id = String(subject).replace(/^user:/, '');
++ await t.exec("DELETE FROM share_members WHERE service='fun' AND subject->>'id' = $1", [id]);
++ await t.exec("DELETE FROM share_transfers WHERE service='fun' AND (from_subject->>'id' = $1 OR to_subject->>'id' = $1)", [id]);
++ await t.exec("DELETE FROM share_access_requests WHERE service='fun' AND subject->>'id' = $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='fun' AND owner_subject->>'id' = $1", [id]);
++ for (const row of owned) {
++ await t.exec("DELETE FROM share_members WHERE service='fun' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
++ await t.exec("DELETE FROM share_transfers WHERE service='fun' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
++ await t.exec("DELETE FROM share_access_requests WHERE service='fun' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
++ await t.exec("DELETE FROM shares WHERE service='fun' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
++ }
++ }
++ },
note: 'A verified IRL quest and a shared group plan stay as the community\'s record with your name removed; your votes, checks, badges, rewards and reports are erased.',
log,
});
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 91f3055..d3acf31 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -83,7 +83,7 @@ function renderPage(o) {
navLinks: NAV,
navbar: nav,
footer,
-- head: `<meta name="referrer" content="strict-origin-when-cross-origin">
++ head: `${o.head || ''}<meta name="referrer" content="strict-origin-when-cross-origin">
${appIcon.headTags({ site: 'fun' })}
<link rel="stylesheet" href="${ovServe.url('kit.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..840e145
---- /dev/null
++++ b/server/sharing.js
@@@ -0,0 +1,173 @@
++'use strict';
++
++/**
++ * Sharing on OpenVibe.Fun (design 2026-11-06, visibility step 6), through openvibe-sdk/share.
++ *
++ * Fun already had its own three words on fun_quests.visibility and fun_plans.visibility. This moves them onto share
++ * rows, 1:1 (public -> Everyone, unlisted -> Anyone with the link, hidden -> Only me), and keeps the old column: it is
++ * still written whenever the audience changes, so an old reader fails closed (Everyone/link stay public/unlisted,
++ * Specific people and Only me are hidden). The read paths ask the share row first and fall back to the column for a
++ * row made before the move.
++ *
++ * A quest's owner is the streamer whose board it is (or the person who suggested it); a plan's owner is its creator.
++ * Status still rules a quest: only the streamer accepts it (and accepting makes it Everyone), and staff can hide it
++ * (which makes it Only me). Those transitions write the share row too, so the sheet and the column never disagree.
++ */
++const { createShares } = require('openvibe-sdk/share');
++const { serviceAuth } = require('openvibe-contracts');
++
++const SERVICE = 'fun';
++const KINDS = ['quest', 'plan'];
++const AUDIENCES = ['public', 'link', 'people', 'private'];
++/** The column's three words, and the audience each one means. */
++const AUDIENCE_OF_COLUMN = { public: 'public', unlisted: 'link', hidden: 'private' };
++const COLUMN_OF_AUDIENCE = { public: 'public', link: 'unlisted', people: 'hidden', private: 'hidden' };
++const TABLE = { quest: 'fun_quests', plan: 'fun_plans' };
++
++const refOf = (kind, id) => ({ service: SERVICE, kind: String(kind), id: String(id) });
++
++/** Fun's viewer as the SDK wants it: a subject object. Staff are moderators, not readers of a private plan. */
++function shareViewer(v) {
++ if (!v || v.kind !== 'user' || !v.subject) return null;
++ return { subject: { type: 'user', id: String(v.subject) }, staff: false };
++}
++
++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 quest's or plan's title. */
++ async function notify(n) {
++ const to = n && n.to;
++ if (!to || to.type !== 'user') return;
++ const words = {
++ 'share.added': ['Something was shared with you on OpenVibe.Fun', 'Open OpenVibe.Fun to read it.'],
++ 'share.access_requested': ['Someone asked for access on OpenVibe.Fun', 'Review the request on OpenVibe.Fun.'],
++ 'share.transfer.requested': ['Someone wants to give you something on OpenVibe.Fun', 'Accept or decline it on OpenVibe.Fun.'],
++ 'share.transfer.answered': ['A transfer on OpenVibe.Fun was answered', 'See what happened on OpenVibe.Fun.'],
++ }[n.kind] || ['Sharing on OpenVibe.Fun', 'There is something new for you.'];
++ const kind = n.ref && n.ref.kind === 'plan' ? 'plans' : 'q';
++ try {
++ await networkJson({
++ path: '/internal/notifications/push', method: 'POST',
++ json: {
++ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],
++ service: 'fun', url: n.ref && n.ref.id ? `${config.baseUrl}/${kind}/${encodeURIComponent(n.ref.id)}` : `${config.baseUrl}/`,
++ 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);
++ }
++
++ /** The share change writes the old column, in the same transaction, so an old reader sees the narrower word. */
++ async function onAudienceChange(ref, audience, tx) {
++ const table = TABLE[ref.kind];
++ if (!table) return;
++ await tx.exec(`UPDATE ${table} SET visibility = $1, updated_at = now() WHERE slug = $2`,
++ [COLUMN_OF_AUDIENCE[audience] || 'hidden', String(ref.id)]);
++ }
++
++ const shares = createShares({
++ db,
++ network: { json: networkJson },
++ events: { publish },
++ notify,
++ onAudienceChange,
++ });
++
++ /** The audience the share row holds, or null for a row made before the move (the column decides then). */
++ const audienceOf = async (kind, slug) => {
++ const row = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3',
++ [SERVICE, String(kind), String(slug)]);
++ return row ? row.audience : null;
++ };
++
++ return {
++ shares,
++ SERVICE,
++ KINDS,
++ AUDIENCES,
++ AUDIENCE_OF_COLUMN,
++ COLUMN_OF_AUDIENCE,
++ refOf,
++ shareViewer,
++ networkJson,
++ canView: async (viewer, kind, slug) => await shares.canView(shareViewer(viewer), refOf(kind, slug)),
++ canEdit: async (viewer, kind, slug) => await shares.canEdit(shareViewer(viewer), refOf(kind, slug)),
++ audienceOf,
++ /** A new quest or plan: its column's word becomes its share row, at the same moment and in the same transaction. */
++ registerSystem: async (kind, slug, ownerSubject, column, tx = db) => {
++ if (!TABLE[kind] || !ownerSubject || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(ownerSubject))) return;
++ await tx.exec(`INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++ VALUES ($1, $2, $3, $4, $5) ON CONFLICT (service, kind, resource_id) DO NOTHING`,
++ [SERVICE, String(kind), String(slug), { type: 'user', id: String(ownerSubject) }, AUDIENCE_OF_COLUMN[column] || 'private']);
++ },
++ /** A status change that moves the audience (accept -> Everyone, hide -> Only me). Never widens silently. */
++ setSystemAudience: async (kind, slug, column, tx = db) => {
++ if (!TABLE[kind]) return;
++ await tx.exec('UPDATE shares SET audience = $1, revision = revision + 1 WHERE service = $2 AND kind = $3 AND resource_id = $4',
++ [AUDIENCE_OF_COLUMN[column] || 'private', SERVICE, String(kind), String(slug)]);
++ },
++ /** The uniform API's describe(ref): the quest's or plan's title and page. */
++ describe: async (ref) => {
++ if (ref.service !== SERVICE) return {};
++ if (ref.kind === 'quest') {
++ const row = await db.maybe('SELECT slug, title FROM fun_quests WHERE slug = $1', [String(ref.id)]);
++ if (!row) return {};
++ return { title: String(row.title || 'A quest').slice(0, 120), link: `${config.baseUrl}/q/${encodeURIComponent(row.slug)}`, roles: ['viewer'], transferable: false };
++ }
++ if (ref.kind === 'plan') {
++ const row = await db.maybe('SELECT slug, title FROM fun_plans WHERE slug = $1', [String(ref.id)]);
++ if (!row) return {};
++ return { title: String(row.title || 'A plan').slice(0, 120), link: `${config.baseUrl}/plans/${encodeURIComponent(row.slug)}`, roles: ['viewer'], transferable: false };
++ }
++ return {};
++ },
++ /** Expire members and transfers, then send the queued events and notifications. */
++ 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 };
++ },
++ };
++}
++
++module.exports = { createSharing, shareViewer, refOf, KINDS, AUDIENCES, AUDIENCE_OF_COLUMN, COLUMN_OF_AUDIENCE, SERVICE };
ddiff --git a/server/store.js b/server/store.js
iindex 87574e0..4e058cc 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -100,7 +100,7 @@ function parseWhen(v) {
return Number.isNaN(d.getTime()) ? null : d;
}
--function createStore(s, { search = { sync: async () => null }, config = {} } = {}) {
++function createStore(s, { search = { sync: async () => null }, config = {}, sharing = null } = {}) {
const db = s.db;
const q = (t) => t || db;
const questConfig = config.quests || {};
@@@ -117,6 +117,26 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
async function planBySlug(slug, t) {
return await q(t).maybe('SELECT * FROM fun_plans WHERE slug = $1', [String(slug || '')]);
}
++
++ /**
++ * The audience a quest or plan really has (design 2026-11-06, step 6): its share row, or — for a row made before
++ * the move — its old column, 1:1. The column is kept written on every change, so gathered queries still filter on it.
++ */
++ async function audienceOf(kind, slug, column) {
++ const moved = sharing ? await sharing.audienceOf(kind, slug).catch(() => null) : null;
++ return moved || ({ public: 'public', unlisted: 'link', hidden: 'private' })[column] || 'private';
++ }
++
++ /** Whether this viewer may read it: the owner and staff always, Everyone/link anyone, Specific people only members. */
++ async function readerMaySee(kind, slug, column, { subject = null, staff = false, owner = false } = {}) {
++ if (owner || staff) return true;
++ const audience = await audienceOf(kind, slug, column);
++ if (audience === 'public' || audience === 'link') return true;
++ if (audience === 'people' && sharing) {
++ return await sharing.canView(subject ? { kind: 'user', subject } : null, kind, slug).catch(() => false);
++ }
++ return false;
++ }
async function planById(id, t) {
return await q(t).maybe('SELECT * FROM fun_plans WHERE id = $1', [String(id || '')]);
}
@@@ -231,6 +251,8 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
board.live_username, board.chat_username, `https://openvibe.live/@${encodeURIComponent(board.live_username)}`,
subject && SUBJECT_RE.test(String(subject)) ? subject : null, chatUsername ? chatVoter(chatUsername).slice(5) : null],
);
++ // Its share row starts at the suggestion's own word (unlisted -> Anyone with the link).
++ if (sharing) await sharing.registerSystem('quest', slug, board.streamer_subject, row.visibility, t);
return { ok: true, quest: wireQuest(row, { votes: 0 }) };
});
}
@@@ -290,7 +312,11 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *`,
[quest.id, Number(questConfig.coins) || 0],
);
++ // Accepting makes it Everyone, on the share row as well as the old column.
++ if (sharing) await sharing.setSystemAudience('quest', quest.slug, 'public', t);
await t.exec("UPDATE fun_quests SET status = 'rejected', visibility = 'hidden', revision = revision + 1, updated_at = now() WHERE board_id = $1 AND id <> $2 AND status = 'suggested'", [board.id, quest.id]);
++ if (sharing) await t.exec(`UPDATE shares SET audience = 'private', revision = revision + 1
++ WHERE service = 'fun' AND kind = 'quest' AND resource_id IN (SELECT slug FROM fun_quests WHERE board_id = $1 AND visibility = 'hidden')`, [board.id]);
await t.exec("UPDATE fun_quest_boards SET status = 'accepted', accepted_quest_id = $2, updated_at = now() WHERE id = $1", [board.id, quest.id]);
const out = wireQuest(updated, { votes: (await countsForQuest(quest.id, t)).votes });
await search.sync(t, 'quest', out);
@@@ -381,6 +407,7 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
if (!quest) return refusal(404, 'fun.quest_not_found', 'No such quest.');
if (quest.visibility === 'hidden') return { ok: true, quest: wireQuest(quest), unchanged: true };
const updated = await t.maybe("UPDATE fun_quests SET visibility = 'hidden', status = 'hidden', revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [quest.id]);
++ if (sharing) await sharing.setSystemAudience('quest', quest.slug, 'hidden', t);
await search.sync(t, 'quest', wireQuest(updated));
return { ok: true, quest: wireQuest(updated) };
});
@@@ -391,11 +418,9 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
if (!quest) return null;
const board = await boardById(quest.board_id);
const isOwner = Boolean(subject && board && board.streamer_subject === subject);
-- if (quest.visibility === 'hidden' && !staff && !isOwner) return null;
-- if (quest.visibility === 'unlisted' && !staff && !isOwner && !subject) {
-- // An unlisted suggestion is visible by its board link, not by the public quest URL, unless signed in.
-- // The board page is the shareable place; the quest page still works for a signed-in viewer by design.
-- }
++ // Its audience (the share row, else the old column) decides: Only me is the owner's, Specific people is its
++ // members', Everyone and Anyone with the link are readable by whoever has the address.
++ if (!await readerMaySee('quest', quest.slug, quest.visibility, { subject, staff, owner: isOwner })) return null;
const counts = await countsForQuest(quest.id);
const voted = subject ? Boolean(await db.maybe('SELECT 1 AS x FROM fun_quest_votes WHERE quest_id = $1 AND voter = $2', [quest.id, subject])) : false;
return { quest: wireQuest(quest, { votes: counts.votes, checks: counts.checks, mine: isOwner, voted }), board: wireBoard(board) };
@@@ -501,6 +526,8 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
saved.push(wireOption(opt));
}
const plan = wirePlan(row, { options: saved });
++ // Its share row starts at the form's own choice (public -> Everyone, unlisted -> Anyone with the link).
++ if (sharing) await sharing.registerSystem('plan', slug, ownerSubject, row.visibility, t);
if (row.visibility === 'public') await search.sync(t, 'plan', plan, saved);
return { ok: true, plan };
});
@@@ -578,7 +605,7 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
const plan = await planBySlug(slug);
if (!plan) return null;
const isOwner = Boolean(subject && plan.owner_subject === subject);
-- if (plan.visibility === 'hidden' && !staff && !isOwner) return null;
++ if (!await readerMaySee('plan', plan.slug, plan.visibility, { subject, staff, owner: isOwner })) return null;
const rows = await db.many('SELECT * FROM fun_plan_options WHERE plan_id = $1 ORDER BY position', [plan.id]);
const voteRows = await db.many('SELECT option_id, COUNT(*)::int AS votes FROM fun_plan_votes WHERE plan_id = $1 GROUP BY option_id', [plan.id]);
const byOption = Object.fromEntries(voteRows.map((r) => [r.option_id, Number(r.votes) || 0]));
@@@ -633,6 +660,7 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
if (!plan) return refusal(404, 'fun.plan_not_found', 'No such plan.');
if (plan.visibility === 'hidden') return { ok: true, plan: wirePlan(plan), unchanged: true };
const updated = await t.maybe("UPDATE fun_plans SET visibility = 'hidden', status = 'cancelled', revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [plan.id]);
++ if (sharing) await sharing.setSystemAudience('plan', plan.slug, 'hidden', t);
await search.sync(t, 'plan', wirePlan(updated), []);
return { ok: true, plan: wirePlan(updated) };
});
ddiff --git a/server/views/index.js b/server/views/index.js
iindex abb677e..f864396 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -250,6 +250,7 @@ function quest({ q, board: b, live = null, signedIn = false, voted = false, mine
return `<div class="fu-wrap" id="fu-quest" data-quest="${esc(q.slug)}">
<article class="fu-quest-detail">
<header class="fu-page-h"><p class="fu-kicker">${categoryLabel(q.category)} · ${statusBadge(q.status)}${mine ? ' · your quest' : ''}</p><h1>${esc(q.title)}</h1>
++${mine ? `<p><button type="button" class="fu-btn fu-btn-quiet" data-ov-share-sheet data-kind="quest" data-id="${esc(q.slug)}" data-title="${esc(q.title)}">Share</button></p>` : ''}
<p class="fu-lede">${esc(q.brief)}</p>
<dl class="fu-facts"><div><dt>Votes</dt><dd><strong id="fu-quest-votes">${Number(q.votes) || 0}</strong></dd></div><div><dt>Verification checks</dt><dd id="fu-quest-checks">${Number(q.checks) || 0}</dd></div><div><dt>On Live</dt><dd>${q.streamer_username ? `<a href="${esc(q.stream_url)}" rel="noopener">@${esc(q.streamer_username)}</a>${live && live.live ? ' · live now' : ''}` : '—'}</dd></div><div><dt>Badge</dt><dd>${q.reward_badge ? esc(q.reward_badge) : 'paid on verification'}</dd></div></dl>
${q.place_name ? `<p class="fu-muted">Place: ${esc(q.place_name)}${q.city ? `, ${esc(q.city)}` : ''}${q.accessible ? ' · accessible' : ''}</p>` : ''}</header>
@@@ -338,6 +339,7 @@ function plan({ p, options = [], winner = null, signedIn = false, mine = false,
return `<div class="fu-wrap" id="fu-plan" data-plan="${esc(p.slug)}">
<article class="fu-plan-detail">
<header class="fu-page-h"><p class="fu-kicker">${statusBadge(p.status)} · ${p.visibility === 'public' ? 'public' : p.visibility === 'unlisted' ? 'share link' : 'private'}</p><h1>${esc(p.title)}</h1>
++${mine ? `<p><button type="button" class="fu-btn fu-btn-quiet" data-ov-share-sheet data-kind="plan" data-id="${esc(p.slug)}" data-title="${esc(p.title)}">Share</button></p>` : ''}
<p class="fu-lede">${p.summary ? esc(p.summary) : `A group plan${p.city ? ` in ${esc(p.city)}` : ''}.`}</p>
<dl class="fu-facts"><div><dt>Starts</dt><dd>${when(p.starts_at)}</dd></div><div><dt>Timezone</dt><dd>${esc(p.timezone)}</dd></div><div><dt>Votes</dt><dd id="fu-plan-total">${Number(totalVotes) || 0}</dd></div><div><dt>Voting closes</dt><dd>${when(p.closes_at)}</dd></div></dl></header>
${flash ? `<p class="fu-note" role="status">${esc(flash)}</p>` : ''}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex d55dddd..944aff8 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -49,7 +49,7 @@ async function startNetworkStub() {
try {
const missing = await uncovered(d.db, {
tables: TABLES,
-- exempt: {account_data_events: 'the ledger of requests itself: which export and deletion fun applied'},
++ exempt: {account_data_events: 'the ledger of requests itself: which export and deletion fun applied', share_members: "the SDK's own sharing model: subject is an added person, erased by extraErase", share_access_requests: "the SDK's own sharing model: subject is the person who asked, erased by extraErase"},
});
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 a1e7b5e..81120db 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -118,9 +118,23 @@ async function startNetwork() {
return json(200, { items: [], next_cursor: null });
}
-- if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });
++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {
++ let b = {}; try { b = JSON.parse(raw.toString('utf8') || '{}'); } catch { /* */ }
++ const results = {};
++ for (const subjectId of b.subject_ids || []) {
++ const u = [...st.users.values()].find((x) => x.subject === subjectId);
++ results[subjectId] = u ? { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false } : null;
++ }
++ return json(200, { results });
++ }
if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {
++ const name = url.searchParams.get('username');
++ if (name) {
++ const u = [...st.users.values()].find((x) => String(x.username).toLowerCase() === String(name).toLowerCase());
++ if (u) return json(200, { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name });
++ return json(404, { code: 'identity.not_found' });
++ }
return json(200, { subject_id: url.searchParams.get('subject_id'), network_user_id: 42 });
}
if (url.pathname === '/internal/coins/credit' && req.method === 'POST') {
ddiff --git a/test/sharing.test.js b/test/sharing.test.js
nnew file mode 100644
iindex 0000000..2d036b6
---- /dev/null
++++ b/test/sharing.test.js
@@@ -0,0 +1,118 @@
++'use strict';
++/**
++ * Sharing a plan and a quest (design 2026-11-06, visibility step 6): Fun's own visibility columns move onto
++ * openvibe-sdk/share rows, 1:1, and the old column is kept in step so old readers fail closed.
++ *
++ * - a plan's word at creation is its share row (unlisted -> Anyone with the link);
++ * - the Share sheet's audience writes the old column (people/private -> hidden);
++ * - a Specific-people plan answers the gate, never its title, to somebody not on the list;
++ * - accepting a quest makes it Everyone, on both the column and the share row;
++ * - gathered pages still list only the column's public rows.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const sharing = require('../server/sharing');
++const { createStore } = require('../server/store');
++const pages = require('../server/http/pages');
++const views = require('../server/views');
++const { createApp } = require('../server/app');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the sharing wiring loads: the column mapping is 1:1', async () => {
++ assert.strictEqual(sharing.SERVICE, 'fun');
++ assert.deepStrictEqual(sharing.KINDS, ['quest', 'plan']);
++ assert.deepStrictEqual(sharing.AUDIENCE_OF_COLUMN, { public: 'public', unlisted: 'link', hidden: 'private' });
++ assert.deepStrictEqual(sharing.COLUMN_OF_AUDIENCE, { public: 'public', link: 'unlisted', people: 'hidden', private: 'hidden' });
++ assert.strictEqual(sharing.shareViewer({ kind: 'anonymous' }), null);
++ assert.strictEqual(typeof createStore, 'function');
++ assert.strictEqual(typeof pages.createPageRoutes, 'function');
++ assert.strictEqual(typeof createApp, 'function');
++ assert.strictEqual(typeof views.quest, 'function');
++ assert.strictEqual(typeof views.plan, 'function');
++ });
++ const kim = t.network.addUser('kim');
++ const bo = t.network.addUser('bo');
++ const future = new Date(Date.now() + 172_800_000).toISOString();
++ const audienceRow = async (kind, slug) => await t.ctx.s.db.maybe(
++ 'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['fun', kind, slug]);
++ const column = async (slug) => await t.ctx.s.db.value('SELECT visibility FROM fun_plans WHERE slug = $1', [slug]);
++ const call = (p, o = {}) => {
++ const { headers, ...rest } = o;
++ return t.get(p, { ...rest, headers: { 'x-requested-with': 'ov-share', ...(headers || {}) } });
++ };
++ const share = (kind, slug, o = {}) => call(`/api/v1/${kind}/${encodeURIComponent(slug)}/share`, o);
++ const setShare = async (kind, slug, body, as) => {
++ const first = await share(kind, slug, { as });
++ assert.strictEqual(first.status, 200, first.text);
++ const etag = first.headers.get('etag');
++ assert.ok(etag);
++ return await share(kind, slug, { as, method: 'PUT', json: body, headers: { 'if-match': etag } });
++ };
++
++ let slug;
++ await check('a plan starts at its own word (unlisted -> Anyone with the link), and its owner gets the Share button', async () => {
++ const out = await t.ctx.store.createPlan({ ownerSubject: kim.subject, ownerUsername: 'kim', title: 'Beach day', startsAt: future, visibility: 'unlisted', options: [{ title: 'The pier' }] });
++ assert.strictEqual(out.ok, true, out.message);
++ slug = out.plan.slug;
++ assert.deepStrictEqual(await audienceRow('plan', slug), { audience: 'link' });
++ assert.strictEqual(await column(slug), 'unlisted');
++ const guest = await t.get(`/plans/${slug}`);
++ assert.strictEqual(guest.status, 200, guest.text);
++ assert.ok(guest.text.includes('Beach day'));
++ const mine = await t.get(`/plans/${slug}`, { as: kim });
++ assert.strictEqual(mine.status, 200);
++ assert.ok(mine.text.includes('data-ov-share-sheet') && mine.text.includes('data-kind="plan"'));
++ assert.ok(mine.text.includes('ov-share.js'));
++ });
++
++ await check('the Share sheet writes the old column: Specific people becomes hidden and answers the gate', async () => {
++ const saved = await setShare('plan', slug, { audience: 'people', members: [{ name: 'bo', role: 'viewer' }] }, kim);
++ assert.strictEqual(saved.status, 200, saved.text);
++ assert.strictEqual(await column(slug), 'hidden', 'an old reader sees Only me');
++ const viewer = await t.get(`/plans/${slug}`, { as: bo });
++ assert.strictEqual(viewer.status, 200, viewer.text);
++ assert.ok(viewer.text.includes('Beach day'));
++ const stranger = t.network.addUser('di');
++ const gate = await t.get(`/plans/${slug}`, { as: stranger });
++ assert.strictEqual(gate.status, 404);
++ assert.ok(gate.text.includes('shared with specific people'));
++ assert.ok(!gate.text.includes('Beach day'), 'the gate never shows the title');
++ assert.ok(gate.text.includes('data-ov-share-gate'));
++ assert.strictEqual((await t.get(`/api/v1/plans/${slug}`, { as: stranger })).status, 404);
++ });
++
++ await check('Everyone writes public back to the column and the gathered list', async () => {
++ const saved = await setShare('plan', slug, { audience: 'public' }, kim);
++ assert.strictEqual(saved.status, 200, saved.text);
++ assert.strictEqual(await column(slug), 'public');
++ const list = await t.get('/api/v1/plans');
++ assert.strictEqual(list.status, 200);
++ assert.ok(list.json().plans.some((p) => p.slug === slug), 'a public plan is listed');
++ const sitemap = await t.get('/sitemap.xml');
++ assert.ok(sitemap.text.includes(slug), 'a public plan is on the sitemap');
++ });
++
++ await check('a quest suggestion is a link; accepting it makes it Everyone on both the column and the share row', async () => {
++ const board = await t.ctx.store.createBoard({ streamerSubject: kim.subject, title: 'Saturday quests', liveUsername: 'kim', chatUsername: 'kimchat' });
++ assert.strictEqual(board.ok, true, board.message);
++ const sug = await t.ctx.store.createSuggestion({ boardId: board.board.id, subject: bo.subject, title: 'Dance in the park', brief: 'A ten minute dance.', category: 'silly' });
++ assert.strictEqual(sug.ok, true, sug.message);
++ const qsl = sug.quest.slug;
++ assert.deepStrictEqual(await audienceRow('quest', qsl), { audience: 'link' });
++ assert.strictEqual(await t.ctx.s.db.value('SELECT visibility FROM fun_quests WHERE slug = $1', [qsl]), 'unlisted');
++ const accepted = await t.ctx.store.acceptQuest({ slug: qsl, subject: kim.subject });
++ assert.strictEqual(accepted.ok, true, accepted.message);
++ assert.deepStrictEqual(await audienceRow('quest', qsl), { audience: 'public' });
++ assert.strictEqual(await t.ctx.s.db.value('SELECT visibility FROM fun_quests WHERE slug = $1', [qsl]), 'public');
++ const guest = await t.get(`/q/${qsl}`);
++ assert.strictEqual(guest.status, 200, guest.text);
++ assert.ok(guest.text.includes('Dance in the park'));
++ });
++
++ done();
++ } finally {
++ await t.close();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });