openvibers/openvibe.network Commits
Sharing: Network's part — the shared list, the notifications, the picker's suggestions (#575)
Changes
@@@ -853,6 +853,42 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:`ADMIN_PASSWORD`, `NETWORK_EVENTS_SECRET`, the webhook secrets and `GITHUB_TOKEN` live in`/etc/openvibe/network.env` (0600), by name only.++## Sharing++++The sharing system (design 2026-11-06) lets a person share an item on one site with another person.++The owning service stays the authority on access; Network keeps the index and the list.++++### The events++++Every share event arrives over OpenVibe.Events at `POST /internal/events` and is handled in++`server/sharing/events.js`:++++| Event | What Network does |++|-------|-------------------|++| `share.granted` | the item joins that person's list, and they get the notification `share.added` |++| `share.revoked` | the item leaves that person's list |++| `share.transfer.requested` | the offer waits for the recipient, who gets `share.transfer.requested` |++| `share.transfer.accepted` | the recipient owns it now; the old owner gets `share.transfer.answered` |++| `share.transfer.declined` | nothing changed; the old owner gets `share.transfer.answered` |++| `share.access.requested` | the owner gets `share.access_requested` |++++The payloads carry ids only (a `{ service, kind, id }` ref and a subject), never a title. The name of++an item and its own link come from the owning service's share API, `GET /api/v1/<kind>/<id>/share`,++asked on the person's behalf; when the service does not answer, the item is shown as its site and its++kind instead.++++### The routes++++| Route | What it answers |++|-------|-----------------|++| `GET /shared` | the page: every item shared with the signed-in person, on every site |++| `GET /api/v1/shared-with-me` | the same list as JSON, with the pending transfers and their Accept and Decline links |++| `GET /api/v1/share/suggestions` | the people this person has shared with (the share picker's suggestions): from their own history only, at most 20 |++| `GET /api/v1/share/name-check?name=` | one @name check for the share picker, thirty a minute for the person |++++The event names and the payload shapes are Contracts share.v1, which is still being written; this code++waits for that release before it ships.++## LicenseMIT (`license` in [package.json](package.json)).nnew file mode 100644@@@ -0,0 +1,54 @@++-- phase: expand++-- Sharing on OpenVibe (design 2026-11-06, SHARING-DESIGN-2026-11-06.md, section 4 "Network"):++-- Network's own record of what is shared with whom, built from the share events over OpenVibe.Events++-- (share.granted, share.revoked, share.transfer.*). The owning service stays the authority on access++-- (its own canView decides); these rows are Network's index for the "Shared with me" list, the++-- notification texts and the share picker's suggestions.++--++-- Timestamps are ISO-8601 UTC strings ("2026-11-06T09:12:00.000Z"), so comparisons stay lexical on++-- PostgreSQL and PGlite, like the rest of the newer tables.++CREATE TABLE share_grants (++ id text COLLATE "C" PRIMARY KEY,++ recipient_subject text COLLATE "C" NOT NULL,++ owner_subject text COLLATE "C" NOT NULL,++ service text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL,++ item_id text COLLATE "C" NOT NULL,++ role text COLLATE "C" NOT NULL DEFAULT 'viewer' CHECK (role IN ('viewer', 'editor')),++ expires_at text COLLATE "C",++ granted_at text COLLATE "C" NOT NULL,++ revoked_at text COLLATE "C",++ event_id text COLLATE "C"++);++-- One live membership per item and person: a second grant for the same item updates the row.++CREATE UNIQUE INDEX share_grants_item ON share_grants (recipient_subject, service, kind, item_id);++CREATE INDEX share_grants_recipient ON share_grants (recipient_subject, revoked_at, granted_at);++CREATE INDEX share_grants_owner ON share_grants (owner_subject, granted_at);++++-- Ownership transfers: pending until the recipient accepts or declines, then the answer.++CREATE TABLE share_transfers (++ id text COLLATE "C" PRIMARY KEY,++ service text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL,++ item_id text COLLATE "C" NOT NULL,++ from_subject text COLLATE "C" NOT NULL,++ to_subject text COLLATE "C" NOT NULL,++ keep_access integer NOT NULL DEFAULT 0,++ state text COLLATE "C" NOT NULL DEFAULT 'pending'++ CHECK (state IN ('pending', 'accepted', 'declined', 'cancelled', 'expired')),++ requested_at text COLLATE "C" NOT NULL,++ expires_at text COLLATE "C",++ answered_at text COLLATE "C",++ event_id text COLLATE "C"++);++CREATE INDEX share_transfers_to ON share_transfers (to_subject, state, requested_at);++CREATE INDEX share_transfers_from ON share_transfers (from_subject, state, requested_at);++++-- The @name checks of the share picker, counted per person: nobody may walk the network's accounts.++CREATE TABLE share_name_checks (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,++ name text COLLATE "C" NOT NULL,++ checked_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()++);++CREATE INDEX share_name_checks_user_time ON share_name_checks (user_id, checked_at);@@@ -402,9 +402,15 @@ app.locals.emailService = emailService;const moderationAudit = require('./admin/moderation-audit').createModerationAudit(db);// Developer projects' usage (WS-N task 4): the services' hourly rollups, per project and day.const projectUsage = require('./developer/usage').createProjectUsage(db);++// Sharing (design 2026-11-06, SHARING-DESIGN-2026-11-06.md): Network's index of what is shared with++// whom, built from the share events, and the notifications that go with them (server/sharing/).++const sharing = {++ db, notifications: notificationService, config, privateKey, issuer: config.jwt.issuer,++};eventsConsumer = await require('./notifications/events-consumer').createEventsConsumer({db, notifications: notificationService, secrets: config.eventsWebhookSecrets,discord: () => app.locals.discordService || null, moderationAudit, projectUsage,++ sharing: { apply: (event) => require('./sharing/events').applyShareEvent(sharing, event) },});console.log(`[Events consumer] ${eventsConsumer.enabled ? 'on' : 'off (NETWORK_EVENTS_SECRET unset)'}: POST /internal/events`);@@@ -430,6 +436,9 @@ const qrSignin = require('./auth/qr-signin').createQrSignin({app.locals.qrSignin = qrSignin;// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());++// Sharing (design 2026-11-06): what is shared with this person, the share picker's suggestions and++// the @name check it uses (server/sharing/routes.js).++app.use('/api/v1', require('./sharing/routes').createSharingApi({ ...sharing, requireAuth }));// Staff capabilities and the staff list (WS-D task 4; services need network.staff.read).app.use('/api/v1/staff', require('./admin/staff-api').createStaffApi({ db, requireAuth, guard: require('./identity/principals').guard }));// Username history (WS-B task 6): who holds a name now, so sites redirect /@old → /@new and pick up a@@@ -914,6 +923,9 @@ function sendPayPage(res) {}app.get('/wallet', (_req, res) => sendWalletPage(res));app.get('/wallet.html', (req, res) => redirectWithoutHtml(req, res, '/wallet'));++// "Shared with me" (design 2026-11-06): everything other people have shared with this person, on++// every site, in one list. Its own page, in the kit's components and with no styles of its own.++app.get('/shared', require('./sharing/page').createSharedPage({ ...sharing, authCtx: authCtx() }));app.get('/pay/:id', (_req, res) => sendPayPage(res));// Account hub (my.html) — the apex hosts the account hub under /my@@@ -29,6 +29,10 @@* per streamer in 24 hours.* today.event.cancelled the stream was cancelled: those notices are cleared (dismissed), so the bell never* points at a stream that is not happening.++ * share.granted, share.revoked, share.transfer.* the sharing system (design 2026-11-06): an item++ * was shared with a person, their access ended, or an ownership transfer was++ * requested or answered. They build Network's "Shared with me" list and the++ * share notifications (server/sharing/events.js); the payloads carry ids only.* <service>.usage.recorded a developer project's hourly usage rollup from Tools, Events or Media* (common.usage-recorded@1): kept per project and day for its dashboard* (server/developer/usage.js), never anyone's notification.@@@ -61,8 +65,11 @@ const streamLive = require('./stream-live');const CONSUMER = 'network-notifications';const INBOX_TABLE = 'network_event_inbox';const AUDIT_TOPICS = require('../admin/moderation-audit').TOPICS;++// Sharing (design 2026-11-06): the share events build Network's "Shared with me" list and the++// notifications that go with it (server/sharing/events.js).++const SHARE_TOPICS = require('../sharing/events').TOPICS;const USAGE_TOPICS = require('../developer/usage').TOPICS;--const TOPICS = Object.freeze(['deals.watch.matched', 'coupons.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', 'today.event.scheduled', 'today.event.cancelled', ...AUDIT_TOPICS, ...USAGE_TOPICS]);++const TOPICS = Object.freeze(['deals.watch.matched', 'coupons.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', 'today.event.scheduled', 'today.event.cancelled', ...SHARE_TOPICS, ...AUDIT_TOPICS, ...USAGE_TOPICS]);const LIVE_STARTED_MAX_AGE_MS = 30 * 60 * 1000;const SCHEDULED_PER_DAY = 3;const SCHEDULE_AHEAD_MIN_MS = 15 * 60 * 1000;@@@ -216,8 +223,9 @@ function liveStarted(event, { now, maxAgeMs }) {* @param {string|string[]} o.secrets NETWORK_EVENTS_SECRET (comma list) or an array* @param {() => ({ sendLiveAlert(streamer: object, stream: object): Promise<object> }|null)} [o.discord]* @param {{ record(event: object): string }} [o.projectUsage] ../developer/usage.js createProjectUsage()++ * @param {{ apply(event: object): Promise<{outcome: string, detail?: object}> }} [o.sharing] ../sharing/events.js*/--async function createEventsConsumer({ db, notifications, secrets, discord = () => null, moderationAudit = null, projectUsage = null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {++async function createEventsConsumer({ db, notifications, secrets, discord = () => null, moderationAudit = null, projectUsage = null, sharing = null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {const keys = Array.isArray(secrets) ? secrets.filter((s) => typeof s === 'string' && s.length >= 32) : secretsFrom(secrets);const inbox = createPgInbox(db, { table: INBOX_TABLE, now });const userBySubject = db.prepare('SELECT id FROM users WHERE subject_id = ?');@@@ -312,6 +320,12 @@ async function createEventsConsumer({ db, notifications, secrets, discord = () =const out = event.event_type === 'today.event.scheduled' ? await todayEventScheduled(event) : await todayEventCancelled(event);return typeof out === 'string' ? out : { outcome: out.outcome, detail: out.detail };}++ // Sharing: the index of what is shared with whom, and the notifications about it.++ if (SHARE_TOPICS.includes(event.event_type)) {++ if (!sharing) return 'ignored:sharing_off';++ const out = await sharing.apply(event);++ return typeof out === 'string' ? out : { outcome: out.outcome, detail: out.detail };++ }if (event.event_type === 'live.stream.started') {const out = await liveStreamStarted(event);if (typeof out === 'string') return out;@@@ -9,8 +9,10 @@// ═══════════════════════════════════════════════════════════════const TABS = {-- // What people said to you or about you.-- mentions: { types: ['MENTION', 'CONTENT_REPLY', 'CONTENT_COMMENT', 'DIRECT_MESSAGE', 'WHISPER'] },++ // What people said to you or about you, and what they shared with you (the share kinds,++ // design 2026-11-06).++ mentions: { types: ['MENTION', 'CONTENT_REPLY', 'CONTENT_COMMENT', 'DIRECT_MESSAGE', 'WHISPER',++ 'share.added', 'share.access_requested', 'share.transfer.requested', 'share.transfer.answered'] },live: { categories: ['stream'] },// Deal, coupon, trade and watch alerts: the service category.alerts: { categories: ['service'] },nnew file mode 100644@@@ -0,0 +1,271 @@++'use strict';++/**++ * The share events, and the notifications they become (design 2026-11-06, SHARING-DESIGN-2026-11-06.md).++ *++ * OpenVibe.Events delivers every share event to Network (POST /internal/events). Each one does two++ * things: it updates Network's index of what is shared with whom (server/sharing/store.js) and it++ * makes the notification the design asks for:++ *++ * share.granted the person who was added gets "goosely shared \"LOOI bridge notes\" with you"++ * share.access.requested the owner gets "goosely asked for access to \"LOOI bridge notes\""++ * share.transfer.requested the recipient gets "goosely wants to give you \"LOOI bridge notes\""++ * share.transfer.accepted the old owner gets "goosely accepted \"LOOI bridge notes\". Now it is yours"++ * share.transfer.declined the old owner gets "goosely declined \"LOOI bridge notes\""++ *++ * The events carry ids only, so the name of the item comes from the owning service's share API++ * (server/sharing/item.js). When the service does not answer, the text says the site and the kind++ * instead: a notification never guesses a title.++ *++ * The event names and shapes are the ones of Contracts share.v1, which is still being written++ * (OpenVibe.Contracts, branch "share"): this code waits for that release before it ships.++ */++const store = require('./store');++const { refOf, memberOf, partyOf, ownerOf, whenOf } = require('./refs');++const { resolveItem, mintViewerToken, audienceOf, siteOf } = require('./item');++++const TOPICS = Object.freeze([++ 'share.granted',++ 'share.revoked',++ 'share.transfer.requested',++ 'share.transfer.accepted',++ 'share.transfer.declined',++ 'share.access.requested',++]);++++// The notification kinds of the design. They are Network's own kinds, sent through the ordinary++// inbox (server/notifications/notification-service.js), so a person can mute them like any other.++const KINDS = Object.freeze({++ added: 'share.added',++ accessRequested: 'share.access_requested',++ transferRequested: 'share.transfer.requested',++ transferAnswered: 'share.transfer.answered',++});++++/** "@goosely", or "someone" when the owner is not a Network account we know. */++function nameOf(person) {++ if (!person) return 'Someone';++ return '@' + String(person.username || '').slice(0, 40);++}++++/** "a paste on OpenVibe.Community": what the text says when the service does not name the item. */++function fallbackName(ref) {++ const site = siteOf(ref.service);++ const kind = String(ref.kind || 'item').replace(/[_-]+/g, ' ');++ return 'a ' + kind + (site ? ' on ' + (site.full_name || site.name) : '');++}++++/** The name of the item: the service's own, or the site and kind. */++function itemName(details, ref) {++ return details && details.title ? '"' + details.title + '"' : fallbackName(ref);++}++++/**++ * The item's name and link, as the owning service sees them, for one person.++ * @returns {Promise<{title: string|null, url: string|null}>}++ */++async function detailsFor(ctx, ref, person) {++ const audience = audienceOf(ref.service);++ const token = person && audience ? mintViewerToken(ctx, person, audience) : null;++ return await resolveItem(ctx, ref, { token });++}++++/**++ * One notification, for one person.++ * @returns {Promise<string>} the outcome, for the consumer's log.++ */++async function notify(ctx, { to, from, kind, title, message, icon, priority = 'normal', ref, url }) {++ const db = ctx.db;++ if (!store.isSubject(to)) return 'ignored:recipient';++ const recipient = await store.personBySubject(db, to);++ if (!recipient) return 'ignored:recipient';++ const owner = from && store.isSubject(from) ? await store.personBySubject(db, from) : null;++ const details = await detailsFor(ctx, ref, recipient);++ const sender = owner ? nameOf(owner) : 'Someone';++ const text = message(sender, itemName(details, ref));++ const link = details.url || url || null;++ const created = await ctx.notifications.create({++ user_id: recipient.id,++ type: kind,++ category: 'social',++ priority,++ icon,++ title,++ message: text,++ service: ref.service,++ url: link,++ sender_id: owner ? owner.id : null,++ sender_name: owner ? (owner.display_name || owner.username) : null,++ sender_avatar: owner ? owner.avatar_url : null,++ rich_content: {++ service: ref.service,++ context: {++ share_kind: kind,++ resource: { service: ref.service, kind: ref.kind, id: ref.id },++ owner_subject: owner ? owner.subject_id : null,++ planned_url: link,++ },++ },++ });++ return created ? 'notified' : 'suppressed:preference';++}++++/** share.granted: the person who was added is told, and the item joins their list. */++async function granted(ctx, event, ref, payload, now) {++ const member = memberOf(payload);++ if (!member) return 'ignored:payload';++ const owner = member.addedBy || ownerOf(event, payload);++ if (!owner) return 'ignored:owner';++ await store.recordGrant(ctx.db, {++ recipient: member.subject,++ owner,++ service: ref.service,++ kind: ref.kind,++ itemId: ref.id,++ role: member.role,++ expiresAt: member.expiresAt,++ grantedAt: member.addedAt || now,++ eventId: event.event_id,++ });++ return await notify(ctx, {++ to: member.subject,++ from: owner,++ kind: KINDS.added,++ title: 'Shared with you',++ message: (sender, item) => sender + ' shared ' + item + ' with you.',++ icon: '🤝',++ ref,++ });++}++++/** share.revoked: the item leaves that person's list. */++async function revoked(ctx, event, ref, payload, now) {++ const member = memberOf(payload);++ if (!member) return 'ignored:payload';++ const changed = await store.recordRevocation(ctx.db, {++ recipient: member.subject,++ service: ref.service,++ kind: ref.kind,++ itemId: ref.id,++ revokedAt: now,++ eventId: event.event_id,++ });++ return changed ? 'revoked' : 'ignored:unknown';++}++++/** share.transfer.requested: the recipient is told, and the offer waits for them. */++async function transferRequested(ctx, event, ref, payload, now) {++ const from = partyOf(payload, 'from') || ownerOf(event, payload);++ const to = partyOf(payload, 'to');++ if (!from || !to) return 'ignored:payload';++ await store.recordTransfer(ctx.db, {++ id: payload.transfer_id ? String(payload.transfer_id).slice(0, 64) : null,++ service: ref.service,++ kind: ref.kind,++ itemId: ref.id,++ from,++ to,++ keepAccess: payload.keep_access === true,++ state: 'pending',++ requestedAt: store.iso(payload.requested_at) || now,++ expiresAt: store.iso(payload.expires_at),++ eventId: event.event_id,++ });++ return await notify(ctx, {++ to,++ from,++ kind: KINDS.transferRequested,++ title: 'Ownership offered',++ message: (sender, item) => sender + ' wants to give you ' + item + '.',++ icon: '🎁',++ priority: 'high',++ ref,++ });++}++++/** share.transfer.accepted / .declined: the offer is answered, and the old owner is told. */++async function transferAnswered(ctx, event, ref, payload, now, accepted) {++ const from = partyOf(payload, 'from') || ownerOf(event, payload);++ const to = partyOf(payload, 'to');++ if (!from || !to) return 'ignored:payload';++ const state = accepted ? 'accepted' : 'declined';++ const keepAccess = payload.keep_access === true;++ const id = payload.transfer_id ? String(payload.transfer_id).slice(0, 64) : null;++ await store.recordTransfer(ctx.db, {++ id,++ service: ref.service,++ kind: ref.kind,++ itemId: ref.id,++ from,++ to,++ keepAccess,++ state,++ requestedAt: store.iso(payload.requested_at) || now,++ expiresAt: store.iso(payload.expires_at),++ answeredAt: now,++ eventId: event.event_id,++ });++ // The new owner holds the item; the old one keeps access only when they asked to keep it.++ if (accepted) {++ if (keepAccess) {++ await store.recordGrant(ctx.db, {++ recipient: from, owner: to, service: ref.service, kind: ref.kind, itemId: ref.id,++ role: 'viewer', expiresAt: null, grantedAt: now, eventId: event.event_id,++ });++ } else {++ await store.recordRevocation(ctx.db, {++ recipient: to, service: ref.service, kind: ref.kind, itemId: ref.id, revokedAt: now, eventId: event.event_id,++ });++ }++ }++ return await notify(ctx, {++ to: from,++ from: to,++ kind: KINDS.transferAnswered,++ title: 'Transfer answered',++ message: (sender, item) => accepted++ ? sender + ' accepted ' + item + '. It is theirs now.'++ : sender + ' declined ' + item + '. Nothing changed.',++ icon: '↩️',++ ref,++ });++}++++/** share.access.requested: the owner is told, so they can add the person with one tap. */++async function accessRequested(ctx, event, ref, payload, now) {++ const from = partyOf(payload, 'from');++ const to = partyOf(payload, 'to') || ownerOf(event, payload);++ if (!from || !to) return 'ignored:payload';++ return await notify(ctx, {++ to,++ from,++ kind: KINDS.accessRequested,++ title: 'Access requested',++ message: (sender, item) => sender + ' asked for access to ' + item + '.',++ icon: '🔑',++ ref,++ });++}++++/**++ * One share event: the index and the notification.++ * @returns {Promise<{outcome: string, detail?: object}>}++ */++async function applyShareEvent(ctx, event) {++ const type = String(event.event_type || '');++ if (!TOPICS.includes(type)) return { outcome: 'ignored:type' };++ const payload = (event.payload && typeof event.payload === 'object') ? event.payload : {};++ const ref = refOf(payload);++ if (!ref) return { outcome: 'ignored:payload' };++ const now = whenOf(event);++ let outcome;++ if (type === 'share.granted') outcome = await granted(ctx, event, ref, payload, now);++ else if (type === 'share.revoked') outcome = await revoked(ctx, event, ref, payload, now);++ else if (type === 'share.transfer.requested') outcome = await transferRequested(ctx, event, ref, payload, now);++ else if (type === 'share.transfer.accepted') outcome = await transferAnswered(ctx, event, ref, payload, now, true);++ else if (type === 'share.transfer.declined') outcome = await transferAnswered(ctx, event, ref, payload, now, false);++ else if (type === 'share.access.requested') outcome = await accessRequested(ctx, event, ref, payload, now);++ else outcome = 'ignored:type';++ return { outcome, detail: { resource: ref } };++}++++module.exports = { applyShareEvent, TOPICS, KINDS };nnew file mode 100644@@@ -0,0 +1,147 @@++'use strict';++/**++ * What a shared item is called, and where it lives (design 2026-11-06, SHARING-DESIGN-2026-11-06.md).++ *++ * The share events carry ids only, so Network asks the owning service for the item's own name and++ * link: GET <service>/api/v1/<kind>/<id>/share, the uniform share API of the SDK++ * (openvibe-sdk/share, in review). The service answers only for a viewer who may see the item, so++ * Network calls it as the person: with a short-lived token of that person's own session, minted for++ * the owning service alone (see mintViewerToken).++ *++ * When the service does not answer (it is down, the item was deleted, the contract does not carry a++ * title yet) the item is shown as its site and kind. Nothing is ever guessed.++ */++const jwt = require('jsonwebtoken');++const contracts = require('openvibe-contracts');++const { SITES, fullName } = require('../frame/sites');++++const TIMEOUT_MS = 3500;++const TTL_SEC = 60;++++/** The manifest of a service, or null when the service is not one of ours. */++function manifestOf(service) {++ try { return contracts.services.get(service) || null; } catch { return null; }++}++++/** The audience a service expects of a person's token: its own host name (openvibe.community). */++function audienceOf(service) {++ const m = manifestOf(service);++ if (!m) return null;++ const origin = m.publicOrigin || (Array.isArray(m.domains) ? m.domains[0] : null);++ try { return new URL(String(origin)).hostname; } catch { return null; }++}++++/** The hosts that belong to a service: its own domains, so a link to the item is kept only when it is on one of them. */++function hostsOf(service) {++ const m = manifestOf(service);++ const hosts = new Set();++ if (m) {++ for (const d of m.domains || []) hosts.add(String(d).toLowerCase());++ try { if (m.publicOrigin) hosts.add(new URL(m.publicOrigin).hostname.toLowerCase()); } catch { /* no origin */ }++ }++ for (const s of SITES) if (s.service === service && s.host) hosts.add(String(s.host).toLowerCase());++ return hosts;++}++++/** The site's name and link, for the "which site is this on" column. */++function siteOf(service) {++ const s = SITES.find((x) => x.service === service) || null;++ const m = manifestOf(service);++ const host = s ? s.host : (m && m.domains && m.domains[0]) || null;++ return {++ id: s ? s.id : service,++ name: s ? s.name : (m && m.name) || service,++ full_name: s ? fullName(s) : (m && m.name) || service,++ host: host || null,++ url: host ? 'https://' + host : null,++ };++}++++/**++ * A short-lived token of the person's own session for one service: the audience is that service++ * alone and it lives a minute. It is minted only for a service of our own manifests and only for a++ * person Network already knows may see the item. It is never stored and never logged.++ */++function mintViewerToken(ctx, user, audience) {++ if (!ctx || !ctx.privateKey || !audience || !user) return null;++ const key = String(ctx.privateKey);++ const algorithm = key.includes('BEGIN') ? 'RS256' : 'HS256';++ try {++ return jwt.sign({++ sub: user.id,++ id: user.id,++ subject_id: user.subject_id || undefined,++ username: user.username,++ display_name: user.display_name || user.username,++ role: user.role,++ avatar_url: user.avatar_url,++ }, key, { algorithm, issuer: ctx.issuer, audience: [audience], expiresIn: TTL_SEC });++ } catch { return null; }++}++++/** The first string of a list of fields, cleaned. */++function pick(body, fields) {++ for (const f of fields) {++ const v = body && typeof body[f] === 'string' ? body[f] : null;++ if (v) { const c = String(v).replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 200); if (c) return c; }++ }++ return null;++}++++/** The first link of a list of fields, kept only when it is https and on one of the service's hosts. */++function pickLink(body, service) {++ const allowed = hostsOf(service);++ for (const f of ['url', 'link', 'item_url', 'href']) {++ const v = body && typeof body[f] === 'string' ? body[f] : null;++ if (!v) continue;++ try {++ const u = new URL(String(v).trim());++ if (u.protocol !== 'https:' || !allowed.has(u.hostname.toLowerCase())) continue;++ return u.toString().slice(0, 500);++ } catch { /* not a link */ }++ }++ return null;++}++++/**++ * The item's name and link, as the owning service sees them.++ * @returns {Promise<{title: string|null, url: string|null}>}++ */++async function resolveItem(ctx, ref, { token = null, timeoutMs = TIMEOUT_MS } = {}) {++ const out = { title: null, url: null };++ if (!ref) return out;++ const base = ctx.serviceBase ? ctx.serviceBase(ref.service) : defaultBase(ctx, ref.service);++ if (!base) return out;++ const path = '/api/v1/' + encodeURIComponent(ref.kind) + '/' + encodeURIComponent(ref.id) + '/share';++ try {++ const headers = { accept: 'application/json' };++ if (token) headers.authorization = 'Bearer ' + token;++ const r = await (ctx.fetchImpl || fetch)(String(base).replace(/\/+$/, '') + path, {++ headers,++ signal: AbortSignal.timeout(timeoutMs),++ });++ if (!r || !r.ok) return out;++ const body = await r.json().catch(() => null);++ if (!body || typeof body !== 'object') return out;++ const item = body.item && typeof body.item === 'object' ? body.item : {};++ const share = body.share && typeof body.share === 'object' ? body.share : {};++ out.title = pick(body, ['title', 'item_title', 'name'])++ || pick(item, ['title', 'name'])++ || pick(share, ['title', 'name'])++ || null;++ out.url = pickLink(body, ref.service)++ || pickLink(item, ref.service)++ || pickLink(share, ref.service);++ return out;++ } catch { return out; }++}++++/** Where a service is reached: its internal URL when Network knows one, else its public origin. */++function defaultBase(ctx, service) {++ const internal = ctx.config && ctx.config.services && ctx.config.services[service] && ctx.config.services[service].internalUrl;++ if (internal) return String(internal);++ const m = manifestOf(service);++ return m && m.publicOrigin ? String(m.publicOrigin) : null;++}++++module.exports = { resolveItem, mintViewerToken, siteOf, audienceOf, manifestOf, hostsOf, TIMEOUT_MS };nnew file mode 100644@@@ -0,0 +1,192 @@++'use strict';++/**++ * openvibe.network/shared — "Shared with me" (design 2026-11-06, SHARING-DESIGN-2026-11-06.md).++ *++ * One list of everything other people have shared with this person, on every site: the site, the++ * item and its link, who shared it, when, and the role. The transfers that wait for an answer come++ * first, with Accept and Decline links to the item itself, where the share sheet asks.++ *++ * The page is built from the kit's own components (openvibe-shared/kit). The one rule of its own is for++ * a phone, where the kit's row would cut the item's name and the words under it: the row wraps there++ * (PAGE_CSS below). Signed out the page says so and offers the sign-in; signed in it never shows++ * anybody else's list.++ */++const kit = require('openvibe-shared/kit');++const shell = require('openvibe-shared/shell');++const store = require('./store');++const { describeItems, itemView, transferView, MAX_DETAILS } = require('./routes');++const { sessionToken, verifySession } = require('../auth/session');++++/**++ * The page's own rules, and only for a phone.++ *++ * The kit's row is built for one line: the title and the words under it are cut with an ellipsis,++ * and the trail (Accept and Decline) sits beside them. On a phone there is no room for that, so the++ * row wraps: the title and the words take two lines each, and the two answers go on their own line++ * under the words, where a thumb can reach them.++ *++ * Scoped to this page (.ovk-shared) and to 480 px, so a computer keeps the kit's own look.++ */++const PAGE_CSS = `<style>++@media (max-width:480px){++.ovk-shared .ovk-row--static{flex-wrap:wrap}++.ovk-shared .ovk-row-trail{width:100%;box-sizing:border-box;padding-left:48px}++.ovk-shared .ovk-row-top{align-items:flex-start}++.ovk-shared .ovk-row-top strong{white-space:normal;overflow:visible;text-overflow:clip}++.ovk-shared .ovk-row-sub>span:first-child{white-space:normal;overflow:visible}++}++</style>`;++++/** What the item is called when the owning service does not name it. */++function fallbackName(item) {++ const kind = String(item.kind || 'item').replace(/[_-]+/g, ' ');++ return 'a ' + kind + (item.site ? ' on ' + (item.site.full_name || item.site.name) : '');++}++++/** "@goosely" for a person. */++function labelOf(person) {++ return person && person.username ? '@' + person.username : 'Someone';++}++++/** The name a person's picture is drawn from. */++function nameOf(person) {++ if (!person) return '?';++ return person.display_name || person.username || '?';++}++++/** The page: the list, the transfers, and the empty states. */++async function renderPage(ctx, user) {++ const subject = user ? user.subject_id : null;++ let items = [];++ let transfers = [];++ if (subject) {++ const grants = await store.listGrants(ctx.db, subject);++ const pending = (await store.listTransfers(ctx.db, subject)).filter((t) => t.state === 'pending');++ const refs = [++ ...grants.map((g) => ({ service: g.service, kind: g.kind, id: g.item_id })),++ ...pending.map((t) => ({ service: t.service, kind: t.kind, id: t.item_id })),++ ].slice(0, MAX_DETAILS);++ const details = await describeItems(ctx, refs, user);++ const owners = new Map();++ const ownerOf = async (s) => {++ if (!store.isSubject(s)) return null;++ if (!owners.has(s)) owners.set(s, await store.personBySubject(ctx.db, s));++ return owners.get(s);++ };++ for (const g of grants) {++ items.push(itemView(g, await ownerOf(g.owner_subject), details.get([g.service, g.kind, g.item_id].join('|'))));++ }++ for (const t of pending) {++ transfers.push(transferView(t, await ownerOf(t.from_subject), details.get([t.service, t.kind, t.item_id].join('|'))));++ }++ }++++ const transferRows = transfers.map((t) => {++ const when = kit.when(t.requested_at);++ // The two answers sit in the row's trail, and the row's own line keeps the item's name: on++ // a phone the trail takes the room a badge or a date would.++ const trail = [++ t.accept_url ? kit.button({ href: t.accept_url, label: 'Accept', kind: 'primary', small: true }) : '',++ t.decline_url ? kit.button({ href: t.decline_url, label: 'Decline', kind: 'quiet', small: true }) : '',++ ].filter(Boolean).join('');++ const site = t.site ? (t.site.full_name || t.site.name) : '';++ return kit.row({++ mark: kit.mark({ name: nameOf(t.from), src: t.from ? t.from.avatar_url : null, size: 36 }),++ title: t.title || fallbackName(t),++ sub: [labelOf(t.from) + ' wants to give you this', site, when.text].filter(Boolean).join(' \u00b7 '),++ trail,++ });++ });++++ const itemRows = items.map((i) => {++ const when = kit.when(i.shared_at);++ return kit.row({++ href: i.url || null,++ mark: kit.mark({ name: nameOf(i.owner), src: i.owner ? i.owner.avatar_url : null, size: 36 }),++ title: i.title || fallbackName(i),++ badge: kit.badge(i.role === 'editor' ? 'Can edit' : 'Can view', i.role === 'editor' ? 'accent' : ''),++ meta: when.text,++ metaIso: when.iso,++ sub: 'shared by ' + labelOf(i.owner) + ' \u00b7 ' + (i.site ? (i.site.full_name || i.site.name) : ''),++ });++ });++++ const stats = kit.stats([++ [items.length, 'shared with you'],++ [transfers.length, 'waiting for you'],++ ], { label: 'What is shared with you' });++++ let body;++ if (!user) {++ body = kit.empty({++ title: 'Sign in to see what is shared with you',++ text: 'Everything other people share with you lives here, on every site.',++ action: { href: '/login?next=%2Fshared', label: 'Sign in' },++ });++ } else if (!items.length && !transfers.length) {++ body = kit.empty({++ title: 'Nothing is shared with you yet',++ text: 'When someone shares a paste, a picture or a project with you, it lands here.',++ icon: 'check',++ });++ } else {++ body = [++ transfers.length ? kit.section({++ title: 'Waiting for you',++ count: transfers.length,++ note: 'Open the item to look before you answer.',++ body: kit.list(transferRows),++ }) : '',++ items.length ? kit.section({++ title: 'Shared with you',++ count: items.length,++ body: kit.list(itemRows),++ }) : '',++ ].join('\n');++ }++++ return shell.page({++ name: 'network',++ service: 'network',++ title: 'Shared with me',++ siteName: 'OpenVibe.Network',++ description: 'Everything other people have shared with you on OpenVibe, in one place.',++ canonical: String(ctx.config.baseUrl || 'https://openvibe.network').replace(/\/+$/, '') + '/shared',++ robots: 'noindex, nofollow',++ head: '<link rel="stylesheet" href="/shared/kit.css">\n<script src="/shared/kit-web.js" defer></script>\n' + PAGE_CSS,++ body: '<main class="ovk-page ovk-shared">\n'++ + kit.head({ title: 'Shared with me', sub: 'What other people have shared with you.' })++ + (user ? stats : '')++ + body++ + '</main>',++ navLinks: [{ label: 'Your account', href: '/my' }],++ navbar: { service: 'network' },++ footer: { service: 'network', variant: 'compact' },++ });++}++++/**++ * The route: GET /shared.++ * @param {object} ctx { db, config, privateKey, issuer, fetchImpl, serviceBase, authCtx }++ */++function createSharedPage(ctx) {++ return async function sharedPage(req, res) {++ try {++ const token = sessionToken(req);++ let user = null;++ if (token) {++ const out = await verifySession(token, ctx.authCtx);++ if (!out.error) user = out.user;++ }++ const html = await renderPage(ctx, user);++ res.set('Cache-Control', 'private, no-store');++ res.set('X-Robots-Tag', 'noindex, nofollow');++ res.type('html').send(html);++ } catch (err) {++ console.error('[Sharing] GET /shared error:', err.message);++ res.status(500).type('html').send('<!doctype html><title>Shared with me</title><p>Something went wrong.');++ }++ };++}++++module.exports = { createSharedPage, renderPage };nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++/**++ * The shapes of the share events (Contracts share.v1, in review: OpenVibe.Contracts, branch "share").++ *++ * a resource ref { service, kind, id }++ * a member { subject, role: viewer | editor, expires_at?, added_by, added_at }++ * a transfer { to, keep_access, requested_at, expires_at, state }++ *++ * The events are share.granted, share.revoked, share.transfer.requested, share.transfer.accepted and++ * share.transfer.declined; their payloads carry ids only, never a title or content.++ *++ * The parser here is deliberately forgiving about where a field sits (payload.resource or the flat++ * payload.service / payload.kind / payload.id, payload.member.subject or payload.recipient), because++ * the contract is still being written. What it never does is invent: a field it cannot read is null,++ * and the caller treats that as "the event does not say enough".++ */++const { clean, iso, isSubject } = require('./store');++++const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;++const KIND_RE = /^[a-z][a-z0-9_-]{1,39}$/;++const ITEM_RE = /^[A-Za-z0-9_.:-]{1,128}$/;++++/** The resource an event is about, or null when the payload does not name one. */++function refOf(payload) {++ const p = payload && typeof payload === 'object' ? payload : {};++ const r = (p.resource && typeof p.resource === 'object') ? p.resource++ : (p.ref && typeof p.ref === 'object') ? p.ref++ : (p.item && typeof p.item === 'object') ? p.item : p;++ const service = clean(r.service, 40).toLowerCase();++ const kind = clean(r.kind, 40).toLowerCase();++ const id = clean(r.id != null ? r.id : r.item_id, 128);++ if (!SERVICE_RE.test(service) || !KIND_RE.test(kind) || !ITEM_RE.test(id)) return null;++ return { service, kind, id };++}++++/** The person a grant names, or null. */++function memberOf(payload) {++ const p = payload && typeof payload === 'object' ? payload : {};++ const m = (p.member && typeof p.member === 'object') ? p.member : p;++ const subject = clean(m.subject || m.subject_id || p.member_subject || p.recipient || p.to, 64);++ if (!isSubject(subject)) return null;++ const role = clean(m.role, 20).toLowerCase() === 'editor' ? 'editor' : 'viewer';++ const addedBy = clean(m.added_by || p.added_by || p.owner || p.from || p.from_subject, 64);++ return {++ subject,++ role,++ expiresAt: iso(m.expires_at || p.expires_at),++ addedBy: isSubject(addedBy) ? addedBy : null,++ addedAt: iso(m.added_at || p.added_at || p.granted_at),++ };++}++++/** The person a transfer names, or null. */++function partyOf(payload, which) {++ const p = payload && typeof payload === 'object' ? payload : {};++ const raw = which === 'from'++ ? (p.from || p.from_subject || p.owner || p.owner_subject || p.sender)++ : (p.to || p.to_subject || p.recipient || p.buyer);++ const subject = clean(raw, 64);++ return isSubject(subject) ? subject : null;++}++++/** The owner of the item, from the event's subject or actor when they are a person. */++function ownerOf(event, payload) {++ const p = payload && typeof payload === 'object' ? payload : {};++ const fromPayload = clean(p.owner || p.owner_subject || p.added_by || p.from || p.from_subject, 64);++ if (isSubject(fromPayload)) return fromPayload;++ const s = event && event.subject;++ if (s && typeof s === 'object' && s.type === 'user' && isSubject(s.id)) return s.id;++ const a = event && event.actor;++ if (a && typeof a === 'object' && a.type === 'user' && isSubject(a.id)) return a.id;++ return null;++}++++/** When the event happened, as an ISO instant. */++function whenOf(event) {++ return iso(event && event.occurred_at) || new Date().toISOString();++}++++module.exports = { refOf, memberOf, partyOf, ownerOf, whenOf };nnew file mode 100644@@@ -0,0 +1,184 @@++'use strict';++/**++ * Network's sharing API (design 2026-11-06, SHARING-DESIGN-2026-11-06.md, section 4 "Network").++ *++ * GET /api/v1/shared-with-me the items other people shared with this person, and the++ * transfers waiting for their answer++ * GET /api/v1/share/suggestions the people this person has shared with (the share picker's++ * suggestions): from their own history only, at most 20++ * GET /api/v1/share/name-check one @name check for the share picker, with a per-person budget++ *++ * The list is built from the share events (server/sharing/store.js); the name of an item and its++ * own link come from the owning service's share API when it answers (server/sharing/item.js).++ * Everything here is private: the routes need the person's own session and answer only about them.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const store = require('./store');++const { resolveItem, mintViewerToken, audienceOf, siteOf } = require('./item');++const { resolveUsername } = require('../identity/subjects');++++// The picker's budget: thirty name checks a minute for one person. Typing a name checks it as you++// go, so the budget is generous for one search and too small to walk the network's accounts.++const NAME_CHECK_LIMIT = 30;++const NAME_CHECK_WINDOW_MS = 60_000;++// How many items are named on demand in one answer. A long list shows the site and kind beyond this.++const MAX_DETAILS = 25;++const NAME_RE = /^[A-Za-z0-9_.-]{1,40}$/;++++/** The key of an item in a map. */++const keyOf = (r) => [r.service, r.kind, r.id].join('|');++++/**++ * The name and link of each item, as the owning service sees them, for one person.++ * One token per service is minted and reused for that service's items.++ */++async function describeItems(ctx, refs, viewer) {++ const out = new Map();++ const byService = new Map();++ for (const r of refs) {++ if (!r) continue;++ if (!byService.has(r.service)) byService.set(r.service, []);++ byService.get(r.service).push(r);++ }++ await Promise.all([...byService.entries()].map(async ([service, list]) => {++ const audience = audienceOf(service);++ const token = viewer && audience ? mintViewerToken(ctx, viewer, audience) : null;++ const named = await Promise.all(list.map((r) => resolveItem(ctx, r, { token }).then((d) => [keyOf(r), d])));++ for (const [k, d] of named) out.set(k, d);++ }));++ return out;++}++++/** The item, as the list shows it. */++function itemView(grant, owner, details) {++ const site = siteOf(grant.service);++ return {++ service: grant.service,++ kind: grant.kind,++ id: grant.item_id,++ site,++ title: (details && details.title) || null,++ url: (details && details.url) || null,++ owner: owner ? store.personView(owner) : null,++ role: grant.role,++ shared_at: grant.granted_at,++ expires_at: grant.expires_at || null,++ };++}++++/** The item's link with the share sheet's answer on it: the person lands on the item, where the++ * Accept and Decline buttons of the sheet are. */++function answerUrl(url, answer) {++ if (!url) return null;++ try {++ const u = new URL(url);++ u.searchParams.set('share', answer);++ return u.toString();++ } catch { return url; }++}++++/** A transfer, as the list shows it. */++function transferView(transfer, owner, details) {++ const site = siteOf(transfer.service);++ const url = (details && details.url) || null;++ return {++ id: transfer.id,++ service: transfer.service,++ kind: transfer.kind,++ item_id: transfer.item_id,++ site,++ title: (details && details.title) || null,++ url,++ from: owner ? store.personView(owner) : null,++ state: transfer.state,++ keep_access: Boolean(transfer.keep_access),++ requested_at: transfer.requested_at,++ expires_at: transfer.expires_at || null,++ accept_url: answerUrl(url, 'accept'),++ decline_url: answerUrl(url, 'decline'),++ };++}++++/**++ * The API of sharing.++ * @param {object} ctx { db, requireAuth, config, privateKey, issuer, fetchImpl, serviceBase }++ */++function createSharingApi(ctx) {++ const router = express.Router();++++ // The items shared with this person, and the transfers waiting for their answer.++ router.get('/shared-with-me', ctx.requireAuth, async (req, res) => {++ try {++ const subject = req.user.subject_id;++ const grants = await store.listGrants(ctx.db, subject);++ const transfers = (await store.listTransfers(ctx.db, subject)).filter((t) => t.state === 'pending');++ const refs = [++ ...grants.map((g) => ({ service: g.service, kind: g.kind, id: g.item_id })),++ ...transfers.map((t) => ({ service: t.service, kind: t.kind, id: t.item_id })),++ ].slice(0, MAX_DETAILS);++ const details = await describeItems(ctx, refs, req.user);++ const owners = new Map();++ const ownerOf = async (s) => {++ if (!store.isSubject(s)) return null;++ if (!owners.has(s)) owners.set(s, await store.personBySubject(ctx.db, s));++ return owners.get(s);++ };++ const items = [];++ for (const g of grants) items.push(itemView(g, await ownerOf(g.owner_subject), details.get(keyOf({ service: g.service, kind: g.kind, id: g.item_id }))));++ const pending = [];++ for (const t of transfers) pending.push(transferView(t, await ownerOf(t.from_subject), details.get(keyOf({ service: t.service, kind: t.kind, id: t.item_id }))));++ res.set('Cache-Control', 'private, no-store');++ res.json({ ok: true, items, transfers: pending, count: items.length });++ } catch (err) {++ console.error('[Sharing] GET /shared-with-me error:', err.message);++ res.status(500).json({ ok: false, error: 'Could not read the shared list' });++ }++ });++++ // The people this person has shared with: the share picker's suggestions, from their own history.++ router.get('/share/suggestions', ctx.requireAuth, async (req, res) => {++ try {++ const people = await store.listSuggestions(ctx.db, req.user.subject_id);++ res.set('Cache-Control', 'private, no-store');++ res.json({ ok: true, people, count: people.length });++ } catch (err) {++ console.error('[Sharing] GET /share/suggestions error:', err.message);++ res.status(500).json({ ok: false, error: 'Could not read the suggestions' });++ }++ });++++ // One @name check for the share picker, with a per-person budget: nobody may walk the network's++ // accounts by typing names one after another.++ router.get('/share/name-check', ctx.requireAuth, async (req, res) => {++ try {++ const name = String(req.query.name || '').trim().replace(/^@/, '');++ if (!NAME_RE.test(name)) return http.sendProblem(res, 400, 'share.invalid_name', { detail: 'an OpenVibe name is 1 to 40 letters, numbers, dots, dashes or underscores' });++ const since = new Date(Date.now() - NAME_CHECK_WINDOW_MS).toISOString();++ const used = await store.countNameChecks(ctx.db, req.user.id, since);++ if (used >= NAME_CHECK_LIMIT) {++ res.set('Retry-After', String(Math.ceil(NAME_CHECK_WINDOW_MS / 1000)));++ return http.sendProblem(res, 429, 'share.too_many_checks', { detail: 'too many name checks; wait a minute and try again' });++ }++ await store.recordNameCheck(ctx.db, req.user.id, name);++ const person = await resolveUsername(ctx.db, name);++ res.set('Cache-Control', 'private, no-store');++ res.json({++ ok: true,++ name,++ exists: Boolean(person),++ subject: person ? person.subject.id : null,++ username: person ? person.username : null,++ display_name: person ? person.display_name : null,++ avatar_url: person ? person.avatar_url : null,++ });++ } catch (err) {++ console.error('[Sharing] GET /share/name-check error:', err.message);++ res.status(500).json({ ok: false, error: 'Could not check the name' });++ }++ });++++ return router;++}++++module.exports = { createSharingApi, describeItems, itemView, transferView, answerUrl, NAME_CHECK_LIMIT, NAME_CHECK_WINDOW_MS, MAX_DETAILS };nnew file mode 100644@@@ -0,0 +1,172 @@++'use strict';++/**++ * Network's index of what is shared with whom (design 2026-11-06, SHARING-DESIGN-2026-11-06.md).++ *++ * The owning service stays the authority on access: its own canView decides who may open an item.++ * These rows are Network's read model, built from the share events over OpenVibe.Events++ * (share.granted, share.revoked, share.transfer.requested, share.transfer.accepted,++ * share.transfer.declined), and they answer three questions:++ *++ * - "Shared with me": the items other people shared with this person (openvibe.network/shared).++ * - "People you've shared with": the picker's suggestions, from this person's own history only.++ * - the pending transfers, with the Accept and Decline links to the item.++ *++ * Event payloads carry ids only (never a title), so a row knows a service, a kind and an id; the++ * title and the item's own link come from the owning service's share API when it answers++ * (server/sharing/item.js).++ *++ * Timestamps are ISO-8601 UTC strings, so comparisons stay lexical.++ */++const crypto = require('crypto');++++const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const MAX_SUGGESTIONS = 20;++const MAX_ITEMS = 200;++++/** A usr_ subject, or null. */++const isSubject = (v) => USER_SUBJECT_RE.test(String(v || ''));++++/** Plain, single-line, length-capped text: nothing from an event reaches a page as markup. */++function clean(v, n = 200) {++ return String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, n);++}++++/** An ISO-8601 UTC instant, or null when the value is not a time. */++function iso(v) {++ if (v == null || v === '') return null;++ const t = Date.parse(v);++ return Number.isFinite(t) ? new Date(t).toISOString() : null;++}++++/** The id of a membership row: the same person, item and grant always get the same row. */++function grantId(recipient, service, kind, itemId) {++ const h = crypto.createHash('sha256').update([recipient, service, kind, itemId].join('\u0000')).digest('hex').toUpperCase();++ return 'sgr_' + h.slice(0, 26);++}++++/** The id of a transfer row, when the event does not name one. */++function transferId(from, to, service, kind, itemId) {++ const h = crypto.createHash('sha256').update([from, to, service, kind, itemId].join('\u0000')).digest('hex').toUpperCase();++ return 'sht_' + h.slice(0, 26);++}++++/** The schema lives in migrations/0038_sharing.sql; nothing is created at runtime. */++function ensureSchema(db) { /* nothing to do */ }++++/**++ * A membership: the item is shared with `recipient` by `owner`.++ * A second grant for the same item and person updates the row (the role may have changed).++ */++async function recordGrant(db, g) {++ const id = grantId(g.recipient, g.service, g.kind, g.itemId);++ await db.prepare(`INSERT INTO share_grants++ (id, recipient_subject, owner_subject, service, kind, item_id, role, expires_at, granted_at, revoked_at, event_id)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)++ ON CONFLICT (recipient_subject, service, kind, item_id) DO UPDATE SET++ owner_subject = EXCLUDED.owner_subject,++ role = EXCLUDED.role,++ expires_at = EXCLUDED.expires_at,++ granted_at = EXCLUDED.granted_at,++ revoked_at = NULL,++ event_id = EXCLUDED.event_id`).run(++ id, g.recipient, g.owner, g.service, g.kind, g.itemId, g.role || 'viewer',++ g.expiresAt || null, g.grantedAt, g.eventId || null);++ return id;++}++++/** The owner's access ended: the item leaves that person's list. */++async function recordRevocation(db, r) {++ const res = await db.prepare(`UPDATE share_grants SET revoked_at = ?, event_id = ?++ WHERE recipient_subject = ? AND service = ? AND kind = ? AND item_id = ? AND revoked_at IS NULL`)++ .run(r.revokedAt, r.eventId || null, r.recipient, r.service, r.kind, r.itemId);++ return res.changes || 0;++}++++/** A transfer, or the answer to one. */++async function recordTransfer(db, t) {++ const id = t.id || transferId(t.from, t.to, t.service, t.kind, t.itemId);++ await db.prepare(`INSERT INTO share_transfers++ (id, service, kind, item_id, from_subject, to_subject, keep_access, state, requested_at, expires_at, answered_at, event_id)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT (id) DO UPDATE SET++ state = EXCLUDED.state,++ keep_access = EXCLUDED.keep_access,++ expires_at = EXCLUDED.expires_at,++ answered_at = EXCLUDED.answered_at,++ event_id = EXCLUDED.event_id`).run(++ id, t.service, t.kind, t.itemId, t.from, t.to, t.keepAccess ? 1 : 0, t.state || 'pending',++ t.requestedAt, t.expiresAt || null, t.answeredAt || null, t.eventId || null);++ return id;++}++++/** The items shared with this person, newest first. A revoked or expired grant is not in the list. */++async function listGrants(db, subject, { now = new Date().toISOString(), limit = MAX_ITEMS } = {}) {++ if (!isSubject(subject)) return [];++ return await db.prepare(`SELECT * FROM share_grants++ WHERE recipient_subject = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)++ ORDER BY granted_at DESC, id DESC LIMIT ?`).all(subject, now, limit);++}++++/** The transfers of this person, newest first. An offer past its date reads as expired. */++async function listTransfers(db, subject, { now = new Date().toISOString(), limit = 50 } = {}) {++ if (!isSubject(subject)) return [];++ const rows = await db.prepare(`SELECT * FROM share_transfers WHERE to_subject = ? OR from_subject = ?++ ORDER BY requested_at DESC, id DESC LIMIT ?`).all(subject, subject, limit);++ return rows.map((r) => (r.state === 'pending' && r.expires_at && r.expires_at <= now ? { ...r, state: 'expired' } : r));++}++++/** The person's own row: their subject, name and picture. */++async function personBySubject(db, subject) {++ if (!isSubject(subject)) return null;++ return await db.prepare('SELECT id, subject_id, username, display_name, avatar_url FROM users WHERE subject_id = ?').get(subject);++}++++/** How a person is shown: @name and a display name. */++function personView(row) {++ if (!row) return null;++ return {++ subject: row.subject_id,++ username: row.username,++ display_name: row.display_name || row.username,++ avatar_url: row.avatar_url || null,++ };++}++++/**++ * The people this person has shared with (the picker's suggestions): only from their own history,++ * newest first, at most 20.++ */++async function listSuggestions(db, subject, { limit = MAX_SUGGESTIONS } = {}) {++ if (!isSubject(subject)) return [];++ const rows = await db.prepare(`SELECT recipient_subject, MAX(granted_at) AS last_at, COUNT(*)::int AS shares++ FROM share_grants WHERE owner_subject = ? AND recipient_subject <> owner_subject++ GROUP BY recipient_subject ORDER BY last_at DESC LIMIT ?`).all(subject, Math.min(Number(limit) || MAX_SUGGESTIONS, MAX_SUGGESTIONS));++ const out = [];++ for (const r of rows) {++ const person = await personBySubject(db, r.recipient_subject);++ if (!person) continue; // a deleted or merged account is not a suggestion++ out.push({ ...personView(person), shared_at: r.last_at, shares: Number(r.shares) || 1 });++ }++ return out;++}++++/** One @name check, counted against the person's budget. */++async function recordNameCheck(db, userId, name, now = new Date().toISOString()) {++ await db.prepare('INSERT INTO share_name_checks (user_id, name, checked_at) VALUES (?, ?, ?)')++ .run(Number(userId), clean(name, 64), now);++}++++/** How many @name checks this person made since a moment. */++async function countNameChecks(db, userId, since) {++ const r = await db.prepare('SELECT COUNT(*)::int AS n FROM share_name_checks WHERE user_id = ? AND checked_at > ?')++ .get(Number(userId), since);++ return Number(r && r.n) || 0;++}++++module.exports = {++ ensureSchema, isSubject, clean, iso, grantId, transferId,++ recordGrant, recordRevocation, recordTransfer,++ listGrants, listTransfers, personBySubject, personView, listSuggestions,++ recordNameCheck, countNameChecks,++ MAX_SUGGESTIONS, MAX_ITEMS,++};@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };// The real directory: 0016 applies on top of 0015, once.const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand']]);assert.ok(await hasAgents());assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.@@@ -68,7 +68,9 @@ const rows = async (userId) => await db.prepare('SELECT * FROM notifications WHE(async () => {// The subscriptions this consumer is meant for, and nothing else.-- assert.deepStrictEqual(TOPICS, ['deals.watch.matched', 'coupons.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', 'today.event.scheduled', 'today.event.cancelled', 'chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'space.moderation.action', 'tips.interaction.moderated', 'billing.staff.action',++ assert.deepStrictEqual(TOPICS, ['deals.watch.matched', 'coupons.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', 'today.event.scheduled', 'today.event.cancelled',++ 'share.granted', 'share.revoked', 'share.transfer.requested', 'share.transfer.accepted', 'share.transfer.declined', 'share.access.requested',++ 'chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'space.moderation.action', 'tips.interaction.moderated', 'billing.staff.action',...['media', 'tools', 'games', 'wiki', 'blog', 'news', 'reviews', 'deals', 'coupons', 'trade', 'services'].map((svc) => `${svc}.moderation.action`),'tools.usage.recorded', 'events.usage.recorded', 'media.usage.recorded']);assert.deepStrictEqual(topicsFrom([]), TOPICS, 'scripts/subscribe-events.js subscribes every topic');nnew file mode 100644@@@ -0,0 +1,245 @@++'use strict';++// Sharing on OpenVibe: Network's part (design 2026-11-06, SHARING-DESIGN-2026-11-06.md).++//++// The share events (share.granted, share.revoked, share.transfer.*) arrive over OpenVibe.Events and++// build two things: the list of what is shared with this person (GET /api/v1/shared-with-me, the++// /shared page) and the notifications about it. The picker's suggestions (GET /api/v1/share/suggestions)++// come from the same events and belong to one person alone; the @name check the picker uses++// (GET /api/v1/share/name-check) has a budget per person, so nobody can walk the network's accounts.++//++// node test/shared-with-me.test.js++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const { ids } = require('openvibe-contracts');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { getDb } = require('../server/db/database');++const { NotificationService } = require('../server/notifications/notification-service');++const { createEventsConsumer } = require('../server/notifications/events-consumer');++const { createSharingApi } = require('../server/sharing/routes');++const { createSharedPage } = require('../server/sharing/page');++const { NAME_CHECK_LIMIT } = require('../server/sharing/routes');++++(async () => {++const db = getDb();++const log = console.log; console.log = () => {};++const notifications = new NotificationService(db);++console.log = log;++++const ALICE = ids.newId('user'), BOB = ids.newId('user');++await db.prepare("INSERT INTO users (id, username, display_name, password_hash, subject_id, email, email_verified) VALUES (7, 'alice', 'Alice', 'x', ?, '[email protected]', 1), (8, 'bob', 'Bob', 'x', ?, NULL, 0)").run(ALICE, BOB);++++const SECRET = 's'.repeat(40);++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' }, baseUrl: 'https://openvibe.network', services: {} };++const { signToken } = require('../server/auth/routes');++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);++++// The owning service: it answers the uniform share API only for a viewer that brings a token, and++// names the item. Network asks it on the person's behalf (server/sharing/item.js).++const items = {++ 'paste/pst_1': { title: 'LOOI bridge notes', url: 'https://openvibe.community/p/pst_1' },++ 'paste/pst_2': { title: 'Second paste', url: 'https://openvibe.community/p/pst_2' },++};++const owner = express();++owner.get('/api/v1/:kind/:id/share', (req, res) => {++ if (!/^Bearer /.test(String(req.headers.authorization || ''))) return res.status(401).json({ error: 'auth.required' });++ const item = items[req.params.kind + '/' + req.params.id];++ if (!item) return res.status(404).json({ error: 'not.found' });++ res.json({ audience: 'people', title: item.title, url: item.url, members: [] });++});++const ownerServer = http.createServer(owner);++await new Promise((r) => ownerServer.listen(0, '127.0.0.1', r));++const ownerBase = 'http://127.0.0.1:' + ownerServer.address().port;++++const sharingCtx = {++ db, notifications, config, privateKey: keys.privateKey, issuer: config.jwt.issuer,++ serviceBase: () => ownerBase,++};++++const consumer = await createEventsConsumer({++ db, notifications, secrets: SECRET,++ sharing: { apply: (event) => require('../server/sharing/events').applyShareEvent(sharingCtx, event) },++});++const app = express();++app.use('/internal/events', (req, res, next) => consumer.router(req, res, next));++app.use(express.json());++app.use('/api/v1', createSharingApi({ ...sharingCtx, requireAuth }));++app.get('/shared', createSharedPage({ ...sharingCtx, authCtx: { db, publicKey: keys.publicKey, config } }));++const server = http.createServer(app);++await new Promise((r) => server.listen(0, '127.0.0.1', r));++const base = 'http://127.0.0.1:' + server.address().port;++++const tok = async (id) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);++const as = async (id) => ({ authorization: 'Bearer ' + await tok(id) });++const call = async (method, path, { body, headers = {} } = {}) => {++ const r = await fetch(base + path, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined });++ return { status: r.status, body: await r.json().catch(() => null) };++};++const post = async (event) => {++ const raw = JSON.stringify({ event, seq: 1 });++ const r = await fetch(base + '/internal/events', { method: 'POST', headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) }, body: raw });++ return { status: r.status, body: await r.json() };++};++const shareEvent = (type, payload, over = {}) => ({++ event_id: ids.newId('event'), event_type: type, version: 1, source: 'community',++ actor: { type: 'user', id: BOB }, subject: { type: 'user', id: BOB }, visibility: 'internal',++ occurred_at: new Date().toISOString(), payload, ...over,++});++const inbox = async (userId) => await db.prepare('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at, seq').all(userId);++++(async () => {++ try {++ // ── share.granted: the item joins the list, and the person is told ──++ const granted = shareEvent('share.granted', {++ resource: { service: 'community', kind: 'paste', id: 'pst_1' },++ member: { subject: ALICE, role: 'editor', expires_at: null, added_by: BOB, added_at: '2026-11-06T09:12:00.000Z' },++ });++ let r = await post(granted);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.outcome, 'notified', JSON.stringify(r.body));++++ let mine = await inbox(7);++ assert.strictEqual(mine.length, 1, 'one notification for the person who was added');++ assert.strictEqual(mine[0].type, 'share.added');++ assert.strictEqual(mine[0].title, 'Shared with you');++ assert.strictEqual(mine[0].message, '@bob shared "LOOI bridge notes" with you.', 'plain words, the item named');++ assert.strictEqual(mine[0].url, 'https://openvibe.community/p/pst_1', 'the item link');++ assert.strictEqual(mine[0].sender_name, 'Bob');++++ // The list, from the events.++ r = await call('GET', '/api/v1/shared-with-me', { headers: await as(7) });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.items.length, 1);++ const item = r.body.items[0];++ assert.strictEqual(item.service, 'community');++ assert.strictEqual(item.kind, 'paste');++ assert.strictEqual(item.id, 'pst_1');++ assert.strictEqual(item.title, 'LOOI bridge notes', 'the name comes from the owning service');++ assert.strictEqual(item.url, 'https://openvibe.community/p/pst_1');++ assert.strictEqual(item.site.full_name, 'OpenVibe.Community', 'the site it is on');++ assert.strictEqual(item.owner.username, 'bob', 'who shared it');++ assert.strictEqual(item.role, 'editor', 'the role');++ assert.ok(item.shared_at, 'when it was shared');++++ // It is private: Bob's list is his own.++ r = await call('GET', '/api/v1/shared-with-me', { headers: await as(8) });++ assert.strictEqual(r.body.items.length, 0, 'the list is the person\'s own');++ r = await call('GET', '/api/v1/shared-with-me');++ assert.strictEqual(r.status, 401, 'signed in only');++++ // The page, for the same person.++ const page = await fetch(base + '/shared', { headers: await as(7) });++ assert.strictEqual(page.status, 200);++ const html = await page.text();++ assert.ok(html.includes('LOOI bridge notes'), 'the page names the item');++ assert.ok(html.includes('OpenVibe.Community'), 'the page names the site');++ assert.ok(html.includes('@bob'), 'the page names who shared it');++ assert.ok(html.includes('Can edit'), 'the page says the role');++ // On a phone the kit's row would cut the item's name and the words under it: the page carries++ // its own rule for that, scoped to itself.++ assert.ok(html.includes('ovk-shared'), 'the page scopes its own phone rule');++ assert.ok(html.includes('max-width:480px'), 'the phone rule is on the page');++ assert.ok(html.includes('ovk-row-trail'), 'the phone rule moves the trail');++ const anon = await fetch(base + '/shared');++ assert.strictEqual(anon.status, 200);++ assert.ok((await anon.text()).includes('Sign in'), 'signed out, the page asks to sign in');++++ // ── share.revoked: the item leaves the list ──++ r = await post(shareEvent('share.revoked', {++ resource: { service: 'community', kind: 'paste', id: 'pst_1' },++ member: { subject: ALICE, added_by: BOB },++ }));++ assert.strictEqual(r.body.outcome, 'revoked');++ r = await call('GET', '/api/v1/shared-with-me', { headers: await as(7) });++ assert.strictEqual(r.body.items.length, 0, 'the revoked item is gone');++++ // ── a transfer: pending, then answered ──++ const transfer = { transfer_id: 'sht_1', resource: { service: 'community', kind: 'paste', id: 'pst_2' }, from: BOB, to: ALICE, keep_access: false, requested_at: '2026-11-06T10:00:00.000Z', expires_at: '2026-11-13T10:00:00.000Z' };++ r = await post(shareEvent('share.transfer.requested', transfer));++ assert.strictEqual(r.body.outcome, 'notified');++ mine = await inbox(7);++ const offer = mine.find((n) => n.type === 'share.transfer.requested');++ assert.ok(offer, 'the recipient is told');++ assert.strictEqual(offer.message, '@bob wants to give you "Second paste".');++ assert.strictEqual(offer.url, 'https://openvibe.community/p/pst_2');++++ r = await call('GET', '/api/v1/shared-with-me', { headers: await as(7) });++ assert.strictEqual(r.body.transfers.length, 1, 'the transfer waits for an answer');++ const t = r.body.transfers[0];++ assert.strictEqual(t.state, 'pending');++ assert.strictEqual(t.from.username, 'bob');++ assert.strictEqual(t.accept_url, 'https://openvibe.community/p/pst_2?share=accept', 'Accept links to the item');++ assert.strictEqual(t.decline_url, 'https://openvibe.community/p/pst_2?share=decline', 'Decline links to the item');++++ // The old owner is told when the answer comes.++ r = await post(shareEvent('share.transfer.accepted', { ...transfer, accepted_at: '2026-11-06T11:00:00.000Z' }));++ assert.strictEqual(r.body.outcome, 'notified');++ const bobs = await inbox(8);++ const answered = bobs.find((n) => n.type === 'share.transfer.answered');++ assert.ok(answered, 'the old owner is told');++ assert.strictEqual(answered.message, '@alice accepted "Second paste". It is theirs now.');++ r = await call('GET', '/api/v1/shared-with-me', { headers: await as(7) });++ assert.strictEqual(r.body.transfers.length, 0, 'the answered transfer is not pending any more');++++ // ── a declined transfer changes nothing ──++ const transfer2 = { transfer_id: 'sht_2', resource: { service: 'community', kind: 'paste', id: 'pst_2' }, from: BOB, to: ALICE, keep_access: true, requested_at: '2026-11-06T12:00:00.000Z' };++ r = await post(shareEvent('share.transfer.requested', transfer2));++ assert.strictEqual(r.body.outcome, 'notified');++ r = await post(shareEvent('share.transfer.declined', { ...transfer2, declined_at: '2026-11-06T12:30:00.000Z' }));++ assert.strictEqual(r.body.outcome, 'notified');++ const bobs2 = await inbox(8);++ assert.ok(bobs2.some((n) => n.type === 'share.transfer.answered' && n.message.includes('declined')), 'the old owner is told');++ r = await call('GET', '/api/v1/shared-with-me', { headers: await as(7) });++ assert.strictEqual(r.body.transfers.length, 0, 'a declined transfer is not pending');++++ // ── share.access.requested: the owner is told ──++ r = await post(shareEvent('share.access.requested', { resource: { service: 'community', kind: 'paste', id: 'pst_1' }, from: ALICE, to: BOB }));++ assert.strictEqual(r.body.outcome, 'notified');++ const bobs3 = await inbox(8);++ const asked = bobs3.find((n) => n.type === 'share.access_requested');++ assert.ok(asked, 'the owner is told');++ assert.strictEqual(asked.message, '@alice asked for access to "LOOI bridge notes".');++++ // ── the suggestions: only from this person's own history, and private ──++ r = await call('GET', '/api/v1/share/suggestions', { headers: await as(8) });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.people.length, 1, 'Bob shared with Alice, so Alice is a suggestion');++ assert.strictEqual(r.body.people[0].username, 'alice');++ r = await call('GET', '/api/v1/share/suggestions', { headers: await as(7) });++ assert.strictEqual(r.body.people.length, 0, 'Alice shared with nobody');++ r = await call('GET', '/api/v1/share/suggestions');++ assert.strictEqual(r.status, 401, 'signed in only');++++ // ── the @name check: one budget per person ──++ r = await call('GET', '/api/v1/share/name-check?name=bob', { headers: await as(7) });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.exists, true);++ assert.strictEqual(r.body.username, 'bob');++ r = await call('GET', '/api/v1/share/name-check?name=@bob', { headers: await as(7) });++ assert.strictEqual(r.body.exists, true, 'a leading @ is the same name');++ r = await call('GET', '/api/v1/share/name-check?name=nobodyhere', { headers: await as(7) });++ assert.strictEqual(r.body.exists, false, 'no such account');++++ // The budget: thirty checks a minute, for this person only.++ await db.prepare('DELETE FROM share_name_checks WHERE user_id = ?').run(7);++ for (let i = 0; i < NAME_CHECK_LIMIT; i++) {++ const c = await call('GET', '/api/v1/share/name-check?name=bob', { headers: await as(7) });++ assert.strictEqual(c.status, 200, 'check ' + i + ' is inside the budget');++ }++ r = await call('GET', '/api/v1/share/name-check?name=bob', { headers: await as(7) });++ assert.strictEqual(r.status, 429, 'the budget stops the walking of accounts');++ assert.strictEqual(r.body.code, 'share.too_many_checks');++ // Bob's own budget is his own.++ r = await call('GET', '/api/v1/share/name-check?name=alice', { headers: await as(8) });++ assert.strictEqual(r.status, 200, 'the budget is per person');++++ console.log('sharing: the list, the transfers, the notifications, the suggestions and the name-check budget are all checked');++ process.exit(0);++ } catch (err) {++ console.error('sharing test failed:', err);++ process.exit(1);++ }++})();++})();@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };// ── 0020 applies on top of 0019, once, and rewrites nothing.const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand']]);assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
@@ -853,6 +853,42 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -853,6 +853,42 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
`ADMIN_PASSWORD`, `NETWORK_EVENTS_SECRET`, the webhook secrets and `GITHUB_TOKEN` live in
`ADMIN_PASSWORD`, `NETWORK_EVENTS_SECRET`, the webhook secrets and `GITHUB_TOKEN` live in
`/etc/openvibe/network.env` (0600), by name only.
`/etc/openvibe/network.env` (0600), by name only.
+## Sharing
+
+The sharing system (design 2026-11-06) lets a person share an item on one site with another person.
+The owning service stays the authority on access; Network keeps the index and the list.
+
+### The events
+
+Every share event arrives over OpenVibe.Events at `POST /internal/events` and is handled in
+`server/sharing/events.js`:
+
+| Event | What Network does |
+|-------|-------------------|
+| `share.granted` | the item joins that person's list, and they get the notification `share.added` |
+| `share.revoked` | the item leaves that person's list |
+| `share.transfer.requested` | the offer waits for the recipient, who gets `share.transfer.requested` |
+| `share.transfer.accepted` | the recipient owns it now; the old owner gets `share.transfer.answered` |
+| `share.transfer.declined` | nothing changed; the old owner gets `share.transfer.answered` |
+| `share.access.requested` | the owner gets `share.access_requested` |
+
+The payloads carry ids only (a `{ service, kind, id }` ref and a subject), never a title. The name of
+an item and its own link come from the owning service's share API, `GET /api/v1/<kind>/<id>/share`,
+asked on the person's behalf; when the service does not answer, the item is shown as its site and its
+kind instead.
+
+### The routes
+
+| Route | What it answers |
+|-------|-----------------|
+| `GET /shared` | the page: every item shared with the signed-in person, on every site |
+| `GET /api/v1/shared-with-me` | the same list as JSON, with the pending transfers and their Accept and Decline links |
+| `GET /api/v1/share/suggestions` | the people this person has shared with (the share picker's suggestions): from their own history only, at most 20 |
+| `GET /api/v1/share/name-check?name=` | one @name check for the share picker, thirty a minute for the person |
+
+The event names and the payload shapes are Contracts share.v1, which is still being written; this code
+waits for that release before it ships.
+
## License
## License
MIT (`license` in [package.json](package.json)).
MIT (`license` in [package.json](package.json)).
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+-- phase: expand
+-- Sharing on OpenVibe (design 2026-11-06, SHARING-DESIGN-2026-11-06.md, section 4 "Network"):
+-- Network's own record of what is shared with whom, built from the share events over OpenVibe.Events
+-- (share.granted, share.revoked, share.transfer.*). The owning service stays the authority on access
+-- (its own canView decides); these rows are Network's index for the "Shared with me" list, the
+-- notification texts and the share picker's suggestions.
+--
+-- Timestamps are ISO-8601 UTC strings ("2026-11-06T09:12:00.000Z"), so comparisons stay lexical on
+-- PostgreSQL and PGlite, like the rest of the newer tables.
+CREATE TABLE share_grants (
+ id text COLLATE "C" PRIMARY KEY,
+ recipient_subject text COLLATE "C" NOT NULL,
+ owner_subject text COLLATE "C" NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL,
+ item_id text COLLATE "C" NOT NULL,
+ role text COLLATE "C" NOT NULL DEFAULT 'viewer' CHECK (role IN ('viewer', 'editor')),
+ expires_at text COLLATE "C",
+ granted_at text COLLATE "C" NOT NULL,
+ revoked_at text COLLATE "C",
+ event_id text COLLATE "C"
+);
+-- One live membership per item and person: a second grant for the same item updates the row.
+CREATE UNIQUE INDEX share_grants_item ON share_grants (recipient_subject, service, kind, item_id);
+CREATE INDEX share_grants_recipient ON share_grants (recipient_subject, revoked_at, granted_at);
+CREATE INDEX share_grants_owner ON share_grants (owner_subject, granted_at);
+
+-- Ownership transfers: pending until the recipient accepts or declines, then the answer.
+CREATE TABLE share_transfers (
+ id text COLLATE "C" PRIMARY KEY,
+ service text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL,
+ item_id text COLLATE "C" NOT NULL,
+ from_subject text COLLATE "C" NOT NULL,
+ to_subject text COLLATE "C" NOT NULL,
+ keep_access integer NOT NULL DEFAULT 0,
+ state text COLLATE "C" NOT NULL DEFAULT 'pending'
+ CHECK (state IN ('pending', 'accepted', 'declined', 'cancelled', 'expired')),
+ requested_at text COLLATE "C" NOT NULL,
+ expires_at text COLLATE "C",
+ answered_at text COLLATE "C",
+ event_id text COLLATE "C"
+);
+CREATE INDEX share_transfers_to ON share_transfers (to_subject, state, requested_at);
+CREATE INDEX share_transfers_from ON share_transfers (from_subject, state, requested_at);
+
+-- The @name checks of the share picker, counted per person: nobody may walk the network's accounts.
+CREATE TABLE share_name_checks (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name text COLLATE "C" NOT NULL,
+ checked_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()
+);
+CREATE INDEX share_name_checks_user_time ON share_name_checks (user_id, checked_at);
@@ -402,9 +402,15 @@ app.locals.emailService = emailService;
@@ -402,9 +402,15 @@ app.locals.emailService = emailService;
const moderationAudit = require('./admin/moderation-audit').createModerationAudit(db);
const moderationAudit = require('./admin/moderation-audit').createModerationAudit(db);
// Developer projects' usage (WS-N task 4): the services' hourly rollups, per project and day.
// Developer projects' usage (WS-N task 4): the services' hourly rollups, per project and day.
const projectUsage = require('./developer/usage').createProjectUsage(db);
const projectUsage = require('./developer/usage').createProjectUsage(db);
+// Sharing (design 2026-11-06, SHARING-DESIGN-2026-11-06.md): Network's index of what is shared with
+// whom, built from the share events, and the notifications that go with them (server/sharing/).
+const sharing = {
+ db, notifications: notificationService, config, privateKey, issuer: config.jwt.issuer,
+};
eventsConsumer = await require('./notifications/events-consumer').createEventsConsumer({
eventsConsumer = await require('./notifications/events-consumer').createEventsConsumer({
db, notifications: notificationService, secrets: config.eventsWebhookSecrets,
db, notifications: notificationService, secrets: config.eventsWebhookSecrets,
discord: () => app.locals.discordService || null, moderationAudit, projectUsage,
discord: () => app.locals.discordService || null, moderationAudit, projectUsage,
+ sharing: { apply: (event) => require('./sharing/events').applyShareEvent(sharing, event) },
});
});
console.log(`[Events consumer] ${eventsConsumer.enabled ? 'on' : 'off (NETWORK_EVENTS_SECRET unset)'}: POST /internal/events`);
console.log(`[Events consumer] ${eventsConsumer.enabled ? 'on' : 'off (NETWORK_EVENTS_SECRET unset)'}: POST /internal/events`);
@@ -430,6 +436,9 @@ const qrSignin = require('./auth/qr-signin').createQrSignin({
@@ -430,6 +436,9 @@ const qrSignin = require('./auth/qr-signin').createQrSignin({
app.locals.qrSignin = qrSignin;
app.locals.qrSignin = qrSignin;
// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.
// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.
app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());
app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());
+// Sharing (design 2026-11-06): what is shared with this person, the share picker's suggestions and
+// the @name check it uses (server/sharing/routes.js).
+app.use('/api/v1', require('./sharing/routes').createSharingApi({ ...sharing, requireAuth }));
// Staff capabilities and the staff list (WS-D task 4; services need network.staff.read).
// Staff capabilities and the staff list (WS-D task 4; services need network.staff.read).
app.use('/api/v1/staff', require('./admin/staff-api').createStaffApi({ db, requireAuth, guard: require('./identity/principals').guard }));
app.use('/api/v1/staff', require('./admin/staff-api').createStaffApi({ db, requireAuth, guard: require('./identity/principals').guard }));
// Username history (WS-B task 6): who holds a name now, so sites redirect /@old → /@new and pick up a
// Username history (WS-B task 6): who holds a name now, so sites redirect /@old → /@new and pick up a
@@ -914,6 +923,9 @@ function sendPayPage(res) {
@@ -914,6 +923,9 @@ function sendPayPage(res) {
}
}
app.get('/wallet', (_req, res) => sendWalletPage(res));
app.get('/wallet', (_req, res) => sendWalletPage(res));
app.get('/wallet.html', (req, res) => redirectWithoutHtml(req, res, '/wallet'));
app.get('/wallet.html', (req, res) => redirectWithoutHtml(req, res, '/wallet'));
+// "Shared with me" (design 2026-11-06): everything other people have shared with this person, on
+// every site, in one list. Its own page, in the kit's components and with no styles of its own.
+app.get('/shared', require('./sharing/page').createSharedPage({ ...sharing, authCtx: authCtx() }));
app.get('/pay/:id', (_req, res) => sendPayPage(res));
app.get('/pay/:id', (_req, res) => sendPayPage(res));
// Account hub (my.html) — the apex hosts the account hub under /my
// Account hub (my.html) — the apex hosts the account hub under /my
@@ -29,6 +29,10 @@
@@ -29,6 +29,10 @@
* per streamer in 24 hours.
* per streamer in 24 hours.
* today.event.cancelled the stream was cancelled: those notices are cleared (dismissed), so the bell never
* today.event.cancelled the stream was cancelled: those notices are cleared (dismissed), so the bell never
* points at a stream that is not happening.
* points at a stream that is not happening.
+ * share.granted, share.revoked, share.transfer.* the sharing system (design 2026-11-06): an item
+ * was shared with a person, their access ended, or an ownership transfer was
+ * requested or answered. They build Network's "Shared with me" list and the
+ * share notifications (server/sharing/events.js); the payloads carry ids only.
* <service>.usage.recorded a developer project's hourly usage rollup from Tools, Events or Media
* <service>.usage.recorded a developer project's hourly usage rollup from Tools, Events or Media
* (common.usage-recorded@1): kept per project and day for its dashboard
* (common.usage-recorded@1): kept per project and day for its dashboard
* (server/developer/usage.js), never anyone's notification.
* (server/developer/usage.js), never anyone's notification.
@@ -61,8 +65,11 @@ const streamLive = require('./stream-live');
@@ -61,8 +65,11 @@ const streamLive = require('./stream-live');
const CONSUMER = 'network-notifications';
const CONSUMER = 'network-notifications';
const INBOX_TABLE = 'network_event_inbox';
const INBOX_TABLE = 'network_event_inbox';
const AUDIT_TOPICS = require('../admin/moderation-audit').TOPICS;
const AUDIT_TOPICS = require('../admin/moderation-audit').TOPICS;
+// Sharing (design 2026-11-06): the share events build Network's "Shared with me" list and the
+// notifications that go with it (server/sharing/events.js).
+const SHARE_TOPICS = require('../sharing/events').TOPICS;
const USAGE_TOPICS = require('../developer/usage').TOPICS;
const USAGE_TOPICS = require('../developer/usage').TOPICS;
-const TOPICS = Object.freeze(['deals.watch.matched', 'coupons.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', 'today.event.scheduled', 'today.event.cancelled', ...AUDIT_TOPICS, ...USAGE_TOPICS]);
+const TOPICS = Object.freeze(['deals.watch.matched', 'coupons.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', 'today.event.scheduled', 'today.event.cancelled', ...SHARE_TOPICS, ...AUDIT_TOPICS, ...USAGE_TOPICS]);
const LIVE_STARTED_MAX_AGE_MS = 30 * 60 * 1000;
const LIVE_STARTED_MAX_AGE_MS = 30 * 60 * 1000;
const SCHEDULED_PER_DAY = 3;
const SCHEDULED_PER_DAY = 3;
const SCHEDULE_AHEAD_MIN_MS = 15 * 60 * 1000;
const SCHEDULE_AHEAD_MIN_MS = 15 * 60 * 1000;
@@ -216,8 +223,9 @@ function liveStarted(event, { now, maxAgeMs }) {
@@ -216,8 +223,9 @@ function liveStarted(event, { now, maxAgeMs }) {
* @param {string|string[]} o.secrets NETWORK_EVENTS_SECRET (comma list) or an array
* @param {string|string[]} o.secrets NETWORK_EVENTS_SECRET (comma list) or an array
* @param {() => ({ sendLiveAlert(streamer: object, stream: object): Promise<object> }|null)} [o.discord]
* @param {() => ({ sendLiveAlert(streamer: object, stream: object): Promise<object> }|null)} [o.discord]
* @param {{ record(event: object): string }} [o.projectUsage] ../developer/usage.js createProjectUsage()
* @param {{ record(event: object): string }} [o.projectUsage] ../developer/usage.js createProjectUsage()
+ * @param {{ apply(event: object): Promise<{outcome: string, detail?: object}> }} [o.sharing] ../sharing/events.js
*/
*/
-async function createEventsConsumer({ db, notifications, secrets, discord = () => null, moderationAudit = null, projectUsage = null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {
+async function createEventsConsumer({ db, notifications, secrets, discord = () => null, moderationAudit = null, projectUsage = null, sharing = null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {
const keys = Array.isArray(secrets) ? secrets.filter((s) => typeof s === 'string' && s.length >= 32) : secretsFrom(secrets);
const keys = Array.isArray(secrets) ? secrets.filter((s) => typeof s === 'string' && s.length >= 32) : secretsFrom(secrets);
const inbox = createPgInbox(db, { table: INBOX_TABLE, now });
const inbox = createPgInbox(db, { table: INBOX_TABLE, now });
const userBySubject = db.prepare('SELECT id FROM users WHERE subject_id = ?');
const userBySubject = db.prepare('SELECT id FROM users WHERE subject_id = ?');
@@ -312,6 +320,12 @@ async function createEventsConsumer({ db, notifications, secrets, discord = () =
@@ -312,6 +320,12 @@ async function createEventsConsumer({ db, notifications, secrets, discord = () =
const out = event.event_type === 'today.event.scheduled' ? await todayEventScheduled(event) : await todayEventCancelled(event);
const out = event.event_type === 'today.event.scheduled' ? await todayEventScheduled(event) : await todayEventCancelled(event);
return typeof out === 'string' ? out : { outcome: out.outcome, detail: out.detail };
return typeof out === 'string' ? out : { outcome: out.outcome, detail: out.detail };
}
}
+ // Sharing: the index of what is shared with whom, and the notifications about it.
+ if (SHARE_TOPICS.includes(event.event_type)) {
+ if (!sharing) return 'ignored:sharing_off';
+ const out = await sharing.apply(event);
+ return typeof out === 'string' ? out : { outcome: out.outcome, detail: out.detail };
+ }
if (event.event_type === 'live.stream.started') {
if (event.event_type === 'live.stream.started') {
const out = await liveStreamStarted(event);
const out = await liveStreamStarted(event);
if (typeof out === 'string') return out;
if (typeof out === 'string') return out;
@@ -9,8 +9,10 @@
@@ -9,8 +9,10 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const TABS = {
const TABS = {
- // What people said to you or about you.
- mentions: { types: ['MENTION', 'CONTENT_REPLY', 'CONTENT_COMMENT', 'DIRECT_MESSAGE', 'WHISPER'] },
+ // What people said to you or about you, and what they shared with you (the share kinds,
+ // design 2026-11-06).
+ mentions: { types: ['MENTION', 'CONTENT_REPLY', 'CONTENT_COMMENT', 'DIRECT_MESSAGE', 'WHISPER',
+ 'share.added', 'share.access_requested', 'share.transfer.requested', 'share.transfer.answered'] },
live: { categories: ['stream'] },
live: { categories: ['stream'] },
// Deal, coupon, trade and watch alerts: the service category.
// Deal, coupon, trade and watch alerts: the service category.
alerts: { categories: ['service'] },
alerts: { categories: ['service'] },
new file mode 100644
new file mode 100644
@@ -0,0 +1,271 @@
@@ -0,0 +1,271 @@
+'use strict';
+/**
+ * The share events, and the notifications they become (design 2026-11-06, SHARING-DESIGN-2026-11-06.md).
+ *
+ * OpenVibe.Events delivers every share event to Network (POST /internal/events). Each one does two
+ * things: it updates Network's index of what is shared with whom (server/sharing/store.js) and it
+ * makes the notification the design asks for:
+ *
+ * share.granted the person who was added gets "goosely shared \"LOOI bridge notes\" with you"
+ * share.access.requested the owner gets "goosely asked for access to \"LOOI bridge notes\""
+ * share.transfer.requested the recipient gets "goosely wants to give you \"LOOI bridge notes\""
+ * share.transfer.accepted the old owner gets "goosely accepted \"LOOI bridge notes\". Now it is yours"
+ * share.transfer.declined the old owner gets "goosely declined \"LOOI bridge notes\""
+ *
+ * The events carry ids only, so the name of the item comes from the owning service's share API
+ * (server/sharing/item.js). When the service does not answer, the text says the site and the kind
+ * instead: a notification never guesses a title.
+ *
+ * The event names and shapes are the ones of Contracts share.v1, which is still being written
+ * (OpenVibe.Contracts, branch "share"): this code waits for that release before it ships.
+ */
+const store = require('./store');
+const { refOf, memberOf, partyOf, ownerOf, whenOf } = require('./refs');
+const { resolveItem, mintViewerToken, audienceOf, siteOf } = require('./item');
+
+const TOPICS = Object.freeze([
+ 'share.granted',
+ 'share.revoked',
+ 'share.transfer.requested',
+ 'share.transfer.accepted',
+ 'share.transfer.declined',
+ 'share.access.requested',
+]);
+
+// The notification kinds of the design. They are Network's own kinds, sent through the ordinary
+// inbox (server/notifications/notification-service.js), so a person can mute them like any other.
+const KINDS = Object.freeze({
+ added: 'share.added',
+ accessRequested: 'share.access_requested',
+ transferRequested: 'share.transfer.requested',
+ transferAnswered: 'share.transfer.answered',
+});
+
+/** "@goosely", or "someone" when the owner is not a Network account we know. */
+function nameOf(person) {
+ if (!person) return 'Someone';
+ return '@' + String(person.username || '').slice(0, 40);
+}
+
+/** "a paste on OpenVibe.Community": what the text says when the service does not name the item. */
+function fallbackName(ref) {
+ const site = siteOf(ref.service);
+ const kind = String(ref.kind || 'item').replace(/[_-]+/g, ' ');
+ return 'a ' + kind + (site ? ' on ' + (site.full_name || site.name) : '');
+}
+
+/** The name of the item: the service's own, or the site and kind. */
+function itemName(details, ref) {
+ return details && details.title ? '"' + details.title + '"' : fallbackName(ref);
+}
+
+/**
+ * The item's name and link, as the owning service sees them, for one person.
+ * @returns {Promise<{title: string|null, url: string|null}>}
+ */
+async function detailsFor(ctx, ref, person) {
+ const audience = audienceOf(ref.service);
+ const token = person && audience ? mintViewerToken(ctx, person, audience) : null;
+ return await resolveItem(ctx, ref, { token });
+}
+
+/**
+ * One notification, for one person.
+ * @returns {Promise<string>} the outcome, for the consumer's log.
+ */
+async function notify(ctx, { to, from, kind, title, message, icon, priority = 'normal', ref, url }) {
+ const db = ctx.db;
+ if (!store.isSubject(to)) return 'ignored:recipient';
+ const recipient = await store.personBySubject(db, to);
+ if (!recipient) return 'ignored:recipient';
+ const owner = from && store.isSubject(from) ? await store.personBySubject(db, from) : null;
+ const details = await detailsFor(ctx, ref, recipient);
+ const sender = owner ? nameOf(owner) : 'Someone';
+ const text = message(sender, itemName(details, ref));
+ const link = details.url || url || null;
+ const created = await ctx.notifications.create({
+ user_id: recipient.id,
+ type: kind,
+ category: 'social',
+ priority,
+ icon,
+ title,
+ message: text,
+ service: ref.service,
+ url: link,
+ sender_id: owner ? owner.id : null,
+ sender_name: owner ? (owner.display_name || owner.username) : null,
+ sender_avatar: owner ? owner.avatar_url : null,
+ rich_content: {
+ service: ref.service,
+ context: {
+ share_kind: kind,
+ resource: { service: ref.service, kind: ref.kind, id: ref.id },
+ owner_subject: owner ? owner.subject_id : null,
+ planned_url: link,
+ },
+ },
+ });
+ return created ? 'notified' : 'suppressed:preference';
+}
+
+/** share.granted: the person who was added is told, and the item joins their list. */
+async function granted(ctx, event, ref, payload, now) {
+ const member = memberOf(payload);
+ if (!member) return 'ignored:payload';
+ const owner = member.addedBy || ownerOf(event, payload);
+ if (!owner) return 'ignored:owner';
+ await store.recordGrant(ctx.db, {
+ recipient: member.subject,
+ owner,
+ service: ref.service,
+ kind: ref.kind,
+ itemId: ref.id,
+ role: member.role,
+ expiresAt: member.expiresAt,
+ grantedAt: member.addedAt || now,
+ eventId: event.event_id,
+ });
+ return await notify(ctx, {
+ to: member.subject,
+ from: owner,
+ kind: KINDS.added,
+ title: 'Shared with you',
+ message: (sender, item) => sender + ' shared ' + item + ' with you.',
+ icon: '🤝',
+ ref,
+ });
+}
+
+/** share.revoked: the item leaves that person's list. */
+async function revoked(ctx, event, ref, payload, now) {
+ const member = memberOf(payload);
+ if (!member) return 'ignored:payload';
+ const changed = await store.recordRevocation(ctx.db, {
+ recipient: member.subject,
+ service: ref.service,
+ kind: ref.kind,
+ itemId: ref.id,
+ revokedAt: now,
+ eventId: event.event_id,
+ });
+ return changed ? 'revoked' : 'ignored:unknown';
+}
+
+/** share.transfer.requested: the recipient is told, and the offer waits for them. */
+async function transferRequested(ctx, event, ref, payload, now) {
+ const from = partyOf(payload, 'from') || ownerOf(event, payload);
+ const to = partyOf(payload, 'to');
+ if (!from || !to) return 'ignored:payload';
+ await store.recordTransfer(ctx.db, {
+ id: payload.transfer_id ? String(payload.transfer_id).slice(0, 64) : null,
+ service: ref.service,
+ kind: ref.kind,
+ itemId: ref.id,
+ from,
+ to,
+ keepAccess: payload.keep_access === true,
+ state: 'pending',
+ requestedAt: store.iso(payload.requested_at) || now,
+ expiresAt: store.iso(payload.expires_at),
+ eventId: event.event_id,
+ });
+ return await notify(ctx, {
+ to,
+ from,
+ kind: KINDS.transferRequested,
+ title: 'Ownership offered',
+ message: (sender, item) => sender + ' wants to give you ' + item + '.',
+ icon: '🎁',
+ priority: 'high',
+ ref,
+ });
+}
+
+/** share.transfer.accepted / .declined: the offer is answered, and the old owner is told. */
+async function transferAnswered(ctx, event, ref, payload, now, accepted) {
+ const from = partyOf(payload, 'from') || ownerOf(event, payload);
+ const to = partyOf(payload, 'to');
+ if (!from || !to) return 'ignored:payload';
+ const state = accepted ? 'accepted' : 'declined';
+ const keepAccess = payload.keep_access === true;
+ const id = payload.transfer_id ? String(payload.transfer_id).slice(0, 64) : null;
+ await store.recordTransfer(ctx.db, {
+ id,
+ service: ref.service,
+ kind: ref.kind,
+ itemId: ref.id,
+ from,
+ to,
+ keepAccess,
+ state,
+ requestedAt: store.iso(payload.requested_at) || now,
+ expiresAt: store.iso(payload.expires_at),
+ answeredAt: now,
+ eventId: event.event_id,
+ });
+ // The new owner holds the item; the old one keeps access only when they asked to keep it.
+ if (accepted) {
+ if (keepAccess) {
+ await store.recordGrant(ctx.db, {
+ recipient: from, owner: to, service: ref.service, kind: ref.kind, itemId: ref.id,
+ role: 'viewer', expiresAt: null, grantedAt: now, eventId: event.event_id,
+ });
+ } else {
+ await store.recordRevocation(ctx.db, {
+ recipient: to, service: ref.service, kind: ref.kind, itemId: ref.id, revokedAt: now, eventId: event.event_id,
+ });
+ }
+ }
+ return await notify(ctx, {
+ to: from,
+ from: to,
+ kind: KINDS.transferAnswered,
+ title: 'Transfer answered',
+ message: (sender, item) => accepted
+ ? sender + ' accepted ' + item + '. It is theirs now.'
+ : sender + ' declined ' + item + '. Nothing changed.',
+ icon: '↩️',
+ ref,
+ });
+}
+
+/** share.access.requested: the owner is told, so they can add the person with one tap. */
+async function accessRequested(ctx, event, ref, payload, now) {
+ const from = partyOf(payload, 'from');
+ const to = partyOf(payload, 'to') || ownerOf(event, payload);
+ if (!from || !to) return 'ignored:payload';
+ return await notify(ctx, {
+ to,
+ from,
+ kind: KINDS.accessRequested,
+ title: 'Access requested',
+ message: (sender, item) => sender + ' asked for access to ' + item + '.',
+ icon: '🔑',
+ ref,
+ });
+}
+
+/**
+ * One share event: the index and the notification.
+ * @returns {Promise<{outcome: string, detail?: object}>}
+ */
+async function applyShareEvent(ctx, event) {
+ const type = String(event.event_type || '');
+ if (!TOPICS.includes(type)) return { outcome: 'ignored:type' };
+ const payload = (event.payload && typeof event.payload === 'object') ? event.payload : {};
+ const ref = refOf(payload);
+ if (!ref) return { outcome: 'ignored:payload' };
+ const now = whenOf(event);
+ let outcome;
+ if (type === 'share.granted') outcome = await granted(ctx, event, ref, payload, now);
+ else if (type === 'share.revoked') outcome = await revoked(ctx, event, ref, payload, now);
+ else if (type === 'share.transfer.requested') outcome = await transferRequested(ctx, event, ref, payload, now);
+ else if (type === 'share.transfer.accepted') outcome = await transferAnswered(ctx, event, ref, payload, now, true);
+ else if (type === 'share.transfer.declined') outcome = await transferAnswered(ctx, event, ref, payload, now, false);
+ else if (type === 'share.access.requested') outcome = await accessRequested(ctx, event, ref, payload, now);
+ else outcome = 'ignored:type';
+ return { outcome, detail: { resource: ref } };
+}
+
+module.exports = { applyShareEvent, TOPICS, KINDS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
+'use strict';
+/**
+ * What a shared item is called, and where it lives (design 2026-11-06, SHARING-DESIGN-2026-11-06.md).
+ *
+ * The share events carry ids only, so Network asks the owning service for the item's own name and
+ * link: GET <service>/api/v1/<kind>/<id>/share, the uniform share API of the SDK
+ * (openvibe-sdk/share, in review). The service answers only for a viewer who may see the item, so
+ * Network calls it as the person: with a short-lived token of that person's own session, minted for
+ * the owning service alone (see mintViewerToken).
+ *
+ * When the service does not answer (it is down, the item was deleted, the contract does not carry a
+ * title yet) the item is shown as its site and kind. Nothing is ever guessed.
+ */
+const jwt = require('jsonwebtoken');
+const contracts = require('openvibe-contracts');
+const { SITES, fullName } = require('../frame/sites');
+
+const TIMEOUT_MS = 3500;
+const TTL_SEC = 60;
+
+/** The manifest of a service, or null when the service is not one of ours. */
+function manifestOf(service) {
+ try { return contracts.services.get(service) || null; } catch { return null; }
+}
+
+/** The audience a service expects of a person's token: its own host name (openvibe.community). */
+function audienceOf(service) {
+ const m = manifestOf(service);
+ if (!m) return null;
+ const origin = m.publicOrigin || (Array.isArray(m.domains) ? m.domains[0] : null);
+ try { return new URL(String(origin)).hostname; } catch { return null; }
+}
+
+/** The hosts that belong to a service: its own domains, so a link to the item is kept only when it is on one of them. */
+function hostsOf(service) {
+ const m = manifestOf(service);
+ const hosts = new Set();
+ if (m) {
+ for (const d of m.domains || []) hosts.add(String(d).toLowerCase());
+ try { if (m.publicOrigin) hosts.add(new URL(m.publicOrigin).hostname.toLowerCase()); } catch { /* no origin */ }
+ }
+ for (const s of SITES) if (s.service === service && s.host) hosts.add(String(s.host).toLowerCase());
+ return hosts;
+}
+
+/** The site's name and link, for the "which site is this on" column. */
+function siteOf(service) {
+ const s = SITES.find((x) => x.service === service) || null;
+ const m = manifestOf(service);
+ const host = s ? s.host : (m && m.domains && m.domains[0]) || null;
+ return {
+ id: s ? s.id : service,
+ name: s ? s.name : (m && m.name) || service,
+ full_name: s ? fullName(s) : (m && m.name) || service,
+ host: host || null,
+ url: host ? 'https://' + host : null,
+ };
+}
+
+/**
+ * A short-lived token of the person's own session for one service: the audience is that service
+ * alone and it lives a minute. It is minted only for a service of our own manifests and only for a
+ * person Network already knows may see the item. It is never stored and never logged.
+ */
+function mintViewerToken(ctx, user, audience) {
+ if (!ctx || !ctx.privateKey || !audience || !user) return null;
+ const key = String(ctx.privateKey);
+ const algorithm = key.includes('BEGIN') ? 'RS256' : 'HS256';
+ try {
+ return jwt.sign({
+ sub: user.id,
+ id: user.id,
+ subject_id: user.subject_id || undefined,
+ username: user.username,
+ display_name: user.display_name || user.username,
+ role: user.role,
+ avatar_url: user.avatar_url,
+ }, key, { algorithm, issuer: ctx.issuer, audience: [audience], expiresIn: TTL_SEC });
+ } catch { return null; }
+}
+
+/** The first string of a list of fields, cleaned. */
+function pick(body, fields) {
+ for (const f of fields) {
+ const v = body && typeof body[f] === 'string' ? body[f] : null;
+ if (v) { const c = String(v).replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 200); if (c) return c; }
+ }
+ return null;
+}
+
+/** The first link of a list of fields, kept only when it is https and on one of the service's hosts. */
+function pickLink(body, service) {
+ const allowed = hostsOf(service);
+ for (const f of ['url', 'link', 'item_url', 'href']) {
+ const v = body && typeof body[f] === 'string' ? body[f] : null;
+ if (!v) continue;
+ try {
+ const u = new URL(String(v).trim());
+ if (u.protocol !== 'https:' || !allowed.has(u.hostname.toLowerCase())) continue;
+ return u.toString().slice(0, 500);
+ } catch { /* not a link */ }
+ }
+ return null;
+}
+
+/**
+ * The item's name and link, as the owning service sees them.
+ * @returns {Promise<{title: string|null, url: string|null}>}
+ */
+async function resolveItem(ctx, ref, { token = null, timeoutMs = TIMEOUT_MS } = {}) {
+ const out = { title: null, url: null };
+ if (!ref) return out;
+ const base = ctx.serviceBase ? ctx.serviceBase(ref.service) : defaultBase(ctx, ref.service);
+ if (!base) return out;
+ const path = '/api/v1/' + encodeURIComponent(ref.kind) + '/' + encodeURIComponent(ref.id) + '/share';
+ try {
+ const headers = { accept: 'application/json' };
+ if (token) headers.authorization = 'Bearer ' + token;
+ const r = await (ctx.fetchImpl || fetch)(String(base).replace(/\/+$/, '') + path, {
+ headers,
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ if (!r || !r.ok) return out;
+ const body = await r.json().catch(() => null);
+ if (!body || typeof body !== 'object') return out;
+ const item = body.item && typeof body.item === 'object' ? body.item : {};
+ const share = body.share && typeof body.share === 'object' ? body.share : {};
+ out.title = pick(body, ['title', 'item_title', 'name'])
+ || pick(item, ['title', 'name'])
+ || pick(share, ['title', 'name'])
+ || null;
+ out.url = pickLink(body, ref.service)
+ || pickLink(item, ref.service)
+ || pickLink(share, ref.service);
+ return out;
+ } catch { return out; }
+}
+
+/** Where a service is reached: its internal URL when Network knows one, else its public origin. */
+function defaultBase(ctx, service) {
+ const internal = ctx.config && ctx.config.services && ctx.config.services[service] && ctx.config.services[service].internalUrl;
+ if (internal) return String(internal);
+ const m = manifestOf(service);
+ return m && m.publicOrigin ? String(m.publicOrigin) : null;
+}
+
+module.exports = { resolveItem, mintViewerToken, siteOf, audienceOf, manifestOf, hostsOf, TIMEOUT_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -0,0 +1,192 @@
+'use strict';
+/**
+ * openvibe.network/shared — "Shared with me" (design 2026-11-06, SHARING-DESIGN-2026-11-06.md).
+ *
+ * One list of everything other people have shared with this person, on every site: the site, the
+ * item and its link, who shared it, when, and the role. The transfers that wait for an answer come
+ * first, with Accept and Decline links to the item itself, where the share sheet asks.
+ *
+ * The page is built from the kit's own components (openvibe-shared/kit). The one rule of its own is for
+ * a phone, where the kit's row would cut the item's name and the words under it: the row wraps there
+ * (PAGE_CSS below). Signed out the page says so and offers the sign-in; signed in it never shows
+ * anybody else's list.
+ */
+const kit = require('openvibe-shared/kit');
+const shell = require('openvibe-shared/shell');
+const store = require('./store');
+const { describeItems, itemView, transferView, MAX_DETAILS } = require('./routes');
+const { sessionToken, verifySession } = require('../auth/session');
+
+/**
+ * The page's own rules, and only for a phone.
+ *
+ * The kit's row is built for one line: the title and the words under it are cut with an ellipsis,
+ * and the trail (Accept and Decline) sits beside them. On a phone there is no room for that, so the
+ * row wraps: the title and the words take two lines each, and the two answers go on their own line
+ * under the words, where a thumb can reach them.
+ *
+ * Scoped to this page (.ovk-shared) and to 480 px, so a computer keeps the kit's own look.
+ */
+const PAGE_CSS = `<style>
+@media (max-width:480px){
+.ovk-shared .ovk-row--static{flex-wrap:wrap}
+.ovk-shared .ovk-row-trail{width:100%;box-sizing:border-box;padding-left:48px}
+.ovk-shared .ovk-row-top{align-items:flex-start}
+.ovk-shared .ovk-row-top strong{white-space:normal;overflow:visible;text-overflow:clip}
+.ovk-shared .ovk-row-sub>span:first-child{white-space:normal;overflow:visible}
+}
+</style>`;
+
+/** What the item is called when the owning service does not name it. */
+function fallbackName(item) {
+ const kind = String(item.kind || 'item').replace(/[_-]+/g, ' ');
+ return 'a ' + kind + (item.site ? ' on ' + (item.site.full_name || item.site.name) : '');
+}
+
+/** "@goosely" for a person. */
+function labelOf(person) {
+ return person && person.username ? '@' + person.username : 'Someone';
+}
+
+/** The name a person's picture is drawn from. */
+function nameOf(person) {
+ if (!person) return '?';
+ return person.display_name || person.username || '?';
+}
+
+/** The page: the list, the transfers, and the empty states. */
+async function renderPage(ctx, user) {
+ const subject = user ? user.subject_id : null;
+ let items = [];
+ let transfers = [];
+ if (subject) {
+ const grants = await store.listGrants(ctx.db, subject);
+ const pending = (await store.listTransfers(ctx.db, subject)).filter((t) => t.state === 'pending');
+ const refs = [
+ ...grants.map((g) => ({ service: g.service, kind: g.kind, id: g.item_id })),
+ ...pending.map((t) => ({ service: t.service, kind: t.kind, id: t.item_id })),
+ ].slice(0, MAX_DETAILS);
+ const details = await describeItems(ctx, refs, user);
+ const owners = new Map();
+ const ownerOf = async (s) => {
+ if (!store.isSubject(s)) return null;
+ if (!owners.has(s)) owners.set(s, await store.personBySubject(ctx.db, s));
+ return owners.get(s);
+ };
+ for (const g of grants) {
+ items.push(itemView(g, await ownerOf(g.owner_subject), details.get([g.service, g.kind, g.item_id].join('|'))));
+ }
+ for (const t of pending) {
+ transfers.push(transferView(t, await ownerOf(t.from_subject), details.get([t.service, t.kind, t.item_id].join('|'))));
+ }
+ }
+
+ const transferRows = transfers.map((t) => {
+ const when = kit.when(t.requested_at);
+ // The two answers sit in the row's trail, and the row's own line keeps the item's name: on
+ // a phone the trail takes the room a badge or a date would.
+ const trail = [
+ t.accept_url ? kit.button({ href: t.accept_url, label: 'Accept', kind: 'primary', small: true }) : '',
+ t.decline_url ? kit.button({ href: t.decline_url, label: 'Decline', kind: 'quiet', small: true }) : '',
+ ].filter(Boolean).join('');
+ const site = t.site ? (t.site.full_name || t.site.name) : '';
+ return kit.row({
+ mark: kit.mark({ name: nameOf(t.from), src: t.from ? t.from.avatar_url : null, size: 36 }),
+ title: t.title || fallbackName(t),
+ sub: [labelOf(t.from) + ' wants to give you this', site, when.text].filter(Boolean).join(' \u00b7 '),
+ trail,
+ });
+ });
+
+ const itemRows = items.map((i) => {
+ const when = kit.when(i.shared_at);
+ return kit.row({
+ href: i.url || null,
+ mark: kit.mark({ name: nameOf(i.owner), src: i.owner ? i.owner.avatar_url : null, size: 36 }),
+ title: i.title || fallbackName(i),
+ badge: kit.badge(i.role === 'editor' ? 'Can edit' : 'Can view', i.role === 'editor' ? 'accent' : ''),
+ meta: when.text,
+ metaIso: when.iso,
+ sub: 'shared by ' + labelOf(i.owner) + ' \u00b7 ' + (i.site ? (i.site.full_name || i.site.name) : ''),
+ });
+ });
+
+ const stats = kit.stats([
+ [items.length, 'shared with you'],
+ [transfers.length, 'waiting for you'],
+ ], { label: 'What is shared with you' });
+
+ let body;
+ if (!user) {
+ body = kit.empty({
+ title: 'Sign in to see what is shared with you',
+ text: 'Everything other people share with you lives here, on every site.',
+ action: { href: '/login?next=%2Fshared', label: 'Sign in' },
+ });
+ } else if (!items.length && !transfers.length) {
+ body = kit.empty({
+ title: 'Nothing is shared with you yet',
+ text: 'When someone shares a paste, a picture or a project with you, it lands here.',
+ icon: 'check',
+ });
+ } else {
+ body = [
+ transfers.length ? kit.section({
+ title: 'Waiting for you',
+ count: transfers.length,
+ note: 'Open the item to look before you answer.',
+ body: kit.list(transferRows),
+ }) : '',
+ items.length ? kit.section({
+ title: 'Shared with you',
+ count: items.length,
+ body: kit.list(itemRows),
+ }) : '',
+ ].join('\n');
+ }
+
+ return shell.page({
+ name: 'network',
+ service: 'network',
+ title: 'Shared with me',
+ siteName: 'OpenVibe.Network',
+ description: 'Everything other people have shared with you on OpenVibe, in one place.',
+ canonical: String(ctx.config.baseUrl || 'https://openvibe.network').replace(/\/+$/, '') + '/shared',
+ robots: 'noindex, nofollow',
+ head: '<link rel="stylesheet" href="/shared/kit.css">\n<script src="/shared/kit-web.js" defer></script>\n' + PAGE_CSS,
+ body: '<main class="ovk-page ovk-shared">\n'
+ + kit.head({ title: 'Shared with me', sub: 'What other people have shared with you.' })
+ + (user ? stats : '')
+ + body
+ + '</main>',
+ navLinks: [{ label: 'Your account', href: '/my' }],
+ navbar: { service: 'network' },
+ footer: { service: 'network', variant: 'compact' },
+ });
+}
+
+/**
+ * The route: GET /shared.
+ * @param {object} ctx { db, config, privateKey, issuer, fetchImpl, serviceBase, authCtx }
+ */
+function createSharedPage(ctx) {
+ return async function sharedPage(req, res) {
+ try {
+ const token = sessionToken(req);
+ let user = null;
+ if (token) {
+ const out = await verifySession(token, ctx.authCtx);
+ if (!out.error) user = out.user;
+ }
+ const html = await renderPage(ctx, user);
+ res.set('Cache-Control', 'private, no-store');
+ res.set('X-Robots-Tag', 'noindex, nofollow');
+ res.type('html').send(html);
+ } catch (err) {
+ console.error('[Sharing] GET /shared error:', err.message);
+ res.status(500).type('html').send('<!doctype html><title>Shared with me</title><p>Something went wrong.');
+ }
+ };
+}
+
+module.exports = { createSharedPage, renderPage };
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+/**
+ * The shapes of the share events (Contracts share.v1, in review: OpenVibe.Contracts, branch "share").
+ *
+ * a resource ref { service, kind, id }
+ * a member { subject, role: viewer | editor, expires_at?, added_by, added_at }
+ * a transfer { to, keep_access, requested_at, expires_at, state }
+ *
+ * The events are share.granted, share.revoked, share.transfer.requested, share.transfer.accepted and
+ * share.transfer.declined; their payloads carry ids only, never a title or content.
+ *
+ * The parser here is deliberately forgiving about where a field sits (payload.resource or the flat
+ * payload.service / payload.kind / payload.id, payload.member.subject or payload.recipient), because
+ * the contract is still being written. What it never does is invent: a field it cannot read is null,
+ * and the caller treats that as "the event does not say enough".
+ */
+const { clean, iso, isSubject } = require('./store');
+
+const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
+const KIND_RE = /^[a-z][a-z0-9_-]{1,39}$/;
+const ITEM_RE = /^[A-Za-z0-9_.:-]{1,128}$/;
+
+/** The resource an event is about, or null when the payload does not name one. */
+function refOf(payload) {
+ const p = payload && typeof payload === 'object' ? payload : {};
+ const r = (p.resource && typeof p.resource === 'object') ? p.resource
+ : (p.ref && typeof p.ref === 'object') ? p.ref
+ : (p.item && typeof p.item === 'object') ? p.item : p;
+ const service = clean(r.service, 40).toLowerCase();
+ const kind = clean(r.kind, 40).toLowerCase();
+ const id = clean(r.id != null ? r.id : r.item_id, 128);
+ if (!SERVICE_RE.test(service) || !KIND_RE.test(kind) || !ITEM_RE.test(id)) return null;
+ return { service, kind, id };
+}
+
+/** The person a grant names, or null. */
+function memberOf(payload) {
+ const p = payload && typeof payload === 'object' ? payload : {};
+ const m = (p.member && typeof p.member === 'object') ? p.member : p;
+ const subject = clean(m.subject || m.subject_id || p.member_subject || p.recipient || p.to, 64);
+ if (!isSubject(subject)) return null;
+ const role = clean(m.role, 20).toLowerCase() === 'editor' ? 'editor' : 'viewer';
+ const addedBy = clean(m.added_by || p.added_by || p.owner || p.from || p.from_subject, 64);
+ return {
+ subject,
+ role,
+ expiresAt: iso(m.expires_at || p.expires_at),
+ addedBy: isSubject(addedBy) ? addedBy : null,
+ addedAt: iso(m.added_at || p.added_at || p.granted_at),
+ };
+}
+
+/** The person a transfer names, or null. */
+function partyOf(payload, which) {
+ const p = payload && typeof payload === 'object' ? payload : {};
+ const raw = which === 'from'
+ ? (p.from || p.from_subject || p.owner || p.owner_subject || p.sender)
+ : (p.to || p.to_subject || p.recipient || p.buyer);
+ const subject = clean(raw, 64);
+ return isSubject(subject) ? subject : null;
+}
+
+/** The owner of the item, from the event's subject or actor when they are a person. */
+function ownerOf(event, payload) {
+ const p = payload && typeof payload === 'object' ? payload : {};
+ const fromPayload = clean(p.owner || p.owner_subject || p.added_by || p.from || p.from_subject, 64);
+ if (isSubject(fromPayload)) return fromPayload;
+ const s = event && event.subject;
+ if (s && typeof s === 'object' && s.type === 'user' && isSubject(s.id)) return s.id;
+ const a = event && event.actor;
+ if (a && typeof a === 'object' && a.type === 'user' && isSubject(a.id)) return a.id;
+ return null;
+}
+
+/** When the event happened, as an ISO instant. */
+function whenOf(event) {
+ return iso(event && event.occurred_at) || new Date().toISOString();
+}
+
+module.exports = { refOf, memberOf, partyOf, ownerOf, whenOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,184 @@
@@ -0,0 +1,184 @@
+'use strict';
+/**
+ * Network's sharing API (design 2026-11-06, SHARING-DESIGN-2026-11-06.md, section 4 "Network").
+ *
+ * GET /api/v1/shared-with-me the items other people shared with this person, and the
+ * transfers waiting for their answer
+ * GET /api/v1/share/suggestions the people this person has shared with (the share picker's
+ * suggestions): from their own history only, at most 20
+ * GET /api/v1/share/name-check one @name check for the share picker, with a per-person budget
+ *
+ * The list is built from the share events (server/sharing/store.js); the name of an item and its
+ * own link come from the owning service's share API when it answers (server/sharing/item.js).
+ * Everything here is private: the routes need the person's own session and answer only about them.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const store = require('./store');
+const { resolveItem, mintViewerToken, audienceOf, siteOf } = require('./item');
+const { resolveUsername } = require('../identity/subjects');
+
+// The picker's budget: thirty name checks a minute for one person. Typing a name checks it as you
+// go, so the budget is generous for one search and too small to walk the network's accounts.
+const NAME_CHECK_LIMIT = 30;
+const NAME_CHECK_WINDOW_MS = 60_000;
+// How many items are named on demand in one answer. A long list shows the site and kind beyond this.
+const MAX_DETAILS = 25;
+const NAME_RE = /^[A-Za-z0-9_.-]{1,40}$/;
+
+/** The key of an item in a map. */
+const keyOf = (r) => [r.service, r.kind, r.id].join('|');
+
+/**
+ * The name and link of each item, as the owning service sees them, for one person.
+ * One token per service is minted and reused for that service's items.
+ */
+async function describeItems(ctx, refs, viewer) {
+ const out = new Map();
+ const byService = new Map();
+ for (const r of refs) {
+ if (!r) continue;
+ if (!byService.has(r.service)) byService.set(r.service, []);
+ byService.get(r.service).push(r);
+ }
+ await Promise.all([...byService.entries()].map(async ([service, list]) => {
+ const audience = audienceOf(service);
+ const token = viewer && audience ? mintViewerToken(ctx, viewer, audience) : null;
+ const named = await Promise.all(list.map((r) => resolveItem(ctx, r, { token }).then((d) => [keyOf(r), d])));
+ for (const [k, d] of named) out.set(k, d);
+ }));
+ return out;
+}
+
+/** The item, as the list shows it. */
+function itemView(grant, owner, details) {
+ const site = siteOf(grant.service);
+ return {
+ service: grant.service,
+ kind: grant.kind,
+ id: grant.item_id,
+ site,
+ title: (details && details.title) || null,
+ url: (details && details.url) || null,
+ owner: owner ? store.personView(owner) : null,
+ role: grant.role,
+ shared_at: grant.granted_at,
+ expires_at: grant.expires_at || null,
+ };
+}
+
+/** The item's link with the share sheet's answer on it: the person lands on the item, where the
+ * Accept and Decline buttons of the sheet are. */
+function answerUrl(url, answer) {
+ if (!url) return null;
+ try {
+ const u = new URL(url);
+ u.searchParams.set('share', answer);
+ return u.toString();
+ } catch { return url; }
+}
+
+/** A transfer, as the list shows it. */
+function transferView(transfer, owner, details) {
+ const site = siteOf(transfer.service);
+ const url = (details && details.url) || null;
+ return {
+ id: transfer.id,
+ service: transfer.service,
+ kind: transfer.kind,
+ item_id: transfer.item_id,
+ site,
+ title: (details && details.title) || null,
+ url,
+ from: owner ? store.personView(owner) : null,
+ state: transfer.state,
+ keep_access: Boolean(transfer.keep_access),
+ requested_at: transfer.requested_at,
+ expires_at: transfer.expires_at || null,
+ accept_url: answerUrl(url, 'accept'),
+ decline_url: answerUrl(url, 'decline'),
+ };
+}
+
+/**
+ * The API of sharing.
+ * @param {object} ctx { db, requireAuth, config, privateKey, issuer, fetchImpl, serviceBase }
+ */
+function createSharingApi(ctx) {
+ const router = express.Router();
+
+ // The items shared with this person, and the transfers waiting for their answer.
+ router.get('/shared-with-me', ctx.requireAuth, async (req, res) => {
+ try {
+ const subject = req.user.subject_id;
+ const grants = await store.listGrants(ctx.db, subject);
+ const transfers = (await store.listTransfers(ctx.db, subject)).filter((t) => t.state === 'pending');
+ const refs = [
+ ...grants.map((g) => ({ service: g.service, kind: g.kind, id: g.item_id })),
+ ...transfers.map((t) => ({ service: t.service, kind: t.kind, id: t.item_id })),
+ ].slice(0, MAX_DETAILS);
+ const details = await describeItems(ctx, refs, req.user);
+ const owners = new Map();
+ const ownerOf = async (s) => {
+ if (!store.isSubject(s)) return null;
+ if (!owners.has(s)) owners.set(s, await store.personBySubject(ctx.db, s));
+ return owners.get(s);
+ };
+ const items = [];
+ for (const g of grants) items.push(itemView(g, await ownerOf(g.owner_subject), details.get(keyOf({ service: g.service, kind: g.kind, id: g.item_id }))));
+ const pending = [];
+ for (const t of transfers) pending.push(transferView(t, await ownerOf(t.from_subject), details.get(keyOf({ service: t.service, kind: t.kind, id: t.item_id }))));
+ res.set('Cache-Control', 'private, no-store');
+ res.json({ ok: true, items, transfers: pending, count: items.length });
+ } catch (err) {
+ console.error('[Sharing] GET /shared-with-me error:', err.message);
+ res.status(500).json({ ok: false, error: 'Could not read the shared list' });
+ }
+ });
+
+ // The people this person has shared with: the share picker's suggestions, from their own history.
+ router.get('/share/suggestions', ctx.requireAuth, async (req, res) => {
+ try {
+ const people = await store.listSuggestions(ctx.db, req.user.subject_id);
+ res.set('Cache-Control', 'private, no-store');
+ res.json({ ok: true, people, count: people.length });
+ } catch (err) {
+ console.error('[Sharing] GET /share/suggestions error:', err.message);
+ res.status(500).json({ ok: false, error: 'Could not read the suggestions' });
+ }
+ });
+
+ // One @name check for the share picker, with a per-person budget: nobody may walk the network's
+ // accounts by typing names one after another.
+ router.get('/share/name-check', ctx.requireAuth, async (req, res) => {
+ try {
+ const name = String(req.query.name || '').trim().replace(/^@/, '');
+ if (!NAME_RE.test(name)) return http.sendProblem(res, 400, 'share.invalid_name', { detail: 'an OpenVibe name is 1 to 40 letters, numbers, dots, dashes or underscores' });
+ const since = new Date(Date.now() - NAME_CHECK_WINDOW_MS).toISOString();
+ const used = await store.countNameChecks(ctx.db, req.user.id, since);
+ if (used >= NAME_CHECK_LIMIT) {
+ res.set('Retry-After', String(Math.ceil(NAME_CHECK_WINDOW_MS / 1000)));
+ return http.sendProblem(res, 429, 'share.too_many_checks', { detail: 'too many name checks; wait a minute and try again' });
+ }
+ await store.recordNameCheck(ctx.db, req.user.id, name);
+ const person = await resolveUsername(ctx.db, name);
+ res.set('Cache-Control', 'private, no-store');
+ res.json({
+ ok: true,
+ name,
+ exists: Boolean(person),
+ subject: person ? person.subject.id : null,
+ username: person ? person.username : null,
+ display_name: person ? person.display_name : null,
+ avatar_url: person ? person.avatar_url : null,
+ });
+ } catch (err) {
+ console.error('[Sharing] GET /share/name-check error:', err.message);
+ res.status(500).json({ ok: false, error: 'Could not check the name' });
+ }
+ });
+
+ return router;
+}
+
+module.exports = { createSharingApi, describeItems, itemView, transferView, answerUrl, NAME_CHECK_LIMIT, NAME_CHECK_WINDOW_MS, MAX_DETAILS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,172 @@
@@ -0,0 +1,172 @@
+'use strict';
+/**
+ * Network's index of what is shared with whom (design 2026-11-06, SHARING-DESIGN-2026-11-06.md).
+ *
+ * The owning service stays the authority on access: its own canView decides who may open an item.
+ * These rows are Network's read model, built from the share events over OpenVibe.Events
+ * (share.granted, share.revoked, share.transfer.requested, share.transfer.accepted,
+ * share.transfer.declined), and they answer three questions:
+ *
+ * - "Shared with me": the items other people shared with this person (openvibe.network/shared).
+ * - "People you've shared with": the picker's suggestions, from this person's own history only.
+ * - the pending transfers, with the Accept and Decline links to the item.
+ *
+ * Event payloads carry ids only (never a title), so a row knows a service, a kind and an id; the
+ * title and the item's own link come from the owning service's share API when it answers
+ * (server/sharing/item.js).
+ *
+ * Timestamps are ISO-8601 UTC strings, so comparisons stay lexical.
+ */
+const crypto = require('crypto');
+
+const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const MAX_SUGGESTIONS = 20;
+const MAX_ITEMS = 200;
+
+/** A usr_ subject, or null. */
+const isSubject = (v) => USER_SUBJECT_RE.test(String(v || ''));
+
+/** Plain, single-line, length-capped text: nothing from an event reaches a page as markup. */
+function clean(v, n = 200) {
+ return String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, n);
+}
+
+/** An ISO-8601 UTC instant, or null when the value is not a time. */
+function iso(v) {
+ if (v == null || v === '') return null;
+ const t = Date.parse(v);
+ return Number.isFinite(t) ? new Date(t).toISOString() : null;
+}
+
+/** The id of a membership row: the same person, item and grant always get the same row. */
+function grantId(recipient, service, kind, itemId) {
+ const h = crypto.createHash('sha256').update([recipient, service, kind, itemId].join('\u0000')).digest('hex').toUpperCase();
+ return 'sgr_' + h.slice(0, 26);
+}
+
+/** The id of a transfer row, when the event does not name one. */
+function transferId(from, to, service, kind, itemId) {
+ const h = crypto.createHash('sha256').update([from, to, service, kind, itemId].join('\u0000')).digest('hex').toUpperCase();
+ return 'sht_' + h.slice(0, 26);
+}
+
+/** The schema lives in migrations/0038_sharing.sql; nothing is created at runtime. */
+function ensureSchema(db) { /* nothing to do */ }
+
+/**
+ * A membership: the item is shared with `recipient` by `owner`.
+ * A second grant for the same item and person updates the row (the role may have changed).
+ */
+async function recordGrant(db, g) {
+ const id = grantId(g.recipient, g.service, g.kind, g.itemId);
+ await db.prepare(`INSERT INTO share_grants
+ (id, recipient_subject, owner_subject, service, kind, item_id, role, expires_at, granted_at, revoked_at, event_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)
+ ON CONFLICT (recipient_subject, service, kind, item_id) DO UPDATE SET
+ owner_subject = EXCLUDED.owner_subject,
+ role = EXCLUDED.role,
+ expires_at = EXCLUDED.expires_at,
+ granted_at = EXCLUDED.granted_at,
+ revoked_at = NULL,
+ event_id = EXCLUDED.event_id`).run(
+ id, g.recipient, g.owner, g.service, g.kind, g.itemId, g.role || 'viewer',
+ g.expiresAt || null, g.grantedAt, g.eventId || null);
+ return id;
+}
+
+/** The owner's access ended: the item leaves that person's list. */
+async function recordRevocation(db, r) {
+ const res = await db.prepare(`UPDATE share_grants SET revoked_at = ?, event_id = ?
+ WHERE recipient_subject = ? AND service = ? AND kind = ? AND item_id = ? AND revoked_at IS NULL`)
+ .run(r.revokedAt, r.eventId || null, r.recipient, r.service, r.kind, r.itemId);
+ return res.changes || 0;
+}
+
+/** A transfer, or the answer to one. */
+async function recordTransfer(db, t) {
+ const id = t.id || transferId(t.from, t.to, t.service, t.kind, t.itemId);
+ await db.prepare(`INSERT INTO share_transfers
+ (id, service, kind, item_id, from_subject, to_subject, keep_access, state, requested_at, expires_at, answered_at, event_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (id) DO UPDATE SET
+ state = EXCLUDED.state,
+ keep_access = EXCLUDED.keep_access,
+ expires_at = EXCLUDED.expires_at,
+ answered_at = EXCLUDED.answered_at,
+ event_id = EXCLUDED.event_id`).run(
+ id, t.service, t.kind, t.itemId, t.from, t.to, t.keepAccess ? 1 : 0, t.state || 'pending',
+ t.requestedAt, t.expiresAt || null, t.answeredAt || null, t.eventId || null);
+ return id;
+}
+
+/** The items shared with this person, newest first. A revoked or expired grant is not in the list. */
+async function listGrants(db, subject, { now = new Date().toISOString(), limit = MAX_ITEMS } = {}) {
+ if (!isSubject(subject)) return [];
+ return await db.prepare(`SELECT * FROM share_grants
+ WHERE recipient_subject = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
+ ORDER BY granted_at DESC, id DESC LIMIT ?`).all(subject, now, limit);
+}
+
+/** The transfers of this person, newest first. An offer past its date reads as expired. */
+async function listTransfers(db, subject, { now = new Date().toISOString(), limit = 50 } = {}) {
+ if (!isSubject(subject)) return [];
+ const rows = await db.prepare(`SELECT * FROM share_transfers WHERE to_subject = ? OR from_subject = ?
+ ORDER BY requested_at DESC, id DESC LIMIT ?`).all(subject, subject, limit);
+ return rows.map((r) => (r.state === 'pending' && r.expires_at && r.expires_at <= now ? { ...r, state: 'expired' } : r));
+}
+
+/** The person's own row: their subject, name and picture. */
+async function personBySubject(db, subject) {
+ if (!isSubject(subject)) return null;
+ return await db.prepare('SELECT id, subject_id, username, display_name, avatar_url FROM users WHERE subject_id = ?').get(subject);
+}
+
+/** How a person is shown: @name and a display name. */
+function personView(row) {
+ if (!row) return null;
+ return {
+ subject: row.subject_id,
+ username: row.username,
+ display_name: row.display_name || row.username,
+ avatar_url: row.avatar_url || null,
+ };
+}
+
+/**
+ * The people this person has shared with (the picker's suggestions): only from their own history,
+ * newest first, at most 20.
+ */
+async function listSuggestions(db, subject, { limit = MAX_SUGGESTIONS } = {}) {
+ if (!isSubject(subject)) return [];
+ const rows = await db.prepare(`SELECT recipient_subject, MAX(granted_at) AS last_at, COUNT(*)::int AS shares
+ FROM share_grants WHERE owner_subject = ? AND recipient_subject <> owner_subject
+ GROUP BY recipient_subject ORDER BY last_at DESC LIMIT ?`).all(subject, Math.min(Number(limit) || MAX_SUGGESTIONS, MAX_SUGGESTIONS));
+ const out = [];
+ for (const r of rows) {
+ const person = await personBySubject(db, r.recipient_subject);
+ if (!person) continue; // a deleted or merged account is not a suggestion
+ out.push({ ...personView(person), shared_at: r.last_at, shares: Number(r.shares) || 1 });
+ }
+ return out;
+}
+
+/** One @name check, counted against the person's budget. */
+async function recordNameCheck(db, userId, name, now = new Date().toISOString()) {
+ await db.prepare('INSERT INTO share_name_checks (user_id, name, checked_at) VALUES (?, ?, ?)')
+ .run(Number(userId), clean(name, 64), now);
+}
+
+/** How many @name checks this person made since a moment. */
+async function countNameChecks(db, userId, since) {
+ const r = await db.prepare('SELECT COUNT(*)::int AS n FROM share_name_checks WHERE user_id = ? AND checked_at > ?')
+ .get(Number(userId), since);
+ return Number(r && r.n) || 0;
+}
+
+module.exports = {
+ ensureSchema, isSubject, clean, iso, grantId, transferId,
+ recordGrant, recordRevocation, recordTransfer,
+ listGrants, listTransfers, personBySubject, personView, listSuggestions,
+ recordNameCheck, countNameChecks,
+ MAX_SUGGESTIONS, MAX_ITEMS,
+};
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand']]);
assert.ok(await hasAgents());
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
@@ -68,7 +68,9 @@ const rows = async (userId) => await db.prepare('SELECT * FROM notifications WHE
@@ -68,7 +68,9 @@ const rows = async (userId) => await db.prepare('SELECT * FROM notifications WHE
(async () => {
(async () => {
// The subscriptions this consumer is meant for, and nothing else.
// The subscriptions this consumer is meant for, and nothing else.
- assert.deepStrictEqual(TOPICS, ['deals.watch.matched', 'coupons.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', 'today.event.scheduled', 'today.event.cancelled', 'chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'space.moderation.action', 'tips.interaction.moderated', 'billing.staff.action',
+ assert.deepStrictEqual(TOPICS, ['deals.watch.matched', 'coupons.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', 'today.event.scheduled', 'today.event.cancelled',
+ 'share.granted', 'share.revoked', 'share.transfer.requested', 'share.transfer.accepted', 'share.transfer.declined', 'share.access.requested',
+ 'chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'space.moderation.action', 'tips.interaction.moderated', 'billing.staff.action',
...['media', 'tools', 'games', 'wiki', 'blog', 'news', 'reviews', 'deals', 'coupons', 'trade', 'services'].map((svc) => `${svc}.moderation.action`),
...['media', 'tools', 'games', 'wiki', 'blog', 'news', 'reviews', 'deals', 'coupons', 'trade', 'services'].map((svc) => `${svc}.moderation.action`),
'tools.usage.recorded', 'events.usage.recorded', 'media.usage.recorded']);
'tools.usage.recorded', 'events.usage.recorded', 'media.usage.recorded']);
assert.deepStrictEqual(topicsFrom([]), TOPICS, 'scripts/subscribe-events.js subscribes every topic');
assert.deepStrictEqual(topicsFrom([]), TOPICS, 'scripts/subscribe-events.js subscribes every topic');
new file mode 100644
new file mode 100644
@@ -0,0 +1,245 @@
@@ -0,0 +1,245 @@
+'use strict';
+// Sharing on OpenVibe: Network's part (design 2026-11-06, SHARING-DESIGN-2026-11-06.md).
+//
+// The share events (share.granted, share.revoked, share.transfer.*) arrive over OpenVibe.Events and
+// build two things: the list of what is shared with this person (GET /api/v1/shared-with-me, the
+// /shared page) and the notifications about it. The picker's suggestions (GET /api/v1/share/suggestions)
+// come from the same events and belong to one person alone; the @name check the picker uses
+// (GET /api/v1/share/name-check) has a budget per person, so nobody can walk the network's accounts.
+//
+// node test/shared-with-me.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const { ids } = require('openvibe-contracts');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { getDb } = require('../server/db/database');
+const { NotificationService } = require('../server/notifications/notification-service');
+const { createEventsConsumer } = require('../server/notifications/events-consumer');
+const { createSharingApi } = require('../server/sharing/routes');
+const { createSharedPage } = require('../server/sharing/page');
+const { NAME_CHECK_LIMIT } = require('../server/sharing/routes');
+
+(async () => {
+const db = getDb();
+const log = console.log; console.log = () => {};
+const notifications = new NotificationService(db);
+console.log = log;
+
+const ALICE = ids.newId('user'), BOB = ids.newId('user');
+await db.prepare("INSERT INTO users (id, username, display_name, password_hash, subject_id, email, email_verified) VALUES (7, 'alice', 'Alice', 'x', ?, '[email protected]', 1), (8, 'bob', 'Bob', 'x', ?, NULL, 0)").run(ALICE, BOB);
+
+const SECRET = 's'.repeat(40);
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' }, baseUrl: 'https://openvibe.network', services: {} };
+const { signToken } = require('../server/auth/routes');
+const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
+
+// The owning service: it answers the uniform share API only for a viewer that brings a token, and
+// names the item. Network asks it on the person's behalf (server/sharing/item.js).
+const items = {
+ 'paste/pst_1': { title: 'LOOI bridge notes', url: 'https://openvibe.community/p/pst_1' },
+ 'paste/pst_2': { title: 'Second paste', url: 'https://openvibe.community/p/pst_2' },
+};
+const owner = express();
+owner.get('/api/v1/:kind/:id/share', (req, res) => {
+ if (!/^Bearer /.test(String(req.headers.authorization || ''))) return res.status(401).json({ error: 'auth.required' });
+ const item = items[req.params.kind + '/' + req.params.id];
+ if (!item) return res.status(404).json({ error: 'not.found' });
+ res.json({ audience: 'people', title: item.title, url: item.url, members: [] });
+});
+const ownerServer = http.createServer(owner);
+await new Promise((r) => ownerServer.listen(0, '127.0.0.1', r));
+const ownerBase = 'http://127.0.0.1:' + ownerServer.address().port;
+
+const sharingCtx = {
+ db, notifications, config, privateKey: keys.privateKey, issuer: config.jwt.issuer,
+ serviceBase: () => ownerBase,
+};
+
+const consumer = await createEventsConsumer({
+ db, notifications, secrets: SECRET,
+ sharing: { apply: (event) => require('../server/sharing/events').applyShareEvent(sharingCtx, event) },
+});
+const app = express();
+app.use('/internal/events', (req, res, next) => consumer.router(req, res, next));
+app.use(express.json());
+app.use('/api/v1', createSharingApi({ ...sharingCtx, requireAuth }));
+app.get('/shared', createSharedPage({ ...sharingCtx, authCtx: { db, publicKey: keys.publicKey, config } }));
+const server = http.createServer(app);
+await new Promise((r) => server.listen(0, '127.0.0.1', r));
+const base = 'http://127.0.0.1:' + server.address().port;
+
+const tok = async (id) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);
+const as = async (id) => ({ authorization: 'Bearer ' + await tok(id) });
+const call = async (method, path, { body, headers = {} } = {}) => {
+ const r = await fetch(base + path, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined });
+ return { status: r.status, body: await r.json().catch(() => null) };
+};
+const post = async (event) => {
+ const raw = JSON.stringify({ event, seq: 1 });
+ const r = await fetch(base + '/internal/events', { method: 'POST', headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) }, body: raw });
+ return { status: r.status, body: await r.json() };
+};
+const shareEvent = (type, payload, over = {}) => ({
+ event_id: ids.newId('event'), event_type: type, version: 1, source: 'community',
+ actor: { type: 'user', id: BOB }, subject: { type: 'user', id: BOB }, visibility: 'internal',
+ occurred_at: new Date().toISOString(), payload, ...over,
+});
+const inbox = async (userId) => await db.prepare('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at, seq').all(userId);
+
+(async () => {
+ try {
+ // ── share.granted: the item joins the list, and the person is told ──
+ const granted = shareEvent('share.granted', {
+ resource: { service: 'community', kind: 'paste', id: 'pst_1' },
+ member: { subject: ALICE, role: 'editor', expires_at: null, added_by: BOB, added_at: '2026-11-06T09:12:00.000Z' },
+ });
+ let r = await post(granted);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.outcome, 'notified', JSON.stringify(r.body));
+
+ let mine = await inbox(7);
+ assert.strictEqual(mine.length, 1, 'one notification for the person who was added');
+ assert.strictEqual(mine[0].type, 'share.added');
+ assert.strictEqual(mine[0].title, 'Shared with you');
+ assert.strictEqual(mine[0].message, '@bob shared "LOOI bridge notes" with you.', 'plain words, the item named');
+ assert.strictEqual(mine[0].url, 'https://openvibe.community/p/pst_1', 'the item link');
+ assert.strictEqual(mine[0].sender_name, 'Bob');
+
+ // The list, from the events.
+ r = await call('GET', '/api/v1/shared-with-me', { headers: await as(7) });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.items.length, 1);
+ const item = r.body.items[0];
+ assert.strictEqual(item.service, 'community');
+ assert.strictEqual(item.kind, 'paste');
+ assert.strictEqual(item.id, 'pst_1');
+ assert.strictEqual(item.title, 'LOOI bridge notes', 'the name comes from the owning service');
+ assert.strictEqual(item.url, 'https://openvibe.community/p/pst_1');
+ assert.strictEqual(item.site.full_name, 'OpenVibe.Community', 'the site it is on');
+ assert.strictEqual(item.owner.username, 'bob', 'who shared it');
+ assert.strictEqual(item.role, 'editor', 'the role');
+ assert.ok(item.shared_at, 'when it was shared');
+
+ // It is private: Bob's list is his own.
+ r = await call('GET', '/api/v1/shared-with-me', { headers: await as(8) });
+ assert.strictEqual(r.body.items.length, 0, 'the list is the person\'s own');
+ r = await call('GET', '/api/v1/shared-with-me');
+ assert.strictEqual(r.status, 401, 'signed in only');
+
+ // The page, for the same person.
+ const page = await fetch(base + '/shared', { headers: await as(7) });
+ assert.strictEqual(page.status, 200);
+ const html = await page.text();
+ assert.ok(html.includes('LOOI bridge notes'), 'the page names the item');
+ assert.ok(html.includes('OpenVibe.Community'), 'the page names the site');
+ assert.ok(html.includes('@bob'), 'the page names who shared it');
+ assert.ok(html.includes('Can edit'), 'the page says the role');
+ // On a phone the kit's row would cut the item's name and the words under it: the page carries
+ // its own rule for that, scoped to itself.
+ assert.ok(html.includes('ovk-shared'), 'the page scopes its own phone rule');
+ assert.ok(html.includes('max-width:480px'), 'the phone rule is on the page');
+ assert.ok(html.includes('ovk-row-trail'), 'the phone rule moves the trail');
+ const anon = await fetch(base + '/shared');
+ assert.strictEqual(anon.status, 200);
+ assert.ok((await anon.text()).includes('Sign in'), 'signed out, the page asks to sign in');
+
+ // ── share.revoked: the item leaves the list ──
+ r = await post(shareEvent('share.revoked', {
+ resource: { service: 'community', kind: 'paste', id: 'pst_1' },
+ member: { subject: ALICE, added_by: BOB },
+ }));
+ assert.strictEqual(r.body.outcome, 'revoked');
+ r = await call('GET', '/api/v1/shared-with-me', { headers: await as(7) });
+ assert.strictEqual(r.body.items.length, 0, 'the revoked item is gone');
+
+ // ── a transfer: pending, then answered ──
+ const transfer = { transfer_id: 'sht_1', resource: { service: 'community', kind: 'paste', id: 'pst_2' }, from: BOB, to: ALICE, keep_access: false, requested_at: '2026-11-06T10:00:00.000Z', expires_at: '2026-11-13T10:00:00.000Z' };
+ r = await post(shareEvent('share.transfer.requested', transfer));
+ assert.strictEqual(r.body.outcome, 'notified');
+ mine = await inbox(7);
+ const offer = mine.find((n) => n.type === 'share.transfer.requested');
+ assert.ok(offer, 'the recipient is told');
+ assert.strictEqual(offer.message, '@bob wants to give you "Second paste".');
+ assert.strictEqual(offer.url, 'https://openvibe.community/p/pst_2');
+
+ r = await call('GET', '/api/v1/shared-with-me', { headers: await as(7) });
+ assert.strictEqual(r.body.transfers.length, 1, 'the transfer waits for an answer');
+ const t = r.body.transfers[0];
+ assert.strictEqual(t.state, 'pending');
+ assert.strictEqual(t.from.username, 'bob');
+ assert.strictEqual(t.accept_url, 'https://openvibe.community/p/pst_2?share=accept', 'Accept links to the item');
+ assert.strictEqual(t.decline_url, 'https://openvibe.community/p/pst_2?share=decline', 'Decline links to the item');
+
+ // The old owner is told when the answer comes.
+ r = await post(shareEvent('share.transfer.accepted', { ...transfer, accepted_at: '2026-11-06T11:00:00.000Z' }));
+ assert.strictEqual(r.body.outcome, 'notified');
+ const bobs = await inbox(8);
+ const answered = bobs.find((n) => n.type === 'share.transfer.answered');
+ assert.ok(answered, 'the old owner is told');
+ assert.strictEqual(answered.message, '@alice accepted "Second paste". It is theirs now.');
+ r = await call('GET', '/api/v1/shared-with-me', { headers: await as(7) });
+ assert.strictEqual(r.body.transfers.length, 0, 'the answered transfer is not pending any more');
+
+ // ── a declined transfer changes nothing ──
+ const transfer2 = { transfer_id: 'sht_2', resource: { service: 'community', kind: 'paste', id: 'pst_2' }, from: BOB, to: ALICE, keep_access: true, requested_at: '2026-11-06T12:00:00.000Z' };
+ r = await post(shareEvent('share.transfer.requested', transfer2));
+ assert.strictEqual(r.body.outcome, 'notified');
+ r = await post(shareEvent('share.transfer.declined', { ...transfer2, declined_at: '2026-11-06T12:30:00.000Z' }));
+ assert.strictEqual(r.body.outcome, 'notified');
+ const bobs2 = await inbox(8);
+ assert.ok(bobs2.some((n) => n.type === 'share.transfer.answered' && n.message.includes('declined')), 'the old owner is told');
+ r = await call('GET', '/api/v1/shared-with-me', { headers: await as(7) });
+ assert.strictEqual(r.body.transfers.length, 0, 'a declined transfer is not pending');
+
+ // ── share.access.requested: the owner is told ──
+ r = await post(shareEvent('share.access.requested', { resource: { service: 'community', kind: 'paste', id: 'pst_1' }, from: ALICE, to: BOB }));
+ assert.strictEqual(r.body.outcome, 'notified');
+ const bobs3 = await inbox(8);
+ const asked = bobs3.find((n) => n.type === 'share.access_requested');
+ assert.ok(asked, 'the owner is told');
+ assert.strictEqual(asked.message, '@alice asked for access to "LOOI bridge notes".');
+
+ // ── the suggestions: only from this person's own history, and private ──
+ r = await call('GET', '/api/v1/share/suggestions', { headers: await as(8) });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.people.length, 1, 'Bob shared with Alice, so Alice is a suggestion');
+ assert.strictEqual(r.body.people[0].username, 'alice');
+ r = await call('GET', '/api/v1/share/suggestions', { headers: await as(7) });
+ assert.strictEqual(r.body.people.length, 0, 'Alice shared with nobody');
+ r = await call('GET', '/api/v1/share/suggestions');
+ assert.strictEqual(r.status, 401, 'signed in only');
+
+ // ── the @name check: one budget per person ──
+ r = await call('GET', '/api/v1/share/name-check?name=bob', { headers: await as(7) });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.exists, true);
+ assert.strictEqual(r.body.username, 'bob');
+ r = await call('GET', '/api/v1/share/name-check?name=@bob', { headers: await as(7) });
+ assert.strictEqual(r.body.exists, true, 'a leading @ is the same name');
+ r = await call('GET', '/api/v1/share/name-check?name=nobodyhere', { headers: await as(7) });
+ assert.strictEqual(r.body.exists, false, 'no such account');
+
+ // The budget: thirty checks a minute, for this person only.
+ await db.prepare('DELETE FROM share_name_checks WHERE user_id = ?').run(7);
+ for (let i = 0; i < NAME_CHECK_LIMIT; i++) {
+ const c = await call('GET', '/api/v1/share/name-check?name=bob', { headers: await as(7) });
+ assert.strictEqual(c.status, 200, 'check ' + i + ' is inside the budget');
+ }
+ r = await call('GET', '/api/v1/share/name-check?name=bob', { headers: await as(7) });
+ assert.strictEqual(r.status, 429, 'the budget stops the walking of accounts');
+ assert.strictEqual(r.body.code, 'share.too_many_checks');
+ // Bob's own budget is his own.
+ r = await call('GET', '/api/v1/share/name-check?name=alice', { headers: await as(8) });
+ assert.strictEqual(r.status, 200, 'the budget is per person');
+
+ console.log('sharing: the list, the transfers, the notifications, the suggestions and the name-check budget are all checked');
+ process.exit(0);
+ } catch (err) {
+ console.error('sharing test failed:', err);
+ process.exit(1);
+ }
+})();
+})();
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');