openvibers/openvibe.coupons Commits
Watched shops: the one Share sheet, Only me by default (#230)
Changes
nnew file mode 100644@@@ -0,0 +1,89 @@++-- 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): the person's default audience for the shops they watch, and one share row++-- for every person who watches a shop today. A watch list was private to its owner, so every one starts as Only me.++-- The list itself has no row of its own: its id is the person's subject (usr_…), which is the resource the share row++-- names. The row is made the first time its owner opens /watching (server/http/public.js); until then nobody can see++-- it, which is the old rule. Repeating this file is a no-op.++++CREATE TABLE IF NOT EXISTS coupons_share_defaults (++ subject text COLLATE "C" PRIMARY KEY,++ audience text COLLATE "C" NOT NULL CHECK (audience IN ('public', 'link', 'people', 'private')),++ updated_at timestamptz NOT NULL DEFAULT now()++);++++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)++SELECT DISTINCT 'coupons', 'watchlist', w.subject, jsonb_build_object('type', 'user', 'id', w.subject), 'private'++FROM coupon_watches w++ON CONFLICT (service, kind, resource_id) DO NOTHING;@@@ -27,7 +27,24 @@ const TABLES = [];function create({ db, log = console } = {}) {-- return createAccountData({ db, service: 'coupons', tables: TABLES, log });++ return createAccountData({++ db, service: 'coupons', tables: TABLES, log,++ /**++ * The watched-shops list's share row names its owner (the subject, which is also the list's id) and its++ * members. A table map cannot cover it: the rows go with the person, and so must everyone they had shared++ * the list with, or a deleted account would linger in somebody else's member list. Children first.++ */++ extraErase: async (t, subjects) => {++ for (const subject of subjects) {++ const id = String(subject).replace(/^user:/, '');++ await t.exec('DELETE FROM coupons_share_defaults WHERE subject = $1', [id]);++ await t.exec("DELETE FROM share_members WHERE service='coupons' AND (subject->>'id' = $1 OR resource_id = $1)", [id]);++ await t.exec("DELETE FROM share_transfers WHERE service='coupons' AND (from_subject->>'id' = $1 OR to_subject->>'id' = $1 OR resource_id = $1)", [id]);++ await t.exec("DELETE FROM share_access_requests WHERE service='coupons' AND (subject->>'id' = $1 OR resource_id = $1)", [id]);++ await t.exec("DELETE FROM shares WHERE service='coupons' AND (owner_subject->>'id' = $1 OR resource_id = $1)", [id]);++ }++ },++ });}module.exports = { create, TABLES, TOPICS };@@@ -30,6 +30,7 @@ const { createCoupons } = require('./domain/coupons');const { createReports } = require('./domain/reports');const { createInstalls } = require('./domain/installs');const { createWatches } = require('./domain/watches');++const { createSharing } = require('./sharing');const { createFacts } = require('./domain/facts');const { createModeration } = require('./domain/moderation');const { createSourcesImporter } = require('./clients/sources');@@@ -84,6 +85,8 @@ async function createApp(opts = {}) {const coupons = createCoupons({ store, merchants, publication, outbox });const reports = createReports({ store, config, coupons, merchants, publication });const installs = createInstalls({ store, config });++ // The one sharing system (design 2026-11-06): a person's watched shops are theirs until the Share sheet says otherwise.++ const sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl, log });const watches = createWatches({ store });// A neighbour's facts about a shop (the latest price we observed for one of its products).const facts = createFacts({ config, log });@@@ -110,7 +113,7 @@ async function createApp(opts = {}) {const viewers = createViewerResolver({ auth, jwks, config, installs });const worker = createWorker({ config, coupons, importer, outbox, log });-- const ctx = { config, store, accountData, accountSend, outbox, publication, merchants, coupons, reports, installs, watches, moderation, importer, auth, jwks, viewers, worker, indexnow, facts };++ const ctx = { config, store, accountData, accountSend, outbox, publication, merchants, coupons, reports, installs, watches, sharing, moderation, importer, auth, jwks, viewers, worker, indexnow, facts };const app = express();// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts@@@ -209,6 +212,28 @@ async function createApp(opts = {}) {},}));++ // ── /api/v1/watchlist/:id/share — the one Share sheet's API (openvibe-sdk/share) ──++ // GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people++ // and /api/v1/shared-with-me. Access is still decided by canView on the list's page; the router answers only its++ // own paths. The list's id is its owner's subject (usr_…).++ {++ const shareApi = express.Router();++ shareApi.use(viewers.pages());++ shareApi.use(sharing.shares.router({++ service: 'coupons',++ 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;++ }++// ── API ─────────────────────────────────────────────────app.use('/api/v1', createApi(ctx));app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));@@@ -37,6 +37,11 @@ const confidence = require('../domain/confidence');const { SCOPES } = require('../auth/capabilities');const COUPON_ID_RE = /^cpn_[0-9A-HJKMNP-TV-Z]{26}$/;++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++// 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="${ovServe.url('ov-share.css')}"><script src="${ovServe.url('ov-share.js')}" defer></script>`;/*** A code page's meta description, from the code's own facts and never its submitted description: the code, what it@@@ -417,11 +422,60 @@ function createPublicRoutes(ctx) {res.redirect(303, '/connect-extension');});++ // The person's own list. Opening it makes its share row (the list has no row of its own, so this is the moment it++ // starts existing), at the person's default; the Share sheet then changes it (design 2026-11-06, step 6).router.get('/watching', async (req, res) => {if (!requireUser(req, res)) return;-- send(req, res, 200, { title: 'Watching', decision: pageDecision('/watching', { indexable: false }), body: pages.watching({ merchants: await watches.list(req.viewer.subject) }) });++ const subject = req.viewer.subject;++ const audience = await ensureShare(subject);++ send(req, res, 200, {++ title: 'Watching', decision: pageDecision('/watching', { indexable: false }), head: SHARE_HEAD,++ body: pages.watching({ merchants: await watches.list(subject), mine: true, audience, shareId: subject, csrf: csrf(req) }),++ });++ });++++ router.post('/watching/defaults', form, async (req, res) => {++ if (!requireForm(req, res)) return;++ const audience = ['private', 'people', 'link', 'public'].includes(req.body.audience) ? req.body.audience : 'private';++ await ctx.sharing.setDefault(req.viewer.subject, audience, { applyAll: true })++ .catch((err) => ctx.log.warn('[Coupons] the watching default was not saved:', err.message));++ res.redirect(303, '/watching');});++ // One person's list, read by whoever holds the link or is on its Specific-people list. The id is its owner's++ // subject; a stranger never learns whether it exists (404), and a Specific-people list answers the gate.++ router.get('/watching/:id', async (req, res) => {++ const id = String(req.params.id || '');++ if (!SUBJECT_RE.test(id)) return notFound(req, res);++ const me = req.viewer.kind === 'user' && req.viewer.subject === id;++ const canView = me || await ctx.sharing.canView(req.viewer, id).catch(() => false);++ if (!canView) {++ const audience = await ctx.sharing.audienceOf(id).catch(() => null);++ if (audience === 'people') {++ const signedIn = req.viewer.kind === 'user';++ return send(req, res, 404, {++ title: 'A watch list shared with specific people', decision: pageDecision('/watching', { indexable: false }), head: SHARE_HEAD,++ body: pages.watchingGate({ id, signedInAs: signedIn ? (req.viewer.user && req.viewer.user.username) || '' : '', signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` }),++ });++ }++ return notFound(req, res);++ }++ const audience = await ctx.sharing.audienceOf(id).catch(() => null);++ send(req, res, 200, {++ title: 'Watched shops', decision: pageDecision('/watching', { indexable: false }), head: SHARE_HEAD,++ body: pages.watching({ merchants: await watches.list(id), mine: me, audience: audience || 'private', shareId: id, csrf: me ? csrf(req) : null }),++ });++ });++++ /** The person's share row, made at their default the first time they open their list. */++ async function ensureShare(subject) {++ const row = await ctx.sharing.shareOf(subject).catch(() => null);++ if (row) return row.audience;++ const audience = await ctx.sharing.defaultOf(subject).catch(() => 'private');++ await ctx.sharing.register(subject, subject, audience).catch(() => {});++ return audience;++ }++// ── Staff ───────────────────────────────────────────────function requireStaff(req, res) {if (!requireUser(req, res)) return false;@@@ -100,7 +100,7 @@ function renderPage(o) {canonical: o.canonical,// The head names the page's own Markdown twin (openvibe-shared/agent-layer).// The rules that show the picked block of a pick block are written for the page, because there is one per block.-- head: [agentHeadTags(o), ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),++ head: [o.head, agentHeadTags(o), ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),decision: o.decision,// The AI-readable summary (publishing v1.3.0): given one, the shell emits the ai-summary meta// and a WebPage JSON-LD tag from these real fields; with none it emits neither.@@@ -456,10 +456,32 @@ function companion() {<p class="meta">Source code and the full privacy table: <a href="${COMPANION_SOURCE}">OpenVibers/OpenVibe.Extensions</a>. How codes are labelled: <a href="/about">the method</a>.</p>`;}--function watching({ merchants }) {-- return h`<h1>Shops you watch</h1>--<p class="meta">Private to you. When a new code for one of these shops is listed, you get a notification in your OpenVibe inbox (unless you muted service notifications there).</p>--${merchants.length ? h`<ul class="merchant-list">${merchants.map((m) => h`<li><a href="/m/${m.slug}">${m.name}</a></li>`)}</ul>` : h`<p class="empty">You are not watching any shop. Use “Watch this shop” on a shop's page.</p>`}`;++const AUDIENCE_WORD = { public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' };++++function watching({ merchants, mine = true, audience = 'private', shareId = null, csrf = null }) {++ const who = AUDIENCE_WORD[audience] || 'Only me';++ const defaultOptions = ['private', 'people', 'link']++ .map((a) => h`<option value="${a}"${a === audience ? raw(' selected') : ''}>${AUDIENCE_WORD[a]}</option>`);++ return h`<h1>${mine ? 'Shops you watch' : 'Watched shops'}</h1>++<p class="meta"><span class="badge">${who}</span>${mine++ ? ' · Private to you. When a new code for one of these shops is listed, you get a notification in your OpenVibe inbox (unless you muted service notifications there).'++ : ' · you are reading a shared list · When a new code for one of these shops is listed, the list\'s owner gets a notification in their OpenVibe inbox (unless they muted service notifications there).'}</p>++${mine && shareId ? h`<section class="card" aria-labelledby="cp-watch-aud"><h2 id="cp-watch-aud">Who can open your list</h2>++<form method="post" action="/watching/defaults" class="inline-form">${csrf ? hidden(csrf) : ''}++<label>New list audience <select name="audience">${defaultOptions}</select></label><button type="submit">Save</button></form>++<p class="meta">The Share button changes this list. It is never listed anywhere.</p>++<p><button type="button" class="btn" data-ov-share-sheet data-kind="watchlist" data-id="${shareId}" data-title="Watched shops">Share</button></p></section>` : ''}++${merchants.length ? h`<ul class="merchant-list">${merchants.map((m) => h`<li><a href="/m/${m.slug}">${m.name}</a></li>`)}</ul>` : h`<p class="empty">${mine ? 'You are not watching any shop. Use “Watch this shop” on a shop\'s page.' : 'This list has no watched shops right now.'}</p>`}`;++}++++/**++ * The page a person not on a Specific-people list's list sees: the audience, never a shop's name.++ * openvibe-shared draws the card from this markup (no inline script).++ */++function watchingGate({ id, signedInAs = '', signInUrl = '/auth/login' }) {++ return h`<h1>A watch list shared with specific people</h1>++<p>Sign in with the OpenVibe account it was shared with, or ask its owner for access.</p>++<div data-ov-share-gate data-kind="watchlist" data-id="${id}" 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>`;}function staff({ pendingMerchants, pendingCoupons, holds, csrf, flashMsg, errs = [] }) {@@@ -495,5 +517,5 @@ function message({ heading, text, action }) {}module.exports = {-- shopTab, isProductDeal, headline, moneyOf, symbolOf, currencyOf, home, shopsSection, merchantTrend, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, statusLabel, SITE_SUMMARY,++ shopTab, isProductDeal, headline, moneyOf, symbolOf, currencyOf, home, shopsSection, merchantTrend, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, watchingGate, staff, message, STATUS_LABEL, statusLabel, SITE_SUMMARY,regionName, BASIS_LABEL };nnew file mode 100644@@@ -0,0 +1,181 @@++'use strict';++++/**++ * Sharing on OpenVibe.Coupons (design 2026-11-06, visibility step 6), through openvibe-sdk/share.++ *++ * The shops a person watches are Only me until they say otherwise. The Share sheet changes just that list; the++ * person's default for it lives on /watching ("Who can open your list: Only me"). The list's page asks the one check,++ * canView. A watched shop is the person's own, so Specific people are viewers.++ *++ * The SDK owns the model and the rules (the shares row, members, requests, transfers, the log and the delivery++ * queue). This file is the wiring: Network's name lookups and notifications, Events for share.* envelopes, and the++ * person's default. The list has no visibility column to keep in step: its share row is the authority.++ */++const { createShares } = require('openvibe-sdk/share');++const { serviceAuth } = require('openvibe-contracts');++++const SERVICE = 'coupons';++const KIND = 'watchlist';++const AUDIENCES = ['public', 'link', 'people', 'private'];++const DEFAULT_AUDIENCE = 'private';++/** A list is never listed: no Everyone. The sheet offers these three. */++const OFFERED = ['private', 'link', 'people'];++++const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });++++/** Coupons' viewer as the SDK wants it: a subject object. A moderator is not staff for a person's watches. */++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 a shop's name. */++ async function notify(n) {++ const to = n && n.to;++ if (!to || to.type !== 'user') return;++ const words = {++ 'share.added': ['A list of watched shops was shared with you', 'Open it on OpenVibe.Coupons.'],++ 'share.access_requested': ['Someone asked for access to your watch list', 'Review the request on OpenVibe.Coupons.'],++ 'share.transfer.requested': ['Someone wants to give you a watch list', 'Accept or decline it on OpenVibe.Coupons.'],++ 'share.transfer.answered': ['A watch-list transfer was answered', 'See what happened on OpenVibe.Coupons.'],++ }[n.kind] || ['Sharing on OpenVibe.Coupons', 'There is something new for you.'];++ try {++ await networkJson({++ path: '/internal/notifications/push', method: 'POST',++ json: {++ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],++ service: 'coupons', url: n.ref && n.ref.id ? `${config.baseUrl}/watching/${encodeURIComponent(n.ref.id)}` : `${config.baseUrl}/watching`,++ category: 'social',++ },++ });++ } catch (err) {++ // Network may not have seeded Deals' network.notifications.push grant yet: the share still happened++ // (it is decided by canView), the person just hears about it elsewhere.++ log.warn('[Sharing] notification not sent:', err.message);++ }++ }++++ /** OpenVibe.Events: one share.* envelope. A refusal only loses the event copy, never the setting. */++ 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);++ }++++ const shares = createShares({++ db,++ network: { json: networkJson },++ events: { publish },++ notify,++ });++++ return {++ shares,++ SERVICE,++ KIND,++ AUDIENCES,++ OFFERED,++ DEFAULT_AUDIENCE,++ refOf,++ shareViewer,++ networkJson,++ /** Register a person's list, at their default until the Share sheet says otherwise. */++ register: async (subject, id, audience = DEFAULT_AUDIENCE) => await shares.register(refOf(id), {++ owner_subject: { type: 'user', id: String(subject) },++ audience: AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE,++ }),++ canView: async (viewer, id) => await shares.canView(shareViewer(viewer), refOf(id)),++ canEdit: async (viewer, id) => await shares.canEdit(shareViewer(viewer), refOf(id)),++ setAudience: async (viewer, id, audience) => await shares.setAudience(shareViewer(viewer), refOf(id), audience),++ /** The share row, or null for a watch that predates the backfill (its owner alone decides then). */++ shareOf: async (id) => await db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3',++ [SERVICE, KIND, String(id)]),++ /** Just the audience, for deciding between "Only me" (no hint) and "Specific people" (the gate). */++ audienceOf: async (id) => {++ const row = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3',++ [SERVICE, KIND, String(id)]);++ return row ? row.audience : null;++ },++ defaultOf: async (subject) => {++ if (!subject) return DEFAULT_AUDIENCE;++ const row = await db.maybe('SELECT audience FROM coupons_share_defaults WHERE subject = $1', [String(subject)]);++ return row && AUDIENCES.includes(row.audience) ? row.audience : DEFAULT_AUDIENCE;++ },++ /** Set the default, and with `applyAll` move the person's existing watches too (design rule 2). */++ setDefault: async (subject, audience, { applyAll = false } = {}) => {++ if (!subject) return false;++ const want = AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE;++ await db.exec(`INSERT INTO coupons_share_defaults (subject, audience, updated_at) VALUES ($1, $2, now())++ ON CONFLICT (subject) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [String(subject), want]);++ if (applyAll) {++ await db.exec(`UPDATE shares SET audience = $1, revision = revision + 1++ WHERE service = $2 AND kind = $3 AND owner_subject->>'id' = $4 AND owner_subject->>'type' = 'user'`,++ [want, SERVICE, KIND, String(subject)]);++ }++ return true;++ },++ /** The uniform API's describe(ref): the list's page and only the audiences a list may use. Never a shop's name. */++ describe: async (ref) => {++ if (ref.service !== SERVICE || ref.kind !== KIND) return {};++ const owner = await db.maybe('SELECT owner_subject FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3',++ [SERVICE, KIND, String(ref.id)]);++ if (!owner) return {};++ return {++ title: 'Watched shops',++ link: `${config.baseUrl}/watching/${encodeURIComponent(ref.id)}`,++ roles: ['viewer'],++ audiences: OFFERED,++ transferable: false,++ };++ },++++ forget: async (id) => {++ for (const table of ['share_members', 'share_transfers', 'share_access_requests']) {++ await db.exec(`DELETE FROM ${table} WHERE service = $1 AND kind = $2 AND resource_id = $3`, [SERVICE, KIND, String(id)]);++ }++ await db.exec('DELETE FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, String(id)]);++ },++ /** 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, AUDIENCES, OFFERED, DEFAULT_AUDIENCE, SERVICE, KIND };@@@ -32,6 +32,9 @@ const B = 'usr_01JZ0000000000000000000BBB';exempt: {account_data_events: 'the ledger of requests itself: which export and deletion Coupons applied',coupons_index_revisions: "owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash",++ 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",++ coupons_share_defaults: "a person's own default for their watched-shops list, erased by extraErase",},});assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);@@@ -32,9 +32,26 @@ async function startNetwork() {const publicPem = publicKey.export({ type: 'spki', format: 'pem' });const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });const grants = [];++ const users = new Map();let issuer = null;const srv = await listen((req, raw, json) => {if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });++ if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {++ const b = JSON.parse(raw || '{}');++ const results = {};++ for (const subjectId of b.subject_ids || []) {++ const u = users.get(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 (req.url.startsWith('/internal/identity/resolve') && req.method === 'GET') {++ const name = String(new URL(req.url, 'http://x').searchParams.get('username') || '').toLowerCase();++ const u = [...users.values()].find((x) => String(x.username).toLowerCase() === name);++ 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' });++ }++ if (req.url === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });if (req.url === '/oauth/token' && req.method === 'POST') {let body = {};if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));@@@ -58,7 +75,9 @@ async function startNetwork() {return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: actorType, aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...app, ...extra }, privatePem);}function addUser(username, extra = {}) {-- return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };++ const u = { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };++ users.set(u.subject, u);++ return u;}function userToken(u) {// Network session tokens are for the site: openvibe-sdk/sso verifies the audience (openvibe.coupons).nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++/**++ * Sharing a watched-shops list (design 2026-11-06, visibility step 6): the one Share sheet (openvibe-sdk/share)++ * and the one check, canView, on the list's page.++ *++ * - the list is Only me; opening /watching makes its share row at the person's default and shows the Share button;++ * - Anyone with the link reads the list, and the owner's own page stays private;++ * - Specific people shows the gate, never a shop's name, to somebody not on the list;++ * - the default moves the existing list;++ * - a list is never on a gathered page: not the sitemap, not llms.txt.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const sharing = require('../server/sharing');++const pages = require('../server/render/pages');++const { createPublicRoutes } = require('../server/http/public');++++(async () => {++ const t = await boot();++ try {++ await check('the sharing wiring loads: lists are never Everyone', async () => {++ assert.strictEqual(sharing.SERVICE, 'coupons');++ assert.strictEqual(sharing.KIND, 'watchlist');++ assert.deepStrictEqual(sharing.OFFERED, ['private', 'link', 'people']);++ assert.strictEqual(sharing.shareViewer({ kind: 'anonymous' }), null);++ assert.deepStrictEqual(sharing.shareViewer({ kind: 'user', subject: 'usr_x', staff: true }), { subject: { type: 'user', id: 'usr_x' }, staff: false });++ assert.strictEqual(typeof createPublicRoutes, 'function');++ assert.strictEqual(typeof pages.watchingGate, 'function');++ });++ const kim = t.network.addUser('kim');++ const bo = t.network.addUser('bo');++ const acme = await t.merchant({ name: 'Acme Outdoors', host: 'acme-outdoors.com' });++++ const call = (p, o = {}) => {++ const { headers, ...rest } = o;++ return t.get(p, { ...rest, headers: { 'x-requested-with': 'ov-share', ...(headers || {}) } });++ };++ const share = (id, o = {}) => call(`/api/v1/watchlist/${encodeURIComponent(id)}/share`, o);++ const setShare = async (id, body, as) => {++ const first = await share(id, { as });++ assert.strictEqual(first.status, 200, first.text);++ const etag = first.headers.get('etag');++ assert.ok(etag);++ return await share(id, { as, method: 'PUT', json: body, headers: { 'if-match': etag } });++ };++ const audienceRow = async (id) => await t.ctx.store.db.maybe(++ 'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['coupons', 'watchlist', id]);++++ await check('the list is Only me; opening /watching makes its share row and shows the Share button', async () => {++ const watched = await t.get(`/m/${acme.slug}/watch`, { as: kim, form: { csrf: t.csrf(kim), action: 'watch' } });++ assert.strictEqual(watched.status, 303, watched.text);++ assert.strictEqual(await audienceRow(kim.subject), null, 'no row before the owner opens the list');++ const mine = await t.get('/watching', { as: kim });++ assert.strictEqual(mine.status, 200, mine.text);++ assert.deepStrictEqual(await audienceRow(kim.subject), { audience: 'private' });++ assert.ok(mine.text.includes('data-ov-share-sheet') && mine.text.includes('data-kind="watchlist"'));++ assert.ok(mine.text.includes('ov-share.js'));++ assert.ok(mine.text.includes('Acme Outdoors'));++ const other = await t.get(`/watching/${kim.subject}`, { as: bo });++ assert.strictEqual(other.status, 404);++ assert.ok(!other.text.includes('Acme Outdoors'), 'the list is never shown to somebody else');++ assert.strictEqual((await t.get(`/watching/${kim.subject}`)).status, 404);++ });++++ await check('Anyone with the link reads the list', async () => {++ const saved = await setShare(kim.subject, { audience: 'link' }, kim);++ assert.strictEqual(saved.status, 200, saved.text);++ const guest = await t.get(`/watching/${kim.subject}`);++ assert.strictEqual(guest.status, 200, guest.text);++ assert.ok(guest.text.includes('Acme Outdoors'));++ assert.ok(guest.text.includes('Anyone with the link'));++ assert.ok(!guest.text.includes('data-ov-share-sheet'), 'a reader gets no Share sheet');++ assert.ok(!guest.text.includes('action="/watching/defaults"'), 'a reader changes nothing');++ });++++ await check('Specific people: a viewer reads, a stranger gets the gate', async () => {++ const saved = await setShare(kim.subject, { audience: 'people', members: [{ name: 'bo', role: 'viewer' }] }, kim);++ assert.strictEqual(saved.status, 200, saved.text);++ const viewer = await t.get(`/watching/${kim.subject}`, { as: bo });++ assert.strictEqual(viewer.status, 200, viewer.text);++ assert.ok(viewer.text.includes('Specific people'));++ const stranger = t.network.addUser('di');++ const gate = await t.get(`/watching/${kim.subject}`, { as: stranger });++ assert.strictEqual(gate.status, 404);++ assert.ok(gate.text.includes('shared with specific people'));++ assert.ok(!gate.text.includes('Acme Outdoors'), 'the gate never shows a shop');++ assert.ok(gate.text.includes('data-ov-share-gate'));++ });++++ await check('the default moves the existing list', async () => {++ const saved = await t.get('/watching/defaults', { as: kim, form: { csrf: t.csrf(kim), audience: 'link' } });++ assert.strictEqual(saved.status, 303, saved.text);++ assert.deepStrictEqual(await audienceRow(kim.subject), { audience: 'link' });++ const own = await t.get('/watching', { as: kim });++ assert.ok(own.text.includes('Who can open your list'), 'the default is on the product page');++ });++++ await check('a list is never on a gathered page: not the sitemap, not llms.txt', async () => {++ assert.ok(!(await t.get('/sitemap.xml')).text.includes(kim.subject));++ assert.ok(!(await t.get('/llms.txt')).text.includes(kim.subject));++ });++++ done();++ } finally {++ await t.close();++ }++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+-- 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): the person's default audience for the shops they watch, and one share row
+-- for every person who watches a shop today. A watch list was private to its owner, so every one starts as Only me.
+-- The list itself has no row of its own: its id is the person's subject (usr_…), which is the resource the share row
+-- names. The row is made the first time its owner opens /watching (server/http/public.js); until then nobody can see
+-- it, which is the old rule. Repeating this file is a no-op.
+
+CREATE TABLE IF NOT EXISTS coupons_share_defaults (
+ subject text COLLATE "C" PRIMARY KEY,
+ audience text COLLATE "C" NOT NULL CHECK (audience IN ('public', 'link', 'people', 'private')),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
+SELECT DISTINCT 'coupons', 'watchlist', w.subject, jsonb_build_object('type', 'user', 'id', w.subject), 'private'
+FROM coupon_watches w
+ON CONFLICT (service, kind, resource_id) DO NOTHING;
@@ -27,7 +27,24 @@ const TABLES = [
@@ -27,7 +27,24 @@ const TABLES = [
];
];
function create({ db, log = console } = {}) {
function create({ db, log = console } = {}) {
- return createAccountData({ db, service: 'coupons', tables: TABLES, log });
+ return createAccountData({
+ db, service: 'coupons', tables: TABLES, log,
+ /**
+ * The watched-shops list's share row names its owner (the subject, which is also the list's id) and its
+ * members. A table map cannot cover it: the rows go with the person, and so must everyone they had shared
+ * the list with, or a deleted account would linger in somebody else's member list. Children first.
+ */
+ extraErase: async (t, subjects) => {
+ for (const subject of subjects) {
+ const id = String(subject).replace(/^user:/, '');
+ await t.exec('DELETE FROM coupons_share_defaults WHERE subject = $1', [id]);
+ await t.exec("DELETE FROM share_members WHERE service='coupons' AND (subject->>'id' = $1 OR resource_id = $1)", [id]);
+ await t.exec("DELETE FROM share_transfers WHERE service='coupons' AND (from_subject->>'id' = $1 OR to_subject->>'id' = $1 OR resource_id = $1)", [id]);
+ await t.exec("DELETE FROM share_access_requests WHERE service='coupons' AND (subject->>'id' = $1 OR resource_id = $1)", [id]);
+ await t.exec("DELETE FROM shares WHERE service='coupons' AND (owner_subject->>'id' = $1 OR resource_id = $1)", [id]);
+ }
+ },
+ });
}
}
module.exports = { create, TABLES, TOPICS };
module.exports = { create, TABLES, TOPICS };
@@ -30,6 +30,7 @@ const { createCoupons } = require('./domain/coupons');
@@ -30,6 +30,7 @@ const { createCoupons } = require('./domain/coupons');
const { createReports } = require('./domain/reports');
const { createReports } = require('./domain/reports');
const { createInstalls } = require('./domain/installs');
const { createInstalls } = require('./domain/installs');
const { createWatches } = require('./domain/watches');
const { createWatches } = require('./domain/watches');
+const { createSharing } = require('./sharing');
const { createFacts } = require('./domain/facts');
const { createFacts } = require('./domain/facts');
const { createModeration } = require('./domain/moderation');
const { createModeration } = require('./domain/moderation');
const { createSourcesImporter } = require('./clients/sources');
const { createSourcesImporter } = require('./clients/sources');
@@ -84,6 +85,8 @@ async function createApp(opts = {}) {
@@ -84,6 +85,8 @@ async function createApp(opts = {}) {
const coupons = createCoupons({ store, merchants, publication, outbox });
const coupons = createCoupons({ store, merchants, publication, outbox });
const reports = createReports({ store, config, coupons, merchants, publication });
const reports = createReports({ store, config, coupons, merchants, publication });
const installs = createInstalls({ store, config });
const installs = createInstalls({ store, config });
+ // The one sharing system (design 2026-11-06): a person's watched shops are theirs until the Share sheet says otherwise.
+ const sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl, log });
const watches = createWatches({ store });
const watches = createWatches({ store });
// A neighbour's facts about a shop (the latest price we observed for one of its products).
// A neighbour's facts about a shop (the latest price we observed for one of its products).
const facts = createFacts({ config, log });
const facts = createFacts({ config, log });
@@ -110,7 +113,7 @@ async function createApp(opts = {}) {
@@ -110,7 +113,7 @@ async function createApp(opts = {}) {
const viewers = createViewerResolver({ auth, jwks, config, installs });
const viewers = createViewerResolver({ auth, jwks, config, installs });
const worker = createWorker({ config, coupons, importer, outbox, log });
const worker = createWorker({ config, coupons, importer, outbox, log });
- const ctx = { config, store, accountData, accountSend, outbox, publication, merchants, coupons, reports, installs, watches, moderation, importer, auth, jwks, viewers, worker, indexnow, facts };
+ const ctx = { config, store, accountData, accountSend, outbox, publication, merchants, coupons, reports, installs, watches, sharing, moderation, importer, auth, jwks, viewers, worker, indexnow, facts };
const app = express();
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@ -209,6 +212,28 @@ async function createApp(opts = {}) {
@@ -209,6 +212,28 @@ async function createApp(opts = {}) {
},
},
}));
}));
+ // ── /api/v1/watchlist/:id/share — the one Share sheet's API (openvibe-sdk/share) ──
+ // GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people
+ // and /api/v1/shared-with-me. Access is still decided by canView on the list's page; the router answers only its
+ // own paths. The list's id is its owner's subject (usr_…).
+ {
+ const shareApi = express.Router();
+ shareApi.use(viewers.pages());
+ shareApi.use(sharing.shares.router({
+ service: 'coupons',
+ 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;
+ }
+
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
@@ -37,6 +37,11 @@ const confidence = require('../domain/confidence');
@@ -37,6 +37,11 @@ const confidence = require('../domain/confidence');
const { SCOPES } = require('../auth/capabilities');
const { SCOPES } = require('../auth/capabilities');
const COUPON_ID_RE = /^cpn_[0-9A-HJKMNP-TV-Z]{26}$/;
const COUPON_ID_RE = /^cpn_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+// 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="${ovServe.url('ov-share.css')}"><script src="${ovServe.url('ov-share.js')}" defer></script>`;
/**
/**
* A code page's meta description, from the code's own facts and never its submitted description: the code, what it
* A code page's meta description, from the code's own facts and never its submitted description: the code, what it
@@ -417,11 +422,60 @@ function createPublicRoutes(ctx) {
@@ -417,11 +422,60 @@ function createPublicRoutes(ctx) {
res.redirect(303, '/connect-extension');
res.redirect(303, '/connect-extension');
});
});
+ // The person's own list. Opening it makes its share row (the list has no row of its own, so this is the moment it
+ // starts existing), at the person's default; the Share sheet then changes it (design 2026-11-06, step 6).
router.get('/watching', async (req, res) => {
router.get('/watching', async (req, res) => {
if (!requireUser(req, res)) return;
if (!requireUser(req, res)) return;
- send(req, res, 200, { title: 'Watching', decision: pageDecision('/watching', { indexable: false }), body: pages.watching({ merchants: await watches.list(req.viewer.subject) }) });
+ const subject = req.viewer.subject;
+ const audience = await ensureShare(subject);
+ send(req, res, 200, {
+ title: 'Watching', decision: pageDecision('/watching', { indexable: false }), head: SHARE_HEAD,
+ body: pages.watching({ merchants: await watches.list(subject), mine: true, audience, shareId: subject, csrf: csrf(req) }),
+ });
+ });
+
+ router.post('/watching/defaults', form, async (req, res) => {
+ if (!requireForm(req, res)) return;
+ const audience = ['private', 'people', 'link', 'public'].includes(req.body.audience) ? req.body.audience : 'private';
+ await ctx.sharing.setDefault(req.viewer.subject, audience, { applyAll: true })
+ .catch((err) => ctx.log.warn('[Coupons] the watching default was not saved:', err.message));
+ res.redirect(303, '/watching');
});
});
+ // One person's list, read by whoever holds the link or is on its Specific-people list. The id is its owner's
+ // subject; a stranger never learns whether it exists (404), and a Specific-people list answers the gate.
+ router.get('/watching/:id', async (req, res) => {
+ const id = String(req.params.id || '');
+ if (!SUBJECT_RE.test(id)) return notFound(req, res);
+ const me = req.viewer.kind === 'user' && req.viewer.subject === id;
+ const canView = me || await ctx.sharing.canView(req.viewer, id).catch(() => false);
+ if (!canView) {
+ const audience = await ctx.sharing.audienceOf(id).catch(() => null);
+ if (audience === 'people') {
+ const signedIn = req.viewer.kind === 'user';
+ return send(req, res, 404, {
+ title: 'A watch list shared with specific people', decision: pageDecision('/watching', { indexable: false }), head: SHARE_HEAD,
+ body: pages.watchingGate({ id, signedInAs: signedIn ? (req.viewer.user && req.viewer.user.username) || '' : '', signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` }),
+ });
+ }
+ return notFound(req, res);
+ }
+ const audience = await ctx.sharing.audienceOf(id).catch(() => null);
+ send(req, res, 200, {
+ title: 'Watched shops', decision: pageDecision('/watching', { indexable: false }), head: SHARE_HEAD,
+ body: pages.watching({ merchants: await watches.list(id), mine: me, audience: audience || 'private', shareId: id, csrf: me ? csrf(req) : null }),
+ });
+ });
+
+ /** The person's share row, made at their default the first time they open their list. */
+ async function ensureShare(subject) {
+ const row = await ctx.sharing.shareOf(subject).catch(() => null);
+ if (row) return row.audience;
+ const audience = await ctx.sharing.defaultOf(subject).catch(() => 'private');
+ await ctx.sharing.register(subject, subject, audience).catch(() => {});
+ return audience;
+ }
+
// ── Staff ───────────────────────────────────────────────
// ── Staff ───────────────────────────────────────────────
function requireStaff(req, res) {
function requireStaff(req, res) {
if (!requireUser(req, res)) return false;
if (!requireUser(req, res)) return false;
@@ -100,7 +100,7 @@ function renderPage(o) {
@@ -100,7 +100,7 @@ function renderPage(o) {
canonical: o.canonical,
canonical: o.canonical,
// The head names the page's own Markdown twin (openvibe-shared/agent-layer).
// The head names the page's own Markdown twin (openvibe-shared/agent-layer).
// The rules that show the picked block of a pick block are written for the page, because there is one per block.
// The rules that show the picked block of a pick block are written for the page, because there is one per block.
- head: [agentHeadTags(o), ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
+ head: [o.head, agentHeadTags(o), ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
decision: o.decision,
decision: o.decision,
// The AI-readable summary (publishing v1.3.0): given one, the shell emits the ai-summary meta
// The AI-readable summary (publishing v1.3.0): given one, the shell emits the ai-summary meta
// and a WebPage JSON-LD tag from these real fields; with none it emits neither.
// and a WebPage JSON-LD tag from these real fields; with none it emits neither.
@@ -456,10 +456,32 @@ function companion() {
@@ -456,10 +456,32 @@ function companion() {
<p class="meta">Source code and the full privacy table: <a href="${COMPANION_SOURCE}">OpenVibers/OpenVibe.Extensions</a>. How codes are labelled: <a href="/about">the method</a>.</p>`;
<p class="meta">Source code and the full privacy table: <a href="${COMPANION_SOURCE}">OpenVibers/OpenVibe.Extensions</a>. How codes are labelled: <a href="/about">the method</a>.</p>`;
}
}
-function watching({ merchants }) {
- return h`<h1>Shops you watch</h1>
-<p class="meta">Private to you. When a new code for one of these shops is listed, you get a notification in your OpenVibe inbox (unless you muted service notifications there).</p>
-${merchants.length ? h`<ul class="merchant-list">${merchants.map((m) => h`<li><a href="/m/${m.slug}">${m.name}</a></li>`)}</ul>` : h`<p class="empty">You are not watching any shop. Use “Watch this shop” on a shop's page.</p>`}`;
+const AUDIENCE_WORD = { public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' };
+
+function watching({ merchants, mine = true, audience = 'private', shareId = null, csrf = null }) {
+ const who = AUDIENCE_WORD[audience] || 'Only me';
+ const defaultOptions = ['private', 'people', 'link']
+ .map((a) => h`<option value="${a}"${a === audience ? raw(' selected') : ''}>${AUDIENCE_WORD[a]}</option>`);
+ return h`<h1>${mine ? 'Shops you watch' : 'Watched shops'}</h1>
+<p class="meta"><span class="badge">${who}</span>${mine
+ ? ' · Private to you. When a new code for one of these shops is listed, you get a notification in your OpenVibe inbox (unless you muted service notifications there).'
+ : ' · you are reading a shared list · When a new code for one of these shops is listed, the list\'s owner gets a notification in their OpenVibe inbox (unless they muted service notifications there).'}</p>
+${mine && shareId ? h`<section class="card" aria-labelledby="cp-watch-aud"><h2 id="cp-watch-aud">Who can open your list</h2>
+<form method="post" action="/watching/defaults" class="inline-form">${csrf ? hidden(csrf) : ''}
+<label>New list audience <select name="audience">${defaultOptions}</select></label><button type="submit">Save</button></form>
+<p class="meta">The Share button changes this list. It is never listed anywhere.</p>
+<p><button type="button" class="btn" data-ov-share-sheet data-kind="watchlist" data-id="${shareId}" data-title="Watched shops">Share</button></p></section>` : ''}
+${merchants.length ? h`<ul class="merchant-list">${merchants.map((m) => h`<li><a href="/m/${m.slug}">${m.name}</a></li>`)}</ul>` : h`<p class="empty">${mine ? 'You are not watching any shop. Use “Watch this shop” on a shop\'s page.' : 'This list has no watched shops right now.'}</p>`}`;
+}
+
+/**
+ * The page a person not on a Specific-people list's list sees: the audience, never a shop's name.
+ * openvibe-shared draws the card from this markup (no inline script).
+ */
+function watchingGate({ id, signedInAs = '', signInUrl = '/auth/login' }) {
+ return h`<h1>A watch list shared with specific people</h1>
+<p>Sign in with the OpenVibe account it was shared with, or ask its owner for access.</p>
+<div data-ov-share-gate data-kind="watchlist" data-id="${id}" 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>`;
}
}
function staff({ pendingMerchants, pendingCoupons, holds, csrf, flashMsg, errs = [] }) {
function staff({ pendingMerchants, pendingCoupons, holds, csrf, flashMsg, errs = [] }) {
@@ -495,5 +517,5 @@ function message({ heading, text, action }) {
@@ -495,5 +517,5 @@ function message({ heading, text, action }) {
}
}
module.exports = {
module.exports = {
- shopTab, isProductDeal, headline, moneyOf, symbolOf, currencyOf, home, shopsSection, merchantTrend, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, statusLabel, SITE_SUMMARY,
+ shopTab, isProductDeal, headline, moneyOf, symbolOf, currencyOf, home, shopsSection, merchantTrend, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, watchingGate, staff, message, STATUS_LABEL, statusLabel, SITE_SUMMARY,
regionName, BASIS_LABEL };
regionName, BASIS_LABEL };
new file mode 100644
new file mode 100644
@@ -0,0 +1,181 @@
@@ -0,0 +1,181 @@
+'use strict';
+
+/**
+ * Sharing on OpenVibe.Coupons (design 2026-11-06, visibility step 6), through openvibe-sdk/share.
+ *
+ * The shops a person watches are Only me until they say otherwise. The Share sheet changes just that list; the
+ * person's default for it lives on /watching ("Who can open your list: Only me"). The list's page asks the one check,
+ * canView. A watched shop is the person's own, so Specific people are viewers.
+ *
+ * The SDK owns the model and the rules (the shares row, members, requests, transfers, the log and the delivery
+ * queue). This file is the wiring: Network's name lookups and notifications, Events for share.* envelopes, and the
+ * person's default. The list has no visibility column to keep in step: its share row is the authority.
+ */
+const { createShares } = require('openvibe-sdk/share');
+const { serviceAuth } = require('openvibe-contracts');
+
+const SERVICE = 'coupons';
+const KIND = 'watchlist';
+const AUDIENCES = ['public', 'link', 'people', 'private'];
+const DEFAULT_AUDIENCE = 'private';
+/** A list is never listed: no Everyone. The sheet offers these three. */
+const OFFERED = ['private', 'link', 'people'];
+
+const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
+
+/** Coupons' viewer as the SDK wants it: a subject object. A moderator is not staff for a person's watches. */
+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 a shop's name. */
+ async function notify(n) {
+ const to = n && n.to;
+ if (!to || to.type !== 'user') return;
+ const words = {
+ 'share.added': ['A list of watched shops was shared with you', 'Open it on OpenVibe.Coupons.'],
+ 'share.access_requested': ['Someone asked for access to your watch list', 'Review the request on OpenVibe.Coupons.'],
+ 'share.transfer.requested': ['Someone wants to give you a watch list', 'Accept or decline it on OpenVibe.Coupons.'],
+ 'share.transfer.answered': ['A watch-list transfer was answered', 'See what happened on OpenVibe.Coupons.'],
+ }[n.kind] || ['Sharing on OpenVibe.Coupons', 'There is something new for you.'];
+ try {
+ await networkJson({
+ path: '/internal/notifications/push', method: 'POST',
+ json: {
+ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],
+ service: 'coupons', url: n.ref && n.ref.id ? `${config.baseUrl}/watching/${encodeURIComponent(n.ref.id)}` : `${config.baseUrl}/watching`,
+ category: 'social',
+ },
+ });
+ } catch (err) {
+ // Network may not have seeded Deals' network.notifications.push grant yet: the share still happened
+ // (it is decided by canView), the person just hears about it elsewhere.
+ log.warn('[Sharing] notification not sent:', err.message);
+ }
+ }
+
+ /** OpenVibe.Events: one share.* envelope. A refusal only loses the event copy, never the setting. */
+ 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);
+ }
+
+ const shares = createShares({
+ db,
+ network: { json: networkJson },
+ events: { publish },
+ notify,
+ });
+
+ return {
+ shares,
+ SERVICE,
+ KIND,
+ AUDIENCES,
+ OFFERED,
+ DEFAULT_AUDIENCE,
+ refOf,
+ shareViewer,
+ networkJson,
+ /** Register a person's list, at their default until the Share sheet says otherwise. */
+ register: async (subject, id, audience = DEFAULT_AUDIENCE) => await shares.register(refOf(id), {
+ owner_subject: { type: 'user', id: String(subject) },
+ audience: AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE,
+ }),
+ canView: async (viewer, id) => await shares.canView(shareViewer(viewer), refOf(id)),
+ canEdit: async (viewer, id) => await shares.canEdit(shareViewer(viewer), refOf(id)),
+ setAudience: async (viewer, id, audience) => await shares.setAudience(shareViewer(viewer), refOf(id), audience),
+ /** The share row, or null for a watch that predates the backfill (its owner alone decides then). */
+ shareOf: async (id) => await db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3',
+ [SERVICE, KIND, String(id)]),
+ /** Just the audience, for deciding between "Only me" (no hint) and "Specific people" (the gate). */
+ audienceOf: async (id) => {
+ const row = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3',
+ [SERVICE, KIND, String(id)]);
+ return row ? row.audience : null;
+ },
+ defaultOf: async (subject) => {
+ if (!subject) return DEFAULT_AUDIENCE;
+ const row = await db.maybe('SELECT audience FROM coupons_share_defaults WHERE subject = $1', [String(subject)]);
+ return row && AUDIENCES.includes(row.audience) ? row.audience : DEFAULT_AUDIENCE;
+ },
+ /** Set the default, and with `applyAll` move the person's existing watches too (design rule 2). */
+ setDefault: async (subject, audience, { applyAll = false } = {}) => {
+ if (!subject) return false;
+ const want = AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE;
+ await db.exec(`INSERT INTO coupons_share_defaults (subject, audience, updated_at) VALUES ($1, $2, now())
+ ON CONFLICT (subject) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [String(subject), want]);
+ if (applyAll) {
+ await db.exec(`UPDATE shares SET audience = $1, revision = revision + 1
+ WHERE service = $2 AND kind = $3 AND owner_subject->>'id' = $4 AND owner_subject->>'type' = 'user'`,
+ [want, SERVICE, KIND, String(subject)]);
+ }
+ return true;
+ },
+ /** The uniform API's describe(ref): the list's page and only the audiences a list may use. Never a shop's name. */
+ describe: async (ref) => {
+ if (ref.service !== SERVICE || ref.kind !== KIND) return {};
+ const owner = await db.maybe('SELECT owner_subject FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3',
+ [SERVICE, KIND, String(ref.id)]);
+ if (!owner) return {};
+ return {
+ title: 'Watched shops',
+ link: `${config.baseUrl}/watching/${encodeURIComponent(ref.id)}`,
+ roles: ['viewer'],
+ audiences: OFFERED,
+ transferable: false,
+ };
+ },
+
+ forget: async (id) => {
+ for (const table of ['share_members', 'share_transfers', 'share_access_requests']) {
+ await db.exec(`DELETE FROM ${table} WHERE service = $1 AND kind = $2 AND resource_id = $3`, [SERVICE, KIND, String(id)]);
+ }
+ await db.exec('DELETE FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, String(id)]);
+ },
+ /** 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, AUDIENCES, OFFERED, DEFAULT_AUDIENCE, SERVICE, KIND };
@@ -32,6 +32,9 @@ const B = 'usr_01JZ0000000000000000000BBB';
@@ -32,6 +32,9 @@ const B = 'usr_01JZ0000000000000000000BBB';
exempt: {
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion Coupons applied',
account_data_events: 'the ledger of requests itself: which export and deletion Coupons applied',
coupons_index_revisions: "owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash",
coupons_index_revisions: "owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash",
+ 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",
+ coupons_share_defaults: "a person's own default for their watched-shops list, 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(', ')}`);
@@ -32,9 +32,26 @@ async function startNetwork() {
@@ -32,9 +32,26 @@ async function startNetwork() {
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const grants = [];
const grants = [];
+ const users = new Map();
let issuer = null;
let issuer = null;
const srv = await listen((req, raw, json) => {
const srv = await listen((req, raw, json) => {
if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
+ if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {
+ const b = JSON.parse(raw || '{}');
+ const results = {};
+ for (const subjectId of b.subject_ids || []) {
+ const u = users.get(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 (req.url.startsWith('/internal/identity/resolve') && req.method === 'GET') {
+ const name = String(new URL(req.url, 'http://x').searchParams.get('username') || '').toLowerCase();
+ const u = [...users.values()].find((x) => String(x.username).toLowerCase() === name);
+ 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' });
+ }
+ if (req.url === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
if (req.url === '/oauth/token' && req.method === 'POST') {
if (req.url === '/oauth/token' && req.method === 'POST') {
let body = {};
let body = {};
if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));
if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));
@@ -58,7 +75,9 @@ async function startNetwork() {
@@ -58,7 +75,9 @@ async function startNetwork() {
return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: actorType, aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...app, ...extra }, privatePem);
return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: actorType, aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...app, ...extra }, privatePem);
}
}
function addUser(username, extra = {}) {
function addUser(username, extra = {}) {
- return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };
+ const u = { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };
+ users.set(u.subject, u);
+ return u;
}
}
function userToken(u) {
function userToken(u) {
// Network session tokens are for the site: openvibe-sdk/sso verifies the audience (openvibe.coupons).
// Network session tokens are for the site: openvibe-sdk/sso verifies the audience (openvibe.coupons).
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+/**
+ * Sharing a watched-shops list (design 2026-11-06, visibility step 6): the one Share sheet (openvibe-sdk/share)
+ * and the one check, canView, on the list's page.
+ *
+ * - the list is Only me; opening /watching makes its share row at the person's default and shows the Share button;
+ * - Anyone with the link reads the list, and the owner's own page stays private;
+ * - Specific people shows the gate, never a shop's name, to somebody not on the list;
+ * - the default moves the existing list;
+ * - a list is never on a gathered page: not the sitemap, not llms.txt.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const sharing = require('../server/sharing');
+const pages = require('../server/render/pages');
+const { createPublicRoutes } = require('../server/http/public');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('the sharing wiring loads: lists are never Everyone', async () => {
+ assert.strictEqual(sharing.SERVICE, 'coupons');
+ assert.strictEqual(sharing.KIND, 'watchlist');
+ assert.deepStrictEqual(sharing.OFFERED, ['private', 'link', 'people']);
+ assert.strictEqual(sharing.shareViewer({ kind: 'anonymous' }), null);
+ assert.deepStrictEqual(sharing.shareViewer({ kind: 'user', subject: 'usr_x', staff: true }), { subject: { type: 'user', id: 'usr_x' }, staff: false });
+ assert.strictEqual(typeof createPublicRoutes, 'function');
+ assert.strictEqual(typeof pages.watchingGate, 'function');
+ });
+ const kim = t.network.addUser('kim');
+ const bo = t.network.addUser('bo');
+ const acme = await t.merchant({ name: 'Acme Outdoors', host: 'acme-outdoors.com' });
+
+ const call = (p, o = {}) => {
+ const { headers, ...rest } = o;
+ return t.get(p, { ...rest, headers: { 'x-requested-with': 'ov-share', ...(headers || {}) } });
+ };
+ const share = (id, o = {}) => call(`/api/v1/watchlist/${encodeURIComponent(id)}/share`, o);
+ const setShare = async (id, body, as) => {
+ const first = await share(id, { as });
+ assert.strictEqual(first.status, 200, first.text);
+ const etag = first.headers.get('etag');
+ assert.ok(etag);
+ return await share(id, { as, method: 'PUT', json: body, headers: { 'if-match': etag } });
+ };
+ const audienceRow = async (id) => await t.ctx.store.db.maybe(
+ 'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['coupons', 'watchlist', id]);
+
+ await check('the list is Only me; opening /watching makes its share row and shows the Share button', async () => {
+ const watched = await t.get(`/m/${acme.slug}/watch`, { as: kim, form: { csrf: t.csrf(kim), action: 'watch' } });
+ assert.strictEqual(watched.status, 303, watched.text);
+ assert.strictEqual(await audienceRow(kim.subject), null, 'no row before the owner opens the list');
+ const mine = await t.get('/watching', { as: kim });
+ assert.strictEqual(mine.status, 200, mine.text);
+ assert.deepStrictEqual(await audienceRow(kim.subject), { audience: 'private' });
+ assert.ok(mine.text.includes('data-ov-share-sheet') && mine.text.includes('data-kind="watchlist"'));
+ assert.ok(mine.text.includes('ov-share.js'));
+ assert.ok(mine.text.includes('Acme Outdoors'));
+ const other = await t.get(`/watching/${kim.subject}`, { as: bo });
+ assert.strictEqual(other.status, 404);
+ assert.ok(!other.text.includes('Acme Outdoors'), 'the list is never shown to somebody else');
+ assert.strictEqual((await t.get(`/watching/${kim.subject}`)).status, 404);
+ });
+
+ await check('Anyone with the link reads the list', async () => {
+ const saved = await setShare(kim.subject, { audience: 'link' }, kim);
+ assert.strictEqual(saved.status, 200, saved.text);
+ const guest = await t.get(`/watching/${kim.subject}`);
+ assert.strictEqual(guest.status, 200, guest.text);
+ assert.ok(guest.text.includes('Acme Outdoors'));
+ assert.ok(guest.text.includes('Anyone with the link'));
+ assert.ok(!guest.text.includes('data-ov-share-sheet'), 'a reader gets no Share sheet');
+ assert.ok(!guest.text.includes('action="/watching/defaults"'), 'a reader changes nothing');
+ });
+
+ await check('Specific people: a viewer reads, a stranger gets the gate', async () => {
+ const saved = await setShare(kim.subject, { audience: 'people', members: [{ name: 'bo', role: 'viewer' }] }, kim);
+ assert.strictEqual(saved.status, 200, saved.text);
+ const viewer = await t.get(`/watching/${kim.subject}`, { as: bo });
+ assert.strictEqual(viewer.status, 200, viewer.text);
+ assert.ok(viewer.text.includes('Specific people'));
+ const stranger = t.network.addUser('di');
+ const gate = await t.get(`/watching/${kim.subject}`, { as: stranger });
+ assert.strictEqual(gate.status, 404);
+ assert.ok(gate.text.includes('shared with specific people'));
+ assert.ok(!gate.text.includes('Acme Outdoors'), 'the gate never shows a shop');
+ assert.ok(gate.text.includes('data-ov-share-gate'));
+ });
+
+ await check('the default moves the existing list', async () => {
+ const saved = await t.get('/watching/defaults', { as: kim, form: { csrf: t.csrf(kim), audience: 'link' } });
+ assert.strictEqual(saved.status, 303, saved.text);
+ assert.deepStrictEqual(await audienceRow(kim.subject), { audience: 'link' });
+ const own = await t.get('/watching', { as: kim });
+ assert.ok(own.text.includes('Who can open your list'), 'the default is on the product page');
+ });
+
+ await check('a list is never on a gathered page: not the sitemap, not llms.txt', async () => {
+ assert.ok(!(await t.get('/sitemap.xml')).text.includes(kim.subject));
+ assert.ok(!(await t.get('/llms.txt')).text.includes(kim.subject));
+ });
+
+ done();
+ } finally {
+ await t.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });