openvibers/openvibe.sdk Commits

Add local sharing enforcement, transfers and agent client (0.85.0) (#118)

OP OpenVibers committed 7 days ago · 8fbeefeeb4 · 26 files, +1438 −37

* Add transactional sharing core and agent client

* Harden sharing core: lock-free checks, staff narrowing, people and CSRF

- canView/canEdit take a plain snapshot; every write still locks the row.
- Staff who are not the owner may narrow access only: audience private and
  member removals through update/removeMember; addMembers refuses staff.
- Members and transfer recipients must be active user subjects, verified
  against Network's batch resolver; banned, deleted and non-user refuse.
- Only the intended notification kinds and recipients; share.added only for a
  newly added member; the owner is never told about their own audience change.
- PUT accepts notify (default true); false suppresses share.added, events flow.
- Cookie-authenticated writes require X-Requested-With or a same-site marker.
- The GET/PUT body names people (owner, members, transfer recipient, pending
  requests), adds you, pending requests and describe() fields; add the exact
  name people lookup and POST requests/<id>/dismiss.
- PUT, transfer, cancel and dismiss answer with the GET body and a fresh ETag.
- Agent client gains people() and dismiss().

* Pin openvibe-contracts v0.291.0 (share.v1 released)

* share: the docs and changelog name the released Contracts

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9827e5e..dbecc70 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,11 @@
# Changelog
++## 0.85.0 (2026-11-05)
++
++- Add server-only `openvibe-sdk/share`: PostgreSQL/PGlite store and expand-only migration, audience/role checks, exact name resolution with persistent per-caller limits, expiring membership, access requests, and recipient-bound seven-day ownership transfers. Each mutation and its append-only audit and delivery records commit atomically. Transactional owner/audience hooks update local resources.
++- Add uniform HTTP router, OpenAPI 3.1 fragment, CJS/ESM/TypeScript exports and `sdk.share({ base, token }).for(kind, id)` agent client. Conditional member updates detect concurrent ACL changes. Failures return generic 404 without resource content; staff overrides are audited.
++- Pins openvibe-contracts v0.291.0 (share.v1). No service integrations or media migration are included in this core package.
++
## 0.84.0 (2026-11-04)
- **`openvibe-sdk/vibes`: Vibes Pay for developer apps** (MONEY.md, D12). A server creates a charge with its app
ddiff --git a/README.md b/README.md
iindex 4a3781a..91ed558 100644
---- a/README.md
++++ b/README.md
@@@ -2,19 +2,19 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.84.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.85.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
--After v0.84.0 is tagged:
++After v0.85.0 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.84.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.85.0
```
--It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
++It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
## Purpose
@@@ -489,7 +489,7 @@ Install the drivers the service uses: `pg` and `iovalkey` (production), `@electr
- `openvibe-sdk` follows semver. While it is 0.x, a minor release may break an API, and [CHANGELOG.md](CHANGELOG.md) says so. Pin a tag.
- [docs/api-surface.json](docs/api-surface.json) lists every name each entry exports and what it is (function, class, a plain object's keys, …). `test/surface.test.js` fails when the exports and the file disagree, so a removal or a change of kind is a reviewed line in a diff, never a surprise on a service's next pin round; `node scripts/surface.js --check` names each change as breaking or added, and `node scripts/surface.js` updates the file. 1.0 freezes `core`, `auth`, `events`, `db` and `media` first: a breaking change there is marked `[1.0 core]`. Inside them each name is frozen, platform (exported, not frozen) or leaving before 1.0: [docs/one-point-oh.md](docs/one-point-oh.md).
--- Each release states the openvibe-contracts range it was tested against (`CONTRACTS_RANGE`), and `discover()` checks it at runtime. Contract types are copied into `types/contracts.d.ts`. `test/types.test.js` fails if they differ from the pinned Contracts release, the `openvibe-contracts` devDependency (a release tarball pin, v0.86.0), which the tools tests also use to validate requests and answers. It is never a runtime or peer dependency.
++- Each release states the openvibe-contracts range it was tested against (`CONTRACTS_RANGE`), and `discover()` checks it at runtime. Contract types are copied into `types/contracts.d.ts`. `test/types.test.js` fails if they differ from the pinned Contracts release, the `openvibe-contracts` devDependency (temporarily the sharing branch), which the tools tests also use to validate requests and answers. It is never a runtime or peer dependency.
- A new public capability ships as a minor release: first the contract, then the wrapper here, then the service's route. Removing a wrapper is a major release, after the capability's deprecation window in Contracts has passed.
## Not wrapped yet (intentionally)
@@@ -564,5 +564,9 @@ Nothing at runtime. `openvibe-contracts` and `@electric-sql/pglite` are devDepen
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.289.1
++- openvibe-contracts: v0.291.0
<!-- versions:end -->
++
++### Network-wide sharing (Node)
++
++`openvibe-sdk/share` provides local access enforcement, expiring memberships and confirmed ownership transfer. See [the sharing module](docs/share.md) for integration, API signatures, migrations and the uniform OpenAPI fragment. Agents use `require('openvibe-sdk').share({ base, token }).for('paste', id)`.
ddiff --git a/STATUS.json b/STATUS.json
iindex 7e105bd..b6eb051 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,12 +3,13 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.84.0",
-- "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
++ "version": "0.85.0",
++ "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), \u00a73.2; roadmap \u00a730",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
-- "contracts": "openvibe-contracts v0.289.1 (devDependency; runtime contracts range >=0.5.0 <1.0.0)",
++ "contracts": "openvibe-contracts v0.291.0",
"features": [
++ "share (openvibe-sdk/share): transactional local sharing, audience and membership checks, exact-name resolution, recipient-bound ownership transfers, audit, durable events/notifications, uniform API and agent client",
"redact (openvibe-sdk/redact): the shared secret scrubber for memory, the same shapes and corpus as OpenVibe.Node's internal/memory/redact.go (redact, onlySecret)",
"core client: retries, timeouts, problem+json errors, trace propagation",
"auth: service and app tokens, PKCE, offline verification, the Network signing keys (createNetworkKeys), revocation cutoffs",
@@@ -28,7 +29,7 @@
"valkey, cache, queue, pubsub and a shared limits store on Valkey 9, each with an in-process twin for tests",
"commerce (openvibe-sdk/commerce): the shared Billing client for Tips and VIP, with intentKey and receipt",
"events: the PostgreSQL outbox (lease-claimed, several relays per table) and inbox (handler in its transaction)",
-- "usage: createUsageReporter (openvibe-sdk/usage), the shared platform.usage-sample@1 reporter — validated readings queued idempotency-keyed in an outbox and relayed to Billing with retry, never dropped, refused readings kept",
++ "usage: createUsageReporter (openvibe-sdk/usage), the shared platform.usage-sample@1 reporter \u2014 validated readings queued idempotency-keyed in an outbox and relayed to Billing with retry, never dropped, refused readings kept",
"the HTTP telemetry kit (openvibe-sdk/service): the platform.telemetry-sample@1 middleware aggregated per route|method|status_class per flush, with active-requests, p95 and event-loop-lag gauges, lifted from Network's server/telemetry.js and server/observability.js",
"the telemetry sink (openvibe-sdk/telemetry): telemetrySchema() DDL, createPgTelemetrySink() and pruneTelemetry(), a per-service PostgreSQL table every service stores platform.telemetry-sample@1 into",
"account-data (openvibe-sdk/account-data, ADR-033): telemetry_samples covered once by the SDK (exported as telemetry.json, anonymized on deletion), and uncovered() listing every table with a person column that no table map, task-keyed list or reasoned exemption covers",
@@@ -37,7 +38,7 @@
"the AI Exchange face of the one planner (openvibe-sdk/placement planAi, off by default): the hard gates (exact artifact, commercial right, privacy class, trust tier, capabilities, context, health, cost ceiling), the expected cost per success in integer nano-USD, sparse quality evidence marked and not trusted, and an explanation that never carries a secret or a host; the money math in openvibe-sdk/cost (nanoUsd, chargeTokens, expectedCostNano, costPerSuccessNano)",
"consent (openvibe-sdk/consent): the L24 check before any use of a person's content or likeness for AI or data; allowed() and filter() (batches of 500), onChange() drops a subject's answers on network.consent.changed, mock() for tests; fails closed, caches 60 s, server-side",
"openvibe-sdk/machines: the network's machines hub (list, get, catalogue, pair, rename, roles, revoke) against OpenVibe.Network, for a person and for the apps they consented to",
-- "vibes (openvibe-sdk/vibes): Vibes Pay charges (create with the app reference as the Idempotency-Key, get, refund) and constant-time webhook verification (X-OpenVibe-Signature-V2, ±300 s) for developer apps"
++ "vibes (openvibe-sdk/vibes): Vibes Pay charges (create with the app reference as the Idempotency-Key, get, refund) and constant-time webhook verification (X-OpenVibe-Signature-V2, \u00b1300 s) for developer apps"
],
"updated": "2026-11-05"
}
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 294aeb0..4f45a4a 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.84.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.85.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -47,7 +47,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.84.0';
++const SDK_VERSION = '0.85.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex a1dc3b7..4e4d2d7 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -255,6 +255,7 @@
"queryOf"
]
},
++ "share": "function",
"space": {
"object": [
"createSpaceClient"
@@@ -782,6 +783,15 @@
"within": "function",
"wrap": "function"
},
++ "openvibe-sdk/share": {
++ "ShareError": "class",
++ "createShareClient": "function",
++ "createShares": "function",
++ "migrateShares": "function",
++ "share": "function",
++ "shareOpenApi": "function",
++ "sharesSchema": "function"
++ },
"openvibe-sdk/space": {
"createSpaceClient": "function"
},
ddiff --git a/docs/share.md b/docs/share.md
nnew file mode 100644
iindex 0000000..a109f03
---- /dev/null
++++ b/docs/share.md
@@@ -0,0 +1,86 @@
++# Network-wide sharing (Node)
++
++`openvibe-sdk/share` implements the 2026-11-06 sharing design in each service's PostgreSQL database. The service is the authority. The module never reads titles, content or media URLs. It uses openvibe-contracts v0.291.0 (share.v1).
++
++```js
++const { createShares, sharesSchema, shareOpenApi } = require('openvibe-sdk/share');
++const shares = createShares({
++ db, // openvibe-sdk/db; PostgreSQL in production, PGlite in tests
++ network, // createClient with an internal Network base URL and service token
++ events, // createEventsClient; publishes envelopes to Events
++ notify, // async notification => Network delivery adapter
++ onOwnerChange: async (ref, from, to, tx) => { /* update this service's resource using tx */ },
++ onAudienceChange: async (ref, audience, tx) => { /* update visibility and search projection using tx */ },
++});
++// Include sharesSchema() in an expand migration run by the migration role.
++// await shares.migrate() is a convenience for tests or that same migration role.
++await db.tx(async tx => {
++ // Insert the resource here, then its sharing row in the same ambient transaction.
++ await shares.register({ service: 'community', kind: 'paste', id }, {
++ owner_subject: authenticatedSubject, audience: 'private',
++ });
++});
++app.use(shares.router({
++ service: 'community', authenticate: verifiedViewer,
++ describe: async ref => ({ title, link, roles: ['viewer', 'editor'], audiences: ['public', 'link', 'people', 'private'], transferable: true }),
++}));
++```
++
++`verifiedViewer(req)` returns `{ subject: { type, id }, staff?: true }` from verified service authentication, or `null`. A caller's body, query, forwarded header or unverified token must never set a subject or staff bit. Cookie authentication must check CSRF for mutations in this function or in earlier middleware. Authentication precedes dispatch. The router supports Node HTTP and Express (mount at the root so it sees `/api/v1/...`); JSON bodies are capped at 64 KiB. Services still authorize their own resource read/edit routes.
++
++For a non-GET request without an `Authorization` header the router itself requires `X-Requested-With` (any value) or `Sec-Fetch-Site: same-origin|same-site`, otherwise it answers `403 { code: 'share.csrf' }` before authentication. Pass `csrf: false` only when `authenticate` already enforces CSRF. `describe(ref)` fills `title`, `link`, `roles`, `audiences` and `transferable`; omitted values default to `null`, `null`, `['viewer']`, all four audiences and `true`.
++
++The migration creates `shares`, `share_members`, `share_transfers`, `share_access_requests`, and `share_log`; supporting tables `share_deliveries` and `share_name_limits` persist deliveries and lookup budgets. It only creates tables, indexes and the append-only log guard; no table/column is dropped. Runtime reads/writes need normal DML permissions, while migration DDL runs separately. Grant runtime INSERT/SELECT on `share_log`, with no DDL privilege. Its trigger also rejects UPDATE, DELETE and TRUNCATE. The helper is repeatable.
++
++| Server method | Result |
++| --- | --- |
++| `register(ref, { owner_subject, audience = 'private' })` | Share; trusted service-only initialization, never an HTTP operation |
++| `canView(viewer, ref)`, `canEdit(viewer, ref)` | boolean; missing resources deny. A plain snapshot: page views never take a row lock |
++| `get(viewer, ref)` | the enriched share (people, `you`, pending `requests`); owner or staff. The router adds `describe()` on top |
++| `getWithRevision(viewer, ref)` | `{ value: ShareView, etag }` |
++| `present(viewer, ref, share)` | enrich a raw share with person projections and the caller's role |
++| `lookup(viewer, '@name')` | `{ person }` or `{ person: null }`, on the same rate-limit budget as name resolution |
++| `setAudience(viewer, ref, audience)` | Share |
++| `addMembers(viewer, ref, ['@name' \| subject \| { name \| subject, role, expires_at? }])` | Share; exact names or canonical active user subjects |
++| `removeMember(viewer, ref, subject)` | Share |
++| `update(viewer, ref, { audience?, members?, notify? }, { ifMatch? }?)` | Share; atomically replaces members when provided. `notify: false` suppresses `share.added` for that save only |
++| `requestAccess(viewer, ref)` | AccessRequest; deduplicated pending request and owner notification |
++| `dismiss(viewer, ref, requestId)` | Share; owner sets a pending request to `dismissed` |
++| `transfer(viewer, ref, { to: '@name' \| subject \| { subject } \| { name }, keep_access = false })` | Transfer |
++| `accept(viewer, ref, transferId)`, `decline(...)`, `cancel(...)` | Transfer |
++| `sharedWithMe(viewer, { service?, limit = 50, cursor? }?)` | `{ items, next_cursor }`; ids and access metadata only |
++| `expireMembers({ limit = 100 }?)` | number of resources swept, including expired transfers |
++| `flush({ limit = 100 }?)` | number of durable deliveries sent |
++| `router({ service, authenticate })` | Node/Express middleware |
++
++`ref` is `{ service, kind, id }`. Subjects follow `identity.subject-ref@1`; the common user case is `{ type: 'user', id: 'usr_<ULID>' }`. Members and transfer recipients must be active **user** subjects: a `guest`, `app`, `mod`, `agent`, `service` or `system` subject is refused, and a user subject is checked against Network (batched, cached 60 s) so unknown, banned and deleted accounts cannot be added. A bare subject-id string is never an identity projection. Expiry timestamps are absolute RFC3339 timestamps in the future. Viewer/editor membership is effective for **people** only. Public/link grant viewing to everyone but never grant editing to strangers; private is owner-only. Active pending transfer recipients may preview in every audience. Staff view/edit overrides append audit records even on public resources; staff cannot start/accept/decline another person's transfer. A staff member who is not the owner may narrow access only — set the audience to `private` and remove members — never widen, add a member or raise a role; anything else is a generic 404 like any other non-owner. Owners and trusted staff may manage share settings. Generic 404 responses reveal no resource title or existence through share settings.
++
++The GET/PUT body names people, not subjects: `owner`, each member, the pending transfer's `to` and each pending request carry `{ subject, username, display_name, avatar_url }` from Network's `POST /internal/identity/resolve-batch` (batched, cached 60 s). A subject Network does not know (or reports banned/deleted) appears as `{ subject, username: null }`. The body also carries `you: { subject, role: 'owner' | 'staff' }`, `requests` (pending only: `id`, person fields, `requested_at`), and whatever `describe(ref)` added. `owner_subject` stays for ids-only consumers.
++
++The resource lock serializes ACL changes, transfer answers and access checks. To make an edit and its check indivisible, call `canEdit` and write the resource in one `db.tx` (ambient mode enabled). Transfer acceptance changes owner, membership and history together, with outgoing and incoming audit entries. An old/forwarded notification cannot accept a new offer: every answer requires its transfer id, exact recipient subject, pending state and a future expiry. Cancel is owner-only. Expiry is enforced in read/action predicates even before the sweep runs. `keep_access: true` retains only viewer membership; if the resource was private, it becomes people to make that viewer access effective. With `false`, the old owner's membership is removed. Public/link resources retain their inherent anonymous viewing.
++
++Hooks run **inside** the database transaction and may run again if the database retries it. Use the provided `tx` to update the local resource, local media proxy authorization state and local search projection, or to queue an external media/search operation in a transactional service outbox. Do not perform irreversible network calls in these hooks. A hook error rolls everything back. Restrict access at the service immediately; media objects must already be private and proxied by the service before enabling people/private sharing.
++
++Every read surface must call `canView`: HTML, raw/download, API, embeds, media proxies, comments and previews. That check alone is not permission to publish/index content: only `audience === 'public'` may go to search, sitemaps or content previews/events. People/private resources must never be embedded, even for an authenticated owner. Return generic cards for other audiences, and never expose a media object's direct URL. This core package does not modify service endpoints; pilot adoption is a separate change.
++
++Names use `GET /internal/identity/resolve?username=<exact name>` and check the response's `subject` object, matching the exact username case-insensitively and refusing a `banned` or `deleted` projection. Canonical subjects use `POST /internal/identity/resolve-batch` and get the same refusal. There is no prefix/global search: `lookup()` answers one exact name, and the sheet's people picker uses it. Configure the Network client with the internal base URL and `identity.subject.resolve` service token. Defaults are 30 name checks per caller per 60-second window (`nameLimit`, `nameWindowMs`); counters are atomic and shared across SDK instances using this database. Unknown, banned, deleted and failed names consume the same budget; a rate-limited lookup is `429 share.rate_limited`. The batch limit is 100 members; PUT and add both enforce it. If multiple entries resolve to the same subject, the last entry wins (so an agent adding an existing @name updates its role). Only an already-authorized owner/staff caller reaches name resolution. Suggestions belong to Network's per-person sharing history.
++
++Name-based mutations must run outside a caller-created ambient transaction so a rollback cannot refund name-check attempts; the module opens its own mutation transaction and hooks still join it. Canonical-subject operations may join a service transaction. Schedule `expireMembers()` and `flush()` in the service worker. `flush()` refuses an ambient transaction to prevent sending uncommitted deliveries. Access expiry never depends on worker timing. Writes only enqueue deliveries; notifications/events are asynchronous and become visible after `flush`. Pending deliveries survive process restart and are sent in insertion order. A failed send remains pending and stops that flush; retry with bounded worker backoff. Delivery is at least once: Events deduplicates the stable `event_id`, and the notification adapter must deduplicate `delivery_id`. The injected `notify` function is called with `{ kind, to, actor, ref, delivery_id, ...accessMetadata }`; pass it to Network's authenticated notification route. Only these notifications exist: `share.added` to a person newly added as a member (not on a role or expiry change, and never on removal or expiry); `share.access_requested` to the owner; `share.transfer.requested` to the recipient; `share.transfer.answered` to the owner when the recipient accepts or declines or when the offer expires, and to the recipient when the owner cancels. Nobody is told about their own action, and the owner is never told about their own audience change. `notify: false` on a PUT suppresses `share.added` for that save only; events still flow. Events keep their ids-only payloads. No title/content is stored in these records. A service can enrich a direct notification only after checking the recipient's current access.
++
++The five Events payloads are `share.granted`, `share.revoked`, `share.transfer.requested`, `share.transfer.accepted`, `share.transfer.declined`. All audiences carry ids and ACL metadata only. Payload `ref.service` and envelope `source` identify the adopting service. Declare these produced types in its service manifest when adopting. Audience updates include the new audience; cancellation/expiry emit revocation of the preview grant, and acceptance includes both subjects. Grant/revoke payloads carry `reason` (member_added, member_removed, member_expired, audience_changed, transfer_cancelled, transfer_expired, ownership_changed) and the relevant `transfer_id`. Event consumers must combine audience, membership and transfer state; a revoked preview is not deletion of an independent membership. Network's aggregated list must recheck local access before disclosing content.
++
++The uniform routes and request/response schemas are exposed by `shareOpenApi()` (OpenAPI 3.1); merge its `paths` and bearer security scheme into the service document. The exact routes are GET/PUT `/api/v1/{kind}/{id}/share`, POST `.../share/requests`, POST `.../share/requests/{id}/dismiss`, POST `.../share/transfer`, POST `.../share/transfer/{accept,decline,cancel}`, GET `/api/v1/share/people?name=`, and GET `/api/v1/shared-with-me`. GET settings returns the GET body and an ETag; PUT supports If-Match and returns 412 on a stale revision. PUT, POST transfer, cancel, dismiss, and a recipient's accept or decline all answer with the GET body (and an ETag) so the sheet redraws from one shape; a recipient's accept or decline keeps the plain transfer shape its banner expects. Transfer answers take `{ transfer_id }`; a transfer request accepts `to` as `'@name'`, a subject object, `{ subject }` or `{ name }`. `POST .../requests` answers the access request. `GET /api/v1/share/people?name=exact` is signed-in only and answers `{ person }` or `{ person: null }`. HTTP errors use `{ code, error: message }`, which the SDK core maps to typed errors; unknown server errors are a generic 500.
++
++```js
++const sdk = require('openvibe-sdk');
++const item = sdk.share({ base: 'https://community.openvibe.network', token }).for('paste', id);
++await item.setAudience('people');
++await item.add('@maticus', { role: 'viewer' });
++const offer = await item.transfer('@maticus');
++// Signed in as that exact recipient:
++await recipientItem.accept(offer.transfer.id);
++```
++
++The client also has `get`, `update`, `remove(subject)`, `requestAccess`, `dismiss(requestId)`, `decline(id)`, `cancel(id)`, and service-level `sharedWithMe` and `people('@name')`. Member additions/removals read the ETag and send a conditional PUT, so they fail visibly on concurrent ACL changes. Network failures on mutations are not automatically retried. Read again to determine the result before retrying. Server CJS, ESM and TypeScript are exported from `openvibe-sdk/share`; the browser export is disabled.
++
++Run `node test/share.test.js` for the database and HTTP security suite. It covers all audience/role combinations, hidden 404s, expiration, audited staff access, exact-name resolution/rate limits, owner notification, all transfer answers, keep_access, forwarded offers, concurrency, hook rollback, immutable audit, durable retry, schemas and the agent client against a real local HTTP server.
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex 51051b6..062f772 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -4,14 +4,14 @@ import cjs from '../index.js';
export const {
createClient, OpenVibeError, isOpenVibeError, paginate, offsetPager, parseTraceparent,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
-- satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
-- registry, identity, modules, events, realtime, media,
-- community, bot, search, bench, meme, vision,
-- themes, redact, chat, ai, jobs, tools,
-- projects, resources, vip, quest, live, inventory,
-- tips, blog, reviews, wiki, coupons, deals,
-- trade, space, host, news, food, actor,
-- run, video, work, zone, play, download,
-- openre, geo, usage, commerce, telemetry,
++ satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, share,
++ auth, registry, identity, modules, events, realtime,
++ media, community, bot, search, bench, meme,
++ vision, themes, redact, chat, ai, jobs,
++ tools, projects, resources, vip, quest, live,
++ inventory, tips, blog, reviews, wiki, coupons,
++ deals, trade, space, host, news, food,
++ actor, run, video, work, zone, play,
++ download, openre, geo, usage, commerce, telemetry,
} = cjs;
export default cjs;
ddiff --git a/esm/share.mjs b/esm/share.mjs
nnew file mode 100644
iindex 0000000..e5b19f1
---- /dev/null
++++ b/esm/share.mjs
@@@ -0,0 +1,8 @@
++// Generated by scripts/esm.js from src/share.js. Do not edit.
++import cjs from '../src/share.js';
++
++export const {
++ createShares, sharesSchema, migrateShares, share, createShareClient, shareOpenApi,
++ ShareError,
++} = cjs;
++export default cjs;
ddiff --git a/index.js b/index.js
iindex 6cb7b18..487e94d 100644
---- a/index.js
++++ b/index.js
@@@ -8,6 +8,7 @@ const core = require('./src/core');
module.exports = {
...core,
++ share: require('./src/share').share,
auth: require('./src/auth'),
registry: require('./src/registry'),
identity: require('./src/identity'),
ddiff --git a/package-lock.json b/package-lock.json
iindex bf303cc..ea4514f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.84.0",
++ "version": "0.85.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.84.0",
++ "version": "0.85.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
@@@ -14,7 +14,7 @@
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.291.0",
"pg": "^8.23.0"
},
"engines": {
@@@ -198,9 +198,9 @@
"license": "MIT"
},
"node_modules/openvibe-contracts": {
-- "version": "0.289.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
-- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",
++ "version": "0.291.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.291.0",
++ "integrity": "sha512-/jZNmCA6rLPFe2Jt1FJbC8rfELcMMd0fk7466HzoXrsKzGTXQaB/hr2sjnnm407+mcEYtKBcdB6Oisjyjehh1Q==",
"dev": true,
"license": "AGPL-3.0-only",
"dependencies": {
ddiff --git a/package.json b/package.json
iindex c567cad..4e8991c 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.84.0",
++ "version": "0.85.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -448,7 +448,13 @@
"types": "./types/bundle.d.ts",
"default": "./browser/openvibe-sdk.mjs"
},
-- "./package.json": "./package.json"
++ "./package.json": "./package.json",
++ "./share": {
++ "types": "./types/share.d.ts",
++ "browser": null,
++ "import": "./esm/share.mjs",
++ "default": "./src/share.js"
++ }
},
"bin": {
"openvibe-download": "bin/openvibe-download.js"
@@@ -493,7 +499,7 @@
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.291.0",
"pg": "^8.23.0"
},
"peerDependencies": {
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 17b1664..e9b4c62 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -12,6 +12,7 @@ const path = require('path');
const ROOT = path.join(__dirname, '..');
const ENTRIES = {
++ 'share.mjs': '../src/share.js',
'index.mjs': '../index.js',
'core.mjs': '../src/core/index.js',
'auth.mjs': '../src/auth/index.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex ed9f9db..75c5ba4 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.84.0';
++const SDK_VERSION = '0.85.0';
module.exports = {
createClient,
ddiff --git a/src/share.js b/src/share.js
nnew file mode 100644
iindex 0000000..869e63a
---- /dev/null
++++ b/src/share.js
@@@ -0,0 +1,3 @@
++'use strict';
++module.exports = { ...require('./share/store'), ...require('./share/schema'), ...require('./share/client'),
++ ...require('./share/openapi'), ShareError: require('./share/validation').ShareError };
ddiff --git a/src/share/client.js b/src/share/client.js
nnew file mode 100644
iindex 0000000..e297be3
---- /dev/null
++++ b/src/share/client.js
@@@ -0,0 +1,44 @@
++'use strict';
++const { createClient } = require('../core');
++const v = require('./validation');
++/** Agent client: optimistic PUT preserves members added by another caller. */
++function share({ base, token, fetch } = {}) {
++ if (typeof base !== 'string' || !/^https?:\/\//.test(base)) throw new TypeError('share requires base');
++ const client = createClient({ token, fetch, retries: 0, autoDiscover: false });
++ const call = (path, options = {}) => client.json({ baseUrl: base, path, ...options });
++ async function read(path) {
++ const response = await client.request({ baseUrl: base, path });
++ return { value: response.data, etag: response.headers.get('etag') };
++ }
++ return {
++ sharedWithMe: options => call('/api/v1/shared-with-me', { query: options }),
++ people: name => call('/api/v1/share/people', { query: { name } }),
++ for(kind, id) {
++ v.ref({ service: 'client', kind, id });
++ const path = `/api/v1/${encodeURIComponent(kind)}/${encodeURIComponent(id)}/share`;
++ const updateMembers = async fn => {
++ const { value, etag } = await read(path);
++ if (!etag) throw new Error('Sharing server did not provide an ETag');
++ const members = value.members.map(({ subject, role, expires_at }) => ({ subject, role, ...(expires_at ? { expires_at } : {}) }));
++ return call(path, { method: 'PUT', headers: { 'If-Match': etag }, json: { members: fn(members) } });
++ };
++ return {
++ get: () => call(path),
++ update: (input, { ifMatch } = {}) => call(path, { method: 'PUT', json: input, ...(ifMatch ? { headers: { 'If-Match': ifMatch } } : {}) }),
++ setAudience: audience => call(path, { method: 'PUT', json: { audience } }),
++ add: (target, { role = 'viewer', expires_at } = {}) => updateMembers(members => {
++ const member = { ...(typeof target === 'string' ? { name: target } : { subject: target }), role, ...(expires_at ? { expires_at } : {}) };
++ return [...members.filter(m => typeof target === 'string' || !v.equal(m.subject, target)), member];
++ }),
++ remove: subject => updateMembers(members => members.filter(m => !v.equal(m.subject, subject))),
++ requestAccess: () => call(path + '/requests', { method: 'POST', json: {} }),
++ dismiss: request_id => call(path + '/requests/' + encodeURIComponent(request_id) + '/dismiss', { method: 'POST', json: {} }),
++ transfer: (to, { keep_access = false } = {}) => call(path + '/transfer', { method: 'POST', json: { to, keep_access } }),
++ accept: transfer_id => call(path + '/transfer/accept', { method: 'POST', json: { transfer_id } }),
++ decline: transfer_id => call(path + '/transfer/decline', { method: 'POST', json: { transfer_id } }),
++ cancel: transfer_id => call(path + '/transfer/cancel', { method: 'POST', json: { transfer_id } }),
++ };
++ },
++ };
++}
++module.exports = { share, createShareClient: share };
ddiff --git a/src/share/openapi.js b/src/share/openapi.js
nnew file mode 100644
iindex 0000000..6e3b44a
---- /dev/null
++++ b/src/share/openapi.js
@@@ -0,0 +1,33 @@
++'use strict';
++/** Merge paths into an adopting service's OpenAPI 3.1 document. */
++function shareOpenApi() {
++ const schema = name => ({ $ref: `https://openvibe.network/contracts/share/${name}.v1.json` });
++ const response = name => ({ description: 'Sharing result (ids and access metadata only)', content: { 'application/json': { schema: schema(name) } } });
++ const op = (id, result, request, status = '200') => ({ operationId: id, security: [{ bearerAuth: [] }],
++ ...(request ? { requestBody: { required: true, content: { 'application/json': { schema: schema(request) } } } } : {}),
++ responses: { [status]: response(result), '400': { description: 'Invalid request or unknown exact name' }, '403': { description: 'Cross-site write refused (share.csrf)' },
++ '404': { description: 'Not found (also unauthorized, no resource metadata)' }, '409': { description: 'Inactive or conflicting transfer' },
++ '412': { description: 'If-Match revision is stale' }, '429': { description: 'Name lookup rate limit' } } });
++ const parameters = ['kind', 'id'].map(name => ({ in: 'path', name, required: true, schema: { type: 'string' } }));
++ const base = '/api/v1/{kind}/{id}/share';
++ const paths = {
++ [base]: { parameters, get: op('shareGet', 'share'), put: { ...op('shareUpdate', 'share', 'update-request'), parameters: [{ in: 'header', name: 'If-Match', schema: { type: 'string' } }] } },
++ [base + '/requests']: { parameters, post: op('shareRequestAccess', 'access-request', 'access-request-create', '201') },
++ [base + '/requests/{requestId}/dismiss']: { parameters: [...parameters, { in: 'path', name: 'requestId', required: true, schema: { type: 'string', format: 'uuid' } }],
++ post: op('shareDismissRequest', 'share') },
++ [base + '/transfer']: { parameters, post: op('shareTransfer', 'share', 'transfer-request', '201') },
++ '/api/v1/shared-with-me': { get: { ...op('shareList', 'list-result'), parameters: [
++ { in: 'query', name: 'limit', schema: { type: 'integer', minimum: 1, maximum: 100, default: 50 } },
++ { in: 'query', name: 'cursor', schema: { type: 'string' } },
++ ] } },
++ '/api/v1/share/people': { get: { ...op('sharePeople', 'people-lookup'), parameters: [
++ { in: 'query', name: 'name', required: true, schema: { type: 'string' } },
++ ] } },
++ };
++ paths[base].get.responses['200'].headers = { ETag: { description: 'Revision for conditional PUT', schema: { type: 'string' } } };
++ for (const action of ['accept', 'decline']) paths[base + '/transfer/' + action] = { parameters, post: op('shareTransfer' + action[0].toUpperCase() + action.slice(1), 'transfer', 'transfer-action-request') };
++ paths[base + '/transfer/cancel'] = { parameters, post: op('shareTransferCancel', 'share', 'transfer-action-request') };
++ return { openapi: '3.1.0', info: { title: 'OpenVibe Sharing', version: '1.0.0' }, paths,
++ components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } } } };
++}
++module.exports = { shareOpenApi };
ddiff --git a/src/share/router.js b/src/share/router.js
nnew file mode 100644
iindex 0000000..a03c37a
---- /dev/null
++++ b/src/share/router.js
@@@ -0,0 +1,95 @@
++'use strict';
++const v = require('./validation');
++function send(res, status, body, headers = {}) {
++ res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'private, no-store', Vary: 'Authorization, Cookie', ...headers });
++ res.end(JSON.stringify(body));
++}
++async function body(req) {
++ if (!(req.headers['content-type'] || '').toLowerCase().startsWith('application/json')) v.fail(415, 'share.json_required', 'JSON required');
++ if (req.body !== undefined) {
++ if (Buffer.byteLength(JSON.stringify(req.body)) > 65536) v.fail(413, 'share.too_large', 'Request too large');
++ return req.body;
++ }
++ const chunks = []; let size = 0;
++ for await (const chunk of req) {
++ size += chunk.length;
++ if (size > 65536) v.fail(413, 'share.too_large', 'Request too large');
++ chunks.push(chunk);
++ }
++ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { v.invalid(); }
++}
++/**
++ * Node/Express middleware. authenticate must use verified server credentials. describe(ref) may add { title, link,
++ * roles, audiences, transferable }; csrf: false is for a service that already checks CSRF in authenticate.
++ */
++function router(shares, { service, authenticate, describe, csrf } = {}) {
++ v.ref({ service, kind: 'resource', id: 'check' });
++ if (typeof authenticate !== 'function') throw new TypeError('router requires authenticate(req)');
++ if (describe !== undefined && typeof describe !== 'function') throw new TypeError('router describe must be a function');
++ const base = { title: null, link: null, roles: ['viewer'], audiences: ['public', 'link', 'people', 'private'], transferable: true };
++ const detail = async ref => ({ ...base, ...(describe ? await describe(ref) : {}) });
++ return async function shareRouter(req, res, next) {
++ try {
++ const url = new URL(req.url, 'http://share.local');
++ const path = url.pathname;
++ const listing = path === '/api/v1/shared-with-me';
++ const people = path === '/api/v1/share/people';
++ const match = path.match(/^\/api\/v1\/([^/]+)\/([^/]+)\/share(?:\/(.*))?$/);
++ if (!listing && !people && !match) { if (next) return next(); v.hidden(); }
++ // Cookie-authenticated writes need a same-site marker; bearer tokens cannot be sent cross-site.
++ if (csrf !== false && !['GET', 'HEAD'].includes(req.method) && !req.headers.authorization) {
++ const site = String(req.headers['sec-fetch-site'] || '').toLowerCase();
++ if (req.headers['x-requested-with'] === undefined && !['same-origin', 'same-site'].includes(site)) {
++ return send(res, 403, { code: 'share.csrf', error: 'Cross-site request refused' });
++ }
++ }
++ const viewer = await authenticate(req);
++ if (people) {
++ if (req.method !== 'GET') v.fail(405, 'share.method', 'Method not allowed');
++ if (!viewer) v.hidden();
++ const name = url.searchParams.get('name');
++ if (!name) v.invalid();
++ return send(res, 200, await shares.lookup(viewer, name));
++ }
++ if (listing) {
++ if (req.method !== 'GET') v.fail(405, 'share.method', 'Method not allowed');
++ return send(res, 200, await shares.sharedWithMe(viewer, { service, limit: url.searchParams.has('limit') ? Number(url.searchParams.get('limit')) : 50, cursor: url.searchParams.get('cursor') || undefined }));
++ }
++ let kind, id;
++ try { kind = decodeURIComponent(match[1]); id = decodeURIComponent(match[2]); } catch { v.invalid(); }
++ const ref = v.ref({ service, kind, id }), rest = match[3];
++ const view = async (status = 200) => {
++ const result = await shares.getWithRevision(viewer, ref);
++ return send(res, status, { ...result.value, ...(await detail(ref)) }, { ETag: result.etag });
++ };
++ if (!rest && req.method === 'GET') return await view();
++ if (!rest && req.method === 'PUT') { await shares.update(viewer, ref, await body(req), { ifMatch: req.headers['if-match'] }); return await view(); }
++ if (req.method !== 'POST' || !rest) v.fail(405, 'share.method', 'Method not allowed');
++ const input = await body(req);
++ if (rest === 'requests') { v.object(input, []); return send(res, 201, await shares.requestAccess(viewer, ref)); }
++ const dismiss = rest.match(/^requests\/([^/]+)\/dismiss$/);
++ if (dismiss) {
++ v.object(input, []);
++ let requestId; try { requestId = decodeURIComponent(dismiss[1]); } catch { v.invalid(); }
++ await shares.dismiss(viewer, ref, requestId);
++ return await view();
++ }
++ if (rest === 'transfer') { v.object(input, ['to', 'keep_access']); await shares.transfer(viewer, ref, input); return await view(201); }
++ const answer = rest.match(/^transfer\/(accept|decline|cancel)$/);
++ if (answer) {
++ v.object(input, ['transfer_id']); v.uuid(input.transfer_id);
++ const result = await shares[answer[1]](viewer, ref, input.transfer_id);
++ if (answer[1] === 'cancel') return await view();
++ // The recipient answered, not the owner: keep the transfer shape its banner expects.
++ return send(res, 200, result);
++ }
++ v.hidden();
++ } catch (err) {
++ if (res.headersSent) { res.end(); return; }
++ if (err instanceof v.ShareError) return send(res, err.status, { code: err.code, error: err.message });
++ // Neither SQL errors nor upstream errors can reveal a private resource or credentials.
++ return send(res, 500, { code: 'share.internal', error: 'Sharing unavailable' });
++ }
++ };
++}
++module.exports = { router };
ddiff --git a/src/share/schema.js b/src/share/schema.js
nnew file mode 100644
iindex 0000000..30d8085
---- /dev/null
++++ b/src/share/schema.js
@@@ -0,0 +1,60 @@
++'use strict';
++/** Expand-only DDL. Run with the migration role, never the HTTP runtime role. */
++function sharesSchema() {
++ return `
++CREATE TABLE IF NOT EXISTS shares (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
++ revision integer NOT NULL DEFAULT 1,
++ PRIMARY KEY (service, kind, resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_members (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
++ PRIMARY KEY (service, kind, resource_id, subject),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_transfers (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_access_requests (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_log (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
++DO $$ BEGIN
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
++ END IF;
++END $$;
++CREATE TABLE IF NOT EXISTS share_deliveries (
++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE TABLE IF NOT EXISTS share_name_limits (
++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
++);
++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
++`;
++}
++async function migrateShares(db) { await db.tx(async (t) => { await t.exec(sharesSchema()); }); }
++module.exports = { sharesSchema, migrateShares };
ddiff --git a/src/share/store.js b/src/share/store.js
nnew file mode 100644
iindex 0000000..c777272
---- /dev/null
++++ b/src/share/store.js
@@@ -0,0 +1,445 @@
++'use strict';
++const { randomUUID } = require('node:crypto');
++const { newEventId } = require('../core/ids');
++const v = require('./validation');
++const { migrateShares } = require('./schema');
++const WEEK = 7 * 24 * 60 * 60 * 1000;
++const PEOPLE_TTL = 60 * 1000;
++const key = ref => [ref.service, ref.kind, ref.id];
++const where = 'service=$1 AND kind=$2 AND resource_id=$3';
++const iso = value => new Date(value).toISOString();
++const personKey = subject => `${subject.type}:${subject.id}`;
++const unknownPerson = subject => ({ subject, username: null, display_name: null, avatar_url: null });
++const transferShape = row => ({ id: row.id, from: row.from_subject, to: row.to_subject, keep_access: row.keep_access,
++ requested_at: iso(row.requested_at), expires_at: iso(row.expires_at), state: row.state });
++const memberShape = row => ({ subject: row.subject, role: row.role, ...(row.expires_at ? { expires_at: iso(row.expires_at) } : {}),
++ added_by: row.added_by, added_at: iso(row.added_at) });
++
++function createShares({ db, network, events, notify, onOwnerChange, onAudienceChange, now = Date.now, nameLimit = 30, nameWindowMs = 60000 } = {}) {
++ if (!db || !db.tx || !network || !network.json || !events || !events.publish || typeof notify !== 'function') {
++ throw new TypeError('createShares requires db, network.json, events.publish and notify');
++ }
++ if (!Number.isInteger(nameLimit) || nameLimit < 1 || !Number.isInteger(nameWindowMs) || nameWindowMs < 1) throw new TypeError('Invalid name rate limit');
++ const timestamp = () => iso(now());
++ const peopleCache = new Map();
++ async function row(t, ref) { const s = await t.maybe(`SELECT * FROM shares WHERE ${where} FOR UPDATE`, key(ref)); if (!s) v.hidden(); return s; }
++ async function log(t, ref, actor, action, details = {}) {
++ await t.exec('INSERT INTO share_log(id,service,kind,resource_id,actor,action,details,occurred_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)', [randomUUID(), ...key(ref), actor, action, details, timestamp()]);
++ }
++ async function queue(t, channel, body) {
++ const id = randomUUID();
++ await t.exec('INSERT INTO share_deliveries(id,channel,body,created_at) VALUES ($1,$2,$3,$4)', [id, channel, { ...body, delivery_id: id }, timestamp()]);
++ }
++ async function event(t, ref, s, actor, type, subject, extra = {}) {
++ // Build an allow-listed payload; never spread a resource or HTTP body into an event.
++ const payload = { ref, actor, owner_subject: s.owner_subject, audience: s.audience, subject, occurred_at: timestamp(), ...extra };
++ await queue(t, 'event', { event_id: newEventId(now()), event_type: type, version: 1, timestamp: timestamp(), source: ref.service, actor, subject: { type: ref.kind, id: ref.id }, payload });
++ }
++ async function notice(t, ref, kind, recipient, actor, extra = {}) {
++ // Never tell somebody about their own action.
++ if (v.equal(recipient, actor)) return;
++ await queue(t, 'notify', { kind, to: recipient, actor, ref, ...extra });
++ }
++ async function permitted(t, ref, s, viewer, audit) {
++ const a = v.actor(viewer);
++ if (v.equal(a, s.owner_subject)) return { actor: a, staff: false };
++ if (viewer.staff === true) { if (audit) await log(t, ref, a, 'staff.manage'); return { actor: a, staff: true }; }
++ v.hidden();
++ }
++ async function owner(t, ref, s, viewer) { return permitted(t, ref, s, viewer, true); }
++ async function members(t, ref) {
++ return t.many(`SELECT * FROM share_members WHERE ${where} AND (expires_at IS NULL OR expires_at>$4) ORDER BY added_at,subject`, [...key(ref), timestamp()]);
++ }
++ async function pending(t, ref) { return t.maybe(`SELECT * FROM share_transfers WHERE ${where} AND state='pending' AND expires_at>$4`, [...key(ref), timestamp()]); }
++ async function snapshot(t, ref, s) {
++ const tr = await pending(t, ref);
++ return { ref, owner_subject: s.owner_subject, audience: s.audience, members: (await members(t, ref)).map(memberShape), ...(tr ? { transfer: transferShape(tr) } : {}) };
++ }
++ async function bump(t, ref) { await t.exec(`UPDATE shares SET revision=revision+1 WHERE ${where}`, key(ref)); }
++ async function access(t, ref, s, viewer, edit) {
++ const a = viewer?.subject ? v.subject(viewer.subject) : null;
++ if (v.equal(a, s.owner_subject)) return true;
++ if (a && viewer.staff === true) { await log(t, ref, a, edit ? 'staff.edit' : 'staff.view'); return true; }
++ if (!edit && ['public', 'link'].includes(s.audience)) return true;
++ if (!a) return false;
++ if (!edit) { const tr = await pending(t, ref); if (tr && v.equal(tr.to_subject, a)) return true; }
++ if (s.audience !== 'people') return false;
++ const m = await t.maybe(`SELECT role FROM share_members WHERE ${where} AND subject=$4 AND (expires_at IS NULL OR expires_at>$5)`, [...key(ref), a, timestamp()]);
++ return !!m && (!edit || m.role === 'editor');
++ }
++ async function check(viewer, input, edit) {
++ const ref = v.ref(input);
++ // A plain snapshot: a page view must never queue behind somebody else's ACL write.
++ return db.tx(async t => {
++ const s = await t.maybe(`SELECT * FROM shares WHERE ${where}`, key(ref));
++ return s ? access(t, ref, s, viewer, edit) : false;
++ });
++ }
++ async function authorize(viewer, ref) { return db.tx(async t => permitted(t, ref, await row(t, ref), viewer, false)); }
++ /** Network projections for these subjects, cached for a minute. Unknown, banned and deleted are null. */
++ async function projections(subjects) {
++ const at = now(), out = new Map(), missing = [];
++ for (const s of subjects) {
++ const k = personKey(s), hit = peopleCache.get(k);
++ if (hit && at - hit.at < PEOPLE_TTL) out.set(k, hit.person);
++ else if (!missing.some(m => personKey(m) === k)) missing.push(s);
++ }
++ for (let i = 0; i < missing.length; i += 500) {
++ const chunk = missing.slice(i, i + 500);
++ const res = await network.json({ service: 'network', audience: 'openvibe.network', path: '/internal/identity/resolve-batch',
++ method: 'POST', json: { subject_ids: chunk.map(s => s.id) } });
++ const results = (res && res.results) || {};
++ for (const s of chunk) { const p = results[s.id] || null; peopleCache.set(personKey(s), { at, person: p }); out.set(personKey(s), p); }
++ }
++ return out;
++ }
++ function personShape(subject, p) {
++ if (!p || p.banned || p.deleted || !p.subject || p.subject.type !== 'user' || p.subject.id !== subject.id || typeof p.username !== 'string') return unknownPerson(subject);
++ return { subject, username: p.username, display_name: p.display_name || p.username, avatar_url: p.avatar_url || null };
++ }
++ async function peopleOf(subjects) {
++ const uniq = [];
++ for (const s of subjects) if (!uniq.some(u => v.equal(u, s))) uniq.push(s);
++ if (!uniq.length) return new Map();
++ const found = await projections(uniq);
++ return new Map(uniq.map(s => [personKey(s), personShape(s, found.get(personKey(s)))]));
++ }
++ /** One name lookup, sharing the per-caller budget with resolve(). null means no active person by that exact name. */
++ async function lookupName(viewer, target) {
++ if (db.inTransaction()) throw new TypeError('Resolve sharing names outside an ambient transaction');
++ const name = v.name(target), caller = v.actor(viewer);
++ // Persistent and atomic across processes; denied and unknown lookups consume the same budget.
++ const start = Math.floor(now() / nameWindowMs) * nameWindowMs;
++ const count = await db.one(`INSERT INTO share_name_limits(caller,window_start,attempts) VALUES ($1,$2,1)
++ ON CONFLICT(caller) DO UPDATE SET window_start=$2,
++ attempts=CASE WHEN share_name_limits.window_start=$2 THEN share_name_limits.attempts+1 ELSE 1 END RETURNING attempts`, [caller, start]);
++ if (count.attempts > nameLimit) v.fail(429, 'share.rate_limited', 'Too many name checks');
++ let found;
++ try { found = await network.json({ service: 'network', audience: 'openvibe.network', path: '/internal/identity/resolve', query: { username: name } }); }
++ catch (err) { if (err.status !== 404) throw err; }
++ if (!found || found.banned || found.deleted || typeof found.username !== 'string' || found.username.toLowerCase() !== name.toLowerCase()) return null;
++ try { const subject = v.subject(found.subject); return subject.type === 'user' ? { ...found, subject } : null; } catch { return null; }
++ }
++ async function resolve(viewer, target) {
++ if (typeof target !== 'string') {
++ const subject = v.subject(target);
++ if (subject.type !== 'user') v.fail(400, 'share.not_person', 'Not an OpenVibe account');
++ const person = (await peopleOf([subject])).get(personKey(subject));
++ if (!person || !person.username) v.fail(400, 'share.name_not_found', 'No OpenVibe account is that subject');
++ return subject;
++ }
++ const found = await lookupName(viewer, target);
++ if (!found) v.fail(400, 'share.name_not_found', `No OpenVibe account is called @${v.name(target)}`);
++ return found.subject;
++ }
++ async function lookup(viewer, name) {
++ const found = await lookupName(viewer, name);
++ if (!found) return { person: null };
++ return { person: { subject: found.subject, username: found.username, display_name: found.display_name || found.username, avatar_url: found.avatar_url || null } };
++ }
++ async function inputs(viewer, list) {
++ if (!Array.isArray(list) || list.length > 100) v.invalid();
++ const parsed = list.map(item => {
++ if (typeof item === 'string') item = { name: item, role: 'viewer' };
++ v.object(item, ['name', 'subject', 'role', 'expires_at']);
++ if (Object.hasOwn(item, 'name') === Object.hasOwn(item, 'subject') || !['viewer', 'editor'].includes(item.role)) v.invalid();
++ return { item, expires_at: v.expiry(item.expires_at, now()) };
++ });
++ // Canonical subjects are people too: verify them in one Network batch, never trust the object alone.
++ const subjects = parsed.filter(x => Object.hasOwn(x.item, 'subject')).map(x => v.subject(x.item.subject));
++ for (const s of subjects) if (s.type !== 'user') v.fail(400, 'share.not_person', 'Not an OpenVibe account');
++ const people = subjects.length ? await peopleOf(subjects) : new Map();
++ const out = new Map();
++ for (const { item, expires_at } of parsed) {
++ let subject;
++ if (Object.hasOwn(item, 'name')) subject = await resolve(viewer, item.name);
++ else {
++ subject = v.subject(item.subject);
++ const person = people.get(personKey(subject));
++ if (!person || !person.username) v.fail(400, 'share.name_not_found', 'No OpenVibe account is that subject');
++ }
++ // Last entry wins after resolution, so the client's add('@name') can update an existing subject.
++ out.set(JSON.stringify(subject), { subject, role: item.role, expires_at });
++ }
++ return [...out.values()];
++ }
++ async function revoke(t, ref, s, actor, m, reason) {
++ await t.exec(`DELETE FROM share_members WHERE ${where} AND subject=$4`, [...key(ref), m.subject]);
++ await log(t, ref, actor, 'member.removed', { subject: m.subject, reason });
++ await event(t, ref, s, actor, 'share.revoked', m.subject, { reason: reason === 'expired' ? 'member_expired' : 'member_removed' });
++ }
++ async function expire(t, ref, s) {
++ const actor = { type: 'service', id: ref.service };
++ const old = await t.many(`SELECT * FROM share_members WHERE ${where} AND expires_at<=$4`, [...key(ref), timestamp()]);
++ for (const m of old) await revoke(t, ref, s, actor, m, 'expired');
++ const tr = await t.maybe(`UPDATE share_transfers SET state='expired' WHERE ${where} AND state='pending' AND expires_at<=$4 RETURNING *`, [...key(ref), timestamp()]);
++ if (tr) {
++ await log(t, ref, actor, 'transfer.expired', { transfer_id: tr.id, from: tr.from_subject, to: tr.to_subject });
++ await event(t, ref, s, actor, 'share.revoked', tr.to_subject, { reason: 'transfer_expired', transfer_id: tr.id });
++ await notice(t, ref, 'share.transfer.answered', tr.from_subject, actor, { transfer_id: tr.id, state: 'expired' });
++ }
++ if (old.length || tr) await bump(t, ref);
++ }
++ async function changeAudience(t, ref, s, actor, audience) {
++ if (s.audience === audience) return;
++ const before = s.audience;
++ await t.exec(`UPDATE shares SET audience=$4 WHERE ${where}`, [...key(ref), audience]); s.audience = audience;
++ if (onAudienceChange) await onAudienceChange(ref, audience, t);
++ await log(t, ref, actor, 'audience.changed', { from: before, to: audience });
++ // The owner-targeted metadata event lets Network invalidate its audience projection.
++ await event(t, ref, s, actor, ['public', 'link', 'people'].includes(audience) ? 'share.granted' : 'share.revoked', s.owner_subject, { reason: 'audience_changed' });
++ }
++ async function grant(t, ref, s, actor, m, notify) {
++ if (v.equal(m.subject, s.owner_subject)) v.invalid();
++ if (m.expires_at && Date.parse(m.expires_at) <= now()) v.invalid();
++ const existing = await t.maybe(`SELECT * FROM share_members WHERE ${where} AND subject=$4`, [...key(ref), m.subject]);
++ if (existing && existing.role === m.role && (existing.expires_at ? iso(existing.expires_at) : null) === m.expires_at) return;
++ await t.exec(`INSERT INTO share_members(service,kind,resource_id,subject,role,expires_at,added_by,added_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
++ ON CONFLICT(service,kind,resource_id,subject) DO UPDATE SET role=$5,expires_at=$6,added_by=$7,added_at=$8`, [...key(ref), m.subject, m.role, m.expires_at, actor, timestamp()]);
++ await t.exec(`UPDATE share_access_requests SET state='granted' WHERE ${where} AND subject=$4 AND state='pending'`, [...key(ref), m.subject]);
++ await log(t, ref, actor, 'member.added', { subject: m.subject, role: m.role });
++ await event(t, ref, s, actor, 'share.granted', m.subject, { reason: 'member_added', role: m.role, ...(m.expires_at ? { expires_at: m.expires_at } : {}) });
++ // Only a person who was not a member before hears about it; a role or expiry change does not.
++ if (!existing && notify) await notice(t, ref, 'share.added', m.subject, actor);
++ }
++ async function mutate(viewer, input, fn) {
++ const ref = v.ref(input);
++ return db.tx(async t => {
++ const s = await row(t, ref), { actor, staff } = await owner(t, ref, s, viewer);
++ await expire(t, ref, s);
++ const result = await fn(t, ref, s, actor, staff);
++ await bump(t, ref);
++ return result;
++ });
++ }
++ async function update(viewer, input, body, { ifMatch } = {}) {
++ const ref = v.ref(input); v.object(body, ['audience', 'members', 'notify']);
++ if (!Object.keys(body).length) v.invalid();
++ if (body.audience !== undefined) v.audience(body.audience);
++ if (body.notify !== undefined && typeof body.notify !== 'boolean') v.invalid();
++ await authorize(viewer, ref);
++ const list = body.members === undefined ? undefined : await inputs(viewer, body.members);
++ const notify = body.notify !== false;
++ return mutate(viewer, ref, async (t, ref, s, actor, staff) => {
++ if (ifMatch !== undefined && ifMatch !== `"${s.revision}"`) v.fail(412, 'share.changed', 'Sharing changed; reload and try again');
++ if (staff) {
++ // Staff narrow access only: private and removals. No widening, additions or role raises.
++ if (body.audience !== undefined && body.audience !== 'private') v.hidden();
++ if (list) {
++ const have = await members(t, ref);
++ for (const m of list) {
++ const cur = have.find(x => v.equal(x.subject, m.subject));
++ if (!cur || cur.role !== m.role || (cur.expires_at ? iso(cur.expires_at) : null) !== m.expires_at) v.hidden();
++ }
++ }
++ }
++ if (body.audience !== undefined) await changeAudience(t, ref, s, actor, body.audience);
++ if (list) {
++ const have = await members(t, ref);
++ for (const m of have) if (!list.some(x => v.equal(x.subject, m.subject))) await revoke(t, ref, s, actor, m, 'removed');
++ for (const m of list) await grant(t, ref, s, actor, m, notify);
++ }
++ return snapshot(t, ref, s);
++ });
++ }
++ async function addMembers(viewer, input, list) {
++ const ref = v.ref(input);
++ // Staff may not add: refuse before touching the name budget or Network.
++ if ((await authorize(viewer, ref)).staff) v.hidden();
++ if (!Array.isArray(list)) v.invalid();
++ const normalized = list.map(item => {
++ if (item && typeof item === 'object' && Object.hasOwn(item, 'type')) return { subject: v.subject(item), role: 'viewer' };
++ return item;
++ });
++ const resolved = await inputs(viewer, normalized);
++ return mutate(viewer, ref, async (t, ref, s, a, staff) => {
++ if (staff) v.hidden();
++ const have = await members(t, ref);
++ if (new Set([...have, ...resolved].map(m => JSON.stringify(m.subject))).size > 100) v.invalid();
++ for (const m of resolved) await grant(t, ref, s, a, m, true);
++ return snapshot(t, ref, s);
++ });
++ }
++ async function removeMember(viewer, ref, input) {
++ const subject = v.subject(input);
++ return mutate(viewer, ref, async (t, ref, s, a) => {
++ const m = await t.maybe(`SELECT * FROM share_members WHERE ${where} AND subject=$4`, [...key(ref), subject]);
++ if (m) await revoke(t, ref, s, a, m, 'removed');
++ return snapshot(t, ref, s);
++ });
++ }
++ async function register(input, { owner_subject, audience = 'private' }) {
++ const ref = v.ref(input), a = v.subject(owner_subject); v.audience(audience);
++ return db.tx(async t => {
++ const made = await t.exec('INSERT INTO shares(service,kind,resource_id,owner_subject,audience) VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING', [...key(ref), a, audience]);
++ const s = await row(t, ref);
++ if (!v.equal(s.owner_subject, a) || s.audience !== audience) v.fail(409, 'share.exists', 'Sharing already registered');
++ if (made) await log(t, ref, a, 'registered', { audience });
++ return snapshot(t, ref, s);
++ });
++ }
++ /** The GET body: people, not bare subjects, plus the caller's own role and pending access requests. */
++ async function present(viewer, ref, share) {
++ const requests = await db.many(`SELECT * FROM share_access_requests WHERE ${where} AND state='pending' ORDER BY requested_at,id`, key(ref));
++ const subjects = [share.owner_subject, ...(share.members || []).map(m => m.subject), ...(share.transfer ? [share.transfer.to] : []), ...requests.map(r => r.subject)];
++ let people;
++ try { people = await peopleOf(subjects); }
++ catch { people = new Map(subjects.map(s => [personKey(s), unknownPerson(s)])); }
++ const person = subject => people.get(personKey(subject)) || unknownPerson(subject);
++ return { ...share,
++ owner: person(share.owner_subject),
++ members: (share.members || []).map(m => ({ ...m, ...person(m.subject) })),
++ ...(share.transfer ? { transfer: { ...share.transfer, to: person(share.transfer.to) } } : {}),
++ you: { subject: v.actor(viewer), role: v.equal(viewer.subject, share.owner_subject) ? 'owner' : 'staff' },
++ requests: requests.map(r => ({ id: r.id, ...person(r.subject), requested_at: iso(r.requested_at) })) };
++ }
++ async function getWithRevision(viewer, input) {
++ const ref = v.ref(input);
++ const { value, etag } = await db.tx(async t => { const s = await row(t, ref); await owner(t, ref, s, viewer); return { value: await snapshot(t, ref, s), etag: `"${s.revision}"` }; });
++ return { value: await present(viewer, ref, value), etag };
++ }
++ async function requestAccess(viewer, input) {
++ const ref = v.ref(input), a = v.actor(viewer);
++ return db.tx(async t => {
++ const s = await row(t, ref);
++ if (s.audience !== 'people') v.hidden();
++ if (await access(t, ref, s, viewer, false)) v.fail(409, 'share.already_allowed', 'Access already allowed');
++ const existing = await t.maybe(`SELECT * FROM share_access_requests WHERE ${where} AND subject=$4 AND state='pending'`, [...key(ref), a]);
++ const request = existing || await t.one(`INSERT INTO share_access_requests VALUES ($1,$2,$3,$4,$5,$6,'pending') RETURNING *`, [randomUUID(), ...key(ref), a, timestamp()]);
++ if (!existing) {
++ await log(t, ref, a, 'access.requested');
++ await notice(t, ref, 'share.access_requested', s.owner_subject, a, { request_id: request.id });
++ }
++ return { id: request.id, subject: request.subject, requested_at: iso(request.requested_at), state: request.state };
++ });
++ }
++ async function dismiss(viewer, input, requestId) {
++ const ref = v.ref(input); v.uuid(requestId);
++ return mutate(viewer, ref, async (t, ref, s, actor) => {
++ // Dismissing a request is the owner's call, not a staff moderation action.
++ if (!v.equal(actor, s.owner_subject)) v.hidden();
++ const request = await t.maybe(`SELECT * FROM share_access_requests WHERE ${where} AND id=$4`, [...key(ref), requestId]);
++ if (!request || request.state !== 'pending') v.hidden();
++ await t.exec(`UPDATE share_access_requests SET state='dismissed' WHERE id=$1`, [request.id]);
++ await log(t, ref, actor, 'access.dismissed', { request_id: request.id, subject: request.subject });
++ return snapshot(t, ref, s);
++ });
++ }
++ async function transfer(viewer, input, { to, keep_access = false } = {}) {
++ const ref = v.ref(input); if (typeof keep_access !== 'boolean') v.invalid();
++ await authorize(viewer, ref);
++ let target = to;
++ if (to && typeof to === 'object' && !Object.hasOwn(to, 'type')) {
++ if (Object.hasOwn(to, 'subject') === Object.hasOwn(to, 'name')) v.invalid();
++ target = Object.hasOwn(to, 'subject') ? to.subject : to.name;
++ }
++ const recipient = await resolve(viewer, target);
++ return mutate(viewer, ref, async (t, ref, s, a) => {
++ // Staff may moderate, but cannot give somebody else's ownership away.
++ if (!v.equal(a, s.owner_subject)) v.hidden();
++ if (v.equal(recipient, s.owner_subject)) v.invalid();
++ if (await pending(t, ref)) v.fail(409, 'share.transfer_pending', 'A transfer is already pending');
++ const tr = await t.one(`INSERT INTO share_transfers VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending') RETURNING *`,
++ [randomUUID(), ...key(ref), s.owner_subject, recipient, keep_access, timestamp(), iso(now() + WEEK)]);
++ const result = transferShape(tr);
++ await log(t, ref, a, 'transfer.requested', { transfer_id: tr.id, from: tr.from_subject, to: recipient, keep_access });
++ await event(t, ref, s, a, 'share.transfer.requested', recipient, { transfer: result });
++ await notice(t, ref, 'share.transfer.requested', recipient, a, { transfer_id: tr.id });
++ return result;
++ });
++ }
++ async function answer(viewer, input, transferId, action) {
++ const ref = v.ref(input), a = v.actor(viewer); v.uuid(transferId);
++ return db.tx(async t => {
++ const s = await row(t, ref);
++ const tr = await t.maybe(`SELECT * FROM share_transfers WHERE ${where} AND id=$4`, [...key(ref), transferId]);
++ if (!tr || (action === 'cancelled' ? !v.equal(a, s.owner_subject) : !v.equal(a, tr.to_subject))) v.hidden();
++ if (tr.state !== 'pending' || Date.parse(tr.expires_at) <= now() || !v.equal(s.owner_subject, tr.from_subject)) {
++ v.fail(409, 'share.transfer_inactive', 'Transfer is no longer pending');
++ }
++ await expire(t, ref, s);
++ await t.exec('UPDATE share_transfers SET state=$2 WHERE id=$1', [tr.id, action]); tr.state = action;
++ if (action === 'accepted') {
++ if (tr.keep_access) {
++ const remaining = (await members(t, ref)).filter(m => !v.equal(m.subject, a) && !v.equal(m.subject, tr.from_subject));
++ if (remaining.length >= 100) v.fail(409, 'share.member_limit', 'Remove a member before retaining the former owner');
++ }
++ const from = s.owner_subject;
++ await t.exec(`DELETE FROM share_members WHERE ${where} AND subject IN ($4,$5)`, [...key(ref), from, a]);
++ await t.exec(`UPDATE shares SET owner_subject=$4 WHERE ${where}`, [...key(ref), a]); s.owner_subject = a;
++ if (onOwnerChange) await onOwnerChange(ref, from, a, t);
++ await log(t, ref, a, 'ownership.outgoing', { transfer_id: tr.id, from, to: a });
++ await log(t, ref, a, 'ownership.incoming', { transfer_id: tr.id, from, to: a });
++ if (tr.keep_access) {
++ if (s.audience === 'private') await changeAudience(t, ref, s, a, 'people');
++ await grant(t, ref, s, a, { subject: from, role: 'viewer', expires_at: null }, true);
++ } else await event(t, ref, s, a, 'share.revoked', from, { reason: 'ownership_changed', transfer_id: tr.id });
++ }
++ await log(t, ref, a, `transfer.${action}`, { transfer_id: tr.id, from: tr.from_subject, to: tr.to_subject });
++ const result = transferShape(tr);
++ if (action === 'cancelled') await event(t, ref, s, a, 'share.revoked', tr.to_subject, { reason: 'transfer_cancelled', transfer_id: tr.id });
++ else await event(t, ref, s, a, `share.transfer.${action}`, tr.to_subject, { transfer: result });
++ // The owner hears an answer (including expiry); the recipient hears a cancellation. Never both.
++ await notice(t, ref, 'share.transfer.answered', action === 'cancelled' ? tr.to_subject : tr.from_subject, a, { transfer_id: tr.id, state: action });
++ await bump(t, ref);
++ return result;
++ });
++ }
++ async function expireMembers({ limit = 100 } = {}) {
++ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) v.invalid();
++ const refs = await db.many(`SELECT service,kind,resource_id AS id FROM share_members WHERE expires_at<=$1
++ UNION SELECT service,kind,resource_id AS id FROM share_transfers WHERE state='pending' AND expires_at<=$1 LIMIT $2`, [timestamp(), limit]);
++ for (const ref of refs) await db.tx(async t => { const s = await row(t, ref); await expire(t, ref, s); });
++ return refs.length;
++ }
++ async function sharedWithMe(viewer, { service, limit = 50, cursor } = {}) {
++ const a = v.actor(viewer);
++ if (!Number.isInteger(limit) || limit < 1 || limit > 100) v.invalid();
++ let after = ['', '', ''];
++ if (cursor) {
++ try { after = JSON.parse(Buffer.from(cursor, 'base64url').toString()); } catch { v.invalid(); }
++ if (!Array.isArray(after) || after.length !== 3 || after.some(x => typeof x !== 'string' || x.length > 200)) v.invalid();
++ }
++ // One SQL snapshot: no membership checked in one state and returned in another.
++ const rows = await db.many(`SELECT s.*,m.role,m.added_at,tr.id AS transfer_id,tr.from_subject,tr.to_subject,tr.keep_access,tr.requested_at,tr.expires_at,tr.state
++ FROM shares s LEFT JOIN share_members m ON m.service=s.service AND m.kind=s.kind AND m.resource_id=s.resource_id
++ AND m.subject=$1 AND (m.expires_at IS NULL OR m.expires_at>$2)
++ LEFT JOIN share_transfers tr ON tr.service=s.service AND tr.kind=s.kind AND tr.resource_id=s.resource_id
++ AND tr.to_subject=$1 AND tr.state='pending' AND tr.expires_at>$2
++ WHERE s.owner_subject<>$1 AND ($3::text IS NULL OR s.service=$3)
++ AND ((s.audience='people' AND m.subject IS NOT NULL) OR tr.id IS NOT NULL)
++ AND (s.service,s.kind,s.resource_id)>($4,$5,$6)
++ ORDER BY s.service,s.kind,s.resource_id LIMIT $7`, [a, timestamp(), service || null, ...after, limit + 1]);
++ const page = rows.slice(0, limit);
++ return { items: page.map(s => ({ ref: { service: s.service, kind: s.kind, id: s.resource_id }, owner_subject: s.owner_subject,
++ audience: s.audience, role: s.audience === 'people' && s.role ? s.role : 'viewer', shared_at: iso(s.added_at || s.requested_at),
++ ...(s.transfer_id ? { transfer: transferShape({ ...s, id: s.transfer_id }) } : {}) })),
++ next_cursor: rows.length > limit ? Buffer.from(JSON.stringify([page.at(-1).service, page.at(-1).kind, page.at(-1).resource_id])).toString('base64url') : null };
++ }
++ async function flush({ limit = 100 } = {}) {
++ if (db.inTransaction()) throw new TypeError('Flush sharing deliveries outside an ambient transaction');
++ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) v.invalid();
++ let sent = 0;
++ for (; sent < limit; sent++) {
++ const delivered = await db.tx(async t => {
++ const d = await t.maybe('SELECT * FROM share_deliveries WHERE delivered_at IS NULL ORDER BY sequence LIMIT 1 FOR UPDATE');
++ if (!d) return false;
++ const { delivery_id, ...body } = d.body;
++ if (d.channel === 'event') await events.publish(body);
++ else await notify({ ...body, delivery_id });
++ await t.exec('UPDATE share_deliveries SET delivered_at=$2 WHERE id=$1', [d.id, timestamp()]);
++ return true;
++ }, { retries: 0 });
++ if (!delivered) break;
++ }
++ return sent;
++ }
++ const api = { register, migrate: () => migrateShares(db), canView: (viewer, ref) => check(viewer, ref, false), canEdit: (viewer, ref) => check(viewer, ref, true),
++ get: async (viewer, ref) => (await getWithRevision(viewer, ref)).value, getWithRevision, present, lookup, update,
++ setAudience: (viewer, ref, audience) => update(viewer, ref, { audience }), addMembers, removeMember, requestAccess, dismiss, transfer,
++ accept: (viewer, ref, id) => answer(viewer, ref, id, 'accepted'), decline: (viewer, ref, id) => answer(viewer, ref, id, 'declined'),
++ cancel: (viewer, ref, id) => answer(viewer, ref, id, 'cancelled'), expireMembers, sharedWithMe, flush };
++ api.router = options => require('./router').router(api, options);
++ return api;
++}
++module.exports = { createShares };
ddiff --git a/src/share/validation.js b/src/share/validation.js
nnew file mode 100644
iindex 0000000..95af56c
---- /dev/null
++++ b/src/share/validation.js
@@@ -0,0 +1,42 @@
++'use strict';
++class ShareError extends Error {
++ constructor(status, code, message) { super(message); this.name = 'ShareError'; this.status = status; this.code = code; }
++}
++const fail = (status, code, message) => { throw new ShareError(status, code, message); };
++const invalid = () => fail(400, 'share.invalid', 'Invalid sharing request');
++const hidden = () => fail(404, 'share.not_found', 'Not found');
++function object(v, keys) {
++ if (!v || typeof v !== 'object' || Array.isArray(v) || Object.keys(v).some(k => !keys.includes(k))) invalid();
++}
++function subject(v) {
++ object(v, ['type', 'id']);
++ const prefixes = { user: 'usr', guest: 'gst', app: 'app', mod: 'mod', agent: 'agt' };
++ const pattern = prefixes[v.type] ? new RegExp(`^${prefixes[v.type]}_[0-9A-HJKMNP-TV-Z]{26}$`)
++ : ['service', 'system'].includes(v.type) ? /^[a-z][a-z0-9-]{1,39}$/ : null;
++ if (!pattern || typeof v.id !== 'string' || !pattern.test(v.id)) invalid();
++ return { type: v.type, id: v.id };
++}
++function ref(v) {
++ object(v, ['service', 'kind', 'id']);
++ if (typeof v.service !== 'string' || !/^[a-z][a-z0-9-]{1,39}$/.test(v.service)
++ || typeof v.kind !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/.test(v.kind)
++ || typeof v.id !== 'string' || !v.id.length || v.id.length > 200) invalid();
++ return { service: v.service, kind: v.kind, id: v.id };
++}
++function audience(v) { if (!['public', 'link', 'people', 'private'].includes(v)) invalid(); return v; }
++function actor(viewer) { if (!viewer || !viewer.subject) hidden(); return subject(viewer.subject); }
++function equal(a, b) { return !!a && !!b && a.type === b.type && a.id === b.id; }
++function name(v) { if (typeof v !== 'string' || !/^@?[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/.test(v)) invalid(); return v.replace(/^@/, ''); }
++function uuid(v) { if (typeof v !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)) invalid(); return v; }
++function expiry(v, now) {
++ if (v === undefined) return null;
++ if (typeof v !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(v)
++ || !Number.isFinite(Date.parse(v)) || Date.parse(v) <= now) invalid();
++ const parts = v.match(/^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)/).slice(1).map(Number);
++ const [year, month, day, hour, minute, second] = parts;
++ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
++ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
++ if (month < 1 || month > 12 || day < 1 || day > days[month - 1] || hour > 23 || minute > 59 || second > 59) invalid();
++ return new Date(v).toISOString();
++}
++module.exports = { ShareError, fail, invalid, hidden, object, subject, ref, audience, actor, equal, name, uuid, expiry };
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 54b3df5..9de86d1 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -76,12 +76,12 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
assert.deepEqual(problems, []);
-- assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './space', './sso', './storage', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
++ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windows
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
}
-- for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/sso/index.js', 'src/app-signin/index.js', 'src/service/index.js', 'src/service-outbox.js', 'src/testing/index.js']) {
++ for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/sso/index.js', 'src/app-signin/index.js', 'src/service/index.js', 'src/service-outbox.js', 'src/share.js', 'src/share/store.js', 'src/testing/index.js']) {
assert.ok(!rel.includes(never), `${never} is not reachable from a browser entry`);
}
console.log(` scanned ${scanned.size} files`);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 0d613b6..3a49652 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -24,7 +24,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8', shell });
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
-- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/vibes.js', 'esm/vibes.mjs', 'types/vibes.d.ts', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'browser/openvibe-sdk.mjs']) {
++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/vibes.js', 'esm/vibes.mjs', 'types/vibes.d.ts', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'src/share.js', 'src/share/store.js', 'esm/share.mjs', 'types/share.d.ts', 'docs/share.md', 'browser/openvibe-sdk.mjs']) {
assert.ok(files.includes(must), `tarball contains ${must}`);
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
@@@ -68,7 +68,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 77); // 0.81.0 adds openvibe-sdk/app-signin and openvibe-sdk/meme; 0.84.0 adds openvibe-sdk/vibes
++ assert.equal(subpaths.length, 78); // 0.85.0 adds the server-only openvibe-sdk/share entry
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/share.test.js b/test/share.test.js
nnew file mode 100644
iindex 0000000..095c45a
---- /dev/null
++++ b/test/share.test.js
@@@ -0,0 +1,459 @@
++'use strict';
++const assert = require('node:assert/strict');
++const http = require('node:http');
++const { createDb } = require('../src/db');
++const { createShares, share, shareOpenApi } = require('../src/share');
++const contracts = require('openvibe-contracts');
++const U = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' };
++const V = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR' };
++const X = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS' };
++const owner = { subject: U }, recipient = { subject: V }, other = { subject: X };
++const PEOPLE = {
++ [U.id]: { subject: U, username: 'ursula', display_name: 'Ursula', avatar_url: null, banned: false },
++ [V.id]: { subject: V, username: 'maticus', display_name: 'Maticus', avatar_url: 'https://cdn.example/avatars/maticus.png', banned: false },
++ [X.id]: { subject: X, username: 'xenia', display_name: 'Xenia', avatar_url: null, banned: false },
++};
++const db = createDb({ pglite: true });
++let time = Date.parse('2026-11-06T00:00:00Z'), seq = 0, failHook = false, failNotify = false;
++const published = [], notified = [], lookups = [], hooks = [];
++const options = { db, now: () => time, nameLimit: 3,
++ network: { json: async req => {
++ lookups.push(req);
++ if (req.path === '/internal/identity/resolve-batch') {
++ const results = {};
++ for (const id of req.json.subject_ids) results[id] = PEOPLE[id] || null;
++ return { results };
++ }
++ return req.query.username.toLowerCase() === 'maticus' ? { ...PEOPLE[V.id] } : null;
++ } },
++ events: { publish: async e => {
++ assert.equal(contracts.validate(e.event_type, e.payload).valid, true, JSON.stringify(contracts.validate(e.event_type, e.payload).errors));
++ assert.equal(contracts.validate('events.event-envelope', e).valid, true, JSON.stringify(contracts.validate('events.event-envelope', e).errors));
++ published.push(e);
++ } },
++ notify: async n => { if (failNotify) throw Error('notification down'); notified.push(n); },
++ onOwnerChange: async (ref, from, to, t) => { assert.ok(t.exec); hooks.push(['owner', ref, from, to]); if (failHook) throw Error('hook failed'); },
++ onAudienceChange: async (ref, audience, t) => { assert.ok(t.exec); hooks.push(['audience', ref, audience]); if (failHook) throw Error('hook failed'); },
++};
++const shares = createShares(options);
++const fresh = async (audience = 'people') => { const ref = { service: 'community', kind: 'paste', id: `p${++seq}` }; await shares.register(ref, { owner_subject: U, audience }); return ref; };
++const denied = (fn, status = 404) => assert.rejects(fn, e => e.status === status);
++const tests = [];
++const test = (name, fn) => tests.push([name, fn]);
++
++test('migration is repeatable and append-only log rejects all mutation', async () => {
++ await shares.migrate(); await shares.migrate(); const ref = await fresh();
++ await assert.rejects(db.exec('DELETE FROM share_log'), /append-only/);
++ await assert.rejects(db.exec("UPDATE share_log SET action='changed'"), /append-only/);
++ await assert.rejects(db.exec('TRUNCATE share_log'), /append-only/);
++ assert.equal((await shares.get(owner, ref)).owner_subject.id, U.id);
++});
++test('every audience and role, anonymous, owner, stranger, staff and missing resource', async () => {
++ for (const audience of ['public', 'link', 'people', 'private']) for (const role of ['viewer', 'editor']) {
++ const ref = await fresh(audience); await shares.addMembers(owner, ref, [{ subject: V, role }]);
++ assert.equal(await shares.canView(null, ref), ['public', 'link'].includes(audience));
++ assert.equal(await shares.canEdit(null, ref), false);
++ assert.equal(await shares.canView(other, ref), ['public', 'link'].includes(audience));
++ assert.equal(await shares.canEdit(other, ref), false);
++ assert.equal(await shares.canView(recipient, ref), audience !== 'private');
++ assert.equal(await shares.canEdit(recipient, ref), audience === 'people' && role === 'editor');
++ assert.equal(await shares.canView(owner, ref), true); assert.equal(await shares.canEdit(owner, ref), true);
++ assert.equal(await shares.canView({ ...other, staff: true }, ref), true);
++ assert.equal(await shares.canEdit({ ...other, staff: true }, ref), true);
++ await denied(() => shares.get(other, ref));
++ }
++ assert.equal(await shares.canView(owner, { service: 'community', kind: 'paste', id: 'missing' }), false);
++ assert.equal(await db.value("SELECT count(*) FROM share_log WHERE action='staff.view'"), 8);
++ assert.equal(await db.value("SELECT count(*) FROM share_log WHERE action='staff.edit'"), 8);
++});
++test('addMembers accepts canonical subject refs as viewers', async () => {
++ const ref = await fresh(); await shares.addMembers(owner, ref, [V]);
++ assert.equal(await shares.canView(recipient, ref), true); assert.equal(await shares.canEdit(recipient, ref), false);
++});
++test('nonowner cannot manage ACL or resolve accounts; editor cannot escalate', async () => {
++ const ref = await fresh(); await shares.addMembers(owner, ref, [{ subject: V, role: 'editor' }]); const before = lookups.length;
++ for (const viewer of [recipient, other]) {
++ await denied(() => shares.addMembers(viewer, ref, ['@maticus']));
++ await denied(() => shares.setAudience(viewer, ref, 'public'));
++ await denied(() => shares.removeMember(viewer, ref, V));
++ await denied(() => shares.transfer(viewer, ref, { to: '@maticus' }));
++ }
++ assert.equal(lookups.length, before);
++});
++test('member expiry fails closed exactly at expiry and sweep logs and emits once', async () => {
++ const ref = await fresh(); await shares.addMembers(owner, ref, [{ subject: V, role: 'editor', expires_at: new Date(time + 1000).toISOString() }]);
++ assert.equal(await shares.canEdit(recipient, ref), true); time += 1000;
++ assert.equal(await shares.canView(recipient, ref), false); assert.equal(await shares.canEdit(recipient, ref), false);
++ assert.equal((await shares.get(owner, ref)).members.length, 0);
++ await shares.expireMembers(); const count = await db.value("SELECT count(*) FROM share_log WHERE action='member.removed'");
++ await shares.expireMembers(); assert.equal(await db.value("SELECT count(*) FROM share_log WHERE action='member.removed'"), count);
++});
++test('exact names use Network subject object; unknown, wildcard, malformed and banned fail closed', async () => {
++ time += 60000; const ref = await fresh(); await shares.addMembers(owner, ref, ['@maticus']);
++ assert.equal(await shares.canView(recipient, ref), true);
++ assert.deepEqual(lookups.at(-1).query, { username: 'maticus' }); assert.equal(lookups.at(-1).path, '/internal/identity/resolve');
++ await denied(() => shares.addMembers(owner, ref, ['@nobody']), 400);
++ const before = lookups.length; await denied(() => shares.addMembers(owner, ref, ['@mat*']), 400); assert.equal(lookups.length, before);
++ const broken = createShares({ ...options, network: { json: async () => ({ subject: V.id, username: 'maticus' }) } });
++ await denied(() => broken.addMembers(owner, ref, ['@maticus']), 400);
++ time += 60000;
++ const banned = createShares({ ...options, network: { json: async () => ({ subject: V, username: 'maticus', banned: true }) } });
++ await denied(() => banned.addMembers(owner, ref, ['@maticus']), 400);
++ const fuzzy = createShares({ ...options, network: { json: async () => ({ subject: V, username: 'maticus-other' }) } });
++ await denied(() => fuzzy.addMembers(owner, ref, ['@maticus']), 400);
++});
++test('per-caller name rate limit is shared across instances and failures; resets by window', async () => {
++ time += 60000; const ref = await fresh();
++ for (let i = 0; i < 3; i++) await denied(() => shares.addMembers(owner, ref, ['@absent']), 400);
++ const before = lookups.length;
++ await denied(() => createShares(options).addMembers(owner, ref, ['@maticus']), 429); assert.equal(lookups.length, before);
++ await shares.register({ ...ref, id: 'owned-by-v' }, { owner_subject: V, audience: 'people' });
++ await denied(() => shares.addMembers(recipient, { ...ref, id: 'owned-by-v' }, ['@absent']), 400);
++ time += 60000; await shares.addMembers(owner, ref, ['@maticus']);
++});
++test('requestAccess notifies owner once, denies anonymous/private/public and resolves on add', async () => {
++ const ref = await fresh(); const a = await shares.requestAccess(recipient, ref); const b = await shares.requestAccess(recipient, ref);
++ assert.equal(a.id, b.id); await shares.flush({ limit: 1000 });
++ const notices = notified.filter(n => n.kind === 'share.access_requested' && n.ref.id === ref.id);
++ assert.equal(notices.length, 1); assert.deepEqual(notices[0].to, U); assert.deepEqual(notices[0].actor, V);
++ await denied(() => shares.requestAccess(null, ref));
++ for (const audience of ['public', 'private', 'link']) { const hiddenRef = await fresh(audience); await denied(() => shares.requestAccess(recipient, hiddenRef)); }
++ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer' }]);
++ assert.equal(await db.value('SELECT state FROM share_access_requests WHERE id=$1', [a.id]), 'granted');
++});
++test('transfer preview only for bound recipient, default removes old access; history logs both directions', async () => {
++ const ref = await fresh('private'); const tr = await shares.transfer(owner, ref, { to: V });
++ assert.equal(tr.keep_access, false); assert.equal(Date.parse(tr.expires_at) - Date.parse(tr.requested_at), 7 * 86400000);
++ assert.equal(await shares.canView(recipient, ref), true); assert.equal(await shares.canEdit(recipient, ref), false);
++ assert.equal(await shares.canView(other, ref), false);
++ await denied(() => shares.accept(other, ref, tr.id)); await denied(() => shares.accept({ ...other, staff: true }, ref, tr.id));
++ await denied(() => shares.decline(other, ref, tr.id)); await denied(() => shares.cancel(recipient, ref, tr.id));
++ const accepted = await shares.accept(recipient, ref, tr.id); assert.equal(accepted.state, 'accepted');
++ assert.equal(await shares.canView(owner, ref), false); assert.equal(await shares.canEdit(recipient, ref), true);
++ assert.deepEqual((await shares.get(recipient, ref)).owner_subject, V);
++ assert.equal(await db.value("SELECT count(*) FROM share_log WHERE resource_id=$1 AND action IN ('ownership.outgoing','ownership.incoming')", [ref.id]), 2);
++ await denied(() => shares.accept(recipient, ref, tr.id), 409);
++});
++test('keep_access false removes preexisting membership and true retains only viewer including private', async () => {
++ for (const keep_access of [false, true]) for (const audience of ['people', 'private', 'public', 'link']) {
++ const ref = await fresh(audience); await shares.addMembers(owner, ref, [{ subject: V, role: 'editor' }]);
++ const tr = await shares.transfer(owner, ref, { to: V, keep_access }); await shares.accept(recipient, ref, tr.id);
++ assert.equal(await shares.canView(owner, ref), keep_access || ['public', 'link'].includes(audience)); assert.equal(await shares.canEdit(owner, ref), false);
++ const state = await shares.get(recipient, ref); assert.equal(state.members.length, keep_access ? 1 : 0);
++ if (keep_access) { assert.deepEqual(state.members[0].subject, U); assert.equal(state.members[0].role, 'viewer'); assert.equal(state.audience, audience === 'private' ? 'people' : audience); }
++ }
++});
++test('decline, cancel, expiry keep owner; stale actions cannot consume a newer transfer', async () => {
++ for (const action of ['decline', 'cancel', 'expire']) {
++ const ref = await fresh('private'); const tr = await shares.transfer(owner, ref, { to: V });
++ if (action === 'expire') { time += 7 * 86400000; assert.equal(await shares.canView(recipient, ref), false); await denied(() => shares.accept(recipient, ref, tr.id), 409); await shares.expireMembers(); }
++ else await shares[action](action === 'cancel' ? owner : recipient, ref, tr.id);
++ assert.equal(await shares.canView(recipient, ref), false); assert.equal(await shares.canEdit(owner, ref), true);
++ assert.equal(await db.value('SELECT state FROM share_transfers WHERE id=$1', [tr.id]), { decline: 'declined', cancel: 'cancelled', expire: 'expired' }[action]);
++ const next = await shares.transfer(owner, ref, { to: V }); await denied(() => shares.accept(recipient, ref, tr.id), 409);
++ await shares.accept(recipient, ref, next.id);
++ }
++});
++test('competing transfer actions serialize and only one ownership change wins', async () => {
++ const ref = await fresh(); const tr = await shares.transfer(owner, ref, { to: V });
++ await denied(() => shares.transfer(owner, ref, { to: X }), 409);
++ const results = await Promise.allSettled([shares.accept(recipient, ref, tr.id), shares.accept(recipient, ref, tr.id)]);
++ assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
++});
++test('staff may narrow only: private and removals, never widen, add or raise a role', async () => {
++ const staff = { ...other, staff: true }, ref = await fresh('public');
++ await shares.setAudience(staff, ref, 'private');
++ assert.equal((await shares.get(owner, ref)).audience, 'private');
++ await denied(() => shares.setAudience(staff, ref, 'people'));
++ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer' }]);
++ await shares.removeMember(staff, ref, V);
++ assert.equal(await shares.canView(recipient, ref), false);
++ await denied(() => shares.addMembers(staff, ref, [{ subject: X, role: 'viewer' }]));
++ await shares.addMembers(owner, ref, [{ subject: V, role: 'editor' }]);
++ await denied(() => shares.update(staff, ref, { members: [{ subject: V, role: 'viewer' }] }));
++ await denied(() => shares.update(staff, ref, { audience: 'public', members: [] }));
++ assert.ok(await db.value("SELECT count(*) FROM share_log WHERE resource_id=$1 AND action='staff.manage'", [ref.id]));
++ await denied(() => shares.transfer(staff, ref, { to: V }));
++});
++test('hooks run inside transaction; failure rolls back ACL, ownership, log and deliveries', async () => {
++ const ref = await fresh('private'); const tr = await shares.transfer(owner, ref, { to: V });
++ const before = await db.value('SELECT count(*) FROM share_log'); const deliveries = await db.value('SELECT count(*) FROM share_deliveries');
++ failHook = true;
++ await assert.rejects(shares.accept(recipient, ref, tr.id), /hook failed/);
++ await assert.rejects(shares.setAudience(owner, ref, 'public'), /hook failed/);
++ failHook = false;
++ assert.equal((await shares.get(owner, ref)).audience, 'private'); assert.equal(await shares.canEdit(owner, ref), true);
++ assert.equal(await db.value('SELECT count(*) FROM share_log'), before); assert.equal(await db.value('SELECT count(*) FROM share_deliveries'), deliveries);
++ await shares.accept(recipient, ref, tr.id);
++});
++test('atomic PUT rejects invalid batch, replaces membership and enforces ETag', async () => {
++ const ref = await fresh(); const old = await shares.getWithRevision(owner, ref);
++ await denied(() => shares.update(owner, ref, { audience: 'public', members: [{ subject: V, role: 'owner' }] }), 400);
++ assert.equal((await shares.get(owner, ref)).audience, 'people');
++ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer' }]);
++ await denied(() => shares.update(owner, ref, { members: [] }, { ifMatch: old.etag }), 412);
++ await shares.update(owner, ref, { members: [{ subject: X, role: 'editor' }] });
++ assert.equal(await shares.canView(recipient, ref), false); assert.equal(await shares.canEdit(other, ref), true);
++ await shares.removeMember(owner, ref, X); assert.equal(await shares.canView(other, ref), false);
++});
++test('shared-with-me is isolated, paginated, excludes hidden/expired memberships, includes pending preview', async () => {
++ const ref = await fresh('private'); await shares.addMembers(owner, ref, [{ subject: X, role: 'viewer' }]);
++ assert.ok(!(await shares.sharedWithMe(other)).items.some(i => i.ref.id === ref.id));
++ const tr = await shares.transfer(owner, ref, { to: X });
++ assert.ok((await shares.sharedWithMe(other)).items.some(i => i.ref.id === ref.id && i.transfer.id === tr.id));
++ const first = await shares.sharedWithMe(recipient, { limit: 1 }); assert.equal(first.items.length, 1); assert.ok(first.next_cursor);
++ const second = await shares.sharedWithMe(recipient, { limit: 1, cursor: first.next_cursor }); assert.notDeepEqual(first.items, second.items);
++ assert.equal(contracts.validate('share.list-result', first).valid, true);
++ assert.deepEqual((await shares.sharedWithMe(recipient, { service: 'wiki' })).items, []);
++ await denied(() => shares.sharedWithMe(null)); await denied(() => shares.sharedWithMe(owner, { cursor: 'junk' }), 400);
++});
++test('events validate, leak no title/content; failed notification stays durable and retries with same id', async () => {
++ const ref = await fresh('people'); await shares.addMembers(owner, ref, [{ subject: X, role: 'viewer' }]);
++ failNotify = true; await assert.rejects(shares.flush({ limit: 1000 }), /notification down/); failNotify = false;
++ const queued = await db.many("SELECT id FROM share_deliveries WHERE channel='notify' AND delivered_at IS NULL");
++ assert.ok(queued.length); await shares.flush({ limit: 1000 });
++ assert.ok(notified.some(n => n.delivery_id === queued[0].id));
++ for (const event of published) assert.ok(!/"(?:title|content)"/.test(JSON.stringify(event)));
++ assert.ok(published.some(e => e.payload.audience === 'private')); assert.ok(published.some(e => e.payload.audience === 'people'));
++ assert.equal(await db.value('SELECT count(*) FROM share_deliveries WHERE delivered_at IS NULL'), 0);
++});
++test('saving unchanged members preserves history and does not notify them again', async () => {
++ const ref = await fresh(); await shares.addMembers(owner, ref, [V]);
++ const before = (await shares.get(owner, ref)).members[0].added_at;
++ const delivered = await db.value('SELECT count(*) FROM share_deliveries');
++ time += 1; await shares.update(owner, ref, { members: [{ subject: V, role: 'viewer' }] });
++ assert.equal((await shares.get(owner, ref)).members[0].added_at, before);
++ assert.equal(await db.value('SELECT count(*) FROM share_deliveries'), delivered);
++});
++test('grant revocation reasons preserve independent preview/member state and delivery order', async () => {
++ const ref = await fresh('people'); await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer' }]);
++ const tr = await shares.transfer(owner, ref, { to: V }); await shares.cancel(owner, ref, tr.id);
++ assert.equal(await shares.canView(recipient, ref), true);
++ await shares.removeMember(owner, ref, V); assert.equal(await shares.canView(recipient, ref), false);
++ await shares.flush({ limit: 1000 });
++ const events = published.filter(e => e.payload.ref.id === ref.id);
++ assert.deepEqual(events.map(e => e.event_type), ['share.granted', 'share.transfer.requested', 'share.revoked', 'share.revoked']);
++ assert.equal(events[2].payload.reason, 'transfer_cancelled'); assert.equal(events[2].payload.transfer_id, tr.id);
++ assert.equal(events[3].payload.reason, 'member_removed');
++});
++test('name resolution cannot authorize a former owner after concurrent transfer', async () => {
++ time += 60000; const ref = await fresh(); const tr = await shares.transfer(owner, ref, { to: V });
++ let release, entered;
++ const started = new Promise(resolve => { entered = resolve; });
++ const waiting = createShares({ ...options, network: { json: async () => { entered(); return new Promise(resolve => { release = resolve; }); } } });
++ const adding = waiting.addMembers(owner, ref, ['@maticus']);
++ await started; await shares.accept(recipient, ref, tr.id);
++ release({ subject: V, username: 'maticus' }); await denied(() => adding);
++ assert.equal((await shares.get(recipient, ref)).members.length, 0);
++});
++test('invalid calendar expiry and unknown audience/role never change ACL', async () => {
++ const ref = await fresh();
++ await denied(() => shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', expires_at: '2030-02-31T00:00:00Z' }]), 400);
++ await denied(() => shares.setAudience(owner, ref, 'unlisted'), 400);
++ await denied(() => shares.addMembers(owner, ref, [{ subject: V, role: 'admin' }]), 400);
++ assert.equal((await shares.get(owner, ref)).members.length, 0);
++});
++test('ambient transactions cannot refund name limits or publish uncommitted deliveries', async () => {
++ const ref = await fresh(); const before = lookups.length;
++ await assert.rejects(db.tx(() => shares.addMembers(owner, ref, ['@absent'])), /outside an ambient transaction/);
++ assert.equal(lookups.length, before);
++ await assert.rejects(db.tx(() => shares.flush()), /outside an ambient transaction/);
++});
++test('external event failure retries the same stable event id', async () => {
++ const ref = await fresh(); await shares.addMembers(owner, ref, [{ subject: X, role: 'viewer' }]);
++ const seen = []; let fail = true;
++ const retrying = createShares({ ...options, events: { publish: async e => { seen.push(e.event_id); if (fail) throw Error('delivery interrupted'); } } });
++ await assert.rejects(retrying.flush(), /delivery interrupted/); fail = false;
++ await retrying.flush(); assert.equal(seen[0], seen[1]);
++});
++test('HTTP uniform routes, agent client, generic 404, forged subjects and staff flags rejected', async () => {
++ const ref = await fresh('private'); const handler = shares.router({ service: 'community', authenticate: req => ({ owner, recipient, other })[(req.headers.authorization || '').replace('Bearer ', '')] || null });
++ const server = http.createServer(handler); await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`; const path = `/api/v1/paste/${ref.id}/share`;
++ try {
++ for (const token of ['other', 'recipient', '']) {
++ const res = await fetch(base + path, { headers: { Authorization: `Bearer ${token}` } });
++ assert.equal(res.status, 404); assert.equal(await res.text(), '{"code":"share.not_found","error":"Not found"}');
++ assert.equal(res.headers.get('cache-control'), 'private, no-store');
++ }
++ const forged = await fetch(base + path, { method: 'PUT', headers: { Authorization: 'Bearer other', 'Content-Type': 'application/json' }, body: JSON.stringify({ audience: 'public', staff: true, subject: U }) });
++ assert.equal(forged.status, 400); assert.equal(await shares.canView(other, ref), false);
++ const client = share({ base, token: 'owner' }).for('paste', ref.id);
++ time += 60000;
++ await assert.rejects(client.add('@nobody'), e => e.status === 400 && e.code === 'share.name_not_found');
++ assert.equal((await client.get()).audience, 'private'); await client.setAudience('people');
++ time += 60000; await client.add('@maticus'); assert.equal(await shares.canView(recipient, ref), true);
++ await client.add('@maticus', { role: 'editor' }); assert.equal(await shares.canEdit(recipient, ref), true);
++ await client.remove(V); assert.equal(await shares.canView(recipient, ref), false);
++ const remote = share({ base, token: 'recipient' }).for('paste', ref.id);
++ const request = await remote.requestAccess(); assert.equal(request.state, 'pending');
++ time += 60000;
++ const tr = await client.transfer('@maticus', { keep_access: true });
++ assert.equal((await remote.accept(tr.transfer.id)).state, 'accepted');
++ assert.equal((await remote.get()).owner_subject.id, V.id);
++ assert.ok((await share({ base, token: 'owner' }).sharedWithMe()).items.some(i => i.ref.id === ref.id));
++ assert.equal(Object.keys(shareOpenApi().paths).length, 9);
++ } finally { await new Promise(r => server.close(r)); }
++});
++test('access checks take no row lock; writes still lock', async () => {
++ const ref = await fresh();
++ const statements = [];
++ const spy = Object.create(db);
++ spy.tx = (fn, opts) => db.tx(t => fn(new Proxy(t, { get(target, prop) {
++ const value = target[prop];
++ if (typeof value !== 'function') return value;
++ return (...args) => { if (typeof args[0] === 'string') statements.push(args[0]); return value.apply(target, args); };
++ } })), opts);
++ const checked = createShares({ ...options, db: spy });
++ statements.length = 0;
++ await checked.canView(owner, ref); await checked.canEdit(owner, ref);
++ assert.ok(statements.some(q => /FROM shares/.test(q)));
++ assert.ok(!statements.some(q => /FROM shares[\s\S]*FOR UPDATE/.test(q)), 'reads must not lock the sharing row');
++ statements.length = 0;
++ await checked.update(owner, ref, { audience: 'public' });
++ assert.ok(statements.some(q => /FROM shares[\s\S]*FOR UPDATE/.test(q)), 'writes keep their lock');
++});
++test('a member or transfer recipient must be a known, active user subject', async () => {
++ const ref = await fresh();
++ const guest = { type: 'guest', id: 'gst_01JAB2C3D4E5F6G7H8J9K0MNPQ' };
++ const unknown = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ' };
++ await denied(() => shares.addMembers(owner, ref, [guest]), 400);
++ await denied(() => shares.addMembers(owner, ref, [unknown]), 400);
++ await denied(() => shares.transfer(owner, ref, { to: guest }), 400);
++ const flagged = flag => createShares({ ...options, network: { json: async req => {
++ if (req.path !== '/internal/identity/resolve-batch') return null;
++ const results = {};
++ for (const id of req.json.subject_ids) results[id] = id === V.id ? { ...PEOPLE[V.id], banned: flag === 'banned', deleted: flag === 'deleted' } : null;
++ return { results };
++ } } });
++ for (const flag of ['banned', 'deleted']) {
++ const strict = flagged(flag);
++ await denied(() => strict.addMembers(owner, ref, [{ subject: V, role: 'viewer' }]), 400);
++ await denied(() => strict.transfer(owner, ref, { to: V }), 400);
++ }
++ time += 60000;
++ const tombstone = createShares({ ...options, network: { json: async () => ({ ...PEOPLE[V.id], deleted: true }) } });
++ await denied(() => tombstone.addMembers(owner, ref, ['@maticus']), 400);
++});
++test('notifications: a new member hears once; role, expiry, removal and the owner\'s own change stay quiet', async () => {
++ await shares.flush({ limit: 1000 });
++ const ref = await fresh('people');
++ const added = () => notified.filter(n => n.kind === 'share.added' && n.ref.id === ref.id).length;
++ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer' }]); await shares.flush({ limit: 1000 });
++ assert.equal(added(), 1);
++ await shares.update(owner, ref, { members: [{ subject: V, role: 'editor' }] }); await shares.flush({ limit: 1000 });
++ assert.equal(added(), 1);
++ await shares.update(owner, ref, { audience: 'private' }); await shares.flush({ limit: 1000 });
++ assert.equal(added(), 1);
++ await shares.removeMember(owner, ref, V); await shares.flush({ limit: 1000 });
++ assert.equal(added(), 1);
++ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', expires_at: new Date(time + 60000).toISOString() }]);
++ await shares.flush({ limit: 1000 });
++ assert.equal(added(), 2);
++});
++test('transfer notifications: recipient hears the offer and a cancellation; the owner hears the answer', async () => {
++ await shares.flush({ limit: 1000 });
++ const ref = await fresh('private');
++ const offers = () => notified.filter(n => n.kind === 'share.transfer.requested' && n.ref.id === ref.id);
++ const answers = () => notified.filter(n => n.kind === 'share.transfer.answered' && n.ref.id === ref.id);
++ const tr = await shares.transfer(owner, ref, { to: V }); await shares.flush({ limit: 1000 });
++ assert.equal(offers().length, 1); assert.equal(offers()[0].to.id, V.id);
++ await shares.cancel(owner, ref, tr.id); await shares.flush({ limit: 1000 });
++ assert.ok(answers().some(n => n.to.id === V.id && n.state === 'cancelled'));
++ assert.ok(!answers().some(n => n.to.id === U.id));
++ const tr2 = await shares.transfer(owner, ref, { to: V });
++ await shares.accept(recipient, ref, tr2.id); await shares.flush({ limit: 1000 });
++ assert.ok(answers().some(n => n.to.id === U.id && n.state === 'accepted'));
++ assert.ok(!answers().some(n => n.to.id === V.id && n.state === 'accepted'));
++ const ref2 = await fresh('private'); const tr3 = await shares.transfer(owner, ref2, { to: V });
++ await shares.decline(recipient, ref2, tr3.id); await shares.flush({ limit: 1000 });
++ assert.ok(notified.some(n => n.kind === 'share.transfer.answered' && n.ref.id === ref2.id && n.to.id === U.id && n.state === 'declined'));
++ const ref3 = await fresh('private'); await shares.transfer(owner, ref3, { to: V });
++ time += 7 * 86400000; await shares.expireMembers(); await shares.flush({ limit: 1000 });
++ assert.ok(notified.some(n => n.kind === 'share.transfer.answered' && n.ref.id === ref3.id && n.to.id === U.id && n.state === 'expired'));
++});
++test('PUT notify:false sends no share.added but events still flow', async () => {
++ await shares.flush({ limit: 1000 });
++ const ref = await fresh('people');
++ const added = () => notified.filter(n => n.kind === 'share.added' && n.ref.id === ref.id).length;
++ const events = () => published.filter(e => e.payload.ref.id === ref.id).length;
++ await shares.update(owner, ref, { members: [{ subject: V, role: 'viewer' }], notify: false });
++ await shares.flush({ limit: 1000 });
++ assert.equal(added(), 0); assert.equal(events(), 1);
++ await denied(() => shares.update(owner, ref, { notify: 'yes' }), 400);
++});
++test('HTTP answers the sheet: people, you, describe, requests, dismiss and the GET body on writes', async () => {
++ time += 60000;
++ const ref = await fresh('people');
++ await shares.requestAccess(other, ref);
++ const staff = { ...other, staff: true };
++ const handler = shares.router({ service: 'community',
++ describe: r => ({ title: `Paste ${r.id}`, link: `https://community.example/p/${r.id}`, roles: ['viewer', 'editor'] }),
++ authenticate: req => ({ owner, recipient, other, staff })[(req.headers.authorization || '').replace('Bearer ', '')] || null });
++ const server = http.createServer(handler); await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`, path = `/api/v1/paste/${ref.id}/share`;
++ const json = (method, url, token, value) => fetch(base + url, { method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, ...(value === undefined ? {} : { body: JSON.stringify(value) }) });
++ try {
++ const res = await json('GET', path, 'owner'); const view = await res.json(); const etag = res.headers.get('etag');
++ assert.equal(res.status, 200);
++ assert.equal(view.owner.username, 'ursula'); assert.deepEqual(view.you, { subject: U, role: 'owner' });
++ assert.equal(view.title, `Paste ${ref.id}`); assert.equal(view.link, `https://community.example/p/${ref.id}`); assert.deepEqual(view.roles, ['viewer', 'editor']);
++ assert.deepEqual(view.audiences, ['public', 'link', 'people', 'private']); assert.equal(view.transferable, true);
++ assert.equal(view.requests.length, 1); assert.equal(view.requests[0].username, 'xenia'); assert.ok(view.requests[0].id);
++ assert.equal((await (await json('GET', path, 'staff')).json()).you.role, 'staff');
++ const put = await json('PUT', path, 'owner', { audience: 'people', members: [{ subject: V, role: 'viewer' }], notify: false });
++ const saved = await put.json();
++ assert.equal(put.status, 200); assert.ok(put.headers.get('etag')); assert.notEqual(put.headers.get('etag'), etag);
++ assert.equal(saved.members.length, 1); assert.equal(saved.members[0].username, 'maticus'); assert.equal(saved.members[0].avatar_url, PEOPLE[V.id].avatar_url);
++ const dismissed = await json('POST', `${path}/requests/${view.requests[0].id}/dismiss`, 'owner', {});
++ assert.equal(dismissed.status, 200); assert.equal((await dismissed.json()).requests.length, 0);
++ assert.equal(await db.value('SELECT state FROM share_access_requests WHERE id=$1', [view.requests[0].id]), 'dismissed');
++ const started = await json('POST', `${path}/transfer`, 'owner', { to: { subject: V }, keep_access: true });
++ const offer = await started.json();
++ assert.equal(started.status, 201); assert.equal(offer.transfer.to.username, 'maticus'); assert.equal(offer.transfer.state, 'pending');
++ assert.equal(offer.title, `Paste ${ref.id}`);
++ const cancelled = await json('POST', `${path}/transfer/cancel`, 'owner', { transfer_id: offer.transfer.id });
++ assert.equal(cancelled.status, 200); const afterCancel = await cancelled.json();
++ assert.equal(afterCancel.transfer, undefined); assert.equal(afterCancel.audience, 'people');
++ const second = await (await json('POST', `${path}/transfer`, 'owner', { to: { subject: V } })).json();
++ const accepted = await json('POST', `${path}/transfer/accept`, 'recipient', { transfer_id: second.transfer.id });
++ const answer = await accepted.json();
++ assert.equal(accepted.status, 200); assert.equal(answer.state, 'accepted'); assert.equal(answer.id, second.transfer.id); assert.equal(answer.audience, undefined);
++ const person = await (await json('GET', `/api/v1/share/people?name=maticus`, 'owner')).json();
++ assert.equal(person.person.username, 'maticus'); assert.deepEqual(person.person.subject, V);
++ const nobody = await (await json('GET', `/api/v1/share/people?name=nobody`, 'owner')).json();
++ assert.equal(nobody.person, null);
++ assert.equal((await fetch(base + '/api/v1/share/people?name=maticus')).status, 404);
++ } finally { await new Promise(r => server.close(r)); }
++});
++test('CSRF: cookie writes need X-Requested-With or a same-site marker; bearer is exempt and csrf:false disables it', async () => {
++ const ref = await fresh('private');
++ const authenticate = req => req.headers.cookie === 'sid=owner' ? owner : ({ owner, recipient, other })[(req.headers.authorization || '').replace('Bearer ', '')] || null;
++ const listen = handler => http.createServer(handler);
++ const server = listen(shares.router({ service: 'community', authenticate }));
++ const loose = listen(shares.router({ service: 'community', authenticate, csrf: false }));
++ await new Promise(r => server.listen(0, '127.0.0.1', r)); await new Promise(r => loose.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`, looseBase = `http://127.0.0.1:${loose.address().port}`;
++ const path = `/api/v1/paste/${ref.id}/share`;
++ const write = (root, headers, value) => fetch(root + path, { method: 'PUT', headers: { Cookie: 'sid=owner', 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(value) });
++ try {
++ let res = await write(base, {}, { audience: 'public' });
++ assert.equal(res.status, 403); assert.equal((await res.json()).code, 'share.csrf');
++ assert.equal((await shares.get(owner, ref)).audience, 'private');
++ assert.equal((await write(base, { 'X-Requested-With': 'ov-share' }, { audience: 'public' })).status, 200);
++ await shares.setAudience(owner, ref, 'private');
++ assert.equal((await write(base, { 'Sec-Fetch-Site': 'same-origin' }, { audience: 'public' })).status, 200);
++ await shares.setAudience(owner, ref, 'private');
++ res = await fetch(base + path, { method: 'PUT', headers: { Authorization: 'Bearer owner', 'Content-Type': 'application/json' }, body: JSON.stringify({ audience: 'public' }) });
++ assert.equal(res.status, 200);
++ await shares.setAudience(owner, ref, 'private');
++ assert.equal((await write(looseBase, {}, { audience: 'public' })).status, 200);
++ } finally {
++ await new Promise(r => server.close(r)); await new Promise(r => loose.close(r));
++ }
++});
++(async () => {
++ let count = 0;
++ try { for (const [name, fn] of tests) { await fn(); count++; console.log('ok ' + name); } console.log(`${count} sharing tests passed`); }
++ finally { await db.close(); }
++})().catch(e => { console.error(e); process.exitCode = 1; });
ddiff --git a/types/contracts.d.ts b/types/contracts.d.ts
iindex 29180f6..8aa15d5 100644
---- a/types/contracts.d.ts
++++ b/types/contracts.d.ts
@@@ -1,4 +1,4 @@
--/* Copied verbatim from openvibe-contracts v0.289.1 generated/typescript/index.d.ts (the SDK does not
++/* Copied verbatim from openvibe-contracts feat/sharing-core-contracts (0.291.0) generated/typescript/index.d.ts (the SDK does not
* require the package at runtime; it is a devDependency for the tests). test/types.test.js checks
* these against it. */
/* eslint-disable */
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex eac4d71..e161d8a 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -47,3 +47,4 @@ export * as geo from './geo';
export * as usage from './usage';
export * as commerce from './commerce';
export * as telemetry from './telemetry';
++export const share: typeof import('./share').share;
ddiff --git a/types/share.d.ts b/types/share.d.ts
nnew file mode 100644
iindex 0000000..6354a59
---- /dev/null
++++ b/types/share.d.ts
@@@ -0,0 +1,96 @@
++import type { Db, Tx } from './db';
++import type { SubjectRef } from './contracts';
++import type { IncomingMessage, ServerResponse } from 'node:http';
++export type Audience = 'public' | 'link' | 'people' | 'private';
++export type Role = 'viewer' | 'editor';
++export interface ResourceRef { service: string; kind: string; id: string }
++/** Trusted output of service authentication. Never constructed from a request body. */
++export interface Viewer { subject: SubjectRef; staff?: boolean }
++export type MemberInput = ({ name: string; subject?: never } | { subject: SubjectRef; name?: never }) & { role: Role; expires_at?: string };
++export interface Member { subject: SubjectRef; role: Role; expires_at?: string; added_by: SubjectRef; added_at: string }
++/** Network's public projection of a person. An unknown subject has a null username. */
++export interface Person { subject: SubjectRef; username: string | null; display_name: string | null; avatar_url: string | null }
++export interface You { subject: SubjectRef; role: 'owner' | 'staff' }
++export interface AccessRequestView extends Person { id: string; requested_at: string }
++export interface Transfer { id: string; from: SubjectRef; to: SubjectRef; keep_access: boolean; requested_at: string; expires_at: string; state: 'pending' | 'accepted' | 'declined' | 'cancelled' | 'expired' }
++export interface TransferView extends Omit<Transfer, 'to'> { to: Person }
++export interface Share { ref: ResourceRef; owner_subject: SubjectRef; audience: Audience; members: Member[]; transfer?: Transfer }
++/** The store's enriched share: people in place of bare subjects, the caller's role and pending requests. */
++export interface ShareView extends Omit<Share, 'members' | 'transfer'> {
++ owner: Person;
++ members: Array<Member & Person>;
++ transfer?: TransferView;
++ you: You;
++ requests: AccessRequestView[];
++}
++/** What the HTTP router answers: ShareView plus the service's describe(ref). */
++export interface ShareResponse extends ShareView {
++ title: string | null;
++ link: string | null;
++ roles: Role[];
++ audiences: Audience[];
++ transferable: boolean;
++}
++export interface AccessRequest { id: string; subject: SubjectRef; requested_at: string; state: 'pending' | 'granted' | 'dismissed' }
++export interface PeopleLookup { person: Person | null }
++export interface ShareList { items: Array<{ ref: ResourceRef; owner_subject: SubjectRef; audience: Audience; role: Role; shared_at: string; transfer?: Transfer }>; next_cursor: string | null }
++export interface Notification { kind: 'share.added' | 'share.access_requested' | 'share.transfer.requested' | 'share.transfer.answered'; to: SubjectRef; actor: SubjectRef; ref: ResourceRef; delivery_id: string; action?: string; audience?: Audience; transfer_id?: string; request_id?: string; state?: Transfer['state'] }
++export interface ShareEvent { event_id: string; event_type: 'share.granted' | 'share.revoked' | 'share.transfer.requested' | 'share.transfer.accepted' | 'share.transfer.declined'; version: number; timestamp: string; source: string; actor: SubjectRef; subject: { type: string; id: string }; payload: { ref: ResourceRef; actor: SubjectRef; owner_subject: SubjectRef; audience: Audience; subject: SubjectRef; occurred_at: string; role?: Role; expires_at?: string; transfer?: Transfer; reason?: 'member_added' | 'member_removed' | 'member_expired' | 'audience_changed' | 'transfer_cancelled' | 'transfer_expired' | 'ownership_changed'; transfer_id?: string } }
++export interface ShareOptions {
++ db: Db;
++ network: { json(options: { service: string; audience: string; path: string; query?: { username: string }; method?: string; json?: unknown }): Promise<unknown> };
++ events: { publish(event: ShareEvent): Promise<unknown> };
++ notify(notification: Notification): Promise<unknown>;
++ /** Transactional hooks: write local resource/media state through tx; never external I/O. */
++ onOwnerChange?(ref: ResourceRef, from: SubjectRef, to: SubjectRef, tx: Tx): Promise<unknown>;
++ onAudienceChange?(ref: ResourceRef, audience: Audience, tx: Tx): Promise<unknown>;
++ now?(): number;
++ nameLimit?: number;
++ nameWindowMs?: number;
++}
++export interface Shares {
++ migrate(): Promise<void>;
++ register(ref: ResourceRef, input: { owner_subject: SubjectRef; audience?: Audience }): Promise<Share>;
++ canView(viewer: Viewer | null, ref: ResourceRef): Promise<boolean>;
++ canEdit(viewer: Viewer | null, ref: ResourceRef): Promise<boolean>;
++ get(viewer: Viewer, ref: ResourceRef): Promise<ShareView>;
++ getWithRevision(viewer: Viewer, ref: ResourceRef): Promise<{ value: ShareView; etag: string }>;
++ /** Add person projections and the caller's role to a raw share without another ownership read. */
++ present(viewer: Viewer, ref: ResourceRef, share: Share): Promise<ShareView>;
++ /** Exact @name → person (null when unknown, banned or deleted), on the same rate-limit budget as resolve(). */
++ lookup(viewer: Viewer, name: string): Promise<PeopleLookup>;
++ update(viewer: Viewer, ref: ResourceRef, input: { audience?: Audience; members?: MemberInput[]; notify?: boolean }, options?: { ifMatch?: string }): Promise<Share>;
++ setAudience(viewer: Viewer, ref: ResourceRef, audience: Audience): Promise<Share>;
++ addMembers(viewer: Viewer, ref: ResourceRef, members: Array<MemberInput | SubjectRef | string>): Promise<Share>;
++ removeMember(viewer: Viewer, ref: ResourceRef, subject: SubjectRef): Promise<Share>;
++ requestAccess(viewer: Viewer, ref: ResourceRef): Promise<AccessRequest>;
++ dismiss(viewer: Viewer, ref: ResourceRef, requestId: string): Promise<Share>;
++ transfer(viewer: Viewer, ref: ResourceRef, input: { to: SubjectRef | string | { subject: SubjectRef } | { name: string }; keep_access?: boolean }): Promise<Transfer>;
++ accept(viewer: Viewer, ref: ResourceRef, transferId: string): Promise<Transfer>;
++ decline(viewer: Viewer, ref: ResourceRef, transferId: string): Promise<Transfer>;
++ cancel(viewer: Viewer, ref: ResourceRef, transferId: string): Promise<Transfer>;
++ expireMembers(options?: { limit?: number }): Promise<number>;
++ sharedWithMe(viewer: Viewer, options?: { service?: string; limit?: number; cursor?: string }): Promise<ShareList>;
++ flush(options?: { limit?: number }): Promise<number>;
++ router(options: { service: string; authenticate(req: IncomingMessage): Viewer | null | Promise<Viewer | null>; describe?(ref: ResourceRef): Partial<Pick<ShareResponse, 'title' | 'link' | 'roles' | 'audiences' | 'transferable'>> | Promise<Partial<Pick<ShareResponse, 'title' | 'link' | 'roles' | 'audiences' | 'transferable'>>>; csrf?: boolean }): (req: IncomingMessage, res: ServerResponse, next?: () => unknown) => Promise<unknown>;
++}
++export function createShares(options: ShareOptions): Shares;
++export function sharesSchema(): string;
++export function migrateShares(db: Db): Promise<void>;
++export class ShareError extends Error { status: number; code: string; constructor(status: number, code: string, message: string) }
++export interface ShareResourceClient {
++ get(): Promise<ShareResponse>;
++ update(input: { audience?: Audience; members?: MemberInput[]; notify?: boolean }, options?: { ifMatch?: string }): Promise<ShareResponse>;
++ setAudience(audience: Audience): Promise<ShareResponse>;
++ add(target: string | SubjectRef, options?: { role?: Role; expires_at?: string }): Promise<ShareResponse>;
++ remove(subject: SubjectRef): Promise<ShareResponse>;
++ requestAccess(): Promise<AccessRequest>;
++ dismiss(requestId: string): Promise<ShareResponse>;
++ transfer(to: string | SubjectRef, options?: { keep_access?: boolean }): Promise<Transfer | ShareResponse>;
++ accept(transferId: string): Promise<Transfer>;
++ decline(transferId: string): Promise<Transfer>;
++ cancel(transferId: string): Promise<ShareResponse>;
++}
++export function share(options: { base: string; token?: string; fetch?: typeof globalThis.fetch }): { for(kind: string, id: string): ShareResourceClient; sharedWithMe(options?: { limit?: number; cursor?: string }): Promise<ShareList>; people(name: string): Promise<PeopleLookup> };
++export const createShareClient: typeof share;
++export function shareOpenApi(): { openapi: string; info: { title: string; version: string }; paths: Record<string, unknown>; components: Record<string, unknown> };