openvibers/openvibe.watch Commits
Watches: the one Share sheet, Only me by default (#218)
Changes
nnew file mode 100644@@@ -0,0 +1,89 @@++-- phase: expand++-- The one sharing system (openvibe-sdk/share) on a watchlist: its audience, the people it is shared with, the log and the++-- delivery queue the SDK writes. Kept verbatim from the SDK's own schema (node_modules/openvibe-sdk/src/share/schema.js++-- at the pinned version), so its store finds the tables it expects and the test database has them.++-- A watchlist's own row is trade_watchlists; one share row per watchlist, kind 'watchlist', id = wl_….++++++CREATE TABLE IF NOT EXISTS shares (++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),++ revision integer NOT NULL DEFAULT 1,++ lock text NOT NULL DEFAULT 'none',++ reseal_nonce_base text,++ PRIMARY KEY (service, kind, resource_id)++);++CREATE TABLE IF NOT EXISTS share_members (++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,++ key_slot jsonb,++ PRIMARY KEY (service, kind, resource_id, subject),++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE TABLE IF NOT EXISTS share_transfers (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),++ key_slot jsonb,++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';++CREATE TABLE IF NOT EXISTS share_access_requests (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';++CREATE TABLE IF NOT EXISTS share_log (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE++);++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;++DO $$ BEGIN++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();++ END IF;++END $$;++CREATE TABLE IF NOT EXISTS share_deliveries (++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE++);++CREATE TABLE IF NOT EXISTS share_name_limits (++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL++);++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the++-- slot a transfer was offered with. Repeating this migration is a no-op.++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';++DO $$ BEGIN++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));++EXCEPTION WHEN duplicate_object THEN NULL; END $$;++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;++++-- Visibility step 6 (design 2026-11-06): the person's default audience for 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;@@@ -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]));@@@ -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) => {@@@ -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@@@ -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 templateconst 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-youconst 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_Sreturn 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.idconst 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 replacesasync 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>@@@ -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')}">nnew file mode 100644@@@ -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 };@@@ -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(', ')}`);});@@@ -78,6 +78,22 @@ async function startNetwork({ privateKey, issuer = 'https://openvibe.network', creturn 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');nnew file mode 100644@@@ -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();
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+-- phase: expand
+-- The one sharing system (openvibe-sdk/share) on a watchlist: its audience, the people it is shared with, the log and the
+-- delivery queue the SDK writes. Kept verbatim from the SDK's own schema (node_modules/openvibe-sdk/src/share/schema.js
+-- at the pinned version), so its store finds the tables it expects and the test database has them.
+-- A watchlist's own row is trade_watchlists; one share row per watchlist, kind 'watchlist', id = wl_….
+
+
+CREATE TABLE IF NOT EXISTS shares (
+ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
+ revision integer NOT NULL DEFAULT 1,
+ lock text NOT NULL DEFAULT 'none',
+ reseal_nonce_base text,
+ PRIMARY KEY (service, kind, resource_id)
+);
+CREATE TABLE IF NOT EXISTS share_members (
+ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
+ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
+ key_slot jsonb,
+ PRIMARY KEY (service, kind, resource_id, subject),
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE TABLE IF NOT EXISTS share_transfers (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
+ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
+ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
+ key_slot jsonb,
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
+CREATE TABLE IF NOT EXISTS share_access_requests (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
+ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
+CREATE TABLE IF NOT EXISTS share_log (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
+ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
+);
+CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
+ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
+ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
+ END IF;
+END $$;
+CREATE TABLE IF NOT EXISTS share_deliveries (
+ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
+ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
+ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
+);
+CREATE TABLE IF NOT EXISTS share_name_limits (
+ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
+);
+CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
+CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
+CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
+-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
+-- slot a transfer was offered with. Repeating this migration is a no-op.
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
+DO $$ BEGIN
+ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
+ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
+
+-- Visibility step 6 (design 2026-11-06): the person's default audience for 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;
@@ -28,6 +28,25 @@ async function extraExport(db, subject) {
@@ -28,6 +28,25 @@ async function extraExport(db, subject) {
}
}
async function extraErase(t, subjects, counts) {
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[]))';
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, '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]));
counts.add(counts.erased, 'check_runs', await t.exec(`DELETE FROM check_runs WHERE watch_id IN ${mine}`, [subjects]));
@@ -22,7 +22,7 @@ const { http } = require('openvibe-contracts');
@@ -22,7 +22,7 @@ const { http } = require('openvibe-contracts');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
const { RegistryError, STATUSES } = require('../registry');
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 router = express.Router();
const read = [auth.requireCap(CAPS.read), limits.reads('watch.read')];
const read = [auth.requireCap(CAPS.read), limits.reads('watch.read')];
const manage = [auth.requireCap(CAPS.manage), limits.budget('watch.write')];
const manage = [auth.requireCap(CAPS.manage), limits.budget('watch.write')];
@@ -57,8 +57,18 @@ function watchesRouter({ registry, auth, limits, webhookSources = null }) {
@@ -57,8 +57,18 @@ function watchesRouter({ registry, auth, limits, webhookSources = null }) {
router.get('/api/v1/watches/:id', read, async (req, res) => {
router.get('/api/v1/watches/:id', read, async (req, res) => {
const row = await registry.get(req.params.id, req.principal);
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) => {
router.post('/api/v1/watches', manage, async (req, res) => {
@@ -20,6 +20,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -20,6 +20,7 @@ const cache = require('openvibe-shared/cache-policy');
const { createWatchReadiness, registerWatchGauges } = require('./observability');
const { createWatchReadiness, registerWatchGauges } = require('./observability');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
+const { createSharing } = require('./sharing');
const { watchesRouter } = require('./api/watches');
const { watchesRouter } = require('./api/watches');
const { resourceIndexRouter } = require('./api/resource-index');
const { resourceIndexRouter } = require('./api/resource-index');
const { resourceControlRouter } = require('./api/resource-control');
const { resourceControlRouter } = require('./api/resource-control');
@@ -61,7 +62,7 @@ const CSP = "default-src 'self'; script-src 'self' 'unsafe-inline' https://openv
@@ -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; "
+ "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";
+ "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();
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// (openvibe-contracts); the operations they mark internal-only are left out.
// (openvibe-contracts); the operations they mark internal-only are left out.
@@ -138,12 +139,34 @@ async function createApp({ config, db, registry, check, observations, scheduler,
@@ -138,12 +139,34 @@ async function createApp({ config, db, registry, check, observations, scheduler,
// ── Who is signed in (the session cookie; never a Network token) ──
// ── Who is signed in (the session cookie; never a Network token) ──
const sso = createSso({ config, keys: keyStore, sessions, fetchImpl, now: now || (() => Date.now()), log });
const sso = createSso({ config, keys: keyStore, sessions, fetchImpl, now: now || (() => Date.now()), log });
app.use(sso.middleware());
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) ──
// ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
app.use('/auth', sso.routes());
app.use('/auth', sso.routes());
// ── Static assets (content-hashed ?v= → immutable) ──
// ── Static assets (content-hashed ?v= → immutable) ──
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
+ // ── /api/v1/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
// 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.
// legal links answered 404 here). The facts must match the code: change both together.
const legal = require('openvibe-shared/legal');
const legal = require('openvibe-shared/legal');
@@ -175,7 +198,7 @@ async function createApp({ config, db, registry, check, observations, scheduler,
@@ -175,7 +198,7 @@ async function createApp({ config, db, registry, check, observations, scheduler,
app.use(resourceIndexRouter({ db, auth }));
app.use(resourceIndexRouter({ db, auth }));
// ...and its control API (watch.resource.control, ADR-048): suspend, resume and delete a person's watch.
// ...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(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 }));
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
// 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.
// 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,
@@ -197,7 +220,7 @@ async function createApp({ config, db, registry, check, observations, scheduler,
next();
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
// 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
// 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
// page's own render (signed in or out) and only a twin request reaches the agent layer. The manifest
@@ -150,8 +150,12 @@ function sameOrigin(req, baseUrl) {
@@ -150,8 +150,12 @@ function sameOrigin(req, baseUrl) {
try { return origin === new URL(baseUrl).origin; } catch { return false; }
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) {
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 r = express.Router();
const formBody = express.urlencoded({ extended: false, limit: '32kb' });
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
@@ -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 before = beforeId(req.query.before);
const rows = await registry.all(principalOf(req), { before, limit: PAGE });
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) })));
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 watchRows = withLatest.map(({ row, latest }) => {
const v = registry.view(row);
const v = registry.view(row);
const notArmed = v.status === 'paused' && row.armed_at == null;
const notArmed = v.status === 'paused' && row.armed_at == null;
return [
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>`,
html`<code>${W.host(v.source.url)}</code>`,
W.conditionSentence(v),
W.conditionSentence(v),
html`${badge(notArmed ? 'Not armed yet' : W.statusWords(v.status), notArmed ? 'warn' : W.statusKind(v.status))}`,
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
@@ -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 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, {
page(req, res, {
- title: 'Your watches', private: true,
+ title: 'Your watches', private: true, head: SHARE_HEAD,
body: html`<h1>Your watches</h1>
body: html`<h1>Your watches</h1>
<p><a class="btn primary" href="/watches/new?template=page">New watch</a></p>
<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.' })}
${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">
<nav class="pager" aria-label="Pages">
${before ? html`<a class="btn" href="/watches">Newest first</a>` : ''}
${before ? html`<a class="btn" href="/watches">Newest first</a>` : ''}
@@ -791,6 +805,59 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: CREATE_S
@@ -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>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>`);
<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 obsLimit = (q) => Math.min(Math.max(INT(q, 20), 1), 100);
const ridBefore = (q) => (/^\d{1,18}$/.test(String(q || '')) ? Number(q) : null);
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
@@ -816,10 +883,34 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: CREATE_S
return request.evidence || (request.body && request.body.evidence) || null;
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) => {
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 v = registry.view(row);
const latest = await observations.latest(row.id);
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) });
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
@@ -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;
const nextRuns = runs.length === obsLimit(req.query.checks_limit) ? `/watches/${v.id}?checks_before=${runs[runs.length - 1].rid}` : null;
page(req, res, {
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>
body: html`<h1>${v.name}</h1>
<p class="in-words">${W.inWords(v)}</p>
<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') : ''}
${ran ? notice(html`The check ran: <strong>${W.stateWords(ran)}</strong>.`, ran === 'changed' || ran === 'condition_met' ? 'ok' : 'info') : ''}
${testRun ? testCheckNotice(testRun, testObs, v) : ''}
${testRun ? testCheckNotice(testRun, testObs, v) : ''}
<dl class="facts">
<dl class="facts">
@@ -974,6 +1066,7 @@ ${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty:
@@ -974,6 +1066,7 @@ ${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty:
await registry.remove(row.id, principalOf(req));
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.
// 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 (ctx.webhookSources) await ctx.webhookSources.forget(row.id);
+ if (sharing) await sharing.forget(row.id);
return res.redirect(303, '/watches');
return res.redirect(303, '/watches');
});
});
@@ -1079,7 +1172,7 @@ ${notice('Copy the secret now: it is not shown again. Making a new one replaces
@@ -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) {
async function detailNotice(req, res, row, status, text) {
const v = registry.view(row);
const v = registry.view(row);
return page(req, res, {
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>
body: html`<h1>${v.name}</h1>
${notice(text, status === 401 || status === 409 ? 'warn' : 'info')}
${notice(text, status === 401 || status === 409 ? 'warn' : 'info')}
<p>${W.inWords(v)}</p>
<p>${W.inWords(v)}</p>
@@ -86,7 +86,7 @@ function renderPage(o) {
@@ -86,7 +86,7 @@ function renderPage(o) {
navLinks: NAV,
navLinks: NAV,
navbar: nav,
navbar: nav,
footer,
footer,
- head: `<meta name="referrer" content="strict-origin-when-cross-origin">
+ head: `${o.head || ''}<meta name="referrer" content="strict-origin-when-cross-origin">
${appIcon.headTags({ site: 'watch' })}
${appIcon.headTags({ site: 'watch' })}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/app.css')}">
new file mode 100644
new file mode 100644
@@ -0,0 +1,180 @@
@@ -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 };
@@ -60,7 +60,7 @@ t('boot, and the person\'s watches, checks and sessions', async () => {
@@ -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 () => {
t('every table that names a person is covered or exempted with a reason', async () => {
const missing = await uncovered(svc.db, {
const missing = await uncovered(svc.db, {
tables: TABLES,
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(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
});
});
@@ -78,6 +78,22 @@ async function startNetwork({ privateKey, issuer = 'https://openvibe.network', c
@@ -78,6 +78,22 @@ async function startNetwork({ privateKey, issuer = 'https://openvibe.network', c
return res.end();
return res.end();
}
}
if (url.pathname === '/oauth/revoke') return json(200, {});
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') {
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).
// The Network takes the token grant as JSON (openvibe-sdk/sso) or as a form (older clients).
const text = raw.toString('utf8');
const text = raw.toString('utf8');
@@ -85,6 +101,12 @@ async function startNetwork({ privateKey, issuer = 'https://openvibe.network', c
@@ -85,6 +101,12 @@ async function startNetwork({ privateKey, issuer = 'https://openvibe.network', c
? JSON.parse(text)
? JSON.parse(text)
: Object.fromEntries(new URLSearchParams(text));
: Object.fromEntries(new URLSearchParams(text));
if (body.client_id !== clientId || body.client_secret !== clientSecret) return problem(json, 401, 'invalid_client', 'bad client credentials');
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);
if (body.grant_type !== 'authorization_code') return problem(json, 400, 'unsupported_grant_type', body.grant_type);
const c = st.codes.get(body.code);
const c = st.codes.get(body.code);
if (!c) return problem(json, 400, 'invalid_grant', 'unknown code');
if (!c) return problem(json, 400, 'invalid_grant', 'unknown code');
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -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();