openvibers/openvibe.fun Commits
Quests and plans: their visibility moves onto share.v1 rows (#85)
Changes
nnew file mode 100644@@@ -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;@@@ -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) {@@@ -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() })] : [],@@@ -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,});@@@ -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')}">nnew file mode 100644@@@ -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 };@@@ -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) };});@@@ -250,6 +250,7 @@ function quest({ q, board: b, live = null, signedIn = false, voted = false, minereturn `<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>` : ''}@@@ -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(); }@@@ -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') {nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -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;
@@ -27,6 +27,7 @@ const { createKeyStore } = require('./auth/keys');
@@ -27,6 +27,7 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
+const { createSharing } = require('./sharing');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
@@ -72,7 +73,10 @@ async function createApp(opts = {}) {
@@ -72,7 +73,10 @@ async function createApp(opts = {}) {
}) : null,
}) : null,
});
});
const search = createSearchIndex({ config, s, onQueued: () => searchRelay.kick() });
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 upstreams = createUpstreams({ config, fetchImpl, now: s.now, log });
const guideUpstream = createGuideUpstreams({ config, s, 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 });
const guide = createGuide({ config, s, upstream: guideUpstream, now: s.now, log });
@@ -88,7 +92,7 @@ async function createApp(opts = {}) {
@@ -88,7 +92,7 @@ async function createApp(opts = {}) {
const ctx = {
const ctx = {
config, s, keys, sso, principal, search, searchRelay, store, upstreams, guide, guideUpstream, coins, chatSync,
config, s, keys, sso, principal, search, searchRelay, store, upstreams, guide, guideUpstream, coins, chatSync,
- accountData, accountSend, log,
+ accountData, accountSend, sharing, log,
};
};
const app = express();
const app = express();
@@ -155,6 +159,26 @@ async function createApp(opts = {}) {
@@ -155,6 +159,26 @@ async function createApp(opts = {}) {
// ── Static assets (content-hashed ?v= → immutable) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
app.use('/shared', require('openvibe-shared/serve').handler());
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, {
app.use(express.static(PUBLIC_DIR, {
index: false, redirect: false,
index: false, redirect: false,
setHeaders(res, filePath) {
setHeaders(res, filePath) {
@@ -31,6 +31,17 @@ const views = require('../views');
@@ -31,6 +31,17 @@ const views = require('../views');
const PUBLIC_CACHE = 'public, max-age=60';
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) {
function createPageRoutes(ctx) {
const { config, s, store, upstreams, guide, coins } = ctx;
const { config, s, store, upstreams, guide, coins } = ctx;
const r = asyncRouter();
const r = asyncRouter();
@@ -182,11 +193,21 @@ function createPageRoutes(ctx) {
@@ -182,11 +193,21 @@ function createPageRoutes(ctx) {
});
});
async function renderQuest(req, res, { problem = null, status = 200, flash = null } = {}) {
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);
const live = await liveOf(out.quest.streamer_username);
return page(req, res, {
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,
title: out.quest.title,
description: out.quest.brief.slice(0, 160),
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() })] : [],
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) {
@@ -282,10 +303,20 @@ function createPageRoutes(ctx) {
});
});
async function renderPlan(req, res, { problem = null, status = 200, flash = null } = {}) {
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, {
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,
title: out.plan.title,
description: (out.plan.summary || 'A group plan on OpenVibe.Fun').slice(0, 160),
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() })] : [],
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() })] : [],
@@ -32,6 +32,29 @@ function create({ db, log = console } = {}) {
@@ -32,6 +32,29 @@ function create({ db, log = console } = {}) {
db,
db,
service: 'fun',
service: 'fun',
tables: TABLES,
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.',
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,
log,
});
});
@@ -83,7 +83,7 @@ function renderPage(o) {
@@ -83,7 +83,7 @@ function renderPage(o) {
navLinks: NAV,
navLinks: NAV,
navbar: nav,
navbar: nav,
footer,
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' })}
${appIcon.headTags({ site: 'fun' })}
<link rel="stylesheet" href="${ovServe.url('kit.css')}">
<link rel="stylesheet" href="${ovServe.url('kit.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
new file mode 100644
new file mode 100644
@@ -0,0 +1,173 @@
@@ -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 };
@@ -100,7 +100,7 @@ function parseWhen(v) {
@@ -100,7 +100,7 @@ function parseWhen(v) {
return Number.isNaN(d.getTime()) ? null : d;
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 db = s.db;
const q = (t) => t || db;
const q = (t) => t || db;
const questConfig = config.quests || {};
const questConfig = config.quests || {};
@@ -117,6 +117,26 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
@@ -117,6 +117,26 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
async function planBySlug(slug, t) {
async function planBySlug(slug, t) {
return await q(t).maybe('SELECT * FROM fun_plans WHERE slug = $1', [String(slug || '')]);
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) {
async function planById(id, t) {
return await q(t).maybe('SELECT * FROM fun_plans WHERE id = $1', [String(id || '')]);
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 = {} } = {
@@ -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)}`,
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],
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 }) };
return { ok: true, quest: wireQuest(row, { votes: 0 }) };
});
});
}
}
@@ -290,7 +312,11 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
@@ -290,7 +312,11 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *`,
revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *`,
[quest.id, Number(questConfig.coins) || 0],
[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]);
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]);
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 });
const out = wireQuest(updated, { votes: (await countsForQuest(quest.id, t)).votes });
await search.sync(t, 'quest', out);
await search.sync(t, 'quest', out);
@@ -381,6 +407,7 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
@@ -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) return refusal(404, 'fun.quest_not_found', 'No such quest.');
if (quest.visibility === 'hidden') return { ok: true, quest: wireQuest(quest), unchanged: true };
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]);
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));
await search.sync(t, 'quest', wireQuest(updated));
return { ok: true, quest: wireQuest(updated) };
return { ok: true, quest: wireQuest(updated) };
});
});
@@ -391,11 +418,9 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
@@ -391,11 +418,9 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
if (!quest) return null;
if (!quest) return null;
const board = await boardById(quest.board_id);
const board = await boardById(quest.board_id);
const isOwner = Boolean(subject && board && board.streamer_subject === subject);
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 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;
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) };
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 = {} } = {
@@ -501,6 +526,8 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
saved.push(wireOption(opt));
saved.push(wireOption(opt));
}
}
const plan = wirePlan(row, { options: saved });
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);
if (row.visibility === 'public') await search.sync(t, 'plan', plan, saved);
return { ok: true, plan };
return { ok: true, plan };
});
});
@@ -578,7 +605,7 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
@@ -578,7 +605,7 @@ function createStore(s, { search = { sync: async () => null }, config = {} } = {
const plan = await planBySlug(slug);
const plan = await planBySlug(slug);
if (!plan) return null;
if (!plan) return null;
const isOwner = Boolean(subject && plan.owner_subject === subject);
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 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 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]));
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 = {} } = {
@@ -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) return refusal(404, 'fun.plan_not_found', 'No such plan.');
if (plan.visibility === 'hidden') return { ok: true, plan: wirePlan(plan), unchanged: true };
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]);
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), []);
await search.sync(t, 'plan', wirePlan(updated), []);
return { ok: true, plan: wirePlan(updated) };
return { ok: true, plan: wirePlan(updated) };
});
});
@@ -250,6 +250,7 @@ function quest({ q, board: b, live = null, signedIn = false, voted = false, mine
@@ -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)}">
return `<div class="fu-wrap" id="fu-quest" data-quest="${esc(q.slug)}">
<article class="fu-quest-detail">
<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>
<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>
<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>
<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>
${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,
@@ -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)}">
return `<div class="fu-wrap" id="fu-plan" data-plan="${esc(p.slug)}">
<article class="fu-plan-detail">
<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>
<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>
<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>
<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>` : ''}
${flash ? `<p class="fu-note" role="status">${esc(flash)}</p>` : ''}
@@ -49,7 +49,7 @@ async function startNetworkStub() {
@@ -49,7 +49,7 @@ async function startNetworkStub() {
try {
try {
const missing = await uncovered(d.db, {
const missing = await uncovered(d.db, {
tables: TABLES,
tables: TABLES,
- exempt: {account_data_events: 'the ledger of requests itself: which export and deletion 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(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
} finally { await d.close(); }
} finally { await d.close(); }
@@ -118,9 +118,23 @@ async function startNetwork() {
@@ -118,9 +118,23 @@ async function startNetwork() {
return json(200, { items: [], next_cursor: null });
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/notifications/push' && req.method === 'POST') return json(200, { ok: true });
if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {
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 });
return json(200, { subject_id: url.searchParams.get('subject_id'), network_user_id: 42 });
}
}
if (url.pathname === '/internal/coins/credit' && req.method === 'POST') {
if (url.pathname === '/internal/coins/credit' && req.method === 'POST') {
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -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); });