openvibers/openvibe.trade Commits

Watchlists: the one Share sheet, Only me by default (#217)

OP OpenVibers committed 6 days ago · bed26d1aac · 13 files, +679 −24

* Trade: share a watchlist with the one Share sheet, Only me by default

Visibility step 6 (design 2026-11-06): a watchlist uses openvibe-sdk/share and
the shared ov-share.js sheet. Only me by default, Anyone with the link to send
one, Specific people with Can view or Can edit; canView on the page and the
API, canEdit on adding and removing instruments. A person's default for new
lists lives on /watchlists with apply-to-all. The share row is the authority
(no old column exists); deleting a list takes its row, and account deletion
erases the person's share rows and memberships.

* Trade: a private watchlist answers anonymous callers 401, non-owners 404

The API read path keeps the old rule: an anonymous caller gets the same 401
whether the list exists or not, and a signed-in non-owner (including a reader
of a Specific-people list) gets the same 404 and body a missing list gets.
Adds the Specific-people disclosure check to test/privacy.test.js.

Changes

ddiff --git a/migrations/0008_shares.sql b/migrations/0008_shares.sql
nnew file mode 100644
iindex 0000000..07f2298
---- /dev/null
++++ b/migrations/0008_shares.sql
@@@ -0,0 +1,88 @@
++-- phase: expand
++-- The one sharing system (openvibe-sdk/share) on a watchlist: its audience, the people it is shared with, the log and the
++-- delivery queue the SDK writes. Kept verbatim from the SDK's own schema (node_modules/openvibe-sdk/src/share/schema.js
++-- at the pinned version), so its store finds the tables it expects and the test database has them.
++-- A watchlist's own row is trade_watchlists; one share row per watchlist, kind 'watchlist', id = wl_….
++
++
++CREATE TABLE IF NOT EXISTS shares (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
++ revision integer NOT NULL DEFAULT 1,
++ lock text NOT NULL DEFAULT 'none',
++ reseal_nonce_base text,
++ PRIMARY KEY (service, kind, resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_members (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
++ key_slot jsonb,
++ PRIMARY KEY (service, kind, resource_id, subject),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_transfers (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
++ key_slot jsonb,
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_access_requests (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_log (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
++DO $$ BEGIN
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
++ END IF;
++END $$;
++CREATE TABLE IF NOT EXISTS share_deliveries (
++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE TABLE IF NOT EXISTS share_name_limits (
++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
++);
++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
++-- slot a transfer was offered with. Repeating this migration is a no-op.
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
++DO $$ BEGIN
++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
++EXCEPTION WHEN duplicate_object THEN NULL; END $$;
++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
++
++-- Visibility step 6 (design 2026-11-06): the person's default audience for new 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;
ddiff --git a/server/app.js b/server/app.js
iindex 957ae4a..163cdb5 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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) ──
ddiff --git a/server/domain/account-data.js b/server/domain/account-data.js
iindex 9e786c9..dcce911 100644
---- a/server/domain/account-data.js
++++ b/server/domain/account-data.js
@@@ -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.',
});
}
ddiff --git a/server/domain/watchlists.js b/server/domain/watchlists.js
iindex ee42830..c1cdae0 100644
---- a/server/domain/watchlists.js
++++ b/server/domain/watchlists.js
@@@ -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');
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 3fb3eb3..067f89e 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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);
ddiff --git a/server/http/private.js b/server/http/private.js
iindex c6f913e..1347c61 100644
---- a/server/http/private.js
++++ b/server/http/private.js
@@@ -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); }
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex d480004..0731b28 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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,
ddiff --git a/server/render/views.js b/server/render/views.js
iindex b119d8a..6a62efe 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -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).pathnam
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.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 };
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..c864a71
---- /dev/null
++++ b/server/sharing.js
@@@ -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 };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 9fda3a6..8014b28 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -42,7 +42,7 @@ const envelope = (id, type, payload) => ({ event: { event_id: id, event_type: ty
try {
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(); }
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 0b59ecb..a933abf 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -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' });
ddiff --git a/test/privacy.test.js b/test/privacy.test.js
iindex bbceb22..7997fb6 100644
---- a/test/privacy.test.js
++++ b/test/privacy.test.js
@@@ -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);
ddiff --git a/test/sharing.test.js b/test/sharing.test.js
nnew file mode 100644
iindex 0000000..a1ebfb5
---- /dev/null
++++ b/test/sharing.test.js
@@@ -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); });