openvibers/openvibe.shoes Commits

Watches: the one Share sheet, Only me by default (#69)

OP OpenVibers committed 6 days ago · 5f950ffd1f · 11 files, +576 −11

Visibility step 6 (design 2026-11-06): a price watch or a pasted listing uses
openvibe-sdk/share and the shared ov-share.js sheet. Only me by default, Anyone
with the link to send one, Specific people with Can view; canView on the watch
page and the API. The person's default for new watches lives on /watches with
apply-to-all. The share row is the authority; account deletion erases the
person's share rows and memberships.

Changes

ddiff --git a/migrations/0003_shares.sql b/migrations/0003_shares.sql
nnew file mode 100644
iindex 0000000..39c783a
---- /dev/null
++++ b/migrations/0003_shares.sql
@@@ -0,0 +1,88 @@
++-- 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 one share row for every
++-- watch that already exists. A watch was private to its owner, so every one starts as Only me. New watches are
++-- registered as they are made (server/catalogue.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 shoes_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 'shoes', 'watch', w.id, jsonb_build_object('type', 'user', 'id', w.subject), 'private'
++FROM shoe_watches w
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
ddiff --git a/server/app.js b/server/app.js
iindex 23c7b5f..055b917 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -29,6 +29,7 @@ const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
++const { createSharing } = require('./sharing');
const { openapi } = require('./http/openapi');
const { createServiceReadiness } = require('./observability');
const { assetVersion, setRelease } = require('./render/layout');
@@@ -58,7 +59,9 @@ async function createApp(opts = {}) {
const keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
-- const catalogue = createCatalogue(s, { staleHours: config.sources.staleHours });
++ // 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: s.db, config, fetchImpl, log });
++ const catalogue = createCatalogue(s, { staleHours: config.sources.staleHours, sharing });
const upstreams = createUpstreams({ config, fetchImpl, log, urlCheck: opts.urlCheck });
const jobs = createJobs({ config, catalogue, upstreams, log });
// The model pages in OpenVibe.Search (started by server/index.js, never under SHOES_DRILL).
@@@ -71,7 +74,7 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Shoes cannot answer account events'); });
-- const ctx = { config, s, keys, sso, principal, catalogue, upstreams, jobs, searchIndex, accountData, accountSend, log };
++ const ctx = { config, s, keys, sso, principal, catalogue, upstreams, jobs, searchIndex, sharing, accountData, accountSend, log };
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
@@@ -148,6 +151,28 @@ async function createApp(opts = {}) {
},
}));
++ // ── /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(principal.middleware);
++ shareApi.use(sharing.shares.router({
++ service: 'shoes',
++ authenticate: (req) => sharing.shareViewer(req.principal.kind === 'user' ? req.principal : req.viewer),
++ describe: sharing.describe,
++ }));
++ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|[^/]+\/[^/]+\/share(?:\/|$))/;
++ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
++ }
++ // Expire members and transfers and send queued share events/notifications. Unref'd: it never holds a test open.
++ if (opts.startSharingWorker !== false) {
++ const timer = setInterval(() => { sharing.work().catch(() => { /* logged inside */ }); }, 15_000);
++ if (timer.unref) timer.unref();
++ app.locals.sharingTimer = timer;
++ }
++
// ── API ─────────────────────────────────────────────────
app.get('/api/openapi.json', (req, res) => res.json(openapi(config)));
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.SHOES_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
ddiff --git a/server/catalogue.js b/server/catalogue.js
iindex 2df79a3..695c761 100644
---- a/server/catalogue.js
++++ b/server/catalogue.js
@@@ -120,7 +120,7 @@ function shortHash(value) {
return crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 6);
}
--function createCatalogue(s, { staleHours = 48 } = {}) {
++function createCatalogue(s, { staleHours = 48, sharing = null } = {}) {
const db = s.db;
const nowMs = () => s.now();
@@@ -493,9 +493,39 @@ function createCatalogue(s, { staleHours = 48 } = {}) {
input.sourceUrl || null, input.remoteId || null, String(input.status || 'active'), input.note || null, s.iso(),
],
);
++ // 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(input.subject);
++ await sharing.register(input.subject, row.id, audience);
++ }
return wireWatch(row);
}
++ /** One watch by id, or null. */
++ async function watchById(id) {
++ return await db.maybe('SELECT w.*, m.slug AS model_slug FROM shoe_watches w LEFT JOIN shoe_models m ON m.id = w.model_id WHERE w.id = $1', [String(id || '')]);
++ }
++
++ /**
++ * 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 or is not this viewer's to see.
++ */
++ async function viewWatch(viewer, id) {
++ const row = await watchById(id);
++ if (!row) return null;
++ const subject = viewer && viewer.kind === 'user' ? viewer.subject : null;
++ if (subject && row.subject === subject) return { watch: wireWatch(row), mine: true };
++ if (!sharing) return null;
++ if (!await sharing.canView(viewer, row.id)) return null;
++ return { watch: wireWatch(row), mine: false };
++ }
++
++ async function mustViewWatch(viewer, id) {
++ const out = await viewWatch(viewer, id);
++ if (!out) { const err = new Error('No such watch'); err.status = 404; err.code = 'watch.not_found'; throw err; }
++ return out;
++ }
++
/** A person's watches, newest first. */
async function listWatches(subject) {
const rows = await db.many(
@@@ -572,7 +602,7 @@ function createCatalogue(s, { staleHours = 48 } = {}) {
return {
ensureModel, upsertOffer, writeSnapshot, listShoes, countShoes, facets, modelBySlug, modelById, offersOf, history,
-- curve, listDrops, dropById, dropsForModel, upsertDrop, addFact, factsOf, createWatch, listWatches, priceFor,
++ curve, listDrops, dropById, dropsForModel, upsertDrop, addFact, factsOf, createWatch, watchById, viewWatch, mustViewWatch, listWatches, priceFor,
startJobRun, finishJobRun, lastJobRuns, counts, wireOffer, wireModel, wireDrop, wireWatch,
};
}
ddiff --git a/server/http/api.js b/server/http/api.js
iindex d77703f..487bb15 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -9,6 +9,7 @@
* GET /drops?from&to official release dates, soonest first
* GET /drops/:id one release
* GET /me/watches the signed-in person's watches
++ * GET /watches/:id one watch: its owner's, or one shared with the reader
* POST /me/watches a price watch through OpenVibe.Watch
* POST /me/track a pasted listing link through Watch's rules
* GET /status the last read of each source
@@@ -115,6 +116,14 @@ function createApi(ctx) {
return res.json({ watches });
});
++ // 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 token; the check is canView inside catalogue.viewWatch.
++ r.get('/watches/:id', async (req, res) => {
++ const out = await catalogue.viewWatch(req.principal, String(req.params.id || ''));
++ if (!out) return fail(res, req, 404, 'watch.not_found', 'No such watch.');
++ return res.json({ watch: out.watch });
++ });
++
r.post('/me/watches', principal.requirePerson, async (req, res) => {
const b = req.body && typeof req.body === 'object' ? req.body : {};
const slug = str(b.slug, 80);
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex b8345eb..0443416 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -22,6 +22,10 @@ const { send } = require('../render/layout');
const views = require('../views');
const discovery = require('./discovery');
++// 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>`;
++
const SORTS = new Set(['recent', 'drop', 'price', 'price_desc', 'popular', 'name']);
const PER_PAGE = 24;
@@@ -209,15 +213,52 @@ function createPageRoutes(ctx) {
r.get('/watches', async (req, res) => {
const list = signedIn(req) ? await catalogue.listWatches(req.viewer.subject) : [];
++ const defaultAudience = signedIn(req) && ctx.sharing ? await ctx.sharing.defaultOf(req.viewer.subject).catch(() => 'private') : 'private';
return page(req, res, {
title: 'Your watches',
description: 'The price watches and pasted listing links that OpenVibe.Watch carries for you.',
-- body: views.watches({ watches: list, signedIn: Boolean(signedIn(req)), notice: noticeOf(req) }),
-- active: '/watches',
++ body: views.watches({ watches: list, signedIn: Boolean(signedIn(req)), notice: noticeOf(req), defaultAudience }),
++ active: '/watches', head: SHARE_HEAD,
robots: 'noindex, nofollow',
});
});
++ // The person's default for new watches, and "apply to all" (design rule 2).
++ r.post('/watches/defaults', async (req, res) => {
++ if (!signedIn(req)) return res.redirect(302, '/auth/login?next=%2Fwatches');
++ if (!guardOrigin(req, res)) 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('[Shoes] the watch default was not saved:', err.message));
++ }
++ return res.redirect(302, '/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.
++ r.get('/watches/:id', async (req, res) => {
++ const id = String(req.params.id || '');
++ const out = await catalogue.viewWatch(req.viewer || { kind: 'anonymous' }, id);
++ if (!out) {
++ const audience = ctx.sharing ? await ctx.sharing.audienceOf(id).catch(() => null) : null;
++ if (audience === 'people') {
++ return page(req, res, {
++ title: 'A watch shared with specific people', head: SHARE_HEAD, active: '/watches', robots: 'noindex, nofollow',
++ body: views.watchGate({ id, signedInAs: signedIn(req) ? req.viewer.username || '' : '', signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` }),
++ }, 404);
++ }
++ return page(req, res, { title: 'Not found', body: views.notFound(), active: '/watches' }, 404);
++ }
++ const audience = ctx.sharing ? await ctx.sharing.audienceOf(id).catch(() => null) : null;
++ return page(req, res, {
++ title: out.watch.kind === 'price' ? 'A price watch' : 'A listing watch',
++ description: 'A watch carried by OpenVibe.Watch for its owner, shared on Open.Shoes.',
++ body: views.watchPage({ watch: out.watch, mine: out.mine, audience }),
++ active: '/watches', head: SHARE_HEAD, robots: 'noindex, nofollow',
++ });
++ });
++
r.get('/developers', (req, res) => page(req, res, {
title: 'The Shoes API',
description: 'The open JSON API for shoes, offers, price history, size curves, drops and watches, with an OpenAPI document.',
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 888ab68..a851eed 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -19,6 +19,30 @@ function create({ db, log = console } = {}) {
db,
service: 'shoes',
tables: TABLES,
++ /**
++ * A share row names its owner and its members by canonical subject, which a table map cannot cover: a watch's
++ * own row goes, 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.
++ */
++ extraErase: async (t, subjects) => {
++ for (const subject of subjects) {
++ const id = String(subject).replace(/^user:/, '');
++ await t.exec('DELETE FROM shoes_share_defaults WHERE subject = $1', [id]);
++ await t.exec("DELETE FROM share_members WHERE service='shoes' AND subject->>'id' = $1", [id]);
++ await t.exec("DELETE FROM share_transfers WHERE service='shoes' AND (from_subject->>'id' = $1 OR to_subject->>'id' = $1)", [id]);
++ await t.exec("DELETE FROM share_access_requests WHERE service='shoes' 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='shoes' AND owner_subject->>'id' = $1", [id]);
++ for (const row of owned) {
++ await t.exec("DELETE FROM share_members WHERE service='shoes' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
++ await t.exec("DELETE FROM share_transfers WHERE service='shoes' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
++ await t.exec("DELETE FROM share_access_requests WHERE service='shoes' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
++ await t.exec("DELETE FROM shares WHERE service='shoes' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
++ }
++ }
++ },
note: 'Models, offers, prices and drops are the network\'s public shoe record; only your watches are here.',
log,
});
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex aa2ce5e..a6ae1e7 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -84,7 +84,7 @@ function layout(o) {
navLinks: NAV,
navbar,
footer,
-- head: `${appIcon.headTags({ site: 'shoes' })}
++ head: `${o.head || ''}${appIcon.headTags({ site: 'shoes' })}
<link rel="stylesheet" href="${esc(ovServe.url('kit.css'))}">
<link rel="stylesheet" href="${esc(ovServe.url('room.css'))}">
<link rel="stylesheet" href="${esc(ovServe.url('showcase.css'))}">
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..95ef42d
---- /dev/null
++++ b/server/sharing.js
@@@ -0,0 +1,182 @@
++'use strict';
++
++/**
++ * Sharing on Open.Shoes (design 2026-11-06, visibility step 6), through openvibe-sdk/share.
++ *
++ * A price watch or a pasted listing 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 = 'shoes';
++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) });
++
++/** Shoes' 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 Open.Shoes.'],
++ 'share.access_requested': ['Someone asked for access to your watch', 'Review the request on Open.Shoes.'],
++ 'share.transfer.requested': ['Someone wants to give you a watch', 'Accept or decline it on Open.Shoes.'],
++ 'share.transfer.answered': ['A watch transfer was answered', 'See what happened on Open.Shoes.'],
++ }[n.kind] || ['Sharing on Open.Shoes', '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 Shoes' 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 shoes_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 shoes_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 w.id, w.kind, w.slug, m.brand, m.model FROM shoe_watches w
++ LEFT JOIN shoe_models m ON m.id = w.model_id WHERE w.id = $1`, [String(ref.id)]);
++ if (!row) return {};
++ const named = row.brand && row.model ? `${row.brand} ${row.model}` : row.slug || (row.kind === 'price' ? 'a price watch' : 'a listing');
++ return {
++ title: `${row.kind === 'price' ? 'Price watch' : 'Listing 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 };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 5892ade..15dbaa8 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -359,17 +359,44 @@ ${upcoming.length ? [...byDay.entries()].map(([day, rows]) => `<section class="s
}
// ── Watches ─────────────────────────────────────────────────
--function watches({ watches: list = [], signedIn = false, notice = null } = {}) {
++function watches({ watches: list = [], signedIn = false, notice = null, defaultAudience = 'private' } = {}) {
if (!signedIn) {
return `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">Your watches</p><h1>Sign in to see your watches</h1><p class="sh-lede">A watch belongs to a person. Sign in with OpenVibe and your price watches and pasted links are here.</p><p><a class="sh-btn" href="/auth/login?next=${encodeURIComponent('/watches')}">Sign in with OpenVibe</a></p></header></div>`;
}
const body = `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">Your watches</p><h1>Watches</h1><p class="sh-lede">Every watch is carried by OpenVibe.Watch. A price watch tells you when the all-in price for your size drops below your number; a pasted link keeps the listing and lets Watch apply its own fetch rules.</p></header>
${notice ? `<div class="sh-notice" role="status">${esc(notice)}</div>` : ''}
--${list.length ? `<div class="sh-table-wrap"><table class="sh-table"><caption>Your watches, newest first</caption><thead><tr><th scope="col">Kind</th><th scope="col">What</th><th scope="col">Size</th><th scope="col">Below</th><th scope="col">Status</th><th scope="col">Created</th></tr></thead><tbody>${list.map((w) => `<tr><td>${esc(w.kind)}</td><td>${w.slug ? link(`/shoe/${encodeURIComponent(w.slug)}`, w.slug) : w.source_url ? `<a href="${esc(w.source_url)}" rel="noopener nofollow">${esc(w.source_url)}</a>` : '—'}${w.note ? `<br><small>${esc(w.note)}</small>` : ''}</td><td>${w.size ? esc(w.size) : 'any'}</td><td>${w.threshold != null ? money(w.threshold, w.currency) : '—'}</td><td>${esc(w.status)}${w.remote_id ? `<br><small>Watch ${esc(w.remote_id)}</small>` : ''}</td><td>${esc(dayShort(w.created_at))}</td></tr>`).join('')}</tbody></table></div>` : '<div class="sh-empty"><h2>No watch yet</h2><p>Open a shoe and set the price for your size, or paste a listing link on the home page.</p><p><a class="sh-btn" href="/shoes">Find a shoe</a></p></div>'}
++<section class="sh-sec sh-card" aria-labelledby="sh-new-watch"><h2 id="sh-new-watch">New watches</h2>
++<form class="sh-form sh-form-inline" method="post" action="/watches/defaults"><label class="sh-field"><span>Who can open a new watch</span><select name="audience">${audienceOptions(defaultAudience)}</select></label><label class="sh-check"><input type="checkbox" name="apply_all" value="1"> Apply to all my existing watches</label><button class="sh-btn" type="submit">Save</button></form>
++<p class="sh-fine">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>
++${list.length ? `<div class="sh-table-wrap"><table class="sh-table"><caption>Your watches, newest first</caption><thead><tr><th scope="col">Kind</th><th scope="col">What</th><th scope="col">Size</th><th scope="col">Below</th><th scope="col">Status</th><th scope="col">Created</th><th scope="col"></th></tr></thead><tbody>${list.map((w) => `<tr><td>${link(`/watches/${encodeURIComponent(w.id)}`, w.kind)}</td><td>${w.slug ? link(`/shoe/${encodeURIComponent(w.slug)}`, w.slug) : w.source_url ? `<a href="${esc(w.source_url)}" rel="noopener nofollow">${esc(w.source_url)}</a>` : '—'}${w.note ? `<br><small>${esc(w.note)}</small>` : ''}</td><td>${w.size ? esc(w.size) : 'any'}</td><td>${w.threshold != null ? money(w.threshold, w.currency) : '—'}</td><td>${esc(w.status)}${w.remote_id ? `<br><small>Watch ${esc(w.remote_id)}</small>` : ''}</td><td>${esc(dayShort(w.created_at))}</td><td><button type="button" class="sh-btn sh-btn-quiet" data-ov-share-sheet data-kind="watch" data-id="${esc(w.id)}" data-title="${esc(w.kind === 'price' ? 'Price watch' : 'Listing watch')}">Share</button></td></tr>`).join('')}</tbody></table></div>` : '<div class="sh-empty"><h2>No watch yet</h2><p>Open a shoe and set the price for your size, or paste a listing link on the home page.</p><p><a class="sh-btn" href="/shoes">Find a shoe</a></p></div>'}
</div>`;
return body;
}
++// ── One watch (its owner's, or a shared one) ────────────────
++const AUDIENCE_WORD = { public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' };
++function audienceOptions(selected = 'private') {
++ return ['private', 'people', 'link'].map((a) => `<option value="${a}"${a === selected ? ' selected' : ''}>${AUDIENCE_WORD[a]}</option>`).join('');
++}
++
++function watchPage({ watch, mine = false, audience = 'private', notice = null } = {}) {
++ const who = AUDIENCE_WORD[audience] || 'Only me';
++ const what = watch.slug ? link(`/shoe/${encodeURIComponent(watch.slug)}`, watch.slug) : watch.source_url ? `<a href="${esc(watch.source_url)}" rel="noopener nofollow">${esc(watch.source_url)}</a>` : '—';
++ return `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">${watch.kind === 'price' ? 'Price watch' : 'Listing watch'}</p><h1>${watch.kind === 'price' ? 'A price watch' : 'A listing watch'}</h1><p class="sh-lede"><span class="sh-badge">${esc(who)}</span>${mine ? '' : ' · you are reading a shared watch'}</p>${notice ? `<div class="sh-notice" role="status">${esc(notice)}</div>` : ''}
++${mine ? `<p><button type="button" class="sh-btn sh-btn-quiet" data-ov-share-sheet data-kind="watch" data-id="${esc(watch.id)}" data-title="${esc(watch.kind === 'price' ? 'Price watch' : 'Listing watch')}">Share</button></p>` : ''}</header>
++<section class="sh-sec sh-card"><dl class="sh-facts"><dt>What</dt><dd>${what}</dd><dt>Size</dt><dd>${watch.size ? esc(watch.size) : 'any'}</dd><dt>Below</dt><dd>${watch.threshold != null ? money(watch.threshold, watch.currency) : '—'}</dd><dt>Status</dt><dd>${esc(watch.status)}</dd><dt>Made</dt><dd>${esc(dayShort(watch.created_at))}</dd></dl></section>
++<p class="sh-fine"><a href="/watches">Your watches</a> · Every watch is carried by OpenVibe.Watch; Shoes never emails.</p></div>`;
++}
++
++/**
++ * 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 `<div class="sh-wrap sh-page" id="sh-watch-gate"><header class="sh-page-h"><p class="sh-kicker">Your watches</p><h1>A watch shared with specific people</h1><p class="sh-lede">Sign in with the OpenVibe account it was shared with, or ask its owner for access.</p></header>
++<div data-ov-share-gate data-kind="watch" data-id="${esc(id)}" data-signed-in-as="${esc(signedInAs)}" data-sign-in-url="${esc(signInUrl)}">This is shared with specific people. Sign in with the OpenVibe account it was shared with, or ask its owner for access.</div></div>`;
++}
++
// ── Developers ──────────────────────────────────────────────
function developers({ baseUrl = 'https://open.shoes' } = {}) {
const api = String(baseUrl).replace(/\/+$/, '');
@@@ -411,4 +438,4 @@ function notFound() {
return `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">404</p><h1>No such page</h1><p class="sh-lede">That shoe, drop or page is not here. The catalogue is at <a href="/shoes">/shoes</a>, the drops at <a href="/drops">/drops</a>.</p><p><a class="sh-btn" href="/">Home</a></p></header></div>`;
}
--module.exports = { home, shoesIndex, shoe, drops, watches, track, developers, signIn, notFound, modelCard, dropCard, groupDrops, money, historyChart, curveBars, offersTable };
++module.exports = { home, shoesIndex, shoe, drops, watches, watchPage, watchGate, track, developers, signIn, notFound, modelCard, dropCard, groupDrops, money, historyChart, curveBars, offersTable };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 0b51730..2a58a47 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -118,7 +118,21 @@ async function startNetwork() {
return json(200, { items: [], next_cursor: null });
}
-- if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });
++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {
++ const b = JSON.parse(raw || '{}');
++ const results = {};
++ for (const subjectId of b.subject_ids || []) {
++ const u = [...st.users.values()].find((x) => x.subject === subjectId);
++ results[subjectId] = u ? { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false } : null;
++ }
++ return json(200, { results });
++ }
++ if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {
++ const name = String(url.searchParams.get('username') || '').toLowerCase();
++ const u = [...st.users.values()].find((x) => String(x.username).toLowerCase() === name);
++ if (u) return json(200, { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name });
++ return json(404, { code: 'identity.not_found' });
++ }
if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
return json(404, { error: 'not found' });
ddiff --git a/test/sharing.test.js b/test/sharing.test.js
nnew file mode 100644
iindex 0000000..4b1aeaa
---- /dev/null
++++ b/test/sharing.test.js
@@@ -0,0 +1,125 @@
++'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 carries the Share button for its owner;
++ * - Anyone with the link reads the page and the API, and only its owner sees the list;
++ * - 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 { createCatalogue } = require('../server/catalogue');
++const views = require('../server/views');
++const { createApi } = require('../server/http/api');
++const { createPageRoutes } = require('../server/http/pages');
++const accountData = require('../server/identity/account-data');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the sharing wiring loads: watches are never Everyone', async () => {
++ assert.strictEqual(sharing.SERVICE, 'shoes');
++ assert.strictEqual(sharing.KIND, 'watch');
++ assert.deepStrictEqual(sharing.OFFERED, ['private', 'link', 'people']);
++ assert.strictEqual(sharing.shareViewer({ kind: 'anonymous' }), null);
++ assert.deepStrictEqual(sharing.shareViewer({ kind: 'user', subject: 'usr_x' }), { subject: { type: 'user', id: 'usr_x' }, staff: false });
++ assert.strictEqual(typeof createCatalogue, 'function');
++ assert.strictEqual(typeof createApi, 'function');
++ assert.strictEqual(typeof views.watchPage, 'function');
++ assert.strictEqual(typeof createPageRoutes, 'function');
++ assert.strictEqual(typeof accountData.create, 'function');
++ });
++ const kim = t.network.addUser('kim');
++ const bo = t.network.addUser('bo');
++ const cy = t.network.addUser('cy');
++
++ const call = (p, o = {}) => {
++ const { headers, ...rest } = o;
++ return t.get(p, { ...rest, headers: { 'x-requested-with': 'ov-share', ...(headers || {}) } });
++ };
++ const share = (id, o = {}) => call(`/api/v1/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.s.db.maybe(
++ 'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['shoes', 'watch', id]);
++ const newWatch = async (subject, note = 'a listing') => (await t.ctx.catalogue.createWatch({ subject, kind: 'link', sourceUrl: 'https://www.ebay.com/itm/1', status: 'active', note })).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.subject);
++ 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('https://www.ebay.com/itm/1'));
++ const other = await t.get(`/watches/${id}`, { as: bo });
++ assert.strictEqual(other.status, 404);
++ assert.ok(!other.text.includes('https://www.ebay.com/itm/1'), 'the watch is never shown 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 the list stays the owner\'s', 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('https://www.ebay.com/itm/1'));
++ assert.ok(guest.text.includes('Anyone with the link'));
++ assert.ok(!guest.text.includes('data-ov-share-sheet'), 'a reader gets no Share sheet');
++ const api = await t.get(`/api/v1/watches/${id}`);
++ assert.strictEqual(api.status, 200);
++ assert.strictEqual(api.json().watch.id, id);
++ assert.strictEqual((await t.get('/api/v1/me/watches', { as: bo })).json().watches.length, 0);
++ });
++
++ 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);
++ const viewer = await t.get(`/watches/${id}`, { as: bo });
++ assert.strictEqual(viewer.status, 200, viewer.text);
++ assert.ok(viewer.text.includes('Specific people'));
++ const stranger = t.network.addUser('di');
++ const gate = await t.get(`/watches/${id}`, { as: stranger });
++ assert.strictEqual(gate.status, 404);
++ assert.ok(gate.text.includes('shared with specific people'));
++ assert.ok(!gate.text.includes('https://www.ebay.com/itm/1'), '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: { audience: 'link', apply_all: '1' } });
++ assert.strictEqual(saved.status, 302, saved.text);
++ assert.deepStrictEqual(await audienceRow(id), { audience: 'link' });
++ const next = await newWatch(kim.subject, 'another');
++ 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('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); });