openvibers/openvibe.trade Commits
Watchlists: the one Share sheet, Only me by default (#217)
Changes
nnew file mode 100644@@@ -0,0 +1,88 @@++-- phase: expand++-- The one sharing system (openvibe-sdk/share) on a watchlist: its audience, the people it is shared with, the log and the++-- delivery queue the SDK writes. Kept verbatim from the SDK's own schema (node_modules/openvibe-sdk/src/share/schema.js++-- at the pinned version), so its store finds the tables it expects and the test database has them.++-- A watchlist's own row is trade_watchlists; one share row per watchlist, kind 'watchlist', id = wl_….++++++CREATE TABLE IF NOT EXISTS shares (++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),++ revision integer NOT NULL DEFAULT 1,++ lock text NOT NULL DEFAULT 'none',++ reseal_nonce_base text,++ PRIMARY KEY (service, kind, resource_id)++);++CREATE TABLE IF NOT EXISTS share_members (++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,++ key_slot jsonb,++ PRIMARY KEY (service, kind, resource_id, subject),++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE TABLE IF NOT EXISTS share_transfers (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),++ key_slot jsonb,++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';++CREATE TABLE IF NOT EXISTS share_access_requests (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';++CREATE TABLE IF NOT EXISTS share_log (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE++);++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;++DO $$ BEGIN++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();++ END IF;++END $$;++CREATE TABLE IF NOT EXISTS share_deliveries (++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE++);++CREATE TABLE IF NOT EXISTS share_name_limits (++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL++);++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the++-- slot a transfer was offered with. Repeating this migration is a no-op.++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';++DO $$ BEGIN++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));++EXCEPTION WHEN duplicate_object THEN NULL; END $$;++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;++++-- Visibility step 6 (design 2026-11-06): the person's default audience for new watchlists, and one share row for every++-- watchlist that already exists. A watchlist was private to its owner, so every one starts as Only me. New watchlists++-- are registered as they are made (server/domain/watchlists.js); a list without a row still answers its owner only,++-- because every read asks canView and a missing row falls back to the old rule. Repeating this file is a no-op.++++CREATE TABLE IF NOT EXISTS trade_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 'trade', 'watchlist', w.id, jsonb_build_object('type', 'user', 'id', w.owner_subject), 'private'++FROM trade_watchlists w++ON CONFLICT (service, kind, resource_id) DO NOTHING;@@@ -42,6 +42,7 @@ const { createObservations } = require('./domain/observations');const { createDocuments } = require('./domain/documents');const { createAlerts } = require('./domain/alerts');const { createWatchlists } = require('./domain/watchlists');++const { createSharing } = require('./sharing');const { createContext } = require('./domain/context');const { createIndexing } = require('./domain/indexing');const { createReading } = require('./domain/reading');@@@ -93,6 +94,8 @@ async function createApp(opts = {}) {ctx.alerts = createAlerts({ store, config, ctx });ctx.observations = createObservations({ store, ctx });ctx.documents = createDocuments({ store, ctx });++ // The one sharing system (design 2026-11-06): a watchlist is its owner's until the Share sheet says otherwise.++ ctx.sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl, log });ctx.watchlists = createWatchlists({ store, config, ctx });ctx.context = createContext({ store, config, ctx });ctx.indexing = createIndexing({ store, config, ctx, indexnow });@@@ -234,6 +237,27 @@ async function createApp(opts = {}) {}));// ── API ─────────────────────────────────────────────────++ // ── /api/v1/watchlist/:id/share — the one Share sheet's API (openvibe-sdk/share) ──++ // GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people++ // and /api/v1/shared-with-me. Access is still decided by canView on every read path; the router answers only++ // its own paths.++ {++ const shareApi = express.Router();++ shareApi.use(ctx.viewers.middleware());++ shareApi.use(ctx.sharing.shares.router({++ service: 'trade',++ authenticate: (req) => ctx.sharing.shareViewer(req.viewer),++ describe: ctx.sharing.describe,++ }));++ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|[^/]+\/[^/]+\/share(?:\/|$))/;++ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));++ }++ // Expire members and transfers and send queued share events/notifications. Unref'd: it never holds a test open.++ if (opts.startSharingWorker !== false) {++ const timer = setInterval(() => { ctx.sharing.work().catch(() => { /* logged inside */ }); }, 15_000);++ if (timer.unref) timer.unref();++ app.locals.sharingTimer = timer;++ }app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));// ── Pages (the viewer is resolved for browsers only; service tokens are not identities here) ──@@@ -66,6 +66,31 @@ async function extraErase(t, subjects, counts) {function create({ db, log = console } = {}) {return createAccountData({db, service: 'trade', tables: TABLES, extraExport, extraErase, log,++ /**++ * A share row names its owner and its members by canonical subject, which a table map cannot cover: the++ * watchlist's own rows go, and so must its share row and everyone it named, or a deleted account would++ * linger in somebody else's member list. Children first: members, transfers and requests point at shares.++ */++ extraErase: async (t, subjects, counts) => {++ await extraErase(t, subjects, counts);++ for (const subject of subjects) {++ const id = String(subject).replace(/^user:/, '');++ await t.exec('DELETE FROM trade_share_defaults WHERE subject = $1', [id]);++ await t.exec("DELETE FROM share_members WHERE service='trade' AND subject->>'id' = $1", [id]);++ await t.exec("DELETE FROM share_transfers WHERE service='trade' AND (from_subject->>'id' = $1 OR to_subject->>'id' = $1)", [id]);++ await t.exec("DELETE FROM share_access_requests WHERE service='trade' AND subject->>'id' = $1", [id]);++ }++ for (const subject of subjects) {++ const id = String(subject).replace(/^user:/, '');++ const owned = await t.many("SELECT kind, resource_id FROM shares WHERE service='trade' AND owner_subject->>'id' = $1", [id]);++ for (const row of owned) {++ await t.exec("DELETE FROM share_members WHERE service='trade' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);++ await t.exec("DELETE FROM share_transfers WHERE service='trade' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);++ await t.exec("DELETE FROM share_access_requests WHERE service='trade' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);++ await t.exec("DELETE FROM shares WHERE service='trade' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);++ }++ }++ },note: 'Instruments, context text and observations you contributed stay without your name; reviews you approved stay attributed.',});}@@@ -41,6 +41,26 @@ function createWatchlists({ store, config, ctx }) {if (!w || !subject || w.owner_subject !== subject) throw new ApiError(404, 'watchlist.not_found', 'No such watchlist');return w;},++ /**++ * A watchlist a viewer may read: the owner's, or one shared with them (design 2026-11-06, step 6). A missing++ * share row falls back to the old rule — the owner alone. Returns null when the list does not exist or is not++ * this viewer's to see; the caller answers 404.++ */++ async view(viewer, id) {++ const w = await q.byId.get(String(id || ''));++ if (!w) return null;++ const subject = viewer && (viewer.kind === 'user' || viewer.kind === 'service') ? viewer.subject : null;++ const mine = Boolean(subject && w.owner_subject === subject);++ if (mine) return { list: w, mine: true, canEdit: true };++ if (!ctx.sharing) return null;++ if (!await ctx.sharing.canView(viewer, w.id)) return null;++ return { list: w, mine: false, canEdit: await ctx.sharing.canEdit(viewer, w.id) };++ },++ async mustView(viewer, id) {++ const out = await api.view(viewer, id);++ if (!out) throw new ApiError(404, 'watchlist.not_found', 'No such watchlist');++ return out;++ },forOwner: async (subject) => (subject ? await q.forOwner.all(subject) : []),items: async (w) => await q.items.all(w.id),@@@ -50,6 +70,11 @@ function createWatchlists({ store, config, ctx }) {if ((await q.count.get(subject)).n >= config.limits.watchlistsPerSubject) throw new ApiError(429, 'watchlist.limit', `At most ${config.limits.watchlistsPerSubject} watchlists`);const id = newId('wl', store.now());await unique(async () => await q.insert.run(id, subject, name, store.now(), store.now()));++ // A new watchlist starts at the person's default (design rule 1); its Share sheet changes just this one.++ if (ctx.sharing) {++ const audience = await ctx.sharing.defaultOf(subject);++ await ctx.sharing.register(subject, id, audience);++ }return await q.byId.get(id);},@@@ -59,7 +84,7 @@ function createWatchlists({ store, config, ctx }) {return await q.byId.get(w.id);},-- async remove(w) { return await store.tx(async () => { await db.prepare('DELETE FROM trade_watchlist_items WHERE watchlist_id = ?').run(w.id); return (await q.remove.run(w.id)).changes > 0; }); },++ async remove(w) { return await store.tx(async () => { await db.prepare('DELETE FROM trade_watchlist_items WHERE watchlist_id = ?').run(w.id); const removed = (await q.remove.run(w.id)).changes > 0; if (removed && ctx.sharing) await ctx.sharing.forget(w.id); return removed; }); },async add(w, instrument, note = null) {if (!instrument) throw new ApiError(404, 'instrument.not_found', 'No such instrument');@@@ -225,7 +225,14 @@ function createApi(ctx) {define(router, 'post', '/watchlists', 'createWatchlist', guard(C.WATCHLIST_CREATE), B('trade.watchlist.create'), jsonBody, run(async (req) => ({ watchlist: await watchlists.dto(await watchlists.create(owner(req), req.body || {})) }), 201));-- define(router, 'get', '/watchlists/:id', 'getWatchlist', guard(C.WATCHLIST_READ), run(async (req) => ({ watchlist: await watchlists.dto(await watchlists.mustOwn(owner(req), req.params.id)) })));++ define(router, 'get', '/watchlists/:id', 'getWatchlist', guard(C.WATCHLIST_READ), run(async (req) => {++ const v = req.viewer;++ // Anonymous callers get the same 401 for an existing list as for a missing one; a person or a service acting++ // for one gets the same 404 for a private or Specific-people list as for one that does not exist.++ if (!((v.kind === 'user' || v.kind === 'service') && v.subject)) throw new ApiError(401, 'auth.required', 'Sign in with OpenVibe');++ const out = await watchlists.mustView(v, req.params.id);++ return { watchlist: await watchlists.dto(out.list), can_edit: out.canEdit };++ }));define(router, 'patch', '/watchlists/:id', 'renameWatchlist', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), jsonBody, run(async (req) => {const w = await watchlists.mustOwn(owner(req), req.params.id);@@@ -1,28 +1,35 @@'use strict';/**-- * Private pages and forms: watchlists and alert rules. Only for the signed-in owner; every response-- * is Cache-Control: private, no-store with X-Robots-Tag: noindex, nofollow. Someone else's-- * watchlist or rule answers "not found". Plain HTML forms with the form token (no JavaScript).++ * Private pages and forms: watchlists and alert rules. A watchlist is the signed-in owner's, or a shared one a reader++ * may open; every response is Cache-Control: private, no-store with X-Robots-Tag: noindex, nofollow. Someone else's++ * watchlist or rule answers "not found" (a Specific-people list answers the gate). Plain HTML forms with the form token.*-- * GET /watchlists your watchlists, alert rules and recent deliveries++ * GET /watchlists your watchlists, their default audience, alert rules and recent deliveries++ * GET /watchlists/:id one watchlist: its owner's, or a shared one (canView)* POST /watchlists create { name }-- * POST /watchlists/rename { watchlist_id, name }-- * POST /watchlists/delete { watchlist_id }-- * POST /watchlists/item add { watchlist_id, symbol | q, note? }++ * POST /watchlists/defaults { audience, apply_all? } the person's default for new lists++ * POST /watchlists/rename { watchlist_id, name } (owner)++ * POST /watchlists/delete { watchlist_id } (owner)++ * POST /watchlists/item add { watchlist_id, symbol | q, note? } (owner or Can edit)* POST /watchlists/watch one tap: add to the first watchlist, creating "Watching" when there is none-- * POST /watchlists/item/remove { watchlist_id, symbol }++ * POST /watchlists/item/remove { watchlist_id, symbol } (owner or Can edit)* POST /alerts create { symbol, kind, … }* POST /alerts/delete { rule_id }*/const express = require('express');const seo = require('openvibe-publishing/seo');++const ovServe = require('openvibe-shared/serve');const views = require('../render/views');const { define } = require('./routes');--const { ApiError, asApiError } = require('./errors');++const { asApiError, ApiError } = require('./errors');++++// The one Share sheet (openvibe-shared ov-share.js): its stylesheet and script, and the gate that draws itself from++// markup. A page with a Share button needs the shared navbar, which loads the sheet on the first press.++const SHARE_HEAD = `<link rel="stylesheet" href="${ovServe.url('ov-share.css')}"><script src="${ovServe.url('ov-share.js')}" defer></script>`;function createPrivate(ctx) {-- const { instruments, watchlists, alerts, common } = ctx;++ const { instruments, watchlists, alerts, common, config } = ctx;const router = express.Router();// Per-actor limits (http/actor-limits.js): each form names its budget, shared with the API route that// does the same thing, checked once the session and form token are.@@@ -41,19 +48,66 @@ function createPrivate(ctx) {return r.status === 'resolved' ? r.instrument : null;}++ /** A watchlist this viewer may change: the owner's, or one shared with them Can edit. */++ async function editable(req) {++ const out = await watchlists.mustView(req.viewer, req.body.watchlist_id);++ if (!out.canEdit) throw new ApiError(403, 'watchlist.read_only', 'This watchlist is shared with you as a view-only list');++ return out.list;++ }++define(router, 'get', '/watchlists', 'showWatchlists', async (req, res) => {const v = req.viewer;const signedIn = v.kind === 'user' && v.subject;const lists = signedIn ? (await Promise.all((await watchlists.forOwner(v.subject)).map(async (w) => await watchlists.dto(w)))) : [];++ const defaultAudience = signedIn && ctx.sharing ? await ctx.sharing.defaultOf(v.subject).catch(() => 'private') : 'private';common.page(req, res, {-- title: 'Your watchlists', decision: privateDecision, personal: true,++ title: 'Your watchlists', decision: privateDecision, personal: true, head: SHARE_HEAD,body: views.watchlistsPage({-- viewer: v, lists, rules: signedIn ? await alerts.forOwner(v.subject) : [], deliveries: signedIn ? await alerts.deliveries(v.subject, 20) : [],++ viewer: v, lists, defaultAudience, rules: signedIn ? await alerts.forOwner(v.subject) : [], deliveries: signedIn ? await alerts.deliveries(v.subject, 20) : [],csrf: common.csrf(req), loginHref: common.loginHref(req), notice: common.noticeOf(req),}),});});++ // The person's default for new watchlists, and "apply to all" (design rule 2). Just the default changes; a list's++ // Share button changes that one.++ define(router, 'post', '/watchlists/defaults', 'setWatchlistDefault', ...common.signedInForm, async (req, res) => {++ if (!ctx.sharing) return res.redirect(303, '/watchlists');++ const audience = ['private', 'people', 'link', 'public'].includes(req.body.audience) ? req.body.audience : 'private';++ await ctx.sharing.setDefault(req.viewer.subject, audience, { applyAll: String(req.body.apply_all || '') === '1' })++ .catch((err) => ctx.log.warn('[Trade] the watchlist default was not saved:', err.message));++ return res.redirect(303, '/watchlists');++ });++++ // One watchlist's own page (design 2026-11-06, step 6): its owner's, or a reader it was shared with. A list shared++ // with specific people answers the gate to everyone else and never shows its name or instruments.++ define(router, 'get', '/watchlists/:id', 'showWatchlist', async (req, res) => {++ const id = String(req.params.id || '');++ const out = await watchlists.view(req.viewer, id);++ if (!out) {++ const audience = ctx.sharing ? await ctx.sharing.audienceOf(id).catch(() => null) : null;++ if (audience === 'people') {++ const signedIn = req.viewer.kind === 'user' && req.viewer.subject;++ return common.page(req, res, {++ status: 404, title: 'A watchlist shared with specific people', head: SHARE_HEAD, personal: true,++ body: views.watchlistGate({++ id, signedInAs: signedIn ? (req.viewer.user && req.viewer.user.username) || '' : '',++ signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}`,++ }),++ });++ }++ return common.notFound(req, res);++ }++ const audience = ctx.sharing ? await ctx.sharing.audienceOf(id).catch(() => null) : null;++ common.page(req, res, {++ title: out.list.name, decision: privateDecision, personal: true, head: SHARE_HEAD,++ body: views.watchlistPage({++ list: out.list, items: await watchlists.items(out.list), mine: out.mine, canEdit: out.canEdit,++ audience, csrf: common.csrf(req), loginHref: common.loginHref(req), notice: common.noticeOf(req),++ }),++ });++ });++define(router, 'post', '/watchlists', 'createWatchlistForm', ...common.signedInForm, B('trade.watchlist.create'), async (req, res) => {try {await watchlists.create(req.viewer.subject, { name: req.body.name });@@@ -77,7 +131,7 @@ function createPrivate(ctx) {define(router, 'post', '/watchlists/item', 'addWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), async (req, res) => {try {-- const w = await watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);++ const w = await editable(req);const instrument = await instrumentFrom(req.body);const added = await watchlists.add(w, instrument, req.body.note);const from = req.get('referer') && /\/i\//.test(new URL(req.get('referer'), ctx.config.baseUrl).pathname) ? `/i/${encodeURIComponent(instrument.symbol)}` : '/watchlists';@@@ -101,7 +155,7 @@ function createPrivate(ctx) {define(router, 'post', '/watchlists/item/remove', 'removeWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), async (req, res) => {try {-- const w = await watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);++ const w = await editable(req);await watchlists.drop(w, await instruments.bySymbol(req.body.symbol));common.after(res, '/watchlists', { n: 'item_removed' });} catch (err) { back(req, res, '/watchlists', err); }@@@ -91,7 +91,7 @@ function renderPage(o) {canonical: o.canonical,// The head names the page's own Markdown twin (openvibe-shared/agent-layer).// The rules that show the picked pane of a pick block are written for the page, because there is one per pane.-- head: [agentHeadTags(o), ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),++ head: [o.head, agentHeadTags(o), ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),decision: o.decision,type: o.type || 'website',image: o.image,@@@ -426,16 +426,20 @@ ${sourcesTable(sources, { caption: 'Sources and their freshness, computed with t// ── Watchlists (private) ─────────────────────────────────────────────────────--function watchlistsPage({ viewer, lists, rules, deliveries, csrf, loginHref, notice: n }) {++function watchlistsPage({ viewer, lists, rules, deliveries, csrf, loginHref, defaultAudience = 'private', notice: n }) {if (!viewer || viewer.kind !== 'user' || !viewer.subject) {-- return html`<h1>Watchlists</h1><p>Watchlists and alerts are private to your OpenVibe account.</p><p><a class="sc-btn sc-primary" href="${loginHref}">Sign in to see yours</a></p>`;++ return html`<h1>Watchlists</h1><p>Watchlists and alerts are private to your OpenVibe account. <a href="${loginHref}">Sign in</a> to see yours.</p>`;}++ 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 ? ' selected' : ''}>${audienceText[a]}</option>`);const listHtml = lists.map((w) => html`<section class="card" aria-label="${w.name}">--<h2>${w.name}</h2>++<h2><a href="/watchlists/${w.id}">${w.name}</a></h2>${w.items.length ? html`<table tabindex="0" class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Note</th><th scope="col"></th></tr></thead><tbody>${w.items.map((it) => html`<tr><td><a href="${new URL(it.instrument.url).pathname}">${it.instrument.symbol}</a></td><td>${it.instrument.name}</td><td>${it.note || ''}</td><td><form method="post" action="/watchlists/item/remove" class="inline">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}"><input type="hidden" name="symbol" value="${it.instrument.symbol}"><button type="submit">Remove</button></form></td></tr>`)}</tbody></table>` : html`<p class="empty">Empty. Add instruments from their pages or below.</p>`}++<p><button type="button" class="btn" data-ov-share-sheet data-kind="watchlist" data-id="${w.id}" data-title="${w.name}">Share</button></p><form method="post" action="/watchlists/item" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}"><label>Add by ticker, CIK or name <input name="q" maxlength="200" required></label> <button type="submit">Add</button></form><form method="post" action="/watchlists/rename" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}">@@@ -449,9 +453,16 @@ ${w.items.map((it) => html`<tr><td><a href="${new URL(it.instrument.url).pathnamconst delRows = deliveries.map((x) => html`<li>${when(x.created_at)} — ${x.trigger.kind === 'observation'? html`${x.trigger.metric} = ${x.trigger.value} ${x.trigger.unit}, observed ${when(x.trigger.observed_at)} (${x.trigger.source_key})`: html`${x.trigger.form_type ? `${x.trigger.form_type}: ` : ''}${x.trigger.title || 'document'}, ${x.trigger.published_at ? html`published ${when(x.trigger.published_at)}` : 'no date stated'}`}</li>`);-- return html`<h1>Your watchlists <span class="badge">private</span></h1>++ return html`<h1>Your watchlists <span class="badge">Only me by default</span></h1><p class="muted">Only you can see this page. It is never cached by shared caches, indexed or listed anywhere.</p>${notice(n)}++<section class="card" aria-labelledby="new-list-h">++<h2 id="new-list-h">New watchlists</h2>++<form method="post" action="/watchlists/defaults" class="inline-form">${csrfField(csrf)}++ <label>Who can open a new watchlist <select name="audience">${defaultOptions}</select></label>++ <label><input type="checkbox" name="apply_all" value="1"> Apply to all my existing watchlists</label>++ <button type="submit">Save</button></form>++<p class="muted">A watchlist's Share button changes just that one. Your watchlists are never listed; a shared list is readable only by whoever holds the link or the people you add.</p></section>${listHtml}<form method="post" action="/watchlists" class="card">${csrfField(csrf)}<label for="wl-name">New watchlist</label><input id="wl-name" name="name" maxlength="80" required>@@@ -463,6 +474,40 @@ ${rules.length ? html`<table tabindex="0" class="data"><thead><tr><th scope="col${deliveries.length ? html`<ul>${delRows}</ul>` : html`<p class="empty">None yet.</p>`}`;}++// ── One watchlist (its owner's, or a shared one) ────────────────────────────++++const WATCHLIST_AUDIENCES = { public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' };++++function watchlistPage({ list, items, mine = false, canEdit = false, audience = 'private', csrf, notice: n }) {++ const who = WATCHLIST_AUDIENCES[audience] || 'Only me';++ const removeCell = (it) => canEdit++ ? html`<td><form method="post" action="/watchlists/item/remove" class="inline">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${list.id}"><input type="hidden" name="symbol" value="${it.symbol}"><button type="submit">Remove</button></form></td>`++ : '';++ return html`<h1>${list.name}</h1>++<p class="muted"><span class="badge">${who}</span>${mine ? '' : ' · you are reading a shared watchlist'}${canEdit && !mine ? ' · you can add and remove instruments' : ''}</p>++${notice(n)}++${mine ? html`<p><button type="button" class="btn" data-ov-share-sheet data-kind="watchlist" data-id="${list.id}" data-title="${list.name}">Share</button></p>` : ''}++${items.length ? html`<table tabindex="0" class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Note</th><th scope="col"></th></tr></thead><tbody>++${items.map((it) => html`<tr><td><a href="/i/${encodeURIComponent(it.symbol)}">${it.symbol}</a></td><td>${it.name}</td><td>${it.note || ''}</td>${removeCell(it)}</tr>`)}++</tbody></table>` : html`<p class="empty">Empty.</p>`}++${canEdit ? html`<form method="post" action="/watchlists/item" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${list.id}">++ <label>Add by ticker, CIK or name <input name="q" maxlength="200" required></label> <button type="submit">Add</button></form>` : ''}++${mine ? html`<form method="post" action="/watchlists/rename" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${list.id}">++ <label>Rename <input name="name" maxlength="80" value="${list.name}" required></label> <button type="submit">Rename</button></form>++<form method="post" action="/watchlists/delete" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${list.id}"><button type="submit" class="danger">Delete this watchlist</button></form>` : ''}++<p class="muted"><a href="/watchlists">Your watchlists</a> · Information only — not investment advice; no trading here.</p>`;++}++++/**++ * The page a person not on a Specific-people watchlist's list sees: the audience, never the list's name or the++ * instruments on it. openvibe-shared draws the card from this markup (no inline script).++ */++function watchlistGate({ id, signedInAs = '', signInUrl = '/auth/login' }) {++ return html`<h1>A watchlist shared with specific people</h1>++<p>Sign in with the OpenVibe account it was shared with, or ask its owner for access.</p>++<div id="watchlist-share-gate" data-ov-share-gate data-kind="watchlist" data-id="${id}" data-signed-in-as="${signedInAs}" data-sign-in-url="${signInUrl}">This is shared with specific people. Sign in with the OpenVibe account it was shared with, or ask its owner for access.</div>`;++}++// ── Editor ───────────────────────────────────────────────────────────────────function editorHome({ instruments, pending, csrf, notice: n, kinds }) {@@@ -570,4 +615,4 @@ const observationStyles = `@media (min-width: 700px) { .obs-item { padding: 11px 14px 8px; } .obs-value { font-size: 1.6rem; } }`;--module.exports = { observationStyles, instrumentsTable, instrumentsPage, filingsPage, filingLine, formLabel, FORM_LABEL, recentBriefs, tradeShowcase, resolvePage, instrumentPage, sourcesPage, watchlistsPage, editorHome, editorInstrument, errorPage, when, staleBadge, grouped, clip };++module.exports = { observationStyles, instrumentsTable, instrumentsPage, filingsPage, filingLine, formLabel, FORM_LABEL, recentBriefs, tradeShowcase, resolvePage, instrumentPage, sourcesPage, watchlistsPage, watchlistPage, watchlistGate, editorHome, editorInstrument, errorPage, when, staleBadge, grouped, clip };nnew file mode 100644@@@ -0,0 +1,181 @@++'use strict';++++/**++ * Sharing on OpenVibe.Trade (design 2026-11-06, visibility step 6), through openvibe-sdk/share.++ *++ * A watchlist is Only me until its owner says otherwise. The Share sheet changes just that list; the person's default++ * for new lists lives on /watchlists ("New watchlists: Only me"). A shared list's page and its API read ask the one++ * check, canView; adding or removing instruments asks canEdit, so a list shared with Specific people Can edit is one a++ * friend can actually build with you.++ *++ * 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 watchlist 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 = 'trade';++const KIND = 'watchlist';++const AUDIENCES = ['public', 'link', 'people', 'private'];++const DEFAULT_AUDIENCE = 'private';++/** A watchlist 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) });++++/** Trade's viewer as the SDK wants it: a subject object. An editor is not staff for a person's watchlist. */++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 watchlist's name or the instruments on it. */++ async function notify(n) {++ const to = n && n.to;++ if (!to || to.type !== 'user') return;++ const words = {++ 'share.added': ['A watchlist was shared with you', 'Open it on OpenVibe.Trade.'],++ 'share.access_requested': ['Someone asked for access to your watchlist', 'Review the request on OpenVibe.Trade.'],++ 'share.transfer.requested': ['Someone wants to give you a watchlist', 'Accept or decline it on OpenVibe.Trade.'],++ 'share.transfer.answered': ['A watchlist transfer was answered', 'See what happened on OpenVibe.Trade.'],++ }[n.kind] || ['Sharing on OpenVibe.Trade', '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: 'trade', url: n.ref && n.ref.id ? `${config.baseUrl}/watchlists/${encodeURIComponent(n.ref.id)}` : `${config.baseUrl}/watchlists`,++ category: 'social',++ },++ });++ } catch (err) {++ // Network may not have seeded Trade's 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 watchlist, 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 list 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 trade_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 lists 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 trade_share_defaults (subject, audience, updated_at) VALUES ($1, $2, now())++ ON CONFLICT (subject) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [String(subject), want]);++ if (applyAll) {++ await db.exec(`UPDATE shares SET audience = $1, revision = revision + 1++ WHERE service = $2 AND kind = $3 AND owner_subject->>'id' = $4 AND owner_subject->>'type' = 'user'`,++ [want, SERVICE, KIND, String(subject)]);++ }++ return true;++ },++ /** The uniform API's describe(ref): the list's name, its page, and only the audiences a list may use. */++ describe: async (ref) => {++ if (ref.service !== SERVICE || ref.kind !== KIND) return {};++ const row = await db.maybe('SELECT id, name FROM trade_watchlists WHERE id = $1', [String(ref.id)]);++ if (!row) return {};++ return {++ title: String(row.name || 'A watchlist').slice(0, 120),++ link: `${config.baseUrl}/watchlists/${encodeURIComponent(row.id)}`,++ roles: ['viewer', 'editor'],++ audiences: OFFERED,++ transferable: false,++ };++ },++ /** A deleted watchlist 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 };@@@ -42,7 +42,7 @@ const envelope = (id, type, payload) => ({ event: { event_id: id, event_type: tytry {const missing = await uncovered(d.db, {tables: TABLES,-- exempt: {"account_data_events":"the ledger of requests itself: which export and deletion trade applied","trade_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash"},++ exempt: {"account_data_events":"the ledger of requests itself: which export and deletion trade applied","trade_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash","share_members":"the SDK's own sharing model: subject is an added person, erased with the watchlist by extraErase","share_access_requests":"the SDK's own sharing model: subject is the person who asked, erased by extraErase","trade_share_defaults":"a person's own default for new watchlists, erased by extraErase"},});assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);} finally { await d.close(); }@@@ -32,9 +32,27 @@ async function startNetwork() {const publicPem = publicKey.export({ type: 'spki', format: 'pem' });const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });const grants = [];++ const users = new Map();++ const byUsername = new Map();let issuer = null;const srv = await listen((req, raw, json) => {if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });++ if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {++ const ids0 = JSON.parse(raw || '{}').subject_ids || [];++ const results = {};++ for (const subjectId of ids0) {++ const u = users.get(subjectId);++ if (u) results[subjectId] = { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name };++ }++ return json(200, { results });++ }++ if (req.url.startsWith('/internal/identity/resolve') && req.method === 'GET') {++ const name = String(new URL(req.url, 'http://x').searchParams.get('username') || '').toLowerCase();++ const found = byUsername.get(name);++ if (found) return json(200, { subject: { type: 'user', id: found.subject }, username: found.username, display_name: found.display_name });++ return json(404, { code: 'identity.not_found' });++ }++ if (req.url === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });if (req.url === '/oauth/token' && req.method === 'POST') {let body = {};if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));@@@ -58,7 +76,10 @@ async function startNetwork() {return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: actorType, aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...app, ...extra }, privatePem);}function addUser(username, extra = {}) {-- return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };++ const u = { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };++ users.set(u.subject, u);++ byUsername.set(String(username).toLowerCase(), u);++ return u;}function userToken(u) {return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role || 'user', aud: ['openvibe.network', 'openvibe.trade'] }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });@@@ -59,6 +59,33 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}`)).status, 401, 'anonymous API');});++ await check('a Specific-people list answers a non-member exactly as a missing list does', async () => {++ const carol = t.network.addUser('carol');++ const made = await t.get('/watchlists', form(t.alice, { name: 'Shared plans' }));++ assert.strictEqual(made.status, 303);++ const shared = (await t.ctx.watchlists.forOwner(t.alice.subject)).find((w) => w.name === 'Shared plans');++ const call = (p, o = {}) => t.get(p, { ...o, headers: { 'x-requested-with': 'ov-share', ...(o.headers || {}) } });++ const first = await call(`/api/v1/watchlist/${shared.id}/share`, { as: t.alice });++ assert.strictEqual(first.status, 200, first.text);++ const saved = await call(`/api/v1/watchlist/${shared.id}/share`, {++ as: t.alice, method: 'PUT', json: { audience: 'people', members: [{ name: 'carol', role: 'viewer' }] },++ headers: { 'x-requested-with': 'ov-share', 'if-match': first.headers.get('etag') },++ });++ assert.strictEqual(saved.status, 200, saved.text);++ const hidden = await t.get(`/api/v1/watchlists/${shared.id}`, { as: t.bob });++ const missing = await t.get('/api/v1/watchlists/wl_01J0000000000000000000000', { as: t.bob });++ assert.strictEqual(hidden.status, 404);++ assert.strictEqual(hidden.status, missing.status);++ assert.strictEqual(hidden.json().code, missing.json().code);++ assert.strictEqual(hidden.json().detail, missing.json().detail);++ assert.strictEqual(hidden.json().title, missing.json().title);++ assert.ok(!hidden.text.includes('Shared plans'), 'nothing about the list');++ assert.strictEqual((await t.get(`/api/v1/watchlists/${shared.id}`)).status, 401, 'anonymous API');++ assert.strictEqual((await t.get(`/watchlists/${shared.id}`, { as: carol })).status, 200, 'the member reads it');++ // Leave alice with the one list the later checks count.++ assert.strictEqual((await t.get(`/api/v1/watchlists/${shared.id}`, { method: 'DELETE', as: t.alice })).status, 200);++ });++await check('the API answers the owner, privately', async () => {const r = await t.get('/api/v1/watchlists', { as: t.alice });assert.strictEqual(r.status, 200);nnew file mode 100644@@@ -0,0 +1,158 @@++'use strict';++/**++ * Sharing a watchlist (design 2026-11-06, visibility step 6): the one Share sheet (openvibe-sdk/share) and the one++ * check, canView, on the watchlist page and its API. Editing asks canEdit, so Specific people with Can edit build a++ * list together.++ *++ * - a new watchlist is Only me; its card carries the Share button for its owner;++ * - Anyone with the link reads the list page and the API, and only its owner (or an editor) changes it;++ * - Specific people shows the gate, never the list's name or its instruments, to somebody not on the list;++ * - Can edit really adds and removes instruments; Can view only reads;++ * - the person's default for new lists, and "apply to all", work;++ * - a watchlist is never on a gathered page: not the sitemap, not llms.txt.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const sharing = require('../server/sharing');++const { createWatchlists } = require('../server/domain/watchlists');++const { createPrivate } = require('../server/http/private');++++(async () => {++ const t = await boot();++ try {++ await check('the sharing wiring loads: watchlists are never Everyone, and an editor is not staff here', async () => {++ assert.strictEqual(sharing.SERVICE, 'trade');++ assert.strictEqual(sharing.KIND, 'watchlist');++ assert.deepStrictEqual(sharing.OFFERED, ['private', 'link', 'people']);++ assert.strictEqual(sharing.shareViewer({ kind: 'anonymous', subject: null }), null);++ assert.deepStrictEqual(sharing.shareViewer({ kind: 'user', subject: 'usr_x', editor: true }), { subject: { type: 'user', id: 'usr_x' }, staff: false });++ assert.strictEqual(typeof createWatchlists, 'function');++ assert.strictEqual(typeof createPrivate, 'function');++ });++ const kim = t.network.addUser('kim');++ const bo = t.network.addUser('bo');++ const cy = t.network.addUser('cy');++ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp', exchange: 'NYSE' });++ const beta = await t.instrument({ symbol: 'BETA', name: 'Beta Holdings', exchange: 'NYSE' });++++ const call = (pathname, o = {}) => {++ const { headers, ...rest } = o;++ return t.get(pathname, { ...rest, headers: { 'x-requested-with': 'ov-share', ...(headers || {}) } });++ };++ const share = (id, o = {}) => call(`/api/v1/watchlist/${encodeURIComponent(id)}/share`, o);++ const setShare = async (id, body, as) => {++ const first = await share(id, { as });++ assert.strictEqual(first.status, 200, first.text);++ const etag = first.headers.get('etag');++ assert.ok(etag);++ return await share(id, { as, method: 'PUT', json: body, headers: { 'if-match': etag } });++ };++ const audienceRow = async (id) => await t.ctx.store.db.maybe(++ 'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['trade', 'watchlist', id]);++ const newList = async (as, name = 'Semis') => {++ const made = await t.get('/api/v1/watchlists', { as, json: { name } });++ assert.strictEqual(made.status, 201, made.text);++ const id = made.json().watchlist.id;++ return id;++ };++++ let id;++ await check('a new watchlist is Only me, and its card carries the Share button for its owner', async () => {++ id = await newList(kim);++ assert.deepStrictEqual(await audienceRow(id), { audience: 'private' });++ await t.get(`/api/v1/watchlists/${id}/items/ACME`, { method: 'PUT', as: kim, json: { note: 'watching' } });++ const mine = await t.get('/watchlists', { as: kim });++ assert.strictEqual(mine.status, 200, mine.text);++ assert.ok(mine.text.includes('data-ov-share-sheet') && mine.text.includes('data-kind="watchlist"'));++ assert.ok(mine.text.includes('ov-share.js'));++ assert.ok(mine.text.includes('Who can open a new watchlist'));++ const own = await t.get(`/watchlists/${id}`, { as: kim });++ assert.strictEqual(own.status, 200, own.text);++ assert.ok(own.text.includes('Semis') && own.text.includes('ACME'));++ const other = await t.get(`/watchlists/${id}`, { as: bo });++ assert.strictEqual(other.status, 404);++ assert.ok(!other.text.includes('Semis'), 'the list is never named to somebody else');++ assert.strictEqual((await t.get(`/watchlists/${id}`)).status, 404);++ assert.strictEqual((await t.get(`/api/v1/watchlists/${id}`)).status, 401, 'the API needs a signed-in caller, as before');++ });++++ await check('Anyone with the link reads the page and the API, and cannot edit', async () => {++ const saved = await setShare(id, { audience: 'link' }, kim);++ assert.strictEqual(saved.status, 200, saved.text);++ assert.strictEqual(saved.json().audience, 'link');++ const guest = await t.get(`/watchlists/${id}`);++ assert.strictEqual(guest.status, 200, guest.text);++ assert.ok(guest.text.includes('Semis') && guest.text.includes('ACME'));++ assert.ok(guest.text.includes('Anyone with the link'));++ assert.ok(!guest.text.includes('data-ov-share-sheet'), 'a reader gets no Share sheet');++ assert.ok(!guest.text.includes('action="/watchlists/item"'), 'a reader gets no add form');++ assert.ok(!guest.text.includes('Delete this watchlist'));++ // The API needs a signed-in caller; a member of the link audience then reads it.++ assert.strictEqual((await t.get(`/api/v1/watchlists/${id}`)).status, 401);++ const api = await t.get(`/api/v1/watchlists/${id}`, { as: bo });++ assert.strictEqual(api.status, 200);++ assert.strictEqual(api.json().can_edit, false);++ assert.strictEqual((await t.get(`/api/v1/watchlists/${id}/items/BETA`, { method: 'PUT', as: bo, json: {} })).status, 404);++ });++++ await check('Specific people: an editor adds and removes instruments, a viewer only reads, a stranger gets the gate', async () => {++ const saved = await setShare(id, { audience: 'people', members: [{ name: 'bo', role: 'editor' }, { name: 'cy', role: 'viewer' }] }, kim);++ assert.strictEqual(saved.status, 200, saved.text);++ assert.strictEqual(saved.json().audience, 'people');++ const editor = await t.get(`/watchlists/${id}`, { as: bo });++ assert.strictEqual(editor.status, 200, editor.text);++ assert.ok(editor.text.includes('Semis'));++ assert.ok(editor.text.includes('action="/watchlists/item"'), 'Can edit opens the add form');++ assert.ok(!editor.text.includes('Delete this watchlist'), 'only the owner deletes');++ const added = await t.get('/watchlists/item', { as: bo, form: { _csrf: t.csrf(bo), watchlist_id: id, q: 'BETA' } });++ assert.strictEqual(added.status, 303, added.text);++ assert.strictEqual(await t.ctx.store.db.value('SELECT COUNT(*) FROM trade_watchlist_items WHERE watchlist_id = $1', [id]), 2);++ const removed = await t.get('/watchlists/item/remove', { as: bo, form: { _csrf: t.csrf(bo), watchlist_id: id, symbol: 'BETA' } });++ assert.strictEqual(removed.status, 303, removed.text);++ const viewer = await t.get(`/watchlists/${id}`, { as: cy });++ assert.strictEqual(viewer.status, 200);++ assert.ok(!viewer.text.includes('action="/watchlists/item"'), 'Can view cannot edit');++ const refused = await t.get('/watchlists/item', { as: cy, form: { _csrf: t.csrf(cy), watchlist_id: id, q: 'BETA' } });++ assert.strictEqual(refused.status, 303, refused.text);++ assert.ok(/view-only/.test(refused.headers.get('location') || ''), 'a viewer is told the list is read-only');++ assert.strictEqual(await t.ctx.store.db.value('SELECT COUNT(*) FROM trade_watchlist_items WHERE watchlist_id = $1', [id]), 1);++ const stranger = t.network.addUser('di');++ const gate = await t.get(`/watchlists/${id}`, { as: stranger });++ assert.strictEqual(gate.status, 404);++ assert.ok(gate.text.includes('shared with specific people'));++ assert.ok(!gate.text.includes('Semis'), 'the gate never shows the name');++ assert.ok(!gate.text.includes('ACME'), 'the gate never shows an instrument');++ assert.ok(gate.text.includes('data-ov-share-gate'));++ assert.strictEqual((await t.get(`/api/v1/watchlists/${id}`, { as: stranger })).status, 404);++ });++++ await check('the default applies to the next list, and "apply to all" moves the existing one', async () => {++ const saved = await t.get('/watchlists/defaults', { as: kim, form: { _csrf: t.csrf(kim), audience: 'link', apply_all: '1' } });++ assert.strictEqual(saved.status, 303, saved.text);++ assert.deepStrictEqual(await audienceRow(id), { audience: 'link' });++ const next = await newList(kim, 'Banks');++ assert.deepStrictEqual(await audienceRow(next), { audience: 'link' });++ const start = await t.get('/watchlists', { as: kim });++ assert.ok(start.text.includes('Who can open a new watchlist'), 'the default is on the product page');++ assert.strictEqual((await t.get(`/watchlists/${next}`)).status, 200, 'the next list follows the default');++ });++++ await check('deleting a watchlist takes its share row with it', async () => {++ const gone = await t.get(`/api/v1/watchlists/${id}`, { method: 'DELETE', as: kim });++ assert.strictEqual(gone.status, 200, gone.text);++ assert.strictEqual(await audienceRow(id), null);++ });++++ await check('a watchlist is never on a gathered page: not the sitemap, not llms.txt', async () => {++ const sitemap = await t.get('/sitemap.xml');++ assert.strictEqual(sitemap.status, 200);++ assert.ok(!sitemap.text.includes(id));++ const llms = await t.get('/llms.txt');++ assert.ok(!llms.text.includes(id) && !/watchlist/i.test(llms.text.split('\n').slice(0, 30).join('\n')));++ });++++ done();++ } finally {++ await t.close();++ }++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+-- phase: expand
+-- The one sharing system (openvibe-sdk/share) on a watchlist: its audience, the people it is shared with, the log and the
+-- delivery queue the SDK writes. Kept verbatim from the SDK's own schema (node_modules/openvibe-sdk/src/share/schema.js
+-- at the pinned version), so its store finds the tables it expects and the test database has them.
+-- A watchlist's own row is trade_watchlists; one share row per watchlist, kind 'watchlist', id = wl_….
+
+
+CREATE TABLE IF NOT EXISTS shares (
+ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
+ revision integer NOT NULL DEFAULT 1,
+ lock text NOT NULL DEFAULT 'none',
+ reseal_nonce_base text,
+ PRIMARY KEY (service, kind, resource_id)
+);
+CREATE TABLE IF NOT EXISTS share_members (
+ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
+ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
+ key_slot jsonb,
+ PRIMARY KEY (service, kind, resource_id, subject),
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE TABLE IF NOT EXISTS share_transfers (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
+ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
+ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
+ key_slot jsonb,
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
+CREATE TABLE IF NOT EXISTS share_access_requests (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
+ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
+CREATE TABLE IF NOT EXISTS share_log (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
+ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
+);
+CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
+ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
+ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
+ END IF;
+END $$;
+CREATE TABLE IF NOT EXISTS share_deliveries (
+ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
+ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
+ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
+);
+CREATE TABLE IF NOT EXISTS share_name_limits (
+ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
+);
+CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
+CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
+CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
+-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
+-- slot a transfer was offered with. Repeating this migration is a no-op.
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
+DO $$ BEGIN
+ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
+ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
+
+-- Visibility step 6 (design 2026-11-06): the person's default audience for new watchlists, and one share row for every
+-- watchlist that already exists. A watchlist was private to its owner, so every one starts as Only me. New watchlists
+-- are registered as they are made (server/domain/watchlists.js); a list without a row still answers its owner only,
+-- because every read asks canView and a missing row falls back to the old rule. Repeating this file is a no-op.
+
+CREATE TABLE IF NOT EXISTS trade_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 'trade', 'watchlist', w.id, jsonb_build_object('type', 'user', 'id', w.owner_subject), 'private'
+FROM trade_watchlists w
+ON CONFLICT (service, kind, resource_id) DO NOTHING;
@@ -42,6 +42,7 @@ const { createObservations } = require('./domain/observations');
@@ -42,6 +42,7 @@ const { createObservations } = require('./domain/observations');
const { createDocuments } = require('./domain/documents');
const { createDocuments } = require('./domain/documents');
const { createAlerts } = require('./domain/alerts');
const { createAlerts } = require('./domain/alerts');
const { createWatchlists } = require('./domain/watchlists');
const { createWatchlists } = require('./domain/watchlists');
+const { createSharing } = require('./sharing');
const { createContext } = require('./domain/context');
const { createContext } = require('./domain/context');
const { createIndexing } = require('./domain/indexing');
const { createIndexing } = require('./domain/indexing');
const { createReading } = require('./domain/reading');
const { createReading } = require('./domain/reading');
@@ -93,6 +94,8 @@ async function createApp(opts = {}) {
@@ -93,6 +94,8 @@ async function createApp(opts = {}) {
ctx.alerts = createAlerts({ store, config, ctx });
ctx.alerts = createAlerts({ store, config, ctx });
ctx.observations = createObservations({ store, ctx });
ctx.observations = createObservations({ store, ctx });
ctx.documents = createDocuments({ store, ctx });
ctx.documents = createDocuments({ store, ctx });
+ // The one sharing system (design 2026-11-06): a watchlist is its owner's until the Share sheet says otherwise.
+ ctx.sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl, log });
ctx.watchlists = createWatchlists({ store, config, ctx });
ctx.watchlists = createWatchlists({ store, config, ctx });
ctx.context = createContext({ store, config, ctx });
ctx.context = createContext({ store, config, ctx });
ctx.indexing = createIndexing({ store, config, ctx, indexnow });
ctx.indexing = createIndexing({ store, config, ctx, indexnow });
@@ -234,6 +237,27 @@ async function createApp(opts = {}) {
@@ -234,6 +237,27 @@ async function createApp(opts = {}) {
}));
}));
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
+ // ── /api/v1/watchlist/:id/share — the one Share sheet's API (openvibe-sdk/share) ──
+ // GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people
+ // and /api/v1/shared-with-me. Access is still decided by canView on every read path; the router answers only
+ // its own paths.
+ {
+ const shareApi = express.Router();
+ shareApi.use(ctx.viewers.middleware());
+ shareApi.use(ctx.sharing.shares.router({
+ service: 'trade',
+ authenticate: (req) => ctx.sharing.shareViewer(req.viewer),
+ describe: ctx.sharing.describe,
+ }));
+ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|[^/]+\/[^/]+\/share(?:\/|$))/;
+ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
+ }
+ // Expire members and transfers and send queued share events/notifications. Unref'd: it never holds a test open.
+ if (opts.startSharingWorker !== false) {
+ const timer = setInterval(() => { ctx.sharing.work().catch(() => { /* logged inside */ }); }, 15_000);
+ if (timer.unref) timer.unref();
+ app.locals.sharingTimer = timer;
+ }
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
// ── Pages (the viewer is resolved for browsers only; service tokens are not identities here) ──
// ── Pages (the viewer is resolved for browsers only; service tokens are not identities here) ──
@@ -66,6 +66,31 @@ async function extraErase(t, subjects, counts) {
@@ -66,6 +66,31 @@ async function extraErase(t, subjects, counts) {
function create({ db, log = console } = {}) {
function create({ db, log = console } = {}) {
return createAccountData({
return createAccountData({
db, service: 'trade', tables: TABLES, extraExport, extraErase, log,
db, service: 'trade', tables: TABLES, extraExport, extraErase, log,
+ /**
+ * A share row names its owner and its members by canonical subject, which a table map cannot cover: the
+ * watchlist's own rows go, and so must its share row and everyone it named, or a deleted account would
+ * linger in somebody else's member list. Children first: members, transfers and requests point at shares.
+ */
+ extraErase: async (t, subjects, counts) => {
+ await extraErase(t, subjects, counts);
+ for (const subject of subjects) {
+ const id = String(subject).replace(/^user:/, '');
+ await t.exec('DELETE FROM trade_share_defaults WHERE subject = $1', [id]);
+ await t.exec("DELETE FROM share_members WHERE service='trade' AND subject->>'id' = $1", [id]);
+ await t.exec("DELETE FROM share_transfers WHERE service='trade' AND (from_subject->>'id' = $1 OR to_subject->>'id' = $1)", [id]);
+ await t.exec("DELETE FROM share_access_requests WHERE service='trade' AND subject->>'id' = $1", [id]);
+ }
+ for (const subject of subjects) {
+ const id = String(subject).replace(/^user:/, '');
+ const owned = await t.many("SELECT kind, resource_id FROM shares WHERE service='trade' AND owner_subject->>'id' = $1", [id]);
+ for (const row of owned) {
+ await t.exec("DELETE FROM share_members WHERE service='trade' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
+ await t.exec("DELETE FROM share_transfers WHERE service='trade' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
+ await t.exec("DELETE FROM share_access_requests WHERE service='trade' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
+ await t.exec("DELETE FROM shares WHERE service='trade' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
+ }
+ }
+ },
note: 'Instruments, context text and observations you contributed stay without your name; reviews you approved stay attributed.',
note: 'Instruments, context text and observations you contributed stay without your name; reviews you approved stay attributed.',
});
});
}
}
@@ -41,6 +41,26 @@ function createWatchlists({ store, config, ctx }) {
@@ -41,6 +41,26 @@ function createWatchlists({ store, config, ctx }) {
if (!w || !subject || w.owner_subject !== subject) throw new ApiError(404, 'watchlist.not_found', 'No such watchlist');
if (!w || !subject || w.owner_subject !== subject) throw new ApiError(404, 'watchlist.not_found', 'No such watchlist');
return w;
return w;
},
},
+ /**
+ * A watchlist a viewer may read: the owner's, or one shared with them (design 2026-11-06, step 6). A missing
+ * share row falls back to the old rule — the owner alone. Returns null when the list does not exist or is not
+ * this viewer's to see; the caller answers 404.
+ */
+ async view(viewer, id) {
+ const w = await q.byId.get(String(id || ''));
+ if (!w) return null;
+ const subject = viewer && (viewer.kind === 'user' || viewer.kind === 'service') ? viewer.subject : null;
+ const mine = Boolean(subject && w.owner_subject === subject);
+ if (mine) return { list: w, mine: true, canEdit: true };
+ if (!ctx.sharing) return null;
+ if (!await ctx.sharing.canView(viewer, w.id)) return null;
+ return { list: w, mine: false, canEdit: await ctx.sharing.canEdit(viewer, w.id) };
+ },
+ async mustView(viewer, id) {
+ const out = await api.view(viewer, id);
+ if (!out) throw new ApiError(404, 'watchlist.not_found', 'No such watchlist');
+ return out;
+ },
forOwner: async (subject) => (subject ? await q.forOwner.all(subject) : []),
forOwner: async (subject) => (subject ? await q.forOwner.all(subject) : []),
items: async (w) => await q.items.all(w.id),
items: async (w) => await q.items.all(w.id),
@@ -50,6 +70,11 @@ function createWatchlists({ store, config, ctx }) {
@@ -50,6 +70,11 @@ function createWatchlists({ store, config, ctx }) {
if ((await q.count.get(subject)).n >= config.limits.watchlistsPerSubject) throw new ApiError(429, 'watchlist.limit', `At most ${config.limits.watchlistsPerSubject} watchlists`);
if ((await q.count.get(subject)).n >= config.limits.watchlistsPerSubject) throw new ApiError(429, 'watchlist.limit', `At most ${config.limits.watchlistsPerSubject} watchlists`);
const id = newId('wl', store.now());
const id = newId('wl', store.now());
await unique(async () => await q.insert.run(id, subject, name, store.now(), store.now()));
await unique(async () => await q.insert.run(id, subject, name, store.now(), store.now()));
+ // A new watchlist starts at the person's default (design rule 1); its Share sheet changes just this one.
+ if (ctx.sharing) {
+ const audience = await ctx.sharing.defaultOf(subject);
+ await ctx.sharing.register(subject, id, audience);
+ }
return await q.byId.get(id);
return await q.byId.get(id);
},
},
@@ -59,7 +84,7 @@ function createWatchlists({ store, config, ctx }) {
@@ -59,7 +84,7 @@ function createWatchlists({ store, config, ctx }) {
return await q.byId.get(w.id);
return await q.byId.get(w.id);
},
},
- async remove(w) { return await store.tx(async () => { await db.prepare('DELETE FROM trade_watchlist_items WHERE watchlist_id = ?').run(w.id); return (await q.remove.run(w.id)).changes > 0; }); },
+ async remove(w) { return await store.tx(async () => { await db.prepare('DELETE FROM trade_watchlist_items WHERE watchlist_id = ?').run(w.id); const removed = (await q.remove.run(w.id)).changes > 0; if (removed && ctx.sharing) await ctx.sharing.forget(w.id); return removed; }); },
async add(w, instrument, note = null) {
async add(w, instrument, note = null) {
if (!instrument) throw new ApiError(404, 'instrument.not_found', 'No such instrument');
if (!instrument) throw new ApiError(404, 'instrument.not_found', 'No such instrument');
@@ -225,7 +225,14 @@ function createApi(ctx) {
@@ -225,7 +225,14 @@ function createApi(ctx) {
define(router, 'post', '/watchlists', 'createWatchlist', guard(C.WATCHLIST_CREATE), B('trade.watchlist.create'), jsonBody, run(async (req) => ({ watchlist: await watchlists.dto(await watchlists.create(owner(req), req.body || {})) }), 201));
define(router, 'post', '/watchlists', 'createWatchlist', guard(C.WATCHLIST_CREATE), B('trade.watchlist.create'), jsonBody, run(async (req) => ({ watchlist: await watchlists.dto(await watchlists.create(owner(req), req.body || {})) }), 201));
- define(router, 'get', '/watchlists/:id', 'getWatchlist', guard(C.WATCHLIST_READ), run(async (req) => ({ watchlist: await watchlists.dto(await watchlists.mustOwn(owner(req), req.params.id)) })));
+ define(router, 'get', '/watchlists/:id', 'getWatchlist', guard(C.WATCHLIST_READ), run(async (req) => {
+ const v = req.viewer;
+ // Anonymous callers get the same 401 for an existing list as for a missing one; a person or a service acting
+ // for one gets the same 404 for a private or Specific-people list as for one that does not exist.
+ if (!((v.kind === 'user' || v.kind === 'service') && v.subject)) throw new ApiError(401, 'auth.required', 'Sign in with OpenVibe');
+ const out = await watchlists.mustView(v, req.params.id);
+ return { watchlist: await watchlists.dto(out.list), can_edit: out.canEdit };
+ }));
define(router, 'patch', '/watchlists/:id', 'renameWatchlist', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), jsonBody, run(async (req) => {
define(router, 'patch', '/watchlists/:id', 'renameWatchlist', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), jsonBody, run(async (req) => {
const w = await watchlists.mustOwn(owner(req), req.params.id);
const w = await watchlists.mustOwn(owner(req), req.params.id);
@@ -1,28 +1,35 @@
@@ -1,28 +1,35 @@
'use strict';
'use strict';
/**
/**
- * Private pages and forms: watchlists and alert rules. Only for the signed-in owner; every response
- * is Cache-Control: private, no-store with X-Robots-Tag: noindex, nofollow. Someone else's
- * watchlist or rule answers "not found". Plain HTML forms with the form token (no JavaScript).
+ * Private pages and forms: watchlists and alert rules. A watchlist is the signed-in owner's, or a shared one a reader
+ * may open; every response is Cache-Control: private, no-store with X-Robots-Tag: noindex, nofollow. Someone else's
+ * watchlist or rule answers "not found" (a Specific-people list answers the gate). Plain HTML forms with the form token.
*
*
- * GET /watchlists your watchlists, alert rules and recent deliveries
+ * GET /watchlists your watchlists, their default audience, alert rules and recent deliveries
+ * GET /watchlists/:id one watchlist: its owner's, or a shared one (canView)
* POST /watchlists create { name }
* POST /watchlists create { name }
- * POST /watchlists/rename { watchlist_id, name }
- * POST /watchlists/delete { watchlist_id }
- * POST /watchlists/item add { watchlist_id, symbol | q, note? }
+ * POST /watchlists/defaults { audience, apply_all? } the person's default for new lists
+ * POST /watchlists/rename { watchlist_id, name } (owner)
+ * POST /watchlists/delete { watchlist_id } (owner)
+ * POST /watchlists/item add { watchlist_id, symbol | q, note? } (owner or Can edit)
* POST /watchlists/watch one tap: add to the first watchlist, creating "Watching" when there is none
* POST /watchlists/watch one tap: add to the first watchlist, creating "Watching" when there is none
- * POST /watchlists/item/remove { watchlist_id, symbol }
+ * POST /watchlists/item/remove { watchlist_id, symbol } (owner or Can edit)
* POST /alerts create { symbol, kind, … }
* POST /alerts create { symbol, kind, … }
* POST /alerts/delete { rule_id }
* POST /alerts/delete { rule_id }
*/
*/
const express = require('express');
const express = require('express');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
+const ovServe = require('openvibe-shared/serve');
const views = require('../render/views');
const views = require('../render/views');
const { define } = require('./routes');
const { define } = require('./routes');
-const { ApiError, asApiError } = require('./errors');
+const { asApiError, ApiError } = require('./errors');
+
+// The one Share sheet (openvibe-shared ov-share.js): its stylesheet and script, and the gate that draws itself from
+// markup. A page with a Share button needs the shared navbar, which loads the sheet on the first press.
+const SHARE_HEAD = `<link rel="stylesheet" href="${ovServe.url('ov-share.css')}"><script src="${ovServe.url('ov-share.js')}" defer></script>`;
function createPrivate(ctx) {
function createPrivate(ctx) {
- const { instruments, watchlists, alerts, common } = ctx;
+ const { instruments, watchlists, alerts, common, config } = ctx;
const router = express.Router();
const router = express.Router();
// Per-actor limits (http/actor-limits.js): each form names its budget, shared with the API route that
// Per-actor limits (http/actor-limits.js): each form names its budget, shared with the API route that
// does the same thing, checked once the session and form token are.
// does the same thing, checked once the session and form token are.
@@ -41,19 +48,66 @@ function createPrivate(ctx) {
@@ -41,19 +48,66 @@ function createPrivate(ctx) {
return r.status === 'resolved' ? r.instrument : null;
return r.status === 'resolved' ? r.instrument : null;
}
}
+ /** A watchlist this viewer may change: the owner's, or one shared with them Can edit. */
+ async function editable(req) {
+ const out = await watchlists.mustView(req.viewer, req.body.watchlist_id);
+ if (!out.canEdit) throw new ApiError(403, 'watchlist.read_only', 'This watchlist is shared with you as a view-only list');
+ return out.list;
+ }
+
define(router, 'get', '/watchlists', 'showWatchlists', async (req, res) => {
define(router, 'get', '/watchlists', 'showWatchlists', async (req, res) => {
const v = req.viewer;
const v = req.viewer;
const signedIn = v.kind === 'user' && v.subject;
const signedIn = v.kind === 'user' && v.subject;
const lists = signedIn ? (await Promise.all((await watchlists.forOwner(v.subject)).map(async (w) => await watchlists.dto(w)))) : [];
const lists = signedIn ? (await Promise.all((await watchlists.forOwner(v.subject)).map(async (w) => await watchlists.dto(w)))) : [];
+ const defaultAudience = signedIn && ctx.sharing ? await ctx.sharing.defaultOf(v.subject).catch(() => 'private') : 'private';
common.page(req, res, {
common.page(req, res, {
- title: 'Your watchlists', decision: privateDecision, personal: true,
+ title: 'Your watchlists', decision: privateDecision, personal: true, head: SHARE_HEAD,
body: views.watchlistsPage({
body: views.watchlistsPage({
- viewer: v, lists, rules: signedIn ? await alerts.forOwner(v.subject) : [], deliveries: signedIn ? await alerts.deliveries(v.subject, 20) : [],
+ viewer: v, lists, defaultAudience, rules: signedIn ? await alerts.forOwner(v.subject) : [], deliveries: signedIn ? await alerts.deliveries(v.subject, 20) : [],
csrf: common.csrf(req), loginHref: common.loginHref(req), notice: common.noticeOf(req),
csrf: common.csrf(req), loginHref: common.loginHref(req), notice: common.noticeOf(req),
}),
}),
});
});
});
});
+ // The person's default for new watchlists, and "apply to all" (design rule 2). Just the default changes; a list's
+ // Share button changes that one.
+ define(router, 'post', '/watchlists/defaults', 'setWatchlistDefault', ...common.signedInForm, async (req, res) => {
+ if (!ctx.sharing) return res.redirect(303, '/watchlists');
+ const audience = ['private', 'people', 'link', 'public'].includes(req.body.audience) ? req.body.audience : 'private';
+ await ctx.sharing.setDefault(req.viewer.subject, audience, { applyAll: String(req.body.apply_all || '') === '1' })
+ .catch((err) => ctx.log.warn('[Trade] the watchlist default was not saved:', err.message));
+ return res.redirect(303, '/watchlists');
+ });
+
+ // One watchlist's own page (design 2026-11-06, step 6): its owner's, or a reader it was shared with. A list shared
+ // with specific people answers the gate to everyone else and never shows its name or instruments.
+ define(router, 'get', '/watchlists/:id', 'showWatchlist', async (req, res) => {
+ const id = String(req.params.id || '');
+ const out = await watchlists.view(req.viewer, id);
+ if (!out) {
+ const audience = ctx.sharing ? await ctx.sharing.audienceOf(id).catch(() => null) : null;
+ if (audience === 'people') {
+ const signedIn = req.viewer.kind === 'user' && req.viewer.subject;
+ return common.page(req, res, {
+ status: 404, title: 'A watchlist shared with specific people', head: SHARE_HEAD, personal: true,
+ body: views.watchlistGate({
+ id, signedInAs: signedIn ? (req.viewer.user && req.viewer.user.username) || '' : '',
+ signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}`,
+ }),
+ });
+ }
+ return common.notFound(req, res);
+ }
+ const audience = ctx.sharing ? await ctx.sharing.audienceOf(id).catch(() => null) : null;
+ common.page(req, res, {
+ title: out.list.name, decision: privateDecision, personal: true, head: SHARE_HEAD,
+ body: views.watchlistPage({
+ list: out.list, items: await watchlists.items(out.list), mine: out.mine, canEdit: out.canEdit,
+ audience, csrf: common.csrf(req), loginHref: common.loginHref(req), notice: common.noticeOf(req),
+ }),
+ });
+ });
+
define(router, 'post', '/watchlists', 'createWatchlistForm', ...common.signedInForm, B('trade.watchlist.create'), async (req, res) => {
define(router, 'post', '/watchlists', 'createWatchlistForm', ...common.signedInForm, B('trade.watchlist.create'), async (req, res) => {
try {
try {
await watchlists.create(req.viewer.subject, { name: req.body.name });
await watchlists.create(req.viewer.subject, { name: req.body.name });
@@ -77,7 +131,7 @@ function createPrivate(ctx) {
@@ -77,7 +131,7 @@ function createPrivate(ctx) {
define(router, 'post', '/watchlists/item', 'addWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), async (req, res) => {
define(router, 'post', '/watchlists/item', 'addWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), async (req, res) => {
try {
try {
- const w = await watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);
+ const w = await editable(req);
const instrument = await instrumentFrom(req.body);
const instrument = await instrumentFrom(req.body);
const added = await watchlists.add(w, instrument, req.body.note);
const added = await watchlists.add(w, instrument, req.body.note);
const from = req.get('referer') && /\/i\//.test(new URL(req.get('referer'), ctx.config.baseUrl).pathname) ? `/i/${encodeURIComponent(instrument.symbol)}` : '/watchlists';
const from = req.get('referer') && /\/i\//.test(new URL(req.get('referer'), ctx.config.baseUrl).pathname) ? `/i/${encodeURIComponent(instrument.symbol)}` : '/watchlists';
@@ -101,7 +155,7 @@ function createPrivate(ctx) {
@@ -101,7 +155,7 @@ function createPrivate(ctx) {
define(router, 'post', '/watchlists/item/remove', 'removeWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), async (req, res) => {
define(router, 'post', '/watchlists/item/remove', 'removeWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), async (req, res) => {
try {
try {
- const w = await watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);
+ const w = await editable(req);
await watchlists.drop(w, await instruments.bySymbol(req.body.symbol));
await watchlists.drop(w, await instruments.bySymbol(req.body.symbol));
common.after(res, '/watchlists', { n: 'item_removed' });
common.after(res, '/watchlists', { n: 'item_removed' });
} catch (err) { back(req, res, '/watchlists', err); }
} catch (err) { back(req, res, '/watchlists', err); }
@@ -91,7 +91,7 @@ function renderPage(o) {
@@ -91,7 +91,7 @@ function renderPage(o) {
canonical: o.canonical,
canonical: o.canonical,
// The head names the page's own Markdown twin (openvibe-shared/agent-layer).
// The head names the page's own Markdown twin (openvibe-shared/agent-layer).
// The rules that show the picked pane of a pick block are written for the page, because there is one per pane.
// The rules that show the picked pane of a pick block are written for the page, because there is one per pane.
- head: [agentHeadTags(o), ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
+ head: [o.head, agentHeadTags(o), ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
decision: o.decision,
decision: o.decision,
type: o.type || 'website',
type: o.type || 'website',
image: o.image,
image: o.image,
@@ -426,16 +426,20 @@ ${sourcesTable(sources, { caption: 'Sources and their freshness, computed with t
@@ -426,16 +426,20 @@ ${sourcesTable(sources, { caption: 'Sources and their freshness, computed with t
// ── Watchlists (private) ─────────────────────────────────────────────────────
// ── Watchlists (private) ─────────────────────────────────────────────────────
-function watchlistsPage({ viewer, lists, rules, deliveries, csrf, loginHref, notice: n }) {
+function watchlistsPage({ viewer, lists, rules, deliveries, csrf, loginHref, defaultAudience = 'private', notice: n }) {
if (!viewer || viewer.kind !== 'user' || !viewer.subject) {
if (!viewer || viewer.kind !== 'user' || !viewer.subject) {
- return html`<h1>Watchlists</h1><p>Watchlists and alerts are private to your OpenVibe account.</p><p><a class="sc-btn sc-primary" href="${loginHref}">Sign in to see yours</a></p>`;
+ return html`<h1>Watchlists</h1><p>Watchlists and alerts are private to your OpenVibe account. <a href="${loginHref}">Sign in</a> to see yours.</p>`;
}
}
+ 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 ? ' selected' : ''}>${audienceText[a]}</option>`);
const listHtml = lists.map((w) => html`<section class="card" aria-label="${w.name}">
const listHtml = lists.map((w) => html`<section class="card" aria-label="${w.name}">
-<h2>${w.name}</h2>
+<h2><a href="/watchlists/${w.id}">${w.name}</a></h2>
${w.items.length ? html`<table tabindex="0" class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Note</th><th scope="col"></th></tr></thead><tbody>
${w.items.length ? html`<table tabindex="0" class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Note</th><th scope="col"></th></tr></thead><tbody>
${w.items.map((it) => html`<tr><td><a href="${new URL(it.instrument.url).pathname}">${it.instrument.symbol}</a></td><td>${it.instrument.name}</td><td>${it.note || ''}</td>
${w.items.map((it) => html`<tr><td><a href="${new URL(it.instrument.url).pathname}">${it.instrument.symbol}</a></td><td>${it.instrument.name}</td><td>${it.note || ''}</td>
<td><form method="post" action="/watchlists/item/remove" class="inline">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}"><input type="hidden" name="symbol" value="${it.instrument.symbol}"><button type="submit">Remove</button></form></td></tr>`)}
<td><form method="post" action="/watchlists/item/remove" class="inline">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}"><input type="hidden" name="symbol" value="${it.instrument.symbol}"><button type="submit">Remove</button></form></td></tr>`)}
</tbody></table>` : html`<p class="empty">Empty. Add instruments from their pages or below.</p>`}
</tbody></table>` : html`<p class="empty">Empty. Add instruments from their pages or below.</p>`}
+<p><button type="button" class="btn" data-ov-share-sheet data-kind="watchlist" data-id="${w.id}" data-title="${w.name}">Share</button></p>
<form method="post" action="/watchlists/item" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}">
<form method="post" action="/watchlists/item" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}">
<label>Add by ticker, CIK or name <input name="q" maxlength="200" required></label> <button type="submit">Add</button></form>
<label>Add by ticker, CIK or name <input name="q" maxlength="200" required></label> <button type="submit">Add</button></form>
<form method="post" action="/watchlists/rename" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}">
<form method="post" action="/watchlists/rename" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}">
@@ -449,9 +453,16 @@ ${w.items.map((it) => html`<tr><td><a href="${new URL(it.instrument.url).pathnam
@@ -449,9 +453,16 @@ ${w.items.map((it) => html`<tr><td><a href="${new URL(it.instrument.url).pathnam
const delRows = deliveries.map((x) => html`<li>${when(x.created_at)} — ${x.trigger.kind === 'observation'
const delRows = deliveries.map((x) => html`<li>${when(x.created_at)} — ${x.trigger.kind === 'observation'
? html`${x.trigger.metric} = ${x.trigger.value} ${x.trigger.unit}, observed ${when(x.trigger.observed_at)} (${x.trigger.source_key})`
? html`${x.trigger.metric} = ${x.trigger.value} ${x.trigger.unit}, observed ${when(x.trigger.observed_at)} (${x.trigger.source_key})`
: html`${x.trigger.form_type ? `${x.trigger.form_type}: ` : ''}${x.trigger.title || 'document'}, ${x.trigger.published_at ? html`published ${when(x.trigger.published_at)}` : 'no date stated'}`}</li>`);
: html`${x.trigger.form_type ? `${x.trigger.form_type}: ` : ''}${x.trigger.title || 'document'}, ${x.trigger.published_at ? html`published ${when(x.trigger.published_at)}` : 'no date stated'}`}</li>`);
- return html`<h1>Your watchlists <span class="badge">private</span></h1>
+ return html`<h1>Your watchlists <span class="badge">Only me by default</span></h1>
<p class="muted">Only you can see this page. It is never cached by shared caches, indexed or listed anywhere.</p>
<p class="muted">Only you can see this page. It is never cached by shared caches, indexed or listed anywhere.</p>
${notice(n)}
${notice(n)}
+<section class="card" aria-labelledby="new-list-h">
+<h2 id="new-list-h">New watchlists</h2>
+<form method="post" action="/watchlists/defaults" class="inline-form">${csrfField(csrf)}
+ <label>Who can open a new watchlist <select name="audience">${defaultOptions}</select></label>
+ <label><input type="checkbox" name="apply_all" value="1"> Apply to all my existing watchlists</label>
+ <button type="submit">Save</button></form>
+<p class="muted">A watchlist's Share button changes just that one. Your watchlists are never listed; a shared list is readable only by whoever holds the link or the people you add.</p></section>
${listHtml}
${listHtml}
<form method="post" action="/watchlists" class="card">${csrfField(csrf)}
<form method="post" action="/watchlists" class="card">${csrfField(csrf)}
<label for="wl-name">New watchlist</label><input id="wl-name" name="name" maxlength="80" required>
<label for="wl-name">New watchlist</label><input id="wl-name" name="name" maxlength="80" required>
@@ -463,6 +474,40 @@ ${rules.length ? html`<table tabindex="0" class="data"><thead><tr><th scope="col
@@ -463,6 +474,40 @@ ${rules.length ? html`<table tabindex="0" class="data"><thead><tr><th scope="col
${deliveries.length ? html`<ul>${delRows}</ul>` : html`<p class="empty">None yet.</p>`}`;
${deliveries.length ? html`<ul>${delRows}</ul>` : html`<p class="empty">None yet.</p>`}`;
}
}
+// ── One watchlist (its owner's, or a shared one) ────────────────────────────
+
+const WATCHLIST_AUDIENCES = { public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' };
+
+function watchlistPage({ list, items, mine = false, canEdit = false, audience = 'private', csrf, notice: n }) {
+ const who = WATCHLIST_AUDIENCES[audience] || 'Only me';
+ const removeCell = (it) => canEdit
+ ? html`<td><form method="post" action="/watchlists/item/remove" class="inline">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${list.id}"><input type="hidden" name="symbol" value="${it.symbol}"><button type="submit">Remove</button></form></td>`
+ : '';
+ return html`<h1>${list.name}</h1>
+<p class="muted"><span class="badge">${who}</span>${mine ? '' : ' · you are reading a shared watchlist'}${canEdit && !mine ? ' · you can add and remove instruments' : ''}</p>
+${notice(n)}
+${mine ? html`<p><button type="button" class="btn" data-ov-share-sheet data-kind="watchlist" data-id="${list.id}" data-title="${list.name}">Share</button></p>` : ''}
+${items.length ? html`<table tabindex="0" class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Note</th><th scope="col"></th></tr></thead><tbody>
+${items.map((it) => html`<tr><td><a href="/i/${encodeURIComponent(it.symbol)}">${it.symbol}</a></td><td>${it.name}</td><td>${it.note || ''}</td>${removeCell(it)}</tr>`)}
+</tbody></table>` : html`<p class="empty">Empty.</p>`}
+${canEdit ? html`<form method="post" action="/watchlists/item" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${list.id}">
+ <label>Add by ticker, CIK or name <input name="q" maxlength="200" required></label> <button type="submit">Add</button></form>` : ''}
+${mine ? html`<form method="post" action="/watchlists/rename" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${list.id}">
+ <label>Rename <input name="name" maxlength="80" value="${list.name}" required></label> <button type="submit">Rename</button></form>
+<form method="post" action="/watchlists/delete" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${list.id}"><button type="submit" class="danger">Delete this watchlist</button></form>` : ''}
+<p class="muted"><a href="/watchlists">Your watchlists</a> · Information only — not investment advice; no trading here.</p>`;
+}
+
+/**
+ * The page a person not on a Specific-people watchlist's list sees: the audience, never the list's name or the
+ * instruments on it. openvibe-shared draws the card from this markup (no inline script).
+ */
+function watchlistGate({ id, signedInAs = '', signInUrl = '/auth/login' }) {
+ return html`<h1>A watchlist shared with specific people</h1>
+<p>Sign in with the OpenVibe account it was shared with, or ask its owner for access.</p>
+<div id="watchlist-share-gate" data-ov-share-gate data-kind="watchlist" data-id="${id}" data-signed-in-as="${signedInAs}" data-sign-in-url="${signInUrl}">This is shared with specific people. Sign in with the OpenVibe account it was shared with, or ask its owner for access.</div>`;
+}
+
// ── Editor ───────────────────────────────────────────────────────────────────
// ── Editor ───────────────────────────────────────────────────────────────────
function editorHome({ instruments, pending, csrf, notice: n, kinds }) {
function editorHome({ instruments, pending, csrf, notice: n, kinds }) {
@@ -570,4 +615,4 @@ const observationStyles = `
@@ -570,4 +615,4 @@ const observationStyles = `
@media (min-width: 700px) { .obs-item { padding: 11px 14px 8px; } .obs-value { font-size: 1.6rem; } }
@media (min-width: 700px) { .obs-item { padding: 11px 14px 8px; } .obs-value { font-size: 1.6rem; } }
`;
`;
-module.exports = { observationStyles, instrumentsTable, instrumentsPage, filingsPage, filingLine, formLabel, FORM_LABEL, recentBriefs, tradeShowcase, resolvePage, instrumentPage, sourcesPage, watchlistsPage, editorHome, editorInstrument, errorPage, when, staleBadge, grouped, clip };
+module.exports = { observationStyles, instrumentsTable, instrumentsPage, filingsPage, filingLine, formLabel, FORM_LABEL, recentBriefs, tradeShowcase, resolvePage, instrumentPage, sourcesPage, watchlistsPage, watchlistPage, watchlistGate, editorHome, editorInstrument, errorPage, when, staleBadge, grouped, clip };
new file mode 100644
new file mode 100644
@@ -0,0 +1,181 @@
@@ -0,0 +1,181 @@
+'use strict';
+
+/**
+ * Sharing on OpenVibe.Trade (design 2026-11-06, visibility step 6), through openvibe-sdk/share.
+ *
+ * A watchlist is Only me until its owner says otherwise. The Share sheet changes just that list; the person's default
+ * for new lists lives on /watchlists ("New watchlists: Only me"). A shared list's page and its API read ask the one
+ * check, canView; adding or removing instruments asks canEdit, so a list shared with Specific people Can edit is one a
+ * friend can actually build with you.
+ *
+ * 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 watchlist 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 = 'trade';
+const KIND = 'watchlist';
+const AUDIENCES = ['public', 'link', 'people', 'private'];
+const DEFAULT_AUDIENCE = 'private';
+/** A watchlist 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) });
+
+/** Trade's viewer as the SDK wants it: a subject object. An editor is not staff for a person's watchlist. */
+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 watchlist's name or the instruments on it. */
+ async function notify(n) {
+ const to = n && n.to;
+ if (!to || to.type !== 'user') return;
+ const words = {
+ 'share.added': ['A watchlist was shared with you', 'Open it on OpenVibe.Trade.'],
+ 'share.access_requested': ['Someone asked for access to your watchlist', 'Review the request on OpenVibe.Trade.'],
+ 'share.transfer.requested': ['Someone wants to give you a watchlist', 'Accept or decline it on OpenVibe.Trade.'],
+ 'share.transfer.answered': ['A watchlist transfer was answered', 'See what happened on OpenVibe.Trade.'],
+ }[n.kind] || ['Sharing on OpenVibe.Trade', '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: 'trade', url: n.ref && n.ref.id ? `${config.baseUrl}/watchlists/${encodeURIComponent(n.ref.id)}` : `${config.baseUrl}/watchlists`,
+ category: 'social',
+ },
+ });
+ } catch (err) {
+ // Network may not have seeded Trade's 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 watchlist, 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 list 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 trade_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 lists 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 trade_share_defaults (subject, audience, updated_at) VALUES ($1, $2, now())
+ ON CONFLICT (subject) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [String(subject), want]);
+ if (applyAll) {
+ await db.exec(`UPDATE shares SET audience = $1, revision = revision + 1
+ WHERE service = $2 AND kind = $3 AND owner_subject->>'id' = $4 AND owner_subject->>'type' = 'user'`,
+ [want, SERVICE, KIND, String(subject)]);
+ }
+ return true;
+ },
+ /** The uniform API's describe(ref): the list's name, its page, and only the audiences a list may use. */
+ describe: async (ref) => {
+ if (ref.service !== SERVICE || ref.kind !== KIND) return {};
+ const row = await db.maybe('SELECT id, name FROM trade_watchlists WHERE id = $1', [String(ref.id)]);
+ if (!row) return {};
+ return {
+ title: String(row.name || 'A watchlist').slice(0, 120),
+ link: `${config.baseUrl}/watchlists/${encodeURIComponent(row.id)}`,
+ roles: ['viewer', 'editor'],
+ audiences: OFFERED,
+ transferable: false,
+ };
+ },
+ /** A deleted watchlist 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 };
@@ -42,7 +42,7 @@ const envelope = (id, type, payload) => ({ event: { event_id: id, event_type: ty
@@ -42,7 +42,7 @@ const envelope = (id, type, payload) => ({ event: { event_id: id, event_type: ty
try {
try {
const missing = await uncovered(d.db, {
const missing = await uncovered(d.db, {
tables: TABLES,
tables: TABLES,
- exempt: {"account_data_events":"the ledger of requests itself: which export and deletion trade applied","trade_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash"},
+ exempt: {"account_data_events":"the ledger of requests itself: which export and deletion trade applied","trade_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash","share_members":"the SDK's own sharing model: subject is an added person, erased with the watchlist by extraErase","share_access_requests":"the SDK's own sharing model: subject is the person who asked, erased by extraErase","trade_share_defaults":"a person's own default for new watchlists, erased by extraErase"},
});
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
} finally { await d.close(); }
} finally { await d.close(); }
@@ -32,9 +32,27 @@ async function startNetwork() {
@@ -32,9 +32,27 @@ async function startNetwork() {
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const grants = [];
const grants = [];
+ const users = new Map();
+ const byUsername = new Map();
let issuer = null;
let issuer = null;
const srv = await listen((req, raw, json) => {
const srv = await listen((req, raw, json) => {
if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
+ if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {
+ const ids0 = JSON.parse(raw || '{}').subject_ids || [];
+ const results = {};
+ for (const subjectId of ids0) {
+ const u = users.get(subjectId);
+ if (u) results[subjectId] = { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name };
+ }
+ return json(200, { results });
+ }
+ if (req.url.startsWith('/internal/identity/resolve') && req.method === 'GET') {
+ const name = String(new URL(req.url, 'http://x').searchParams.get('username') || '').toLowerCase();
+ const found = byUsername.get(name);
+ if (found) return json(200, { subject: { type: 'user', id: found.subject }, username: found.username, display_name: found.display_name });
+ return json(404, { code: 'identity.not_found' });
+ }
+ if (req.url === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
if (req.url === '/oauth/token' && req.method === 'POST') {
if (req.url === '/oauth/token' && req.method === 'POST') {
let body = {};
let body = {};
if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));
if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));
@@ -58,7 +76,10 @@ async function startNetwork() {
@@ -58,7 +76,10 @@ async function startNetwork() {
return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: actorType, aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...app, ...extra }, privatePem);
return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: actorType, aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...app, ...extra }, privatePem);
}
}
function addUser(username, extra = {}) {
function addUser(username, extra = {}) {
- return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };
+ const u = { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };
+ users.set(u.subject, u);
+ byUsername.set(String(username).toLowerCase(), u);
+ return u;
}
}
function userToken(u) {
function userToken(u) {
return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role || 'user', aud: ['openvibe.network', 'openvibe.trade'] }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role || 'user', aud: ['openvibe.network', 'openvibe.trade'] }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
@@ -59,6 +59,33 @@ const { boot, check, done } = require('./helpers/boot');
@@ -59,6 +59,33 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}`)).status, 401, 'anonymous API');
assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}`)).status, 401, 'anonymous API');
});
});
+ await check('a Specific-people list answers a non-member exactly as a missing list does', async () => {
+ const carol = t.network.addUser('carol');
+ const made = await t.get('/watchlists', form(t.alice, { name: 'Shared plans' }));
+ assert.strictEqual(made.status, 303);
+ const shared = (await t.ctx.watchlists.forOwner(t.alice.subject)).find((w) => w.name === 'Shared plans');
+ const call = (p, o = {}) => t.get(p, { ...o, headers: { 'x-requested-with': 'ov-share', ...(o.headers || {}) } });
+ const first = await call(`/api/v1/watchlist/${shared.id}/share`, { as: t.alice });
+ assert.strictEqual(first.status, 200, first.text);
+ const saved = await call(`/api/v1/watchlist/${shared.id}/share`, {
+ as: t.alice, method: 'PUT', json: { audience: 'people', members: [{ name: 'carol', role: 'viewer' }] },
+ headers: { 'x-requested-with': 'ov-share', 'if-match': first.headers.get('etag') },
+ });
+ assert.strictEqual(saved.status, 200, saved.text);
+ const hidden = await t.get(`/api/v1/watchlists/${shared.id}`, { as: t.bob });
+ const missing = await t.get('/api/v1/watchlists/wl_01J0000000000000000000000', { as: t.bob });
+ assert.strictEqual(hidden.status, 404);
+ assert.strictEqual(hidden.status, missing.status);
+ assert.strictEqual(hidden.json().code, missing.json().code);
+ assert.strictEqual(hidden.json().detail, missing.json().detail);
+ assert.strictEqual(hidden.json().title, missing.json().title);
+ assert.ok(!hidden.text.includes('Shared plans'), 'nothing about the list');
+ assert.strictEqual((await t.get(`/api/v1/watchlists/${shared.id}`)).status, 401, 'anonymous API');
+ assert.strictEqual((await t.get(`/watchlists/${shared.id}`, { as: carol })).status, 200, 'the member reads it');
+ // Leave alice with the one list the later checks count.
+ assert.strictEqual((await t.get(`/api/v1/watchlists/${shared.id}`, { method: 'DELETE', as: t.alice })).status, 200);
+ });
+
await check('the API answers the owner, privately', async () => {
await check('the API answers the owner, privately', async () => {
const r = await t.get('/api/v1/watchlists', { as: t.alice });
const r = await t.get('/api/v1/watchlists', { as: t.alice });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
new file mode 100644
new file mode 100644
@@ -0,0 +1,158 @@
@@ -0,0 +1,158 @@
+'use strict';
+/**
+ * Sharing a watchlist (design 2026-11-06, visibility step 6): the one Share sheet (openvibe-sdk/share) and the one
+ * check, canView, on the watchlist page and its API. Editing asks canEdit, so Specific people with Can edit build a
+ * list together.
+ *
+ * - a new watchlist is Only me; its card carries the Share button for its owner;
+ * - Anyone with the link reads the list page and the API, and only its owner (or an editor) changes it;
+ * - Specific people shows the gate, never the list's name or its instruments, to somebody not on the list;
+ * - Can edit really adds and removes instruments; Can view only reads;
+ * - the person's default for new lists, and "apply to all", work;
+ * - a watchlist is never on a gathered page: not the sitemap, not llms.txt.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const sharing = require('../server/sharing');
+const { createWatchlists } = require('../server/domain/watchlists');
+const { createPrivate } = require('../server/http/private');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('the sharing wiring loads: watchlists are never Everyone, and an editor is not staff here', async () => {
+ assert.strictEqual(sharing.SERVICE, 'trade');
+ assert.strictEqual(sharing.KIND, 'watchlist');
+ assert.deepStrictEqual(sharing.OFFERED, ['private', 'link', 'people']);
+ assert.strictEqual(sharing.shareViewer({ kind: 'anonymous', subject: null }), null);
+ assert.deepStrictEqual(sharing.shareViewer({ kind: 'user', subject: 'usr_x', editor: true }), { subject: { type: 'user', id: 'usr_x' }, staff: false });
+ assert.strictEqual(typeof createWatchlists, 'function');
+ assert.strictEqual(typeof createPrivate, 'function');
+ });
+ const kim = t.network.addUser('kim');
+ const bo = t.network.addUser('bo');
+ const cy = t.network.addUser('cy');
+ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp', exchange: 'NYSE' });
+ const beta = await t.instrument({ symbol: 'BETA', name: 'Beta Holdings', exchange: 'NYSE' });
+
+ const call = (pathname, o = {}) => {
+ const { headers, ...rest } = o;
+ return t.get(pathname, { ...rest, headers: { 'x-requested-with': 'ov-share', ...(headers || {}) } });
+ };
+ const share = (id, o = {}) => call(`/api/v1/watchlist/${encodeURIComponent(id)}/share`, o);
+ const setShare = async (id, body, as) => {
+ const first = await share(id, { as });
+ assert.strictEqual(first.status, 200, first.text);
+ const etag = first.headers.get('etag');
+ assert.ok(etag);
+ return await share(id, { as, method: 'PUT', json: body, headers: { 'if-match': etag } });
+ };
+ const audienceRow = async (id) => await t.ctx.store.db.maybe(
+ 'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['trade', 'watchlist', id]);
+ const newList = async (as, name = 'Semis') => {
+ const made = await t.get('/api/v1/watchlists', { as, json: { name } });
+ assert.strictEqual(made.status, 201, made.text);
+ const id = made.json().watchlist.id;
+ return id;
+ };
+
+ let id;
+ await check('a new watchlist is Only me, and its card carries the Share button for its owner', async () => {
+ id = await newList(kim);
+ assert.deepStrictEqual(await audienceRow(id), { audience: 'private' });
+ await t.get(`/api/v1/watchlists/${id}/items/ACME`, { method: 'PUT', as: kim, json: { note: 'watching' } });
+ const mine = await t.get('/watchlists', { as: kim });
+ assert.strictEqual(mine.status, 200, mine.text);
+ assert.ok(mine.text.includes('data-ov-share-sheet') && mine.text.includes('data-kind="watchlist"'));
+ assert.ok(mine.text.includes('ov-share.js'));
+ assert.ok(mine.text.includes('Who can open a new watchlist'));
+ const own = await t.get(`/watchlists/${id}`, { as: kim });
+ assert.strictEqual(own.status, 200, own.text);
+ assert.ok(own.text.includes('Semis') && own.text.includes('ACME'));
+ const other = await t.get(`/watchlists/${id}`, { as: bo });
+ assert.strictEqual(other.status, 404);
+ assert.ok(!other.text.includes('Semis'), 'the list is never named to somebody else');
+ assert.strictEqual((await t.get(`/watchlists/${id}`)).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/watchlists/${id}`)).status, 401, 'the API needs a signed-in caller, as before');
+ });
+
+ await check('Anyone with the link reads the page and the API, and cannot edit', async () => {
+ const saved = await setShare(id, { audience: 'link' }, kim);
+ assert.strictEqual(saved.status, 200, saved.text);
+ assert.strictEqual(saved.json().audience, 'link');
+ const guest = await t.get(`/watchlists/${id}`);
+ assert.strictEqual(guest.status, 200, guest.text);
+ assert.ok(guest.text.includes('Semis') && guest.text.includes('ACME'));
+ assert.ok(guest.text.includes('Anyone with the link'));
+ assert.ok(!guest.text.includes('data-ov-share-sheet'), 'a reader gets no Share sheet');
+ assert.ok(!guest.text.includes('action="/watchlists/item"'), 'a reader gets no add form');
+ assert.ok(!guest.text.includes('Delete this watchlist'));
+ // The API needs a signed-in caller; a member of the link audience then reads it.
+ assert.strictEqual((await t.get(`/api/v1/watchlists/${id}`)).status, 401);
+ const api = await t.get(`/api/v1/watchlists/${id}`, { as: bo });
+ assert.strictEqual(api.status, 200);
+ assert.strictEqual(api.json().can_edit, false);
+ assert.strictEqual((await t.get(`/api/v1/watchlists/${id}/items/BETA`, { method: 'PUT', as: bo, json: {} })).status, 404);
+ });
+
+ await check('Specific people: an editor adds and removes instruments, a viewer only reads, a stranger gets the gate', async () => {
+ const saved = await setShare(id, { audience: 'people', members: [{ name: 'bo', role: 'editor' }, { name: 'cy', role: 'viewer' }] }, kim);
+ assert.strictEqual(saved.status, 200, saved.text);
+ assert.strictEqual(saved.json().audience, 'people');
+ const editor = await t.get(`/watchlists/${id}`, { as: bo });
+ assert.strictEqual(editor.status, 200, editor.text);
+ assert.ok(editor.text.includes('Semis'));
+ assert.ok(editor.text.includes('action="/watchlists/item"'), 'Can edit opens the add form');
+ assert.ok(!editor.text.includes('Delete this watchlist'), 'only the owner deletes');
+ const added = await t.get('/watchlists/item', { as: bo, form: { _csrf: t.csrf(bo), watchlist_id: id, q: 'BETA' } });
+ assert.strictEqual(added.status, 303, added.text);
+ assert.strictEqual(await t.ctx.store.db.value('SELECT COUNT(*) FROM trade_watchlist_items WHERE watchlist_id = $1', [id]), 2);
+ const removed = await t.get('/watchlists/item/remove', { as: bo, form: { _csrf: t.csrf(bo), watchlist_id: id, symbol: 'BETA' } });
+ assert.strictEqual(removed.status, 303, removed.text);
+ const viewer = await t.get(`/watchlists/${id}`, { as: cy });
+ assert.strictEqual(viewer.status, 200);
+ assert.ok(!viewer.text.includes('action="/watchlists/item"'), 'Can view cannot edit');
+ const refused = await t.get('/watchlists/item', { as: cy, form: { _csrf: t.csrf(cy), watchlist_id: id, q: 'BETA' } });
+ assert.strictEqual(refused.status, 303, refused.text);
+ assert.ok(/view-only/.test(refused.headers.get('location') || ''), 'a viewer is told the list is read-only');
+ assert.strictEqual(await t.ctx.store.db.value('SELECT COUNT(*) FROM trade_watchlist_items WHERE watchlist_id = $1', [id]), 1);
+ const stranger = t.network.addUser('di');
+ const gate = await t.get(`/watchlists/${id}`, { as: stranger });
+ assert.strictEqual(gate.status, 404);
+ assert.ok(gate.text.includes('shared with specific people'));
+ assert.ok(!gate.text.includes('Semis'), 'the gate never shows the name');
+ assert.ok(!gate.text.includes('ACME'), 'the gate never shows an instrument');
+ assert.ok(gate.text.includes('data-ov-share-gate'));
+ assert.strictEqual((await t.get(`/api/v1/watchlists/${id}`, { as: stranger })).status, 404);
+ });
+
+ await check('the default applies to the next list, and "apply to all" moves the existing one', async () => {
+ const saved = await t.get('/watchlists/defaults', { as: kim, form: { _csrf: t.csrf(kim), audience: 'link', apply_all: '1' } });
+ assert.strictEqual(saved.status, 303, saved.text);
+ assert.deepStrictEqual(await audienceRow(id), { audience: 'link' });
+ const next = await newList(kim, 'Banks');
+ assert.deepStrictEqual(await audienceRow(next), { audience: 'link' });
+ const start = await t.get('/watchlists', { as: kim });
+ assert.ok(start.text.includes('Who can open a new watchlist'), 'the default is on the product page');
+ assert.strictEqual((await t.get(`/watchlists/${next}`)).status, 200, 'the next list follows the default');
+ });
+
+ await check('deleting a watchlist takes its share row with it', async () => {
+ const gone = await t.get(`/api/v1/watchlists/${id}`, { method: 'DELETE', as: kim });
+ assert.strictEqual(gone.status, 200, gone.text);
+ assert.strictEqual(await audienceRow(id), null);
+ });
+
+ await check('a watchlist is never on a gathered page: not the sitemap, not llms.txt', async () => {
+ const sitemap = await t.get('/sitemap.xml');
+ assert.strictEqual(sitemap.status, 200);
+ assert.ok(!sitemap.text.includes(id));
+ const llms = await t.get('/llms.txt');
+ assert.ok(!llms.text.includes(id) && !/watchlist/i.test(llms.text.split('\n').slice(0, 30).join('\n')));
+ });
+
+ done();
+ } finally {
+ await t.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });