openvibers/openvibe.sdk Commits

Support large staged Locker shares and restored key exports (#141)

OP OpenVibers committed 5 days ago · 12dd5a8d46 · 18 files, +149 −31

* Support staged Locker sharing and restored key exports

* Regenerate browser bundle for restored Locker key exports

* 0.101.0: streamed Locker envelopes, staged share reseals, key export after recovery

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 4197410..48de598 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,11 @@
# Changelog
++## 0.101.0 (2026-11-08)
++
++- Add bounded `locker/server.inspectEnvelopePrefix` validation against an actual stored ciphertext byte count.
++- Allow service-bound staged envelope receipts in share updates, with the existing recipient, nonce and atomic reseal checks.
++- Allow Locker public/private key export after `importPersonKeys`, so a recovered locker can upload and share using the same WebCrypto operations.
++
## 0.100.0 (2026-11-08)
- Add `openvibe-sdk/compute`: shared compute strategies, hard placement constraints, RAM-aware pool scaling, exact billing arithmetic and provider quote/lifecycle adapters. See `docs/compute.md` for gates and unsupported purchase paths.
ddiff --git a/README.md b/README.md
iindex 3c0d07b..1effacb 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.100.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.101.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.
@@@ -11,7 +11,7 @@
After v0.97.1 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.100.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.101.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).
ddiff --git a/STATUS.json b/STATUS.json
iindex 9396313..ee7eb5b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.100.0",
++ "version": "0.101.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex c44a99d..53264bd 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.100.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.101.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.100.0';
++const SDK_VERSION = '0.101.0';
module.exports = {
createClient,
@@@ -1360,7 +1360,7 @@ async function exportPersonKeys(person) {
const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey));
const message = fingerprintMessage(ecdhPublic, signingPublic);
const signature = person.fingerprintSignature
-- || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, person.signing.privateKey, message));
++ || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, (person.signing?.privateKey || person.signingPrivateKey), message));
return {
ecdh: ecdhPublic,
signing: signingPublic,
@@@ -1373,8 +1373,8 @@ async function exportPersonKeys(person) {
* The private half of a person's keys, as it is stored: the two private keys as JWKs.
*/
async function exportPrivateKeys(person) {
-- const ecdh = await subtle().exportKey('jwk', person.ecdh.privateKey);
-- const signing = await subtle().exportKey('jwk', person.signing.privateKey);
++ const ecdh = await subtle().exportKey('jwk', person.ecdh?.privateKey || person.ecdhPrivateKey);
++ const signing = await subtle().exportKey('jwk', person.signing?.privateKey || person.signingPrivateKey);
return { ecdh, signing };
}
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex ed7e1c5..3c8b6d5 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -766,6 +766,7 @@
"openvibe-sdk/locker/server": {
"checkHeader": "function",
"inspectEnvelope": "function",
++ "inspectEnvelopePrefix": "function",
"peopleSlotOk": "function"
},
"openvibe-sdk/locker/vectors": {
ddiff --git a/docs/share.md b/docs/share.md
iindex b09ea01..7a5e60d 100644
---- a/docs/share.md
++++ b/docs/share.md
@@@ -117,3 +117,20 @@ await recipientItem.accept(offer.transfer.id);
The client also has `get`, `update`, `remove(subject)`, `requestAccess`, `dismiss(requestId)`, `decline(id)`, `cancel(id)`, and service-level `sharedWithMe` and `people('@name')`. Member additions/removals read the ETag and send a conditional PUT, so they fail visibly on concurrent ACL changes. Network failures on mutations are not automatically retried. Read again to determine the result before retrying. Server CJS, ESM and TypeScript are exported from `openvibe-sdk/share`; the browser export is disabled.
Run `node test/share.test.js` for the database and HTTP security suite. It covers all audience/role combinations, hidden 404s, expiration, audited staff access, exact-name resolution/rate limits, owner notification, all transfer answers, keep_access, forwarded offers, concurrency, hook rollback, immutable audit, durable retry, schemas and the agent client against a real local HTTP server.
++
++## Streamed file reseals
++
++A service that stores large ciphertext uploads can support `envelope_ref` on `update` instead of embedding an
++`envelope` in JSON. Existing callers are unchanged. Supply `resolveReseal(ref, viewer, reference)`, returning
++`{ prefix, byteLength }` from a **complete, validated upload bound to that resource and authenticated owner**.
++`prefix` contains the eight-byte envelope preamble and complete header (at most 65544 bytes); `byteLength` is
++the actual counted ciphertext size. Never resolve arbitrary URLs or trust a client-declared size.
++
++The store checks the header, exact expected size, lock kind, fresh nonce and the departing people's slots with
++the same checks as inline reseals. `onResealReference(ref, reference, tx)` then attaches that stored object and
++consumes its receipt in the ACL transaction. The hook must recheck ownership, expiry, resource revision and
++quota in that transaction. Failed ACL writes must leave the old content and ACL intact; abandoned uploads
++must expire. A service without these hooks refuses receipts. The same request cannot carry both forms.
++
++`openvibe-sdk/locker/server.inspectEnvelopePrefix(prefix, actualByteLength)` exposes the bounded header and
++size check for streaming storage adapters. It never authenticates ciphertext; only the client can do that.
ddiff --git a/esm/locker-server.mjs b/esm/locker-server.mjs
iindex 8d020bd..8a5526f 100644
---- a/esm/locker-server.mjs
++++ b/esm/locker-server.mjs
@@@ -2,6 +2,6 @@
import cjs from '../src/locker/server.js';
export const {
-- inspectEnvelope, checkHeader, peopleSlotOk,
++ inspectEnvelope, inspectEnvelopePrefix, checkHeader, peopleSlotOk,
} = cjs;
export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex 2b9bafc..23c0d01 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.100.0",
++ "version": "0.101.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.100.0",
++ "version": "0.101.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 6784b15..c2cec02 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.100.0",
++ "version": "0.101.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",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 6172221..3059e94 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.100.0';
++const SDK_VERSION = '0.101.0';
module.exports = {
createClient,
ddiff --git a/src/locker/keys.js b/src/locker/keys.js
iindex ab20e66..8e5e54f 100644
---- a/src/locker/keys.js
++++ b/src/locker/keys.js
@@@ -121,7 +121,7 @@ async function exportPersonKeys(person) {
const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey));
const message = fingerprintMessage(ecdhPublic, signingPublic);
const signature = person.fingerprintSignature
-- || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, person.signing.privateKey, message));
++ || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, (person.signing?.privateKey || person.signingPrivateKey), message));
return {
ecdh: ecdhPublic,
signing: signingPublic,
@@@ -134,8 +134,8 @@ async function exportPersonKeys(person) {
* The private half of a person's keys, as it is stored: the two private keys as JWKs.
*/
async function exportPrivateKeys(person) {
-- const ecdh = await subtle().exportKey('jwk', person.ecdh.privateKey);
-- const signing = await subtle().exportKey('jwk', person.signing.privateKey);
++ const ecdh = await subtle().exportKey('jwk', person.ecdh?.privateKey || person.ecdhPrivateKey);
++ const signing = await subtle().exportKey('jwk', person.signing?.privateKey || person.signingPrivateKey);
return { ecdh, signing };
}
ddiff --git a/src/locker/server.js b/src/locker/server.js
iindex e1a555d..2a69cc8 100644
---- a/src/locker/server.js
++++ b/src/locker/server.js
@@@ -108,7 +108,7 @@ function checkHeader(header) {
* Reads an envelope's header and checks its shape and its size. Nothing is decrypted and no key
* is needed: this is what a service runs on bytes it may never be able to read.
*/
--function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
++function inspectBytes(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}, totalBytes = bytes && bytes.length) {
const errors = [];
const data = bytes instanceof Uint8Array ? bytes : null;
if (!data) return { valid: false, errors: ['the envelope is not bytes'], header: null };
@@@ -126,7 +126,8 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
let header = null;
try { header = JSON.parse(fromUtf8(data.subarray(8, 8 + headerLength))); }
catch { return { valid: false, errors: ['the envelope header is not JSON'], header: null }; }
-- errors.push(...checkHeader(header));
++ if (!header || typeof header !== 'object' || Array.isArray(header)) return { valid: false, errors: ['the header is not an object'], header: null };
++ try { errors.push(...checkHeader(header)); } catch { errors.push('the slots are not an object'); }
const chunkOffset = 8 + headerLength;
let expectedBytes = null;
if (Number.isInteger(header.padded_size) && header.padded_size >= MIN_PADDED_SIZE
@@@ -138,7 +139,7 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
const whole = (chunks - 1) * (frame + CHUNK_SIZE + TAG_SIZE);
const last = frame + lastChunkSize(header.padded_size) + TAG_SIZE;
expectedBytes = chunkOffset + whole + last;
-- if (data.length < expectedBytes) errors.push('the envelope is shorter than its own header says');
++ if (totalBytes < expectedBytes) errors.push('the envelope is shorter than its own header says');
}
return {
valid: errors.length === 0,
@@@ -153,4 +154,19 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
};
}
--module.exports = { inspectEnvelope, checkHeader, peopleSlotOk };
++function inspectEnvelope(bytes, options) { return inspectBytes(bytes, options); }
++
++/** A streamed upload: inspect just its bounded header against the ACTUAL stored byte count.
++ * The caller must have counted and bounded the complete ciphertext first, not trust Content-Length.
++ * No chunks are read or authenticated here; as with inspectEnvelope, only a client can do that.
++ */
++function inspectEnvelopePrefix(prefix, totalBytes, options) {
++ const info = inspectBytes(prefix, options, totalBytes);
++ if (!Number.isSafeInteger(totalBytes) || !Number.isSafeInteger(info.expectedBytes) || totalBytes !== info.expectedBytes) {
++ info.errors.push('the stored byte count does not match the envelope');
++ }
++ if (info.header && !Number.isSafeInteger(info.header.padded_size)) info.errors.push('the padded size is not a safe integer');
++ info.valid = info.errors.length === 0;
++ return info;
++}
++module.exports = { inspectEnvelope, inspectEnvelopePrefix, checkHeader, peopleSlotOk };
ddiff --git a/src/share/store.js b/src/share/store.js
iindex 6d20db7..7515dbe 100644
---- a/src/share/store.js
++++ b/src/share/store.js
@@@ -1,7 +1,7 @@
'use strict';
const { randomUUID } = require('node:crypto');
const { newEventId } = require('../core/ids');
--const { inspectEnvelope } = require('../locker/server');
++const { inspectEnvelope, inspectEnvelopePrefix } = require('../locker/server');
const { jwkThumbprint } = require('../locker/keys');
const v = require('./validation');
const { migrateShares } = require('./schema');
@@@ -21,7 +21,7 @@ const memberShape = row => ({ subject: row.subject, role: row.role, ...(row.expi
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, onReseal, now = Date.now, nameLimit = 30, nameWindowMs = 60000 } = {}) {
++function createShares({ db, network, events, notify, onOwnerChange, onAudienceChange, onReseal, resolveReseal, onResealReference, 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');
}
@@@ -193,11 +193,21 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
if (!read.valid || read.lock !== 'people') v.fail(400, 'share.reseal_invalid', 'A re-seal is a valid people-locked envelope');
return bytes;
}
++ async function preparedEnvelope(ref, viewer, reference) {
++ if (typeof reference !== 'string' || !/^[A-Za-z0-9_-]{1,160}$/.test(reference)) v.invalid();
++ if (typeof resolveReseal !== 'function' || typeof onResealReference !== 'function') v.fail(409, 'share.reseal_unavailable', 'This service cannot attach a staged envelope');
++ // This hook is trusted service code. It binds a stored, complete upload to this resource and actor.
++ const stored = await resolveReseal(ref, viewer, reference);
++ if (!stored || !(stored.prefix instanceof Uint8Array) || stored.prefix.length > 65544) v.fail(400, 'share.reseal_invalid', 'The staged header is invalid');
++ const info = inspectEnvelopePrefix(stored.prefix, stored.byteLength);
++ if (!info.valid || info.lock !== 'people') v.fail(400, 'share.reseal_invalid', 'The staged upload is not a complete people-locked envelope');
++ return { prepared: true, reference, info, length: stored.byteLength };
++ }
/** 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);
++ if (bytes.prepared ? typeof onResealReference !== 'function' : typeof onReseal !== 'function') v.fail(409, 'share.reseal_unavailable', 'This service cannot store a re-sealed envelope');
++ const read = bytes.prepared ? bytes.info : 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.
@@@ -224,7 +234,8 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
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);
++ if (bytes.prepared) await onResealReference(ref, bytes.reference, t);
++ else 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 });
@@@ -314,7 +325,7 @@ 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', 'lock', 'envelope']);
++ const ref = v.ref(input); v.object(body, ['audience', 'members', 'notify', 'lock', 'envelope', 'envelope_ref']);
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();
@@@ -325,7 +336,9 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
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);
++ if (body.envelope !== undefined && body.envelope_ref !== undefined) v.invalid();
++ const resealed = body.envelope_ref !== undefined ? await preparedEnvelope(ref, viewer, body.envelope_ref)
++ : 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');
ddiff --git a/test/locker.test.js b/test/locker.test.js
iindex b8d48a3..21149de 100644
---- a/test/locker.test.js
++++ b/test/locker.test.js
@@@ -107,6 +107,12 @@ run([
const published = await locker.exportPersonKeys(alice);
assert.equal(published.fingerprint, alice.fingerprint);
assert.equal(await locker.verifyPersonKeys(published), true);
++ const privateBackup = await locker.exportPrivateKeys(alice);
++ const restored = await locker.importPersonKeys(privateBackup);
++ const restoredPublic = await locker.exportPersonKeys(restored);
++ assert.equal(restoredPublic.fingerprint, published.fingerprint, 'an unlocked backup can publish its own public keys');
++ assert.equal(await locker.verifyPersonKeys(restoredPublic), true);
++ assert.deepEqual(await locker.exportPrivateKeys(restored), privateBackup);
// And the published keys are the contract.
const checked = contracts.validate('locker.public-keys', {
subject: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' },
ddiff --git a/test/share-staged.test.js b/test/share-staged.test.js
nnew file mode 100644
iindex 0000000..7485e11
---- /dev/null
++++ b/test/share-staged.test.js
@@@ -0,0 +1,52 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { createDb } = require('../src/db');
++const { createShares } = require('../src/share');
++const { inspectEnvelopePrefix } = require('../src/locker/server');
++const locker = require('../src/locker');
++(async () => {
++ const db = createDb({ pglite: true });
++ const subject = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' };
++ const other = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR' };
++ const person = await locker.generatePersonKeys(), friend = await locker.generatePersonKeys();
++ const friendPublic = await locker.exportPersonKeys(friend);
++ const ref = { service: 'locker', kind: 'files', id: 'streamed-file' };
++ const sealed = await locker.seal(new Uint8Array(9 * 1024 ** 2), { lock: 'people', keys: [{ subject, publicKey: person.ecdhPublic }, { subject: other, publicKey: friend.ecdhPublic }] });
++ const prefix = sealed.bytes.subarray(0, sealed.chunkOffset);
++ assert.ok(inspectEnvelopePrefix(prefix, sealed.bytes.length).valid);
++ assert.equal(inspectEnvelopePrefix(prefix, sealed.bytes.length - 1).valid, false);
++ assert.equal(inspectEnvelopePrefix(prefix, sealed.bytes.length + 1).valid, false);
++ assert.equal(inspectEnvelopePrefix(prefix.subarray(0, -1), sealed.bytes.length).valid, false);
++ const stored = new Map([['upload-1', { prefix, byteLength: sealed.bytes.length }]]);
++ let committed = 0, failCommit = false;
++ const shares = createShares({ db, network: { json: async (req) => {
++ if (req.path.endsWith('/keys')) return friendPublic;
++ if (req.path.endsWith('resolve-batch')) return { results: { [subject.id]: { subject, username: 'owner' }, [other.id]: { subject: other, username: 'friend' } } };
++ return { subject: other, username: 'friend' };
++ } }, events: { publish: async () => {} }, notify: async () => {},
++ resolveReseal: async (resource, viewer, receipt) => {
++ assert.deepEqual(resource, ref); assert.deepEqual(viewer.subject, subject); return stored.get(receipt);
++ }, onResealReference: async (_ref, receipt, tx) => {
++ if (failCommit) throw new Error('storage conflict');
++ await tx.exec('INSERT INTO attachments(receipt) VALUES ($1)', [receipt]); committed++;
++ } });
++ await shares.migrate(); await db.exec('CREATE TABLE attachments(receipt text PRIMARY KEY)');
++ await shares.register(ref, { owner_subject: subject, lock: 'people', audience: 'people' });
++ const member = { subject: other, role: 'viewer', key_slot: sealed.header.slots.people[1] };
++ await assert.rejects(() => shares.update({ subject: other }, ref, { envelope_ref: 'upload-1' }), (e) => e.status === 404);
++ await assert.rejects(() => shares.update({ subject }, ref, { envelope_ref: 'missing' }), (e) => e.code === 'share.reseal_invalid');
++ await assert.rejects(() => shares.update({ subject }, ref, { envelope_ref: 'upload-1', envelope: 'x' }), (e) => e.status === 400);
++ failCommit = true;
++ await assert.rejects(() => shares.update({ subject }, ref, { members: [member], envelope_ref: 'upload-1' }), /storage conflict/);
++ assert.equal(await shares.canView({ subject: other }, ref), false);
++ failCommit = false;
++ await shares.update({ subject }, ref, { members: [member], envelope_ref: 'upload-1' });
++ assert.equal(committed, 1); assert.equal(await shares.canView({ subject: other }, ref), true);
++ await assert.rejects(() => shares.update({ subject }, ref, { members: [], envelope_ref: 'upload-1' }), (e) => e.code === 'share.reseal_not_fresh');
++ const remaining = await locker.seal('private again', { lock: 'people', keys: [{ subject, publicKey: person.ecdhPublic }] });
++ stored.set('upload-2', { prefix: remaining.bytes.subarray(0, remaining.chunkOffset), byteLength: remaining.bytes.length });
++ await shares.update({ subject }, ref, { members: [], envelope_ref: 'upload-2' });
++ assert.equal(committed, 2); assert.equal(await shares.canView({ subject: other }, ref), false);
++ assert.equal(await db.value('SELECT COUNT(*)::int FROM attachments'), 2);
++ await db.close(); console.log('share-staged: bounded inspection, authorized receipts, rollback, fresh-key removal');
++})().catch((e) => { console.error(e); process.exitCode = 1; });
ddiff --git a/types/locker-server.d.ts b/types/locker-server.d.ts
iindex 8eb4114..7339ed9 100644
---- a/types/locker-server.d.ts
++++ b/types/locker-server.d.ts
@@@ -27,6 +27,9 @@ export interface EnvelopeInfo {
/** Reads an envelope's header and checks its shape and its size. Nothing is decrypted. */
export function inspectEnvelope(bytes: Uint8Array, options?: { maxHeaderBytes?: number }): EnvelopeInfo;
++/** Check a bounded header prefix against the actual complete stored ciphertext size. */
++export function inspectEnvelopePrefix(prefix: Uint8Array, totalBytes: number, options?: { maxHeaderBytes?: number }): EnvelopeInfo;
++
/** The header's shape: what it says, and whether the slots match the lock it names. */
export function checkHeader(header: unknown): string[];
ddiff --git a/types/locker.d.ts b/types/locker.d.ts
iindex f61c04e..552dc6d 100644
---- a/types/locker.d.ts
++++ b/types/locker.d.ts
@@@ -159,9 +159,9 @@ export function rewrap(envelope: Envelope | Uint8Array, options?: RewrapOptions)
/** A person's two key pairs, made in the browser. */
export function generatePersonKeys(): Promise<PersonKeys>;
/** The public half of a person's keys, as it is published. */
--export function exportPersonKeys(person: PersonKeys): Promise<PublicKeys>;
++export function exportPersonKeys(person: PersonKeys | Awaited<ReturnType<typeof importPersonKeys>>): Promise<PublicKeys>;
/** The private half of a person's keys, as it is stored. */
--export function exportPrivateKeys(person: PersonKeys): Promise<{ ecdh: Record<string, unknown>; signing: Record<string, unknown> }>;
++export function exportPrivateKeys(person: PersonKeys | Awaited<ReturnType<typeof importPersonKeys>>): Promise<{ ecdh: Record<string, unknown>; signing: Record<string, unknown> }>;
/** A person's keys, ready to use. */
export function importPersonKeys(data: { ecdh?: Record<string, unknown>; signing?: Record<string, unknown>; ecdhPrivateKey?: CryptoKey; signingPrivateKey?: CryptoKey }): Promise<{ ecdhPrivateKey: CryptoKey; signingPrivateKey: CryptoKey; ecdhPublic: PublicKeyJwk; signingPublic: PublicKeyJwk }>;
/** Checks a published set of keys: the signature and the eight words. */
ddiff --git a/types/share.d.ts b/types/share.d.ts
iindex e4a3b4c..73b2d57 100644
---- a/types/share.d.ts
++++ b/types/share.d.ts
@@@ -65,6 +65,10 @@ export interface ShareOptions {
* 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>;
++ /** Resolve a fully received ciphertext upload, bound to this resource and authenticated viewer. Count actual bytes, not a client declaration. */
++ resolveReseal?(ref: ResourceRef, viewer: Viewer, reference: string): Promise<{ prefix: Uint8Array; byteLength: number }>;
++ /** Atomically attach that upload and consume its reference. Recheck owner, resource, expiry and revision through tx. Never external I/O. */
++ onResealReference?(ref: ResourceRef, reference: string, tx: Tx): Promise<unknown>;
now?(): number;
nameLimit?: number;
nameWindowMs?: number;
@@@ -80,7 +84,7 @@ 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; lock?: Lock; envelope?: string }, options?: { ifMatch?: string }): Promise<Share>;
++ update(viewer: Viewer, ref: ResourceRef, input: { audience?: Audience; members?: MemberInput[]; notify?: boolean; lock?: Lock; envelope?: string; envelope_ref?: 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>;
/** On a people-locked item, `envelope` is required: the content re-sealed for those who stay. */
@@@ -102,7 +106,7 @@ 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; lock?: Lock; envelope?: string }, options?: { ifMatch?: string }): Promise<ShareResponse>;
++ update(input: { audience?: Audience; members?: MemberInput[]; notify?: boolean; lock?: Lock; envelope?: string; envelope_ref?: 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>;