openvibers/openvibe.zone Commits
Zone: shared buckets, Specific people or Only me (sharing step 4) (#152)
Changes
nnew file mode 100644@@@ -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";nnew file mode 100644@@@ -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;@@@ -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 };@@@ -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 }));@@@ -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;@@@ -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 clareturn 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>`;nnew file mode 100644@@@ -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 };@@@ -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 };}@@@ -24,7 +24,12 @@ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'neconst 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(', ')}`);});@@@ -320,6 +320,21 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mconst 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;nnew file mode 100644@@@ -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);++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -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";
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -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;
@@ -14,8 +14,30 @@ const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
@@ -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).
// Zone keeps no table of its own about a person: the telemetry samples are the SDK's own (see above).
const TABLES = [];
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 } = {}) {
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 };
module.exports = { create, TABLES, TOPICS };
@@ -35,6 +35,7 @@ const { createServiceReadiness } = require('./observability');
@@ -35,6 +35,7 @@ const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createCallerLimits } = require('./http/caller-limits');
const { createClients } = require('./zone/clients');
const { createClients } = require('./zone/clients');
const { createZones } = require('./zone/zones');
const { createZones } = require('./zone/zones');
+const { createSharing, describeRef } = require('./sharing');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
const { html } = require('./render/html');
@@ -58,7 +59,9 @@ async function createApp(opts = {}) {
@@ -58,7 +59,9 @@ async function createApp(opts = {}) {
const principal = createPrincipal({ config, keys });
const principal = createPrincipal({ config, keys });
const clients = opts.clients || createClients({ config, fetchImpl });
const clients = opts.clients || createClients({ config, fetchImpl });
const usage = createUsage({ s, log });
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 });
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
// 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.
// 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 = {}) {
@@ -66,7 +69,7 @@ async function createApp(opts = {}) {
const accountSend = opts.accountSend !== undefined ? opts.accountSend : (config.oauth.clientSecret ? createNetworkSender({
const accountSend = opts.accountSend !== undefined ? opts.accountSend : (config.oauth.clientSecret ? createNetworkSender({
networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl,
networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl,
}) : null);
}) : 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();
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// 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 = {}) {
@@ -165,6 +168,18 @@ async function createApp(opts = {}) {
// public; a first-party service calls over loopback, unproxied, and is counted per caller instead.
// 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 || ''));
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 }) }));
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/v1', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
@@ -71,6 +71,9 @@ function createApi(ctx) {
@@ -71,6 +71,9 @@ function createApi(ctx) {
const row = await zones.byId(req.params.zone);
const row = await zones.byId(req.params.zone);
if (!row) throw new ZoneError(404, 'zone.unknown', 'no such 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) {
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.
// 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');
if (err.code === 'zone.unknown_project' || err.code === 'zone.sandbox_only') throw new ZoneError(404, 'zone.unknown', 'no such zone');
throw err;
throw err;
@@ -22,6 +22,7 @@ const ovServe = require('openvibe-shared/serve');
@@ -22,6 +22,7 @@ const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
+const kit = require('openvibe-shared/kit');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
@@ -244,7 +245,7 @@ ${projects.length ? html`<ul class="zn-cards">${projects.map((p) => html`<li cla
@@ -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>
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">${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 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>
<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>
<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>`;
<button class="zn-button zn-danger" type="submit">Delete ${z.name}</button></form></details>` : ''}</td></tr>`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,204 @@
@@ -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 };
@@ -28,7 +28,7 @@ class ZoneError extends Error {
@@ -28,7 +28,7 @@ class ZoneError extends Error {
const fail = (status, code, detail) => { throw new ZoneError(status, code, detail); };
const fail = (status, code, detail) => { throw new ZoneError(status, code, detail); };
const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
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;
const { db } = s;
/** The zone.object-zone@1 of a row (usage when given; deletion progress while it is deleting or deleted). */
/** 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 }) {
@@ -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',
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()]);
[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 };
return { zone: record(row), repeat: false };
}
}
@@ -24,7 +24,12 @@ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'ne
@@ -24,7 +24,12 @@ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'ne
const db = t.ctx.s.db;
const db = t.ctx.s.db;
try {
try {
await check('every table that names a person is covered or exempted with a reason', async () => {
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(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
});
});
@@ -320,6 +320,21 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -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);
const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
return await projectsApi(req, raw, json, u, parts, url.searchParams);
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' });
return json(404, { error: 'not found' });
});
});
issuer = srv.url;
issuer = srv.url;
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -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);
+})();