openvibers/openvibe.watch Commits

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

OP OpenVibers committed 6 days ago · b1d8ae9218 · 10 files, +578 −16

Visibility step 6 (design 2026-11-06): a watch 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. A shared
page shows the watch and what it observed, never its actions, webhook secret or
agent. The person's default for new watches lives on /watches with apply-to-all.
Account deletion erases the person's share rows and memberships.

Changes

ddiff --git a/migrations/0020_shares.sql b/migrations/0020_shares.sql
nnew file mode 100644
iindex 0000000..25d2711
---- /dev/null
++++ b/migrations/0020_shares.sql
@@@ -0,0 +1,89 @@
++-- phase: expand
++-- The one sharing system (openvibe-sdk/share) on a watchlist: its audience, the people it is shared with, the log and the
++-- delivery queue the SDK writes. Kept verbatim from the SDK's own schema (node_modules/openvibe-sdk/src/share/schema.js
++-- at the pinned version), so its store finds the tables it expects and the test database has them.
++-- A watchlist's own row is trade_watchlists; one share row per watchlist, kind 'watchlist', id = wl_….
++
++
++CREATE TABLE IF NOT EXISTS shares (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
++ revision integer NOT NULL DEFAULT 1,
++ lock text NOT NULL DEFAULT 'none',
++ reseal_nonce_base text,
++ PRIMARY KEY (service, kind, resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_members (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
++ key_slot jsonb,
++ PRIMARY KEY (service, kind, resource_id, subject),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_transfers (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
++ key_slot jsonb,
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_access_requests (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_log (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
++DO $$ BEGIN
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
++ END IF;
++END $$;
++CREATE TABLE IF NOT EXISTS share_deliveries (
++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE TABLE IF NOT EXISTS share_name_limits (
++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
++);
++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
++-- slot a transfer was offered with. Repeating this migration is a no-op.
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
++DO $$ BEGIN
++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
++EXCEPTION WHEN duplicate_object THEN NULL; END $$;
++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
++
++-- Visibility step 6 (design 2026-11-06): the person's default audience for 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 (a deleted watch needs
++-- no row). New watches are registered the first time their owner opens them (server/http/pages.js). Repeating this
++-- file is a no-op.
++
++CREATE TABLE IF NOT EXISTS watch_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 'watch', 'watch', w.id, jsonb_build_object('type', 'user', 'id', w.owner_sub), 'private'
++FROM watches w
++WHERE w.deleted_at IS NULL AND w.owner_sub LIKE 'usr_%'
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 0e5cf1b..f932cde 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -28,6 +28,25 @@ async function extraExport(db, subject) {
}
async function extraErase(t, subjects, counts) {
++ // Sharing (design 2026-11-06, step 6): the person's share rows and default go, and so do their memberships in
++ // somebody else's shared watch, or a deleted account would linger in a member list. Children first.
++ for (const subject of subjects) {
++ const id = String(subject).replace(/^user:/, '');
++ await t.exec('DELETE FROM watch_share_defaults WHERE subject = $1', [id]);
++ await t.exec("DELETE FROM share_members WHERE service='watch' AND subject->>'id' = $1", [id]);
++ await t.exec("DELETE FROM share_transfers WHERE service='watch' AND (from_subject->>'id' = $1 OR to_subject->>'id' = $1)", [id]);
++ await t.exec("DELETE FROM share_access_requests WHERE service='watch' AND subject->>'id' = $1", [id]);
++ }
++ for (const subject of subjects) {
++ const id = String(subject).replace(/^user:/, '');
++ const owned = await t.many("SELECT resource_id FROM shares WHERE service='watch' AND owner_subject->>'id' = $1", [id]);
++ for (const row of owned) {
++ await t.exec("DELETE FROM share_members WHERE service='watch' AND resource_id=$1", [row.resource_id]);
++ await t.exec("DELETE FROM share_transfers WHERE service='watch' AND resource_id=$1", [row.resource_id]);
++ await t.exec("DELETE FROM share_access_requests WHERE service='watch' AND resource_id=$1", [row.resource_id]);
++ await t.exec("DELETE FROM shares WHERE service='watch' AND resource_id=$1", [row.resource_id]);
++ }
++ }
const mine = '(SELECT id FROM watches WHERE owner_sub = ANY($1::text[]))';
counts.add(counts.erased, 'observations', await t.exec(`DELETE FROM observations WHERE watch_id IN ${mine}`, [subjects]));
counts.add(counts.erased, 'check_runs', await t.exec(`DELETE FROM check_runs WHERE watch_id IN ${mine}`, [subjects]));
ddiff --git a/server/api/watches.js b/server/api/watches.js
iindex f814a80..24e3e25 100644
---- a/server/api/watches.js
++++ b/server/api/watches.js
@@@ -22,7 +22,7 @@ const { http } = require('openvibe-contracts');
const { CAPS } = require('../auth');
const { RegistryError, STATUSES } = require('../registry');
--function watchesRouter({ registry, auth, limits, webhookSources = null }) {
++function watchesRouter({ registry, auth, limits, webhookSources = null, sharing = null }) {
const router = express.Router();
const read = [auth.requireCap(CAPS.read), limits.reads('watch.read')];
const manage = [auth.requireCap(CAPS.manage), limits.budget('watch.write')];
@@@ -57,8 +57,18 @@ function watchesRouter({ registry, auth, limits, webhookSources = null }) {
router.get('/api/v1/watches/:id', read, async (req, res) => {
const row = await registry.get(req.params.id, req.principal);
-- if (!row) return notFound(res, req);
-- res.json({ watch: registry.view(row) });
++ if (row) return res.json({ watch: registry.view(row) });
++ // A watch shared with this person (design 2026-11-06, step 6): the person's own token, never a service's
++ // subject, and the one check is canView. Another owner's private watch is still 404, never 403.
++ const person = req.principal && (req.principal.kind === 'person' ? req.principal.sub : null);
++ if (sharing && person) {
++ const ok = await sharing.canView({ kind: 'user', subject: person }, req.params.id).catch(() => false);
++ if (ok) {
++ const any = await registry.get(req.params.id, {});
++ if (any) return res.json({ watch: registry.view(any) });
++ }
++ }
++ return notFound(res, req);
});
router.post('/api/v1/watches', manage, async (req, res) => {
ddiff --git a/server/app.js b/server/app.js
iindex a653a33..e3f1909 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -20,6 +20,7 @@ const cache = require('openvibe-shared/cache-policy');
const { createWatchReadiness, registerWatchGauges } = require('./observability');
const { createSso } = require('./auth/sso');
const { createPageRoutes } = require('./http/pages');
++const { createSharing } = require('./sharing');
const { watchesRouter } = require('./api/watches');
const { resourceIndexRouter } = require('./api/resource-index');
const { resourceControlRouter } = require('./api/resource-control');
@@@ -61,7 +62,7 @@ const CSP = "default-src 'self'; script-src 'self' 'unsafe-inline' https://openv
+ "connect-src 'self' https://openvibe.network https://openvibe.events https://cloudflareinsights.com; "
+ "frame-src 'self' https://openvibe.network; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self' https://openvibe.network";
--async function createApp({ config, db, registry, check, observations, scheduler, auth, outbox, actions = null, now, log = console, limitsNow = null, sessions, keyStore, siteLimits, fetchImpl = globalThis.fetch, accountData = null, accountSend = null, eventSources = null, webhookSources = null, billing = null, providerRates = null, env = process.env, onConfigActivate = null }) {
++async function createApp({ config, db, registry, check, observations, scheduler, auth, outbox, actions = null, now, log = console, limitsNow = null, sessions, keyStore, siteLimits, fetchImpl = globalThis.fetch, accountData = null, accountSend = null, eventSources = null, webhookSources = null, billing = null, providerRates = null, env = process.env, onConfigActivate = null, sharing = null }) {
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// (openvibe-contracts); the operations they mark internal-only are left out.
@@@ -138,12 +139,34 @@ async function createApp({ config, db, registry, check, observations, scheduler,
// ── Who is signed in (the session cookie; never a Network token) ──
const sso = createSso({ config, keys: keyStore, sessions, fetchImpl, now: now || (() => Date.now()), log });
app.use(sso.middleware());
++ // The one sharing system (design 2026-11-06): a watch is its owner's until the Share sheet says otherwise.
++ const sharingSystem = sharing || createSharing({ db, config, fetchImpl, log });
// ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
app.use('/auth', sso.routes());
// ── Static assets (content-hashed ?v= → immutable) ──
app.use('/shared', require('openvibe-shared/serve').handler());
++ // ── /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 the watch's page; the router answers only its
++ // own paths. The sheet is a browser, so it presents the session cookie (req.viewer).
++ {
++ const shareApi = express.Router();
++ shareApi.use(sharingSystem.shares.router({
++ service: 'watch',
++ authenticate: (req) => sharingSystem.shareViewer(req.viewer),
++ describe: sharingSystem.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.
++ {
++ const timer = setInterval(() => { sharingSystem.work().catch(() => { /* logged inside */ }); }, 15_000);
++ if (timer.unref) timer.unref();
++ app.locals.sharingTimer = timer;
++ }
// Terms, Privacy and DMCA in this site's own words (openvibe-shared/legal, profile 'service'; 11-03: the footer's
// legal links answered 404 here). The facts must match the code: change both together.
const legal = require('openvibe-shared/legal');
@@@ -175,7 +198,7 @@ async function createApp({ config, db, registry, check, observations, scheduler,
app.use(resourceIndexRouter({ db, auth }));
// ...and its control API (watch.resource.control, ADR-048): suspend, resume and delete a person's watch.
app.use(resourceControlRouter({ db, auth, registry, now }));
-- app.use(watchesRouter({ registry, auth, limits, webhookSources }));
++ app.use(watchesRouter({ registry, auth, limits, webhookSources, sharing: sharingSystem }));
app.use(checksRouter({ registry, check, observations, auth, limits }));
// Provider-rate evidence (plan P6, server/api/provider-rates.js): mounted only with WATCH_PROVIDER_RATES=1. With
// the flag off every path below is the API's ordinary 404 and no timer exists.
@@@ -197,7 +220,7 @@ async function createApp({ config, db, registry, check, observations, scheduler,
next();
});
-- const pageRoutes = createPageRoutes({ config, db, registry, check, observations, sessions, siteLimits, actions, log, webhookSources });
++ const pageRoutes = createPageRoutes({ config, db, registry, check, observations, sessions, siteLimits, actions, log, webhookSources, sharing: sharingSystem });
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the
// public pages. Mounted before the page routes but gated to twin requests only, so a browser keeps the
// page's own render (signed in or out) and only a twin request reaches the agent layer. The manifest
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 446394d..ad9b900 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -150,8 +150,12 @@ function sameOrigin(req, baseUrl) {
try { return origin === new URL(baseUrl).origin; } catch { return false; }
}
++// 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 createPageRoutes(ctx) {
-- const { config, db, registry, check, observations, sessions, actions = null, log = console } = ctx; // ctx.webhookSources: the webhook template
++ const { config, db, registry, check, observations, sessions, actions = null, log = console, sharing = null } = ctx; // ctx.webhookSources: the webhook template
const r = express.Router();
const formBody = express.urlencoded({ extended: false, limit: '32kb' });
@@@ -669,11 +673,14 @@ ${summary ? html`<section class="sc-sec" aria-labelledby="h-yours"><h2 id="h-you
const before = beforeId(req.query.before);
const rows = await registry.all(principalOf(req), { before, limit: PAGE });
const withLatest = await Promise.all(rows.map(async (row) => ({ row, latest: await observations.latest(row.id) })));
++ // Every watch on this page gets its share row (Only me until the person shares it), so its Share button works.
++ await Promise.all(withLatest.map(({ row }) => ensureShare(req.viewer.subject, row.id)));
++ const defaultAudience = sharing ? await sharing.defaultOf(req.viewer.subject).catch(() => 'private') : 'private';
const watchRows = withLatest.map(({ row, latest }) => {
const v = registry.view(row);
const notArmed = v.status === 'paused' && row.armed_at == null;
return [
-- html`<a href="/watches/${row.id}">${v.name}</a>`,
++ html`<a href="/watches/${row.id}">${v.name}</a> <button type="button" class="btn small" data-ov-share-sheet data-kind="watch" data-id="${row.id}" data-title="${v.name}">Share</button>`,
html`<code>${W.host(v.source.url)}</code>`,
W.conditionSentence(v),
html`${badge(notArmed ? 'Not armed yet' : W.statusWords(v.status), notArmed ? 'warn' : W.statusKind(v.status))}`,
@@@ -685,10 +692,17 @@ ${summary ? html`<section class="sc-sec" aria-labelledby="h-yours"><h2 id="h-you
];
});
const older = rows.length === PAGE ? `/watches?before=${rows[rows.length - 1].id}` : null;
++ 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>`);
page(req, res, {
-- title: 'Your watches', private: true,
++ title: 'Your watches', private: true, head: SHARE_HEAD,
body: html`<h1>Your watches</h1>
<p><a class="btn primary" href="/watches/new?template=page">New watch</a></p>
++<section class="card" aria-labelledby="h-new-aud"><h2 id="h-new-aud">New watches</h2>
++<form method="post" action="/watches/defaults" class="inline"><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 class="btn" type="submit">Save</button></form>
++<p class="muted small">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>
${table(['Name', 'Source', 'Condition', 'Status', 'Last check', 'Last value', 'Next due'], watchRows, { empty: 'You have no watches yet. Start with one of the four kinds on the home page.' })}
<nav class="pager" aria-label="Pages">
${before ? html`<a class="btn" href="/watches">Newest first</a>` : ''}
@@@ -791,6 +805,59 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: CREATE_S
<p>There is no watch here with that id, or it belongs to someone else. A watch is private to the account that made it.</p>
<p><a href="/watches">Your watches</a></p>`);
++ /** A watch's share row, made at the owner's default the first time its owner opens it (or the list). */
++ async function ensureShare(subject, id) {
++ if (!sharing) return 'private';
++ const row = await sharing.shareOf(id).catch(() => null);
++ if (row) return row.audience;
++ const audience = await sharing.defaultOf(subject).catch(() => 'private');
++ await sharing.register(subject, id, audience).catch(() => {});
++ return audience;
++ }
++
++ /** The page a reader of a shared watch sees: the watch and its observations, never its actions or secrets. */
++ async function sharedWatchPage(req, res, row) {
++ const id = String(row.id);
++ const v = registry.view(row);
++ const audience = sharing ? await sharing.audienceOf(id).catch(() => null) : null;
++ const latest = await observations.latest(row.id);
++ const obs = await observations.list(row.id, { limit: 20 });
++ const obsRows = obs.map((o) => [
++ time(new Date(Number(o.observed_at)).toISOString()),
++ W.valueWords(o.value, 120),
++ o.changed ? badge('changed', 'info') : html`<span class="muted">no</span>`,
++ o.condition_met ? badge('met', 'ok') : html`<span class="muted">no</span>`,
++ ]);
++ const who = { public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' }[audience] || 'Only me';
++ return page(req, res, {
++ title: v.name, private: true, head: SHARE_HEAD, crumbs: [{ label: 'A shared watch', href: '/' }, { label: v.name }],
++ body: html`<h1>${v.name}</h1>
++<p class="in-words">${W.inWords(v)}</p>
++<p class="muted"><span class="badge">${who}</span> · a watch shared on OpenVibe.Watch. What it does when it fires is its owner's business and is not shown here.</p>
++<dl class="facts">
++<dt>Status</dt><dd>${badge(W.statusWords(v.status), W.statusKind(v.status))}</dd>
++<dt>Source</dt><dd><code>${v.source.kind}</code> · <code>${v.source.url || (v.source.pattern ? v.source.pattern : '(none)')}</code></dd>
++<dt>Cadence</dt><dd>${v.cadence ? W.everyWords(v.cadence.every_sec) : 'when its event arrives'}</dd>
++<dt>Last check</dt><dd>${v.health.last_check_at ? html`${time(v.health.last_check_at)} — ${W.stateWords(v.health.last_state)}` : html`<span class="muted">not checked yet</span>`}</dd>
++<dt>Last value</dt><dd>${latest ? W.valueWords(latest.value, 200) : html`<span class="muted">nothing recorded yet</span>`}</dd>
++</dl>
++<section aria-labelledby="h-obs"><h2 id="h-obs">Observation history</h2>
++${table(['When', 'Value', 'Changed', 'Condition'], obsRows, { empty: 'No observations yet.' })}</section>
++<p class="muted small"><a href="/watches">Your watches</a></p>`,
++ });
++ }
++
++ /** The page a person not on a Specific-people watch's list sees: the audience, never the watch's name or source. */
++ function sharedWatchGate(req, res, id) {
++ const who = signedIn(req) ? (viewer(req).username || '') : '';
++ return page(req, res, {
++ title: 'A watch shared with specific people', private: true, head: SHARE_HEAD,
++ body: 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 data-ov-share-gate data-kind="watch" data-id="${id}" data-signed-in-as="${who}" data-sign-in-url="/auth/login?next=${encodeURIComponent(req.originalUrl)}">This is shared with specific people. Sign in with the OpenVibe account it was shared with, or ask its owner for access.</div>`,
++ }, 404);
++ }
++
const obsLimit = (q) => Math.min(Math.max(INT(q, 20), 1), 100);
const ridBefore = (q) => (/^\d{1,18}$/.test(String(q || '')) ? Number(q) : null);
@@@ -816,10 +883,34 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: CREATE_S
return request.evidence || (request.body && request.body.evidence) || null;
}
++ r.post('/watches/defaults', formBody, async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fwatches');
++ if (!sameOrigin(req, config.baseUrl)) return crossSite(req, res, 'Changing');
++ if (sharing) {
++ const audience = ['private', 'people', 'link', 'public'].includes(req.body.audience) ? req.body.audience : 'private';
++ await sharing.setDefault(req.viewer.subject, audience, { applyAll: String(req.body.apply_all || '') === '1' })
++ .catch((err) => log.warn('[Watch] the watch default was not saved:', err.message));
++ }
++ return res.redirect(303, '/watches');
++ });
++
r.get('/watches/:id', async (req, res) => {
-- if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
-- const row = await owned(req, res);
-- if (!row) return notFoundPage(req, res);
++ const id = String(req.params.id || '');
++ const row = signedIn(req) ? await owned(req, res) : null;
++ if (!row) {
++ // A shared watch (design 2026-11-06, step 6): its owner's, or a reader it was shared with. A reader sees the
++ // watch and what it observed, never its actions or secrets. A Specific-people watch answers the gate.
++ const canView = sharing ? await sharing.canView(viewer(req), id).catch(() => false) : false;
++ if (canView) {
++ const any = await registry.get(id, {});
++ if (any) return await sharedWatchPage(req, res, any);
++ }
++ const audience = sharing ? await sharing.audienceOf(id).catch(() => null) : null;
++ if (audience === 'people') return sharedWatchGate(req, res, id);
++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ return notFoundPage(req, res);
++ }
++ const audience = await ensureShare(req.viewer.subject, row.id);
const v = registry.view(row);
const latest = await observations.latest(row.id);
const obs = await observations.list(row.id, { before: ridBefore(req.query.obs_before), limit: obsLimit(req.query.obs_limit) });
@@@ -887,9 +978,10 @@ ${unarmed ? '' : html`<form class="inline" method="post" action="/watches/${v.id
const nextRuns = runs.length === obsLimit(req.query.checks_limit) ? `/watches/${v.id}?checks_before=${runs[runs.length - 1].rid}` : null;
page(req, res, {
-- title: v.name, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name }],
++ title: v.name, private: true, head: SHARE_HEAD, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name }],
body: html`<h1>${v.name}</h1>
<p class="in-words">${W.inWords(v)}</p>
++<p class="muted"><span class="badge">${({ public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' })[audience] || 'Only me'}</span> <button type="button" class="btn small" data-ov-share-sheet data-kind="watch" data-id="${v.id}" data-title="${v.name}">Share</button></p>
${ran ? notice(html`The check ran: <strong>${W.stateWords(ran)}</strong>.`, ran === 'changed' || ran === 'condition_met' ? 'ok' : 'info') : ''}
${testRun ? testCheckNotice(testRun, testObs, v) : ''}
<dl class="facts">
@@@ -974,6 +1066,7 @@ ${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty:
await registry.remove(row.id, principalOf(req));
// As the API's delete does: its webhook secret or address goes, and a YouTube hub is told to stop.
if (ctx.webhookSources) await ctx.webhookSources.forget(row.id);
++ if (sharing) await sharing.forget(row.id);
return res.redirect(303, '/watches');
});
@@@ -1079,7 +1172,7 @@ ${notice('Copy the secret now: it is not shown again. Making a new one replaces
async function detailNotice(req, res, row, status, text) {
const v = registry.view(row);
return page(req, res, {
-- title: v.name, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name }],
++ title: v.name, private: true, head: SHARE_HEAD, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name }],
body: html`<h1>${v.name}</h1>
${notice(text, status === 401 || status === 409 ? 'warn' : 'info')}
<p>${W.inWords(v)}</p>
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex a7a88eb..3171bd7 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -86,7 +86,7 @@ function renderPage(o) {
navLinks: NAV,
navbar: nav,
footer,
-- head: `<meta name="referrer" content="strict-origin-when-cross-origin">
++ head: `${o.head || ''}<meta name="referrer" content="strict-origin-when-cross-origin">
${appIcon.headTags({ site: 'watch' })}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
<link rel="stylesheet" href="${asset('css/app.css')}">
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..45fcd91
---- /dev/null
++++ b/server/sharing.js
@@@ -0,0 +1,180 @@
++'use strict';
++
++/**
++ * Sharing on OpenVibe.Watch (design 2026-11-06, visibility step 6), through openvibe-sdk/share.
++ *
++ * A watch 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 asks the one check, canView, and
++ * shows only the watch and what it observed — never its actions, webhook secret or agent. 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 = 'watch';
++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) });
++
++/** Watch's 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.Watch.'],
++ 'share.access_requested': ['Someone asked for access to your watch', 'Review the request on OpenVibe.Watch.'],
++ 'share.transfer.requested': ['Someone wants to give you a watch', 'Accept or decline it on OpenVibe.Watch.'],
++ 'share.transfer.answered': ['A watch transfer was answered', 'See what happened on OpenVibe.Watch.'],
++ }[n.kind] || ['Sharing on OpenVibe.Watch', '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 watch_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 watch_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, name FROM watches WHERE id = $1 AND deleted_at IS NULL', [String(ref.id)]);
++ if (!row) return {};
++ return {
++ title: String(row.name || 'A watch').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/test/account-data.test.js b/test/account-data.test.js
iindex d917488..682c086 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -60,7 +60,7 @@ t('boot, and the person\'s watches, checks and sessions', async () => {
t('every table that names a person is covered or exempted with a reason', async () => {
const missing = await uncovered(svc.db, {
tables: TABLES,
-- exempt: { account_data_events: 'the ledger of requests itself: which export and deletion Watch applied', config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data' },
++ exempt: { account_data_events: 'the ledger of requests itself: which export and deletion Watch applied', config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data', share_members: "the SDK's own sharing model: subject is an added person, erased by extraErase", share_access_requests: "the SDK's own sharing model: subject is the person who asked, erased by extraErase", watch_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(', ')}`);
});
ddiff --git a/test/helpers/network.js b/test/helpers/network.js
iindex b39da4b..ab6aebd 100644
---- a/test/helpers/network.js
++++ b/test/helpers/network.js
@@@ -78,6 +78,22 @@ async function startNetwork({ privateKey, issuer = 'https://openvibe.network', c
return res.end();
}
if (url.pathname === '/oauth/revoke') return json(200, {});
++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {
++ let b = {}; try { b = JSON.parse(raw.toString('utf8') || '{}'); } catch { /* */ }
++ const results = {};
++ for (const subjectId of b.subject_ids || []) {
++ const u = [...st.users.values()].find((x) => x.subject === subjectId);
++ results[subjectId] = u ? { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false } : null;
++ }
++ return json(200, { results });
++ }
++ if (url.pathname === '/internal/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 });
if (url.pathname === '/oauth/token' && req.method === 'POST') {
// The Network takes the token grant as JSON (openvibe-sdk/sso) or as a form (older clients).
const text = raw.toString('utf8');
@@@ -85,6 +101,12 @@ async function startNetwork({ privateKey, issuer = 'https://openvibe.network', c
? JSON.parse(text)
: Object.fromEntries(new URLSearchParams(text));
if (body.client_id !== clientId || body.client_secret !== clientSecret) return problem(json, 401, 'invalid_client', 'bad client credentials');
++ // A first-party service's own token (the sharing SDK resolves @names and sends notifications with it).
++ if (body.grant_type === 'client_credentials') {
++ const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
++ const access_token = serviceAuth.signServiceToken({ iss: issuer, sub: `svc:${body.client_id}`, actor_type: 'service', aud: [body.audience || 'openvibe.network'], cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, privateKey);
++ return json(200, { access_token, token_type: 'Bearer', expires_in: 300 });
++ }
if (body.grant_type !== 'authorization_code') return problem(json, 400, 'unsupported_grant_type', body.grant_type);
const c = st.codes.get(body.code);
if (!c) return problem(json, 400, 'invalid_grant', 'unknown code');
ddiff --git a/test/sharing.test.js b/test/sharing.test.js
nnew file mode 100644
iindex 0000000..34e7311
---- /dev/null
++++ b/test/sharing.test.js
@@@ -0,0 +1,126 @@
++'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's page and its API.
++ *
++ * - a watch is Only me; its page and the list carry the Share button for its owner;
++ * - Anyone with the link reads the page, which shows the watch and what it observed, never its actions or secrets;
++ * - Specific people shows the gate, never the watch's name, to somebody not on the list; the API answers 404;
++ * - 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, request, site, suite, watchDef } = require('./helpers');
++const sharing = require('../server/sharing');
++const { watchesRouter } = require('../server/api/watches');
++const { createPageRoutes } = require('../server/http/pages');
++const layout = require('../server/render/layout');
++
++const t = suite('sharing');
++
++t('the sharing wiring loads: watches are never Everyone', async () => {
++ assert.strictEqual(sharing.SERVICE, 'watch');
++ 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 watchesRouter, 'function');
++ assert.strictEqual(typeof createPageRoutes, 'function');
++ assert.strictEqual(typeof layout.renderPage, 'function');
++});
++
++let svc;
++let web;
++const call = (p, o = {}) => {
++ const { headers, ...rest } = o;
++ return svc.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 svc.db.maybe(
++ 'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['watch', 'watch', id]);
++
++t('boot', async () => {
++ web = await site({ '/page': () => ({ body: 'value 1' }) });
++ svc = await boot();
++});
++
++let kim; let bo; let cy; let id;
++
++t('a new watch is Only me; its owner sees the Share button', async () => {
++ kim = svc.network.addUser('kim');
++ bo = svc.network.addUser('bo');
++ cy = svc.network.addUser('cy');
++ const row = await svc.registry.create(watchDef({ name: 'Stub watch' }), { sub: kim.subject });
++ id = row.id;
++ assert.strictEqual(await audienceRow(id), null, 'no row until its owner opens it');
++ const own = await svc.get(`/watches/${id}`, { as: kim });
++ assert.strictEqual(own.status, 200, own.text);
++ assert.deepStrictEqual(await audienceRow(id), { audience: 'private' });
++ assert.ok(own.text.includes('data-ov-share-sheet') && own.text.includes('data-kind="watch"'));
++ assert.ok(own.text.includes('ov-share.js'));
++ const list = await svc.get('/watches', { as: kim });
++ assert.strictEqual(list.status, 200, list.text);
++ assert.ok(list.text.includes('Who can open a new watch'));
++ assert.ok(list.text.includes('data-kind="watch"'));
++ const other = await svc.get(`/watches/${id}`, { as: bo });
++ assert.strictEqual(other.status, 404);
++ assert.ok(!other.text.includes('Stub watch'), 'the watch is never named to somebody else');
++ assert.strictEqual((await svc.get(`/api/v1/watches/${id}`, { bearer: svc.network.userToken(bo) })).status, 404);
++});
++
++t('Anyone with the link reads the page, without the watch\'s actions or secrets', 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 svc.get(`/watches/${id}`);
++ assert.strictEqual(guest.status, 200, guest.text);
++ assert.ok(guest.text.includes('Stub watch'));
++ 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('/pause') && !guest.text.includes('/edit'), 'a reader gets no controls');
++ assert.ok(!guest.text.includes('What it did when it fired'), 'actions are not shown');
++});
++
++t('Specific people: a viewer reads on the page and through the API; 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 svc.get(`/watches/${id}`, { as: bo });
++ assert.strictEqual(viewer.status, 200, viewer.text);
++ assert.ok(viewer.text.includes('Stub watch'));
++ assert.ok(viewer.text.includes('Specific people'));
++ const api = await svc.get(`/api/v1/watches/${id}`, { bearer: svc.network.userToken(bo) });
++ assert.strictEqual(api.status, 200, api.text);
++ assert.strictEqual(api.json().watch.id, id);
++ const stranger = svc.network.addUser('di');
++ const gate = await svc.get(`/watches/${id}`, { as: stranger });
++ assert.strictEqual(gate.status, 404);
++ assert.ok(gate.text.includes('shared with specific people'));
++ assert.ok(!gate.text.includes('Stub watch'), 'the gate never shows the name');
++ assert.ok(gate.text.includes('data-ov-share-gate'));
++ assert.strictEqual((await svc.get(`/api/v1/watches/${id}`, { bearer: svc.network.userToken(stranger) })).status, 404);
++});
++
++t('the default applies to the next watch, and "apply to all" moves the existing one', async () => {
++ const saved = await svc.get('/watches/defaults', { as: kim, form: { audience: 'link', apply_all: '1' } });
++ assert.strictEqual(saved.status, 303, saved.text);
++ assert.deepStrictEqual(await audienceRow(id), { audience: 'link' });
++ const next = await svc.registry.create(watchDef({ name: 'Second watch' }), { sub: kim.subject });
++ await svc.get(`/watches/${next.id}`, { as: kim });
++ assert.deepStrictEqual(await audienceRow(next.id), { audience: 'link' });
++});
++
++t('a watch is never on a gathered page: not the sitemap, not llms.txt', async () => {
++ assert.ok(!(await svc.get('/sitemap.xml')).text.includes(id));
++ assert.ok(!(await svc.get('/llms.txt')).text.includes(id));
++});
++
++t('stop', async () => { await svc.stop(); await web.close(); });
++
++t.run();