openvibers/openvibe.network Commits

Sharing: Network's part — the shared list, the notifications, the picker's suggestions (#575)

OP OpenVibers committed 7 days ago · 5468b09c32 · 15 files, +1418 −7

* Sharing: Network's index of what is shared with whom, and the shared list

The share events over OpenVibe.Events (share.granted, share.revoked, share.transfer.*) build Network's index of what is shared with whom, the notifications about it (share.added, share.access_requested, share.transfer.requested, share.transfer.answered), the /shared page and GET /api/v1/shared-with-me, the share picker's suggestions (GET /api/v1/share/suggestions, from the person's own history only) and its @name check with a budget of thirty checks a minute per person.

The payloads carry ids only, so the name of an item and its own link come from the owning service's share API, asked on the person's behalf; when the service does not answer, the item is shown as its site and kind.

The event names and the payload shapes are Contracts share.v1, which is still being written: this waits for that release.

* Tests: the applied-migration lists carry the sharing migration

The two rehearsals that read the migration list (agent-schema, user-owned-trust) now include 0038_sharing.

* The shared page: on a phone the row wraps instead of cutting the name

On a phone the kit's row is one line: the item's name and the words under it are cut with an ellipsis, and Accept and Decline sit beside them. The page carries one rule of its own, scoped to itself and to 480 px: the row wraps, the title and the words take two lines each, and the two answers go on their own line under the words. On a computer the row keeps the kit's own look.

Changes

ddiff --git a/README.md b/README.md
iindex cf398e22..7e93457a 100644
---- a/README.md
++++ b/README.md
@@@ -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.
++
## License
MIT (`license` in [package.json](package.json)).
ddiff --git a/migrations/0038_sharing.sql b/migrations/0038_sharing.sql
nnew file mode 100644
iindex 00000000..aeadc6d3
---- /dev/null
++++ b/migrations/0038_sharing.sql
@@@ -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);
ddiff --git a/server/index.js b/server/index.js
iindex 3a481742..8e789005 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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
ddiff --git a/server/notifications/events-consumer.js b/server/notifications/events-consumer.js
iindex 4a296c86..16667693 100644
---- a/server/notifications/events-consumer.js
++++ b/server/notifications/events-consumer.js
@@@ -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;
ddiff --git a/server/notifications/tabs.js b/server/notifications/tabs.js
iindex e3e97d55..c6de4f93 100644
---- a/server/notifications/tabs.js
++++ b/server/notifications/tabs.js
@@@ -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'] },
ddiff --git a/server/sharing/events.js b/server/sharing/events.js
nnew file mode 100644
iindex 00000000..d912b317
---- /dev/null
++++ b/server/sharing/events.js
@@@ -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 };
ddiff --git a/server/sharing/item.js b/server/sharing/item.js
nnew file mode 100644
iindex 00000000..e39a2bdd
---- /dev/null
++++ b/server/sharing/item.js
@@@ -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 };
ddiff --git a/server/sharing/page.js b/server/sharing/page.js
nnew file mode 100644
iindex 00000000..cc447aac
---- /dev/null
++++ b/server/sharing/page.js
@@@ -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 };
ddiff --git a/server/sharing/refs.js b/server/sharing/refs.js
nnew file mode 100644
iindex 00000000..ca28e290
---- /dev/null
++++ b/server/sharing/refs.js
@@@ -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 };
ddiff --git a/server/sharing/routes.js b/server/sharing/routes.js
nnew file mode 100644
iindex 00000000..3be18520
---- /dev/null
++++ b/server/sharing/routes.js
@@@ -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 };
ddiff --git a/server/sharing/store.js b/server/sharing/store.js
nnew file mode 100644
iindex 00000000..53d4b858
---- /dev/null
++++ b/server/sharing/store.js
@@@ -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,
++};
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 597860fa..de0d4e26 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -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.
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
iindex e838e753..b7d0b91d 100644
---- a/test/events-consumer.test.js
++++ b/test/events-consumer.test.js
@@@ -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');
ddiff --git a/test/shared-with-me.test.js b/test/shared-with-me.test.js
nnew file mode 100644
iindex 00000000..aecc6e43
---- /dev/null
++++ b/test/shared-with-me.test.js
@@@ -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);
++ }
++})();
++})();
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 1a460781..1c8293ba 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -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 twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');