openvibers/openvibe.design Commits
Design: a design can be shared with specific people (sharing step 5) (#164)
Changes
@@@ -229,6 +229,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by<!-- versions:start -->- openvibe-contracts: v0.289.1- openvibe-sdk: v0.91.0--- openvibe-shared: v4.122.0++- openvibe-shared: v4.123.0- openvibe-publishing: v1.5.0<!-- versions:end -->@@@ -10,12 +10,12 @@"domain": "openvibe.design","contracts": "openvibe-contracts v0.289.1","sdk": "openvibe-sdk v0.91.0",-- "shared": "openvibe-shared v4.122.0",++ "shared": "openvibe-shared v4.123.0","packages": ["openvibe-contracts v0.289.1","openvibe-publishing v1.5.0","openvibe-sdk v0.91.0",-- "openvibe-shared v4.122.0"++ "openvibe-shared v4.123.0"],"summary": "The service skeleton for openvibe.design: Network sign-in as OAuth client `design` (PKCE S256, httpOnly design_at/design_rt cookies) and a theme API proxy to OpenVibe.Network's /api/themes. The proxy serves the public catalogue (cached 5 minutes), one theme, the person's active theme and custom variables, saving a theme with source 'studio', its history and a restore by id, and a submission to the gallery. Writes need this site's own Origin; Network 4xx answers pass through and a Network failure is 502. The studio is designed: a gallery, previews on a Live channel, a Chat room and a shop's grid (with colour-vision views), a six-colour editor that derives the full token set, WCAG checks that gate Use, sharing to the gallery, and History with Restore. The Arena queues one prompt for three models through OpenVibe.AI (client credentials, ai.run.create), stores sanitized HTML with reported model/provider/cost/time/tokens, renders it only in sandboxed CSP-capped iframes, draws blind pairs, records one vote per person per round, and keeps Elo ratings per category plus median cost and time. The prompts, outputs and votes are an open JSONL dataset; rounds and votes follow ADR-033 export and deletion.","works": [nnew file mode 100644@@@ -0,0 +1,70 @@++-- phase: expand++-- Sharing (design 2026-11-06, VISIBILITY-EVERYWHERE step 5): the tables openvibe-sdk/share v0.91.0's++-- sharesSchema() creates, kept verbatim so Design's database has the same shape as every other service++-- that adopts it. Expand only: nothing is dropped or rewritten.++++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,8 @@++-- phase: migrate++-- Every design with a signed-in owner is registered once, Only me. A guest design is reached with its++-- own secret edit key and is not a share.v1 resource: it has no owner subject to give it to.++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)++SELECT 'design', 'design', d.id, jsonb_build_object('type', 'user', 'id', d.owner), 'private'++FROM designs d++WHERE d.owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'++ON CONFLICT (service, kind, resource_id) DO NOTHING;@@@ -18,7 +18,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.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.1","subset-font": "^2.9.0"},@@@ -1384,9 +1384,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",@@@ -24,7 +24,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.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.1","subset-font": "^2.9.0"},@@@ -38,6 +38,7 @@ const { createMcpRoutes } = require('./http/mcp');const { createArenaApi } = require('./http/arena-api');const { createDesignApi } = require('./http/design-api');const { createDesigns } = require('./designs');++const { createSharing, describeRef } = require('./sharing');const { createPageRoutes } = require('./http/pages');const { createTokenFileRoutes } = require('./http/tokens');const { createDiscoveryRoutes } = require('./http/discovery');@@@ -73,7 +74,10 @@ async function createApp(opts = {}) {const ai = opts.arenaAi || opts.ai || createArenaAi({ config, fetchImpl, log });// The Arena generation job now lives on Bench; Design keeps a no-op loop for test compatibility.const jobs = opts.jobs || { tick: async () => null, start: () => null, stop: () => null, get running() { return false; } };-- const designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, log, now });++ // Sharing (openvibe-sdk/share): the shares row next to each signed-in maker's design, the one access++ // check every read path asks, and the uniform API below. Guest designs keep their secret edit key.++ const sharing = opts.sharing || createSharing({ db: s.db, config, log, now });++ const designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, log, now, sharing });// Publishing a design on openvibe.space (open item D8).const publisher = opts.publisher || require('./publish').createPublisher({ config, fetchImpl, log });const accountData = accountDataLib.create({ db: s.db, log });@@@ -83,7 +87,7 @@ async function createApp(opts = {}) {: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });const ctx = {-- config, s, store: s, keys, sso, catalog, searchIndex, arena, bench, ai, jobs, designs, publisher, accountData, accountSend, log, picks,++ config, s, store: s, keys, sso, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks,random: opts.random || Math.random,};@@@ -195,6 +199,34 @@ async function createApp(opts = {}) {// The mark's intro, as the files /brand offers to download.app.use('/brand/motion', express.static(path.join(PUBLIC_DIR, 'brand', 'motion'), { index: false, redirect: false, setHeaders: setAssetHeaders }));++ // ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────++ // GET/PUT /api/v1/design/:id/share, .../share/requests, .../share/transfer[/accept|decline|cancel],++ // GET /api/v1/share/people?name=, GET /api/v1/shared-with-me. Authentication is Design's own session;++ // a guest key is never a share owner. The router answers only its own paths, so the rest of /api/v1++ // keeps its own 404.++ {++ const routed = Object.create(sharing.shares);++ routed.getWithRevision = async (viewer, ref) => { await sharing.ensure(ref.id); return await sharing.shares.getWithRevision(viewer, ref); };++ routed.update = async (viewer, ref, body, extra) => { await sharing.ensure(ref.id); return await sharing.shares.update(viewer, ref, body, extra); };++ routed.requestAccess = async (viewer, ref) => { await sharing.ensure(ref.id); return await sharing.shares.requestAccess(viewer, ref); };++ const shareApi = express.Router();++ shareApi.use((req, res, next) => {++ req.shareViewer = req.viewer && req.viewer.kind === 'user' ? { subject: { type: 'user', id: req.viewer.subject }, staff: false } : null;++ next();++ });++ shareApi.use(sharing.shares.router({++ service: 'design',++ authenticate: (req) => req.shareViewer || null,++ describe: describeRef(s.db, config),++ }));++ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|design\/[^/]+\/share(?:\/|$))/;++ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));++ if (!opts.startSharingWorker) {++ const timer = setInterval(() => { sharing.work().catch(() => { /* logged inside */ }); }, 15_000);++ if (timer.unref) timer.unref();++ app.locals.sharingTimer = timer;++ }++ }// ── API: the Arena, the glyphs (with their pages), then the theme proxy ──app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));app.use('/api', express.json({ limit: '64kb' }));@@@ -92,7 +92,7 @@ function editPrompt(instruction, html, names) {].join('\n\n');}--function createDesigns({ s, ai, bench = null, catalog, config = {}, log = console, now = () => Date.now() }) {++function createDesigns({ s, ai, bench = null, catalog, config = {}, log = console, now = () => Date.now(), sharing = null }) {const db = s.db;const chatEditLimit = Math.max(1, Number(config.studio && config.studio.chatEditsPerDesign) || CHAT_EDITS_DEFAULT);const publicRow = d => ({ id: d.id, prompt: d.prompt, kind: d.kind, themeId: d.theme_id,@@@ -105,11 +105,27 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolif (!d.guest_hash || !key) return false;return crypto.timingSafeEqual(Buffer.from(d.guest_hash, 'hex'), hash(String(key)));}-- async function row(id, owner, key) {++ /**++ * A design the viewer may open: its owner, a guest with the secret edit key, or somebody the share++ * row names (Everyone, Anyone with the link, Specific people). The guest link keeps working exactly++ * as before; a share row never widens its own guest key.++ */++ async function row(id, owner, key, viewer = null, edit = false) {const d = await db.maybe('SELECT * FROM designs WHERE id=$1', [id]);if (!d) throw fail(404, 'design.not_found', 'Design not found.');-- if (!allowed(d, owner, key)) throw fail(403, 'design.forbidden', 'Use the private link for this design.');-- return d;++ if (allowed(d, owner, key)) return d;++ if (sharing) {++ const share = await sharing.shareOf(id).catch(() => null);++ if (share) {++ if (!edit && ['public', 'link'].includes(share.audience)) return d;++ if (await (edit ? sharing.canEdit(viewer, id) : sharing.canView(viewer, id)).catch(() => false)) return d;++ // A shares row is the authority: a person not on it is refused before the prompt, a++ // variant or a version is read. The page turns this into the gate; the API keeps its++ // long-standing 403 shape ("use the private link").++ throw fail(403, 'design.forbidden', 'Use the private link for this design.');++ }++ }++ throw fail(403, 'design.forbidden', 'Use the private link for this design.');}async function theme(id) {const list = await catalog.list();@@@ -136,6 +152,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolconst id = s.newId('dsn'), editKey = owner ? null : random();await db.exec('INSERT INTO designs(id,prompt,kind,theme_id,variants,owner,guest_hash,guest_ip,created_at,updated_at) VALUES($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9::timestamptz,$9::timestamptz)',[id, prompt, kind, selected.id, JSON.stringify(variants), owner, editKey ? hash(editKey).toString('hex') : null, owner ? null : ip, s.iso()]);++ // A signed-in maker's new design is Only me until its Share sheet says otherwise (a guest's is its key).++ if (sharing && owner) await sharing.register(id, owner).catch((err) => log.warn('[OpenVibe.Design] share row not registered:', err.message));run(id).catch(err => log.warn('[OpenVibe.Design] generation:', err.message));return { design: publicRow({ id, prompt, kind, theme_id: selected.id, variants, created_at: s.iso(), updated_at: s.iso() }),editKey, editUrl: editKey ? `/d/${id}?key=${encodeURIComponent(editKey)}` : null };@@@ -295,8 +313,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolreturn row;}-- async function read(id, owner, key) {-- const d = await row(id, owner, key), selected = await theme(d.theme_id);++ async function read(id, owner, key, viewer = null) {++ const d = await row(id, owner, key, viewer), selected = await theme(d.theme_id);const p = await pickRow(id);const versions = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]) : [];const current = p ? versions.find(v => Number(v.seq) === Number(p.current_seq)) : null;@@@ -312,8 +330,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolreturn { design: publicRow(d), variants, pick: pickPublic(p, current), versions: versions.map(v => versionPublic(v, p ? p.current_seq : 1)) };}-- async function pick({ id, variantIndex, variantId, owner, key, subject = null, token = null }) {-- const d = await row(id, owner, key);++ async function pick({ id, variantIndex, variantId, owner, key, subject = null, token = null, viewer = null }) {++ const d = await row(id, owner, key, viewer, true);let index = variantIndex == null || variantIndex === '' ? null : Number(variantIndex);if (variantId) {const found = d.variants.findIndex(v => String(v.id) === String(variantId));@@@ -329,7 +347,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolif (existing) {if (Number(existing.variant_index) !== index) throw fail(409, 'design.already_picked', 'This design already has a working frame.');const vote = await recordPickVote(d, subject, token);-- const data = await read(id, owner, key);++ const data = await read(id, owner, key, viewer);return { ...data, vote, idempotent: true };}const inserted = await db.maybe(`INSERT INTO design_picks(design_id,variant_index,variant_id,voter_subject,created_at,updated_at)@@@ -339,12 +357,12 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolif (!p || Number(p.variant_index) !== index) throw fail(409, 'design.already_picked', 'This design already has a working frame.');await ensureBaseVersion(d, p, variant);const vote = await recordPickVote(d, subject, token);-- const data = await read(id, owner, key);++ const data = await read(id, owner, key, viewer);return { ...data, vote, idempotent: !inserted };}-- async function currentForEdit(id, owner, key) {-- const d = await row(id, owner, key);++ async function currentForEdit(id, owner, key, viewer = null) {++ const d = await row(id, owner, key, viewer, true);const p = await pickRow(id);if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before editing it.');const selected = await theme(d.theme_id);@@@ -358,11 +376,11 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolreturn { d, p, selected, variant, current };}-- async function chatEdit({ id, instruction, owner, key, subject = null }) {++ async function chatEdit({ id, instruction, owner, key, subject = null, viewer = null }) {instruction = String(instruction == null ? '' : instruction).trim();if (!instruction || instruction.length > CHAT_INSTRUCTION_MAX)throw fail(422, 'design.invalid_instruction', `Write an instruction up to ${CHAT_INSTRUCTION_MAX} characters.`);-- const { d, p, selected, variant } = await currentForEdit(id, owner, key);++ const { d, p, selected, variant } = await currentForEdit(id, owner, key, viewer);const used = await db.maybe('UPDATE design_picks SET chat_edits=chat_edits+1, updated_at=$2 WHERE design_id=$1 AND chat_edits < $3 RETURNING chat_edits',[d.id, s.iso(), chatEditLimit]);if (!used) throw fail(429, 'design.edit_limit', `This design has used its ${chatEditLimit} AI edits. Click edits and undo still work.`);@@@ -382,12 +400,12 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolconst version = await addVersion(d, p, { kind: 'chat', html, instruction, model: out.model || variant.model,provider: out.provider || variant.provider, costUsd: out.cost_usd, ms: out.ms, createdBy: subject || (d.owner ? 'owner' : 'guest') });const fresh = await pickRow(id);-- const data = await read(id, owner, key);++ const data = await read(id, owner, key, viewer);return { version: versionPublic(version, fresh.current_seq), html, pick: data.pick, versions: data.versions };}-- async function clickEdit({ id, path, text, color, spacing, align, owner, key, subject = null }) {-- const { d, p, selected, variant } = await currentForEdit(id, owner, key);++ async function clickEdit({ id, path, text, color, spacing, align, owner, key, subject = null, viewer = null }) {++ const { d, p, selected, variant } = await currentForEdit(id, owner, key, viewer);const current = await versionRow(id, p.current_seq);let change;try {@@@ -411,48 +429,48 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolconst version = await addVersion(d, p, { kind: 'click', html, change: record, model: variant.model,provider: variant.provider, costUsd: 0, ms: 0, createdBy: subject || (d.owner ? 'owner' : 'guest') });const fresh = await pickRow(id);-- const data = await read(id, owner, key);++ const data = await read(id, owner, key, viewer);return { version: versionPublic(version, fresh.current_seq), html, pick: data.pick, versions: data.versions };}-- async function versions(id, owner, key) {-- const d = await row(id, owner, key);++ async function versions(id, owner, key, viewer = null) {++ const d = await row(id, owner, key, viewer);const p = await pickRow(id);const list = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]) : [];return { designId: d.id, currentSeq: p ? Number(p.current_seq) : null, versions: list.map(v => versionPublic(v, p ? p.current_seq : 1)) };}-- async function undo(id, owner, key) {-- const d = await row(id, owner, key);++ async function undo(id, owner, key, viewer = null) {++ const d = await row(id, owner, key, viewer, true);const p = await pickRow(id);if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before undoing an edit.');const current = await versionRow(id, p.current_seq);if (!current || current.parent_seq == null) {-- return { changed: false, ...(await read(id, owner, key)) };++ return { changed: false, ...(await read(id, owner, key, viewer)) };}const stack = Array.isArray(p.redo_stack) ? p.redo_stack.slice() : [];stack.push(Number(current.seq));await db.exec('UPDATE design_picks SET current_seq=$2, redo_stack=$3::jsonb, updated_at=$4 WHERE design_id=$1',[d.id, Number(current.parent_seq), JSON.stringify(stack.slice(-100)), s.iso()]);-- return { changed: true, ...(await read(id, owner, key)) };++ return { changed: true, ...(await read(id, owner, key, viewer)) };}-- async function redo(id, owner, key) {-- const d = await row(id, owner, key);++ async function redo(id, owner, key, viewer = null) {++ const d = await row(id, owner, key, viewer, true);const p = await pickRow(id);if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before redoing an edit.');const stack = Array.isArray(p.redo_stack) ? p.redo_stack.slice() : [];-- if (!stack.length) return { changed: false, ...(await read(id, owner, key)) };++ if (!stack.length) return { changed: false, ...(await read(id, owner, key, viewer)) };const seq = Number(stack.pop());const target = await versionRow(id, seq);-- if (!target) return { changed: false, ...(await read(id, owner, key)) };++ if (!target) return { changed: false, ...(await read(id, owner, key, viewer)) };await db.exec('UPDATE design_picks SET current_seq=$2, redo_stack=$3::jsonb, updated_at=$4 WHERE design_id=$1',[d.id, seq, JSON.stringify(stack), s.iso()]);-- return { changed: true, ...(await read(id, owner, key)) };++ return { changed: true, ...(await read(id, owner, key, viewer)) };}-- async function setTheme(id, themeId, owner, key) {-- await row(id, owner, key);++ async function setTheme(id, themeId, owner, key, viewer = null) {++ await row(id, owner, key, viewer, true);const selected = await theme(themeId);await db.exec('UPDATE designs SET theme_id=$1,updated_at=$2::timestamptz WHERE id=$3', [selected.id, s.iso(), id]);return { themeId: selected.id, variables: selected.variables || {} };@@@ -474,9 +492,9 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol* Publish the working frame on openvibe.space (publish.js): its owner only (a guest's private link cannot), and* only once a frame is picked. The site is remembered, so publishing again updates the same address.*/-- async function publish({ id, owner, name = '', publisher }) {++ async function publish({ id, owner, name = '', publisher, viewer = null }) {if (!owner) throw fail(401, 'auth.required', 'Sign in to publish a design on openvibe.space.');-- const d = await row(id, owner, null);++ const d = await row(id, owner, null, viewer, true);if (d.owner !== owner) throw fail(403, 'design.forbidden', 'Only the person who made this design can publish it.');const data = await read(id, owner, null);const index = data.pick ? Number(data.pick.variantIndex) : -1;@@@ -20,6 +20,8 @@ const { asyncRouter } = require('./router');const { requireSameOrigin } = require('./same-origin');const owner = req => req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null;++/** The session viewer the share check reads (`kind`, `subject`), never a token. */++const viewer = req => req.viewer && req.viewer.kind === 'user' ? req.viewer : null;const token = req => req.viewer && req.viewer.kind === 'user' ? req.viewer.token : null;const keyOf = (req) => req.get('x-design-key') || req.query.key || (req.body && req.body.editKey) || null;@@@ -50,36 +52,36 @@ function createDesignApi(ctx) {return priv(res, 201, result);}));routes.get('/:id', safe(async (req, res) => priv(res, 200,-- await ctx.designs.read(req.params.id, owner(req), keyOf(req)))));++ await ctx.designs.read(req.params.id, owner(req), keyOf(req), viewer(req)))));routes.post('/:id/theme', sameOrigin, safe(async (req, res) => priv(res, 200,-- await ctx.designs.setTheme(req.params.id, (req.body || {}).themeId, owner(req), keyOf(req)))));++ await ctx.designs.setTheme(req.params.id, (req.body || {}).themeId, owner(req), keyOf(req), viewer(req)))));// Publish the working frame on openvibe.space (open item D8): the owner only; { name? } for a new site.routes.post('/:id/publish', sameOrigin, safe(async (req, res) => priv(res, 200,await ctx.designs.publish({ id: req.params.id, owner: owner(req), name: (req.body || {}).name || '', publisher: ctx.publisher }))));routes.post('/:id/pick', sameOrigin, safe(async (req, res) => {const body = req.body || {};const result = await ctx.designs.pick({ id: req.params.id, variantIndex: body.variantIndex,-- variantId: body.variantId, owner: owner(req), key: keyOf(req), subject: owner(req), token: token(req) });++ variantId: body.variantId, owner: owner(req), key: keyOf(req), subject: owner(req), token: token(req), viewer: viewer(req) });return priv(res, result.idempotent ? 200 : 201, result);}));routes.post('/:id/edit', sameOrigin, safe(async (req, res) => {const body = req.body || {};const result = await ctx.designs.chatEdit({ id: req.params.id, instruction: body.instruction,-- owner: owner(req), key: keyOf(req), subject: owner(req) });++ owner: owner(req), key: keyOf(req), subject: owner(req), viewer: viewer(req) });return priv(res, 200, result);}));routes.post('/:id/click', sameOrigin, safe(async (req, res) => {const body = req.body || {};const result = await ctx.designs.clickEdit({ id: req.params.id, path: body.path, text: body.text,-- color: body.color, spacing: body.spacing, align: body.align, owner: owner(req), key: keyOf(req), subject: owner(req) });++ color: body.color, spacing: body.spacing, align: body.align, owner: owner(req), key: keyOf(req), subject: owner(req), viewer: viewer(req) });return priv(res, 201, result);}));routes.get('/:id/versions', safe(async (req, res) => priv(res, 200,-- await ctx.designs.versions(req.params.id, owner(req), keyOf(req)))));++ await ctx.designs.versions(req.params.id, owner(req), keyOf(req), viewer(req)))));routes.post('/:id/undo', sameOrigin, safe(async (req, res) => priv(res, 200,-- await ctx.designs.undo(req.params.id, owner(req), keyOf(req)))));++ await ctx.designs.undo(req.params.id, owner(req), keyOf(req), viewer(req)))));routes.post('/:id/redo', sameOrigin, safe(async (req, res) => priv(res, 200,-- await ctx.designs.redo(req.params.id, owner(req), keyOf(req)))));++ await ctx.designs.redo(req.params.id, owner(req), keyOf(req), viewer(req)))));return routes;}@@@ -126,11 +126,24 @@ function createPageRoutes(ctx) {res.set('Cache-Control', 'private, no-store');res.set('Referrer-Policy', 'no-referrer');try {-- const data = await ctx.designs.read(req.params.id, subjectOf(req), req.query.key);++ const data = await ctx.designs.read(req.params.id, subjectOf(req), req.query.key, req.viewer);const themes = (await catalog.list()).themes;return page(res, 200, designView.canvas({ data, themes, editKey: req.query.key || '', viewer: subjectOf(req), publishing: { enabled: Boolean(ctx.publisher && ctx.publisher.enabled), notice: req.query.published ? 'Published.' : null, error: req.query.publish_error ? String(req.query.publish_error).slice(0, 300) : null } }), req.viewer,{ cacheControl: 'private, no-store' });} catch (err) {++ if (err && (err.status === 403 || err.status === 404) && ctx.sharing) {++ const share = await ctx.sharing.shareOf(req.params.id).catch(() => null);++ if (share && (share.audience === 'people' || share.audience === 'private')) {++ res.set('Cache-Control', 'private, no-store');++ res.set('Referrer-Policy', 'no-referrer');++ const body = share.audience === 'people'++ ? designView.gate({ id: req.params.id,++ signedInAs: req.viewer && req.viewer.kind === 'user' && req.viewer.username ? String(req.viewer.username) : null,++ signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl || '')}` })++ : views.notFound();++ return page(res, 404, body, req.viewer, { cacheControl: 'private, no-store' });++ }++ }return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not read the design.');}});@@@ -23,6 +23,15 @@ const TABLES = [];async function extraExport(db, subject) {++ // The share.v1 rows the person owns or is on: the design itself is exported above; this is who it was++ // shared with and who shared what with them (subjects as the ids they are).++ const files = [];++ const owned = await db.many('SELECT service, kind, resource_id, audience, lock, revision FROM shares WHERE owner_subject->>\'id\' = $1 ORDER BY resource_id DESC LIMIT 5000', [subject]);++ if (owned.length) files.push({ name: 'shares.json', content: owned });++ const memberships = await db.many(`SELECT s.service, s.kind, s.resource_id, s.audience, m.role, m.added_at, m.expires_at++ FROM share_members m JOIN shares s ON s.service=m.service AND s.kind=m.kind AND s.resource_id=m.resource_id++ WHERE m.subject->>\'id\' = $1 ORDER BY m.added_at DESC LIMIT 5000`, [subject]);++ if (memberships.length) files.push({ name: 'shared_with_me.json', content: memberships });const rows = await db.many(`SELECT o.* FROM arena_outputs oJOIN arena_rounds r ON r.id = o."round"@@@ -30,7 +39,22 @@ async function extraExport(db, subject) {ORDER BY o.created_at DESC LIMIT 5000`,[subject],);-- return rows.length ? [{ name: 'arena_outputs.json', content: rows }] : [];++ if (rows.length) files.push({ name: 'arena_outputs.json', content: rows });++ return files;++}++++/**++ * Deleting an account removes the share rows that named it: ownership of its designs (which the designs++ * table above deletes), memberships in other people's, pending requests and transfers. share_log is the++ * append-only audit trail and is kept (its rows carry ids, no titles or content).++ */++async function extraErase(t, subjects) {++ const ids = [...subjects];++ await t.exec('DELETE FROM share_members WHERE subject->>\'id\' = ANY($1::text[])', [ids]);++ await t.exec('DELETE FROM share_access_requests WHERE subject->>\'id\' = ANY($1::text[])', [ids]);++ await t.exec('DELETE FROM share_transfers WHERE from_subject->>\'id\' = ANY($1::text[]) OR to_subject->>\'id\' = ANY($1::text[])', [ids]);++ await t.exec('DELETE FROM shares WHERE owner_subject->>\'id\' = ANY($1::text[])', [ids]);++ // A member's slot is gone with their membership; the service's own projections follow the design deleted above.}function create({ db, log = console } = {}) {@@@ -39,9 +63,10 @@ function create({ db, log = console } = {}) {service: 'design',tables: TABLES,extraExport,++ extraErase,note: 'Arena votes are exported without another voter\'s identity; outputs travel with the round that made them.',log,});}--module.exports = { create, TABLES, TOPICS, extraExport };++module.exports = { create, TABLES, TOPICS, extraExport, extraErase };nnew file mode 100644@@@ -0,0 +1,178 @@++'use strict';++/**++ * Sharing for designs (design 2026-11-06, VISIBILITY-EVERYWHERE step 5; openvibe-sdk/share).++ *++ * A design belongs to its signed-in maker and starts Only me. Its Share sheet can name people++ * ("review this before I publish it"), or make it Everyone or Anyone with the link. The guest edit++ * key (an unsigned-in maker's secret link) keeps working exactly as before; a guest design has no++ * share row because there is no canonical subject to own it.++ *++ * The SDK owns the model: shares/share_members/share_transfers/share_access_requests/share_log and++ * the delivery queue. This file supplies Network lookups, the events outbox and notification push.++ */++const { createShares } = require('openvibe-sdk/share');++const { serviceAuth } = require('openvibe-contracts');++const { createServiceOutbox } = require('openvibe-sdk/events');++++const SERVICE = 'design';++const KIND = 'design';++const AUDIENCES = ['public', 'link', 'people', 'private'];++const SHARE_EVENTS = [++ 'share.granted', 'share.revoked', 'share.transfer.requested',++ 'share.transfer.accepted', 'share.transfer.declined',++];++const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });++++/** Design's session viewer as the SDK wants it: a canonical subject object and the staff bit. */++function shareViewer(viewer) {++ if (!viewer || viewer.kind !== 'user' || !viewer.subject) return null;++ const id = String(viewer.subject);++ if (!/^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/.test(id)) return null;++ return { subject: { type: id.startsWith('gst_') ? 'guest' : 'user', id }, staff: false };++}++++function createSharing({ db, config, log = console, fetchImpl = globalThis.fetch, now = Date.now, networkJson: networkOverride = null }) {++ const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');++ const tokens = !networkOverride && config.oauth && config.oauth.clientSecret++ ? serviceAuth.createTokenClient({++ tokenUrl: `${base}/oauth/token`,++ clientId: config.oauth.clientId || 'design',++ 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 (networkOverride) return await networkOverride({ path, method, 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;++ }++++ const outbox = createServiceOutbox({++ db, source: SERVICE, eventsUrl: (config.events && config.events.url) || null,++ networkInternalUrl: base, clientId: config.oauth && config.oauth.clientId,++ clientSecret: config.oauth && config.oauth.clientSecret, log, now, eventTypes: SHARE_EVENTS,++ });++ outbox.start();++++ /** One notification, addressed by canonical subject; it never carries a prompt or a design's HTML. */++ async function notify(n) {++ const to = n && n.to;++ if (!to || to.type !== 'user') return;++ const words = {++ 'share.added': ['A design was shared with you', 'Open it on OpenVibe.Design.'],++ 'share.access_requested': ['Someone asked for access to your design', 'Review the request on OpenVibe.Design.'],++ 'share.transfer.requested': ['Someone wants to give you a design', 'Accept or decline it on OpenVibe.Design.'],++ 'share.transfer.answered': ['A design transfer was answered', 'See what happened on OpenVibe.Design.'],++ }[n.kind] || ['Sharing on OpenVibe.Design', '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: SERVICE, url: n.ref && n.ref.id ? `${config.baseUrl}/d/${encodeURIComponent(n.ref.id)}` : config.baseUrl,++ category: 'social',++ },++ });++ } catch (err) {++ log.warn('[Sharing] notification not sent:', err.message);++ }++ }++++ const shares = createShares({++ db,++ network: { json: networkJson },++ events: {++ async publish(envelope) {++ if (!outbox.enabled) return;++ await db.tx(async (t) => { await outbox.emitIn(t, envelope); });++ await outbox.kick();++ },++ },++ notify,++ });++++ async function shareOf(id) {++ return await db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, String(id)]);++ }++++ /** Register a signed-in maker's design Only me, in the transaction that inserted it. */++ async function register(id, owner) {++ if (!owner || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner))) return null;++ try {++ return await shares.register(refOf(id), { owner_subject: { type: 'user', id: String(owner) }, audience: 'private' });++ } catch (err) {++ if (err.status === 409) return await shareOf(id);++ throw err;++ }++ }++++ /** A legacy design, or one made before the sheet opened: register it Only me on first use (owner only). */++ async function ensure(id) {++ if (await shareOf(id)) return;++ const design = await db.maybe('SELECT id, owner FROM designs WHERE id = $1', [String(id)]);++ if (design && design.owner) await register(id, design.owner);++ }++++ return {++ shares,++ SERVICE,++ KIND,++ AUDIENCES,++ refOf,++ shareViewer,++ outbox,++ shareOf,++ register,++ ensure,++ canView: async (viewer, id) => await shares.canView(shareViewer(viewer), refOf(id)),++ canEdit: async (viewer, id) => await shares.canEdit(shareViewer(viewer), refOf(id)),++ getWithRevision: async (viewer, id) => { await ensure(id); return await shares.getWithRevision(shareViewer(viewer), refOf(id)); },++ update: async (viewer, id, body, opts) => { await ensure(id); return await shares.update(shareViewer(viewer), refOf(id), body, opts); },++ requestAccess: async (viewer, id) => { await ensure(id); return await shares.requestAccess(shareViewer(viewer), refOf(id)); },++ dismiss: async (viewer, id, requestId) => await shares.dismiss(shareViewer(viewer), refOf(id), requestId),++ transfer: async (viewer, id, input) => await shares.transfer(shareViewer(viewer), refOf(id), input),++ accept: async (viewer, id, transferId) => await shares.accept(shareViewer(viewer), refOf(id), transferId),++ decline: async (viewer, id, transferId) => await shares.decline(shareViewer(viewer), refOf(id), transferId),++ cancel: async (viewer, id, transferId) => await shares.cancel(shareViewer(viewer), refOf(id), transferId),++ lookup: async (viewer, name) => await shares.lookup(shareViewer(viewer), name),++ sharedWithMe: async (viewer, opts) => await shares.sharedWithMe(shareViewer(viewer), { service: SERVICE, ...(opts || {}) }),++ 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 design's prompt and page to its owner. */++function describeRef(db, config) {++ return async (ref) => {++ if (ref.service !== SERVICE || ref.kind !== KIND) return {};++ const d = await db.maybe('SELECT id, prompt, owner FROM designs WHERE id = $1', [String(ref.id)]);++ if (!d) return {};++ return {++ title: String(d.prompt || 'A design').slice(0, 200),++ link: `${String(config.baseUrl || '').replace(/\/+$/, '')}/d/${encodeURIComponent(d.id)}`,++ roles: ['viewer'],++ audiences: AUDIENCES,++ transferable: !!d.owner,++ };++ };++}++++module.exports = { createSharing, describeRef, shareViewer, refOf, AUDIENCES, SERVICE, KIND };@@@ -1,11 +1,26 @@'use strict';const { layout, esc } = require('../render/layout');++const ovServe = require('openvibe-shared/serve');const { attrEscape } = require('../arena/sanitize');const { themeCss } = require('../designs');const examples = ['Landing page for a coffee roaster', 'Dashboard for a fitness app', 'Pricing section', 'Mobile onboarding'];++/**++ * The page somebody who is not on a Specific-people design's list sees. It never shows the prompt,++ * a variant, a version or a thumbnail; OVShare.gate offers "Ask for access" to a signed-in person and++ * "Sign in" to everybody else.++ */++function gate({ id, signedInAs = null, signInUrl = '/auth/login' }) {++ const body = `<section class="dv-gate"><h1>A design on OpenVibe.Design</h1>++<p>This is shared with specific people.</p>++<div data-ov-share-gate data-kind="design" data-id="${esc(id)}"${signedInAs ? ` data-signed-in-as="${esc(signedInAs)}"` : ''} data-sign-in-url="${esc(signInUrl)}">This design is shared with specific people on OpenVibe.Design.</div>++</section>++<script src="${ovServe.url('ov-share.js')}" defer></script>`;++ return layout({ title: 'A design on OpenVibe.Design', path: '/d', robots: 'noindex, nofollow', body, scripts: [] });++}++function create({ recent = [] } = {}) {const body = `<section class="dv-create"><h1>What will you design?</h1><form method="post" action="/create"><label class="dv-visually-hidden" for="dv-prompt">Describe your design</label>@@@ -138,7 +153,9 @@ ${versionDetail(v)}</span></li>`).join('');const keyInput = `<input type="hidden" name="key" value="${esc(editKey)}">`;const body = `<section class="dv-canvas" data-id="${esc(design.id)}" data-key="${esc(editKey)}" data-picked="${pick ? pickedIndex : ''}" data-variant-id="${esc(pickedVariant && pickedVariant.id || '')}"><header class="dv-heading"><a href="/create">← New design</a><h1>${esc(design.prompt)}</h1>--<p id="dv-status" role="status">${esc(statusText)}</p></header>++<p id="dv-status" role="status">${esc(statusText)}</p>++<p class="dv-share"><button type="button" data-ov-share-sheet data-kind="design" data-id="${esc(design.id)}"++ data-title="${esc(design.prompt)}" data-url="${esc(`/d/${encodeURIComponent(design.id)}`)}">Share</button></p></header><form class="dv-chat" id="dv-chat-form" method="post" action="/d/${encodeURIComponent(design.id)}/edit">${keyInput}<label for="dv-instruction">Edit with AI</label>@@@ -177,4 +194,4 @@ ${versions.length ? `<ol class="dv-vlist" id="dv-vlist">${versionRows}</ol>` : 'body, scripts: ['js/design.js'] });}--module.exports = { create, canvas, frame, overlayScript };++module.exports = { create, canvas, gate, frame, overlayScript };@@@ -17,6 +17,12 @@ const { testDb } = require('./helpers/db');exempt: {account_data_events: 'the ledger of requests itself: which export and deletion design applied',design_index_revisions: 'the Search index sequencer: owner is the service (design), the rows are icon and theme pages, never a person',++ share_members: 'who a design is shared with: exported as shared_with_me.json, and erased by extraErase',++ share_access_requests: 'pending access requests: transient, erased by extraErase',++ share_transfers: 'pending ownership transfers: transient, erased by extraErase',++ share_deliveries: 'the delivery queue for share events/notifications; rows carry ids and are delivered once',++ share_log: 'the append-only sharing audit trail; rows carry subject ids, never titles or content',++ shares: 'the share.v1 row of a design: exported as shares.json (owned) or shared_with_me.json, erased by extraErase',},});@@@ -170,6 +170,26 @@ async function startNetwork({ themes = DEFAULT_THEMES } = {}) {if (!t) return json(404, { error: 'Theme not found' });return json(200, { theme: t });}++ // Sharing (openvibe-sdk/share): exact @name lookup, batch projections and the notification push.++ if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {++ const name = String(url.searchParams.get('username') || '').toLowerCase()++ const u = [...st.users.values()].find((x) => x.username.toLowerCase() === name)++ if (!u) return json(404, { error: 'no such person' })++ return json(200, { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false, deleted: false })++ }++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {++ const results = {}++ for (const id of body.subject_ids || []) {++ const u = [...st.users.values()].find((x) => x.subject === id)++ if (u) results[id] = { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false, deleted: false }++ }++ return json(200, { results })++ }++ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') {++ st.notifications = st.notifications || []++ st.notifications.push(body)++ return json(202, { queued: true })++ }return json(404, { error: 'not found' });});issuer = srv.url;nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++/**++ * The Specific-people gate is markup, not an inline script (openvibe-shared 4.123.0): Headless Chrome++ * loads the real design gate, runs ov-share.js and checks that it draws — "Sign in" signed out,++ * "Ask for access" for a signed-in stranger — with no console error and no CSP refusal.++ *++ * Skips (exit 0) where Playwright or Chrome is not installed, as in CI; PLAYWRIGHT_MODULE overrides the path.++ */++const assert = require('assert');++const http = require('http');++const fs = require('fs');++++function loadPlaywright() {++ const candidates = [process.env.PLAYWRIGHT_MODULE, 'playwright',++ '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'].filter(Boolean);++ for (const name of candidates) { try { return require(name); } catch { /* next */ } }++ return null;++}++const CHROME = process.env.CHROME_PATH || '/usr/bin/google-chrome';++++(async () => {++ if (!fs.existsSync(CHROME)) return console.log('◌ share gate browser: skipped (no Chrome)');++ const playwright = loadPlaywright();++ if (!playwright) return console.log('◌ share gate browser: skipped (no Playwright)');++++ process.env.BASE_URL = 'https://design.test';++ process.env.OV_NETWORK_URL = 'https://openvibe.network';++ const express = require('express');++ const designView = require('../server/views/design');++++ const app = express();++ app.use('/shared', require('openvibe-shared/serve').handler());++ const gate = (signedInAs) => designView.gate({ id: 'dsn_01J0000000000000000000000A', signedInAs,++ signInUrl: '/auth/login?next=%2Fd%2Fdsn_01J0000000000000000000000A' });++ app.get('/gate-out', (_req, res) => res.status(404).type('html').send(gate(null)));++ app.get('/gate-in', (_req, res) => res.status(404).type('html').send(gate('sam')));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++++ const browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });++ try {++ const check = async (path, words) => {++ const page = await browser.newPage();++ const errors = [];++ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });++ page.on('pageerror', (e) => errors.push(String(e && e.message || e)));++ await page.goto(base + path, { waitUntil: 'domcontentloaded' });++ await page.waitForSelector('.ov-share-gate', { timeout: 15000 });++ const text = await page.locator('.ov-share-gate').innerText();++ for (const word of words) assert.ok(text.includes(word), `${path}: ${word} in ${JSON.stringify(text)}`);++ const bad = errors.filter((e) => /content security policy|refused to (execute|load|apply).*inline|violates the following/i.test(e));++ assert.deepStrictEqual(bad, [], `${path}: CSP/console errors: ${bad.join(' | ')}`);++ await page.close();++ };++ await check('/gate-out', ['This is shared with specific people', 'Sign in']);++ await check('/gate-in', ['This is shared with specific people', 'Ask for access', '@sam']);++ console.log('✅ share gate browser: both gates draw with no CSP refusal');++ } finally {++ await browser.close();++ await new Promise((r) => server.close(r));++ }++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++/**++ * Sharing a design with specific people (design 2026-11-06, VISIBILITY-EVERYWHERE step 5).++ *++ * A signed-in maker's design starts Only me. The Share sheet's four audiences are enforced by canView++ * on the page and the API; the named person opens it, a stranger gets the gate (never the prompt), and++ * a guest design keeps its secret edit key exactly as before. Leaving Everyone takes it out of the++ * public answer at once.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const carol = t.network.addUser('carol');++ const maticus = t.network.addUser('maticus');++ const sam = t.network.addUser('sam');++ const make = async (prompt = 'A landing page for a robot café') => {++ const r = await t.get('/api/v1/designs', { as: carol, origin: 'https://openvibe.design', json: { prompt, kind: 'page' } });++ assert.strictEqual(r.status, 201, r.text);++ return r.json().design;++ };++ const share = (id, opts = {}) => t.get(`/api/v1/design/${id}/share`, { headers: { 'sec-fetch-site': 'same-origin' }, ...opts });++++ await check('a signed-in maker’s design is registered Only me, and a guest design has no share row', async () => {++ const d = await make();++ const row = await t.ctx.s.db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['design', 'design', d.id]);++ assert.ok(row, 'the signed-in design has a share row');++ assert.strictEqual(row.audience, 'private');++ assert.deepStrictEqual(row.owner_subject, { type: 'user', id: carol.subject });++ const guest = await t.get('/api/v1/designs', { origin: 'https://openvibe.design', json: { prompt: 'A guest design' } });++ assert.strictEqual(guest.status, 201, guest.text);++ const none = await t.ctx.s.db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['design', 'design', guest.json().design.id]);++ assert.strictEqual(none, null, 'a guest design is reached by its key, not a share row');++ });++++ await check('Specific people: the named person opens it, a stranger gets the gate and never the prompt', async () => {++ const d = await make('SECRET-CANARY robot café');++ const put = await share(d.id, { method: 'PUT', as: carol, json: { audience: 'people', members: [{ name: '@maticus', role: 'viewer' }] } });++ assert.strictEqual(put.status, 200, put.text);++ assert.strictEqual(put.json().audience, 'people');++ const row = await t.ctx.s.db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['design', 'design', d.id]);++ assert.strictEqual(row.audience, 'people');++++ const member = await t.get(`/d/${d.id}`, { as: maticus });++ assert.strictEqual(member.status, 200);++ assert.ok(member.text.includes('SECRET-CANARY robot café'), 'the person on the list reads the design');++ assert.match(member.headers.get('cache-control') || '', /private, no-store/);++ const memberApi = await t.get(`/api/v1/designs/${d.id}`, { as: maticus });++ assert.strictEqual(memberApi.status, 200);++ assert.match(memberApi.headers.get('cache-control') || '', /private, no-store/);++++ const stranger = await t.get(`/d/${d.id}`, { as: 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(`data-id="${d.id}"`) && !stranger.text.includes('OVShare.gate('), 'markup gate, no inline script');++ assert.ok(!stranger.text.includes('SECRET-CANARY'), 'never the prompt');++ assert.strictEqual((await t.get(`/api/v1/designs/${d.id}`, { as: sam })).status, 403, 'the API refuses it too');++ assert.strictEqual((await t.get(`/api/v1/designs/${d.id}`)).status, 403);++ assert.strictEqual((await share(d.id, { as: sam })).status, 404, 'the sheet is the owner’s');++ });++++ await check('Everyone and the link, then Only me: the page follows the audience at once', async () => {++ const d = await make('Public by choice');++ assert.strictEqual((await share(d.id, { as: carol, method: 'PUT', json: { audience: 'public' } })).status, 200);++ assert.strictEqual((await t.get(`/d/${d.id}`)).status, 200, 'Everyone');++ assert.strictEqual((await share(d.id, { as: carol, method: 'PUT', json: { audience: 'link' } })).status, 200);++ assert.strictEqual((await t.get(`/d/${d.id}`)).status, 200, 'the link is the key');++ assert.strictEqual((await share(d.id, { as: carol, method: 'PUT', json: { audience: 'private' } })).status, 200);++ assert.strictEqual((await t.get(`/d/${d.id}`)).status, 404, 'Only me');++ assert.strictEqual((await t.get(`/d/${d.id}`, { as: carol })).status, 200, 'the owner still opens it');++ });++++ await check('the sheet lists the people for the owner, and the agent API (subjects) agrees with names', async () => {++ const d = await make('Reviewed by two');++ const put = await share(d.id, { as: carol, method: 'PUT', json: { audience: 'people',++ members: [{ subject: { type: 'user', id: sam.subject }, role: 'viewer' }, { name: '@maticus', role: 'editor' }] } });++ assert.strictEqual(put.status, 200, put.text);++ const mine = await share(d.id, { as: carol });++ assert.strictEqual(mine.status, 200);++ const body = mine.json();++ assert.strictEqual(body.you.role, 'owner');++ assert.deepStrictEqual(body.members.map((m) => m.username).sort(), ['maticus', 'sam']);++ assert.strictEqual(body.members.find((m) => m.username === 'maticus').role, 'editor');++ assert.strictEqual((await t.get(`/d/${d.id}`, { as: sam })).status, 200);++ // Can view and Can edit are different doors: a viewer cannot change the working frame, the editor can.++ const origin = 'https://openvibe.design';++ assert.strictEqual((await t.get(`/api/v1/designs/${d.id}/undo`, { as: sam, method: 'POST', origin, json: {} })).status, 403, 'a viewer cannot edit');++ assert.strictEqual((await t.get(`/api/v1/designs/${d.id}/undo`, { as: maticus, method: 'POST', origin, json: {} })).status, 409, 'an editor passes the door (no working frame yet)');++ });++++ await done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -229,6 +229,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
@@ -229,6 +229,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
<!-- 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
- openvibe-publishing: v1.5.0
- openvibe-publishing: v1.5.0
<!-- versions:end -->
<!-- versions:end -->
@@ -10,12 +10,12 @@
@@ -10,12 +10,12 @@
"domain": "openvibe.design",
"domain": "openvibe.design",
"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",
"packages": [
"packages": [
"openvibe-contracts v0.289.1",
"openvibe-contracts v0.289.1",
"openvibe-publishing v1.5.0",
"openvibe-publishing v1.5.0",
"openvibe-sdk v0.91.0",
"openvibe-sdk v0.91.0",
- "openvibe-shared v4.122.0"
+ "openvibe-shared v4.123.0"
],
],
"summary": "The service skeleton for openvibe.design: Network sign-in as OAuth client `design` (PKCE S256, httpOnly design_at/design_rt cookies) and a theme API proxy to OpenVibe.Network's /api/themes. The proxy serves the public catalogue (cached 5 minutes), one theme, the person's active theme and custom variables, saving a theme with source 'studio', its history and a restore by id, and a submission to the gallery. Writes need this site's own Origin; Network 4xx answers pass through and a Network failure is 502. The studio is designed: a gallery, previews on a Live channel, a Chat room and a shop's grid (with colour-vision views), a six-colour editor that derives the full token set, WCAG checks that gate Use, sharing to the gallery, and History with Restore. The Arena queues one prompt for three models through OpenVibe.AI (client credentials, ai.run.create), stores sanitized HTML with reported model/provider/cost/time/tokens, renders it only in sandboxed CSP-capped iframes, draws blind pairs, records one vote per person per round, and keeps Elo ratings per category plus median cost and time. The prompts, outputs and votes are an open JSONL dataset; rounds and votes follow ADR-033 export and deletion.",
"summary": "The service skeleton for openvibe.design: Network sign-in as OAuth client `design` (PKCE S256, httpOnly design_at/design_rt cookies) and a theme API proxy to OpenVibe.Network's /api/themes. The proxy serves the public catalogue (cached 5 minutes), one theme, the person's active theme and custom variables, saving a theme with source 'studio', its history and a restore by id, and a submission to the gallery. Writes need this site's own Origin; Network 4xx answers pass through and a Network failure is 502. The studio is designed: a gallery, previews on a Live channel, a Chat room and a shop's grid (with colour-vision views), a six-colour editor that derives the full token set, WCAG checks that gate Use, sharing to the gallery, and History with Restore. The Arena queues one prompt for three models through OpenVibe.AI (client credentials, ai.run.create), stores sanitized HTML with reported model/provider/cost/time/tokens, renders it only in sandboxed CSP-capped iframes, draws blind pairs, records one vote per person per round, and keeps Elo ratings per category plus median cost and time. The prompts, outputs and votes are an open JSONL dataset; rounds and votes follow ADR-033 export and deletion.",
"works": [
"works": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+-- phase: expand
+-- Sharing (design 2026-11-06, VISIBILITY-EVERYWHERE step 5): the tables openvibe-sdk/share v0.91.0's
+-- sharesSchema() creates, kept verbatim so Design's database has the same shape as every other service
+-- that adopts it. Expand only: nothing is dropped or rewritten.
+
+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,8 @@
@@ -0,0 +1,8 @@
+-- phase: migrate
+-- Every design with a signed-in owner is registered once, Only me. A guest design is reached with its
+-- own secret edit key and is not a share.v1 resource: it has no owner subject to give it to.
+INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
+SELECT 'design', 'design', d.id, jsonb_build_object('type', 'user', 'id', d.owner), 'private'
+FROM designs d
+WHERE d.owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
+ON CONFLICT (service, kind, resource_id) DO NOTHING;
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"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.1",
"pg": "^8.23.1",
"subset-font": "^2.9.0"
"subset-font": "^2.9.0"
},
},
@@ -1384,9 +1384,9 @@
@@ -1384,9 +1384,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",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"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.1",
"pg": "^8.23.1",
"subset-font": "^2.9.0"
"subset-font": "^2.9.0"
},
},
@@ -38,6 +38,7 @@ const { createMcpRoutes } = require('./http/mcp');
@@ -38,6 +38,7 @@ const { createMcpRoutes } = require('./http/mcp');
const { createArenaApi } = require('./http/arena-api');
const { createArenaApi } = require('./http/arena-api');
const { createDesignApi } = require('./http/design-api');
const { createDesignApi } = require('./http/design-api');
const { createDesigns } = require('./designs');
const { createDesigns } = require('./designs');
+const { createSharing, describeRef } = require('./sharing');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createTokenFileRoutes } = require('./http/tokens');
const { createTokenFileRoutes } = require('./http/tokens');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createDiscoveryRoutes } = require('./http/discovery');
@@ -73,7 +74,10 @@ async function createApp(opts = {}) {
@@ -73,7 +74,10 @@ async function createApp(opts = {}) {
const ai = opts.arenaAi || opts.ai || createArenaAi({ config, fetchImpl, log });
const ai = opts.arenaAi || opts.ai || createArenaAi({ config, fetchImpl, log });
// The Arena generation job now lives on Bench; Design keeps a no-op loop for test compatibility.
// The Arena generation job now lives on Bench; Design keeps a no-op loop for test compatibility.
const jobs = opts.jobs || { tick: async () => null, start: () => null, stop: () => null, get running() { return false; } };
const jobs = opts.jobs || { tick: async () => null, start: () => null, stop: () => null, get running() { return false; } };
- const designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, log, now });
+ // Sharing (openvibe-sdk/share): the shares row next to each signed-in maker's design, the one access
+ // check every read path asks, and the uniform API below. Guest designs keep their secret edit key.
+ const sharing = opts.sharing || createSharing({ db: s.db, config, log, now });
+ const designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, log, now, sharing });
// Publishing a design on openvibe.space (open item D8).
// Publishing a design on openvibe.space (open item D8).
const publisher = opts.publisher || require('./publish').createPublisher({ config, fetchImpl, log });
const publisher = opts.publisher || require('./publish').createPublisher({ config, fetchImpl, log });
const accountData = accountDataLib.create({ db: s.db, log });
const accountData = accountDataLib.create({ db: s.db, log });
@@ -83,7 +87,7 @@ async function createApp(opts = {}) {
@@ -83,7 +87,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
const ctx = {
const ctx = {
- config, s, store: s, keys, sso, catalog, searchIndex, arena, bench, ai, jobs, designs, publisher, accountData, accountSend, log, picks,
+ config, s, store: s, keys, sso, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks,
random: opts.random || Math.random,
random: opts.random || Math.random,
};
};
@@ -195,6 +199,34 @@ async function createApp(opts = {}) {
@@ -195,6 +199,34 @@ async function createApp(opts = {}) {
// The mark's intro, as the files /brand offers to download.
// The mark's intro, as the files /brand offers to download.
app.use('/brand/motion', express.static(path.join(PUBLIC_DIR, 'brand', 'motion'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
app.use('/brand/motion', express.static(path.join(PUBLIC_DIR, 'brand', 'motion'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
+ // ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────
+ // GET/PUT /api/v1/design/:id/share, .../share/requests, .../share/transfer[/accept|decline|cancel],
+ // GET /api/v1/share/people?name=, GET /api/v1/shared-with-me. Authentication is Design's own session;
+ // a guest key is never a share owner. The router answers only its own paths, so the rest of /api/v1
+ // keeps its own 404.
+ {
+ const routed = Object.create(sharing.shares);
+ routed.getWithRevision = async (viewer, ref) => { await sharing.ensure(ref.id); return await sharing.shares.getWithRevision(viewer, ref); };
+ routed.update = async (viewer, ref, body, extra) => { await sharing.ensure(ref.id); return await sharing.shares.update(viewer, ref, body, extra); };
+ routed.requestAccess = async (viewer, ref) => { await sharing.ensure(ref.id); return await sharing.shares.requestAccess(viewer, ref); };
+ const shareApi = express.Router();
+ shareApi.use((req, res, next) => {
+ req.shareViewer = req.viewer && req.viewer.kind === 'user' ? { subject: { type: 'user', id: req.viewer.subject }, staff: false } : null;
+ next();
+ });
+ shareApi.use(sharing.shares.router({
+ service: 'design',
+ authenticate: (req) => req.shareViewer || null,
+ describe: describeRef(s.db, config),
+ }));
+ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|design\/[^/]+\/share(?:\/|$))/;
+ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
+ if (!opts.startSharingWorker) {
+ const timer = setInterval(() => { sharing.work().catch(() => { /* logged inside */ }); }, 15_000);
+ if (timer.unref) timer.unref();
+ app.locals.sharingTimer = timer;
+ }
+ }
// ── API: the Arena, the glyphs (with their pages), then the theme proxy ──
// ── API: the Arena, the glyphs (with their pages), then the theme proxy ──
app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));
app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));
app.use('/api', express.json({ limit: '64kb' }));
app.use('/api', express.json({ limit: '64kb' }));
@@ -92,7 +92,7 @@ function editPrompt(instruction, html, names) {
@@ -92,7 +92,7 @@ function editPrompt(instruction, html, names) {
].join('\n\n');
].join('\n\n');
}
}
-function createDesigns({ s, ai, bench = null, catalog, config = {}, log = console, now = () => Date.now() }) {
+function createDesigns({ s, ai, bench = null, catalog, config = {}, log = console, now = () => Date.now(), sharing = null }) {
const db = s.db;
const db = s.db;
const chatEditLimit = Math.max(1, Number(config.studio && config.studio.chatEditsPerDesign) || CHAT_EDITS_DEFAULT);
const chatEditLimit = Math.max(1, Number(config.studio && config.studio.chatEditsPerDesign) || CHAT_EDITS_DEFAULT);
const publicRow = d => ({ id: d.id, prompt: d.prompt, kind: d.kind, themeId: d.theme_id,
const publicRow = d => ({ id: d.id, prompt: d.prompt, kind: d.kind, themeId: d.theme_id,
@@ -105,11 +105,27 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -105,11 +105,27 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
if (!d.guest_hash || !key) return false;
if (!d.guest_hash || !key) return false;
return crypto.timingSafeEqual(Buffer.from(d.guest_hash, 'hex'), hash(String(key)));
return crypto.timingSafeEqual(Buffer.from(d.guest_hash, 'hex'), hash(String(key)));
}
}
- async function row(id, owner, key) {
+ /**
+ * A design the viewer may open: its owner, a guest with the secret edit key, or somebody the share
+ * row names (Everyone, Anyone with the link, Specific people). The guest link keeps working exactly
+ * as before; a share row never widens its own guest key.
+ */
+ async function row(id, owner, key, viewer = null, edit = false) {
const d = await db.maybe('SELECT * FROM designs WHERE id=$1', [id]);
const d = await db.maybe('SELECT * FROM designs WHERE id=$1', [id]);
if (!d) throw fail(404, 'design.not_found', 'Design not found.');
if (!d) throw fail(404, 'design.not_found', 'Design not found.');
- if (!allowed(d, owner, key)) throw fail(403, 'design.forbidden', 'Use the private link for this design.');
- return d;
+ if (allowed(d, owner, key)) return d;
+ if (sharing) {
+ const share = await sharing.shareOf(id).catch(() => null);
+ if (share) {
+ if (!edit && ['public', 'link'].includes(share.audience)) return d;
+ if (await (edit ? sharing.canEdit(viewer, id) : sharing.canView(viewer, id)).catch(() => false)) return d;
+ // A shares row is the authority: a person not on it is refused before the prompt, a
+ // variant or a version is read. The page turns this into the gate; the API keeps its
+ // long-standing 403 shape ("use the private link").
+ throw fail(403, 'design.forbidden', 'Use the private link for this design.');
+ }
+ }
+ throw fail(403, 'design.forbidden', 'Use the private link for this design.');
}
}
async function theme(id) {
async function theme(id) {
const list = await catalog.list();
const list = await catalog.list();
@@ -136,6 +152,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -136,6 +152,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const id = s.newId('dsn'), editKey = owner ? null : random();
const id = s.newId('dsn'), editKey = owner ? null : random();
await db.exec('INSERT INTO designs(id,prompt,kind,theme_id,variants,owner,guest_hash,guest_ip,created_at,updated_at) VALUES($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9::timestamptz,$9::timestamptz)',
await db.exec('INSERT INTO designs(id,prompt,kind,theme_id,variants,owner,guest_hash,guest_ip,created_at,updated_at) VALUES($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9::timestamptz,$9::timestamptz)',
[id, prompt, kind, selected.id, JSON.stringify(variants), owner, editKey ? hash(editKey).toString('hex') : null, owner ? null : ip, s.iso()]);
[id, prompt, kind, selected.id, JSON.stringify(variants), owner, editKey ? hash(editKey).toString('hex') : null, owner ? null : ip, s.iso()]);
+ // A signed-in maker's new design is Only me until its Share sheet says otherwise (a guest's is its key).
+ if (sharing && owner) await sharing.register(id, owner).catch((err) => log.warn('[OpenVibe.Design] share row not registered:', err.message));
run(id).catch(err => log.warn('[OpenVibe.Design] generation:', err.message));
run(id).catch(err => log.warn('[OpenVibe.Design] generation:', err.message));
return { design: publicRow({ id, prompt, kind, theme_id: selected.id, variants, created_at: s.iso(), updated_at: s.iso() }),
return { design: publicRow({ id, prompt, kind, theme_id: selected.id, variants, created_at: s.iso(), updated_at: s.iso() }),
editKey, editUrl: editKey ? `/d/${id}?key=${encodeURIComponent(editKey)}` : null };
editKey, editUrl: editKey ? `/d/${id}?key=${encodeURIComponent(editKey)}` : null };
@@ -295,8 +313,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -295,8 +313,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
return row;
return row;
}
}
- async function read(id, owner, key) {
- const d = await row(id, owner, key), selected = await theme(d.theme_id);
+ async function read(id, owner, key, viewer = null) {
+ const d = await row(id, owner, key, viewer), selected = await theme(d.theme_id);
const p = await pickRow(id);
const p = await pickRow(id);
const versions = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]) : [];
const versions = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]) : [];
const current = p ? versions.find(v => Number(v.seq) === Number(p.current_seq)) : null;
const current = p ? versions.find(v => Number(v.seq) === Number(p.current_seq)) : null;
@@ -312,8 +330,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -312,8 +330,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
return { design: publicRow(d), variants, pick: pickPublic(p, current), versions: versions.map(v => versionPublic(v, p ? p.current_seq : 1)) };
return { design: publicRow(d), variants, pick: pickPublic(p, current), versions: versions.map(v => versionPublic(v, p ? p.current_seq : 1)) };
}
}
- async function pick({ id, variantIndex, variantId, owner, key, subject = null, token = null }) {
- const d = await row(id, owner, key);
+ async function pick({ id, variantIndex, variantId, owner, key, subject = null, token = null, viewer = null }) {
+ const d = await row(id, owner, key, viewer, true);
let index = variantIndex == null || variantIndex === '' ? null : Number(variantIndex);
let index = variantIndex == null || variantIndex === '' ? null : Number(variantIndex);
if (variantId) {
if (variantId) {
const found = d.variants.findIndex(v => String(v.id) === String(variantId));
const found = d.variants.findIndex(v => String(v.id) === String(variantId));
@@ -329,7 +347,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -329,7 +347,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
if (existing) {
if (existing) {
if (Number(existing.variant_index) !== index) throw fail(409, 'design.already_picked', 'This design already has a working frame.');
if (Number(existing.variant_index) !== index) throw fail(409, 'design.already_picked', 'This design already has a working frame.');
const vote = await recordPickVote(d, subject, token);
const vote = await recordPickVote(d, subject, token);
- const data = await read(id, owner, key);
+ const data = await read(id, owner, key, viewer);
return { ...data, vote, idempotent: true };
return { ...data, vote, idempotent: true };
}
}
const inserted = await db.maybe(`INSERT INTO design_picks(design_id,variant_index,variant_id,voter_subject,created_at,updated_at)
const inserted = await db.maybe(`INSERT INTO design_picks(design_id,variant_index,variant_id,voter_subject,created_at,updated_at)
@@ -339,12 +357,12 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -339,12 +357,12 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
if (!p || Number(p.variant_index) !== index) throw fail(409, 'design.already_picked', 'This design already has a working frame.');
if (!p || Number(p.variant_index) !== index) throw fail(409, 'design.already_picked', 'This design already has a working frame.');
await ensureBaseVersion(d, p, variant);
await ensureBaseVersion(d, p, variant);
const vote = await recordPickVote(d, subject, token);
const vote = await recordPickVote(d, subject, token);
- const data = await read(id, owner, key);
+ const data = await read(id, owner, key, viewer);
return { ...data, vote, idempotent: !inserted };
return { ...data, vote, idempotent: !inserted };
}
}
- async function currentForEdit(id, owner, key) {
- const d = await row(id, owner, key);
+ async function currentForEdit(id, owner, key, viewer = null) {
+ const d = await row(id, owner, key, viewer, true);
const p = await pickRow(id);
const p = await pickRow(id);
if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before editing it.');
if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before editing it.');
const selected = await theme(d.theme_id);
const selected = await theme(d.theme_id);
@@ -358,11 +376,11 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -358,11 +376,11 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
return { d, p, selected, variant, current };
return { d, p, selected, variant, current };
}
}
- async function chatEdit({ id, instruction, owner, key, subject = null }) {
+ async function chatEdit({ id, instruction, owner, key, subject = null, viewer = null }) {
instruction = String(instruction == null ? '' : instruction).trim();
instruction = String(instruction == null ? '' : instruction).trim();
if (!instruction || instruction.length > CHAT_INSTRUCTION_MAX)
if (!instruction || instruction.length > CHAT_INSTRUCTION_MAX)
throw fail(422, 'design.invalid_instruction', `Write an instruction up to ${CHAT_INSTRUCTION_MAX} characters.`);
throw fail(422, 'design.invalid_instruction', `Write an instruction up to ${CHAT_INSTRUCTION_MAX} characters.`);
- const { d, p, selected, variant } = await currentForEdit(id, owner, key);
+ const { d, p, selected, variant } = await currentForEdit(id, owner, key, viewer);
const used = await db.maybe('UPDATE design_picks SET chat_edits=chat_edits+1, updated_at=$2 WHERE design_id=$1 AND chat_edits < $3 RETURNING chat_edits',
const used = await db.maybe('UPDATE design_picks SET chat_edits=chat_edits+1, updated_at=$2 WHERE design_id=$1 AND chat_edits < $3 RETURNING chat_edits',
[d.id, s.iso(), chatEditLimit]);
[d.id, s.iso(), chatEditLimit]);
if (!used) throw fail(429, 'design.edit_limit', `This design has used its ${chatEditLimit} AI edits. Click edits and undo still work.`);
if (!used) throw fail(429, 'design.edit_limit', `This design has used its ${chatEditLimit} AI edits. Click edits and undo still work.`);
@@ -382,12 +400,12 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -382,12 +400,12 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const version = await addVersion(d, p, { kind: 'chat', html, instruction, model: out.model || variant.model,
const version = await addVersion(d, p, { kind: 'chat', html, instruction, model: out.model || variant.model,
provider: out.provider || variant.provider, costUsd: out.cost_usd, ms: out.ms, createdBy: subject || (d.owner ? 'owner' : 'guest') });
provider: out.provider || variant.provider, costUsd: out.cost_usd, ms: out.ms, createdBy: subject || (d.owner ? 'owner' : 'guest') });
const fresh = await pickRow(id);
const fresh = await pickRow(id);
- const data = await read(id, owner, key);
+ const data = await read(id, owner, key, viewer);
return { version: versionPublic(version, fresh.current_seq), html, pick: data.pick, versions: data.versions };
return { version: versionPublic(version, fresh.current_seq), html, pick: data.pick, versions: data.versions };
}
}
- async function clickEdit({ id, path, text, color, spacing, align, owner, key, subject = null }) {
- const { d, p, selected, variant } = await currentForEdit(id, owner, key);
+ async function clickEdit({ id, path, text, color, spacing, align, owner, key, subject = null, viewer = null }) {
+ const { d, p, selected, variant } = await currentForEdit(id, owner, key, viewer);
const current = await versionRow(id, p.current_seq);
const current = await versionRow(id, p.current_seq);
let change;
let change;
try {
try {
@@ -411,48 +429,48 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -411,48 +429,48 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const version = await addVersion(d, p, { kind: 'click', html, change: record, model: variant.model,
const version = await addVersion(d, p, { kind: 'click', html, change: record, model: variant.model,
provider: variant.provider, costUsd: 0, ms: 0, createdBy: subject || (d.owner ? 'owner' : 'guest') });
provider: variant.provider, costUsd: 0, ms: 0, createdBy: subject || (d.owner ? 'owner' : 'guest') });
const fresh = await pickRow(id);
const fresh = await pickRow(id);
- const data = await read(id, owner, key);
+ const data = await read(id, owner, key, viewer);
return { version: versionPublic(version, fresh.current_seq), html, pick: data.pick, versions: data.versions };
return { version: versionPublic(version, fresh.current_seq), html, pick: data.pick, versions: data.versions };
}
}
- async function versions(id, owner, key) {
- const d = await row(id, owner, key);
+ async function versions(id, owner, key, viewer = null) {
+ const d = await row(id, owner, key, viewer);
const p = await pickRow(id);
const p = await pickRow(id);
const list = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]) : [];
const list = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]) : [];
return { designId: d.id, currentSeq: p ? Number(p.current_seq) : null, versions: list.map(v => versionPublic(v, p ? p.current_seq : 1)) };
return { designId: d.id, currentSeq: p ? Number(p.current_seq) : null, versions: list.map(v => versionPublic(v, p ? p.current_seq : 1)) };
}
}
- async function undo(id, owner, key) {
- const d = await row(id, owner, key);
+ async function undo(id, owner, key, viewer = null) {
+ const d = await row(id, owner, key, viewer, true);
const p = await pickRow(id);
const p = await pickRow(id);
if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before undoing an edit.');
if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before undoing an edit.');
const current = await versionRow(id, p.current_seq);
const current = await versionRow(id, p.current_seq);
if (!current || current.parent_seq == null) {
if (!current || current.parent_seq == null) {
- return { changed: false, ...(await read(id, owner, key)) };
+ return { changed: false, ...(await read(id, owner, key, viewer)) };
}
}
const stack = Array.isArray(p.redo_stack) ? p.redo_stack.slice() : [];
const stack = Array.isArray(p.redo_stack) ? p.redo_stack.slice() : [];
stack.push(Number(current.seq));
stack.push(Number(current.seq));
await db.exec('UPDATE design_picks SET current_seq=$2, redo_stack=$3::jsonb, updated_at=$4 WHERE design_id=$1',
await db.exec('UPDATE design_picks SET current_seq=$2, redo_stack=$3::jsonb, updated_at=$4 WHERE design_id=$1',
[d.id, Number(current.parent_seq), JSON.stringify(stack.slice(-100)), s.iso()]);
[d.id, Number(current.parent_seq), JSON.stringify(stack.slice(-100)), s.iso()]);
- return { changed: true, ...(await read(id, owner, key)) };
+ return { changed: true, ...(await read(id, owner, key, viewer)) };
}
}
- async function redo(id, owner, key) {
- const d = await row(id, owner, key);
+ async function redo(id, owner, key, viewer = null) {
+ const d = await row(id, owner, key, viewer, true);
const p = await pickRow(id);
const p = await pickRow(id);
if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before redoing an edit.');
if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before redoing an edit.');
const stack = Array.isArray(p.redo_stack) ? p.redo_stack.slice() : [];
const stack = Array.isArray(p.redo_stack) ? p.redo_stack.slice() : [];
- if (!stack.length) return { changed: false, ...(await read(id, owner, key)) };
+ if (!stack.length) return { changed: false, ...(await read(id, owner, key, viewer)) };
const seq = Number(stack.pop());
const seq = Number(stack.pop());
const target = await versionRow(id, seq);
const target = await versionRow(id, seq);
- if (!target) return { changed: false, ...(await read(id, owner, key)) };
+ if (!target) return { changed: false, ...(await read(id, owner, key, viewer)) };
await db.exec('UPDATE design_picks SET current_seq=$2, redo_stack=$3::jsonb, updated_at=$4 WHERE design_id=$1',
await db.exec('UPDATE design_picks SET current_seq=$2, redo_stack=$3::jsonb, updated_at=$4 WHERE design_id=$1',
[d.id, seq, JSON.stringify(stack), s.iso()]);
[d.id, seq, JSON.stringify(stack), s.iso()]);
- return { changed: true, ...(await read(id, owner, key)) };
+ return { changed: true, ...(await read(id, owner, key, viewer)) };
}
}
- async function setTheme(id, themeId, owner, key) {
- await row(id, owner, key);
+ async function setTheme(id, themeId, owner, key, viewer = null) {
+ await row(id, owner, key, viewer, true);
const selected = await theme(themeId);
const selected = await theme(themeId);
await db.exec('UPDATE designs SET theme_id=$1,updated_at=$2::timestamptz WHERE id=$3', [selected.id, s.iso(), id]);
await db.exec('UPDATE designs SET theme_id=$1,updated_at=$2::timestamptz WHERE id=$3', [selected.id, s.iso(), id]);
return { themeId: selected.id, variables: selected.variables || {} };
return { themeId: selected.id, variables: selected.variables || {} };
@@ -474,9 +492,9 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -474,9 +492,9 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
* Publish the working frame on openvibe.space (publish.js): its owner only (a guest's private link cannot), and
* Publish the working frame on openvibe.space (publish.js): its owner only (a guest's private link cannot), and
* only once a frame is picked. The site is remembered, so publishing again updates the same address.
* only once a frame is picked. The site is remembered, so publishing again updates the same address.
*/
*/
- async function publish({ id, owner, name = '', publisher }) {
+ async function publish({ id, owner, name = '', publisher, viewer = null }) {
if (!owner) throw fail(401, 'auth.required', 'Sign in to publish a design on openvibe.space.');
if (!owner) throw fail(401, 'auth.required', 'Sign in to publish a design on openvibe.space.');
- const d = await row(id, owner, null);
+ const d = await row(id, owner, null, viewer, true);
if (d.owner !== owner) throw fail(403, 'design.forbidden', 'Only the person who made this design can publish it.');
if (d.owner !== owner) throw fail(403, 'design.forbidden', 'Only the person who made this design can publish it.');
const data = await read(id, owner, null);
const data = await read(id, owner, null);
const index = data.pick ? Number(data.pick.variantIndex) : -1;
const index = data.pick ? Number(data.pick.variantIndex) : -1;
@@ -20,6 +20,8 @@ const { asyncRouter } = require('./router');
@@ -20,6 +20,8 @@ const { asyncRouter } = require('./router');
const { requireSameOrigin } = require('./same-origin');
const { requireSameOrigin } = require('./same-origin');
const owner = req => req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null;
const owner = req => req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null;
+/** The session viewer the share check reads (`kind`, `subject`), never a token. */
+const viewer = req => req.viewer && req.viewer.kind === 'user' ? req.viewer : null;
const token = req => req.viewer && req.viewer.kind === 'user' ? req.viewer.token : null;
const token = req => req.viewer && req.viewer.kind === 'user' ? req.viewer.token : null;
const keyOf = (req) => req.get('x-design-key') || req.query.key || (req.body && req.body.editKey) || null;
const keyOf = (req) => req.get('x-design-key') || req.query.key || (req.body && req.body.editKey) || null;
@@ -50,36 +52,36 @@ function createDesignApi(ctx) {
@@ -50,36 +52,36 @@ function createDesignApi(ctx) {
return priv(res, 201, result);
return priv(res, 201, result);
}));
}));
routes.get('/:id', safe(async (req, res) => priv(res, 200,
routes.get('/:id', safe(async (req, res) => priv(res, 200,
- await ctx.designs.read(req.params.id, owner(req), keyOf(req)))));
+ await ctx.designs.read(req.params.id, owner(req), keyOf(req), viewer(req)))));
routes.post('/:id/theme', sameOrigin, safe(async (req, res) => priv(res, 200,
routes.post('/:id/theme', sameOrigin, safe(async (req, res) => priv(res, 200,
- await ctx.designs.setTheme(req.params.id, (req.body || {}).themeId, owner(req), keyOf(req)))));
+ await ctx.designs.setTheme(req.params.id, (req.body || {}).themeId, owner(req), keyOf(req), viewer(req)))));
// Publish the working frame on openvibe.space (open item D8): the owner only; { name? } for a new site.
// Publish the working frame on openvibe.space (open item D8): the owner only; { name? } for a new site.
routes.post('/:id/publish', sameOrigin, safe(async (req, res) => priv(res, 200,
routes.post('/:id/publish', sameOrigin, safe(async (req, res) => priv(res, 200,
await ctx.designs.publish({ id: req.params.id, owner: owner(req), name: (req.body || {}).name || '', publisher: ctx.publisher }))));
await ctx.designs.publish({ id: req.params.id, owner: owner(req), name: (req.body || {}).name || '', publisher: ctx.publisher }))));
routes.post('/:id/pick', sameOrigin, safe(async (req, res) => {
routes.post('/:id/pick', sameOrigin, safe(async (req, res) => {
const body = req.body || {};
const body = req.body || {};
const result = await ctx.designs.pick({ id: req.params.id, variantIndex: body.variantIndex,
const result = await ctx.designs.pick({ id: req.params.id, variantIndex: body.variantIndex,
- variantId: body.variantId, owner: owner(req), key: keyOf(req), subject: owner(req), token: token(req) });
+ variantId: body.variantId, owner: owner(req), key: keyOf(req), subject: owner(req), token: token(req), viewer: viewer(req) });
return priv(res, result.idempotent ? 200 : 201, result);
return priv(res, result.idempotent ? 200 : 201, result);
}));
}));
routes.post('/:id/edit', sameOrigin, safe(async (req, res) => {
routes.post('/:id/edit', sameOrigin, safe(async (req, res) => {
const body = req.body || {};
const body = req.body || {};
const result = await ctx.designs.chatEdit({ id: req.params.id, instruction: body.instruction,
const result = await ctx.designs.chatEdit({ id: req.params.id, instruction: body.instruction,
- owner: owner(req), key: keyOf(req), subject: owner(req) });
+ owner: owner(req), key: keyOf(req), subject: owner(req), viewer: viewer(req) });
return priv(res, 200, result);
return priv(res, 200, result);
}));
}));
routes.post('/:id/click', sameOrigin, safe(async (req, res) => {
routes.post('/:id/click', sameOrigin, safe(async (req, res) => {
const body = req.body || {};
const body = req.body || {};
const result = await ctx.designs.clickEdit({ id: req.params.id, path: body.path, text: body.text,
const result = await ctx.designs.clickEdit({ id: req.params.id, path: body.path, text: body.text,
- color: body.color, spacing: body.spacing, align: body.align, owner: owner(req), key: keyOf(req), subject: owner(req) });
+ color: body.color, spacing: body.spacing, align: body.align, owner: owner(req), key: keyOf(req), subject: owner(req), viewer: viewer(req) });
return priv(res, 201, result);
return priv(res, 201, result);
}));
}));
routes.get('/:id/versions', safe(async (req, res) => priv(res, 200,
routes.get('/:id/versions', safe(async (req, res) => priv(res, 200,
- await ctx.designs.versions(req.params.id, owner(req), keyOf(req)))));
+ await ctx.designs.versions(req.params.id, owner(req), keyOf(req), viewer(req)))));
routes.post('/:id/undo', sameOrigin, safe(async (req, res) => priv(res, 200,
routes.post('/:id/undo', sameOrigin, safe(async (req, res) => priv(res, 200,
- await ctx.designs.undo(req.params.id, owner(req), keyOf(req)))));
+ await ctx.designs.undo(req.params.id, owner(req), keyOf(req), viewer(req)))));
routes.post('/:id/redo', sameOrigin, safe(async (req, res) => priv(res, 200,
routes.post('/:id/redo', sameOrigin, safe(async (req, res) => priv(res, 200,
- await ctx.designs.redo(req.params.id, owner(req), keyOf(req)))));
+ await ctx.designs.redo(req.params.id, owner(req), keyOf(req), viewer(req)))));
return routes;
return routes;
}
}
@@ -126,11 +126,24 @@ function createPageRoutes(ctx) {
@@ -126,11 +126,24 @@ function createPageRoutes(ctx) {
res.set('Cache-Control', 'private, no-store');
res.set('Cache-Control', 'private, no-store');
res.set('Referrer-Policy', 'no-referrer');
res.set('Referrer-Policy', 'no-referrer');
try {
try {
- const data = await ctx.designs.read(req.params.id, subjectOf(req), req.query.key);
+ const data = await ctx.designs.read(req.params.id, subjectOf(req), req.query.key, req.viewer);
const themes = (await catalog.list()).themes;
const themes = (await catalog.list()).themes;
return page(res, 200, designView.canvas({ data, themes, editKey: req.query.key || '', viewer: subjectOf(req), publishing: { enabled: Boolean(ctx.publisher && ctx.publisher.enabled), notice: req.query.published ? 'Published.' : null, error: req.query.publish_error ? String(req.query.publish_error).slice(0, 300) : null } }), req.viewer,
return page(res, 200, designView.canvas({ data, themes, editKey: req.query.key || '', viewer: subjectOf(req), publishing: { enabled: Boolean(ctx.publisher && ctx.publisher.enabled), notice: req.query.published ? 'Published.' : null, error: req.query.publish_error ? String(req.query.publish_error).slice(0, 300) : null } }), req.viewer,
{ cacheControl: 'private, no-store' });
{ cacheControl: 'private, no-store' });
} catch (err) {
} catch (err) {
+ if (err && (err.status === 403 || err.status === 404) && ctx.sharing) {
+ const share = await ctx.sharing.shareOf(req.params.id).catch(() => null);
+ if (share && (share.audience === 'people' || share.audience === 'private')) {
+ res.set('Cache-Control', 'private, no-store');
+ res.set('Referrer-Policy', 'no-referrer');
+ const body = share.audience === 'people'
+ ? designView.gate({ id: req.params.id,
+ signedInAs: req.viewer && req.viewer.kind === 'user' && req.viewer.username ? String(req.viewer.username) : null,
+ signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl || '')}` })
+ : views.notFound();
+ return page(res, 404, body, req.viewer, { cacheControl: 'private, no-store' });
+ }
+ }
return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not read the design.');
return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not read the design.');
}
}
});
});
@@ -23,6 +23,15 @@ const TABLES = [
@@ -23,6 +23,15 @@ const TABLES = [
];
];
async function extraExport(db, subject) {
async function extraExport(db, subject) {
+ // The share.v1 rows the person owns or is on: the design itself is exported above; this is who it was
+ // shared with and who shared what with them (subjects as the ids they are).
+ const files = [];
+ const owned = await db.many('SELECT service, kind, resource_id, audience, lock, revision FROM shares WHERE owner_subject->>\'id\' = $1 ORDER BY resource_id DESC LIMIT 5000', [subject]);
+ if (owned.length) files.push({ name: 'shares.json', content: owned });
+ const memberships = await db.many(`SELECT s.service, s.kind, s.resource_id, s.audience, m.role, m.added_at, m.expires_at
+ FROM share_members m JOIN shares s ON s.service=m.service AND s.kind=m.kind AND s.resource_id=m.resource_id
+ WHERE m.subject->>\'id\' = $1 ORDER BY m.added_at DESC LIMIT 5000`, [subject]);
+ if (memberships.length) files.push({ name: 'shared_with_me.json', content: memberships });
const rows = await db.many(
const rows = await db.many(
`SELECT o.* FROM arena_outputs o
`SELECT o.* FROM arena_outputs o
JOIN arena_rounds r ON r.id = o."round"
JOIN arena_rounds r ON r.id = o."round"
@@ -30,7 +39,22 @@ async function extraExport(db, subject) {
@@ -30,7 +39,22 @@ async function extraExport(db, subject) {
ORDER BY o.created_at DESC LIMIT 5000`,
ORDER BY o.created_at DESC LIMIT 5000`,
[subject],
[subject],
);
);
- return rows.length ? [{ name: 'arena_outputs.json', content: rows }] : [];
+ if (rows.length) files.push({ name: 'arena_outputs.json', content: rows });
+ return files;
+}
+
+/**
+ * Deleting an account removes the share rows that named it: ownership of its designs (which the designs
+ * table above deletes), memberships in other people's, pending requests and transfers. share_log is the
+ * append-only audit trail and is kept (its rows carry ids, no titles or content).
+ */
+async function extraErase(t, subjects) {
+ const ids = [...subjects];
+ await t.exec('DELETE FROM share_members WHERE subject->>\'id\' = ANY($1::text[])', [ids]);
+ await t.exec('DELETE FROM share_access_requests WHERE subject->>\'id\' = ANY($1::text[])', [ids]);
+ await t.exec('DELETE FROM share_transfers WHERE from_subject->>\'id\' = ANY($1::text[]) OR to_subject->>\'id\' = ANY($1::text[])', [ids]);
+ await t.exec('DELETE FROM shares WHERE owner_subject->>\'id\' = ANY($1::text[])', [ids]);
+ // A member's slot is gone with their membership; the service's own projections follow the design deleted above.
}
}
function create({ db, log = console } = {}) {
function create({ db, log = console } = {}) {
@@ -39,9 +63,10 @@ function create({ db, log = console } = {}) {
@@ -39,9 +63,10 @@ function create({ db, log = console } = {}) {
service: 'design',
service: 'design',
tables: TABLES,
tables: TABLES,
extraExport,
extraExport,
+ extraErase,
note: 'Arena votes are exported without another voter\'s identity; outputs travel with the round that made them.',
note: 'Arena votes are exported without another voter\'s identity; outputs travel with the round that made them.',
log,
log,
});
});
}
}
-module.exports = { create, TABLES, TOPICS, extraExport };
+module.exports = { create, TABLES, TOPICS, extraExport, extraErase };
new file mode 100644
new file mode 100644
@@ -0,0 +1,178 @@
@@ -0,0 +1,178 @@
+'use strict';
+/**
+ * Sharing for designs (design 2026-11-06, VISIBILITY-EVERYWHERE step 5; openvibe-sdk/share).
+ *
+ * A design belongs to its signed-in maker and starts Only me. Its Share sheet can name people
+ * ("review this before I publish it"), or make it Everyone or Anyone with the link. The guest edit
+ * key (an unsigned-in maker's secret link) keeps working exactly as before; a guest design has no
+ * share row because there is no canonical subject to own it.
+ *
+ * The SDK owns the model: shares/share_members/share_transfers/share_access_requests/share_log and
+ * the delivery queue. This file supplies Network lookups, the events outbox and notification push.
+ */
+const { createShares } = require('openvibe-sdk/share');
+const { serviceAuth } = require('openvibe-contracts');
+const { createServiceOutbox } = require('openvibe-sdk/events');
+
+const SERVICE = 'design';
+const KIND = 'design';
+const AUDIENCES = ['public', 'link', 'people', 'private'];
+const SHARE_EVENTS = [
+ 'share.granted', 'share.revoked', 'share.transfer.requested',
+ 'share.transfer.accepted', 'share.transfer.declined',
+];
+const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
+
+/** Design's session viewer as the SDK wants it: a canonical subject object and the staff bit. */
+function shareViewer(viewer) {
+ if (!viewer || viewer.kind !== 'user' || !viewer.subject) return null;
+ const id = String(viewer.subject);
+ if (!/^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/.test(id)) return null;
+ return { subject: { type: id.startsWith('gst_') ? 'guest' : 'user', id }, staff: false };
+}
+
+function createSharing({ db, config, log = console, fetchImpl = globalThis.fetch, now = Date.now, networkJson: networkOverride = null }) {
+ const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');
+ const tokens = !networkOverride && config.oauth && config.oauth.clientSecret
+ ? serviceAuth.createTokenClient({
+ tokenUrl: `${base}/oauth/token`,
+ clientId: config.oauth.clientId || 'design',
+ 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 (networkOverride) return await networkOverride({ path, method, 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;
+ }
+
+ const outbox = createServiceOutbox({
+ db, source: SERVICE, eventsUrl: (config.events && config.events.url) || null,
+ networkInternalUrl: base, clientId: config.oauth && config.oauth.clientId,
+ clientSecret: config.oauth && config.oauth.clientSecret, log, now, eventTypes: SHARE_EVENTS,
+ });
+ outbox.start();
+
+ /** One notification, addressed by canonical subject; it never carries a prompt or a design's HTML. */
+ async function notify(n) {
+ const to = n && n.to;
+ if (!to || to.type !== 'user') return;
+ const words = {
+ 'share.added': ['A design was shared with you', 'Open it on OpenVibe.Design.'],
+ 'share.access_requested': ['Someone asked for access to your design', 'Review the request on OpenVibe.Design.'],
+ 'share.transfer.requested': ['Someone wants to give you a design', 'Accept or decline it on OpenVibe.Design.'],
+ 'share.transfer.answered': ['A design transfer was answered', 'See what happened on OpenVibe.Design.'],
+ }[n.kind] || ['Sharing on OpenVibe.Design', '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: SERVICE, url: n.ref && n.ref.id ? `${config.baseUrl}/d/${encodeURIComponent(n.ref.id)}` : config.baseUrl,
+ category: 'social',
+ },
+ });
+ } catch (err) {
+ log.warn('[Sharing] notification not sent:', err.message);
+ }
+ }
+
+ const shares = createShares({
+ db,
+ network: { json: networkJson },
+ events: {
+ async publish(envelope) {
+ if (!outbox.enabled) return;
+ await db.tx(async (t) => { await outbox.emitIn(t, envelope); });
+ await outbox.kick();
+ },
+ },
+ notify,
+ });
+
+ async function shareOf(id) {
+ return await db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, String(id)]);
+ }
+
+ /** Register a signed-in maker's design Only me, in the transaction that inserted it. */
+ async function register(id, owner) {
+ if (!owner || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner))) return null;
+ try {
+ return await shares.register(refOf(id), { owner_subject: { type: 'user', id: String(owner) }, audience: 'private' });
+ } catch (err) {
+ if (err.status === 409) return await shareOf(id);
+ throw err;
+ }
+ }
+
+ /** A legacy design, or one made before the sheet opened: register it Only me on first use (owner only). */
+ async function ensure(id) {
+ if (await shareOf(id)) return;
+ const design = await db.maybe('SELECT id, owner FROM designs WHERE id = $1', [String(id)]);
+ if (design && design.owner) await register(id, design.owner);
+ }
+
+ return {
+ shares,
+ SERVICE,
+ KIND,
+ AUDIENCES,
+ refOf,
+ shareViewer,
+ outbox,
+ shareOf,
+ register,
+ ensure,
+ canView: async (viewer, id) => await shares.canView(shareViewer(viewer), refOf(id)),
+ canEdit: async (viewer, id) => await shares.canEdit(shareViewer(viewer), refOf(id)),
+ getWithRevision: async (viewer, id) => { await ensure(id); return await shares.getWithRevision(shareViewer(viewer), refOf(id)); },
+ update: async (viewer, id, body, opts) => { await ensure(id); return await shares.update(shareViewer(viewer), refOf(id), body, opts); },
+ requestAccess: async (viewer, id) => { await ensure(id); return await shares.requestAccess(shareViewer(viewer), refOf(id)); },
+ dismiss: async (viewer, id, requestId) => await shares.dismiss(shareViewer(viewer), refOf(id), requestId),
+ transfer: async (viewer, id, input) => await shares.transfer(shareViewer(viewer), refOf(id), input),
+ accept: async (viewer, id, transferId) => await shares.accept(shareViewer(viewer), refOf(id), transferId),
+ decline: async (viewer, id, transferId) => await shares.decline(shareViewer(viewer), refOf(id), transferId),
+ cancel: async (viewer, id, transferId) => await shares.cancel(shareViewer(viewer), refOf(id), transferId),
+ lookup: async (viewer, name) => await shares.lookup(shareViewer(viewer), name),
+ sharedWithMe: async (viewer, opts) => await shares.sharedWithMe(shareViewer(viewer), { service: SERVICE, ...(opts || {}) }),
+ 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 design's prompt and page to its owner. */
+function describeRef(db, config) {
+ return async (ref) => {
+ if (ref.service !== SERVICE || ref.kind !== KIND) return {};
+ const d = await db.maybe('SELECT id, prompt, owner FROM designs WHERE id = $1', [String(ref.id)]);
+ if (!d) return {};
+ return {
+ title: String(d.prompt || 'A design').slice(0, 200),
+ link: `${String(config.baseUrl || '').replace(/\/+$/, '')}/d/${encodeURIComponent(d.id)}`,
+ roles: ['viewer'],
+ audiences: AUDIENCES,
+ transferable: !!d.owner,
+ };
+ };
+}
+
+module.exports = { createSharing, describeRef, shareViewer, refOf, AUDIENCES, SERVICE, KIND };
@@ -1,11 +1,26 @@
@@ -1,11 +1,26 @@
'use strict';
'use strict';
const { layout, esc } = require('../render/layout');
const { layout, esc } = require('../render/layout');
+const ovServe = require('openvibe-shared/serve');
const { attrEscape } = require('../arena/sanitize');
const { attrEscape } = require('../arena/sanitize');
const { themeCss } = require('../designs');
const { themeCss } = require('../designs');
const examples = ['Landing page for a coffee roaster', 'Dashboard for a fitness app', 'Pricing section', 'Mobile onboarding'];
const examples = ['Landing page for a coffee roaster', 'Dashboard for a fitness app', 'Pricing section', 'Mobile onboarding'];
+/**
+ * The page somebody who is not on a Specific-people design's list sees. It never shows the prompt,
+ * a variant, a version or a thumbnail; OVShare.gate offers "Ask for access" to a signed-in person and
+ * "Sign in" to everybody else.
+ */
+function gate({ id, signedInAs = null, signInUrl = '/auth/login' }) {
+ const body = `<section class="dv-gate"><h1>A design on OpenVibe.Design</h1>
+<p>This is shared with specific people.</p>
+<div data-ov-share-gate data-kind="design" data-id="${esc(id)}"${signedInAs ? ` data-signed-in-as="${esc(signedInAs)}"` : ''} data-sign-in-url="${esc(signInUrl)}">This design is shared with specific people on OpenVibe.Design.</div>
+</section>
+<script src="${ovServe.url('ov-share.js')}" defer></script>`;
+ return layout({ title: 'A design on OpenVibe.Design', path: '/d', robots: 'noindex, nofollow', body, scripts: [] });
+}
+
function create({ recent = [] } = {}) {
function create({ recent = [] } = {}) {
const body = `<section class="dv-create"><h1>What will you design?</h1>
const body = `<section class="dv-create"><h1>What will you design?</h1>
<form method="post" action="/create"><label class="dv-visually-hidden" for="dv-prompt">Describe your design</label>
<form method="post" action="/create"><label class="dv-visually-hidden" for="dv-prompt">Describe your design</label>
@@ -138,7 +153,9 @@ ${versionDetail(v)}</span></li>`).join('');
@@ -138,7 +153,9 @@ ${versionDetail(v)}</span></li>`).join('');
const keyInput = `<input type="hidden" name="key" value="${esc(editKey)}">`;
const keyInput = `<input type="hidden" name="key" value="${esc(editKey)}">`;
const body = `<section class="dv-canvas" data-id="${esc(design.id)}" data-key="${esc(editKey)}" data-picked="${pick ? pickedIndex : ''}" data-variant-id="${esc(pickedVariant && pickedVariant.id || '')}">
const body = `<section class="dv-canvas" data-id="${esc(design.id)}" data-key="${esc(editKey)}" data-picked="${pick ? pickedIndex : ''}" data-variant-id="${esc(pickedVariant && pickedVariant.id || '')}">
<header class="dv-heading"><a href="/create">← New design</a><h1>${esc(design.prompt)}</h1>
<header class="dv-heading"><a href="/create">← New design</a><h1>${esc(design.prompt)}</h1>
-<p id="dv-status" role="status">${esc(statusText)}</p></header>
+<p id="dv-status" role="status">${esc(statusText)}</p>
+<p class="dv-share"><button type="button" data-ov-share-sheet data-kind="design" data-id="${esc(design.id)}"
+ data-title="${esc(design.prompt)}" data-url="${esc(`/d/${encodeURIComponent(design.id)}`)}">Share</button></p></header>
<form class="dv-chat" id="dv-chat-form" method="post" action="/d/${encodeURIComponent(design.id)}/edit">
<form class="dv-chat" id="dv-chat-form" method="post" action="/d/${encodeURIComponent(design.id)}/edit">
${keyInput}
${keyInput}
<label for="dv-instruction">Edit with AI</label>
<label for="dv-instruction">Edit with AI</label>
@@ -177,4 +194,4 @@ ${versions.length ? `<ol class="dv-vlist" id="dv-vlist">${versionRows}</ol>` : '
@@ -177,4 +194,4 @@ ${versions.length ? `<ol class="dv-vlist" id="dv-vlist">${versionRows}</ol>` : '
body, scripts: ['js/design.js'] });
body, scripts: ['js/design.js'] });
}
}
-module.exports = { create, canvas, frame, overlayScript };
+module.exports = { create, canvas, gate, frame, overlayScript };
@@ -17,6 +17,12 @@ const { testDb } = require('./helpers/db');
@@ -17,6 +17,12 @@ const { testDb } = require('./helpers/db');
exempt: {
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion design applied',
account_data_events: 'the ledger of requests itself: which export and deletion design applied',
design_index_revisions: 'the Search index sequencer: owner is the service (design), the rows are icon and theme pages, never a person',
design_index_revisions: 'the Search index sequencer: owner is the service (design), the rows are icon and theme pages, never a person',
+ share_members: 'who a design is shared with: exported as shared_with_me.json, and erased by extraErase',
+ share_access_requests: 'pending access requests: transient, erased by extraErase',
+ share_transfers: 'pending ownership transfers: transient, erased by extraErase',
+ share_deliveries: 'the delivery queue for share events/notifications; rows carry ids and are delivered once',
+ share_log: 'the append-only sharing audit trail; rows carry subject ids, never titles or content',
+ shares: 'the share.v1 row of a design: exported as shares.json (owned) or shared_with_me.json, erased by extraErase',
},
},
});
});
@@ -170,6 +170,26 @@ async function startNetwork({ themes = DEFAULT_THEMES } = {}) {
@@ -170,6 +170,26 @@ async function startNetwork({ themes = DEFAULT_THEMES } = {}) {
if (!t) return json(404, { error: 'Theme not found' });
if (!t) return json(404, { error: 'Theme not found' });
return json(200, { theme: t });
return json(200, { theme: t });
}
}
+ // Sharing (openvibe-sdk/share): exact @name lookup, batch projections and the notification push.
+ if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {
+ const name = String(url.searchParams.get('username') || '').toLowerCase()
+ const u = [...st.users.values()].find((x) => x.username.toLowerCase() === name)
+ if (!u) return json(404, { error: 'no such person' })
+ return json(200, { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false, deleted: false })
+ }
+ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {
+ const results = {}
+ for (const id of body.subject_ids || []) {
+ const u = [...st.users.values()].find((x) => x.subject === id)
+ if (u) results[id] = { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false, deleted: false }
+ }
+ return json(200, { results })
+ }
+ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') {
+ st.notifications = st.notifications || []
+ st.notifications.push(body)
+ return json(202, { queued: true })
+ }
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
});
});
issuer = srv.url;
issuer = srv.url;
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+/**
+ * The Specific-people gate is markup, not an inline script (openvibe-shared 4.123.0): Headless Chrome
+ * loads the real design gate, runs ov-share.js and checks that it draws — "Sign in" signed out,
+ * "Ask for access" for a signed-in stranger — with no console error and no CSP refusal.
+ *
+ * Skips (exit 0) where Playwright or Chrome is not installed, as in CI; PLAYWRIGHT_MODULE overrides the path.
+ */
+const assert = require('assert');
+const http = require('http');
+const fs = require('fs');
+
+function loadPlaywright() {
+ const candidates = [process.env.PLAYWRIGHT_MODULE, 'playwright',
+ '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'].filter(Boolean);
+ for (const name of candidates) { try { return require(name); } catch { /* next */ } }
+ return null;
+}
+const CHROME = process.env.CHROME_PATH || '/usr/bin/google-chrome';
+
+(async () => {
+ if (!fs.existsSync(CHROME)) return console.log('◌ share gate browser: skipped (no Chrome)');
+ const playwright = loadPlaywright();
+ if (!playwright) return console.log('◌ share gate browser: skipped (no Playwright)');
+
+ process.env.BASE_URL = 'https://design.test';
+ process.env.OV_NETWORK_URL = 'https://openvibe.network';
+ const express = require('express');
+ const designView = require('../server/views/design');
+
+ const app = express();
+ app.use('/shared', require('openvibe-shared/serve').handler());
+ const gate = (signedInAs) => designView.gate({ id: 'dsn_01J0000000000000000000000A', signedInAs,
+ signInUrl: '/auth/login?next=%2Fd%2Fdsn_01J0000000000000000000000A' });
+ app.get('/gate-out', (_req, res) => res.status(404).type('html').send(gate(null)));
+ app.get('/gate-in', (_req, res) => res.status(404).type('html').send(gate('sam')));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+
+ const browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
+ try {
+ const check = async (path, words) => {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
+ page.on('pageerror', (e) => errors.push(String(e && e.message || e)));
+ await page.goto(base + path, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('.ov-share-gate', { timeout: 15000 });
+ const text = await page.locator('.ov-share-gate').innerText();
+ for (const word of words) assert.ok(text.includes(word), `${path}: ${word} in ${JSON.stringify(text)}`);
+ const bad = errors.filter((e) => /content security policy|refused to (execute|load|apply).*inline|violates the following/i.test(e));
+ assert.deepStrictEqual(bad, [], `${path}: CSP/console errors: ${bad.join(' | ')}`);
+ await page.close();
+ };
+ await check('/gate-out', ['This is shared with specific people', 'Sign in']);
+ await check('/gate-in', ['This is shared with specific people', 'Ask for access', '@sam']);
+ console.log('✅ share gate browser: both gates draw with no CSP refusal');
+ } finally {
+ await browser.close();
+ await new Promise((r) => server.close(r));
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+/**
+ * Sharing a design with specific people (design 2026-11-06, VISIBILITY-EVERYWHERE step 5).
+ *
+ * A signed-in maker's design starts Only me. The Share sheet's four audiences are enforced by canView
+ * on the page and the API; the named person opens it, a stranger gets the gate (never the prompt), and
+ * a guest design keeps its secret edit key exactly as before. Leaving Everyone takes it out of the
+ * public answer at once.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const carol = t.network.addUser('carol');
+ const maticus = t.network.addUser('maticus');
+ const sam = t.network.addUser('sam');
+ const make = async (prompt = 'A landing page for a robot café') => {
+ const r = await t.get('/api/v1/designs', { as: carol, origin: 'https://openvibe.design', json: { prompt, kind: 'page' } });
+ assert.strictEqual(r.status, 201, r.text);
+ return r.json().design;
+ };
+ const share = (id, opts = {}) => t.get(`/api/v1/design/${id}/share`, { headers: { 'sec-fetch-site': 'same-origin' }, ...opts });
+
+ await check('a signed-in maker’s design is registered Only me, and a guest design has no share row', async () => {
+ const d = await make();
+ const row = await t.ctx.s.db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['design', 'design', d.id]);
+ assert.ok(row, 'the signed-in design has a share row');
+ assert.strictEqual(row.audience, 'private');
+ assert.deepStrictEqual(row.owner_subject, { type: 'user', id: carol.subject });
+ const guest = await t.get('/api/v1/designs', { origin: 'https://openvibe.design', json: { prompt: 'A guest design' } });
+ assert.strictEqual(guest.status, 201, guest.text);
+ const none = await t.ctx.s.db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['design', 'design', guest.json().design.id]);
+ assert.strictEqual(none, null, 'a guest design is reached by its key, not a share row');
+ });
+
+ await check('Specific people: the named person opens it, a stranger gets the gate and never the prompt', async () => {
+ const d = await make('SECRET-CANARY robot café');
+ const put = await share(d.id, { method: 'PUT', as: carol, json: { audience: 'people', members: [{ name: '@maticus', role: 'viewer' }] } });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.strictEqual(put.json().audience, 'people');
+ const row = await t.ctx.s.db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['design', 'design', d.id]);
+ assert.strictEqual(row.audience, 'people');
+
+ const member = await t.get(`/d/${d.id}`, { as: maticus });
+ assert.strictEqual(member.status, 200);
+ assert.ok(member.text.includes('SECRET-CANARY robot café'), 'the person on the list reads the design');
+ assert.match(member.headers.get('cache-control') || '', /private, no-store/);
+ const memberApi = await t.get(`/api/v1/designs/${d.id}`, { as: maticus });
+ assert.strictEqual(memberApi.status, 200);
+ assert.match(memberApi.headers.get('cache-control') || '', /private, no-store/);
+
+ const stranger = await t.get(`/d/${d.id}`, { as: 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(`data-id="${d.id}"`) && !stranger.text.includes('OVShare.gate('), 'markup gate, no inline script');
+ assert.ok(!stranger.text.includes('SECRET-CANARY'), 'never the prompt');
+ assert.strictEqual((await t.get(`/api/v1/designs/${d.id}`, { as: sam })).status, 403, 'the API refuses it too');
+ assert.strictEqual((await t.get(`/api/v1/designs/${d.id}`)).status, 403);
+ assert.strictEqual((await share(d.id, { as: sam })).status, 404, 'the sheet is the owner’s');
+ });
+
+ await check('Everyone and the link, then Only me: the page follows the audience at once', async () => {
+ const d = await make('Public by choice');
+ assert.strictEqual((await share(d.id, { as: carol, method: 'PUT', json: { audience: 'public' } })).status, 200);
+ assert.strictEqual((await t.get(`/d/${d.id}`)).status, 200, 'Everyone');
+ assert.strictEqual((await share(d.id, { as: carol, method: 'PUT', json: { audience: 'link' } })).status, 200);
+ assert.strictEqual((await t.get(`/d/${d.id}`)).status, 200, 'the link is the key');
+ assert.strictEqual((await share(d.id, { as: carol, method: 'PUT', json: { audience: 'private' } })).status, 200);
+ assert.strictEqual((await t.get(`/d/${d.id}`)).status, 404, 'Only me');
+ assert.strictEqual((await t.get(`/d/${d.id}`, { as: carol })).status, 200, 'the owner still opens it');
+ });
+
+ await check('the sheet lists the people for the owner, and the agent API (subjects) agrees with names', async () => {
+ const d = await make('Reviewed by two');
+ const put = await share(d.id, { as: carol, method: 'PUT', json: { audience: 'people',
+ members: [{ subject: { type: 'user', id: sam.subject }, role: 'viewer' }, { name: '@maticus', role: 'editor' }] } });
+ assert.strictEqual(put.status, 200, put.text);
+ const mine = await share(d.id, { as: carol });
+ assert.strictEqual(mine.status, 200);
+ const body = mine.json();
+ assert.strictEqual(body.you.role, 'owner');
+ assert.deepStrictEqual(body.members.map((m) => m.username).sort(), ['maticus', 'sam']);
+ assert.strictEqual(body.members.find((m) => m.username === 'maticus').role, 'editor');
+ assert.strictEqual((await t.get(`/d/${d.id}`, { as: sam })).status, 200);
+ // Can view and Can edit are different doors: a viewer cannot change the working frame, the editor can.
+ const origin = 'https://openvibe.design';
+ assert.strictEqual((await t.get(`/api/v1/designs/${d.id}/undo`, { as: sam, method: 'POST', origin, json: {} })).status, 403, 'a viewer cannot edit');
+ assert.strictEqual((await t.get(`/api/v1/designs/${d.id}/undo`, { as: maticus, method: 'POST', origin, json: {} })).status, 409, 'an editor passes the door (no working frame yet)');
+ });
+
+ await done();
+})().catch((err) => { console.error(err); process.exit(1); });