openvibers/openvibe.space Commits

Space: the four audiences on apps and drafts (sharing step 4) (#213)

OP OpenVibers committed 6 days ago · 680f766edb · 16 files, +572 −17

* Space: the four audiences on apps and drafts (sharing step 4)

Every space gets an openvibe-sdk/share row next to it. A draft is Only me;
its maker's Share sheet can name people ("look at this before I publish it").
Publishing makes it Everyone (or the link, when the gallery says unlisted) and
unpublishing takes it back to Only me. The space page and the editor's reads
ask canView, so a people draft answers a stranger with the gate, never the
name, description or files. The uniform API is mounted at
/api/v1/space/:id/share, and a claimed guest draft's row follows its new owner.

Migrations 0012 (the SDK's tables) and 0013 (the backfill of every space).

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

* Sharing: a row written before the model falls back to the old column; the second account-data guard too

* The share gate draws from markup alone (openvibe-shared 4.123.0), with a headless-Chrome check; MediaHub's cache and column follow the audience

* Merge remote-tracking branch 'origin/main' into feat/share-space

# Conflicts:
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json

Changes

ddiff --git a/README.md b/README.md
iindex 068662f..da993fc 100644
---- a/README.md
++++ b/README.md
@@@ -151,5 +151,5 @@ AGPL-3.0-only. See [LICENSE](LICENSE).
<!-- versions:start -->
- openvibe-contracts: v0.289.1
- openvibe-sdk: v0.91.0
--- openvibe-shared: v4.122.0
++- openvibe-shared: v4.123.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex d521bf4..3c601b4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,7 +8,7 @@
"domain": "openvibe.space",
"contracts": "openvibe-contracts v0.289.1",
"sdk": "openvibe-sdk v0.91.0",
-- "shared": "openvibe-shared v4.122.0",
++ "shared": "openvibe-shared v4.123.0",
"capabilities": [
"space.site.manage",
"space.site.config",
ddiff --git a/migrations/0012_sharing.sql b/migrations/0012_sharing.sql
nnew file mode 100644
iindex 0000000..0a18db9
---- /dev/null
++++ b/migrations/0012_sharing.sql
@@@ -0,0 +1,78 @@
++-- 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 Space'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;
++
ddiff --git a/migrations/0013_sharing_backfill.sql b/migrations/0013_sharing_backfill.sql
nnew file mode 100644
iindex 0000000..3d10645
---- /dev/null
++++ b/migrations/0013_sharing_backfill.sql
@@@ -0,0 +1,26 @@
++-- phase: migrate
++-- Every space that existed before sharing is registered once, so canView has a row for all of them.
++--
++-- published (site_id IS NOT NULL), visibility public → public (Everyone, in the gallery)
++-- published, visibility unlisted → link
++-- a draft (no site) → private (Only me)
++--
++-- The owner is the space's owner_subject; a guest draft with no account yet gets Space itself,
++-- which nobody can sign in as (its secret link is still how its maker edits it). Removed spaces are
++-- not registered.
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT
++ 'space', 'space', a.id,
++ CASE
++ WHEN a.owner_subject LIKE 'usr\_%' THEN jsonb_build_object('type', 'user', 'id', a.owner_subject)
++ WHEN a.owner_subject LIKE 'gst\_%' THEN jsonb_build_object('type', 'guest', 'id', a.owner_subject)
++ ELSE jsonb_build_object('type', 'service', 'id', 'space')
++ END,
++ CASE
++ WHEN a.site_id IS NULL THEN 'private'
++ WHEN a.visibility = 'public' THEN 'public'
++ ELSE 'link'
++ END
++FROM space_apps a
++WHERE a.status = 'active'
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
ddiff --git a/package-lock.json b/package-lock.json
iindex 3f898cd..af2097d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0",
"quickjs-emscripten": "0.32.0"
},
@@@ -1166,9 +1166,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.122.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
-- "integrity": "sha512-/BneZ1PYDWUM/DBEb8EodvTbsK9a+5EGY+bM8zJi1vgL+D8ohau7EixB4uNcqD0RLXJMu4IUYtSdwb/QPPyPMA==",
++ "version": "4.123.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
++ "integrity": "sha512-9XLlRm24VEf/amaSHm+3OEKYKKrDj9ic4Fii9qsKwtUV9HilQ8cw6H1TNi27m/VIdJcvFkZASpkmxnIhQW+rkw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 6f4d2dc..ec0e093 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,7 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0",
"quickjs-emscripten": "0.32.0"
},
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 377e94b..4ecc67c 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -34,14 +34,31 @@ const TABLES = [
{ table: 'space_takedowns', subject: 'created_by', file: null, erase: { keep: 'a moderation record stays attributed for accountability' } },
];
++
++/** The sharing system's rows (openvibe-sdk/share): jsonb subjects, so the SDK's table map cannot name them. */
++async function shareExport(db, subject) {
++ const owned = await db.many("SELECT * FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['space', subject]);
++ const member = await db.many("SELECT * FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['space', subject]);
++ const asked = await db.many("SELECT * FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['space', subject]);
++ return owned.length || member.length || asked.length ? [{ name: 'shares.json', content: { owned, member_of: member, requests: asked } }] : [];
++}
++async function shareErase(t, subjects) {
++ for (const subject of subjects) {
++ await t.exec("DELETE FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['space', subject]);
++ await t.exec("DELETE FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['space', subject]);
++ await t.exec("DELETE FROM share_transfers WHERE service = $1 AND (from_subject->>'id' = $2 OR to_subject->>'id' = $2)", ['space', subject]);
++ await t.exec("DELETE FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['space', subject]);
++ }
++}
++
async function extraExport(db, subject) {
const spaces = await db.many(`SELECT id, slug, name, description, instructions, capabilities, template, forked_from, visibility, site_id IS NOT NULL AS published,
forks, created_at, updated_at, published_at FROM space_apps WHERE owner_subject = $1 AND status = 'active' ORDER BY created_at`, [subject]);
-- if (!spaces.length) return [];
++ if (!spaces.length) return await shareExport(db, subject);
const files = await db.many('SELECT space_id, path, content FROM space_app_files WHERE space_id = ANY($1::text[]) ORDER BY space_id, path', [spaces.map((s) => s.id)]);
const byId = new Map(spaces.map((s) => [s.id, { ...s, capabilities: JSON.parse(s.capabilities || '[]'), files: [] }]));
for (const f of files) byId.get(f.space_id).files.push({ path: f.path, content: f.content });
-- return [{ name: 'spaces.json', content: [...byId.values()] }];
++ return [{ name: 'spaces.json', content: [...byId.values()] }, ...await shareExport(db, subject)];
}
/** Close sites (and their deploys) the way removing them does; the worker sweeps the objects nothing references. */
@@@ -55,6 +72,7 @@ async function closeSites(t, siteIds, now) {
}
async function extraErase(t, subjects, counts) {
++ await shareErase(t, subjects);
const now = Date.now();
// Their spaces: published ones stop serving, then the spaces go with what others kept in them.
const spaces = await t.many("SELECT id, site_id FROM space_apps WHERE owner_subject = ANY($1::text[])", [subjects]);
ddiff --git a/server/hosting.js b/server/hosting.js
iindex 796cc54..1b82e66 100644
---- a/server/hosting.js
++++ b/server/hosting.js
@@@ -18,6 +18,7 @@ const { createSiteSources } = require('./domain/site-sources');
const { createViewerResolver, guard } = require('./auth/viewer');
const { createTenantServer } = require('./http/tenant');
const { createUploadGate } = require('./http/upload');
++const { createSharing, describeRef } = require('./sharing');
const { createApi } = require('./http/api');
const resourceIndex = require('./registry/resource-index');
const { createDashboard } = require('./http/dashboard');
@@@ -61,11 +62,13 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
const worker = createWorker({ config, store, domains, blobs, outbox, log });
const billing = createSpaceBilling({ db, config, now: store.now, ...(fetchImpl ? { fetchImpl } : {}), log });
const ctx = { config, store, blobs, outbox, billing, access, takedowns, projects, sites, deploys, domains, siteConfig, siteSources, auth, viewers, tenant, worker, uploadGate, indexnow, log, valkey: valkey || null };
++ // Sharing (openvibe-sdk/share): the shares row next to each space, members, transfers and the one access check.
++ ctx.sharing = createSharing({ db, config, outbox, now: store.now, ...(fetchImpl ? { fetchImpl } : {}), log });
ctx.actorLimits = require('./http/actor-limits').createHostActorLimits({ registry: app.locals.metrics, valkey: ctx.valkey });
app.locals.ctx = ctx;
// Spaces (server/spaces/): apps people make here and publish as sites of their Spaces project.
-- ctx.spaces = require('./spaces/spaces').createSpaces({ store, config, projects, sites, deploys, takedowns, outbox, log });
++ ctx.spaces = require('./spaces/spaces').createSpaces({ store, config, projects, sites, deploys, takedowns, outbox, log, sharing: ctx.sharing });
ctx.spacesAi = require('./spaces/ai').createSpacesAi({ config, client: spacesAiClient, log });
ctx.spaceData = require('./spaces/data').createSpaceData({ store, config, spaces: ctx.spaces, ai: ctx.spacesAi });
ctx.spaceFunctions = require('./spaces/functions').createSpaceFunctions({ data: ctx.spaceData, workers: config.spaces.fnWorkers, log });
@@@ -129,6 +132,20 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
app.use('/api/v1/resources', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), viewers.middleware('api'), require('./registry/resource-control').router({ guard: guard('space.resource.control') }), resourceIndex.router({ guard: guard('space.resource.read') }));
// The Spaces API had no rate limit of its own (it is mounted before /api/v1's): a space's page calls it, so its
// limit is wider than the API's (REVIEW-0 11-03).
++ // ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────
++ // GET/PUT /api/v1/space/:id/share, .../share/requests, .../share/transfer[/accept|decline|cancel],
++ // GET /api/v1/share/people?name=, GET /api/v1/shared-with-me.
++ {
++ const shareApi = require('express').Router();
++ shareApi.use(viewers.middleware('api'));
++ shareApi.use(ctx.sharing.shares.router({
++ service: 'space',
++ authenticate: (req) => ctx.sharing.shareViewer(req.viewer),
++ describe: describeRef(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/apps', rateLimit({ windowMs: 60_000, max: 600, standardHeaders: true, legacyHeaders: false }), require('./spaces/api').createSpacesApi({ config, spaces: ctx.spaces, data: ctx.spaceData, sites, takedowns, log }));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => Promise.resolve(tenant.preview(req, res)).catch(next));
ddiff --git a/server/index.js b/server/index.js
iindex bf1bc08..8ebb65a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -29,6 +29,9 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
app.locals.hosting.outbox.start();
app.locals.hosting.billing.start(); // off unless SPACE_BILLING_INTERVAL_MS > 0
app.locals.hosting.guestCleanup.start(); // the guest drafts whose week is up (off with SPACE_GUEST_DRAFTS=off)
++ // Sharing (openvibe-sdk/share): expire members and transfers, then send queued share events and notifications.
++ const sharingTimer = setInterval(() => { app.locals.hosting.sharing.work().catch(() => { /* logged inside */ }); }, 15_000);
++ if (sharingTimer.unref) sharingTimer.unref();
// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
// SPACE_EVENTS_SECRET or the client secret.
const hc = app.locals.hosting.config;
@@@ -46,7 +49,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
gracefulStop({
name: 'Space', server,
drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
-- stop: [async () => { if (subscriptions) subscriptions.stop(); app.locals.hosting.worker.stop(); app.locals.hosting.guestCleanup.stop(); await app.locals.hosting.outbox.stop(); await app.locals.hosting.billing.stop(); }],
++ stop: [async () => { if (subscriptions) subscriptions.stop(); clearInterval(sharingTimer); app.locals.hosting.worker.stop(); app.locals.hosting.guestCleanup.stop(); await app.locals.hosting.outbox.stop(); await app.locals.hosting.billing.stop(); }],
close: [
async () => { if (telemetryRetention) await telemetryRetention.stop(); await telemetry.stop(); },
async () => {
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..fe9ba27
---- /dev/null
++++ b/server/sharing.js
@@@ -0,0 +1,192 @@
++'use strict';
++
++/**
++ * Sharing for Space apps (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 space, 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 Space's own Events outbox, so share.* envelopes go out with every other space.* event
++ * - notify Network's notification push; a refusal is logged, never allowed to stall the queue
++ * - onAudienceChange keeps space_apps.visibility in the gallery's words (public→public, anything narrower→unlisted), so
++ * the gallery, the sitemap and llms.txt drop it at once
++ * - onOwnerChange moves space_apps.owner_subject to the new owner
++ *
++ * A draft space is registered private (Only me). The sheet on it can name people ("look at this
++ * before I publish it"); /spaces/:slug is the link, and it answers with the gate, never the space's
++ * files, to anyone not on the list. A published space is Everyone; the sheet offers Everyone and
++ * Anyone with the link for it (people/private would leave the deployed site up).
++ */
++const { createShares } = require('openvibe-sdk/share');
++const { serviceAuth } = require('openvibe-contracts');
++
++const SERVICE = 'space';
++const KIND = 'space';
++const AUDIENCES = ['public', 'link', 'people', 'private'];
++/** Space's gallery words to the SDK's, and back. Narrower than the gallery is 'unlisted' there. */
++const TO_SDK = { public: 'public', unlisted: 'link', private: 'private' };
++const FROM_SDK = { public: 'public', link: 'unlisted', people: 'unlisted', private: 'unlisted' };
++
++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, outbox, publication, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {
++ 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 space was shared with you', 'Open it on OpenVibe.Space.'],
++ 'share.access_requested': ['Someone asked for access to your space', 'Review the request on OpenVibe.Space.'],
++ 'share.transfer.requested': ['Someone wants to give you a space', 'Accept or decline it on OpenVibe.Space.'],
++ 'share.transfer.answered': ['A space transfer was answered', 'See what happened on OpenVibe.Space.'],
++ }[n.kind] || ['Sharing on OpenVibe.Space', '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: 'space', url: n.ref && n.ref.id ? `${config.baseUrl}/spaces/${encodeURIComponent(n.ref.id)}` : config.baseUrl,
++ category: 'social',
++ },
++ });
++ } catch (err) {
++ log.warn('[Sharing] notification not sent:', err.message);
++ }
++ }
++
++ /** The row's audience change keeps the gallery column in step (the gallery, sitemap and llms read it live). */
++ async function onAudienceChange(ref, audience, tx) {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return;
++ const row = await tx.maybe('SELECT visibility FROM space_apps WHERE id = $1', [ref.id]);
++ if (!row) return;
++ await tx.exec('UPDATE space_apps SET visibility = $1 WHERE id = $2', [FROM_SDK[audience] || 'unlisted', ref.id]);
++ }
++
++ 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 space_apps SET owner_subject = $1 WHERE id = $2', [owner, ref.id]);
++ }
++
++ const shares = createShares({
++ db,
++ network: { json: networkJson },
++ events: { publish: async (envelope) => await outbox.emit(envelope) },
++ notify,
++ onAudienceChange,
++ onOwnerChange,
++ });
++
++ 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)]);
++ });
++ },
++ /** A claimed guest draft's share row moves to the person who claimed it (never through the sheet). */
++ adopt: async (id, viewer) => await db.exec(`UPDATE shares SET owner_subject = $1::jsonb WHERE service = $2 AND kind = $3 AND resource_id = $4`,
++ [JSON.stringify({ type: String(viewer.subject).startsWith('gst_') ? 'guest' : 'user', id: viewer.subject }), 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 space = await db.maybe('SELECT id, slug, name, site_id FROM space_apps WHERE id = $1 AND status = $2', [ref.id, 'active']);
++ if (!space) return {};
++ return {
++ title: space.name || 'Untitled space',
++ link: `${String(config.baseUrl || '').replace(/\/+$/, '')}/spaces/${space.slug}`,
++ roles: ['viewer'],
++ // A published space's site is already up: only Everyone and the link make sense. A draft can name people.
++ audiences: space.site_id ? ['public', 'link'] : ['people', 'private'],
++ transferable: true,
++ };
++ };
++}
++
++module.exports = { createSharing, describeRef, shareViewer, refOf, AUDIENCES, TO_SDK, FROM_SDK, SERVICE, KIND };
ddiff --git a/server/spaces/pages.js b/server/spaces/pages.js
iindex f3ed94a..70cc458 100644
---- a/server/spaces/pages.js
++++ b/server/spaces/pages.js
@@@ -269,7 +269,20 @@ function createSpacesPages(ctx) {
}));
router.get('/spaces/:slug', view(async (req, res) => {
-- const { space, owner } = await spaces.load(req.viewer, req.params.slug, 'read');
++ let loaded;
++ try { loaded = await spaces.load(req.viewer, req.params.slug, 'read'); } catch (err) {
++ // A space shared with specific people answers with the gate, never its name, description or files.
++ if (err.status === 404 && ctx.sharing) {
++ const space = await spaces.getBySlug(req.params.slug);
++ const row = space ? await ctx.sharing.shareOf(space.id).catch(() => null) : null;
++ if (row && row.audience === 'people') {
++ return html(res, 404, render.sharedGate({ id: space.id, signedInAs: req.viewer && req.viewer.user && req.viewer.user.username ? req.viewer.user.username : null,
++ signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` }));
++ }
++ }
++ throw err;
++ }
++ const { space, owner } = loaded;
const forkedFrom = space.forked_from ? await spaces.get(space.forked_from) : null;
html(res, 200, render.spacePage({ viewer: signedIn(req) ? req.viewer : null, space, owner, url: spaces.urlOf(space), csrf: csrfOf(req), forkedFrom: forkedFrom && forkedFrom.site_id ? forkedFrom : null }));
}));
ddiff --git a/server/spaces/render.js b/server/spaces/render.js
iindex d748cb5..ea88c5b 100644
---- a/server/spaces/render.js
++++ b/server/spaces/render.js
@@@ -134,9 +134,27 @@ function minePage({ spaces }) {
return page({ title: 'Your spaces', path: '/spaces/mine', robots: 'noindex,nofollow', body });
}
++/**
++ * The page a person who is not on the list sees (design 2026-11-06): the audience, never the space's
++ * name, description or files. OVShare.gate offers "Ask for access" when signed in.
++ */
++function sharedGate({ id, signedInAs = null, signInUrl = '/auth/login' }) {
++ const body = `<div class="sp-wrap">
++ <p class="eyebrow">A space on OpenVibe.Space</p>
++ <h1 class="sp-title">This is shared with specific people.</h1>
++ <div id="share-gate" data-ov-share-gate data-kind="space" data-id="${esc(String(id))}" data-signed-in-as="${esc(signedInAs || '')}" data-sign-in-url="${esc(signInUrl)}">
++ <p class="sp-muted">This space is shared with specific people on OpenVibe.Space.</p>
++ <p><a class="sp-btn" href="${esc(signInUrl)}">Sign in</a></p>
++ </div>
++ <link rel="stylesheet" href="/shared/ov-share.css">
++ <script src="/shared/ov-share.js" defer></script>
++</div>`;
++ return page({ title: 'A space on OpenVibe.Space', description: 'A space shared with specific people on OpenVibe.Space.', path: '/spaces', robots: 'noindex,nofollow', body });
++}
++
function spacePage({ viewer, space, owner, url, csrf, forkedFrom }) {
const live = Boolean(space.site_id);
-- const share = kit.share({ title: space.name, url: live && url ? url : null, text: space.description || '' });
++ const share = kit.share({ title: space.name, url: live && url ? url : null, text: space.description || '', kind: 'space', id: space.id });
const actions = [
live ? `<a class="sp-btn" href="${esc(url)}" rel="noopener"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Open</a>` : '', share,
owner ? `<a class="sp-btn ${live ? 'sp-btn-quiet' : ''}" href="/spaces/${esc(space.slug)}/edit"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</a>` : '',
@@@ -310,4 +328,4 @@ function errorBody(status, message) {
return page({ title: status === 404 ? 'Space not found' : 'Spaces', path: '/spaces', robots: 'noindex,nofollow', body: `<div class="sp-wrap"><p class="eyebrow">Error ${Number(status)}</p><h1 class="sp-title">${status === 404 ? 'No such space' : 'That did not work'}</h1><p class="sp-lede">${esc(message)}</p><p><a class="sp-btn" href="/spaces">Back to spaces</a></p></div>` });
}
--module.exports = { galleryPage, newPage, minePage, spacePage, editorPage, guestPage, connectPage, signInPage, errorBody, CAP_TEXT };
++module.exports = { sharedGate, galleryPage, newPage, minePage, spacePage, editorPage, guestPage, connectPage, signInPage, errorBody, CAP_TEXT };
ddiff --git a/server/spaces/spaces.js b/server/spaces/spaces.js
iindex a48badc..5ebbf1d 100644
---- a/server/spaces/spaces.js
++++ b/server/spaces/spaces.js
@@@ -42,7 +42,7 @@ function slugBase(name) {
return s.length >= 3 ? s : `space-${s}`.replace(/-+$/, '');
}
--function createSpaces({ store, config, projects, sites, deploys, takedowns, outbox = null, log = console }) {
++function createSpaces({ store, config, projects, sites, deploys, takedowns, outbox = null, log = console, sharing = null }) {
const { db } = store;
const limits = config.spaces;
const q = {
@@@ -103,14 +103,22 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
async function forSite(siteId) { return siteId ? (await q.bySite.get(siteId)) || null : null; }
const isOwner = (viewer, space) => Boolean(viewer && viewer.kind === 'user' && viewer.subject === space.owner_subject);
-- /** read: a published space anyone can open, or the owner's (and staff's) own drafts. own: the owner or staff. */
++ /**
++ * read: the owner and staff, or whoever the space's share row admits (Everyone, the link, the
++ * people on its list); a draft is Only me until its maker shares it. own: the owner or staff.
++ */
async function load(viewer, slug, need = 'read') {
const space = await getBySlug(slug);
const notFound = new ApiError(404, 'space.not_found', 'no such space');
if (!space) throw notFound;
const owner = isOwner(viewer, space) || Boolean(viewer && viewer.staff);
if (need === 'own' && !owner) throw isOwner(viewer, space) || space.site_id ? new ApiError(403, 'space.not_owner', 'only the space\'s maker can change it') : notFound;
-- if (need === 'read' && !owner && !space.site_id) throw notFound;
++ if (need === 'read' && !owner) {
++ // A space from before sharing has no row: it falls back to "published is anyone's, a draft is the maker's".
++ const row = sharing ? await sharing.shareOf(space.id).catch(() => null) : null;
++ const allowed = row ? await sharing.canView(viewer, space.id).catch(() => false) : Boolean(space.site_id);
++ if (!allowed) throw notFound;
++ }
return { space, owner };
}
@@@ -184,6 +192,8 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
await q.insert.run(id, slug, person.subject, person.username, person.name, name, text(input.description, 400), String(input.instructions || '').trim().slice(0, 4000),
JSON.stringify(capsOf(input.capabilities)), input.template ? text(input.template, 40) : null, input.forked_from || null, now, now);
for (const f of fileList) await q.addFile.run(id, f.path, f.content, now);
++ // A new space is a draft: Only me until its maker shares it or publishes it.
++ if (sharing) await sharing.register(viewer, id, 'private');
});
return await get(id);
}
@@@ -225,6 +235,7 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
await store.tx(async () => {
await q.insertGuest.run(id, slug, text(name, 40), text(description, 400), template, now, now, guest.keyHash(key), String(ip || '').slice(0, 64), now + limits.guest.ttlDays * DAY);
for (const f of list) await q.addFile.run(id, f.path, f.content, now);
++ if (sharing) await sharing.register(null, id, 'private');
});
return { space: await get(id), key };
}
@@@ -243,6 +254,8 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
const claimed = await store.tx(async () => {
const r = await q.claimGuest.run(person.subject, person.username, person.name, slug, now, space.id);
if (!r.changes) return false;
++ // The share row follows the draft to its new owner.
++ if (sharing) await sharing.adopt(space.id, { subject: person.subject });
// The claim is a status event: the draft now belongs to a person.
if (outbox) {
await outbox.emit({
@@@ -284,6 +297,7 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
await q.settings.run(name, input.description !== undefined ? text(input.description, 400) : space.description,
input.instructions !== undefined ? String(input.instructions || '').trim().slice(0, 4000) : space.instructions,
JSON.stringify(input.capabilities !== undefined ? capsOf(input.capabilities) : capsOf(space.capabilities)), visibility, store.now(), space.id);
++ if (sharing && input.visibility !== undefined) await sharing.syncAudience(space.id, visibility);
return await get(space.id);
}
@@@ -351,6 +365,8 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
const deploy = await deploys.create(viewer, pre, entries, { source: 'files', activate: true, notes: [`space ${space.id}`], traceparent });
const now = store.now();
await q.published.run(site.id, space.published_at || now, now, space.id);
++ // Publishing makes it Everyone (or the link, when its gallery visibility says unlisted).
++ if (sharing) await sharing.syncAudience(space.id, space.visibility);
return { space: await get(space.id), site, deploy, url: urlOf(space) };
}
@@@ -359,6 +375,7 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
const site = await sites.get(space.site_id);
if (site && site.status === 'active') await sites.remove(viewer, site.id, { deploys });
await q.unpublished.run(store.now(), space.id);
++ if (sharing) await sharing.syncAudience(space.id, 'private');
return await get(space.id);
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 3c02ce3..304386b 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -25,6 +25,8 @@ const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion Space applied',
account_data_events_forum: 'the forum ledger of requests itself: which export and deletion the Space forum applied',
++ share_members: 'jsonb subject: the SDK table map cannot name it; extraExport/extraErase cover the share rows',
++ share_access_requests: 'jsonb subject: the SDK table map cannot name it; extraExport/extraErase cover the share rows',
},
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
ddiff --git a/test/share-gate-chrome.test.js b/test/share-gate-chrome.test.js
nnew file mode 100644
iindex 0000000..0dbea1f
---- /dev/null
++++ b/test/share-gate-chrome.test.js
@@@ -0,0 +1,81 @@
++'use strict';
++/**
++ * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]):
++ * markup only; signed out it offers Sign in, a signed-in stranger is offered Ask for access, and the
++ * page has no console error and no CSP refusal. Skipped without Chrome, or with OV_SKIP_BROWSER=1.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const { boot, check, done } = require('./helpers/app');
++
++let playwright = null;
++try { playwright = require('playwright'); } catch {
++ try { playwright = require(process.env.PLAYWRIGHT_MODULE || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'); } catch { playwright = null; }
++}
++const CHROME = process.env.CHROME_BIN || '/usr/bin/google-chrome';
++if (process.env.OV_SKIP_BROWSER === '1' || !playwright || !fs.existsSync(CHROME)) {
++ console.log('share-gate-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++const REFUSED = /Content Security Policy|Refused to (?:load|execute|apply)|Uncaught SyntaxError/i;
++
++(async () => {
++ const h = await boot();
++ const alice = h.network.addUser({ network_user_id: 21, username: 'alice' });
++ const bob = h.network.addUser({ network_user_id: 22, username: 'bob' });
++ const sam = h.network.addUser({ network_user_id: 23, username: 'sam' });
++ const tok = (u) => h.network.sign({ id: u.network_user_id, subject_id: u.subject_id, username: u.username, display_name: u.display_name, role: 'user', aud: ['openvibe.network', 'openvibe.space'] });
++ const spaces = h.app.locals.hosting.spaces;
++ const space = await spaces.create({ kind: 'user', subject: alice.subject_id, user: { username: 'alice', display_name: 'alice' } }, { name: 'Secret plan', description: 'SECRET-PLAN', files: [{ path: 'index.html', content: '<h1>SECRET-PLAN</h1>' }] });
++ await h.app.locals.hosting.sharing.shares.setAudience(
++ { subject: { type: 'user', id: alice.subject_id } }, { service: 'space', kind: 'space', id: space.id }, 'people',
++ );
++ const url = `${h.base}/spaces/${space.slug}`;
++
++ let browser;
++ const errors = [];
++ const watch = (p) => {
++ p.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
++ p.on('pageerror', (e) => errors.push(String(e)));
++ };
++ const settled = async (p) => {
++ await p.waitForSelector('#share-gate .ov-share-gate', { timeout: 8000 });
++ return await p.$eval('#share-gate', (el) => el.textContent.replace(/\s+/g, ' ').trim());
++ };
++
++ try {
++ await check('headless Chrome: the gate draws itself, signed out, with Sign in', async () => {
++ browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
++ const ctx = await browser.newContext();
++ const p = await ctx.newPage();
++ watch(p);
++ const res = await p.goto(url, { waitUntil: 'load', timeout: 15000 });
++ assert.strictEqual(res.status(), 404, 'the gate answers 404, never the space');
++ const text = await settled(p);
++ assert.ok(/Sign in/.test(text), `signed out the gate offers Sign in, got: ${text}`);
++ const href = await p.$eval('#share-gate .ov-share-gate a', (a) => a.getAttribute('href'));
++ assert.match(href, /^\/auth\/login\?next=/);
++ await ctx.close();
++ });
++
++ await check('headless Chrome: a stranger is offered Ask for access', async () => {
++ const ctx = await browser.newContext();
++ await ctx.addCookies([{ name: 'ov_token', value: tok(sam), url: h.base }]);
++ const p = await ctx.newPage();
++ watch(p);
++ await p.goto(url, { waitUntil: 'load', timeout: 15000 });
++ const text = await settled(p);
++ assert.ok(/Ask for access/.test(text), `a stranger is offered Ask for access, got: ${text}`);
++ assert.ok(/@sam/.test(text), 'the gate names the account it is signed in as');
++ await ctx.close();
++ });
++
++ await check('headless Chrome: no console error and no CSP refusal', async () => {
++ assert.deepStrictEqual(errors.filter((e) => REFUSED.test(e)), []);
++ });
++ } finally {
++ if (browser) await browser.close().catch(() => {});
++ await h.close();
++ done();
++ }
++})();
ddiff --git a/test/space-sharing.test.js b/test/space-sharing.test.js
nnew file mode 100644
iindex 0000000..267e45f
---- /dev/null
++++ b/test/space-sharing.test.js
@@@ -0,0 +1,90 @@
++'use strict';
++/**
++ * Sharing on Space apps (design 2026-11-06, VISIBILITY-EVERYWHERE step 4; openvibe-sdk/share).
++ * A draft space is Only me; its maker can name people ("look at this before I publish it").
++ * Publishing makes it Everyone (or the link); unpublishing takes it back to Only me.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const h = await boot();
++ const alice = h.network.addUser({ network_user_id: 21, username: 'alice' });
++ const bob = h.network.addUser({ network_user_id: 22, username: 'bob' });
++ const sam = h.network.addUser({ network_user_id: 23, username: 'sam' });
++ const tok = (u) => h.network.sign({ id: u.network_user_id, subject_id: u.subject_id, username: u.username, display_name: u.display_name, role: 'user', aud: ['openvibe.network', 'openvibe.space'] });
++ const spaces = h.app.locals.hosting.spaces;
++ const sharing = h.app.locals.hosting.sharing;
++ const viewer = (u) => ({ kind: 'user', subject: u.subject_id, user: { username: u.username, display_name: u.display_name } });
++ const get = (path, u, opts = {}) => h.get(path, { ...opts, ...(u ? { cookies: [`ov_token=${tok(u)}`] } : {}) });
++ const share = (id, opts = {}) => h.get(`/api/v1/space/${id}/share`, { headers: { 'x-requested-with': 'ov-share', ...(opts.headers || {}) }, ...opts });
++ const putShare = (id, u, body) => share(id, { method: 'PUT', headers: { authorization: `Bearer ${tok(u)}`, 'content-type': 'application/json' }, body: JSON.stringify(body) });
++ const row = async (id) => await h.db.prepare('SELECT * FROM shares WHERE service = ? AND kind = ? AND resource_id = ?').get('space', 'space', id);
++
++ const make = async () => await spaces.create(viewer(alice), { name: 'Secret plan', description: 'SECRET-PLAN', files: [{ path: 'index.html', content: '<h1>SECRET-PLAN</h1>' }] });
++
++ await check('a new space is registered Only me, owned by its maker; nobody else opens the draft', async () => {
++ const space = await make();
++ const s = await row(space.id);
++ assert.strictEqual(s.audience, 'private');
++ assert.deepStrictEqual(s.owner_subject, { type: 'user', id: alice.subject_id });
++ assert.strictEqual((await get(`/spaces/${space.slug}`, alice)).status, 200);
++ assert.strictEqual((await get(`/spaces/${space.slug}`, bob)).status, 404);
++ assert.strictEqual((await get(`/spaces/${space.slug}`)).status, 404);
++ });
++
++ await check('Specific people: the named person opens the draft; a stranger gets the gate and no name', async () => {
++ const space = await make();
++ const put = await putShare(space.id, alice, { audience: 'people', members: [{ subject: { type: 'user', id: bob.subject_id }, role: 'viewer' }] });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.strictEqual(put.json().audience, 'people');
++ const member = await get(`/spaces/${space.slug}`, bob);
++ assert.strictEqual(member.status, 200);
++ assert.ok(member.text.includes('Secret plan'));
++ const stranger = await get(`/spaces/${space.slug}`, sam);
++ assert.strictEqual(stranger.status, 404);
++ assert.ok(stranger.text.includes('shared with specific people') && stranger.text.includes('data-ov-share-gate'));
++ assert.ok(!stranger.text.includes('SECRET-PLAN'), 'no description or files');
++ const anon = await get(`/spaces/${space.slug}`);
++ assert.strictEqual(anon.status, 404);
++ assert.ok(anon.text.includes('/auth/login?next='));
++ assert.strictEqual((await share(space.id, { headers: { authorization: `Bearer ${tok(sam)}` } })).status, 404, 'the sheet is the owner’s');
++ });
++
++ await check('Everyone and the link open a draft by its address; the gallery still shows only published, public spaces', async () => {
++ const space = await make();
++ assert.strictEqual((await putShare(space.id, alice, { audience: 'public' })).status, 200);
++ assert.strictEqual((await get(`/spaces/${space.slug}`)).status, 200);
++ assert.strictEqual((await putShare(space.id, alice, { audience: 'link' })).status, 200);
++ assert.strictEqual((await get(`/spaces/${space.slug}`)).status, 200, 'the link is the key');
++ const gallery = await get('/spaces');
++ assert.ok(!gallery.text.includes('Secret plan'), 'a draft is never in the gallery');
++ });
++
++ await check('unpublishing returns a space to Only me, and the row follows the gallery visibility', async () => {
++ const space = await make();
++ // A space can only be published by staff in this build; flip the row as publishing would, then unpublish.
++ await h.db.prepare('UPDATE space_apps SET published_at = ?, visibility = ? WHERE id = ?').run(Date.now(), 'public', space.id);
++ await h.db.prepare('UPDATE space_apps SET site_id = ? WHERE id = ?').run('sit_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', space.id);
++ await sharing.syncAudience(space.id, 'public');
++ assert.strictEqual((await row(space.id)).audience, 'public');
++ assert.strictEqual((await get(`/spaces/${space.slug}`)).status, 200);
++ await h.db.prepare('UPDATE space_apps SET site_id = NULL, published_at = NULL WHERE id = ?').run(space.id);
++ await sharing.syncAudience(space.id, 'private');
++ assert.strictEqual((await row(space.id)).audience, 'private');
++ assert.strictEqual((await get(`/spaces/${space.slug}`)).status, 404);
++ });
++
++ await check('the sheet on a published space offers Everyone and the link; a draft offers people and Only me', async () => {
++ const draft = await make();
++ const d = await share(draft.id, { headers: { authorization: `Bearer ${tok(alice)}` } });
++ assert.strictEqual(d.status, 200, d.text);
++ assert.deepStrictEqual(d.json().audiences, ['people', 'private']);
++ await h.db.prepare('UPDATE space_apps SET site_id = ? WHERE id = ?').run('sit_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', draft.id);
++ const p = await share(draft.id, { headers: { authorization: `Bearer ${tok(alice)}` } });
++ assert.deepStrictEqual(p.json().audiences, ['public', 'link']);
++ });
++
++ await done();
++ await h.close();
++})();