openvibers/openvibe.deals Commits
Watches and saved searches: the one Share sheet, Only me by default (#234)
Changes
nnew file mode 100644@@@ -0,0 +1,90 @@++-- 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 new watches and saved searches, and one++-- share row for every watch that already exists. A watch was private to its owner, so every one starts as Only me++-- (a deleted watch needs no row). New watches are registered as they are made (server/domain/watches.js); a watch++-- without a row still answers its owner alone, because every read asks canView and a missing row falls back to the old++-- rule. Repeating this file is a no-op.++++CREATE TABLE IF NOT EXISTS deals_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 'deals', 'watch', w.id, jsonb_build_object('type', 'user', 'id', w.subject), 'private'++FROM deal_watches w++WHERE w.deleted_at IS NULL++ON CONFLICT (service, kind, resource_id) DO NOTHING;@@@ -30,6 +30,7 @@ const { createPublication } = require('./domain/publication');const { createIndexing } = require('./domain/indexing');const { createHotness } = require('./domain/hotness');const { createWatches } = require('./domain/watches');++const { createSharing } = require('./sharing');const { createLimits } = require('./domain/limits');const { createAccess } = require('./domain/access');const { createOffers } = require('./domain/offers');@@@ -82,7 +83,9 @@ async function createApp(opts = {}) {});const indexing = createIndexing({ config, store, publication, outbox, catalog, indexnow });const hotness = createHotness({ store, reads });-- const watches = createWatches({ config, store, reads, catalog, publication, outbox });++ // The one sharing system (design 2026-11-06): a watch is its owner's until the Share sheet says otherwise.++ const sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl, log });++ const watches = createWatches({ config, store, reads, catalog, publication, outbox, sharing });const limits = createLimits({ config, store });const access = createAccess();const offers = createOffers({ config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community, outbox });@@@ -113,7 +116,7 @@ async function createApp(opts = {}) {? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }): null);-- const ctx = { config, store, outbox, people, community, sources, reads, catalog, publication, indexing, hotness, watches, limits, access, offers, flags, votes, listings, importer, campaigns, auth, viewers, worker, indexnow, accountData, accountSend };++ const ctx = { config, store, outbox, people, community, sources, reads, catalog, publication, indexing, hotness, watches, sharing, limits, access, offers, flags, votes, listings, importer, campaigns, auth, viewers, worker, indexnow, accountData, accountSend };const app = express();// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts@@@ -211,6 +214,27 @@ async function createApp(opts = {}) {// ── Events consumer, API ────────────────────────────────// Never per-actor limited: Events pushes at its own pace, and a 429 would only make it retry and fall behind.app.use(createEvents({ config, store, importer, accountData: ctx.accountData, accountSend: ctx.accountSend }));++ // ── /api/v1/watch/:id/share — the one Share sheet's API (openvibe-sdk/share) ──++ // GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people++ // and /api/v1/shared-with-me. Access is still decided by canView on every read path; the router answers only++ // its own paths.++ {++ const shareApi = express.Router();++ shareApi.use(ctx.viewers.middleware());++ shareApi.use(ctx.sharing.shares.router({++ service: 'deals',++ authenticate: (req) => ctx.sharing.shareViewer(req.viewer),++ describe: ctx.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(() => { ctx.sharing.work().catch(() => { /* logged inside */ }); }, 15_000);++ if (timer.unref) timer.unref();++ app.locals.sharingTimer = timer;++ }app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));// ── Discovery, public pages ─────────────────────────────@@@ -64,6 +64,26 @@ async function extraErase(t, subjects, counts) {if (d.shared_offers && typeof d.shared_offers === 'object') for (const s of subjects) delete d.shared_offers[s];counts.add(counts.retained, 'tombstones', await t.exec('UPDATE deal_flags SET details = $1 WHERE id = $2', [JSON.stringify(d), f.id]));}++ // A share row names its owner and its members by canonical subject, which a table map cannot cover: the watch's++ // own rows go, and so must its share row and everyone it named, or a deleted account would linger in somebody++ // else's member list. Children first: members, transfers and requests point at shares.++ for (const subject of subjects) {++ const id = String(subject).replace(/^user:/, '');++ await t.exec('DELETE FROM deals_share_defaults WHERE subject = $1', [id]);++ await t.exec("DELETE FROM share_members WHERE service='deals' AND subject->>'id' = $1", [id]);++ await t.exec("DELETE FROM share_transfers WHERE service='deals' AND (from_subject->>'id' = $1 OR to_subject->>'id' = $1)", [id]);++ await t.exec("DELETE FROM share_access_requests WHERE service='deals' 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='deals' AND owner_subject->>'id' = $1", [id]);++ for (const row of owned) {++ await t.exec("DELETE FROM share_members WHERE service='deals' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);++ await t.exec("DELETE FROM share_transfers WHERE service='deals' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);++ await t.exec("DELETE FROM share_access_requests WHERE service='deals' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);++ await t.exec("DELETE FROM shares WHERE service='deals' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);++ }++ }}/** The account-data handle for Deals' database (server/db.js, store.db). */@@@ -25,7 +25,7 @@ const { ApiError, newId, text, tokens, parseAmount, parseCurrency, iso } = requiconst KINDS = ['keyword', 'product', 'price_below', 'search'];--function createWatches({ config, store, reads, catalog, publication, outbox }) {++function createWatches({ config, store, reads, catalog, publication, outbox, sharing }) {const { db } = store;const q = {get: db.prepare('SELECT * FROM deal_watches WHERE id = ?'),@@@ -68,6 +68,11 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {Object.assign(row, { product_id: product ? product.id : null, query: product ? null : query, max_price: amount.text, max_price_num: amount.num, currency });}await q.insert.run(row);++ // A new watch starts at the person's default (design rule 1); its Share sheet changes just this one.++ if (sharing) {++ const audience = await sharing.defaultOf(subject);++ await sharing.register(subject, row.id, audience);++ }return await q.get.get(row.id);}@@@ -75,9 +80,32 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {const w = await q.get.get(String(id || ''));if (!w || w.deleted_at || (w.subject !== subject && !staff)) throw new ApiError(404, 'watch.not_found', 'No such watch');await q.remove.run(store.now(), w.id);++ if (sharing) await sharing.forget(w.id);return { id: w.id, deleted: true };}++ /**++ * A watch a viewer may read: the owner's, or one shared with them (design 2026-11-06, step 6). A missing share row++ * falls back to the old rule — the owner alone. Returns null when it does not exist, is deleted, or is not this++ * viewer's to see; the caller answers 404.++ */++ async function view(viewer, id) {++ const w = await q.get.get(String(id || ''));++ if (!w || w.deleted_at) return null;++ const subject = viewer && (viewer.kind === 'user' || viewer.kind === 'service') ? viewer.subject : null;++ const mine = Boolean(subject && w.subject === subject);++ if (mine) return { watch: w, mine: true };++ if (!sharing) return null;++ if (!await sharing.canView(viewer, w.id)) return null;++ return { watch: w, mine: false };++ }++++ async function mustView(viewer, id) {++ const out = await view(viewer, id);++ if (!out) throw new ApiError(404, 'watch.not_found', 'No such watch');++ return out;++ }++function offerText(v) {return new Set(tokens([v.root.title, v.root.description, v.product && v.product.name, v.product && v.product.brand, v.store && v.store.domain, v.store && v.store.name].filter(Boolean).join(' ')));}@@@ -147,7 +175,7 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {};}-- return { create, remove, onObservation, list: async (subject) => await q.listFor.all(subject), get: async (id) => await q.get.get(id), dto, KINDS };++ return { create, remove, view, mustView, onObservation, list: async (subject) => await q.listFor.all(subject), get: async (id) => await q.get.get(id), dto, KINDS };}module.exports = { createWatches, KINDS };@@@ -210,6 +210,13 @@ function createApi(ctx) {router.get('/watches', guard('deals.watch.read'), run(async (req) => { needSubject(req); return { watches: await Promise.all((await watches.list(req.viewer.subject)).map(watches.dto)) }; }));++ // One watch's data: its owner's, or a reader it was shared with (design 2026-11-06, step 6). A signed-out reader++ // of an Anyone-with-the-link watch needs no capability; the check is canView inside watches.view.++ router.get('/watches/:id', guard('deals.watch.read'), run(async (req) => {++ const out = await watches.mustView(req.viewer, req.params.id);++ return { watch: await watches.dto(out.watch) };++ }));++router.post('/watches', guard('deals.watch.create'), B('deals.watch'), jsonBody, run(async (req) => {needSubject(req);return { watch: await watches.dto(await watches.create(req.viewer.subject, req.body || {})) };@@@ -41,6 +41,10 @@ const { ttlCache } = require('./ttl-cache');const PER_PAGE = 25;++// 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>`;++function createPages(ctx) {const { config, store, reads, catalog, publication, listings, offers, votes, watches, flags, community, access, viewers, actorLimits } = ctx;const router = express.Router();@@@ -594,7 +598,8 @@ function createPages(ctx) {});async function watchesPage(req, res, status = 200, { error, notice } = {}) {const list = await Promise.all((await watches.list(req.viewer.subject)).map(watches.dto));-- send(req, res, status, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.watchesPage({ csrf: csrf(req), watches: list, error, notice, urls, values: watchValues(req.query) }) });++ const defaultAudience = ctx.sharing ? await ctx.sharing.defaultOf(req.viewer.subject).catch(() => 'private') : 'private';++ send(req, res, status, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), head: SHARE_HEAD, body: views.watchesPage({ csrf: csrf(req), watches: list, error, notice, urls, values: watchValues(req.query), defaultAudience }) });}router.get('/watches', wrap(async (req, res) => {@@@ -602,6 +607,40 @@ function createPages(ctx) {await watchesPage(req, res);}));++ // The person's default for new watches, and "apply to all" (design rule 2).++ router.post('/watches/defaults', form, wrap(async (req, res) => {++ if (!formGuard(req, res, '/watches')) return;++ if (ctx.sharing) {++ const audience = ['private', 'people', 'link', 'public'].includes(req.body.audience) ? req.body.audience : 'private';++ await ctx.sharing.setDefault(req.viewer.subject, audience, { applyAll: String(req.body.apply_all || '') === '1' })++ .catch((err) => ctx.log.warn('[Deals] the watch default was not saved:', err.message));++ }++ res.redirect(303, '/watches');++ }));++++ // One watch's own page (design 2026-11-06, step 6): its owner's, or a reader it was shared with. A watch shared with++ // specific people answers the gate to everyone else and never shows what it looks for.++ router.get('/watches/:id', wrap(async (req, res) => {++ const id = String(req.params.id || '');++ const out = await watches.view(req.viewer, id);++ if (!out) {++ const audience = ctx.sharing ? await ctx.sharing.audienceOf(id).catch(() => null) : null;++ if (audience === 'people') {++ const signedInUser = signedIn(req) ? req.viewer : null;++ return send(req, res, 404, {++ title: 'A watch shared with specific people', decision: pageDecision('/watches', { indexable: false }), head: SHARE_HEAD,++ body: views.watchGate({ id, signedInAs: signedInUser && signedInUser.user ? signedInUser.user.username || '' : '', signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` }),++ });++ }++ return send(req, res, 404, { title: 'Not found', decision: pageDecision('/watches', { indexable: false }), body: views.message({ heading: 'Not found', text: 'There is no watch at this address.' }) });++ }++ const audience = ctx.sharing ? await ctx.sharing.audienceOf(id).catch(() => null) : null;++ send(req, res, 200, {++ title: out.watch.kind === 'search' ? 'Saved search' : 'Watch', decision: pageDecision('/watches', { indexable: false }), head: SHARE_HEAD,++ body: views.watchPage({ watch: await watches.dto(out.watch), mine: out.mine, audience, csrf: csrf(req), urls }),++ });++ }));++router.post('/watches', B('deals.watch'), form, wrap(async (req, res) => {if (!formGuard(req, res, '/watches')) return;try {@@@ -93,7 +93,7 @@ function renderPage(o) {return layout.renderDocument({// The head names the page's own Markdown twin, and carries the rules that show the picked pane of a pick// block (there is one per pane, written for the page).-- head: [agentHead, o.path === '/' ? '' : `<link rel="stylesheet" href="${asset('css/deal-cards.css')}">`, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),++ head: [o.head, agentHead, o.path === '/' ? '' : `<link rel="stylesheet" href="${asset('css/deal-cards.css')}">`, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),site: 'deals',siteName: SITE_NAME,lang: o.lang,@@@ -379,16 +379,27 @@ ${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}</form>`;}--function watchesPage({ csrf, watches, error, notice, urls, values = {} }) {++function watchesPage({ csrf, watches, error, notice, urls, values = {}, defaultAudience = 'private' }) {// Arriving from a shop's "Alert me" (/watches?kind=keyword&query=zappos.com): the form says so, filled in.const kindOpt = (k, label) => html`<option value="${k}"${values.kind === k ? raw(' selected') : ''}>${label}</option>`;++ const audienceText = { private: 'Only me', people: 'Specific people', link: 'Anyone with the link', public: 'Everyone' };++ const defaultOptions = ['private', 'people', 'link']++ .map((a) => html`<option value="${a}"${a === defaultAudience ? raw(' selected') : ''}>${audienceText[a]}</option>`);return html`<h1>Watches and saved searches</h1><p class="lede">A watch tells you once when a new observation matches it (at most one notification per observation). A saved search never notifies. Notifications are delivered by the OpenVibe network; Deals never emails.</p>${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}${notice ? html`<p class="notice" role="status">${notice}</p>` : ''}--${watches.length ? html`<ul class="watches">${watches.map((w) => html`<li><strong>${w.kind === 'search' ? 'Saved search' : w.kind === 'price_below' ? 'Price below' : w.kind === 'product' ? 'Product' : 'Keywords'}</strong>:++<section class="card" aria-labelledby="new-watch-h">++<h2 id="new-watch-h">New watches</h2>++<form method="post" action="/watches/defaults" class="inline">${csrfField(csrf)}++<label>Who can open a new watch <select name="audience">${defaultOptions}</select></label>++<label><input type="checkbox" name="apply_all" value="1"> Apply to all my existing watches</label>++<button type="submit">Save</button></form>++<p class="muted">A watch's Share button changes just that one. Your watches are never listed; a shared watch is readable only by whoever holds the link or the people you add.</p></section>++${watches.length ? html`<ul class="watches">${watches.map((w) => html`<li><strong><a href="/watches/${w.id}">${w.kind === 'search' ? 'Saved search' : w.kind === 'price_below' ? 'Price below' : w.kind === 'product' ? 'Product' : 'Keywords'}</a></strong>:${w.query ? html` “${w.query}”` : ''}${w.product ? html` <a href="${w.product.url}">${w.product.name}</a>` : ''}${w.max_price ? html` under ${w.max_price} ${w.currency}` : ''}${w.kind === 'search' ? html` · <a href="/search?q=${encodeURIComponent(w.query)}">run</a>` : html` · ${w.notifications} notifications`}++<button type="button" class="btn btn-quiet" data-ov-share-sheet data-kind="watch" data-id="${w.id}" data-title="${w.kind === 'search' ? 'Saved search' : 'Watch'}">Share</button><form class="inline" method="post" action="/watches/${w.id}/delete">${csrfField(csrf)}<button type="submit">Delete</button></form></li>`)}</ul>` : html`<p class="empty">No watches yet.</p>`}<form method="post" action="/watches" class="card">${csrfField(csrf)}<h2>${values.query ? html`Alert me about “${values.query}”` : 'Add'}</h2>@@@ -401,6 +412,37 @@ ${w.kind === 'search' ? html` · <a href="/search?q=${encodeURIComponent(w.query</form>`;}++// ── One watch (its owner's, or a shared one) ────────────────────────────────++++const WATCH_AUDIENCES = { public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' };++++function watchSummary(w) {++ return html`<strong>${w.kind === 'search' ? 'Saved search' : w.kind === 'price_below' ? 'Price below' : w.kind === 'product' ? 'Product' : 'Keywords'}</strong>:++${w.query ? html` “${w.query}”` : ''}${w.product ? html` <a href="${w.product.url}">${w.product.name}</a>` : ''}${w.max_price ? html` under ${w.max_price} ${w.currency}` : ''}`;++}++++function watchPage({ watch, mine = false, audience = 'private', csrf, urls, notice }) {++ const who = WATCH_AUDIENCES[audience] || 'Only me';++ return html`<h1>${watch.kind === 'search' ? 'Saved search' : 'Watch'}</h1>++<p class="muted"><span class="badge">${who}</span>${mine ? '' : ' · you are reading a shared watch'}</p>++${notice ? html`<p class="notice" role="status">${notice}</p>` : ''}++${mine ? html`<p><button type="button" class="btn" data-ov-share-sheet data-kind="watch" data-id="${watch.id}" data-title="${watch.kind === 'search' ? 'Saved search' : 'Watch'}">Share</button></p>` : ''}++<p>${watchSummary(watch)}</p>++<p class="muted">${watch.kind === 'search' ? html`A saved search never notifies. <a href="/search?q=${encodeURIComponent(watch.query)}">Run it</a>.` : html`${watch.notifications} notification${watch.notifications === 1 ? '' : 's'} so far.`} Made ${when(watch.created_at)}.</p>++${mine ? html`<form class="inline" method="post" action="/watches/${watch.id}/delete">${csrfField(csrf)}<button type="submit">Delete this watch</button></form>` : ''}++<p class="muted"><a href="/watches">Your watches</a> · Notifications are delivered by the OpenVibe network; Deals never emails.</p>`;++}++++/**++ * The page a person not on a Specific-people watch's list sees: the audience, never what the watch looks for.++ * openvibe-shared draws the card from this markup (no inline script).++ */++function watchGate({ id, signedInAs = '', signInUrl = '/auth/login' }) {++ return html`<h1>A watch shared with specific people</h1>++<p>Sign in with the OpenVibe account it was shared with, or ask its owner for access.</p>++<div id="watch-share-gate" data-ov-share-gate data-kind="watch" 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 searchPage({ q, results, pager, urls, csrf, signedIn }) {return html`<h1>Search deals</h1><form action="/search" method="get" role="search"><label for="sq">Words</label><input id="sq" name="q" type="search" value="${q || ''}"><button type="submit">Search</button></form>@@@ -496,4 +538,4 @@ function message({ heading, text, action }) {return html`<h1>${heading}</h1><p>${text}</p>${action ? html`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}`;}--module.exports = { storesPage, offerList, tabs, offerPage, productPage, submitForm, watchesPage, searchPage, modPage, storePage, sellerPage, sellersPage, categoryPage, categoriesPage, message, priceText, when, ago };++module.exports = { storesPage, offerList, tabs, offerPage, productPage, submitForm, watchesPage, watchPage, watchGate, searchPage, modPage, storePage, sellerPage, sellersPage, categoryPage, categoriesPage, message, priceText, when, ago };nnew file mode 100644@@@ -0,0 +1,181 @@++'use strict';++++/**++ * Sharing on OpenVibe.Deals (design 2026-11-06, visibility step 6), through openvibe-sdk/share.++ *++ * A watch or saved search is Only me until its owner says otherwise. The Share sheet changes just that watch; the++ * person's default for new watches lives on /watches ("New watches: Only me"). A shared watch's page and its API read++ * ask the one check, canView. A watch has no editing beyond delete, 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. A watch 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 = 'deals';++const KIND = 'watch';++const AUDIENCES = ['public', 'link', 'people', 'private'];++const DEFAULT_AUDIENCE = 'private';++/** A watch 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) });++++/** Deals' 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 the watch's words, product or price. */++ async function notify(n) {++ const to = n && n.to;++ if (!to || to.type !== 'user') return;++ const words = {++ 'share.added': ['A watch was shared with you', 'Open it on OpenVibe.Deals.'],++ 'share.access_requested': ['Someone asked for access to your watch', 'Review the request on OpenVibe.Deals.'],++ 'share.transfer.requested': ['Someone wants to give you a watch', 'Accept or decline it on OpenVibe.Deals.'],++ 'share.transfer.answered': ['A watch transfer was answered', 'See what happened on OpenVibe.Deals.'],++ }[n.kind] || ['Sharing on OpenVibe.Deals', '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: 'deals', url: n.ref && n.ref.id ? `${config.baseUrl}/watches/${encodeURIComponent(n.ref.id)}` : `${config.baseUrl}/watches`,++ 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 watch, at the owner's 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 deals_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 deals_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): what the watch is, its page, and only the audiences a watch may use. */++ describe: async (ref) => {++ if (ref.service !== SERVICE || ref.kind !== KIND) return {};++ const row = await db.maybe('SELECT id, kind, query, label FROM deal_watches WHERE id = $1 AND deleted_at IS NULL', [String(ref.id)]);++ if (!row) return {};++ const named = row.label || row.query || (row.kind === 'product' || row.kind === 'price_below' ? 'a product' : 'saved words');++ return {++ title: `${row.kind === 'search' ? 'Saved search' : 'Watch'}: ${String(named).slice(0, 100)}`.slice(0, 120),++ link: `${config.baseUrl}/watches/${encodeURIComponent(row.id)}`,++ roles: ['viewer'],++ audiences: OFFERED,++ transferable: false,++ };++ },++ /** A deleted watch takes its share row and everyone it named with it. */++ 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 };@@@ -43,7 +43,7 @@ const envelope = (id, type, payload) => ({ event: { event_id: id, event_type: tytables: TABLES,taskKeyed: ["deal_watches"],-- exempt: {"account_data_events":"the ledger of requests itself: which export and deletion Deals applied","deal_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash"},++ exempt: {"account_data_events":"the ledger of requests itself: which export and deletion Deals applied","deal_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 with the watch by extraErase","share_access_requests":"the SDK's own sharing model: subject is the person who asked, erased by extraErase","deals_share_defaults":"a person's own default for new watches, erased by extraErase"},});assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);} finally { await d.close(); }@@@ -62,6 +62,13 @@ async function startNetwork() {}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 found = [...directory.values()].find((u) => String(u.username).toLowerCase() === name);++ if (found) return json(200, { subject: { type: 'user', id: found.subject }, username: found.username, display_name: found.display_name });++ return json(404, { code: 'identity.not_found' });++ }++ if (req.url === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });return json(404, { error: 'not found' });});issuer = srv.url;nnew file mode 100644@@@ -0,0 +1,138 @@++'use strict';++/**++ * Sharing a watch (design 2026-11-06, visibility step 6): the one Share sheet (openvibe-sdk/share) and the one++ * check, canView, on the watch page and its API.++ *++ * - a new watch is Only me; the list and the watch's page carry the Share button for its owner;++ * - Anyone with the link reads the page and the API, and only its owner deletes it;++ * - Specific people shows the gate, never what the watch looks for, to somebody not on the list;++ * - the person's default for new watches, and "apply to all", work;++ * - a watch 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 { createWatches } = require('../server/domain/watches');++const { createPages } = require('../server/http/pages');++const { createApi } = require('../server/http/api');++const views = require('../server/render/views');++++(async () => {++ const t = await boot();++ try {++ await check('the sharing wiring loads: watches are never Everyone, and a moderator is not staff here', async () => {++ assert.strictEqual(sharing.SERVICE, 'deals');++ assert.strictEqual(sharing.KIND, 'watch');++ assert.deepStrictEqual(sharing.OFFERED, ['private', 'link', 'people']);++ assert.strictEqual(sharing.shareViewer({ kind: 'anonymous', subject: null }), null);++ assert.deepStrictEqual(sharing.shareViewer({ kind: 'user', subject: 'usr_x', staff: true }), { subject: { type: 'user', id: 'usr_x' }, staff: false });++ assert.strictEqual(typeof createWatches, 'function');++ assert.strictEqual(typeof createPages, 'function');++ assert.strictEqual(typeof createApi, 'function');++ assert.strictEqual(typeof views.watchPage, 'function');++ assert.strictEqual(typeof views.watchGate, 'function');++ });++ const kim = t.network.addUser('kim');++ const bo = t.network.addUser('bo');++ const cy = t.network.addUser('cy');++++ const call = (pathname, o = {}) => {++ const { headers, ...rest } = o;++ return t.get(pathname, { ...rest, headers: { 'x-requested-with': 'ov-share', ...(headers || {}) } });++ };++ const share = (id, o = {}) => call(`/api/v1/watch/${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', ['deals', 'watch', id]);++ const newWatch = async (as, body = { kind: 'keyword', query: 'zappos shoes' }) => {++ const made = await t.get('/api/v1/watches', { as, json: body });++ assert.strictEqual(made.status, 201, made.text);++ return made.json().watch.id;++ };++++ let id;++ await check('a new watch is Only me, and the list carries the Share button for its owner', async () => {++ id = await newWatch(kim);++ assert.deepStrictEqual(await audienceRow(id), { audience: 'private' });++ const mine = await t.get('/watches', { as: kim });++ assert.strictEqual(mine.status, 200, mine.text);++ assert.ok(mine.text.includes('data-ov-share-sheet') && mine.text.includes('data-kind="watch"'));++ assert.ok(mine.text.includes('ov-share.js'));++ assert.ok(mine.text.includes('Who can open a new watch'));++ const own = await t.get(`/watches/${id}`, { as: kim });++ assert.strictEqual(own.status, 200, own.text);++ assert.ok(own.text.includes('zappos shoes'));++ assert.ok(own.text.includes('data-ov-share-sheet'));++ const other = await t.get(`/watches/${id}`, { as: bo });++ assert.strictEqual(other.status, 404);++ assert.ok(!other.text.includes('zappos shoes'), 'the watch is never named to somebody else');++ assert.strictEqual((await t.get(`/watches/${id}`)).status, 404);++ assert.strictEqual((await t.get(`/api/v1/watches/${id}`)).status, 404);++ });++++ await check('Anyone with the link reads the page and the API, and cannot delete it', async () => {++ const saved = await setShare(id, { audience: 'link' }, kim);++ assert.strictEqual(saved.status, 200, saved.text);++ assert.strictEqual(saved.json().audience, 'link');++ const guest = await t.get(`/watches/${id}`);++ assert.strictEqual(guest.status, 200, guest.text);++ assert.ok(guest.text.includes('zappos shoes'));++ 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('Delete this watch'));++ const api = await t.get(`/api/v1/watches/${id}`);++ assert.strictEqual(api.status, 200);++ assert.strictEqual(api.json().watch.query, 'zappos shoes');++ assert.strictEqual((await t.get(`/api/v1/watches/${id}`, { method: 'DELETE', as: bo })).status, 404);++ });++++ await check('Specific people: a viewer reads, a stranger gets the gate', async () => {++ const saved = await setShare(id, { audience: 'people', members: [{ name: 'bo', role: 'viewer' }] }, kim);++ assert.strictEqual(saved.status, 200, saved.text);++ assert.strictEqual(saved.json().audience, 'people');++ const viewer = await t.get(`/watches/${id}`, { as: bo });++ assert.strictEqual(viewer.status, 200, viewer.text);++ assert.ok(viewer.text.includes('zappos shoes'));++ assert.ok(viewer.text.includes('Specific people'));++ const stranger = t.network.addUser('di');++ const gate = await t.get(`/watches/${id}`, { as: stranger });++ assert.strictEqual(gate.status, 404);++ assert.ok(gate.text.includes('shared with specific people'));++ assert.ok(!gate.text.includes('zappos shoes'), 'the gate never shows what the watch looks for');++ assert.ok(gate.text.includes('data-ov-share-gate'));++ assert.strictEqual((await t.get(`/api/v1/watches/${id}`, { as: stranger })).status, 404);++ });++++ await check('the default applies to the next watch, and "apply to all" moves the existing one', async () => {++ const saved = await t.get('/watches/defaults', { as: kim, form: { csrf: t.csrf(kim), audience: 'link', apply_all: '1' } });++ assert.strictEqual(saved.status, 303, saved.text);++ assert.deepStrictEqual(await audienceRow(id), { audience: 'link' });++ const next = await newWatch(kim, { kind: 'search', query: 'running shoes' });++ assert.deepStrictEqual(await audienceRow(next), { audience: 'link' });++ const list = await t.get('/watches', { as: kim });++ assert.ok(list.text.includes('Who can open a new watch'), 'the default is on the product page');++ assert.strictEqual((await t.get(`/watches/${next}`)).status, 200, 'the next watch follows the default');++ });++++ await check('deleting a watch takes its share row with it', async () => {++ const gone = await t.get(`/api/v1/watches/${id}`, { method: 'DELETE', as: kim });++ assert.strictEqual(gone.status, 200, gone.text);++ assert.strictEqual(await audienceRow(id), null);++ });++++ await check('a watch is never on a gathered page: not the sitemap, not llms.txt', async () => {++ assert.ok(!(await t.get('/sitemap.xml')).text.includes(id));++ assert.ok(!(await t.get('/llms.txt')).text.includes(id));++ });++++ done();++ } finally {++ await t.close();++ }++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+-- 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 new watches and saved searches, and one
+-- share row for every watch that already exists. A watch was private to its owner, so every one starts as Only me
+-- (a deleted watch needs no row). New watches are registered as they are made (server/domain/watches.js); a watch
+-- without a row still answers its owner alone, because every read asks canView and a missing row falls back to the old
+-- rule. Repeating this file is a no-op.
+
+CREATE TABLE IF NOT EXISTS deals_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 'deals', 'watch', w.id, jsonb_build_object('type', 'user', 'id', w.subject), 'private'
+FROM deal_watches w
+WHERE w.deleted_at IS NULL
+ON CONFLICT (service, kind, resource_id) DO NOTHING;
@@ -30,6 +30,7 @@ const { createPublication } = require('./domain/publication');
@@ -30,6 +30,7 @@ const { createPublication } = require('./domain/publication');
const { createIndexing } = require('./domain/indexing');
const { createIndexing } = require('./domain/indexing');
const { createHotness } = require('./domain/hotness');
const { createHotness } = require('./domain/hotness');
const { createWatches } = require('./domain/watches');
const { createWatches } = require('./domain/watches');
+const { createSharing } = require('./sharing');
const { createLimits } = require('./domain/limits');
const { createLimits } = require('./domain/limits');
const { createAccess } = require('./domain/access');
const { createAccess } = require('./domain/access');
const { createOffers } = require('./domain/offers');
const { createOffers } = require('./domain/offers');
@@ -82,7 +83,9 @@ async function createApp(opts = {}) {
@@ -82,7 +83,9 @@ async function createApp(opts = {}) {
});
});
const indexing = createIndexing({ config, store, publication, outbox, catalog, indexnow });
const indexing = createIndexing({ config, store, publication, outbox, catalog, indexnow });
const hotness = createHotness({ store, reads });
const hotness = createHotness({ store, reads });
- const watches = createWatches({ config, store, reads, catalog, publication, outbox });
+ // The one sharing system (design 2026-11-06): a watch is its owner's until the Share sheet says otherwise.
+ const sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl, log });
+ const watches = createWatches({ config, store, reads, catalog, publication, outbox, sharing });
const limits = createLimits({ config, store });
const limits = createLimits({ config, store });
const access = createAccess();
const access = createAccess();
const offers = createOffers({ config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community, outbox });
const offers = createOffers({ config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community, outbox });
@@ -113,7 +116,7 @@ async function createApp(opts = {}) {
@@ -113,7 +116,7 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: null);
: null);
- const ctx = { config, store, outbox, people, community, sources, reads, catalog, publication, indexing, hotness, watches, limits, access, offers, flags, votes, listings, importer, campaigns, auth, viewers, worker, indexnow, accountData, accountSend };
+ const ctx = { config, store, outbox, people, community, sources, reads, catalog, publication, indexing, hotness, watches, sharing, limits, access, offers, flags, votes, listings, importer, campaigns, auth, viewers, worker, indexnow, accountData, accountSend };
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
@@ -211,6 +214,27 @@ async function createApp(opts = {}) {
@@ -211,6 +214,27 @@ async function createApp(opts = {}) {
// ── Events consumer, API ────────────────────────────────
// ── Events consumer, API ────────────────────────────────
// Never per-actor limited: Events pushes at its own pace, and a 429 would only make it retry and fall behind.
// Never per-actor limited: Events pushes at its own pace, and a 429 would only make it retry and fall behind.
app.use(createEvents({ config, store, importer, accountData: ctx.accountData, accountSend: ctx.accountSend }));
app.use(createEvents({ config, store, importer, accountData: ctx.accountData, accountSend: ctx.accountSend }));
+ // ── /api/v1/watch/:id/share — the one Share sheet's API (openvibe-sdk/share) ──
+ // GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people
+ // and /api/v1/shared-with-me. Access is still decided by canView on every read path; the router answers only
+ // its own paths.
+ {
+ const shareApi = express.Router();
+ shareApi.use(ctx.viewers.middleware());
+ shareApi.use(ctx.sharing.shares.router({
+ service: 'deals',
+ authenticate: (req) => ctx.sharing.shareViewer(req.viewer),
+ describe: ctx.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(() => { ctx.sharing.work().catch(() => { /* logged inside */ }); }, 15_000);
+ if (timer.unref) timer.unref();
+ app.locals.sharingTimer = timer;
+ }
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
// ── Discovery, public pages ─────────────────────────────
// ── Discovery, public pages ─────────────────────────────
@@ -64,6 +64,26 @@ async function extraErase(t, subjects, counts) {
@@ -64,6 +64,26 @@ async function extraErase(t, subjects, counts) {
if (d.shared_offers && typeof d.shared_offers === 'object') for (const s of subjects) delete d.shared_offers[s];
if (d.shared_offers && typeof d.shared_offers === 'object') for (const s of subjects) delete d.shared_offers[s];
counts.add(counts.retained, 'tombstones', await t.exec('UPDATE deal_flags SET details = $1 WHERE id = $2', [JSON.stringify(d), f.id]));
counts.add(counts.retained, 'tombstones', await t.exec('UPDATE deal_flags SET details = $1 WHERE id = $2', [JSON.stringify(d), f.id]));
}
}
+ // A share row names its owner and its members by canonical subject, which a table map cannot cover: the watch's
+ // own rows go, and so must its share row and everyone it named, or a deleted account would linger in somebody
+ // else's member list. Children first: members, transfers and requests point at shares.
+ for (const subject of subjects) {
+ const id = String(subject).replace(/^user:/, '');
+ await t.exec('DELETE FROM deals_share_defaults WHERE subject = $1', [id]);
+ await t.exec("DELETE FROM share_members WHERE service='deals' AND subject->>'id' = $1", [id]);
+ await t.exec("DELETE FROM share_transfers WHERE service='deals' AND (from_subject->>'id' = $1 OR to_subject->>'id' = $1)", [id]);
+ await t.exec("DELETE FROM share_access_requests WHERE service='deals' 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='deals' AND owner_subject->>'id' = $1", [id]);
+ for (const row of owned) {
+ await t.exec("DELETE FROM share_members WHERE service='deals' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
+ await t.exec("DELETE FROM share_transfers WHERE service='deals' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
+ await t.exec("DELETE FROM share_access_requests WHERE service='deals' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
+ await t.exec("DELETE FROM shares WHERE service='deals' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
+ }
+ }
}
}
/** The account-data handle for Deals' database (server/db.js, store.db). */
/** The account-data handle for Deals' database (server/db.js, store.db). */
@@ -25,7 +25,7 @@ const { ApiError, newId, text, tokens, parseAmount, parseCurrency, iso } = requi
@@ -25,7 +25,7 @@ const { ApiError, newId, text, tokens, parseAmount, parseCurrency, iso } = requi
const KINDS = ['keyword', 'product', 'price_below', 'search'];
const KINDS = ['keyword', 'product', 'price_below', 'search'];
-function createWatches({ config, store, reads, catalog, publication, outbox }) {
+function createWatches({ config, store, reads, catalog, publication, outbox, sharing }) {
const { db } = store;
const { db } = store;
const q = {
const q = {
get: db.prepare('SELECT * FROM deal_watches WHERE id = ?'),
get: db.prepare('SELECT * FROM deal_watches WHERE id = ?'),
@@ -68,6 +68,11 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {
@@ -68,6 +68,11 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {
Object.assign(row, { product_id: product ? product.id : null, query: product ? null : query, max_price: amount.text, max_price_num: amount.num, currency });
Object.assign(row, { product_id: product ? product.id : null, query: product ? null : query, max_price: amount.text, max_price_num: amount.num, currency });
}
}
await q.insert.run(row);
await q.insert.run(row);
+ // A new watch starts at the person's default (design rule 1); its Share sheet changes just this one.
+ if (sharing) {
+ const audience = await sharing.defaultOf(subject);
+ await sharing.register(subject, row.id, audience);
+ }
return await q.get.get(row.id);
return await q.get.get(row.id);
}
}
@@ -75,9 +80,32 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {
@@ -75,9 +80,32 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {
const w = await q.get.get(String(id || ''));
const w = await q.get.get(String(id || ''));
if (!w || w.deleted_at || (w.subject !== subject && !staff)) throw new ApiError(404, 'watch.not_found', 'No such watch');
if (!w || w.deleted_at || (w.subject !== subject && !staff)) throw new ApiError(404, 'watch.not_found', 'No such watch');
await q.remove.run(store.now(), w.id);
await q.remove.run(store.now(), w.id);
+ if (sharing) await sharing.forget(w.id);
return { id: w.id, deleted: true };
return { id: w.id, deleted: true };
}
}
+ /**
+ * A watch a viewer may read: the owner's, or one shared with them (design 2026-11-06, step 6). A missing share row
+ * falls back to the old rule — the owner alone. Returns null when it does not exist, is deleted, or is not this
+ * viewer's to see; the caller answers 404.
+ */
+ async function view(viewer, id) {
+ const w = await q.get.get(String(id || ''));
+ if (!w || w.deleted_at) return null;
+ const subject = viewer && (viewer.kind === 'user' || viewer.kind === 'service') ? viewer.subject : null;
+ const mine = Boolean(subject && w.subject === subject);
+ if (mine) return { watch: w, mine: true };
+ if (!sharing) return null;
+ if (!await sharing.canView(viewer, w.id)) return null;
+ return { watch: w, mine: false };
+ }
+
+ async function mustView(viewer, id) {
+ const out = await view(viewer, id);
+ if (!out) throw new ApiError(404, 'watch.not_found', 'No such watch');
+ return out;
+ }
+
function offerText(v) {
function offerText(v) {
return new Set(tokens([v.root.title, v.root.description, v.product && v.product.name, v.product && v.product.brand, v.store && v.store.domain, v.store && v.store.name].filter(Boolean).join(' ')));
return new Set(tokens([v.root.title, v.root.description, v.product && v.product.name, v.product && v.product.brand, v.store && v.store.domain, v.store && v.store.name].filter(Boolean).join(' ')));
}
}
@@ -147,7 +175,7 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {
@@ -147,7 +175,7 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {
};
};
}
}
- return { create, remove, onObservation, list: async (subject) => await q.listFor.all(subject), get: async (id) => await q.get.get(id), dto, KINDS };
+ return { create, remove, view, mustView, onObservation, list: async (subject) => await q.listFor.all(subject), get: async (id) => await q.get.get(id), dto, KINDS };
}
}
module.exports = { createWatches, KINDS };
module.exports = { createWatches, KINDS };
@@ -210,6 +210,13 @@ function createApi(ctx) {
@@ -210,6 +210,13 @@ function createApi(ctx) {
router.get('/watches', guard('deals.watch.read'), run(async (req) => { needSubject(req); return { watches: await Promise.all((await watches.list(req.viewer.subject)).map(watches.dto)) }; }));
router.get('/watches', guard('deals.watch.read'), run(async (req) => { needSubject(req); return { watches: await Promise.all((await watches.list(req.viewer.subject)).map(watches.dto)) }; }));
+ // One watch's data: its owner's, or a reader it was shared with (design 2026-11-06, step 6). A signed-out reader
+ // of an Anyone-with-the-link watch needs no capability; the check is canView inside watches.view.
+ router.get('/watches/:id', guard('deals.watch.read'), run(async (req) => {
+ const out = await watches.mustView(req.viewer, req.params.id);
+ return { watch: await watches.dto(out.watch) };
+ }));
+
router.post('/watches', guard('deals.watch.create'), B('deals.watch'), jsonBody, run(async (req) => {
router.post('/watches', guard('deals.watch.create'), B('deals.watch'), jsonBody, run(async (req) => {
needSubject(req);
needSubject(req);
return { watch: await watches.dto(await watches.create(req.viewer.subject, req.body || {})) };
return { watch: await watches.dto(await watches.create(req.viewer.subject, req.body || {})) };
@@ -41,6 +41,10 @@ const { ttlCache } = require('./ttl-cache');
@@ -41,6 +41,10 @@ const { ttlCache } = require('./ttl-cache');
const PER_PAGE = 25;
const PER_PAGE = 25;
+// 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>`;
+
function createPages(ctx) {
function createPages(ctx) {
const { config, store, reads, catalog, publication, listings, offers, votes, watches, flags, community, access, viewers, actorLimits } = ctx;
const { config, store, reads, catalog, publication, listings, offers, votes, watches, flags, community, access, viewers, actorLimits } = ctx;
const router = express.Router();
const router = express.Router();
@@ -594,7 +598,8 @@ function createPages(ctx) {
@@ -594,7 +598,8 @@ function createPages(ctx) {
});
});
async function watchesPage(req, res, status = 200, { error, notice } = {}) {
async function watchesPage(req, res, status = 200, { error, notice } = {}) {
const list = await Promise.all((await watches.list(req.viewer.subject)).map(watches.dto));
const list = await Promise.all((await watches.list(req.viewer.subject)).map(watches.dto));
- send(req, res, status, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.watchesPage({ csrf: csrf(req), watches: list, error, notice, urls, values: watchValues(req.query) }) });
+ const defaultAudience = ctx.sharing ? await ctx.sharing.defaultOf(req.viewer.subject).catch(() => 'private') : 'private';
+ send(req, res, status, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), head: SHARE_HEAD, body: views.watchesPage({ csrf: csrf(req), watches: list, error, notice, urls, values: watchValues(req.query), defaultAudience }) });
}
}
router.get('/watches', wrap(async (req, res) => {
router.get('/watches', wrap(async (req, res) => {
@@ -602,6 +607,40 @@ function createPages(ctx) {
@@ -602,6 +607,40 @@ function createPages(ctx) {
await watchesPage(req, res);
await watchesPage(req, res);
}));
}));
+ // The person's default for new watches, and "apply to all" (design rule 2).
+ router.post('/watches/defaults', form, wrap(async (req, res) => {
+ if (!formGuard(req, res, '/watches')) return;
+ if (ctx.sharing) {
+ const audience = ['private', 'people', 'link', 'public'].includes(req.body.audience) ? req.body.audience : 'private';
+ await ctx.sharing.setDefault(req.viewer.subject, audience, { applyAll: String(req.body.apply_all || '') === '1' })
+ .catch((err) => ctx.log.warn('[Deals] the watch default was not saved:', err.message));
+ }
+ res.redirect(303, '/watches');
+ }));
+
+ // One watch's own page (design 2026-11-06, step 6): its owner's, or a reader it was shared with. A watch shared with
+ // specific people answers the gate to everyone else and never shows what it looks for.
+ router.get('/watches/:id', wrap(async (req, res) => {
+ const id = String(req.params.id || '');
+ const out = await watches.view(req.viewer, id);
+ if (!out) {
+ const audience = ctx.sharing ? await ctx.sharing.audienceOf(id).catch(() => null) : null;
+ if (audience === 'people') {
+ const signedInUser = signedIn(req) ? req.viewer : null;
+ return send(req, res, 404, {
+ title: 'A watch shared with specific people', decision: pageDecision('/watches', { indexable: false }), head: SHARE_HEAD,
+ body: views.watchGate({ id, signedInAs: signedInUser && signedInUser.user ? signedInUser.user.username || '' : '', signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` }),
+ });
+ }
+ return send(req, res, 404, { title: 'Not found', decision: pageDecision('/watches', { indexable: false }), body: views.message({ heading: 'Not found', text: 'There is no watch at this address.' }) });
+ }
+ const audience = ctx.sharing ? await ctx.sharing.audienceOf(id).catch(() => null) : null;
+ send(req, res, 200, {
+ title: out.watch.kind === 'search' ? 'Saved search' : 'Watch', decision: pageDecision('/watches', { indexable: false }), head: SHARE_HEAD,
+ body: views.watchPage({ watch: await watches.dto(out.watch), mine: out.mine, audience, csrf: csrf(req), urls }),
+ });
+ }));
+
router.post('/watches', B('deals.watch'), form, wrap(async (req, res) => {
router.post('/watches', B('deals.watch'), form, wrap(async (req, res) => {
if (!formGuard(req, res, '/watches')) return;
if (!formGuard(req, res, '/watches')) return;
try {
try {
@@ -93,7 +93,7 @@ function renderPage(o) {
@@ -93,7 +93,7 @@ function renderPage(o) {
return layout.renderDocument({
return layout.renderDocument({
// The head names the page's own Markdown twin, and carries the rules that show the picked pane of a pick
// The head names the page's own Markdown twin, and carries the rules that show the picked pane of a pick
// block (there is one per pane, written for the page).
// block (there is one per pane, written for the page).
- head: [agentHead, o.path === '/' ? '' : `<link rel="stylesheet" href="${asset('css/deal-cards.css')}">`, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
+ head: [o.head, agentHead, o.path === '/' ? '' : `<link rel="stylesheet" href="${asset('css/deal-cards.css')}">`, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
site: 'deals',
site: 'deals',
siteName: SITE_NAME,
siteName: SITE_NAME,
lang: o.lang,
lang: o.lang,
@@ -379,16 +379,27 @@ ${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}
@@ -379,16 +379,27 @@ ${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}
</form>`;
</form>`;
}
}
-function watchesPage({ csrf, watches, error, notice, urls, values = {} }) {
+function watchesPage({ csrf, watches, error, notice, urls, values = {}, defaultAudience = 'private' }) {
// Arriving from a shop's "Alert me" (/watches?kind=keyword&query=zappos.com): the form says so, filled in.
// Arriving from a shop's "Alert me" (/watches?kind=keyword&query=zappos.com): the form says so, filled in.
const kindOpt = (k, label) => html`<option value="${k}"${values.kind === k ? raw(' selected') : ''}>${label}</option>`;
const kindOpt = (k, label) => html`<option value="${k}"${values.kind === k ? raw(' selected') : ''}>${label}</option>`;
+ const audienceText = { private: 'Only me', people: 'Specific people', link: 'Anyone with the link', public: 'Everyone' };
+ const defaultOptions = ['private', 'people', 'link']
+ .map((a) => html`<option value="${a}"${a === defaultAudience ? raw(' selected') : ''}>${audienceText[a]}</option>`);
return html`<h1>Watches and saved searches</h1>
return html`<h1>Watches and saved searches</h1>
<p class="lede">A watch tells you once when a new observation matches it (at most one notification per observation). A saved search never notifies. Notifications are delivered by the OpenVibe network; Deals never emails.</p>
<p class="lede">A watch tells you once when a new observation matches it (at most one notification per observation). A saved search never notifies. Notifications are delivered by the OpenVibe network; Deals never emails.</p>
${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}
${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}
${notice ? html`<p class="notice" role="status">${notice}</p>` : ''}
${notice ? html`<p class="notice" role="status">${notice}</p>` : ''}
-${watches.length ? html`<ul class="watches">${watches.map((w) => html`<li><strong>${w.kind === 'search' ? 'Saved search' : w.kind === 'price_below' ? 'Price below' : w.kind === 'product' ? 'Product' : 'Keywords'}</strong>:
+<section class="card" aria-labelledby="new-watch-h">
+<h2 id="new-watch-h">New watches</h2>
+<form method="post" action="/watches/defaults" class="inline">${csrfField(csrf)}
+<label>Who can open a new watch <select name="audience">${defaultOptions}</select></label>
+<label><input type="checkbox" name="apply_all" value="1"> Apply to all my existing watches</label>
+<button type="submit">Save</button></form>
+<p class="muted">A watch's Share button changes just that one. Your watches are never listed; a shared watch is readable only by whoever holds the link or the people you add.</p></section>
+${watches.length ? html`<ul class="watches">${watches.map((w) => html`<li><strong><a href="/watches/${w.id}">${w.kind === 'search' ? 'Saved search' : w.kind === 'price_below' ? 'Price below' : w.kind === 'product' ? 'Product' : 'Keywords'}</a></strong>:
${w.query ? html` “${w.query}”` : ''}${w.product ? html` <a href="${w.product.url}">${w.product.name}</a>` : ''}${w.max_price ? html` under ${w.max_price} ${w.currency}` : ''}
${w.query ? html` “${w.query}”` : ''}${w.product ? html` <a href="${w.product.url}">${w.product.name}</a>` : ''}${w.max_price ? html` under ${w.max_price} ${w.currency}` : ''}
${w.kind === 'search' ? html` · <a href="/search?q=${encodeURIComponent(w.query)}">run</a>` : html` · ${w.notifications} notifications`}
${w.kind === 'search' ? html` · <a href="/search?q=${encodeURIComponent(w.query)}">run</a>` : html` · ${w.notifications} notifications`}
+<button type="button" class="btn btn-quiet" data-ov-share-sheet data-kind="watch" data-id="${w.id}" data-title="${w.kind === 'search' ? 'Saved search' : 'Watch'}">Share</button>
<form class="inline" method="post" action="/watches/${w.id}/delete">${csrfField(csrf)}<button type="submit">Delete</button></form></li>`)}</ul>` : html`<p class="empty">No watches yet.</p>`}
<form class="inline" method="post" action="/watches/${w.id}/delete">${csrfField(csrf)}<button type="submit">Delete</button></form></li>`)}</ul>` : html`<p class="empty">No watches yet.</p>`}
<form method="post" action="/watches" class="card">${csrfField(csrf)}
<form method="post" action="/watches" class="card">${csrfField(csrf)}
<h2>${values.query ? html`Alert me about “${values.query}”` : 'Add'}</h2>
<h2>${values.query ? html`Alert me about “${values.query}”` : 'Add'}</h2>
@@ -401,6 +412,37 @@ ${w.kind === 'search' ? html` · <a href="/search?q=${encodeURIComponent(w.query
@@ -401,6 +412,37 @@ ${w.kind === 'search' ? html` · <a href="/search?q=${encodeURIComponent(w.query
</form>`;
</form>`;
}
}
+// ── One watch (its owner's, or a shared one) ────────────────────────────────
+
+const WATCH_AUDIENCES = { public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' };
+
+function watchSummary(w) {
+ return html`<strong>${w.kind === 'search' ? 'Saved search' : w.kind === 'price_below' ? 'Price below' : w.kind === 'product' ? 'Product' : 'Keywords'}</strong>:
+${w.query ? html` “${w.query}”` : ''}${w.product ? html` <a href="${w.product.url}">${w.product.name}</a>` : ''}${w.max_price ? html` under ${w.max_price} ${w.currency}` : ''}`;
+}
+
+function watchPage({ watch, mine = false, audience = 'private', csrf, urls, notice }) {
+ const who = WATCH_AUDIENCES[audience] || 'Only me';
+ return html`<h1>${watch.kind === 'search' ? 'Saved search' : 'Watch'}</h1>
+<p class="muted"><span class="badge">${who}</span>${mine ? '' : ' · you are reading a shared watch'}</p>
+${notice ? html`<p class="notice" role="status">${notice}</p>` : ''}
+${mine ? html`<p><button type="button" class="btn" data-ov-share-sheet data-kind="watch" data-id="${watch.id}" data-title="${watch.kind === 'search' ? 'Saved search' : 'Watch'}">Share</button></p>` : ''}
+<p>${watchSummary(watch)}</p>
+<p class="muted">${watch.kind === 'search' ? html`A saved search never notifies. <a href="/search?q=${encodeURIComponent(watch.query)}">Run it</a>.` : html`${watch.notifications} notification${watch.notifications === 1 ? '' : 's'} so far.`} Made ${when(watch.created_at)}.</p>
+${mine ? html`<form class="inline" method="post" action="/watches/${watch.id}/delete">${csrfField(csrf)}<button type="submit">Delete this watch</button></form>` : ''}
+<p class="muted"><a href="/watches">Your watches</a> · Notifications are delivered by the OpenVibe network; Deals never emails.</p>`;
+}
+
+/**
+ * The page a person not on a Specific-people watch's list sees: the audience, never what the watch looks for.
+ * openvibe-shared draws the card from this markup (no inline script).
+ */
+function watchGate({ id, signedInAs = '', signInUrl = '/auth/login' }) {
+ return html`<h1>A watch shared with specific people</h1>
+<p>Sign in with the OpenVibe account it was shared with, or ask its owner for access.</p>
+<div id="watch-share-gate" data-ov-share-gate data-kind="watch" 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 searchPage({ q, results, pager, urls, csrf, signedIn }) {
function searchPage({ q, results, pager, urls, csrf, signedIn }) {
return html`<h1>Search deals</h1>
return html`<h1>Search deals</h1>
<form action="/search" method="get" role="search"><label for="sq">Words</label><input id="sq" name="q" type="search" value="${q || ''}"><button type="submit">Search</button></form>
<form action="/search" method="get" role="search"><label for="sq">Words</label><input id="sq" name="q" type="search" value="${q || ''}"><button type="submit">Search</button></form>
@@ -496,4 +538,4 @@ function message({ heading, text, action }) {
@@ -496,4 +538,4 @@ function message({ heading, text, action }) {
return html`<h1>${heading}</h1><p>${text}</p>${action ? html`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}`;
return html`<h1>${heading}</h1><p>${text}</p>${action ? html`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}`;
}
}
-module.exports = { storesPage, offerList, tabs, offerPage, productPage, submitForm, watchesPage, searchPage, modPage, storePage, sellerPage, sellersPage, categoryPage, categoriesPage, message, priceText, when, ago };
+module.exports = { storesPage, offerList, tabs, offerPage, productPage, submitForm, watchesPage, watchPage, watchGate, searchPage, modPage, storePage, sellerPage, sellersPage, categoryPage, categoriesPage, message, priceText, when, ago };
new file mode 100644
new file mode 100644
@@ -0,0 +1,181 @@
@@ -0,0 +1,181 @@
+'use strict';
+
+/**
+ * Sharing on OpenVibe.Deals (design 2026-11-06, visibility step 6), through openvibe-sdk/share.
+ *
+ * A watch or saved search is Only me until its owner says otherwise. The Share sheet changes just that watch; the
+ * person's default for new watches lives on /watches ("New watches: Only me"). A shared watch's page and its API read
+ * ask the one check, canView. A watch has no editing beyond delete, 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. A watch 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 = 'deals';
+const KIND = 'watch';
+const AUDIENCES = ['public', 'link', 'people', 'private'];
+const DEFAULT_AUDIENCE = 'private';
+/** A watch 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) });
+
+/** Deals' 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 the watch's words, product or price. */
+ async function notify(n) {
+ const to = n && n.to;
+ if (!to || to.type !== 'user') return;
+ const words = {
+ 'share.added': ['A watch was shared with you', 'Open it on OpenVibe.Deals.'],
+ 'share.access_requested': ['Someone asked for access to your watch', 'Review the request on OpenVibe.Deals.'],
+ 'share.transfer.requested': ['Someone wants to give you a watch', 'Accept or decline it on OpenVibe.Deals.'],
+ 'share.transfer.answered': ['A watch transfer was answered', 'See what happened on OpenVibe.Deals.'],
+ }[n.kind] || ['Sharing on OpenVibe.Deals', '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: 'deals', url: n.ref && n.ref.id ? `${config.baseUrl}/watches/${encodeURIComponent(n.ref.id)}` : `${config.baseUrl}/watches`,
+ 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 watch, at the owner's 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 deals_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 deals_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): what the watch is, its page, and only the audiences a watch may use. */
+ describe: async (ref) => {
+ if (ref.service !== SERVICE || ref.kind !== KIND) return {};
+ const row = await db.maybe('SELECT id, kind, query, label FROM deal_watches WHERE id = $1 AND deleted_at IS NULL', [String(ref.id)]);
+ if (!row) return {};
+ const named = row.label || row.query || (row.kind === 'product' || row.kind === 'price_below' ? 'a product' : 'saved words');
+ return {
+ title: `${row.kind === 'search' ? 'Saved search' : 'Watch'}: ${String(named).slice(0, 100)}`.slice(0, 120),
+ link: `${config.baseUrl}/watches/${encodeURIComponent(row.id)}`,
+ roles: ['viewer'],
+ audiences: OFFERED,
+ transferable: false,
+ };
+ },
+ /** A deleted watch takes its share row and everyone it named with it. */
+ 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 };
@@ -43,7 +43,7 @@ const envelope = (id, type, payload) => ({ event: { event_id: id, event_type: ty
@@ -43,7 +43,7 @@ const envelope = (id, type, payload) => ({ event: { event_id: id, event_type: ty
tables: TABLES,
tables: TABLES,
taskKeyed: ["deal_watches"],
taskKeyed: ["deal_watches"],
- exempt: {"account_data_events":"the ledger of requests itself: which export and deletion Deals applied","deal_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash"},
+ exempt: {"account_data_events":"the ledger of requests itself: which export and deletion Deals applied","deal_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 with the watch by extraErase","share_access_requests":"the SDK's own sharing model: subject is the person who asked, erased by extraErase","deals_share_defaults":"a person's own default for new watches, 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(); }
@@ -62,6 +62,13 @@ async function startNetwork() {
@@ -62,6 +62,13 @@ async function startNetwork() {
}
}
return json(200, { results });
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 found = [...directory.values()].find((u) => String(u.username).toLowerCase() === name);
+ if (found) return json(200, { subject: { type: 'user', id: found.subject }, username: found.username, display_name: found.display_name });
+ return json(404, { code: 'identity.not_found' });
+ }
+ if (req.url === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
});
});
issuer = srv.url;
issuer = srv.url;
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -0,0 +1,138 @@
+'use strict';
+/**
+ * Sharing a watch (design 2026-11-06, visibility step 6): the one Share sheet (openvibe-sdk/share) and the one
+ * check, canView, on the watch page and its API.
+ *
+ * - a new watch is Only me; the list and the watch's page carry the Share button for its owner;
+ * - Anyone with the link reads the page and the API, and only its owner deletes it;
+ * - Specific people shows the gate, never what the watch looks for, to somebody not on the list;
+ * - the person's default for new watches, and "apply to all", work;
+ * - a watch 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 { createWatches } = require('../server/domain/watches');
+const { createPages } = require('../server/http/pages');
+const { createApi } = require('../server/http/api');
+const views = require('../server/render/views');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('the sharing wiring loads: watches are never Everyone, and a moderator is not staff here', async () => {
+ assert.strictEqual(sharing.SERVICE, 'deals');
+ assert.strictEqual(sharing.KIND, 'watch');
+ assert.deepStrictEqual(sharing.OFFERED, ['private', 'link', 'people']);
+ assert.strictEqual(sharing.shareViewer({ kind: 'anonymous', subject: null }), null);
+ assert.deepStrictEqual(sharing.shareViewer({ kind: 'user', subject: 'usr_x', staff: true }), { subject: { type: 'user', id: 'usr_x' }, staff: false });
+ assert.strictEqual(typeof createWatches, 'function');
+ assert.strictEqual(typeof createPages, 'function');
+ assert.strictEqual(typeof createApi, 'function');
+ assert.strictEqual(typeof views.watchPage, 'function');
+ assert.strictEqual(typeof views.watchGate, 'function');
+ });
+ const kim = t.network.addUser('kim');
+ const bo = t.network.addUser('bo');
+ const cy = t.network.addUser('cy');
+
+ const call = (pathname, o = {}) => {
+ const { headers, ...rest } = o;
+ return t.get(pathname, { ...rest, headers: { 'x-requested-with': 'ov-share', ...(headers || {}) } });
+ };
+ const share = (id, o = {}) => call(`/api/v1/watch/${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', ['deals', 'watch', id]);
+ const newWatch = async (as, body = { kind: 'keyword', query: 'zappos shoes' }) => {
+ const made = await t.get('/api/v1/watches', { as, json: body });
+ assert.strictEqual(made.status, 201, made.text);
+ return made.json().watch.id;
+ };
+
+ let id;
+ await check('a new watch is Only me, and the list carries the Share button for its owner', async () => {
+ id = await newWatch(kim);
+ assert.deepStrictEqual(await audienceRow(id), { audience: 'private' });
+ const mine = await t.get('/watches', { as: kim });
+ assert.strictEqual(mine.status, 200, mine.text);
+ assert.ok(mine.text.includes('data-ov-share-sheet') && mine.text.includes('data-kind="watch"'));
+ assert.ok(mine.text.includes('ov-share.js'));
+ assert.ok(mine.text.includes('Who can open a new watch'));
+ const own = await t.get(`/watches/${id}`, { as: kim });
+ assert.strictEqual(own.status, 200, own.text);
+ assert.ok(own.text.includes('zappos shoes'));
+ assert.ok(own.text.includes('data-ov-share-sheet'));
+ const other = await t.get(`/watches/${id}`, { as: bo });
+ assert.strictEqual(other.status, 404);
+ assert.ok(!other.text.includes('zappos shoes'), 'the watch is never named to somebody else');
+ assert.strictEqual((await t.get(`/watches/${id}`)).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/watches/${id}`)).status, 404);
+ });
+
+ await check('Anyone with the link reads the page and the API, and cannot delete it', async () => {
+ const saved = await setShare(id, { audience: 'link' }, kim);
+ assert.strictEqual(saved.status, 200, saved.text);
+ assert.strictEqual(saved.json().audience, 'link');
+ const guest = await t.get(`/watches/${id}`);
+ assert.strictEqual(guest.status, 200, guest.text);
+ assert.ok(guest.text.includes('zappos shoes'));
+ 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('Delete this watch'));
+ const api = await t.get(`/api/v1/watches/${id}`);
+ assert.strictEqual(api.status, 200);
+ assert.strictEqual(api.json().watch.query, 'zappos shoes');
+ assert.strictEqual((await t.get(`/api/v1/watches/${id}`, { method: 'DELETE', as: bo })).status, 404);
+ });
+
+ await check('Specific people: a viewer reads, a stranger gets the gate', async () => {
+ const saved = await setShare(id, { audience: 'people', members: [{ name: 'bo', role: 'viewer' }] }, kim);
+ assert.strictEqual(saved.status, 200, saved.text);
+ assert.strictEqual(saved.json().audience, 'people');
+ const viewer = await t.get(`/watches/${id}`, { as: bo });
+ assert.strictEqual(viewer.status, 200, viewer.text);
+ assert.ok(viewer.text.includes('zappos shoes'));
+ assert.ok(viewer.text.includes('Specific people'));
+ const stranger = t.network.addUser('di');
+ const gate = await t.get(`/watches/${id}`, { as: stranger });
+ assert.strictEqual(gate.status, 404);
+ assert.ok(gate.text.includes('shared with specific people'));
+ assert.ok(!gate.text.includes('zappos shoes'), 'the gate never shows what the watch looks for');
+ assert.ok(gate.text.includes('data-ov-share-gate'));
+ assert.strictEqual((await t.get(`/api/v1/watches/${id}`, { as: stranger })).status, 404);
+ });
+
+ await check('the default applies to the next watch, and "apply to all" moves the existing one', async () => {
+ const saved = await t.get('/watches/defaults', { as: kim, form: { csrf: t.csrf(kim), audience: 'link', apply_all: '1' } });
+ assert.strictEqual(saved.status, 303, saved.text);
+ assert.deepStrictEqual(await audienceRow(id), { audience: 'link' });
+ const next = await newWatch(kim, { kind: 'search', query: 'running shoes' });
+ assert.deepStrictEqual(await audienceRow(next), { audience: 'link' });
+ const list = await t.get('/watches', { as: kim });
+ assert.ok(list.text.includes('Who can open a new watch'), 'the default is on the product page');
+ assert.strictEqual((await t.get(`/watches/${next}`)).status, 200, 'the next watch follows the default');
+ });
+
+ await check('deleting a watch takes its share row with it', async () => {
+ const gone = await t.get(`/api/v1/watches/${id}`, { method: 'DELETE', as: kim });
+ assert.strictEqual(gone.status, 200, gone.text);
+ assert.strictEqual(await audienceRow(id), null);
+ });
+
+ await check('a watch is never on a gathered page: not the sitemap, not llms.txt', async () => {
+ assert.ok(!(await t.get('/sitemap.xml')).text.includes(id));
+ assert.ok(!(await t.get('/llms.txt')).text.includes(id));
+ });
+
+ done();
+ } finally {
+ await t.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });