openvibers/openvibe.sdk Commits
0.88.0: openvibe-sdk/share learns locked items (Locker) (#121)
Changes
@@@ -1,5 +1,17 @@# Changelog++## 0.88.0 (2026-11-06)++++- `openvibe-sdk/share` learns locked items (Locker). A share records how its item is sealed — `lock: none | people | password | link`, set by `register(ref, { lock })` and answered by GET — and `describe()` cannot override it. A locked item is never public (`share.lock_public`), and the lock cannot change after registration (`share.lock_fixed`). Expand-only migration: `shares.lock`, `share_members.key_slot`, `share_transfers.key_slot`.++- A people-locked item carries each member's key slot (`locker.people-slot@1`). `addMembers`/`update` refuse a member without a slot (`share.slot_required`), refuse a slot for another subject or key, and answer `share.key_changed` (409) when the slot's `recipient_key` is not the RFC 7638 thumbprint of that person's key in Network's key directory now (`GET /api/v1/people/:subject/keys`, cached a minute). A person with no locker yet is `share.no_key`.++- Removing somebody from a people-locked item is a re-seal: the save carries `envelope` (base64 of a people-locked `locker.envelope@1`), or `removeMember(..., { envelope })`. The envelope must not still hold the leaving member's slot (`share.reseal_invalid`), and it is stored through the new `onReseal(ref, envelopeBytes, tx)` hook inside the transaction, before the member row goes; a hook error rolls everything back. Without an envelope: `share.reseal_required`; without the hook: `share.reseal_unavailable`. A save that carries an envelope may be up to 8 MiB; every other body stays at 64 KiB.++- A re-seal must be fresh (`share.reseal_not_fresh`): its nonce base may not repeat the one the module stored last, and a staying member's slot may not be byte for byte the stored one. Either means the envelope already stored was sent again, so the person leaving could still open what is served next. The last nonce base is kept in `shares.reseal_nonce_base` (expand-only).++- A transfer of a people-locked item carries the recipient's `key_slot` with the offer (required, checked like a member slot); accepting drops the former owner's membership unless `keep_access`.++- GET for a people-locked item answers each member's `key_slot`, and for the owner each member's `key_fingerprint` and `fingerprint_changed`, so the sheet can warn and re-wrap.++- The agent client gains `addLocked('@name', { contentKey })` (looks up the person's key, wraps, saves the member and the slot) and `removeLocked('@name', { contentKey | envelope })` (re-seals for the owner and the members who stay, then removes); `share({ base, token, network })` names the key directory. `transfer(to, { key_slot })` passes the recipient's slot.++- `openvibe-sdk/locker`: `open`/`rewrap` accept `{ contentKey }`, so an agent that sealed an item can open or re-seal it without a private key. `openvibe-sdk/locker/server` exports `peopleSlotOk`.++- Pins openvibe-contracts v0.296.0 (was v0.292.0): `[email protected]`, `[email protected]_slot` and `[email protected]_fingerprint` are the shapes the locked sharing uses.++## 0.87.0 (2026-11-06)- **The 1.0 core surface is frozen now.** `core`, `auth`, `events`, `db` and `media` are stable before 1.0:@@@ -2,16 +2,16 @@> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.87.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; 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.88.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; 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.87.0 is tagged:++After v0.88.0 is tagged:```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.87.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.88.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 the temporary Contracts sharing branch, a devDependency the tests check them against).@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.87.0",++ "version": "0.88.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",@@@ -11,7 +11,7 @@"features": ["locker (openvibe-sdk/locker, openvibe-sdk/locker/server): the Locker envelope (locker.envelope@1) for end-to-end encryption, WebCrypto only, in Node and the browser: seal/open for text and bytes, sealStream/openStream, the three locks (people, password, link), person key pairs (ECDH P-256, ECDSA P-256), the eight-word fingerprint, wrapFor and rewrap, recoveryWords for the phrase to write down, opening a people lock by key, inspect for what an envelope says about itself, and the header checks a service runs without a key","the 1.0 core surface is frozen now, before 1.0 (core, auth, events, db, media): docs/one-point-oh.json gives every export its tier, test/surface.test.js pins the exact names, and removing or renaming one needs a major release; createCodeVerifier and pkceChallenge left in 0.86.0",-- "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",++ "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; locked items (Locker): the lock kind, per-member key slots checked against the key directory, a re-seal on removal through onReseal, and the transfer offer slot","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",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.87.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.88.0, MIT). Do not edit.// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces// (auth, locker, 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 */@@@ -48,7 +48,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.87.0';++const SDK_VERSION = '0.88.0';module.exports = {createClient,@@@ -1889,6 +1889,15 @@ function readCore(header) {/** The content key out of the header, with the credentials the reader has. */async function openContentKey(header, credentials = {}) {++ // A caller that still holds the content key needs no private key: an agent that sealed the item++ // can open it, or wrap it for the people who stay, with the key it kept. A string is base64url,++ // as the link key travels.++ if (credentials.contentKey) {++ const given = typeof credentials.contentKey === 'string'++ ? fromBase64url(credentials.contentKey) : toBytes(credentials.contentKey);++ if (given.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes');++ return given;++ }if (header.lock === 'people') {// A person may be named by their key instead of their subject: the slot says which key// it is for, so the reader does not need to know who they are.@@@ -664,7 +664,8 @@},"openvibe-sdk/locker/server": {"checkHeader": "function",-- "inspectEnvelope": "function"++ "inspectEnvelope": "function",++ "peopleSlotOk": "function"},"openvibe-sdk/locker/vectors": {"CASES": "array",@@@ -1,6 +1,6 @@# 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).++`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.296.0 (share.v1, with the Locker lock kind and the member key slots).```jsconst { createShares, sharesSchema, shareOpenApi } = require('openvibe-sdk/share');@@@ -11,6 +11,7 @@ const shares = createShares({notify, // async notification => Network delivery adapteronOwnerChange: async (ref, from, to, tx) => { /* update this service's resource using tx */ },onAudienceChange: async (ref, audience, tx) => { /* update visibility and search projection using tx */ },++ onReseal: async (ref, envelope, tx) => { /* store the re-sealed locker envelope 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.@@@ -34,19 +35,19 @@ The migration creates `shares`, `share_members`, `share_transfers`, `share_acces| Server method | Result || --- | --- |--| `register(ref, { owner_subject, audience = 'private' })` | Share; trusted service-only initialization, never an HTTP operation |++| `register(ref, { owner_subject, audience = 'private', lock = 'none' })` | Share; trusted service-only initialization, never an HTTP operation. `lock` is how the item is sealed (`locker.envelope@1`); a locked item cannot be public || `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 |++| `addMembers(viewer, ref, ['@name' \| subject \| { name \| subject, role, expires_at?, key_slot? }])` | Share; exact names or canonical active user subjects |++| `removeMember(viewer, ref, subject, { envelope }?)` | Share; on a people-locked item `envelope` (base64) is required |++| `update(viewer, ref, { audience?, members?, notify?, lock?, envelope? }, { ifMatch? }?)` | Share; atomically replaces members when provided. `notify: false` suppresses `share.added` for that save only. `lock` may only echo the registered value; `envelope` stores a re-seal || `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 |++| `transfer(viewer, ref, { to: '@name' \| subject \| { subject } \| { name }, keep_access = false, key_slot? })` | Transfer; `key_slot` is required on a people-locked item || `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 |@@@ -71,6 +72,38 @@ The five Events payloads are `share.granted`, `share.revoked`, `share.transfer.rThe 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.++## Locked items (Locker)++++Sharing decides who may fetch a thing; `locker.envelope@1` decides who can read it. A share records how its item is sealed (`lock: none | people | password | link`) and the module stores the slots, never a content key. Locked items (`lock !== 'none'`) can never be public: `register` and `update` refuse `audience: 'public'` with `400 share.lock_public`, and `lock` is fixed once registered (`409 share.lock_fixed`; a save may echo it).++++For a **people** lock each member carries `key_slot`, the content key wrapped for that member's public key (`locker.people-slot@1`). The browser, or an agent with `openvibe-sdk/locker`, makes the slot; the server only checks it:++++* a member without a slot is refused: `400 share.slot_required`;++* a slot is refused when it names another subject (`400 share.invalid`) or a key other than the one Network's key directory has for that person now (`GET /api/v1/people/:subject/keys`, through the same `network` client; `409 share.key_changed`);++* a person with no locker yet is `409 share.no_key`.++++Removing a member is a re-seal, not a row deletion: the caller sends the content sealed again for the people who stay (`envelope`, base64 of a people-locked `locker.envelope@1`) with the members that remain, or as `removeMember(..., { envelope })`. The envelope must not still hold the leaving member's slot (`400 share.reseal_invalid`), and it is stored through `onReseal(ref, envelopeBytes, tx)` **inside** the transaction and before the member row goes. Without an envelope the save is `400 share.reseal_required`; without the hook, `409 share.reseal_unavailable`. A hook error rolls everything back, member included. An expired member is revoked by the sweep without a re-seal: access stops, and the service should re-seal when it can.++++A re-seal must also be **fresh** (`400 share.reseal_not_fresh`): the envelope may not repeat the nonce base of the one this module stored last, and a member who stays may not keep a slot that is byte for byte the stored one. Either means the envelope already stored came back, so the person leaving could still open what is served next. The header carries no key id, so the check is the per-seal nonce base plus the wraps, which is what a freshly sealed envelope always changes; it does not prove what key a caller sealed with.++++A transfer of a people-locked item carries the recipient's slot with the offer (`key_slot`; `400 share.slot_required`, checked like any member slot). Accepting removes the former owner's membership unless `keep_access`; a former owner who keeps access starts without a slot, because the fresh content key is the new owner's to wrap for them.++++GET for a people-locked item answers each member's `key_slot`, and for the owner each member's `key_fingerprint` (from the key directory) and `fingerprint_changed` — true when the key the slot was made for is not the person's key now — so the sheet can warn and re-wrap. `describe()` cannot override `lock`. The body limit stays 64 KiB except for a save that carries an envelope (up to 8 MiB).++++```js++const sdk = require('openvibe-sdk');++const locker = require('openvibe-sdk/locker');++const item = sdk.share({ base: 'https://community.openvibe.network', token }).for('paste', id);++const contentKey = crypto.getRandomValues(new Uint8Array(32)); // keep it++const sealed = await locker.seal('a note', { lock: 'people', contentKey, keys: [{ subject: me, publicKey: myKey }] });++await item.setAudience('people');++await item.addLocked('@maticus', { contentKey }); // looks up his key, wraps, saves both++await item.removeLocked('@maticus', { contentKey, envelope: sealed }); // re-seals for those who stay, removes++await item.transfer('@xenia', { key_slot: await locker.wrapFor(contentKey, xeniaKey, { subject: xenia }) });++```++++`removeLocked(target, { envelope })` also accepts an envelope that the caller has already re-sealed; with `contentKey` the envelope is opened and sealed again for the owner and the members who stay (their public keys come from the directory, or pass `keys`).++```jsconst sdk = require('openvibe-sdk');const item = sdk.share({ base: 'https://community.openvibe.network', token }).for('paste', id);@@@ -2,6 +2,6 @@import cjs from '../src/locker/server.js';export const {-- inspectEnvelope, checkHeader,++ inspectEnvelope, checkHeader, peopleSlotOk,} = cjs;export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.87.0",++ "version": "0.88.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.87.0",++ "version": "0.88.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.292.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.296.0","pg": "^8.23.0"},"engines": {@@@ -198,9 +198,9 @@"license": "MIT"},"node_modules/openvibe-contracts": {-- "version": "0.292.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.292.0",-- "integrity": "sha512-zJwkQHzMJ5/SyLzujbIGOOOtRSwILwe9oQo1INb3sFnyB7KDoYKAhAgGFLs7Y7ooQYlO+Bo+Q6GlHfyfe858ew==",++ "version": "0.296.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.296.0",++ "integrity": "sha512-+irrEq65eTSkMp3aHt2a7Wuxo689QBrUpgdhkZGBtU4owrsmS3VlyWVBqmy4ZAprqJ1rQJKqpW7n3RlgZ1KKBw==","dev": true,"license": "AGPL-3.0-only","dependencies": {@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.87.0",++ "version": "0.88.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",@@@ -514,7 +514,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.292.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.296.0","pg": "^8.23.0"},"peerDependencies": {@@@ -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.87.0';++const SDK_VERSION = '0.88.0';module.exports = {createClient,@@@ -232,6 +232,15 @@ function readCore(header) {/** The content key out of the header, with the credentials the reader has. */async function openContentKey(header, credentials = {}) {++ // A caller that still holds the content key needs no private key: an agent that sealed the item++ // can open it, or wrap it for the people who stay, with the key it kept. A string is base64url,++ // as the link key travels.++ if (credentials.contentKey) {++ const given = typeof credentials.contentKey === 'string'++ ? fromBase64url(credentials.contentKey) : toBytes(credentials.contentKey);++ if (given.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes');++ return given;++ }if (header.lock === 'people') {// A person may be named by their key instead of their subject: the slot says which key// it is for, so the reader does not need to know who they are.@@@ -153,4 +153,4 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {};}--module.exports = { inspectEnvelope, checkHeader };++module.exports = { inspectEnvelope, checkHeader, peopleSlotOk };@@@ -1,26 +1,56 @@'use strict';const { createClient } = require('../core');++const locker = require('../locker');const v = require('./validation');++/** Network is where a person's public keys live (locker.public-keys@1). */++const DEFAULT_NETWORK = 'https://openvibe.network';++const bytesOf = value => (value instanceof Uint8Array ? value : value && value.bytes);++const base64Of = value => Buffer.from(bytesOf(value)).toString('base64');++/** A content key: 32 bytes, as bytes or base64url (as a link key travels). */++function contentKeyOf(value) {++ if (!value) throw new TypeError('A locked member needs the item\'s content key');++ const bytes = typeof value === 'string'++ ? Buffer.from(value.replace(/-/g, '+').replace(/_/g, '/'), 'base64')++ : bytesOf(value);++ if (!bytes || bytes.length !== 32) throw new TypeError('A content key is 32 bytes, as bytes or base64url');++ return bytes;++}++/** The member fields a save echoes back: the slot travels with its member or the server refuses it. */++const echo = ({ subject, role, expires_at, key_slot }) => ({ subject, role, ...(expires_at ? { expires_at } : {}), ...(key_slot ? { key_slot } : {}) });/** Agent client: optimistic PUT preserves members added by another caller. */--function share({ base, token, fetch } = {}) {++function share({ base, token, fetch, network = DEFAULT_NETWORK } = {}) {if (typeof base !== 'string' || !/^https?:\/\//.test(base)) throw new TypeError('share requires base');++ if (typeof network !== 'string' || !/^https?:\/\//.test(network)) throw new TypeError('share network must be a URL');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') };}++ /** A person's public keys, as anyone may read them. */++ async function keysFor(subject) {++ const found = await client.json({ baseUrl: network, path: `/api/v1/people/${encodeURIComponent(subject.id)}/keys` });++ if (!found || !found.ecdh) throw new Error('That person has no locker key yet');++ return found;++ }++ /** An @name or a subject object, as the canonical subject. */++ async function subjectOf(target) {++ if (typeof target !== 'string') return v.subject(target);++ const { person } = await call('/api/v1/share/people', { query: { name: target } });++ if (!person) throw new Error(`No OpenVibe account is called @${target.replace(/^@/, '')}`);++ return person.subject;++ }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 updateMembers = async (fn, { envelope } = {}) => {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 call(path, { method: 'PUT', headers: { 'If-Match': etag },++ json: { members: fn((value.members || []).map(echo)), ...(envelope ? { envelope: base64Of(envelope) } : {}) } });};return {get: () => call(path),@@@ -31,9 +61,41 @@ function share({ base, token, fetch } = {}) {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))),++ /** Adds a member to a people-locked item: looks up their key, wraps the content key for it, saves both. */++ addLocked: async (target, { contentKey, role = 'viewer', expires_at } = {}) => {++ const subject = await subjectOf(target);++ const found = await keysFor(subject);++ const key_slot = await locker.wrapFor(contentKeyOf(contentKey), found.ecdh, { subject });++ return updateMembers(members => [...members.filter(m => !v.equal(m.subject, subject)),++ { subject, role, ...(expires_at ? { expires_at } : {}), key_slot }]);++ },++ /**++ * Removes a member from a people-locked item with a re-seal. `envelope` is either the content++ * already sealed for those who stay, or — with `contentKey` — the item's current envelope, which++ * is opened and sealed again without that person.++ */++ removeLocked: async (target, { contentKey, envelope, keys } = {}) => {++ const subject = await subjectOf(target);++ const { value, etag } = await read(path);++ if (!etag) throw new Error('Sharing server did not provide an ETag');++ const staying = (value.members || []).map(echo).filter(m => !v.equal(m.subject, subject));++ let sealed = envelope ? bytesOf(envelope) : null;++ if (contentKey) {++ if (!sealed) throw new TypeError('removeLocked needs the item\'s current envelope alongside the content key');++ // The owner keeps their slot: they are not a member, and their key is not in the member list.++ const recipients = keys || await Promise.all([value.owner_subject, ...staying.map(m => m.subject)]++ .filter((s, i, all) => all.findIndex(x => v.equal(x, s)) === i)++ .map(async s => ({ subject: s, publicKey: (await keysFor(s)).ecdh })));++ const wrapped = await locker.rewrap(sealed, { remove: [subject], keys: recipients,++ credentials: { contentKey: contentKeyOf(contentKey) } });++ sealed = wrapped.bytes;++ }++ if (!sealed) throw new TypeError('removeLocked needs a re-sealed envelope, or the content key and the current envelope');++ return call(path, { method: 'PUT', headers: { 'If-Match': etag }, json: { members: staying, envelope: base64Of(sealed) } });++ },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 } }),++ transfer: (to, { keep_access = false, key_slot } = {}) => call(path + '/transfer', { method: 'POST', json: { to, keep_access, ...(key_slot ? { key_slot } : {}) } }),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 } }),@@@ -5,9 +5,11 @@ function shareOpenApi() {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' } } });++ responses: { [status]: response(result), '400': { description: 'Invalid request or unknown exact name (also share.slot_required, share.reseal_required, share.reseal_invalid, share.lock_public)' },++ '403': { description: 'Cross-site write refused (share.csrf)' },++ '404': { description: 'Not found (also unauthorized, no resource metadata)' },++ '409': { description: 'Inactive or conflicting transfer, a member key that changed (share.key_changed), a member with no locker key (share.no_key), a fixed lock (share.lock_fixed) or no re-seal storage (share.reseal_unavailable)' },++ '412': { description: 'If-Match revision is stale' }, '413': { description: 'Request too large (share.too_large)' }, '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 = {@@@ -1,5 +1,6 @@'use strict';const v = require('./validation');++const { MAX_ENVELOPE_BYTES } = v;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));@@@ -7,16 +8,25 @@ function send(res, status, body, headers = {}) {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');++ if (Buffer.byteLength(JSON.stringify(req.body)) > MAX_BODY(req.body)) v.fail(413, 'share.too_large', 'Request too large');return req.body;}const chunks = []; let size = 0;++ // A save may carry a re-sealed envelope, so it may be larger than an ACL edit; nothing else may.for await (const chunk of req) {size += chunk.length;-- if (size > 65536) v.fail(413, 'share.too_large', 'Request too large');++ if (size > MAX_ENVELOPE_BYTES + 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(); }++ const raw = Buffer.concat(chunks);++ let parsed;++ try { parsed = JSON.parse(raw.toString('utf8')); } catch { v.invalid(); }++ if (raw.length > MAX_BODY(parsed)) v.fail(413, 'share.too_large', 'Request too large');++ return parsed;++}++/** 64 KiB, unless the save carries a re-sealed envelope (bounded by v.envelope). */++function MAX_BODY(json) {++ return json && typeof json.envelope === 'string' ? MAX_ENVELOPE_BYTES + 65536 : 65536;}/*** Node/Express middleware. authenticate must use verified server credentials. describe(ref) may add { title, link,@@@ -60,7 +70,10 @@ function router(shares, { service, authenticate, describe, csrf } = {}) {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 });++ const extra = await detail(ref);++ // How an item is sealed is the store's to answer, never a service's describe().++ delete extra.lock;++ return send(res, status, { ...result.value, ...extra, lock: result.value.lock }, { 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(); }@@@ -74,7 +87,7 @@ function router(shares, { service, authenticate, describe, csrf } = {}) {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); }++ if (rest === 'transfer') { v.object(input, ['to', 'keep_access', 'key_slot']); 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);@@@ -6,12 +6,15 @@ CREATE TABLE IF NOT EXISTS shares (service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),revision integer NOT NULL DEFAULT 1,++ lock text NOT NULL DEFAULT 'none',++ reseal_nonce_base text,PRIMARY KEY (service, kind, resource_id));CREATE TABLE IF NOT EXISTS share_members (service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,++ key_slot jsonb,PRIMARY KEY (service, kind, resource_id, subject),FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id));@@@ -20,6 +23,7 @@ CREATE TABLE IF NOT EXISTS share_transfers (from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),++ key_slot jsonb,FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id));CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';@@@ -54,6 +58,15 @@ CREATE TABLE IF NOT EXISTS share_name_limits (CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the++-- slot a transfer was offered with. Repeating this migration is a no-op.++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';++DO $$ BEGIN++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));++EXCEPTION WHEN duplicate_object THEN NULL; END $$;++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;`;}async function migrateShares(db) { await db.tx(async (t) => { await t.exec(sharesSchema()); }); }@@@ -1,6 +1,8 @@'use strict';const { randomUUID } = require('node:crypto');const { newEventId } = require('../core/ids');++const { inspectEnvelope } = require('../locker/server');++const { jwkThumbprint } = require('../locker/keys');const v = require('./validation');const { migrateShares } = require('./schema');const WEEK = 7 * 24 * 60 * 60 * 1000;@@@ -10,18 +12,23 @@ 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 slotKey = slot => (slot && slot.recipient_key) || 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) });++ added_by: row.added_by, added_at: iso(row.added_at), ...(row.key_slot ? { key_slot: row.key_slot } : {}) });++/** A public key we can name by its thumbprint: an EC P-256 JWK with both coordinates. */++const usableKey = jwk => !!jwk && jwk.kty === 'EC' && jwk.crv === 'P-256'++ && typeof jwk.x === 'string' && typeof jwk.y === 'string';--function createShares({ db, network, events, notify, onOwnerChange, onAudienceChange, now = Date.now, nameLimit = 30, nameWindowMs = 60000 } = {}) {++function createShares({ db, network, events, notify, onOwnerChange, onAudienceChange, onReseal, 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();++ const keyCache = 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()]);@@@ -53,7 +60,7 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceChasync 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) } : {}) };++ return { ref, owner_subject: s.owner_subject, audience: s.audience, lock: s.lock || 'none', 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) {@@@ -137,20 +144,105 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceChif (!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) {++ /**++ * A person's current public keys from Network's key directory (GET /api/v1/people/:subject/keys),++ * cached for a minute. null means the person has no locker yet. The thumbprint is what a slot++ * names, so it is the thing a member's slot is checked against.++ */++ async function directoryKeys(subject) {++ const at = now(), hit = keyCache.get(subject.id);++ if (hit && at - hit.at < PEOPLE_TTL) return hit.keys;++ let keys = null;++ try {++ const found = await network.json({ service: 'network', audience: 'openvibe.network',++ path: `/api/v1/people/${encodeURIComponent(subject.id)}/keys` });++ if (found && usableKey(found.ecdh) && typeof found.fingerprint === 'string') {++ keys = { ecdh: found.ecdh, fingerprint: found.fingerprint, thumbprint: await jwkThumbprint(found.ecdh) };++ }++ } catch (err) { if (err.status !== 404) throw err; }++ keyCache.set(subject.id, { at, keys });++ return keys;++ }++ /** Everyone's current keys, one lookup each and never inside a transaction. */++ async function currentKeys(subjects) {++ const unique = [];++ for (const s of subjects) if (!unique.some(u => v.equal(u, s))) unique.push(s);++ const found = await Promise.all(unique.map(directoryKeys));++ return new Map(unique.map((s, i) => [personKey(s), found[i]]));++ }++ /** A slot is for this person only if it names them and the key Network has for them now. */++ async function checkSlot(subject, slot) {++ if (!v.equal(slot.subject, subject)) v.invalid();++ const keys = await directoryKeys(subject);++ if (!keys) v.fail(409, 'share.no_key', 'That person has no locker key yet; they make one the first time they open a locked item');++ if (keys.thumbprint !== slot.recipient_key) {++ v.fail(409, 'share.key_changed', 'That person\'s key changed; wrap the content key for their new key');++ }++ return slot;++ }++ /** The lock kind of a registered item, read before name and key resolution so that check runs outside a transaction. */++ async function storedLock(ref) {++ const s = await db.maybe(`SELECT lock FROM shares WHERE ${where}`, key(ref));++ if (!s) v.hidden();++ return s.lock;++ }++ /** A re-sealed envelope: base64 in, bytes out, and it must be a people-locked envelope we can read. */++ function sealed(base64) {++ const bytes = v.envelope(base64);++ const read = inspectEnvelope(bytes);++ if (!read.valid || read.lock !== 'people') v.fail(400, 'share.reseal_invalid', 'A re-seal is a valid people-locked envelope');++ return bytes;++ }++ /** Stores a re-seal through the service's hook, inside the transaction and before the member goes. */++ async function reseal(t, ref, s, bytes, actor, gone) {++ if (s.lock !== 'people') v.fail(400, 'share.reseal_invalid', 'Only a people-locked item is re-sealed');++ if (typeof onReseal !== 'function') v.fail(409, 'share.reseal_unavailable', 'This service cannot store a re-sealed envelope');++ const read = inspectEnvelope(bytes);++ // Every seal writes a new nonce base, and a re-seal wraps a new content key. The nonce base the++ // envelope already stored is the one to beat: the same value is that envelope sent again, and the++ // member who is leaving could still open what is served next.++ if (gone.length && s.reseal_nonce_base && read.header.nonce_base === s.reseal_nonce_base) {++ v.fail(400, 'share.reseal_not_fresh', 'That is the envelope already stored; seal the content again with a new key');++ }++ // A wrap is randomised every time, so a slot that is byte for byte the stored one means the old++ // key, still wrapped for the same person, came back.++ if (gone.length) {++ const people = new Map(read.header.slots.people.map(slot => [personKey(slot.subject), slot]));++ const staying = await t.many(`SELECT subject, key_slot FROM share_members WHERE ${where} AND key_slot IS NOT NULL`, key(ref));++ for (const m of staying) {++ if (gone.some(g => v.equal(g.subject, m.subject))) continue;++ const slot = people.get(personKey(m.subject));++ if (slot && slot.salt === m.key_slot.salt && slot.nonce === m.key_slot.nonce && slot.wrapped_key === m.key_slot.wrapped_key) {++ v.fail(400, 'share.reseal_not_fresh', 'The re-sealed envelope still wraps the old content key for a member who stays');++ }++ }++ }++ // The point of a re-seal is that the person leaving cannot open what they fetch next: their slot++ // has to be gone from the envelope, not only from the member table.++ const recipients = new Set(read.header.slots.people.map(slot => slot.recipient_key));++ for (const m of gone) {++ const left = slotKey(m.key_slot);++ if (left && recipients.has(left)) v.fail(400, 'share.reseal_invalid', 'The re-sealed envelope still holds the slot of the member who is leaving');++ }++ await onReseal(ref, bytes, t);++ await t.exec(`UPDATE shares SET reseal_nonce_base=$4 WHERE ${where}`, [...key(ref), read.header.nonce_base]);++ s.reseal_nonce_base = read.header.nonce_base;++ await log(t, ref, actor, 'lock.resealed', { removed: gone.map(m => m.subject), bytes: bytes.length });++ }++ async function inputs(viewer, list, lock = 'none') {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']);++ v.object(item, ['name', 'subject', 'role', 'expires_at', 'key_slot']);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()) };++ return { item, expires_at: v.expiry(item.expires_at, now()), key_slot: item.key_slot === undefined ? null : v.keySlot(item.key_slot) };});// 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) {++ for (const { item, expires_at, key_slot } of parsed) {let subject;if (Object.hasOwn(item, 'name')) subject = await resolve(viewer, item.name);else {@@@ -159,9 +251,15 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceChif (!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 });++ out.set(JSON.stringify(subject), { subject, role: item.role, expires_at, key_slot });}-- return [...out.values()];++ const resolved = [...out.values()];++ // A people-locked item is only shared with a key slot each: the server can check it, never make it.++ if (lock === 'people') {++ for (const m of resolved) if (!m.key_slot) v.fail(400, 'share.slot_required', 'A people-locked item needs a key slot for every member');++ await Promise.all(resolved.map(m => checkSlot(m.subject, m.key_slot)));++ } else for (const m of resolved) if (m.key_slot) v.fail(400, 'share.key_slot_unexpected', 'Only a people-locked item carries key slots');++ return resolved;}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]);@@@ -193,11 +291,14 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceChif (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()]);++ // A new slot for the same person is a change even when the role and the expiry are not.++ const slot = m.key_slot || null;++ if (existing && existing.role === m.role && (existing.expires_at ? iso(existing.expires_at) : null) === m.expires_at++ && slotKey(existing.key_slot) === slotKey(slot)) return;++ await t.exec(`INSERT INTO share_members(service,kind,resource_id,subject,role,expires_at,added_by,added_at,key_slot) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)++ ON CONFLICT(service,kind,resource_id,subject) DO UPDATE SET role=$5,expires_at=$6,added_by=$7,added_at=$8,key_slot=$9`, [...key(ref), m.subject, m.role, m.expires_at, actor, timestamp(), slot]);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 log(t, ref, actor, 'member.added', { subject: m.subject, role: m.role, ...(slot ? { recipient_key: slot.recipient_key } : {}) });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);@@@ -213,20 +314,26 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh});}async function update(viewer, input, body, { ifMatch } = {}) {-- const ref = v.ref(input); v.object(body, ['audience', 'members', 'notify']);++ const ref = v.ref(input); v.object(body, ['audience', 'members', 'notify', 'lock', 'envelope']);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();++ if (body.lock !== undefined) v.lock(body.lock);await authorize(viewer, ref);-- const list = body.members === undefined ? undefined : await inputs(viewer, body.members);++ // How an item is sealed is set when it is created; a save may echo it, never change it.++ const lock = await storedLock(ref);++ if (body.lock !== undefined && body.lock !== lock) v.fail(409, 'share.lock_fixed', 'How an item is sealed is set when it is created');++ if (body.audience === 'public' && lock !== 'none') v.fail(400, 'share.lock_public', 'A locked item cannot be public');++ const list = body.members === undefined ? undefined : await inputs(viewer, body.members, lock);++ const resealed = body.envelope === undefined ? null : sealed(body.envelope);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');++ const have = await members(t, ref);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();@@@ -234,9 +341,16 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh}}if (body.audience !== undefined) await changeAudience(t, ref, s, actor, body.audience);++ const gone = list ? have.filter(m => !list.some(x => v.equal(x.subject, m.subject))) : [];++ // Somebody leaving a people-locked item: the content is re-sealed for those who stay, and the++ // service stores it before the member row goes. The envelope is checked before the hook, so the++ // service never receives bytes it cannot check.++ if (gone.length && s.lock === 'people' && !resealed) {++ v.fail(400, 'share.reseal_required', 'Removing somebody from a people-locked item needs the content re-sealed for those who stay');++ }++ if (resealed) await reseal(t, ref, s, resealed, actor, gone);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 gone) 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);@@@ -251,7 +365,7 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceChif (item && typeof item === 'object' && Object.hasOwn(item, 'type')) return { subject: v.subject(item), role: 'viewer' };return item;});-- const resolved = await inputs(viewer, normalized);++ const resolved = await inputs(viewer, normalized, await storedLock(ref));return mutate(viewer, ref, async (t, ref, s, a, staff) => {if (staff) v.hidden();const have = await members(t, ref);@@@ -260,21 +374,27 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceChreturn snapshot(t, ref, s);});}-- async function removeMember(viewer, ref, input) {++ async function removeMember(viewer, ref, input, { envelope } = {}) {const subject = v.subject(input);++ const resealed = envelope === undefined ? null : sealed(envelope);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');++ if (m) {++ if (s.lock === 'people' && !resealed) v.fail(400, 'share.reseal_required', 'Removing somebody from a people-locked item needs the content re-sealed for those who stay');++ if (resealed) await reseal(t, ref, s, resealed, a, [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);++ async function register(input, { owner_subject, audience = 'private', lock = 'none' }) {++ const ref = v.ref(input), a = v.subject(owner_subject); v.audience(audience); v.lock(lock);++ if (lock !== 'none' && audience === 'public') v.fail(400, 'share.lock_public', 'A locked item cannot be public');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 made = await t.exec('INSERT INTO shares(service,kind,resource_id,owner_subject,audience,lock) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING', [...key(ref), a, audience, lock]);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 });++ if (!v.equal(s.owner_subject, a) || s.audience !== audience || s.lock !== lock) v.fail(409, 'share.exists', 'Sharing already registered');++ if (made) await log(t, ref, a, 'registered', { audience, lock });return snapshot(t, ref, s);});}@@@ -286,9 +406,23 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceChtry { people = await peopleOf(subjects); }catch { people = new Map(subjects.map(s => [personKey(s), unknownPerson(s)])); }const person = subject => people.get(personKey(subject)) || unknownPerson(subject);++ // The owner of a people-locked item sees each member's current fingerprint and whether the slot++ // is still for the key that person has now, so the sheet can warn before it wraps again. A read++ // that cannot reach the key directory says nothing rather than claiming every key changed.++ let keys = null;++ if (share.lock === 'people' && v.equal(viewer.subject, share.owner_subject) && (share.members || []).length) {++ try { keys = await currentKeys(share.members.map(m => m.subject)); }++ catch { keys = null; }++ }++ const member = m => {++ if (!keys) return { ...m, ...person(m.subject) };++ const found = keys.get(personKey(m.subject));++ return { ...m, ...person(m.subject), key_fingerprint: found ? found.fingerprint : null,++ fingerprint_changed: !found || found.thumbprint !== slotKey(m.key_slot) };++ };return { ...share,owner: person(share.owner_subject),-- members: (share.members || []).map(m => ({ ...m, ...person(m.subject) })),++ members: (share.members || []).map(member),...(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) })) };@@@ -325,22 +459,29 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceChreturn snapshot(t, ref, s);});}-- async function transfer(viewer, input, { to, keep_access = false } = {}) {++ async function transfer(viewer, input, { to, keep_access = false, key_slot } = {}) {const ref = v.ref(input); if (typeof keep_access !== 'boolean') v.invalid();++ const slot = key_slot === undefined ? null : v.keySlot(key_slot);await authorize(viewer, ref);++ const lock = await storedLock(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);++ // The new owner must be able to open it: their slot comes with the offer, checked against their key now.++ if (lock === 'people') {++ if (!slot) v.fail(400, 'share.slot_required', 'A transfer of a people-locked item needs the recipient\'s key slot');++ await checkSlot(recipient, slot);++ } else if (slot) v.fail(400, 'share.key_slot_unexpected', 'Only a people-locked item carries key slots');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 tr = await t.one(`INSERT INTO share_transfers(id,service,kind,resource_id,from_subject,to_subject,keep_access,requested_at,expires_at,state,key_slot) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending',$10) RETURNING *`,++ [randomUUID(), ...key(ref), s.owner_subject, recipient, keep_access, timestamp(), iso(now() + WEEK), slot]);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 });@@@ -372,6 +513,8 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceChawait 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');++ // A people-locked item leaves the former owner without a slot until the new owner wraps++ // for them: the fresh content key is not theirs to hold.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 });}@@@ -1,4 +1,5 @@'use strict';++const { peopleSlotOk } = require('../locker/server');class ShareError extends Error {constructor(status, code, message) { super(message); this.name = 'ShareError'; this.status = status; this.code = code; }}@@@ -24,6 +25,23 @@ function ref(v) {return { service: v.service, kind: v.kind, id: v.id };}function audience(v) { if (!['public', 'link', 'people', 'private'].includes(v)) invalid(); return v; }++const LOCKS = ['none', 'people', 'password', 'link'];++function lock(v) { if (!LOCKS.includes(v)) invalid(); return v; }++/** A people slot: the shape, and the subject it names. The key check needs Network, so the store does it. */++function keySlot(v) {++ if (!peopleSlotOk(v) || Object.keys(v).some(k => !['subject', 'algorithm', 'recipient_key', 'ephemeral_public_key', 'salt', 'nonce', 'wrapped_key'].includes(k))) invalid();++ return { ...v, subject: subject(v.subject) };++}++/** The largest re-sealed envelope a save may carry: a whole content chunk plus room for its header. */++const MAX_ENVELOPE_BYTES = 8 * 1024 * 1024;++/** A base64 locker.envelope@1, as bytes. Its header is checked by the caller, which knows the lock. */++function envelope(v) {++ if (typeof v !== 'string' || !v.length || v.length > Math.ceil(MAX_ENVELOPE_BYTES / 3) * 4++ || !/^[A-Za-z0-9+/]+={0,2}$/.test(v)) invalid();++ const bytes = Buffer.from(v, 'base64');++ if (!bytes.length || bytes.length > MAX_ENVELOPE_BYTES) invalid();++ return bytes;++}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(/^@/, ''); }@@@ -39,4 +57,4 @@ function expiry(v, now) {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 };++module.exports = { ShareError, fail, invalid, hidden, object, subject, ref, audience, actor, equal, name, uuid, expiry, lock, LOCKS, keySlot, envelope, MAX_ENVELOPE_BYTES };@@@ -451,6 +451,27 @@ run([}), 'locker.invalid');}],++ ['a caller that holds the content key needs no private key', async () => {++ const contentKey = new Uint8Array(32).fill(9);++ const sealed = await locker.seal('a note', {++ lock: 'people',++ keys: [{ subject: A, publicKey: alice.ecdhPublic }],++ contentKey,++ });++ // An agent that sealed the item kept its key: it can open it, or re-seal it for the people who++ // stay, without the private key of a recipient.++ assert.equal(decoder.decode(await locker.open(sealed, { contentKey })), 'a note');++ const again = await locker.rewrap(sealed, {++ remove: [B],++ keys: [{ subject: A, publicKey: alice.ecdhPublic }],++ credentials: { contentKey },++ });++ assert.equal(decoder.decode(await locker.open(again, { privateKey: alice.ecdh.privateKey, subject: A })), 'a note');++ // The re-seal made a new content key: the one the caller kept opens the old envelope only.++ await assert.rejects(() => locker.open(again, { contentKey }), (err) => err.code === 'locker.wrong_key');++ await fails(() => locker.open(sealed, { contentKey: new Uint8Array(31) }), 'locker.invalid');++ }],++['every seal makes a new key and a new nonce base', async () => {const one = await locker.seal('the same note', { lock: 'password', password: 'x' });const two = await locker.seal('the same note', { lock: 'password', password: 'x' });nnew file mode 100644@@@ -0,0 +1,335 @@++'use strict';++/**++ * Locked items in sharing: the lock kind, the per-member key slots, the re-seal on removal, the++ * transfer offer, and the agent client that wraps and re-seals. Real P-256 keys and real people++ * slots, so a slot's thumbprint is checked the way a browser's would be.++ */++const assert = require('node:assert/strict');++const http = require('node:http');++const { createDb } = require('../src/db');++const { createShares, share } = require('../src/share');++const locker = require('../src/locker');++++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 SUBJECTS = { [U.id]: U, [V.id]: V, [X.id]: X };++const PEOPLE = {}, KEYS = {};++const contentKey = new Uint8Array(32).fill(7);++const base64 = bytes => Buffer.from(bytes).toString('base64');++const slotKey = key => key.recipient_key;++++const db = createDb({ pglite: true });++let time = Date.parse('2026-11-06T00:00:00Z'), seq = 0;++const hooks = [];++let resealDown = false;++const shares = createShares({++ db, now: () => time, nameLimit: 1000,++ network: { json: async 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 };++ }++ if (req.path === '/internal/identity/resolve') {++ const found = Object.values(PEOPLE).find(p => p.username.toLowerCase() === String(req.query.username).toLowerCase());++ return found ? { ...found } : null;++ }++ const keys = String(req.path).match(/^\/api\/v1\/people\/([^/]+)\/keys$/);++ if (keys) {++ const found = KEYS[decodeURIComponent(keys[1])];++ if (!found) throw Object.assign(new Error('no keys'), { status: 404 });++ return found;++ }++ throw new Error('unexpected network path ' + req.path);++ } },++ events: { publish: async () => {} },++ notify: async () => {},++ onReseal: async (ref, bytes, tx) => {++ // The member row must still be there while the service stores the re-seal.++ const left = await tx.one("SELECT count(*)::int AS n FROM share_members WHERE service=$1 AND kind=$2 AND resource_id=$3", [ref.service, ref.kind, ref.id]);++ hooks.push({ ref, bytes, members: Number(left.n) });++ if (resealDown) throw new Error('storage down');++ },++});++const fresh = async (lock = 'people') => {++ const ref = { service: 'community', kind: 'paste', id: `l${++seq}` };++ await shares.register(ref, { owner_subject: U, audience: 'people', lock });++ return ref;++};++const bad = (fn, status, code) => assert.rejects(fn, e => e.status === status && (!code || e.code === code));++/** A real people slot for that person's current key. */++const slotFor = id => locker.wrapFor(contentKey, KEYS[id].ecdh, { subject: SUBJECTS[id] });++/** A real people-locked envelope for whoever is named. */++const sealedFor = (ids, key = contentKey) => locker.seal('a note', { lock: 'people', keys: ids.map(id => ({ subject: SUBJECTS[id], publicKey: KEYS[id].ecdh })), contentKey: key });++const makePerson = async (subject, username) => {++ const person = await locker.generatePersonKeys();++ const published = await locker.exportPersonKeys(person);++ PEOPLE[subject.id] = { subject, username, display_name: username, avatar_url: null, banned: false };++ KEYS[subject.id] = { subject, ...published, updated_at: '2026-11-06T00:00:00Z' };++ return person;++};++const tests = [];++const test = (name, fn) => tests.push([name, fn]);++++(async () => {++ const keysOf = { u: await makePerson(U, 'ursula'), v: await makePerson(V, 'maticus'), x: await makePerson(X, 'xenia') };++ await shares.migrate();++++ test('a share records its lock, GET answers it, and it is fixed once registered', async () => {++ const ref = await fresh('people');++ assert.equal((await shares.get(owner, ref)).lock, 'people');++ await bad(async () => shares.register(ref, { owner_subject: U, audience: 'people', lock: 'none' }), 409, 'share.exists');++ await bad(async () => shares.register(ref, { owner_subject: U, audience: 'people', lock: 'secret' }), 400, 'share.invalid');++ const open = await fresh('none');++ assert.equal((await shares.get(owner, open)).lock, 'none');++ // A save may echo the lock, never change it.++ await bad(async () => shares.update(owner, ref, { lock: 'none' }), 409, 'share.lock_fixed');++ assert.equal((await shares.update(owner, ref, { lock: 'people', audience: 'people' })).lock, 'people');++ });++++ test('a locked item is never public, at registration or later', async () => {++ await bad(async () => shares.register({ service: 'community', kind: 'paste', id: `p${++seq}` },++ { owner_subject: U, audience: 'public', lock: 'people' }), 400, 'share.lock_public');++ const ref = await fresh('people');++ await bad(async () => shares.update(owner, ref, { audience: 'public' }), 400, 'share.lock_public');++ assert.equal((await shares.get(owner, ref)).audience, 'people');++ });++++ test('a people-locked item needs a slot for every member, for that person and that key', async () => {++ const ref = await fresh();++ const slot = await slotFor(V.id);++ await bad(async () => shares.addMembers(owner, ref, [{ subject: V, role: 'viewer' }]), 400, 'share.slot_required');++ const theirs = await slotFor(X.id);++ await bad(async () => shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: theirs }]), 400, 'share.invalid');++ // The person changes their key: the slot they handed over is for the key they no longer have.++ await makePerson(V, 'maticus');++ time += 61000;++ await bad(async () => shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: slot }]), 409, 'share.key_changed');++ KEYS[V.id] = { subject: V, ...(await locker.exportPersonKeys(keysOf.v)), updated_at: '2026-11-06T00:00:00Z' };++ time += 61000;++ const added = await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: slot }]);++ assert.deepEqual(added.members[0].key_slot, slot);++ assert.equal(await shares.canView(recipient, ref), true);++ });++++ test('a member with no locker key is refused, not assumed', async () => {++ const ref = await fresh();++ const slot = await slotFor(X.id);++ const kept = KEYS[X.id]; delete KEYS[X.id];++ try { await bad(async () => shares.addMembers(owner, ref, [{ subject: X, role: 'viewer', key_slot: slot }]), 409, 'share.no_key'); }++ // The miss is cached for a minute like a hit; let it expire so the next test sees the key again.++ finally { KEYS[X.id] = kept; time += 61000; }++ });++++ test('removing somebody is a re-seal refused without an envelope and stored before the member goes', async () => {++ const ref = await fresh();++ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]);++ await bad(async () => shares.update(owner, ref, { members: [] }), 400, 'share.reseal_required');++ await bad(async () => shares.update(owner, ref, { members: [], envelope: base64(Buffer.from('not an envelope')) }), 400, 'share.reseal_invalid');++ await bad(async () => shares.update(owner, ref, { members: [], envelope: base64((await sealedFor([U.id, V.id])).bytes) }), 400, 'share.reseal_invalid');++ assert.equal((await shares.get(owner, ref)).members.length, 1);++++ const before = hooks.length;++ const sealed = await sealedFor([U.id]);++ const view = await shares.update(owner, ref, { members: [], envelope: base64(sealed.bytes) });++ assert.equal(view.members.length, 0);++ const hook = hooks[before];++ assert.deepEqual(hook.ref, ref);++ assert.equal(hook.members, 1, 'the hook runs before the member row goes');++ assert.equal(Buffer.compare(Buffer.from(hook.bytes), Buffer.from(sealed.bytes)), 0);++ assert.equal(await shares.canView(recipient, ref), false);++ // The same through removeMember, which is the service-side call.++ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]);++ await bad(async () => shares.removeMember(owner, ref, V), 400, 'share.reseal_required');++ await shares.removeMember(owner, ref, V, { envelope: base64((await sealedFor([U.id])).bytes) });++ assert.equal((await shares.get(owner, ref)).members.length, 0);++ });++++ test('a re-seal that still holds the leaving member\'s slot is refused', async () => {++ const ref = await fresh();++ const slot = await slotFor(V.id);++ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: slot }]);++ // The envelope as it was: still holds V's slot.++ await bad(async () => shares.update(owner, ref, { members: [], envelope: base64((await sealedFor([U.id, V.id])).bytes) }), 400, 'share.reseal_invalid');++ assert.equal((await shares.get(owner, ref)).members.length, 1);++ });++++ test('a re-seal must be fresh: the envelope already stored is refused', async () => {++ const ref = await fresh();++ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]);++ const first = await sealedFor([U.id]);++ await shares.update(owner, ref, { members: [], envelope: base64(first.bytes) });++ // Somebody comes back, and the caller sends the envelope that is already stored: the content key++ // is the one the item had before, so the re-seal proves nothing.++ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]);++ await bad(async () => shares.update(owner, ref, { members: [], envelope: base64(first.bytes) }), 400, 'share.reseal_not_fresh');++ assert.equal((await shares.get(owner, ref)).members.length, 1);++ // A new seal has a new nonce base, and it goes through.++ const second = await sealedFor([U.id]);++ assert.notEqual(second.header.nonce_base, first.header.nonce_base);++ assert.equal((await shares.update(owner, ref, { members: [], envelope: base64(second.bytes) })).members.length, 0);++ });++++ test('a re-seal that wraps the old content key for a member who stays is refused', async () => {++ const ref = await fresh();++ // The stored envelope, and the member's slot is the one inside it (a member added for that very seal).++ const env = await sealedFor([U.id, V.id]);++ const fromEnvelope = env.header.slots.people.find(slot => slot.subject.id === V.id);++ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: fromEnvelope }]);++ await shares.addMembers(owner, ref, [{ subject: X, role: 'viewer', key_slot: await slotFor(X.id) }]);++ // A member with no stored slot (what keep_access leaves) can go without a leaver-slot check, so the++ // only thing left to catch is the wrap V keeps.++ await db.exec("UPDATE share_members SET key_slot=NULL WHERE service='community' AND kind='paste' AND resource_id=$1 AND subject->>'id'=$2", [ref.id, X.id]);++ await bad(async () => shares.update(owner, ref, { members: [{ subject: V, role: 'viewer', key_slot: fromEnvelope }], envelope: base64(env.bytes) }), 400, 'share.reseal_not_fresh');++ assert.equal((await shares.get(owner, ref)).members.length, 2);++ });++++ test('a transfer of a people-locked item needs the recipient\'s slot and drops the old owner', async () => {++ const ref = await fresh();++ await bad(async () => shares.transfer(owner, ref, { to: V }), 400, 'share.slot_required');++ const wrong = await slotFor(X.id);++ await bad(async () => shares.transfer(owner, ref, { to: V, key_slot: wrong }), 400, 'share.invalid');++ const offer = await shares.transfer(owner, ref, { to: V, key_slot: await slotFor(V.id) });++ assert.equal(offer.state, 'pending');++ assert.equal((await shares.accept(recipient, ref, offer.id)).state, 'accepted');++ const view = await shares.get(recipient, ref);++ assert.equal(view.owner_subject.id, V.id);++ assert.equal(view.members.length, 0);++ assert.equal(await shares.canView(owner, ref), false);++ // keep_access: the former owner stays a viewer, without a slot until the new owner wraps for them.++ const again = await fresh();++ const kept = await shares.transfer(owner, again, { to: V, keep_access: true, key_slot: await slotFor(V.id) });++ await shares.accept(recipient, again, kept.id);++ const keptView = await shares.get(recipient, again);++ assert.equal(keptView.members.length, 1);++ assert.equal(keptView.members[0].subject.id, U.id);++ assert.equal(keptView.members[0].key_slot, undefined);++ assert.equal(keptView.members[0].fingerprint_changed, true);++ assert.equal(await shares.canView(owner, again), true);++ });++++ test('GET answers each member\'s slot, and for the owner the fingerprint and whether the key changed', async () => {++ const ref = await fresh();++ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]);++ time += 61000;++ let view = await shares.get(owner, ref);++ assert.equal(slotKey(view.members[0].key_slot), view.members[0].key_slot.recipient_key);++ assert.equal(view.members[0].key_fingerprint, KEYS[V.id].fingerprint);++ assert.equal(view.members[0].fingerprint_changed, false);++ // Staff see the slot, not the owner's warning fields.++ const staff = await shares.get({ ...other, staff: true }, ref);++ assert.equal(staff.members[0].key_fingerprint, undefined);++ // The person changes their key: the old slot is still stored, and the sheet is told.++ KEYS[V.id] = { subject: V, ...(await locker.exportPersonKeys(await locker.generatePersonKeys())), updated_at: '2026-11-07T00:00:00Z' };++ time += 61000;++ view = await shares.get(owner, ref);++ assert.equal(view.members[0].fingerprint_changed, true);++ assert.equal(view.members[0].key_fingerprint, KEYS[V.id].fingerprint);++ KEYS[V.id] = { subject: V, ...(await locker.exportPersonKeys(keysOf.v)), updated_at: '2026-11-06T00:00:00Z' };++ time += 61000;++ });++++ test('an unlocked item behaves exactly as before and refuses slots', async () => {++ const ref = await fresh('none');++ await bad(async () => shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]), 400, 'share.key_slot_unexpected');++ await shares.addMembers(owner, ref, ['@maticus']);++ assert.equal((await shares.get(owner, ref)).members[0].key_slot, undefined);++ await shares.removeMember(owner, ref, V);++ assert.equal(await shares.canView(recipient, ref), false);++ const offer = await shares.transfer(owner, ref, { to: V });++ const surplus = await slotFor(V.id);++ await bad(async () => shares.transfer(owner, ref, { to: V, key_slot: surplus }), 400, 'share.key_slot_unexpected');++ await shares.accept(recipient, ref, offer.id);++ assert.equal((await shares.get(recipient, ref)).owner_subject.id, V.id);++ });++++ test('a service without the re-seal hook cannot remove somebody from a people-locked item', async () => {++ const ref = await fresh();++ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]);++ const hookless = createShares({ db, now: () => time, network: { json: async () => null }, events: { publish: async () => {} }, notify: async () => {} });++ await bad(async () => hookless.update(owner, ref, { members: [], envelope: base64((await sealedFor([U.id])).bytes) }), 409, 'share.reseal_unavailable');++ assert.equal((await shares.get(owner, ref)).members.length, 1);++ });++++ test('a failed re-seal store leaves the member and the old envelope in place', async () => {++ const ref = await fresh();++ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]);++ resealDown = true;++ try { await assert.rejects(async () => shares.update(owner, ref, { members: [], envelope: base64((await sealedFor([U.id])).bytes) }), /storage down/); }++ finally { resealDown = false; }++ assert.equal((await shares.get(owner, ref)).members.length, 1, 'a failed store leaves the member in place');++ });++++ test('HTTP: the lock cannot be overridden by describe, the slot travels, and the body may carry a re-seal', async () => {++ const handler = shares.router({ service: 'community', authenticate: req => ({ owner, recipient, other })[(req.headers.authorization || '').replace('Bearer ', '')] || null,++ describe: async () => ({ lock: 'none' }) });++ const server = http.createServer(handler); await new Promise(r => server.listen(0, '127.0.0.1', r));++ const ref = await fresh();++ const base = `http://127.0.0.1:${server.address().port}`, path = `/api/v1/paste/${ref.id}/share`;++ try {++ assert.equal((await shares.get(owner, ref)).lock, 'people');++ const res = await fetch(base + path, { headers: { Authorization: 'Bearer owner' } });++ assert.equal(res.status, 200);++ assert.equal((await res.json()).lock, 'people', 'describe cannot claim an item is unlocked');++ const put = (body, headers = {}) => fetch(base + path, { method: 'PUT', headers: { Authorization: 'Bearer owner', 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });++ const slot = await slotFor(V.id);++ assert.equal((await put({ members: [{ subject: V, role: 'viewer', key_slot: slot }] })).status, 200);++ assert.equal((await put({ members: [] })).status, 400);++ // A re-seal is bigger than an ACL edit and is allowed to be; anything else stays at 64 KiB.++ const big = await locker.seal('x'.repeat(90000), { lock: 'people', keys: [{ subject: U, publicKey: KEYS[U.id].ecdh }] });++ assert.ok(big.bytes.length > 65536);++ assert.equal((await put({ members: [], envelope: base64(big.bytes) })).status, 200);++ assert.equal((await put({ members: [], padding: 'x'.repeat(70000) })).status, 413);++ } finally { await new Promise(r => server.close(r)); }++ });++++ test('the agent client wraps for a name and re-seals on removal', async () => {++ const shareHandler = shares.router({ service: 'community', authenticate: req => ({ owner, recipient, other })[(req.headers.authorization || '').replace('Bearer ', '')] || null });++ const server = http.createServer((req, res) => {++ const path = new URL(req.url, 'http://x').pathname;++ const keys = path.match(/^\/api\/v1\/people\/([^/]+)\/keys$/);++ if (keys) {++ const found = KEYS[decodeURIComponent(keys[1])];++ if (!found) { res.writeHead(404); res.end('{}'); return; }++ res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(found)); return;++ }++ if (path === '/api/v1/share/people') {++ const name = new URL(req.url, 'http://x').searchParams.get('name').replace(/^@/, '').toLowerCase();++ const person = Object.values(PEOPLE).find(p => p.username === name);++ res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ person: person || null })); return;++ }++ shareHandler(req, res);++ });++ await new Promise(r => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ try {++ const ref = await fresh();++ const client = share({ base, network: base, token: 'owner' }).for('paste', ref.id);++ const added = await client.addLocked('@maticus', { contentKey });++ assert.equal(added.members.length, 1);++ assert.equal(added.members[0].username, 'maticus');++ assert.equal(slotKey(added.members[0].key_slot), (await slotFor(V.id)).recipient_key);++ // The item as it is stored: sealed for the owner and the member.++ const item = await sealedFor([U.id, V.id]);++ const removed = await client.removeLocked('@maticus', { contentKey, envelope: item });++ assert.equal(removed.members.length, 0);++ assert.equal(hooks.at(-1).ref.id, ref.id);++ const stored = hooks.at(-1).bytes;++ assert.notEqual(Buffer.compare(Buffer.from(stored), Buffer.from(item.bytes)), 0, 'a fresh content key, a new envelope');++ assert.deepEqual(locker.inspect(stored).recipients, [await locker.jwkThumbprint(KEYS[U.id].ecdh)]);++ assert.deepEqual([...new Uint8Array(await locker.open(stored, { privateKey: keysOf.u.ecdh.privateKey, subject: U }))], [...Buffer.from('a note')]);++ // And a caller that already re-sealed can hand it over instead.++ await client.addLocked('@maticus', { contentKey });++ const ready = await sealedFor([U.id]);++ const second = await client.removeLocked('@maticus', { envelope: ready });++ assert.equal(second.members.length, 0);++ assert.equal(Buffer.compare(Buffer.from(hooks.at(-1).bytes), Buffer.from(ready.bytes)), 0);++ } finally { await new Promise(r => server.close(r)); }++ });++++ let count = 0;++ try { for (const [name, fn] of tests) { await fn(); count++; console.log('ok ' + name); } console.log(`${count} locked-sharing tests passed`); }++ finally { await db.close(); }++})().catch(e => { console.error(e); process.exitCode = 1; });@@@ -29,3 +29,6 @@ export function inspectEnvelope(bytes: Uint8Array, options?: { maxHeaderBytes?:/** The header's shape: what it says, and whether the slots match the lock it names. */export function checkHeader(header: unknown): string[];++++/** One people slot's shape: whose it is, which key it names, and the bytes it must hold. */++export function peopleSlotOk(slot: unknown): boolean;@@@ -3,22 +3,37 @@ import type { SubjectRef } from './contracts';import type { IncomingMessage, ServerResponse } from 'node:http';export type Audience = 'public' | 'link' | 'people' | 'private';export type Role = 'viewer' | 'editor';++/** How an item is sealed (Locker). `none` is stored as it is. */++export type Lock = 'none' | 'people' | 'password' | 'link';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 }++/** The content key wrapped for one person (locker.people-slot@1). The server stores it, never opens it. */++export interface PeopleSlot {++ subject: SubjectRef;++ algorithm: 'ECDH-P256/HKDF-SHA256/AES-256-GCM';++ /** The RFC 7638 thumbprint of the recipient's ECDH key: what the store checks against the key directory. */++ recipient_key: string;++ ephemeral_public_key: { kty: 'EC'; crv: 'P-256'; x: string; y: string };++ salt: string;++ nonce: string;++ wrapped_key: string;++}++export type MemberInput = ({ name: string; subject?: never } | { subject: SubjectRef; name?: never }) & { role: Role; expires_at?: string; key_slot?: PeopleSlot };++export interface Member { subject: SubjectRef; role: Role; expires_at?: string; added_by: SubjectRef; added_at: string; key_slot?: PeopleSlot }++/** What the owner of a people-locked item sees per member: their current fingerprint, and whether it changed. */++export interface MemberKeyInfo { key_fingerprint?: string | null; fingerprint_changed?: boolean }/** 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 }++export interface Share { ref: ResourceRef; owner_subject: SubjectRef; audience: Audience; lock: Lock; 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>;++ members: Array<Member & Person & MemberKeyInfo>;transfer?: TransferView;you: You;requests: AccessRequestView[];@@@ -44,13 +59,19 @@ export interface ShareOptions {/** 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>;++ /**++ * Stores a re-sealed envelope (a `locker.envelope@1` for the people who stay) when somebody is++ * removed from a people-locked item. Called inside the transaction, before the member row goes.++ * A service that allows people locks must pass it; otherwise a removal answers 409 share.reseal_unavailable.++ */++ onReseal?(ref: ResourceRef, envelope: Uint8Array, 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>;++ register(ref: ResourceRef, input: { owner_subject: SubjectRef; audience?: Audience; lock?: Lock }): 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>;@@@ -59,13 +80,14 @@ export interface Shares {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>;++ update(viewer: Viewer, ref: ResourceRef, input: { audience?: Audience; members?: MemberInput[]; notify?: boolean; lock?: Lock; envelope?: string }, 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>;++ /** On a people-locked item, `envelope` is required: the content re-sealed for those who stay. */++ removeMember(viewer: Viewer, ref: ResourceRef, subject: SubjectRef, options?: { envelope?: string }): 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>;++ transfer(viewer: Viewer, ref: ResourceRef, input: { to: SubjectRef | string | { subject: SubjectRef } | { name: string }; keep_access?: boolean; key_slot?: PeopleSlot }): 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>;@@@ -80,17 +102,26 @@ 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>;++ update(input: { audience?: Audience; members?: MemberInput[]; notify?: boolean; lock?: Lock; envelope?: string }, 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>;++ /** A people-locked item: looks up their key, wraps the content key for it, saves the member and the slot. */++ addLocked(target: string | SubjectRef, options: { contentKey: Uint8Array | string; role?: Role; expires_at?: string }): Promise<ShareResponse>;++ /**++ * A people-locked item: removes a member with a re-seal. With `contentKey`, `envelope` is the item's++ * current envelope, opened and sealed again for the people who stay (the owner keeps their slot);++ * without it, `envelope` is the envelope already re-sealed. `keys` ({ subject, publicKey }[]) skips++ * the key-directory lookups.++ */++ removeLocked(target: string | SubjectRef, options: { contentKey?: Uint8Array | string; envelope?: Uint8Array | { bytes: Uint8Array }; keys?: Array<{ subject: SubjectRef; publicKey: unknown }> }): Promise<ShareResponse>;requestAccess(): Promise<AccessRequest>;dismiss(requestId: string): Promise<ShareResponse>;-- transfer(to: string | SubjectRef, options?: { keep_access?: boolean }): Promise<Transfer | ShareResponse>;++ transfer(to: string | SubjectRef, options?: { keep_access?: boolean; key_slot?: PeopleSlot }): 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 function share(options: { base: string; token?: string; fetch?: typeof globalThis.fetch; /** Where the key directory is; defaults to https://openvibe.network. */ network?: string }): { 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> };
@@ -1,5 +1,17 @@
@@ -1,5 +1,17 @@
# Changelog
# Changelog
+## 0.88.0 (2026-11-06)
+
+- `openvibe-sdk/share` learns locked items (Locker). A share records how its item is sealed — `lock: none | people | password | link`, set by `register(ref, { lock })` and answered by GET — and `describe()` cannot override it. A locked item is never public (`share.lock_public`), and the lock cannot change after registration (`share.lock_fixed`). Expand-only migration: `shares.lock`, `share_members.key_slot`, `share_transfers.key_slot`.
+- A people-locked item carries each member's key slot (`locker.people-slot@1`). `addMembers`/`update` refuse a member without a slot (`share.slot_required`), refuse a slot for another subject or key, and answer `share.key_changed` (409) when the slot's `recipient_key` is not the RFC 7638 thumbprint of that person's key in Network's key directory now (`GET /api/v1/people/:subject/keys`, cached a minute). A person with no locker yet is `share.no_key`.
+- Removing somebody from a people-locked item is a re-seal: the save carries `envelope` (base64 of a people-locked `locker.envelope@1`), or `removeMember(..., { envelope })`. The envelope must not still hold the leaving member's slot (`share.reseal_invalid`), and it is stored through the new `onReseal(ref, envelopeBytes, tx)` hook inside the transaction, before the member row goes; a hook error rolls everything back. Without an envelope: `share.reseal_required`; without the hook: `share.reseal_unavailable`. A save that carries an envelope may be up to 8 MiB; every other body stays at 64 KiB.
+- A re-seal must be fresh (`share.reseal_not_fresh`): its nonce base may not repeat the one the module stored last, and a staying member's slot may not be byte for byte the stored one. Either means the envelope already stored was sent again, so the person leaving could still open what is served next. The last nonce base is kept in `shares.reseal_nonce_base` (expand-only).
+- A transfer of a people-locked item carries the recipient's `key_slot` with the offer (required, checked like a member slot); accepting drops the former owner's membership unless `keep_access`.
+- GET for a people-locked item answers each member's `key_slot`, and for the owner each member's `key_fingerprint` and `fingerprint_changed`, so the sheet can warn and re-wrap.
+- The agent client gains `addLocked('@name', { contentKey })` (looks up the person's key, wraps, saves the member and the slot) and `removeLocked('@name', { contentKey | envelope })` (re-seals for the owner and the members who stay, then removes); `share({ base, token, network })` names the key directory. `transfer(to, { key_slot })` passes the recipient's slot.
+- `openvibe-sdk/locker`: `open`/`rewrap` accept `{ contentKey }`, so an agent that sealed an item can open or re-seal it without a private key. `openvibe-sdk/locker/server` exports `peopleSlotOk`.
+- Pins openvibe-contracts v0.296.0 (was v0.292.0): `[email protected]`, `[email protected]_slot` and `[email protected]_fingerprint` are the shapes the locked sharing uses.
+
## 0.87.0 (2026-11-06)
## 0.87.0 (2026-11-06)
- **The 1.0 core surface is frozen now.** `core`, `auth`, `events`, `db` and `media` are stable before 1.0:
- **The 1.0 core surface is frozen now.** `core`, `auth`, `events`, `db` and `media` are stable before 1.0:
@@ -2,16 +2,16 @@
@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.87.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; 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.88.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; 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.
**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.
**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.
**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.87.0 is tagged:
+After v0.88.0 is tagged:
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.87.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.88.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 the temporary Contracts sharing branch, 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).
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.87.0",
+ "version": "0.88.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), \u00a73.2; roadmap \u00a730",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), \u00a73.2; roadmap \u00a730",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
"features": [
"features": [
"locker (openvibe-sdk/locker, openvibe-sdk/locker/server): the Locker envelope (locker.envelope@1) for end-to-end encryption, WebCrypto only, in Node and the browser: seal/open for text and bytes, sealStream/openStream, the three locks (people, password, link), person key pairs (ECDH P-256, ECDSA P-256), the eight-word fingerprint, wrapFor and rewrap, recoveryWords for the phrase to write down, opening a people lock by key, inspect for what an envelope says about itself, and the header checks a service runs without a key",
"locker (openvibe-sdk/locker, openvibe-sdk/locker/server): the Locker envelope (locker.envelope@1) for end-to-end encryption, WebCrypto only, in Node and the browser: seal/open for text and bytes, sealStream/openStream, the three locks (people, password, link), person key pairs (ECDH P-256, ECDSA P-256), the eight-word fingerprint, wrapFor and rewrap, recoveryWords for the phrase to write down, opening a people lock by key, inspect for what an envelope says about itself, and the header checks a service runs without a key",
"the 1.0 core surface is frozen now, before 1.0 (core, auth, events, db, media): docs/one-point-oh.json gives every export its tier, test/surface.test.js pins the exact names, and removing or renaming one needs a major release; createCodeVerifier and pkceChallenge left in 0.86.0",
"the 1.0 core surface is frozen now, before 1.0 (core, auth, events, db, media): docs/one-point-oh.json gives every export its tier, test/surface.test.js pins the exact names, and removing or renaming one needs a major release; createCodeVerifier and pkceChallenge left in 0.86.0",
- "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",
+ "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; locked items (Locker): the lock kind, per-member key slots checked against the key directory, a re-seal on removal through onReseal, and the transfer offer slot",
"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)",
"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",
"core client: retries, timeouts, problem+json errors, trace propagation",
"auth: service and app tokens, PKCE, offline verification, the Network signing keys (createNetworkKeys), revocation cutoffs",
"auth: service and app tokens, PKCE, offline verification, the Network signing keys (createNetworkKeys), revocation cutoffs",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.87.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.88.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, locker, 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.
// (auth, locker, 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 */
/* eslint-disable */
@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.87.0';
+const SDK_VERSION = '0.88.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -1889,6 +1889,15 @@ function readCore(header) {
@@ -1889,6 +1889,15 @@ function readCore(header) {
/** The content key out of the header, with the credentials the reader has. */
/** The content key out of the header, with the credentials the reader has. */
async function openContentKey(header, credentials = {}) {
async function openContentKey(header, credentials = {}) {
+ // A caller that still holds the content key needs no private key: an agent that sealed the item
+ // can open it, or wrap it for the people who stay, with the key it kept. A string is base64url,
+ // as the link key travels.
+ if (credentials.contentKey) {
+ const given = typeof credentials.contentKey === 'string'
+ ? fromBase64url(credentials.contentKey) : toBytes(credentials.contentKey);
+ if (given.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes');
+ return given;
+ }
if (header.lock === 'people') {
if (header.lock === 'people') {
// A person may be named by their key instead of their subject: the slot says which key
// A person may be named by their key instead of their subject: the slot says which key
// it is for, so the reader does not need to know who they are.
// it is for, so the reader does not need to know who they are.
@@ -664,7 +664,8 @@
@@ -664,7 +664,8 @@
},
},
"openvibe-sdk/locker/server": {
"openvibe-sdk/locker/server": {
"checkHeader": "function",
"checkHeader": "function",
- "inspectEnvelope": "function"
+ "inspectEnvelope": "function",
+ "peopleSlotOk": "function"
},
},
"openvibe-sdk/locker/vectors": {
"openvibe-sdk/locker/vectors": {
"CASES": "array",
"CASES": "array",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
# Network-wide sharing (Node)
# 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).
+`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.296.0 (share.v1, with the Locker lock kind and the member key slots).
```js
```js
const { createShares, sharesSchema, shareOpenApi } = require('openvibe-sdk/share');
const { createShares, sharesSchema, shareOpenApi } = require('openvibe-sdk/share');
@@ -11,6 +11,7 @@ const shares = createShares({
@@ -11,6 +11,7 @@ const shares = createShares({
notify, // async notification => Network delivery adapter
notify, // async notification => Network delivery adapter
onOwnerChange: async (ref, from, to, tx) => { /* update this service's resource using tx */ },
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 */ },
onAudienceChange: async (ref, audience, tx) => { /* update visibility and search projection using tx */ },
+ onReseal: async (ref, envelope, tx) => { /* store the re-sealed locker envelope using tx */ },
});
});
// Include sharesSchema() in an expand migration run by the migration role.
// 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 shares.migrate() is a convenience for tests or that same migration role.
@@ -34,19 +35,19 @@ The migration creates `shares`, `share_members`, `share_transfers`, `share_acces
@@ -34,19 +35,19 @@ The migration creates `shares`, `share_members`, `share_transfers`, `share_acces
| Server method | Result |
| Server method | Result |
| --- | --- |
| --- | --- |
-| `register(ref, { owner_subject, audience = 'private' })` | Share; trusted service-only initialization, never an HTTP operation |
+| `register(ref, { owner_subject, audience = 'private', lock = 'none' })` | Share; trusted service-only initialization, never an HTTP operation. `lock` is how the item is sealed (`locker.envelope@1`); a locked item cannot be public |
| `canView(viewer, ref)`, `canEdit(viewer, ref)` | boolean; missing resources deny. A plain snapshot: page views never take a row lock |
| `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 |
| `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 }` |
| `getWithRevision(viewer, ref)` | `{ value: ShareView, etag }` |
| `present(viewer, ref, share)` | enrich a raw share with person projections and the caller's role |
| `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 |
| `lookup(viewer, '@name')` | `{ person }` or `{ person: null }`, on the same rate-limit budget as name resolution |
| `setAudience(viewer, ref, audience)` | Share |
| `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 |
+| `addMembers(viewer, ref, ['@name' \| subject \| { name \| subject, role, expires_at?, key_slot? }])` | Share; exact names or canonical active user subjects |
+| `removeMember(viewer, ref, subject, { envelope }?)` | Share; on a people-locked item `envelope` (base64) is required |
+| `update(viewer, ref, { audience?, members?, notify?, lock?, envelope? }, { ifMatch? }?)` | Share; atomically replaces members when provided. `notify: false` suppresses `share.added` for that save only. `lock` may only echo the registered value; `envelope` stores a re-seal |
| `requestAccess(viewer, ref)` | AccessRequest; deduplicated pending request and owner notification |
| `requestAccess(viewer, ref)` | AccessRequest; deduplicated pending request and owner notification |
| `dismiss(viewer, ref, requestId)` | Share; owner sets a pending request to `dismissed` |
| `dismiss(viewer, ref, requestId)` | Share; owner sets a pending request to `dismissed` |
-| `transfer(viewer, ref, { to: '@name' \| subject \| { subject } \| { name }, keep_access = false })` | Transfer |
+| `transfer(viewer, ref, { to: '@name' \| subject \| { subject } \| { name }, keep_access = false, key_slot? })` | Transfer; `key_slot` is required on a people-locked item |
| `accept(viewer, ref, transferId)`, `decline(...)`, `cancel(...)` | Transfer |
| `accept(viewer, ref, transferId)`, `decline(...)`, `cancel(...)` | Transfer |
| `sharedWithMe(viewer, { service?, limit = 50, cursor? }?)` | `{ items, next_cursor }`; ids and access metadata only |
| `sharedWithMe(viewer, { service?, limit = 50, cursor? }?)` | `{ items, next_cursor }`; ids and access metadata only |
| `expireMembers({ limit = 100 }?)` | number of resources swept, including expired transfers |
| `expireMembers({ limit = 100 }?)` | number of resources swept, including expired transfers |
@@ -71,6 +72,38 @@ The five Events payloads are `share.granted`, `share.revoked`, `share.transfer.r
@@ -71,6 +72,38 @@ The five Events payloads are `share.granted`, `share.revoked`, `share.transfer.r
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.
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.
+## Locked items (Locker)
+
+Sharing decides who may fetch a thing; `locker.envelope@1` decides who can read it. A share records how its item is sealed (`lock: none | people | password | link`) and the module stores the slots, never a content key. Locked items (`lock !== 'none'`) can never be public: `register` and `update` refuse `audience: 'public'` with `400 share.lock_public`, and `lock` is fixed once registered (`409 share.lock_fixed`; a save may echo it).
+
+For a **people** lock each member carries `key_slot`, the content key wrapped for that member's public key (`locker.people-slot@1`). The browser, or an agent with `openvibe-sdk/locker`, makes the slot; the server only checks it:
+
+* a member without a slot is refused: `400 share.slot_required`;
+* a slot is refused when it names another subject (`400 share.invalid`) or a key other than the one Network's key directory has for that person now (`GET /api/v1/people/:subject/keys`, through the same `network` client; `409 share.key_changed`);
+* a person with no locker yet is `409 share.no_key`.
+
+Removing a member is a re-seal, not a row deletion: the caller sends the content sealed again for the people who stay (`envelope`, base64 of a people-locked `locker.envelope@1`) with the members that remain, or as `removeMember(..., { envelope })`. The envelope must not still hold the leaving member's slot (`400 share.reseal_invalid`), and it is stored through `onReseal(ref, envelopeBytes, tx)` **inside** the transaction and before the member row goes. Without an envelope the save is `400 share.reseal_required`; without the hook, `409 share.reseal_unavailable`. A hook error rolls everything back, member included. An expired member is revoked by the sweep without a re-seal: access stops, and the service should re-seal when it can.
+
+A re-seal must also be **fresh** (`400 share.reseal_not_fresh`): the envelope may not repeat the nonce base of the one this module stored last, and a member who stays may not keep a slot that is byte for byte the stored one. Either means the envelope already stored came back, so the person leaving could still open what is served next. The header carries no key id, so the check is the per-seal nonce base plus the wraps, which is what a freshly sealed envelope always changes; it does not prove what key a caller sealed with.
+
+A transfer of a people-locked item carries the recipient's slot with the offer (`key_slot`; `400 share.slot_required`, checked like any member slot). Accepting removes the former owner's membership unless `keep_access`; a former owner who keeps access starts without a slot, because the fresh content key is the new owner's to wrap for them.
+
+GET for a people-locked item answers each member's `key_slot`, and for the owner each member's `key_fingerprint` (from the key directory) and `fingerprint_changed` — true when the key the slot was made for is not the person's key now — so the sheet can warn and re-wrap. `describe()` cannot override `lock`. The body limit stays 64 KiB except for a save that carries an envelope (up to 8 MiB).
+
+```js
+const sdk = require('openvibe-sdk');
+const locker = require('openvibe-sdk/locker');
+const item = sdk.share({ base: 'https://community.openvibe.network', token }).for('paste', id);
+const contentKey = crypto.getRandomValues(new Uint8Array(32)); // keep it
+const sealed = await locker.seal('a note', { lock: 'people', contentKey, keys: [{ subject: me, publicKey: myKey }] });
+await item.setAudience('people');
+await item.addLocked('@maticus', { contentKey }); // looks up his key, wraps, saves both
+await item.removeLocked('@maticus', { contentKey, envelope: sealed }); // re-seals for those who stay, removes
+await item.transfer('@xenia', { key_slot: await locker.wrapFor(contentKey, xeniaKey, { subject: xenia }) });
+```
+
+`removeLocked(target, { envelope })` also accepts an envelope that the caller has already re-sealed; with `contentKey` the envelope is opened and sealed again for the owner and the members who stay (their public keys come from the directory, or pass `keys`).
+
```js
```js
const sdk = require('openvibe-sdk');
const sdk = require('openvibe-sdk');
const item = sdk.share({ base: 'https://community.openvibe.network', token }).for('paste', id);
const item = sdk.share({ base: 'https://community.openvibe.network', token }).for('paste', id);
@@ -2,6 +2,6 @@
@@ -2,6 +2,6 @@
import cjs from '../src/locker/server.js';
import cjs from '../src/locker/server.js';
export const {
export const {
- inspectEnvelope, checkHeader,
+ inspectEnvelope, checkHeader, peopleSlotOk,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.87.0",
+ "version": "0.88.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.87.0",
+ "version": "0.88.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.292.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.296.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -198,9 +198,9 @@
@@ -198,9 +198,9 @@
"license": "MIT"
"license": "MIT"
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.292.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.292.0",
- "integrity": "sha512-zJwkQHzMJ5/SyLzujbIGOOOtRSwILwe9oQo1INb3sFnyB7KDoYKAhAgGFLs7Y7ooQYlO+Bo+Q6GlHfyfe858ew==",
+ "version": "0.296.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.296.0",
+ "integrity": "sha512-+irrEq65eTSkMp3aHt2a7Wuxo689QBrUpgdhkZGBtU4owrsmS3VlyWVBqmy4ZAprqJ1rQJKqpW7n3RlgZ1KKBw==",
"dev": true,
"dev": true,
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.87.0",
+ "version": "0.88.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.",
"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",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -514,7 +514,7 @@
@@ -514,7 +514,7 @@
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.292.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.296.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"peerDependencies": {
"peerDependencies": {
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.87.0';
+const SDK_VERSION = '0.88.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -232,6 +232,15 @@ function readCore(header) {
@@ -232,6 +232,15 @@ function readCore(header) {
/** The content key out of the header, with the credentials the reader has. */
/** The content key out of the header, with the credentials the reader has. */
async function openContentKey(header, credentials = {}) {
async function openContentKey(header, credentials = {}) {
+ // A caller that still holds the content key needs no private key: an agent that sealed the item
+ // can open it, or wrap it for the people who stay, with the key it kept. A string is base64url,
+ // as the link key travels.
+ if (credentials.contentKey) {
+ const given = typeof credentials.contentKey === 'string'
+ ? fromBase64url(credentials.contentKey) : toBytes(credentials.contentKey);
+ if (given.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes');
+ return given;
+ }
if (header.lock === 'people') {
if (header.lock === 'people') {
// A person may be named by their key instead of their subject: the slot says which key
// A person may be named by their key instead of their subject: the slot says which key
// it is for, so the reader does not need to know who they are.
// it is for, so the reader does not need to know who they are.
@@ -153,4 +153,4 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
@@ -153,4 +153,4 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
};
};
}
}
-module.exports = { inspectEnvelope, checkHeader };
+module.exports = { inspectEnvelope, checkHeader, peopleSlotOk };
@@ -1,26 +1,56 @@
@@ -1,26 +1,56 @@
'use strict';
'use strict';
const { createClient } = require('../core');
const { createClient } = require('../core');
+const locker = require('../locker');
const v = require('./validation');
const v = require('./validation');
+/** Network is where a person's public keys live (locker.public-keys@1). */
+const DEFAULT_NETWORK = 'https://openvibe.network';
+const bytesOf = value => (value instanceof Uint8Array ? value : value && value.bytes);
+const base64Of = value => Buffer.from(bytesOf(value)).toString('base64');
+/** A content key: 32 bytes, as bytes or base64url (as a link key travels). */
+function contentKeyOf(value) {
+ if (!value) throw new TypeError('A locked member needs the item\'s content key');
+ const bytes = typeof value === 'string'
+ ? Buffer.from(value.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
+ : bytesOf(value);
+ if (!bytes || bytes.length !== 32) throw new TypeError('A content key is 32 bytes, as bytes or base64url');
+ return bytes;
+}
+/** The member fields a save echoes back: the slot travels with its member or the server refuses it. */
+const echo = ({ subject, role, expires_at, key_slot }) => ({ subject, role, ...(expires_at ? { expires_at } : {}), ...(key_slot ? { key_slot } : {}) });
/** Agent client: optimistic PUT preserves members added by another caller. */
/** Agent client: optimistic PUT preserves members added by another caller. */
-function share({ base, token, fetch } = {}) {
+function share({ base, token, fetch, network = DEFAULT_NETWORK } = {}) {
if (typeof base !== 'string' || !/^https?:\/\//.test(base)) throw new TypeError('share requires base');
if (typeof base !== 'string' || !/^https?:\/\//.test(base)) throw new TypeError('share requires base');
+ if (typeof network !== 'string' || !/^https?:\/\//.test(network)) throw new TypeError('share network must be a URL');
const client = createClient({ token, fetch, retries: 0, autoDiscover: false });
const client = createClient({ token, fetch, retries: 0, autoDiscover: false });
const call = (path, options = {}) => client.json({ baseUrl: base, path, ...options });
const call = (path, options = {}) => client.json({ baseUrl: base, path, ...options });
async function read(path) {
async function read(path) {
const response = await client.request({ baseUrl: base, path });
const response = await client.request({ baseUrl: base, path });
return { value: response.data, etag: response.headers.get('etag') };
return { value: response.data, etag: response.headers.get('etag') };
}
}
+ /** A person's public keys, as anyone may read them. */
+ async function keysFor(subject) {
+ const found = await client.json({ baseUrl: network, path: `/api/v1/people/${encodeURIComponent(subject.id)}/keys` });
+ if (!found || !found.ecdh) throw new Error('That person has no locker key yet');
+ return found;
+ }
+ /** An @name or a subject object, as the canonical subject. */
+ async function subjectOf(target) {
+ if (typeof target !== 'string') return v.subject(target);
+ const { person } = await call('/api/v1/share/people', { query: { name: target } });
+ if (!person) throw new Error(`No OpenVibe account is called @${target.replace(/^@/, '')}`);
+ return person.subject;
+ }
return {
return {
sharedWithMe: options => call('/api/v1/shared-with-me', { query: options }),
sharedWithMe: options => call('/api/v1/shared-with-me', { query: options }),
people: name => call('/api/v1/share/people', { query: { name } }),
people: name => call('/api/v1/share/people', { query: { name } }),
for(kind, id) {
for(kind, id) {
v.ref({ service: 'client', kind, id });
v.ref({ service: 'client', kind, id });
const path = `/api/v1/${encodeURIComponent(kind)}/${encodeURIComponent(id)}/share`;
const path = `/api/v1/${encodeURIComponent(kind)}/${encodeURIComponent(id)}/share`;
- const updateMembers = async fn => {
+ const updateMembers = async (fn, { envelope } = {}) => {
const { value, etag } = await read(path);
const { value, etag } = await read(path);
if (!etag) throw new Error('Sharing server did not provide an ETag');
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 call(path, { method: 'PUT', headers: { 'If-Match': etag },
+ json: { members: fn((value.members || []).map(echo)), ...(envelope ? { envelope: base64Of(envelope) } : {}) } });
};
};
return {
return {
get: () => call(path),
get: () => call(path),
@@ -31,9 +61,41 @@ function share({ base, token, fetch } = {}) {
@@ -31,9 +61,41 @@ function share({ base, token, fetch } = {}) {
return [...members.filter(m => typeof target === 'string' || !v.equal(m.subject, target)), member];
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))),
remove: subject => updateMembers(members => members.filter(m => !v.equal(m.subject, subject))),
+ /** Adds a member to a people-locked item: looks up their key, wraps the content key for it, saves both. */
+ addLocked: async (target, { contentKey, role = 'viewer', expires_at } = {}) => {
+ const subject = await subjectOf(target);
+ const found = await keysFor(subject);
+ const key_slot = await locker.wrapFor(contentKeyOf(contentKey), found.ecdh, { subject });
+ return updateMembers(members => [...members.filter(m => !v.equal(m.subject, subject)),
+ { subject, role, ...(expires_at ? { expires_at } : {}), key_slot }]);
+ },
+ /**
+ * Removes a member from a people-locked item with a re-seal. `envelope` is either the content
+ * already sealed for those who stay, or — with `contentKey` — the item's current envelope, which
+ * is opened and sealed again without that person.
+ */
+ removeLocked: async (target, { contentKey, envelope, keys } = {}) => {
+ const subject = await subjectOf(target);
+ const { value, etag } = await read(path);
+ if (!etag) throw new Error('Sharing server did not provide an ETag');
+ const staying = (value.members || []).map(echo).filter(m => !v.equal(m.subject, subject));
+ let sealed = envelope ? bytesOf(envelope) : null;
+ if (contentKey) {
+ if (!sealed) throw new TypeError('removeLocked needs the item\'s current envelope alongside the content key');
+ // The owner keeps their slot: they are not a member, and their key is not in the member list.
+ const recipients = keys || await Promise.all([value.owner_subject, ...staying.map(m => m.subject)]
+ .filter((s, i, all) => all.findIndex(x => v.equal(x, s)) === i)
+ .map(async s => ({ subject: s, publicKey: (await keysFor(s)).ecdh })));
+ const wrapped = await locker.rewrap(sealed, { remove: [subject], keys: recipients,
+ credentials: { contentKey: contentKeyOf(contentKey) } });
+ sealed = wrapped.bytes;
+ }
+ if (!sealed) throw new TypeError('removeLocked needs a re-sealed envelope, or the content key and the current envelope');
+ return call(path, { method: 'PUT', headers: { 'If-Match': etag }, json: { members: staying, envelope: base64Of(sealed) } });
+ },
requestAccess: () => call(path + '/requests', { method: 'POST', json: {} }),
requestAccess: () => call(path + '/requests', { method: 'POST', json: {} }),
dismiss: request_id => call(path + '/requests/' + encodeURIComponent(request_id) + '/dismiss', { 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 } }),
+ transfer: (to, { keep_access = false, key_slot } = {}) => call(path + '/transfer', { method: 'POST', json: { to, keep_access, ...(key_slot ? { key_slot } : {}) } }),
accept: transfer_id => call(path + '/transfer/accept', { method: 'POST', json: { transfer_id } }),
accept: transfer_id => call(path + '/transfer/accept', { method: 'POST', json: { transfer_id } }),
decline: transfer_id => call(path + '/transfer/decline', { 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 } }),
cancel: transfer_id => call(path + '/transfer/cancel', { method: 'POST', json: { transfer_id } }),
@@ -5,9 +5,11 @@ function shareOpenApi() {
@@ -5,9 +5,11 @@ function shareOpenApi() {
const response = name => ({ description: 'Sharing result (ids and access metadata only)', content: { 'application/json': { schema: schema(name) } } });
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: [] }],
const op = (id, result, request, status = '200') => ({ operationId: id, security: [{ bearerAuth: [] }],
...(request ? { requestBody: { required: true, content: { 'application/json': { schema: schema(request) } } } } : {}),
...(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' } } });
+ responses: { [status]: response(result), '400': { description: 'Invalid request or unknown exact name (also share.slot_required, share.reseal_required, share.reseal_invalid, share.lock_public)' },
+ '403': { description: 'Cross-site write refused (share.csrf)' },
+ '404': { description: 'Not found (also unauthorized, no resource metadata)' },
+ '409': { description: 'Inactive or conflicting transfer, a member key that changed (share.key_changed), a member with no locker key (share.no_key), a fixed lock (share.lock_fixed) or no re-seal storage (share.reseal_unavailable)' },
+ '412': { description: 'If-Match revision is stale' }, '413': { description: 'Request too large (share.too_large)' }, '429': { description: 'Name lookup rate limit' } } });
const parameters = ['kind', 'id'].map(name => ({ in: 'path', name, required: true, schema: { type: 'string' } }));
const parameters = ['kind', 'id'].map(name => ({ in: 'path', name, required: true, schema: { type: 'string' } }));
const base = '/api/v1/{kind}/{id}/share';
const base = '/api/v1/{kind}/{id}/share';
const paths = {
const paths = {
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
'use strict';
'use strict';
const v = require('./validation');
const v = require('./validation');
+const { MAX_ENVELOPE_BYTES } = v;
function send(res, status, body, headers = {}) {
function send(res, status, body, headers = {}) {
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'private, no-store', Vary: 'Authorization, Cookie', ...headers });
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'private, no-store', Vary: 'Authorization, Cookie', ...headers });
res.end(JSON.stringify(body));
res.end(JSON.stringify(body));
@@ -7,16 +8,25 @@ function send(res, status, body, headers = {}) {
@@ -7,16 +8,25 @@ function send(res, status, body, headers = {}) {
async function body(req) {
async function body(req) {
if (!(req.headers['content-type'] || '').toLowerCase().startsWith('application/json')) v.fail(415, 'share.json_required', 'JSON required');
if (!(req.headers['content-type'] || '').toLowerCase().startsWith('application/json')) v.fail(415, 'share.json_required', 'JSON required');
if (req.body !== undefined) {
if (req.body !== undefined) {
- if (Buffer.byteLength(JSON.stringify(req.body)) > 65536) v.fail(413, 'share.too_large', 'Request too large');
+ if (Buffer.byteLength(JSON.stringify(req.body)) > MAX_BODY(req.body)) v.fail(413, 'share.too_large', 'Request too large');
return req.body;
return req.body;
}
}
const chunks = []; let size = 0;
const chunks = []; let size = 0;
+ // A save may carry a re-sealed envelope, so it may be larger than an ACL edit; nothing else may.
for await (const chunk of req) {
for await (const chunk of req) {
size += chunk.length;
size += chunk.length;
- if (size > 65536) v.fail(413, 'share.too_large', 'Request too large');
+ if (size > MAX_ENVELOPE_BYTES + 65536) v.fail(413, 'share.too_large', 'Request too large');
chunks.push(chunk);
chunks.push(chunk);
}
}
- try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { v.invalid(); }
+ const raw = Buffer.concat(chunks);
+ let parsed;
+ try { parsed = JSON.parse(raw.toString('utf8')); } catch { v.invalid(); }
+ if (raw.length > MAX_BODY(parsed)) v.fail(413, 'share.too_large', 'Request too large');
+ return parsed;
+}
+/** 64 KiB, unless the save carries a re-sealed envelope (bounded by v.envelope). */
+function MAX_BODY(json) {
+ return json && typeof json.envelope === 'string' ? MAX_ENVELOPE_BYTES + 65536 : 65536;
}
}
/**
/**
* Node/Express middleware. authenticate must use verified server credentials. describe(ref) may add { title, link,
* Node/Express middleware. authenticate must use verified server credentials. describe(ref) may add { title, link,
@@ -60,7 +70,10 @@ function router(shares, { service, authenticate, describe, csrf } = {}) {
@@ -60,7 +70,10 @@ function router(shares, { service, authenticate, describe, csrf } = {}) {
const ref = v.ref({ service, kind, id }), rest = match[3];
const ref = v.ref({ service, kind, id }), rest = match[3];
const view = async (status = 200) => {
const view = async (status = 200) => {
const result = await shares.getWithRevision(viewer, ref);
const result = await shares.getWithRevision(viewer, ref);
- return send(res, status, { ...result.value, ...(await detail(ref)) }, { ETag: result.etag });
+ const extra = await detail(ref);
+ // How an item is sealed is the store's to answer, never a service's describe().
+ delete extra.lock;
+ return send(res, status, { ...result.value, ...extra, lock: result.value.lock }, { ETag: result.etag });
};
};
if (!rest && req.method === 'GET') return await view();
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 (!rest && req.method === 'PUT') { await shares.update(viewer, ref, await body(req), { ifMatch: req.headers['if-match'] }); return await view(); }
@@ -74,7 +87,7 @@ function router(shares, { service, authenticate, describe, csrf } = {}) {
@@ -74,7 +87,7 @@ function router(shares, { service, authenticate, describe, csrf } = {}) {
await shares.dismiss(viewer, ref, requestId);
await shares.dismiss(viewer, ref, requestId);
return await view();
return await view();
}
}
- if (rest === 'transfer') { v.object(input, ['to', 'keep_access']); await shares.transfer(viewer, ref, input); return await view(201); }
+ if (rest === 'transfer') { v.object(input, ['to', 'keep_access', 'key_slot']); await shares.transfer(viewer, ref, input); return await view(201); }
const answer = rest.match(/^transfer\/(accept|decline|cancel)$/);
const answer = rest.match(/^transfer\/(accept|decline|cancel)$/);
if (answer) {
if (answer) {
v.object(input, ['transfer_id']); v.uuid(input.transfer_id);
v.object(input, ['transfer_id']); v.uuid(input.transfer_id);
@@ -6,12 +6,15 @@ CREATE TABLE IF NOT EXISTS shares (
@@ -6,12 +6,15 @@ CREATE TABLE IF NOT EXISTS shares (
service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
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')),
owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
revision integer NOT NULL DEFAULT 1,
revision integer NOT NULL DEFAULT 1,
+ lock text NOT NULL DEFAULT 'none',
+ reseal_nonce_base text,
PRIMARY KEY (service, kind, resource_id)
PRIMARY KEY (service, kind, resource_id)
);
);
CREATE TABLE IF NOT EXISTS share_members (
CREATE TABLE IF NOT EXISTS share_members (
service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
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')),
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,
expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
+ key_slot jsonb,
PRIMARY KEY (service, kind, resource_id, subject),
PRIMARY KEY (service, kind, resource_id, subject),
FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
);
);
@@ -20,6 +23,7 @@ CREATE TABLE IF NOT EXISTS share_transfers (
@@ -20,6 +23,7 @@ CREATE TABLE IF NOT EXISTS share_transfers (
from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
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,
requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
+ key_slot jsonb,
FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
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 UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
@@ -54,6 +58,15 @@ CREATE TABLE IF NOT EXISTS share_name_limits (
@@ -54,6 +58,15 @@ CREATE TABLE IF NOT EXISTS share_name_limits (
CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
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_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;
CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
+-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
+-- slot a transfer was offered with. Repeating this migration is a no-op.
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
+DO $$ BEGIN
+ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
+ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
`;
`;
}
}
async function migrateShares(db) { await db.tx(async (t) => { await t.exec(sharesSchema()); }); }
async function migrateShares(db) { await db.tx(async (t) => { await t.exec(sharesSchema()); }); }
@@ -1,6 +1,8 @@
@@ -1,6 +1,8 @@
'use strict';
'use strict';
const { randomUUID } = require('node:crypto');
const { randomUUID } = require('node:crypto');
const { newEventId } = require('../core/ids');
const { newEventId } = require('../core/ids');
+const { inspectEnvelope } = require('../locker/server');
+const { jwkThumbprint } = require('../locker/keys');
const v = require('./validation');
const v = require('./validation');
const { migrateShares } = require('./schema');
const { migrateShares } = require('./schema');
const WEEK = 7 * 24 * 60 * 60 * 1000;
const WEEK = 7 * 24 * 60 * 60 * 1000;
@@ -10,18 +12,23 @@ const where = 'service=$1 AND kind=$2 AND resource_id=$3';
@@ -10,18 +12,23 @@ const where = 'service=$1 AND kind=$2 AND resource_id=$3';
const iso = value => new Date(value).toISOString();
const iso = value => new Date(value).toISOString();
const personKey = subject => `${subject.type}:${subject.id}`;
const personKey = subject => `${subject.type}:${subject.id}`;
const unknownPerson = subject => ({ subject, username: null, display_name: null, avatar_url: null });
const unknownPerson = subject => ({ subject, username: null, display_name: null, avatar_url: null });
+const slotKey = slot => (slot && slot.recipient_key) || null;
const transferShape = row => ({ id: row.id, from: row.from_subject, to: row.to_subject, keep_access: row.keep_access,
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 });
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) } : {}),
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) });
+ added_by: row.added_by, added_at: iso(row.added_at), ...(row.key_slot ? { key_slot: row.key_slot } : {}) });
+/** A public key we can name by its thumbprint: an EC P-256 JWK with both coordinates. */
+const usableKey = jwk => !!jwk && jwk.kty === 'EC' && jwk.crv === 'P-256'
+ && typeof jwk.x === 'string' && typeof jwk.y === 'string';
-function createShares({ db, network, events, notify, onOwnerChange, onAudienceChange, now = Date.now, nameLimit = 30, nameWindowMs = 60000 } = {}) {
+function createShares({ db, network, events, notify, onOwnerChange, onAudienceChange, onReseal, now = Date.now, nameLimit = 30, nameWindowMs = 60000 } = {}) {
if (!db || !db.tx || !network || !network.json || !events || !events.publish || typeof notify !== 'function') {
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');
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');
if (!Number.isInteger(nameLimit) || nameLimit < 1 || !Number.isInteger(nameWindowMs) || nameWindowMs < 1) throw new TypeError('Invalid name rate limit');
const timestamp = () => iso(now());
const timestamp = () => iso(now());
const peopleCache = new Map();
const peopleCache = new Map();
+ const keyCache = 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 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 = {}) {
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()]);
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()]);
@@ -53,7 +60,7 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
@@ -53,7 +60,7 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
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 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) {
async function snapshot(t, ref, s) {
const tr = await pending(t, ref);
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) } : {}) };
+ return { ref, owner_subject: s.owner_subject, audience: s.audience, lock: s.lock || 'none', 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 bump(t, ref) { await t.exec(`UPDATE shares SET revision=revision+1 WHERE ${where}`, key(ref)); }
async function access(t, ref, s, viewer, edit) {
async function access(t, ref, s, viewer, edit) {
@@ -137,20 +144,105 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
@@ -137,20 +144,105 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
if (!found) return { person: null };
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 } };
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) {
+ /**
+ * A person's current public keys from Network's key directory (GET /api/v1/people/:subject/keys),
+ * cached for a minute. null means the person has no locker yet. The thumbprint is what a slot
+ * names, so it is the thing a member's slot is checked against.
+ */
+ async function directoryKeys(subject) {
+ const at = now(), hit = keyCache.get(subject.id);
+ if (hit && at - hit.at < PEOPLE_TTL) return hit.keys;
+ let keys = null;
+ try {
+ const found = await network.json({ service: 'network', audience: 'openvibe.network',
+ path: `/api/v1/people/${encodeURIComponent(subject.id)}/keys` });
+ if (found && usableKey(found.ecdh) && typeof found.fingerprint === 'string') {
+ keys = { ecdh: found.ecdh, fingerprint: found.fingerprint, thumbprint: await jwkThumbprint(found.ecdh) };
+ }
+ } catch (err) { if (err.status !== 404) throw err; }
+ keyCache.set(subject.id, { at, keys });
+ return keys;
+ }
+ /** Everyone's current keys, one lookup each and never inside a transaction. */
+ async function currentKeys(subjects) {
+ const unique = [];
+ for (const s of subjects) if (!unique.some(u => v.equal(u, s))) unique.push(s);
+ const found = await Promise.all(unique.map(directoryKeys));
+ return new Map(unique.map((s, i) => [personKey(s), found[i]]));
+ }
+ /** A slot is for this person only if it names them and the key Network has for them now. */
+ async function checkSlot(subject, slot) {
+ if (!v.equal(slot.subject, subject)) v.invalid();
+ const keys = await directoryKeys(subject);
+ if (!keys) v.fail(409, 'share.no_key', 'That person has no locker key yet; they make one the first time they open a locked item');
+ if (keys.thumbprint !== slot.recipient_key) {
+ v.fail(409, 'share.key_changed', 'That person\'s key changed; wrap the content key for their new key');
+ }
+ return slot;
+ }
+ /** The lock kind of a registered item, read before name and key resolution so that check runs outside a transaction. */
+ async function storedLock(ref) {
+ const s = await db.maybe(`SELECT lock FROM shares WHERE ${where}`, key(ref));
+ if (!s) v.hidden();
+ return s.lock;
+ }
+ /** A re-sealed envelope: base64 in, bytes out, and it must be a people-locked envelope we can read. */
+ function sealed(base64) {
+ const bytes = v.envelope(base64);
+ const read = inspectEnvelope(bytes);
+ if (!read.valid || read.lock !== 'people') v.fail(400, 'share.reseal_invalid', 'A re-seal is a valid people-locked envelope');
+ return bytes;
+ }
+ /** Stores a re-seal through the service's hook, inside the transaction and before the member goes. */
+ async function reseal(t, ref, s, bytes, actor, gone) {
+ if (s.lock !== 'people') v.fail(400, 'share.reseal_invalid', 'Only a people-locked item is re-sealed');
+ if (typeof onReseal !== 'function') v.fail(409, 'share.reseal_unavailable', 'This service cannot store a re-sealed envelope');
+ const read = inspectEnvelope(bytes);
+ // Every seal writes a new nonce base, and a re-seal wraps a new content key. The nonce base the
+ // envelope already stored is the one to beat: the same value is that envelope sent again, and the
+ // member who is leaving could still open what is served next.
+ if (gone.length && s.reseal_nonce_base && read.header.nonce_base === s.reseal_nonce_base) {
+ v.fail(400, 'share.reseal_not_fresh', 'That is the envelope already stored; seal the content again with a new key');
+ }
+ // A wrap is randomised every time, so a slot that is byte for byte the stored one means the old
+ // key, still wrapped for the same person, came back.
+ if (gone.length) {
+ const people = new Map(read.header.slots.people.map(slot => [personKey(slot.subject), slot]));
+ const staying = await t.many(`SELECT subject, key_slot FROM share_members WHERE ${where} AND key_slot IS NOT NULL`, key(ref));
+ for (const m of staying) {
+ if (gone.some(g => v.equal(g.subject, m.subject))) continue;
+ const slot = people.get(personKey(m.subject));
+ if (slot && slot.salt === m.key_slot.salt && slot.nonce === m.key_slot.nonce && slot.wrapped_key === m.key_slot.wrapped_key) {
+ v.fail(400, 'share.reseal_not_fresh', 'The re-sealed envelope still wraps the old content key for a member who stays');
+ }
+ }
+ }
+ // The point of a re-seal is that the person leaving cannot open what they fetch next: their slot
+ // has to be gone from the envelope, not only from the member table.
+ const recipients = new Set(read.header.slots.people.map(slot => slot.recipient_key));
+ for (const m of gone) {
+ const left = slotKey(m.key_slot);
+ if (left && recipients.has(left)) v.fail(400, 'share.reseal_invalid', 'The re-sealed envelope still holds the slot of the member who is leaving');
+ }
+ await onReseal(ref, bytes, t);
+ await t.exec(`UPDATE shares SET reseal_nonce_base=$4 WHERE ${where}`, [...key(ref), read.header.nonce_base]);
+ s.reseal_nonce_base = read.header.nonce_base;
+ await log(t, ref, actor, 'lock.resealed', { removed: gone.map(m => m.subject), bytes: bytes.length });
+ }
+ async function inputs(viewer, list, lock = 'none') {
if (!Array.isArray(list) || list.length > 100) v.invalid();
if (!Array.isArray(list) || list.length > 100) v.invalid();
const parsed = list.map(item => {
const parsed = list.map(item => {
if (typeof item === 'string') item = { name: item, role: 'viewer' };
if (typeof item === 'string') item = { name: item, role: 'viewer' };
- v.object(item, ['name', 'subject', 'role', 'expires_at']);
+ v.object(item, ['name', 'subject', 'role', 'expires_at', 'key_slot']);
if (Object.hasOwn(item, 'name') === Object.hasOwn(item, 'subject') || !['viewer', 'editor'].includes(item.role)) v.invalid();
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()) };
+ return { item, expires_at: v.expiry(item.expires_at, now()), key_slot: item.key_slot === undefined ? null : v.keySlot(item.key_slot) };
});
});
// Canonical subjects are people too: verify them in one Network batch, never trust the object alone.
// 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));
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');
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 people = subjects.length ? await peopleOf(subjects) : new Map();
const out = new Map();
const out = new Map();
- for (const { item, expires_at } of parsed) {
+ for (const { item, expires_at, key_slot } of parsed) {
let subject;
let subject;
if (Object.hasOwn(item, 'name')) subject = await resolve(viewer, item.name);
if (Object.hasOwn(item, 'name')) subject = await resolve(viewer, item.name);
else {
else {
@@ -159,9 +251,15 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
@@ -159,9 +251,15 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
if (!person || !person.username) v.fail(400, 'share.name_not_found', 'No OpenVibe account is that 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.
// 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 });
+ out.set(JSON.stringify(subject), { subject, role: item.role, expires_at, key_slot });
}
}
- return [...out.values()];
+ const resolved = [...out.values()];
+ // A people-locked item is only shared with a key slot each: the server can check it, never make it.
+ if (lock === 'people') {
+ for (const m of resolved) if (!m.key_slot) v.fail(400, 'share.slot_required', 'A people-locked item needs a key slot for every member');
+ await Promise.all(resolved.map(m => checkSlot(m.subject, m.key_slot)));
+ } else for (const m of resolved) if (m.key_slot) v.fail(400, 'share.key_slot_unexpected', 'Only a people-locked item carries key slots');
+ return resolved;
}
}
async function revoke(t, ref, s, actor, m, reason) {
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 t.exec(`DELETE FROM share_members WHERE ${where} AND subject=$4`, [...key(ref), m.subject]);
@@ -193,11 +291,14 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
@@ -193,11 +291,14 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
if (v.equal(m.subject, s.owner_subject)) v.invalid();
if (v.equal(m.subject, s.owner_subject)) v.invalid();
if (m.expires_at && Date.parse(m.expires_at) <= now()) 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]);
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()]);
+ // A new slot for the same person is a change even when the role and the expiry are not.
+ const slot = m.key_slot || null;
+ if (existing && existing.role === m.role && (existing.expires_at ? iso(existing.expires_at) : null) === m.expires_at
+ && slotKey(existing.key_slot) === slotKey(slot)) return;
+ await t.exec(`INSERT INTO share_members(service,kind,resource_id,subject,role,expires_at,added_by,added_at,key_slot) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
+ ON CONFLICT(service,kind,resource_id,subject) DO UPDATE SET role=$5,expires_at=$6,added_by=$7,added_at=$8,key_slot=$9`, [...key(ref), m.subject, m.role, m.expires_at, actor, timestamp(), slot]);
await t.exec(`UPDATE share_access_requests SET state='granted' WHERE ${where} AND subject=$4 AND state='pending'`, [...key(ref), m.subject]);
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 log(t, ref, actor, 'member.added', { subject: m.subject, role: m.role, ...(slot ? { recipient_key: slot.recipient_key } : {}) });
await event(t, ref, s, actor, 'share.granted', m.subject, { reason: 'member_added', role: m.role, ...(m.expires_at ? { expires_at: m.expires_at } : {}) });
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.
// 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);
if (!existing && notify) await notice(t, ref, 'share.added', m.subject, actor);
@@ -213,20 +314,26 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
@@ -213,20 +314,26 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
});
});
}
}
async function update(viewer, input, body, { ifMatch } = {}) {
async function update(viewer, input, body, { ifMatch } = {}) {
- const ref = v.ref(input); v.object(body, ['audience', 'members', 'notify']);
+ const ref = v.ref(input); v.object(body, ['audience', 'members', 'notify', 'lock', 'envelope']);
if (!Object.keys(body).length) v.invalid();
if (!Object.keys(body).length) v.invalid();
if (body.audience !== undefined) v.audience(body.audience);
if (body.audience !== undefined) v.audience(body.audience);
if (body.notify !== undefined && typeof body.notify !== 'boolean') v.invalid();
if (body.notify !== undefined && typeof body.notify !== 'boolean') v.invalid();
+ if (body.lock !== undefined) v.lock(body.lock);
await authorize(viewer, ref);
await authorize(viewer, ref);
- const list = body.members === undefined ? undefined : await inputs(viewer, body.members);
+ // How an item is sealed is set when it is created; a save may echo it, never change it.
+ const lock = await storedLock(ref);
+ if (body.lock !== undefined && body.lock !== lock) v.fail(409, 'share.lock_fixed', 'How an item is sealed is set when it is created');
+ if (body.audience === 'public' && lock !== 'none') v.fail(400, 'share.lock_public', 'A locked item cannot be public');
+ const list = body.members === undefined ? undefined : await inputs(viewer, body.members, lock);
+ const resealed = body.envelope === undefined ? null : sealed(body.envelope);
const notify = body.notify !== false;
const notify = body.notify !== false;
return mutate(viewer, ref, async (t, ref, s, actor, staff) => {
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 (ifMatch !== undefined && ifMatch !== `"${s.revision}"`) v.fail(412, 'share.changed', 'Sharing changed; reload and try again');
+ const have = await members(t, ref);
if (staff) {
if (staff) {
// Staff narrow access only: private and removals. No widening, additions or role raises.
// Staff narrow access only: private and removals. No widening, additions or role raises.
if (body.audience !== undefined && body.audience !== 'private') v.hidden();
if (body.audience !== undefined && body.audience !== 'private') v.hidden();
if (list) {
if (list) {
- const have = await members(t, ref);
for (const m of list) {
for (const m of list) {
const cur = have.find(x => v.equal(x.subject, m.subject));
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 (!cur || cur.role !== m.role || (cur.expires_at ? iso(cur.expires_at) : null) !== m.expires_at) v.hidden();
@@ -234,9 +341,16 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
@@ -234,9 +341,16 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
}
}
}
}
if (body.audience !== undefined) await changeAudience(t, ref, s, actor, body.audience);
if (body.audience !== undefined) await changeAudience(t, ref, s, actor, body.audience);
+ const gone = list ? have.filter(m => !list.some(x => v.equal(x.subject, m.subject))) : [];
+ // Somebody leaving a people-locked item: the content is re-sealed for those who stay, and the
+ // service stores it before the member row goes. The envelope is checked before the hook, so the
+ // service never receives bytes it cannot check.
+ if (gone.length && s.lock === 'people' && !resealed) {
+ v.fail(400, 'share.reseal_required', 'Removing somebody from a people-locked item needs the content re-sealed for those who stay');
+ }
+ if (resealed) await reseal(t, ref, s, resealed, actor, gone);
if (list) {
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 gone) await revoke(t, ref, s, actor, m, 'removed');
for (const m of list) await grant(t, ref, s, actor, m, notify);
for (const m of list) await grant(t, ref, s, actor, m, notify);
}
}
return snapshot(t, ref, s);
return snapshot(t, ref, s);
@@ -251,7 +365,7 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
@@ -251,7 +365,7 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
if (item && typeof item === 'object' && Object.hasOwn(item, 'type')) return { subject: v.subject(item), role: 'viewer' };
if (item && typeof item === 'object' && Object.hasOwn(item, 'type')) return { subject: v.subject(item), role: 'viewer' };
return item;
return item;
});
});
- const resolved = await inputs(viewer, normalized);
+ const resolved = await inputs(viewer, normalized, await storedLock(ref));
return mutate(viewer, ref, async (t, ref, s, a, staff) => {
return mutate(viewer, ref, async (t, ref, s, a, staff) => {
if (staff) v.hidden();
if (staff) v.hidden();
const have = await members(t, ref);
const have = await members(t, ref);
@@ -260,21 +374,27 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
@@ -260,21 +374,27 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
return snapshot(t, ref, s);
return snapshot(t, ref, s);
});
});
}
}
- async function removeMember(viewer, ref, input) {
+ async function removeMember(viewer, ref, input, { envelope } = {}) {
const subject = v.subject(input);
const subject = v.subject(input);
+ const resealed = envelope === undefined ? null : sealed(envelope);
return mutate(viewer, ref, async (t, ref, s, a) => {
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]);
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');
+ if (m) {
+ if (s.lock === 'people' && !resealed) v.fail(400, 'share.reseal_required', 'Removing somebody from a people-locked item needs the content re-sealed for those who stay');
+ if (resealed) await reseal(t, ref, s, resealed, a, [m]);
+ await revoke(t, ref, s, a, m, 'removed');
+ }
return snapshot(t, ref, s);
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);
+ async function register(input, { owner_subject, audience = 'private', lock = 'none' }) {
+ const ref = v.ref(input), a = v.subject(owner_subject); v.audience(audience); v.lock(lock);
+ if (lock !== 'none' && audience === 'public') v.fail(400, 'share.lock_public', 'A locked item cannot be public');
return db.tx(async t => {
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 made = await t.exec('INSERT INTO shares(service,kind,resource_id,owner_subject,audience,lock) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING', [...key(ref), a, audience, lock]);
const s = await row(t, ref);
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 });
+ if (!v.equal(s.owner_subject, a) || s.audience !== audience || s.lock !== lock) v.fail(409, 'share.exists', 'Sharing already registered');
+ if (made) await log(t, ref, a, 'registered', { audience, lock });
return snapshot(t, ref, s);
return snapshot(t, ref, s);
});
});
}
}
@@ -286,9 +406,23 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
@@ -286,9 +406,23 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
try { people = await peopleOf(subjects); }
try { people = await peopleOf(subjects); }
catch { people = new Map(subjects.map(s => [personKey(s), unknownPerson(s)])); }
catch { people = new Map(subjects.map(s => [personKey(s), unknownPerson(s)])); }
const person = subject => people.get(personKey(subject)) || unknownPerson(subject);
const person = subject => people.get(personKey(subject)) || unknownPerson(subject);
+ // The owner of a people-locked item sees each member's current fingerprint and whether the slot
+ // is still for the key that person has now, so the sheet can warn before it wraps again. A read
+ // that cannot reach the key directory says nothing rather than claiming every key changed.
+ let keys = null;
+ if (share.lock === 'people' && v.equal(viewer.subject, share.owner_subject) && (share.members || []).length) {
+ try { keys = await currentKeys(share.members.map(m => m.subject)); }
+ catch { keys = null; }
+ }
+ const member = m => {
+ if (!keys) return { ...m, ...person(m.subject) };
+ const found = keys.get(personKey(m.subject));
+ return { ...m, ...person(m.subject), key_fingerprint: found ? found.fingerprint : null,
+ fingerprint_changed: !found || found.thumbprint !== slotKey(m.key_slot) };
+ };
return { ...share,
return { ...share,
owner: person(share.owner_subject),
owner: person(share.owner_subject),
- members: (share.members || []).map(m => ({ ...m, ...person(m.subject) })),
+ members: (share.members || []).map(member),
...(share.transfer ? { transfer: { ...share.transfer, to: person(share.transfer.to) } } : {}),
...(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' },
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) })) };
requests: requests.map(r => ({ id: r.id, ...person(r.subject), requested_at: iso(r.requested_at) })) };
@@ -325,22 +459,29 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
@@ -325,22 +459,29 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
return snapshot(t, ref, s);
return snapshot(t, ref, s);
});
});
}
}
- async function transfer(viewer, input, { to, keep_access = false } = {}) {
+ async function transfer(viewer, input, { to, keep_access = false, key_slot } = {}) {
const ref = v.ref(input); if (typeof keep_access !== 'boolean') v.invalid();
const ref = v.ref(input); if (typeof keep_access !== 'boolean') v.invalid();
+ const slot = key_slot === undefined ? null : v.keySlot(key_slot);
await authorize(viewer, ref);
await authorize(viewer, ref);
+ const lock = await storedLock(ref);
let target = to;
let target = to;
if (to && typeof to === 'object' && !Object.hasOwn(to, 'type')) {
if (to && typeof to === 'object' && !Object.hasOwn(to, 'type')) {
if (Object.hasOwn(to, 'subject') === Object.hasOwn(to, 'name')) v.invalid();
if (Object.hasOwn(to, 'subject') === Object.hasOwn(to, 'name')) v.invalid();
target = Object.hasOwn(to, 'subject') ? to.subject : to.name;
target = Object.hasOwn(to, 'subject') ? to.subject : to.name;
}
}
const recipient = await resolve(viewer, target);
const recipient = await resolve(viewer, target);
+ // The new owner must be able to open it: their slot comes with the offer, checked against their key now.
+ if (lock === 'people') {
+ if (!slot) v.fail(400, 'share.slot_required', 'A transfer of a people-locked item needs the recipient\'s key slot');
+ await checkSlot(recipient, slot);
+ } else if (slot) v.fail(400, 'share.key_slot_unexpected', 'Only a people-locked item carries key slots');
return mutate(viewer, ref, async (t, ref, s, a) => {
return mutate(viewer, ref, async (t, ref, s, a) => {
// Staff may moderate, but cannot give somebody else's ownership away.
// Staff may moderate, but cannot give somebody else's ownership away.
if (!v.equal(a, s.owner_subject)) v.hidden();
if (!v.equal(a, s.owner_subject)) v.hidden();
if (v.equal(recipient, s.owner_subject)) v.invalid();
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');
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 tr = await t.one(`INSERT INTO share_transfers(id,service,kind,resource_id,from_subject,to_subject,keep_access,requested_at,expires_at,state,key_slot) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending',$10) RETURNING *`,
+ [randomUUID(), ...key(ref), s.owner_subject, recipient, keep_access, timestamp(), iso(now() + WEEK), slot]);
const result = transferShape(tr);
const result = transferShape(tr);
await log(t, ref, a, 'transfer.requested', { transfer_id: tr.id, from: tr.from_subject, to: recipient, keep_access });
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 event(t, ref, s, a, 'share.transfer.requested', recipient, { transfer: result });
@@ -372,6 +513,8 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
@@ -372,6 +513,8 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
await log(t, ref, a, 'ownership.incoming', { 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 (tr.keep_access) {
if (s.audience === 'private') await changeAudience(t, ref, s, a, 'people');
if (s.audience === 'private') await changeAudience(t, ref, s, a, 'people');
+ // A people-locked item leaves the former owner without a slot until the new owner wraps
+ // for them: the fresh content key is not theirs to hold.
await grant(t, ref, s, a, { subject: from, role: 'viewer', expires_at: null }, true);
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 });
} else await event(t, ref, s, a, 'share.revoked', from, { reason: 'ownership_changed', transfer_id: tr.id });
}
}
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const { peopleSlotOk } = require('../locker/server');
class ShareError extends Error {
class ShareError extends Error {
constructor(status, code, message) { super(message); this.name = 'ShareError'; this.status = status; this.code = code; }
constructor(status, code, message) { super(message); this.name = 'ShareError'; this.status = status; this.code = code; }
}
}
@@ -24,6 +25,23 @@ function ref(v) {
@@ -24,6 +25,23 @@ function ref(v) {
return { service: v.service, kind: v.kind, id: v.id };
return { service: v.service, kind: v.kind, id: v.id };
}
}
function audience(v) { if (!['public', 'link', 'people', 'private'].includes(v)) invalid(); return v; }
function audience(v) { if (!['public', 'link', 'people', 'private'].includes(v)) invalid(); return v; }
+const LOCKS = ['none', 'people', 'password', 'link'];
+function lock(v) { if (!LOCKS.includes(v)) invalid(); return v; }
+/** A people slot: the shape, and the subject it names. The key check needs Network, so the store does it. */
+function keySlot(v) {
+ if (!peopleSlotOk(v) || Object.keys(v).some(k => !['subject', 'algorithm', 'recipient_key', 'ephemeral_public_key', 'salt', 'nonce', 'wrapped_key'].includes(k))) invalid();
+ return { ...v, subject: subject(v.subject) };
+}
+/** The largest re-sealed envelope a save may carry: a whole content chunk plus room for its header. */
+const MAX_ENVELOPE_BYTES = 8 * 1024 * 1024;
+/** A base64 locker.envelope@1, as bytes. Its header is checked by the caller, which knows the lock. */
+function envelope(v) {
+ if (typeof v !== 'string' || !v.length || v.length > Math.ceil(MAX_ENVELOPE_BYTES / 3) * 4
+ || !/^[A-Za-z0-9+/]+={0,2}$/.test(v)) invalid();
+ const bytes = Buffer.from(v, 'base64');
+ if (!bytes.length || bytes.length > MAX_ENVELOPE_BYTES) invalid();
+ return bytes;
+}
function actor(viewer) { if (!viewer || !viewer.subject) hidden(); return subject(viewer.subject); }
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 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 name(v) { if (typeof v !== 'string' || !/^@?[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/.test(v)) invalid(); return v.replace(/^@/, ''); }
@@ -39,4 +57,4 @@ function expiry(v, now) {
@@ -39,4 +57,4 @@ function expiry(v, now) {
if (month < 1 || month > 12 || day < 1 || day > days[month - 1] || hour > 23 || minute > 59 || second > 59) invalid();
if (month < 1 || month > 12 || day < 1 || day > days[month - 1] || hour > 23 || minute > 59 || second > 59) invalid();
return new Date(v).toISOString();
return new Date(v).toISOString();
}
}
-module.exports = { ShareError, fail, invalid, hidden, object, subject, ref, audience, actor, equal, name, uuid, expiry };
+module.exports = { ShareError, fail, invalid, hidden, object, subject, ref, audience, actor, equal, name, uuid, expiry, lock, LOCKS, keySlot, envelope, MAX_ENVELOPE_BYTES };
@@ -451,6 +451,27 @@ run([
@@ -451,6 +451,27 @@ run([
}), 'locker.invalid');
}), 'locker.invalid');
}],
}],
+ ['a caller that holds the content key needs no private key', async () => {
+ const contentKey = new Uint8Array(32).fill(9);
+ const sealed = await locker.seal('a note', {
+ lock: 'people',
+ keys: [{ subject: A, publicKey: alice.ecdhPublic }],
+ contentKey,
+ });
+ // An agent that sealed the item kept its key: it can open it, or re-seal it for the people who
+ // stay, without the private key of a recipient.
+ assert.equal(decoder.decode(await locker.open(sealed, { contentKey })), 'a note');
+ const again = await locker.rewrap(sealed, {
+ remove: [B],
+ keys: [{ subject: A, publicKey: alice.ecdhPublic }],
+ credentials: { contentKey },
+ });
+ assert.equal(decoder.decode(await locker.open(again, { privateKey: alice.ecdh.privateKey, subject: A })), 'a note');
+ // The re-seal made a new content key: the one the caller kept opens the old envelope only.
+ await assert.rejects(() => locker.open(again, { contentKey }), (err) => err.code === 'locker.wrong_key');
+ await fails(() => locker.open(sealed, { contentKey: new Uint8Array(31) }), 'locker.invalid');
+ }],
+
['every seal makes a new key and a new nonce base', async () => {
['every seal makes a new key and a new nonce base', async () => {
const one = await locker.seal('the same note', { lock: 'password', password: 'x' });
const one = await locker.seal('the same note', { lock: 'password', password: 'x' });
const two = await locker.seal('the same note', { lock: 'password', password: 'x' });
const two = await locker.seal('the same note', { lock: 'password', password: 'x' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,335 @@
@@ -0,0 +1,335 @@
+'use strict';
+/**
+ * Locked items in sharing: the lock kind, the per-member key slots, the re-seal on removal, the
+ * transfer offer, and the agent client that wraps and re-seals. Real P-256 keys and real people
+ * slots, so a slot's thumbprint is checked the way a browser's would be.
+ */
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const { createDb } = require('../src/db');
+const { createShares, share } = require('../src/share');
+const locker = require('../src/locker');
+
+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 SUBJECTS = { [U.id]: U, [V.id]: V, [X.id]: X };
+const PEOPLE = {}, KEYS = {};
+const contentKey = new Uint8Array(32).fill(7);
+const base64 = bytes => Buffer.from(bytes).toString('base64');
+const slotKey = key => key.recipient_key;
+
+const db = createDb({ pglite: true });
+let time = Date.parse('2026-11-06T00:00:00Z'), seq = 0;
+const hooks = [];
+let resealDown = false;
+const shares = createShares({
+ db, now: () => time, nameLimit: 1000,
+ network: { json: async 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 };
+ }
+ if (req.path === '/internal/identity/resolve') {
+ const found = Object.values(PEOPLE).find(p => p.username.toLowerCase() === String(req.query.username).toLowerCase());
+ return found ? { ...found } : null;
+ }
+ const keys = String(req.path).match(/^\/api\/v1\/people\/([^/]+)\/keys$/);
+ if (keys) {
+ const found = KEYS[decodeURIComponent(keys[1])];
+ if (!found) throw Object.assign(new Error('no keys'), { status: 404 });
+ return found;
+ }
+ throw new Error('unexpected network path ' + req.path);
+ } },
+ events: { publish: async () => {} },
+ notify: async () => {},
+ onReseal: async (ref, bytes, tx) => {
+ // The member row must still be there while the service stores the re-seal.
+ const left = await tx.one("SELECT count(*)::int AS n FROM share_members WHERE service=$1 AND kind=$2 AND resource_id=$3", [ref.service, ref.kind, ref.id]);
+ hooks.push({ ref, bytes, members: Number(left.n) });
+ if (resealDown) throw new Error('storage down');
+ },
+});
+const fresh = async (lock = 'people') => {
+ const ref = { service: 'community', kind: 'paste', id: `l${++seq}` };
+ await shares.register(ref, { owner_subject: U, audience: 'people', lock });
+ return ref;
+};
+const bad = (fn, status, code) => assert.rejects(fn, e => e.status === status && (!code || e.code === code));
+/** A real people slot for that person's current key. */
+const slotFor = id => locker.wrapFor(contentKey, KEYS[id].ecdh, { subject: SUBJECTS[id] });
+/** A real people-locked envelope for whoever is named. */
+const sealedFor = (ids, key = contentKey) => locker.seal('a note', { lock: 'people', keys: ids.map(id => ({ subject: SUBJECTS[id], publicKey: KEYS[id].ecdh })), contentKey: key });
+const makePerson = async (subject, username) => {
+ const person = await locker.generatePersonKeys();
+ const published = await locker.exportPersonKeys(person);
+ PEOPLE[subject.id] = { subject, username, display_name: username, avatar_url: null, banned: false };
+ KEYS[subject.id] = { subject, ...published, updated_at: '2026-11-06T00:00:00Z' };
+ return person;
+};
+const tests = [];
+const test = (name, fn) => tests.push([name, fn]);
+
+(async () => {
+ const keysOf = { u: await makePerson(U, 'ursula'), v: await makePerson(V, 'maticus'), x: await makePerson(X, 'xenia') };
+ await shares.migrate();
+
+ test('a share records its lock, GET answers it, and it is fixed once registered', async () => {
+ const ref = await fresh('people');
+ assert.equal((await shares.get(owner, ref)).lock, 'people');
+ await bad(async () => shares.register(ref, { owner_subject: U, audience: 'people', lock: 'none' }), 409, 'share.exists');
+ await bad(async () => shares.register(ref, { owner_subject: U, audience: 'people', lock: 'secret' }), 400, 'share.invalid');
+ const open = await fresh('none');
+ assert.equal((await shares.get(owner, open)).lock, 'none');
+ // A save may echo the lock, never change it.
+ await bad(async () => shares.update(owner, ref, { lock: 'none' }), 409, 'share.lock_fixed');
+ assert.equal((await shares.update(owner, ref, { lock: 'people', audience: 'people' })).lock, 'people');
+ });
+
+ test('a locked item is never public, at registration or later', async () => {
+ await bad(async () => shares.register({ service: 'community', kind: 'paste', id: `p${++seq}` },
+ { owner_subject: U, audience: 'public', lock: 'people' }), 400, 'share.lock_public');
+ const ref = await fresh('people');
+ await bad(async () => shares.update(owner, ref, { audience: 'public' }), 400, 'share.lock_public');
+ assert.equal((await shares.get(owner, ref)).audience, 'people');
+ });
+
+ test('a people-locked item needs a slot for every member, for that person and that key', async () => {
+ const ref = await fresh();
+ const slot = await slotFor(V.id);
+ await bad(async () => shares.addMembers(owner, ref, [{ subject: V, role: 'viewer' }]), 400, 'share.slot_required');
+ const theirs = await slotFor(X.id);
+ await bad(async () => shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: theirs }]), 400, 'share.invalid');
+ // The person changes their key: the slot they handed over is for the key they no longer have.
+ await makePerson(V, 'maticus');
+ time += 61000;
+ await bad(async () => shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: slot }]), 409, 'share.key_changed');
+ KEYS[V.id] = { subject: V, ...(await locker.exportPersonKeys(keysOf.v)), updated_at: '2026-11-06T00:00:00Z' };
+ time += 61000;
+ const added = await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: slot }]);
+ assert.deepEqual(added.members[0].key_slot, slot);
+ assert.equal(await shares.canView(recipient, ref), true);
+ });
+
+ test('a member with no locker key is refused, not assumed', async () => {
+ const ref = await fresh();
+ const slot = await slotFor(X.id);
+ const kept = KEYS[X.id]; delete KEYS[X.id];
+ try { await bad(async () => shares.addMembers(owner, ref, [{ subject: X, role: 'viewer', key_slot: slot }]), 409, 'share.no_key'); }
+ // The miss is cached for a minute like a hit; let it expire so the next test sees the key again.
+ finally { KEYS[X.id] = kept; time += 61000; }
+ });
+
+ test('removing somebody is a re-seal refused without an envelope and stored before the member goes', async () => {
+ const ref = await fresh();
+ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]);
+ await bad(async () => shares.update(owner, ref, { members: [] }), 400, 'share.reseal_required');
+ await bad(async () => shares.update(owner, ref, { members: [], envelope: base64(Buffer.from('not an envelope')) }), 400, 'share.reseal_invalid');
+ await bad(async () => shares.update(owner, ref, { members: [], envelope: base64((await sealedFor([U.id, V.id])).bytes) }), 400, 'share.reseal_invalid');
+ assert.equal((await shares.get(owner, ref)).members.length, 1);
+
+ const before = hooks.length;
+ const sealed = await sealedFor([U.id]);
+ const view = await shares.update(owner, ref, { members: [], envelope: base64(sealed.bytes) });
+ assert.equal(view.members.length, 0);
+ const hook = hooks[before];
+ assert.deepEqual(hook.ref, ref);
+ assert.equal(hook.members, 1, 'the hook runs before the member row goes');
+ assert.equal(Buffer.compare(Buffer.from(hook.bytes), Buffer.from(sealed.bytes)), 0);
+ assert.equal(await shares.canView(recipient, ref), false);
+ // The same through removeMember, which is the service-side call.
+ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]);
+ await bad(async () => shares.removeMember(owner, ref, V), 400, 'share.reseal_required');
+ await shares.removeMember(owner, ref, V, { envelope: base64((await sealedFor([U.id])).bytes) });
+ assert.equal((await shares.get(owner, ref)).members.length, 0);
+ });
+
+ test('a re-seal that still holds the leaving member\'s slot is refused', async () => {
+ const ref = await fresh();
+ const slot = await slotFor(V.id);
+ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: slot }]);
+ // The envelope as it was: still holds V's slot.
+ await bad(async () => shares.update(owner, ref, { members: [], envelope: base64((await sealedFor([U.id, V.id])).bytes) }), 400, 'share.reseal_invalid');
+ assert.equal((await shares.get(owner, ref)).members.length, 1);
+ });
+
+ test('a re-seal must be fresh: the envelope already stored is refused', async () => {
+ const ref = await fresh();
+ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]);
+ const first = await sealedFor([U.id]);
+ await shares.update(owner, ref, { members: [], envelope: base64(first.bytes) });
+ // Somebody comes back, and the caller sends the envelope that is already stored: the content key
+ // is the one the item had before, so the re-seal proves nothing.
+ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]);
+ await bad(async () => shares.update(owner, ref, { members: [], envelope: base64(first.bytes) }), 400, 'share.reseal_not_fresh');
+ assert.equal((await shares.get(owner, ref)).members.length, 1);
+ // A new seal has a new nonce base, and it goes through.
+ const second = await sealedFor([U.id]);
+ assert.notEqual(second.header.nonce_base, first.header.nonce_base);
+ assert.equal((await shares.update(owner, ref, { members: [], envelope: base64(second.bytes) })).members.length, 0);
+ });
+
+ test('a re-seal that wraps the old content key for a member who stays is refused', async () => {
+ const ref = await fresh();
+ // The stored envelope, and the member's slot is the one inside it (a member added for that very seal).
+ const env = await sealedFor([U.id, V.id]);
+ const fromEnvelope = env.header.slots.people.find(slot => slot.subject.id === V.id);
+ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: fromEnvelope }]);
+ await shares.addMembers(owner, ref, [{ subject: X, role: 'viewer', key_slot: await slotFor(X.id) }]);
+ // A member with no stored slot (what keep_access leaves) can go without a leaver-slot check, so the
+ // only thing left to catch is the wrap V keeps.
+ await db.exec("UPDATE share_members SET key_slot=NULL WHERE service='community' AND kind='paste' AND resource_id=$1 AND subject->>'id'=$2", [ref.id, X.id]);
+ await bad(async () => shares.update(owner, ref, { members: [{ subject: V, role: 'viewer', key_slot: fromEnvelope }], envelope: base64(env.bytes) }), 400, 'share.reseal_not_fresh');
+ assert.equal((await shares.get(owner, ref)).members.length, 2);
+ });
+
+ test('a transfer of a people-locked item needs the recipient\'s slot and drops the old owner', async () => {
+ const ref = await fresh();
+ await bad(async () => shares.transfer(owner, ref, { to: V }), 400, 'share.slot_required');
+ const wrong = await slotFor(X.id);
+ await bad(async () => shares.transfer(owner, ref, { to: V, key_slot: wrong }), 400, 'share.invalid');
+ const offer = await shares.transfer(owner, ref, { to: V, key_slot: await slotFor(V.id) });
+ assert.equal(offer.state, 'pending');
+ assert.equal((await shares.accept(recipient, ref, offer.id)).state, 'accepted');
+ const view = await shares.get(recipient, ref);
+ assert.equal(view.owner_subject.id, V.id);
+ assert.equal(view.members.length, 0);
+ assert.equal(await shares.canView(owner, ref), false);
+ // keep_access: the former owner stays a viewer, without a slot until the new owner wraps for them.
+ const again = await fresh();
+ const kept = await shares.transfer(owner, again, { to: V, keep_access: true, key_slot: await slotFor(V.id) });
+ await shares.accept(recipient, again, kept.id);
+ const keptView = await shares.get(recipient, again);
+ assert.equal(keptView.members.length, 1);
+ assert.equal(keptView.members[0].subject.id, U.id);
+ assert.equal(keptView.members[0].key_slot, undefined);
+ assert.equal(keptView.members[0].fingerprint_changed, true);
+ assert.equal(await shares.canView(owner, again), true);
+ });
+
+ test('GET answers each member\'s slot, and for the owner the fingerprint and whether the key changed', async () => {
+ const ref = await fresh();
+ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]);
+ time += 61000;
+ let view = await shares.get(owner, ref);
+ assert.equal(slotKey(view.members[0].key_slot), view.members[0].key_slot.recipient_key);
+ assert.equal(view.members[0].key_fingerprint, KEYS[V.id].fingerprint);
+ assert.equal(view.members[0].fingerprint_changed, false);
+ // Staff see the slot, not the owner's warning fields.
+ const staff = await shares.get({ ...other, staff: true }, ref);
+ assert.equal(staff.members[0].key_fingerprint, undefined);
+ // The person changes their key: the old slot is still stored, and the sheet is told.
+ KEYS[V.id] = { subject: V, ...(await locker.exportPersonKeys(await locker.generatePersonKeys())), updated_at: '2026-11-07T00:00:00Z' };
+ time += 61000;
+ view = await shares.get(owner, ref);
+ assert.equal(view.members[0].fingerprint_changed, true);
+ assert.equal(view.members[0].key_fingerprint, KEYS[V.id].fingerprint);
+ KEYS[V.id] = { subject: V, ...(await locker.exportPersonKeys(keysOf.v)), updated_at: '2026-11-06T00:00:00Z' };
+ time += 61000;
+ });
+
+ test('an unlocked item behaves exactly as before and refuses slots', async () => {
+ const ref = await fresh('none');
+ await bad(async () => shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]), 400, 'share.key_slot_unexpected');
+ await shares.addMembers(owner, ref, ['@maticus']);
+ assert.equal((await shares.get(owner, ref)).members[0].key_slot, undefined);
+ await shares.removeMember(owner, ref, V);
+ assert.equal(await shares.canView(recipient, ref), false);
+ const offer = await shares.transfer(owner, ref, { to: V });
+ const surplus = await slotFor(V.id);
+ await bad(async () => shares.transfer(owner, ref, { to: V, key_slot: surplus }), 400, 'share.key_slot_unexpected');
+ await shares.accept(recipient, ref, offer.id);
+ assert.equal((await shares.get(recipient, ref)).owner_subject.id, V.id);
+ });
+
+ test('a service without the re-seal hook cannot remove somebody from a people-locked item', async () => {
+ const ref = await fresh();
+ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]);
+ const hookless = createShares({ db, now: () => time, network: { json: async () => null }, events: { publish: async () => {} }, notify: async () => {} });
+ await bad(async () => hookless.update(owner, ref, { members: [], envelope: base64((await sealedFor([U.id])).bytes) }), 409, 'share.reseal_unavailable');
+ assert.equal((await shares.get(owner, ref)).members.length, 1);
+ });
+
+ test('a failed re-seal store leaves the member and the old envelope in place', async () => {
+ const ref = await fresh();
+ await shares.addMembers(owner, ref, [{ subject: V, role: 'viewer', key_slot: await slotFor(V.id) }]);
+ resealDown = true;
+ try { await assert.rejects(async () => shares.update(owner, ref, { members: [], envelope: base64((await sealedFor([U.id])).bytes) }), /storage down/); }
+ finally { resealDown = false; }
+ assert.equal((await shares.get(owner, ref)).members.length, 1, 'a failed store leaves the member in place');
+ });
+
+ test('HTTP: the lock cannot be overridden by describe, the slot travels, and the body may carry a re-seal', async () => {
+ const handler = shares.router({ service: 'community', authenticate: req => ({ owner, recipient, other })[(req.headers.authorization || '').replace('Bearer ', '')] || null,
+ describe: async () => ({ lock: 'none' }) });
+ const server = http.createServer(handler); await new Promise(r => server.listen(0, '127.0.0.1', r));
+ const ref = await fresh();
+ const base = `http://127.0.0.1:${server.address().port}`, path = `/api/v1/paste/${ref.id}/share`;
+ try {
+ assert.equal((await shares.get(owner, ref)).lock, 'people');
+ const res = await fetch(base + path, { headers: { Authorization: 'Bearer owner' } });
+ assert.equal(res.status, 200);
+ assert.equal((await res.json()).lock, 'people', 'describe cannot claim an item is unlocked');
+ const put = (body, headers = {}) => fetch(base + path, { method: 'PUT', headers: { Authorization: 'Bearer owner', 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });
+ const slot = await slotFor(V.id);
+ assert.equal((await put({ members: [{ subject: V, role: 'viewer', key_slot: slot }] })).status, 200);
+ assert.equal((await put({ members: [] })).status, 400);
+ // A re-seal is bigger than an ACL edit and is allowed to be; anything else stays at 64 KiB.
+ const big = await locker.seal('x'.repeat(90000), { lock: 'people', keys: [{ subject: U, publicKey: KEYS[U.id].ecdh }] });
+ assert.ok(big.bytes.length > 65536);
+ assert.equal((await put({ members: [], envelope: base64(big.bytes) })).status, 200);
+ assert.equal((await put({ members: [], padding: 'x'.repeat(70000) })).status, 413);
+ } finally { await new Promise(r => server.close(r)); }
+ });
+
+ test('the agent client wraps for a name and re-seals on removal', async () => {
+ const shareHandler = shares.router({ service: 'community', authenticate: req => ({ owner, recipient, other })[(req.headers.authorization || '').replace('Bearer ', '')] || null });
+ const server = http.createServer((req, res) => {
+ const path = new URL(req.url, 'http://x').pathname;
+ const keys = path.match(/^\/api\/v1\/people\/([^/]+)\/keys$/);
+ if (keys) {
+ const found = KEYS[decodeURIComponent(keys[1])];
+ if (!found) { res.writeHead(404); res.end('{}'); return; }
+ res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(found)); return;
+ }
+ if (path === '/api/v1/share/people') {
+ const name = new URL(req.url, 'http://x').searchParams.get('name').replace(/^@/, '').toLowerCase();
+ const person = Object.values(PEOPLE).find(p => p.username === name);
+ res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ person: person || null })); return;
+ }
+ shareHandler(req, res);
+ });
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ try {
+ const ref = await fresh();
+ const client = share({ base, network: base, token: 'owner' }).for('paste', ref.id);
+ const added = await client.addLocked('@maticus', { contentKey });
+ assert.equal(added.members.length, 1);
+ assert.equal(added.members[0].username, 'maticus');
+ assert.equal(slotKey(added.members[0].key_slot), (await slotFor(V.id)).recipient_key);
+ // The item as it is stored: sealed for the owner and the member.
+ const item = await sealedFor([U.id, V.id]);
+ const removed = await client.removeLocked('@maticus', { contentKey, envelope: item });
+ assert.equal(removed.members.length, 0);
+ assert.equal(hooks.at(-1).ref.id, ref.id);
+ const stored = hooks.at(-1).bytes;
+ assert.notEqual(Buffer.compare(Buffer.from(stored), Buffer.from(item.bytes)), 0, 'a fresh content key, a new envelope');
+ assert.deepEqual(locker.inspect(stored).recipients, [await locker.jwkThumbprint(KEYS[U.id].ecdh)]);
+ assert.deepEqual([...new Uint8Array(await locker.open(stored, { privateKey: keysOf.u.ecdh.privateKey, subject: U }))], [...Buffer.from('a note')]);
+ // And a caller that already re-sealed can hand it over instead.
+ await client.addLocked('@maticus', { contentKey });
+ const ready = await sealedFor([U.id]);
+ const second = await client.removeLocked('@maticus', { envelope: ready });
+ assert.equal(second.members.length, 0);
+ assert.equal(Buffer.compare(Buffer.from(hooks.at(-1).bytes), Buffer.from(ready.bytes)), 0);
+ } finally { await new Promise(r => server.close(r)); }
+ });
+
+ let count = 0;
+ try { for (const [name, fn] of tests) { await fn(); count++; console.log('ok ' + name); } console.log(`${count} locked-sharing tests passed`); }
+ finally { await db.close(); }
+})().catch(e => { console.error(e); process.exitCode = 1; });
@@ -29,3 +29,6 @@ export function inspectEnvelope(bytes: Uint8Array, options?: { maxHeaderBytes?:
@@ -29,3 +29,6 @@ export function inspectEnvelope(bytes: Uint8Array, options?: { maxHeaderBytes?:
/** The header's shape: what it says, and whether the slots match the lock it names. */
/** The header's shape: what it says, and whether the slots match the lock it names. */
export function checkHeader(header: unknown): string[];
export function checkHeader(header: unknown): string[];
+
+/** One people slot's shape: whose it is, which key it names, and the bytes it must hold. */
+export function peopleSlotOk(slot: unknown): boolean;
@@ -3,22 +3,37 @@ import type { SubjectRef } from './contracts';
@@ -3,22 +3,37 @@ import type { SubjectRef } from './contracts';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { IncomingMessage, ServerResponse } from 'node:http';
export type Audience = 'public' | 'link' | 'people' | 'private';
export type Audience = 'public' | 'link' | 'people' | 'private';
export type Role = 'viewer' | 'editor';
export type Role = 'viewer' | 'editor';
+/** How an item is sealed (Locker). `none` is stored as it is. */
+export type Lock = 'none' | 'people' | 'password' | 'link';
export interface ResourceRef { service: string; kind: string; id: string }
export interface ResourceRef { service: string; kind: string; id: string }
/** Trusted output of service authentication. Never constructed from a request body. */
/** Trusted output of service authentication. Never constructed from a request body. */
export interface Viewer { subject: SubjectRef; staff?: boolean }
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 }
+/** The content key wrapped for one person (locker.people-slot@1). The server stores it, never opens it. */
+export interface PeopleSlot {
+ subject: SubjectRef;
+ algorithm: 'ECDH-P256/HKDF-SHA256/AES-256-GCM';
+ /** The RFC 7638 thumbprint of the recipient's ECDH key: what the store checks against the key directory. */
+ recipient_key: string;
+ ephemeral_public_key: { kty: 'EC'; crv: 'P-256'; x: string; y: string };
+ salt: string;
+ nonce: string;
+ wrapped_key: string;
+}
+export type MemberInput = ({ name: string; subject?: never } | { subject: SubjectRef; name?: never }) & { role: Role; expires_at?: string; key_slot?: PeopleSlot };
+export interface Member { subject: SubjectRef; role: Role; expires_at?: string; added_by: SubjectRef; added_at: string; key_slot?: PeopleSlot }
+/** What the owner of a people-locked item sees per member: their current fingerprint, and whether it changed. */
+export interface MemberKeyInfo { key_fingerprint?: string | null; fingerprint_changed?: boolean }
/** Network's public projection of a person. An unknown subject has a null username. */
/** 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 Person { subject: SubjectRef; username: string | null; display_name: string | null; avatar_url: string | null }
export interface You { subject: SubjectRef; role: 'owner' | 'staff' }
export interface You { subject: SubjectRef; role: 'owner' | 'staff' }
export interface AccessRequestView extends Person { id: string; requested_at: string }
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 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 TransferView extends Omit<Transfer, 'to'> { to: Person }
-export interface Share { ref: ResourceRef; owner_subject: SubjectRef; audience: Audience; members: Member[]; transfer?: Transfer }
+export interface Share { ref: ResourceRef; owner_subject: SubjectRef; audience: Audience; lock: Lock; members: Member[]; transfer?: Transfer }
/** The store's enriched share: people in place of bare subjects, the caller's role and pending requests. */
/** 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'> {
export interface ShareView extends Omit<Share, 'members' | 'transfer'> {
owner: Person;
owner: Person;
- members: Array<Member & Person>;
+ members: Array<Member & Person & MemberKeyInfo>;
transfer?: TransferView;
transfer?: TransferView;
you: You;
you: You;
requests: AccessRequestView[];
requests: AccessRequestView[];
@@ -44,13 +59,19 @@ export interface ShareOptions {
@@ -44,13 +59,19 @@ export interface ShareOptions {
/** Transactional hooks: write local resource/media state through tx; never external I/O. */
/** Transactional hooks: write local resource/media state through tx; never external I/O. */
onOwnerChange?(ref: ResourceRef, from: SubjectRef, to: SubjectRef, tx: Tx): Promise<unknown>;
onOwnerChange?(ref: ResourceRef, from: SubjectRef, to: SubjectRef, tx: Tx): Promise<unknown>;
onAudienceChange?(ref: ResourceRef, audience: Audience, tx: Tx): Promise<unknown>;
onAudienceChange?(ref: ResourceRef, audience: Audience, tx: Tx): Promise<unknown>;
+ /**
+ * Stores a re-sealed envelope (a `locker.envelope@1` for the people who stay) when somebody is
+ * removed from a people-locked item. Called inside the transaction, before the member row goes.
+ * A service that allows people locks must pass it; otherwise a removal answers 409 share.reseal_unavailable.
+ */
+ onReseal?(ref: ResourceRef, envelope: Uint8Array, tx: Tx): Promise<unknown>;
now?(): number;
now?(): number;
nameLimit?: number;
nameLimit?: number;
nameWindowMs?: number;
nameWindowMs?: number;
}
}
export interface Shares {
export interface Shares {
migrate(): Promise<void>;
migrate(): Promise<void>;
- register(ref: ResourceRef, input: { owner_subject: SubjectRef; audience?: Audience }): Promise<Share>;
+ register(ref: ResourceRef, input: { owner_subject: SubjectRef; audience?: Audience; lock?: Lock }): Promise<Share>;
canView(viewer: Viewer | null, ref: ResourceRef): Promise<boolean>;
canView(viewer: Viewer | null, ref: ResourceRef): Promise<boolean>;
canEdit(viewer: Viewer | null, ref: ResourceRef): Promise<boolean>;
canEdit(viewer: Viewer | null, ref: ResourceRef): Promise<boolean>;
get(viewer: Viewer, ref: ResourceRef): Promise<ShareView>;
get(viewer: Viewer, ref: ResourceRef): Promise<ShareView>;
@@ -59,13 +80,14 @@ export interface Shares {
@@ -59,13 +80,14 @@ export interface Shares {
present(viewer: Viewer, ref: ResourceRef, share: Share): Promise<ShareView>;
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(). */
/** Exact @name → person (null when unknown, banned or deleted), on the same rate-limit budget as resolve(). */
lookup(viewer: Viewer, name: string): Promise<PeopleLookup>;
lookup(viewer: Viewer, name: string): Promise<PeopleLookup>;
- update(viewer: Viewer, ref: ResourceRef, input: { audience?: Audience; members?: MemberInput[]; notify?: boolean }, options?: { ifMatch?: string }): Promise<Share>;
+ update(viewer: Viewer, ref: ResourceRef, input: { audience?: Audience; members?: MemberInput[]; notify?: boolean; lock?: Lock; envelope?: string }, options?: { ifMatch?: string }): Promise<Share>;
setAudience(viewer: Viewer, ref: ResourceRef, audience: Audience): Promise<Share>;
setAudience(viewer: Viewer, ref: ResourceRef, audience: Audience): Promise<Share>;
addMembers(viewer: Viewer, ref: ResourceRef, members: Array<MemberInput | SubjectRef | string>): Promise<Share>;
addMembers(viewer: Viewer, ref: ResourceRef, members: Array<MemberInput | SubjectRef | string>): Promise<Share>;
- removeMember(viewer: Viewer, ref: ResourceRef, subject: SubjectRef): Promise<Share>;
+ /** On a people-locked item, `envelope` is required: the content re-sealed for those who stay. */
+ removeMember(viewer: Viewer, ref: ResourceRef, subject: SubjectRef, options?: { envelope?: string }): Promise<Share>;
requestAccess(viewer: Viewer, ref: ResourceRef): Promise<AccessRequest>;
requestAccess(viewer: Viewer, ref: ResourceRef): Promise<AccessRequest>;
dismiss(viewer: Viewer, ref: ResourceRef, requestId: string): Promise<Share>;
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>;
+ transfer(viewer: Viewer, ref: ResourceRef, input: { to: SubjectRef | string | { subject: SubjectRef } | { name: string }; keep_access?: boolean; key_slot?: PeopleSlot }): Promise<Transfer>;
accept(viewer: Viewer, ref: ResourceRef, transferId: string): Promise<Transfer>;
accept(viewer: Viewer, ref: ResourceRef, transferId: string): Promise<Transfer>;
decline(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>;
cancel(viewer: Viewer, ref: ResourceRef, transferId: string): Promise<Transfer>;
@@ -80,17 +102,26 @@ export function migrateShares(db: Db): Promise<void>;
@@ -80,17 +102,26 @@ export function migrateShares(db: Db): Promise<void>;
export class ShareError extends Error { status: number; code: string; constructor(status: number, code: string, message: string) }
export class ShareError extends Error { status: number; code: string; constructor(status: number, code: string, message: string) }
export interface ShareResourceClient {
export interface ShareResourceClient {
get(): Promise<ShareResponse>;
get(): Promise<ShareResponse>;
- update(input: { audience?: Audience; members?: MemberInput[]; notify?: boolean }, options?: { ifMatch?: string }): Promise<ShareResponse>;
+ update(input: { audience?: Audience; members?: MemberInput[]; notify?: boolean; lock?: Lock; envelope?: string }, options?: { ifMatch?: string }): Promise<ShareResponse>;
setAudience(audience: Audience): Promise<ShareResponse>;
setAudience(audience: Audience): Promise<ShareResponse>;
add(target: string | SubjectRef, options?: { role?: Role; expires_at?: string }): Promise<ShareResponse>;
add(target: string | SubjectRef, options?: { role?: Role; expires_at?: string }): Promise<ShareResponse>;
remove(subject: SubjectRef): Promise<ShareResponse>;
remove(subject: SubjectRef): Promise<ShareResponse>;
+ /** A people-locked item: looks up their key, wraps the content key for it, saves the member and the slot. */
+ addLocked(target: string | SubjectRef, options: { contentKey: Uint8Array | string; role?: Role; expires_at?: string }): Promise<ShareResponse>;
+ /**
+ * A people-locked item: removes a member with a re-seal. With `contentKey`, `envelope` is the item's
+ * current envelope, opened and sealed again for the people who stay (the owner keeps their slot);
+ * without it, `envelope` is the envelope already re-sealed. `keys` ({ subject, publicKey }[]) skips
+ * the key-directory lookups.
+ */
+ removeLocked(target: string | SubjectRef, options: { contentKey?: Uint8Array | string; envelope?: Uint8Array | { bytes: Uint8Array }; keys?: Array<{ subject: SubjectRef; publicKey: unknown }> }): Promise<ShareResponse>;
requestAccess(): Promise<AccessRequest>;
requestAccess(): Promise<AccessRequest>;
dismiss(requestId: string): Promise<ShareResponse>;
dismiss(requestId: string): Promise<ShareResponse>;
- transfer(to: string | SubjectRef, options?: { keep_access?: boolean }): Promise<Transfer | ShareResponse>;
+ transfer(to: string | SubjectRef, options?: { keep_access?: boolean; key_slot?: PeopleSlot }): Promise<Transfer | ShareResponse>;
accept(transferId: string): Promise<Transfer>;
accept(transferId: string): Promise<Transfer>;
decline(transferId: string): Promise<Transfer>;
decline(transferId: string): Promise<Transfer>;
cancel(transferId: string): Promise<ShareResponse>;
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 function share(options: { base: string; token?: string; fetch?: typeof globalThis.fetch; /** Where the key directory is; defaults to https://openvibe.network. */ network?: string }): { 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 const createShareClient: typeof share;
export function shareOpenApi(): { openapi: string; info: { title: string; version: string }; paths: Record<string, unknown>; components: Record<string, unknown> };
export function shareOpenApi(): { openapi: string; info: { title: string; version: string }; paths: Record<string, unknown>; components: Record<string, unknown> };