openvibers/openvibe.space Commits
Space: the four audiences on apps and drafts (sharing step 4) (#213)
Changes
@@@ -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 -->@@@ -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",nnew file mode 100644@@@ -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;++nnew file mode 100644@@@ -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;@@@ -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",@@@ -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"},@@@ -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]);@@@ -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, fconst 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, fapp.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));@@@ -29,6 +29,9 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdkapp.locals.hosting.outbox.start();app.locals.hosting.billing.start(); // off unless SPACE_BILLING_INTERVAL_MS > 0app.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-sdkgracefulStop({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 () => {nnew file mode 100644@@@ -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 };@@@ -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 }));}));@@@ -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 };@@@ -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, outbasync 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, outbawait 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, outbawait 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, outbconst 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, outbawait 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, outbconst 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, outbconst 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);}@@@ -25,6 +25,8 @@ const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//gexempt: {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(', ')}`);nnew file mode 100644@@@ -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();++ }++})();nnew file mode 100644@@@ -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();++})();
@@ -151,5 +151,5 @@ AGPL-3.0-only. See [LICENSE](LICENSE).
@@ -151,5 +151,5 @@ AGPL-3.0-only. See [LICENSE](LICENSE).
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.289.1
- openvibe-contracts: v0.289.1
- openvibe-sdk: v0.91.0
- openvibe-sdk: v0.91.0
-- openvibe-shared: v4.122.0
+- openvibe-shared: v4.123.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"domain": "openvibe.space",
"domain": "openvibe.space",
"contracts": "openvibe-contracts v0.289.1",
"contracts": "openvibe-contracts v0.289.1",
"sdk": "openvibe-sdk v0.91.0",
"sdk": "openvibe-sdk v0.91.0",
- "shared": "openvibe-shared v4.122.0",
+ "shared": "openvibe-shared v4.123.0",
"capabilities": [
"capabilities": [
"space.site.manage",
"space.site.manage",
"space.site.config",
"space.site.config",
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -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;
+
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -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;
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"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-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",
"pg": "^8.23.0",
"quickjs-emscripten": "0.32.0"
"quickjs-emscripten": "0.32.0"
},
},
@@ -1166,9 +1166,9 @@
@@ -1166,9 +1166,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"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-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",
"pg": "^8.23.0",
"quickjs-emscripten": "0.32.0"
"quickjs-emscripten": "0.32.0"
},
},
@@ -34,14 +34,31 @@ const TABLES = [
@@ -34,14 +34,31 @@ const TABLES = [
{ table: 'space_takedowns', subject: 'created_by', file: null, erase: { keep: 'a moderation record stays attributed for accountability' } },
{ 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) {
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,
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]);
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 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: [] }]));
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 });
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. */
/** 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) {
@@ -55,6 +72,7 @@ async function closeSites(t, siteIds, now) {
}
}
async function extraErase(t, subjects, counts) {
async function extraErase(t, subjects, counts) {
+ await shareErase(t, subjects);
const now = Date.now();
const now = Date.now();
// Their spaces: published ones stop serving, then the spaces go with what others kept in them.
// 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]);
const spaces = await t.many("SELECT id, site_id FROM space_apps WHERE owner_subject = ANY($1::text[])", [subjects]);
@@ -18,6 +18,7 @@ const { createSiteSources } = require('./domain/site-sources');
@@ -18,6 +18,7 @@ const { createSiteSources } = require('./domain/site-sources');
const { createViewerResolver, guard } = require('./auth/viewer');
const { createViewerResolver, guard } = require('./auth/viewer');
const { createTenantServer } = require('./http/tenant');
const { createTenantServer } = require('./http/tenant');
const { createUploadGate } = require('./http/upload');
const { createUploadGate } = require('./http/upload');
+const { createSharing, describeRef } = require('./sharing');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const resourceIndex = require('./registry/resource-index');
const resourceIndex = require('./registry/resource-index');
const { createDashboard } = require('./http/dashboard');
const { createDashboard } = require('./http/dashboard');
@@ -61,11 +62,13 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
@@ -61,11 +62,13 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
const worker = createWorker({ config, store, domains, blobs, outbox, log });
const worker = createWorker({ config, store, domains, blobs, outbox, log });
const billing = createSpaceBilling({ db, config, now: store.now, ...(fetchImpl ? { fetchImpl } : {}), 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 };
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 });
ctx.actorLimits = require('./http/actor-limits').createHostActorLimits({ registry: app.locals.metrics, valkey: ctx.valkey });
app.locals.ctx = ctx;
app.locals.ctx = ctx;
// Spaces (server/spaces/): apps people make here and publish as sites of their Spaces project.
// 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.spacesAi = require('./spaces/ai').createSpacesAi({ config, client: spacesAiClient, log });
ctx.spaceData = require('./spaces/data').createSpaceData({ store, config, spaces: ctx.spaces, ai: ctx.spacesAi });
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 });
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
@@ -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') }));
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
// 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).
// 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/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('/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));
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));
@@ -29,6 +29,9 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -29,6 +29,9 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
app.locals.hosting.outbox.start();
app.locals.hosting.outbox.start();
app.locals.hosting.billing.start(); // off unless SPACE_BILLING_INTERVAL_MS > 0
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)
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,
// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
// SPACE_EVENTS_SECRET or the client secret.
// SPACE_EVENTS_SECRET or the client secret.
const hc = app.locals.hosting.config;
const hc = app.locals.hosting.config;
@@ -46,7 +49,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -46,7 +49,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
gracefulStop({
gracefulStop({
name: 'Space', server,
name: 'Space', server,
drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
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: [
close: [
async () => { if (telemetryRetention) await telemetryRetention.stop(); await telemetry.stop(); },
async () => { if (telemetryRetention) await telemetryRetention.stop(); await telemetry.stop(); },
async () => {
async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -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 };
@@ -269,7 +269,20 @@ function createSpacesPages(ctx) {
@@ -269,7 +269,20 @@ function createSpacesPages(ctx) {
}));
}));
router.get('/spaces/:slug', view(async (req, res) => {
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;
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 }));
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 }));
}));
}));
@@ -134,9 +134,27 @@ function minePage({ spaces }) {
@@ -134,9 +134,27 @@ function minePage({ spaces }) {
return page({ title: 'Your spaces', path: '/spaces/mine', robots: 'noindex,nofollow', body });
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 }) {
function spacePage({ viewer, space, owner, url, csrf, forkedFrom }) {
const live = Boolean(space.site_id);
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 = [
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,
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>` : '',
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) {
@@ -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>` });
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 };
@@ -42,7 +42,7 @@ function slugBase(name) {
@@ -42,7 +42,7 @@ function slugBase(name) {
return s.length >= 3 ? s : `space-${s}`.replace(/-+$/, '');
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 { db } = store;
const limits = config.spaces;
const limits = config.spaces;
const q = {
const q = {
@@ -103,14 +103,22 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
@@ -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; }
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);
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') {
async function load(viewer, slug, need = 'read') {
const space = await getBySlug(slug);
const space = await getBySlug(slug);
const notFound = new ApiError(404, 'space.not_found', 'no such space');
const notFound = new ApiError(404, 'space.not_found', 'no such space');
if (!space) throw notFound;
if (!space) throw notFound;
const owner = isOwner(viewer, space) || Boolean(viewer && viewer.staff);
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 === '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 };
return { space, owner };
}
}
@@ -184,6 +192,8 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
@@ -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),
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);
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);
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);
return await get(id);
}
}
@@ -225,6 +235,7 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
@@ -225,6 +235,7 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
await store.tx(async () => {
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);
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);
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 };
return { space: await get(id), key };
}
}
@@ -243,6 +254,8 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
@@ -243,6 +254,8 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
const claimed = await store.tx(async () => {
const claimed = await store.tx(async () => {
const r = await q.claimGuest.run(person.subject, person.username, person.name, slug, now, space.id);
const r = await q.claimGuest.run(person.subject, person.username, person.name, slug, now, space.id);
if (!r.changes) return false;
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.
// The claim is a status event: the draft now belongs to a person.
if (outbox) {
if (outbox) {
await outbox.emit({
await outbox.emit({
@@ -284,6 +297,7 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
@@ -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,
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,
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);
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);
return await get(space.id);
}
}
@@ -351,6 +365,8 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
@@ -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 deploy = await deploys.create(viewer, pre, entries, { source: 'files', activate: true, notes: [`space ${space.id}`], traceparent });
const now = store.now();
const now = store.now();
await q.published.run(site.id, space.published_at || now, now, space.id);
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) };
return { space: await get(space.id), site, deploy, url: urlOf(space) };
}
}
@@ -359,6 +375,7 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
@@ -359,6 +375,7 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
const site = await sites.get(space.site_id);
const site = await sites.get(space.site_id);
if (site && site.status === 'active') await sites.remove(viewer, site.id, { deploys });
if (site && site.status === 'active') await sites.remove(viewer, site.id, { deploys });
await q.unpublished.run(store.now(), space.id);
await q.unpublished.run(store.now(), space.id);
+ if (sharing) await sharing.syncAudience(space.id, 'private');
return await get(space.id);
return await get(space.id);
}
}
@@ -25,6 +25,8 @@ const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g
@@ -25,6 +25,8 @@ const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g
exempt: {
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion Space applied',
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',
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(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -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();
+ }
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -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();
+})();