openvibers/openvibe.zone Commits

Zone: shared buckets, Specific people or Only me (sharing step 4) (#152)

OP OpenVibers committed 6 days ago · 7003e2adc7 · 11 files, +447 −6

* Zone: shared buckets, Specific people or Only me (sharing step 4)

Every bucket gets an openvibe-sdk/share row, owned by the person who made it,
Only me by default. The Share button beside a bucket's actions opens the one
sheet; it offers Specific people and Only me, never Everyone or a public link,
because a bucket's objects are the project's and are reached with its S3 keys.
GET /api/v1/zones/:zone answers a named person with the console record (never
its keys or objects); the project, its list and its writes stay project-only.
The uniform API is mounted at /api/v1/zone/:id/share.

Migrations 0005 (the SDK's tables and the bucket's maker) and 0006 (the
backfill: every existing bucket is Only me).

* Account export and deletion cover the share rows; keep drafts and members pages closed

Changes

ddiff --git a/migrations/0005_sharing.sql b/migrations/0005_sharing.sql
nnew file mode 100644
iindex 0000000..66e9aca
---- /dev/null
++++ b/migrations/0005_sharing.sql
@@@ -0,0 +1,81 @@
++-- phase: expand
++-- Sharing (design 2026-11-06, VISIBILITY-EVERYWHERE step 4): the tables openvibe-sdk/share v0.91.0's
++-- sharesSchema() creates, kept verbatim so Zone's database has the same shape as every other
++-- service that adopts it. Expand only: nothing is dropped or rewritten.
++
++-- phase: expand
++-- The one sharing system (openvibe-sdk/share v0.91.0), for the companions characters.
++-- Kept verbatim from openvibe-sdk src/share/schema.js, so the SDKs own store finds the tables it expects, and
++-- the test database (which applies this directory) has them too. The characters own rows live in
++-- exchange_characters; these rows are the audience, the members, the log and the delivery queue the SDK writes.
++
++
++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)
++);
++CREATE TABLE IF NOT EXISTS share_transfers (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
++ 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';
++CREATE TABLE IF NOT EXISTS share_access_requests (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_log (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
++DO $$ BEGIN
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
++ END IF;
++END $$;
++CREATE TABLE IF NOT EXISTS share_deliveries (
++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE TABLE IF NOT EXISTS share_name_limits (
++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
++);
++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
++-- 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;
++
++
++-- The person who made the bucket owns its share row (an older zone has none, so its row is Zone's).
++ALTER TABLE zone_zones ADD COLUMN IF NOT EXISTS created_by text COLLATE "C";
ddiff --git a/migrations/0006_sharing_backfill.sql b/migrations/0006_sharing_backfill.sql
nnew file mode 100644
iindex 0000000..57b16cf
---- /dev/null
++++ b/migrations/0006_sharing_backfill.sql
@@@ -0,0 +1,17 @@
++-- phase: migrate
++-- Every bucket that existed before sharing is registered once, so canView has a row for all of them.
++-- A bucket is private storage: everyone's rows are Only me, and the project's members keep the S3
++-- access they always had (their keys, not this row). Buckets with no recorded maker belong to Zone
++-- itself, which nobody can sign in as.
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT
++ 'zone', 'zone', z.id,
++ CASE
++ WHEN z.created_by LIKE 'usr\_%' THEN jsonb_build_object('type', 'user', 'id', z.created_by)
++ WHEN z.created_by LIKE 'gst\_%' THEN jsonb_build_object('type', 'guest', 'id', z.created_by)
++ ELSE jsonb_build_object('type', 'service', 'id', 'zone')
++ END,
++ 'private'
++FROM zone_zones z
++WHERE z.state <> 'deleted'
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 29d8afa..baaae18 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -14,8 +14,30 @@ const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
// Zone keeps no table of its own about a person: the telemetry samples are the SDK's own (see above).
const TABLES = [];
++/**
++ * The sharing system's own rows (openvibe-sdk/share): the SDK's table map cannot name a jsonb subject
++ * column, so they are exported and erased here, and the guard knows them by reason.
++ */
++async function extraExport(db, subject) {
++ const owned = await db.many("SELECT * FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['zone', subject]);
++ const member = await db.many("SELECT * FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['zone', subject]);
++ const asked = await db.many("SELECT * FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['zone', subject]);
++ return [{ name: 'shares.json', content: { owned, member_of: member, requests: asked } }];
++}
++
++async function extraErase(t, subjects) {
++ for (const subject of subjects) {
++ await t.exec("DELETE FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['zone', subject]);
++ await t.exec("DELETE FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['zone', subject]);
++ await t.exec("DELETE FROM share_transfers WHERE service = $1 AND (from_subject->>'id' = $2 OR to_subject->>'id' = $2)", ['zone', subject]);
++ await t.exec("DELETE FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['zone', subject]);
++ // A bucket stays with its project; its maker's id is the only thing about them left on it.
++ await t.exec('UPDATE zone_zones SET created_by = NULL WHERE created_by = $1', [subject]);
++ }
++}
++
function create({ db, log = console } = {}) {
-- return createAccountData({ db, service: 'zone', tables: TABLES, log });
++ return createAccountData({ db, service: 'zone', tables: TABLES, extraExport, extraErase, log });
}
module.exports = { create, TABLES, TOPICS };
ddiff --git a/server/app.js b/server/app.js
iindex 0e66cda..ca588a2 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -35,6 +35,7 @@ const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createClients } = require('./zone/clients');
const { createZones } = require('./zone/zones');
++const { createSharing, describeRef } = require('./sharing');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
@@@ -58,7 +59,9 @@ async function createApp(opts = {}) {
const principal = createPrincipal({ config, keys });
const clients = opts.clients || createClients({ config, fetchImpl });
const usage = createUsage({ s, log });
-- const zones = createZones({ s, clients, config, log, metering: usage });
++ // Sharing (openvibe-sdk/share): a bucket's share row, its people and the one access check for its console page.
++ const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });
++ const zones = createZones({ s, clients, config, log, metering: usage, sharing });
const mediaLimits = opts.mediaLimits || require('./zone/media-limits').createMediaLimits({ config, fetchImpl, log });
// The account export and deletion (ADR-033, server/account-data.js): what Zone holds about a person, and the
// erasure of it. The network sender posts Zone's part of an export back to Network with Zone's own token.
@@@ -66,7 +69,7 @@ async function createApp(opts = {}) {
const accountSend = opts.accountSend !== undefined ? opts.accountSend : (config.oauth.clientSecret ? createNetworkSender({
networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl,
}) : null);
-- const ctx = { config, s, keys, sso, principal, log, clients, zones, usage, mediaLimits, accountData, accountSend };
++ const ctx = { config, s, keys, sso, principal, log, clients, zones, usage, mediaLimits, accountData, accountSend, sharing };
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@@ -165,6 +168,18 @@ async function createApp(opts = {}) {
// public; a first-party service calls over loopback, unproxied, and is counted per caller instead.
const internal = (req) => !req.headers['x-forwarded-for'] && !req.headers['x-real-ip'] && /^(::1|::ffff:127\.|127\.)/.test(String(req.socket.remoteAddress || ''));
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.ZONE_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, skip: internal, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
++ // ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────
++ {
++ const shareApi = express.Router();
++ shareApi.use(principal.middleware);
++ shareApi.use(sharing.shares.router({
++ service: 'zone',
++ authenticate: (req) => (req.principal && req.principal.kind === 'user' ? { subject: { type: 'user', id: String(req.principal.requester).replace(/^user:/, '') }, staff: false } : null),
++ describe: describeRef(s.db, config),
++ }));
++ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|[^/]+\/[^/]+\/share(?:\/|$))/;
++ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
++ }
app.use('/api/v1', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 559dbe6..d45c236 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -71,6 +71,9 @@ function createApi(ctx) {
const row = await zones.byId(req.params.zone);
if (!row) throw new ZoneError(404, 'zone.unknown', 'no such zone');
try { await access(req, row.project_id, { write, env: row.env }); } catch (err) {
++ // A bucket shared with this signed-in person opens its console record (never its keys or objects).
++ const subject = !write && req.principal && req.principal.kind === 'user' ? String(req.principal.requester).replace(/^user:/, '') : null;
++ if (subject && ctx.sharing && await ctx.sharing.canView({ subject }, row.id).catch(() => false)) return row;
// Another project's zone, and a production zone to a sandbox app, do not exist for this caller.
if (err.code === 'zone.unknown_project' || err.code === 'zone.sandbox_only') throw new ZoneError(404, 'zone.unknown', 'no such zone');
throw err;
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex bbe5062..72021bf 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -22,6 +22,7 @@ const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const cache = require('openvibe-shared/cache-policy');
++const kit = require('openvibe-shared/kit');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
@@@ -244,7 +245,7 @@ ${projects.length ? html`<ul class="zn-cards">${projects.map((p) => html`<li cla
return html`<tr id="${z.id}"><td><code>${z.name}</code>${z.default ? html` <span class="zn-badge">default</span>` : ''}</td><td>${z.state}</td>
<td class="zn-n">${u ? `${bytes(u.stored_bytes)} · ${plural(u.objects, 'object', 'objects')}` : '—'}${monthLine(z.id) ? html`<br><span class="zn-muted">${monthLine(z.id)}</span>` : ''}</td>
<td class="zn-n">${z.quota_bytes ? bytes(z.quota_bytes) : (z.default ? 'environment' : '—')}</td><td>${day(z.created_at)}</td>
--<td>${admin && !z.default && z.state === 'active' ? html`<details><summary>Delete</summary><form method="post" action="/p/${project}/zones/${z.id}/delete">
++<td>${admin && z.state === 'active' ? html`<span class="zn-share">${raw(kit.share({ title: z.name, url: `${config.baseUrl}/p/${project}#${z.id}`, text: `A bucket in ${project}`, kind: 'zone', id: z.id }))}</span> ` : ''}${admin && !z.default && z.state === 'active' ? html`<details><summary>Delete</summary><form method="post" action="/p/${project}/zones/${z.id}/delete">
<p>This deletes every object in <code>${z.name}</code>, with no undo, and the name can never be used again.</p>
<label>Type the zone's name to confirm <input name="confirm" required autocomplete="off" pattern="${z.name}"></label>
<button class="zn-button zn-danger" type="submit">Delete ${z.name}</button></form></details>` : ''}</td></tr>`;
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..4a8ff38
---- /dev/null
++++ b/server/sharing.js
@@@ -0,0 +1,204 @@
++'use strict';
++
++/**
++ * Sharing for Zone buckets (design 2026-11-06, VISIBILITY-EVERYWHERE step 4; openvibe-sdk/share).
++ *
++ * The SDK owns the model and the rules: the `shares` row next to each bucket, the one access check
++ * (canView), members, access requests, transfers, the append-only log and the delivery queue. This
++ * file is the wiring a service supplies:
++ *
++ * - network.json Network's identity lookups (@name → subject, and a person's card for the sheet)
++ * - events.publish OpenVibe.Events, so share.* envelopes go out like every other event
++ * - notify Network's notification push; a refusal is logged, never allowed to stall the queue
++ * - onAudienceChange leaves the zone alone (a bucket is private storage: its access is its S3 keys), so only the share
++ * row decides who may read its console page
++ * - onOwnerChange moves zone_zones.created_by to the new owner
++ *
++ * A bucket is Only me. The sheet on it can name a teammate ("look at this before I hand over the
++ * keys"); a bucket is never Everyone or a public link, because its objects are the project's, not
++ * a page's. The project's members keep the S3 access they always had.
++ */
++const { createShares, ShareError } = require('openvibe-sdk/share');
++const { serviceAuth } = require('openvibe-contracts');
++
++const SERVICE = 'zone';
++const KIND = 'zone';
++const AUDIENCES = ['people', 'private'];
++/** A bucket has no public audience: any narrowing or widening is Only me or the people on its list. */
++const TO_SDK = { public: 'private', unlisted: 'private', people: 'people', private: 'private' };
++const FROM_SDK = { public: 'private', link: 'private', people: 'people', private: 'private' };
++
++const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
++
++/** Blog's viewer as the SDK wants it: a subject object, and the staff bit. */
++function shareViewer(v) {
++ if (!v || !v.subject) return null;
++ if (String(v.subject).startsWith('usr_')) return { subject: { type: 'user', id: v.subject }, staff: !!v.staff };
++ if (String(v.subject).startsWith('gst_')) return { subject: { type: 'guest', id: v.subject }, staff: !!v.staff };
++ return null;
++}
++
++function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {
++ // Zone has no generic outbox for arbitrary envelopes: share.* events go to Events with their own token.
++ const eventsToken = config.events && config.events.url && config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({
++ tokenUrl: `${String(config.networkInternalUrl || config.networkUrl).replace(/\/+$/, '')}/oauth/token`,
++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.events', scope: 'events.event.publish', fetchImpl,
++ }) : null;
++ async function publish(envelope) {
++ if (!eventsToken) return;
++ const res = await fetchImpl(config.events.url, { method: 'POST', headers: { ...(await eventsToken.authHeaders()), 'Content-Type': 'application/json' },
++ body: JSON.stringify(envelope), signal: AbortSignal.timeout(8000) });
++ if (!res.ok) throw new Error(`Events answered ${res.status}`);
++ }
++ const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');
++ const tokens = config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({
++ tokenUrl: `${base}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network',
++ scope: 'identity.subject.resolve network.notifications.push',
++ fetchImpl,
++ }) : null;
++
++ async function networkJson({ path, method = 'GET', json, query } = {}) {
++ if (!tokens) throw new Error('Network is not configured for this service');
++ const url = new URL(`${base}${path}`);
++ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));
++ const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };
++ if (json !== undefined) headers['Content-Type'] = 'application/json';
++ const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });
++ const body = await res.json().catch(() => null);
++ if (!res.ok) {
++ const err = new Error(`Network ${method} ${path} ${res.status}: ${(body && (body.detail || body.error)) || 'failed'}`);
++ err.status = res.status;
++ throw err;
++ }
++ return body;
++ }
++
++ /** One notification, addressed by canonical subject. It never carries a post's title or text. */
++ async function notify(n) {
++ const to = n && n.to;
++ if (!to || to.type !== 'user') return;
++ const words = {
++ 'share.added': ['A bucket was shared with you', 'Open it on OpenVibe.Zone.'],
++ 'share.access_requested': ['Someone asked for access to your bucket', 'Review the request on OpenVibe.Zone.'],
++ 'share.transfer.requested': ['Someone wants to give you a bucket', 'Accept or decline it on OpenVibe.Zone.'],
++ 'share.transfer.answered': ['A bucket transfer was answered', 'See what happened on OpenVibe.Zone.'],
++ }[n.kind] || ['Sharing on OpenVibe.Zone', 'There is something new for you.'];
++ try {
++ await networkJson({
++ path: '/internal/notifications/push', method: 'POST',
++ json: {
++ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],
++ service: 'zone', url: config.baseUrl,
++ category: 'social',
++ },
++ });
++ } catch (err) {
++ log.warn('[Sharing] notification not sent:', err.message);
++ }
++ }
++
++
++
++ async function onOwnerChange(ref, from, to, tx) {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return;
++ const owner = to && to.type === 'user' ? to.id : null;
++ await tx.exec('UPDATE zone_zones SET created_by = $1 WHERE id = $2', [owner, ref.id]);
++ }
++
++ const shares = createShares({
++ db,
++ network: { json: networkJson },
++ events: { publish },
++ notify,
++ onOwnerChange,
++ });
++
++ // A bucket has no public audience: the sheet offers two, and a hand-made request cannot widen it.
++ const onlyPeopleOrPrivate = (body) => {
++ if (body && body.audience !== undefined && !['people', 'private'].includes(body.audience)) {
++ throw new ShareError(400, 'share.invalid', 'A bucket can be shared with specific people or only you');
++ }
++ };
++ const rawUpdate = shares.update, rawSetAudience = shares.setAudience;
++ shares.update = (viewer, ref, body, opts) => { onlyPeopleOrPrivate(body); return rawUpdate(viewer, ref, body, opts); };
++ shares.setAudience = (viewer, ref, audience) => {
++ if (!['people', 'private'].includes(audience)) throw new ShareError(400, 'share.invalid', 'A bucket can be shared with specific people or only you');
++ return rawSetAudience(viewer, ref, audience);
++ };
++
++ const sv = (viewer) => shareViewer(viewer);
++ const at = (id) => refOf(id);
++
++ return {
++ shares,
++ SERVICE,
++ KIND,
++ AUDIENCES,
++ refOf,
++ shareViewer,
++ /** Register a post with the sharing system (inside the post's own transaction). */
++ register: async (viewer, id, audience = 'private') => await shares.register(at(id), {
++ owner_subject: viewer && viewer.subject ? { type: String(viewer.subject).startsWith('gst_') ? 'guest' : 'user', id: viewer.subject } : { type: 'service', id: SERVICE },
++ audience: TO_SDK[audience] || 'private',
++ }),
++ /**
++ * The service's own audience move (a create form, the editor's select, a publish). The caller
++ * has already passed access.canWrite and owns the column and the Search sync; this only keeps
++ * the share row in step. The SDK's owner check is for people using the sheet.
++ */
++ syncAudience: async (id, audience) => {
++ const next = TO_SDK[audience] || 'private';
++ await db.tx(async (t) => {
++ const row = await t.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3 FOR UPDATE', [SERVICE, KIND, String(id)]);
++ if (!row || row.audience === next) return;
++ await t.exec('UPDATE shares SET audience = $1, revision = revision + 1 WHERE service = $2 AND kind = $3 AND resource_id = $4', [next, SERVICE, KIND, String(id)]);
++ });
++ },
++ shareOf: async (id) => await db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, String(id)]),
++ canView: async (viewer, id) => await shares.canView(sv(viewer), at(id)),
++ canEdit: async (viewer, id) => await shares.canEdit(sv(viewer), at(id)),
++ get: async (viewer, id) => await shares.get(sv(viewer), at(id)),
++ getWithRevision: async (viewer, id) => await shares.getWithRevision(sv(viewer), at(id)),
++ setAudience: async (viewer, id, audience) => await shares.setAudience(sv(viewer), at(id), TO_SDK[audience] || 'private'),
++ addMembers: async (viewer, id, list) => await shares.addMembers(sv(viewer), at(id), list),
++ removeMember: async (viewer, id, subject) => await shares.removeMember(sv(viewer), at(id), subject),
++ update: async (viewer, id, body, opts) => await shares.update(sv(viewer), at(id), body, opts),
++ requestAccess: async (viewer, id) => await shares.requestAccess(sv(viewer), at(id)),
++ dismiss: async (viewer, id, requestId) => await shares.dismiss(sv(viewer), at(id), requestId),
++ transfer: async (viewer, id, input) => await shares.transfer(sv(viewer), at(id), input),
++ accept: async (viewer, id, transferId) => await shares.accept(sv(viewer), at(id), transferId),
++ decline: async (viewer, id, transferId) => await shares.decline(sv(viewer), at(id), transferId),
++ cancel: async (viewer, id, transferId) => await shares.cancel(sv(viewer), at(id), transferId),
++ lookup: async (viewer, name) => await shares.lookup(sv(viewer), name),
++ sharedWithMe: async (viewer, opts) => await shares.sharedWithMe(sv(viewer), { service: SERVICE, ...(opts || {}) }),
++ /** Expire members and transfers, then send queued events and notifications. */
++ work: async ({ flush = true } = {}) => {
++ let expired = 0;
++ try { expired = await shares.expireMembers(); } catch (err) { log.warn('[Sharing] expiry sweep:', err.message); }
++ let sent = 0;
++ if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[Sharing] deliveries:', err.message); } }
++ return { expired, sent };
++ },
++ };
++}
++
++/** The uniform API's describe(ref): the sheet shows the post's title and link to its owner. */
++function describeRef(db, config) {
++ return async (ref) => {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return {};
++ const zone = await db.maybe('SELECT id, project_id, name, env FROM zone_zones WHERE id = $1', [ref.id]);
++ if (!zone) return {};
++ return {
++ title: `${zone.name} (${zone.env})`,
++ link: `${String(config.baseUrl || '').replace(/\/+$/, '')}/p/${zone.project_id}#${zone.id}`,
++ roles: ['viewer'],
++ audiences: AUDIENCES,
++ transferable: true,
++ };
++ };
++}
++
++module.exports = { createSharing, describeRef, shareViewer, refOf, AUDIENCES, TO_SDK, FROM_SDK, SERVICE, KIND };
ddiff --git a/server/zone/zones.js b/server/zone/zones.js
iindex 9fa8a72..ef6b2a6 100644
---- a/server/zone/zones.js
++++ b/server/zone/zones.js
@@@ -28,7 +28,7 @@ class ZoneError extends Error {
const fail = (status, code, detail) => { throw new ZoneError(status, code, detail); };
const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
--function createZones({ s, clients, config, log = console, metering = null }) {
++function createZones({ s, clients, config, log = console, metering = null, sharing = null }) {
const { db } = s;
/** The zone.object-zone@1 of a row (usage when given; deletion progress while it is deleting or deleted). */
@@@ -126,6 +126,12 @@ function createZones({ s, clients, config, log = console, metering = null }) {
await db.exec('INSERT INTO zone_idempotency (caller, idempotency_key, digest, zone_id, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING',
[caller, idempotencyKey, digest, row.id, s.now()]);
}
++ // A new bucket is Only me. Its row records the person who made it, so their Share sheet owns it.
++ if (sharing) {
++ const maker = /^user:usr_/.test(String(caller)) ? caller.slice(5) : null;
++ await db.exec('UPDATE zone_zones SET created_by = $2 WHERE id = $1', [row.id, maker]);
++ await sharing.register(maker ? { subject: maker } : null, row.id, 'private');
++ }
return { zone: record(row), repeat: false };
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 0db741e..5845295 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -24,7 +24,12 @@ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'ne
const db = t.ctx.s.db;
try {
await check('every table that names a person is covered or exempted with a reason', async () => {
-- const missing = await uncovered(db, { tables: TABLES, exempt: { account_data_events: 'the ledger of requests itself: which export and deletion Zone applied' } });
++ const missing = await uncovered(db, { tables: TABLES, exempt: {
++ account_data_events: 'the ledger of requests itself: which export and deletion Zone applied',
++ share_members: 'openvibe-sdk/share: its subject is jsonb, so the SDK table map cannot name it; extraExport/extraErase cover it',
++ share_access_requests: 'openvibe-sdk/share: jsonb subject, covered by extraExport/extraErase',
++ zone_zones: 'a bucket belongs to its project; its maker is in created_by, which extraErase clears (the bucket stays)',
++ } });
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
});
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex f3cda17..a9dcf91 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -320,6 +320,21 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
return await projectsApi(req, raw, json, u, parts, url.searchParams);
}
++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {
++ const body = JSON.parse(raw.toString('utf8') || '{}');
++ const results = {};
++ for (const id of body.subject_ids || []) {
++ const u = [...st.users.values()].find((x) => x.subject === id);
++ results[id] = u ? { subject: { type: 'user', id }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false } : null;
++ }
++ return json(200, { results });
++ }
++ if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {
++ const name = (url.searchParams.get('username') || '').toLowerCase();
++ const u = [...st.users.values()].find((x) => x.username.toLowerCase() === name);
++ return u ? json(200, { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false }) : json(404, { error: 'no such person' });
++ }
++ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { sent: true });
return json(404, { error: 'not found' });
});
issuer = srv.url;
ddiff --git a/test/zone-sharing.test.js b/test/zone-sharing.test.js
nnew file mode 100644
iindex 0000000..ffc8de4
---- /dev/null
++++ b/test/zone-sharing.test.js
@@@ -0,0 +1,72 @@
++'use strict';
++/**
++ * Sharing on Zone buckets (design 2026-11-06, VISIBILITY-EVERYWHERE step 4; openvibe-sdk/share).
++ * A bucket is private storage: its objects are the project's, reached with its S3 keys. The Share
++ * sheet names a teammate who may read its console record; it is never Everyone or a public link.
++ */
++const assert = require('assert');
++const { boot } = require('./helpers/boot');
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 5).join('\n ')}\n`); }
++}
++
++(async () => {
++ const media = {
++ async provision() { return { applied: true }; },
++ async report() { return null; },
++ async memberRole(project, subject) {
++ const m = t.network.state.members.get(project);
++ const p = t.network.state.projects.get(project);
++ return m && p && !p.archived_at ? (m.get(subject) || null) : null;
++ },
++ async listKeys() { return []; },
++ };
++ const t = await boot({ clients: media });
++ const owner = t.network.addUser('owen');
++ const mate = t.network.addUser('mia');
++ const stranger = t.network.addUser('sam');
++ const mk = await fetch(`${t.network.url}/api/v1/projects`, { method: 'POST', headers: { Authorization: `Bearer ${t.network.userToken(owner)}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Shop' }) });
++ const project = (await mk.json()).id;
++ t.network.state.members.get(project).set(mate.subject, 'viewer');
++ t.network.setEnvironmentPolicy(project, 'sandbox+production');
++ const same = { 'sec-fetch-site': 'same-origin' };
++
++ await check('a new bucket is registered Only me, owned by the person who made it', async () => {
++ const r = await t.get(`/api/v1/projects/${project}/zones`, { as: owner, json: { env: 'production', name: 'assets' }, headers: same });
++ assert.strictEqual(r.status, 201, r.text);
++ const zone = r.json();
++ const s = await t.ctx.s.db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['zone', 'zone', zone.id]);
++ assert.strictEqual(s.audience, 'private');
++ assert.deepStrictEqual(s.owner_subject, { type: 'user', id: owner.subject });
++ assert.strictEqual((await t.get(`/api/v1/zones/${zone.id}`, { as: owner })).status, 200);
++ assert.strictEqual((await t.get(`/api/v1/zones/${zone.id}`, { as: stranger })).status, 404, 'a stranger is not a project member');
++ });
++
++ await check('Specific people: the named person reads the bucket record; the project stays closed to them', async () => {
++ const list = await t.get(`/api/v1/projects/${project}/zones`, { as: owner });
++ const zone = list.json().zones.find((z) => z.name === 'assets');
++ const put = await t.get(`/api/v1/zone/${zone.id}/share`, { as: owner, method: 'PUT', json: { audience: 'people', members: [{ subject: { type: 'user', id: stranger.subject }, role: 'viewer' }] }, headers: { 'x-requested-with': 'ov-share' } });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.strictEqual(put.json().audience, 'people');
++ assert.strictEqual((await t.get(`/api/v1/zones/${zone.id}`, { as: stranger })).status, 200, 'the record, never its keys or objects');
++ assert.strictEqual((await t.get(`/api/v1/projects/${project}/zones`, { as: stranger })).status, 404, 'the project is still not theirs');
++ assert.strictEqual((await t.get(`/api/v1/zones/${zone.id}`, { as: mate })).status, 200, 'the project member already could');
++ assert.strictEqual((await t.get(`/api/v1/zone/${zone.id}/share`, { as: stranger })).status, 404, 'the sheet is the owner’s');
++ });
++
++ await check('the sheet offers only Specific people and Only me; a bucket is never Everyone or a link', async () => {
++ const list = await t.get(`/api/v1/projects/${project}/zones`, { as: owner });
++ const zone = list.json().zones.find((z) => z.name === 'assets');
++ const sheet = await t.get(`/api/v1/zone/${zone.id}/share`, { as: owner });
++ assert.strictEqual(sheet.status, 200);
++ assert.deepStrictEqual(sheet.json().audiences, ['people', 'private']);
++ const pub = await t.get(`/api/v1/zone/${zone.id}/share`, { as: owner, method: 'PUT', json: { audience: 'public' }, headers: { 'x-requested-with': 'ov-share' } });
++ assert.strictEqual(pub.status, 400, 'a bucket cannot be made public');
++ });
++
++ process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n');
++ await t.close();
++ process.exit(failures ? 1 : 0);
++})();